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/dirty-carrots-invent.md b/.changeset/dirty-carrots-invent.md new file mode 100644 index 0000000000..ceb4e4d2bb --- /dev/null +++ b/.changeset/dirty-carrots-invent.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Add className to the SidebarItem 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/fuzzy-pumpkins-tie.md b/.changeset/fuzzy-pumpkins-tie.md new file mode 100644 index 0000000000..1e87972ce1 --- /dev/null +++ b/.changeset/fuzzy-pumpkins-tie.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +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. 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/little-pets-cross.md b/.changeset/little-pets-cross.md new file mode 100644 index 0000000000..0f4e2605cd --- /dev/null +++ b/.changeset/little-pets-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Replace `yup` with `ajv`, for validation of catalog entities. 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/metal-pans-leave.md b/.changeset/metal-pans-leave.md new file mode 100644 index 0000000000..16d5478e43 --- /dev/null +++ b/.changeset/metal-pans-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +Bump `config-loader` to `ajv` 7, to enable v7 feature use elsewhere diff --git a/.changeset/moody-buckets-visit.md b/.changeset/moody-buckets-visit.md new file mode 100644 index 0000000000..07a960620b --- /dev/null +++ b/.changeset/moody-buckets-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Properly forward errors that occur when looking up GitLab project IDs. diff --git a/.changeset/six-ravens-heal.md b/.changeset/six-ravens-heal.md new file mode 100644 index 0000000000..960c482be9 --- /dev/null +++ b/.changeset/six-ravens-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Introduce json schema variants of the `yup` validation schemas 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/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..2be043cd78 --- /dev/null +++ b/.tugboat/config.yml @@ -0,0 +1,15 @@ +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 workspace example-app build + update: + - yarn install 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 02afb8ac41..c150ef5902 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -99,6 +99,13 @@ kubernetes: - 'config' clusters: [] +kafka: + clientId: backstage + clusters: + - name: cluster + brokers: + - localhost:9092 + integrations: github: - host: github.com @@ -372,8 +379,3 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' - -kafka: - clientId: backstage - brokers: - - localhost:9092 diff --git a/docs/architecture-decisions/adr000-template.md b/docs/architecture-decisions/adr000-template.md index 538afccf87..5e6edd8dba 100644 --- a/docs/architecture-decisions/adr000-template.md +++ b/docs/architecture-decisions/adr000-template.md @@ -4,8 +4,6 @@ title: ADR000: [TITLE] description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION] --- -# ADR000: [title] - ## Context diff --git a/docs/architecture-decisions/adr010-luxon-date-library.md b/docs/architecture-decisions/adr010-luxon-date-library.md index b4c1ddedba..43f18f0f69 100644 --- a/docs/architecture-decisions/adr010-luxon-date-library.md +++ b/docs/architecture-decisions/adr010-luxon-date-library.md @@ -4,8 +4,6 @@ title: ADR010: Use the Luxon Date Library description: Architecture Decision Record (ADR) for Luxon Date Library --- -# ADR010: Use the Luxon Date Library - ## Context Date formatting (e.g. `a day ago`) and calculations are common within Backstage. 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/assets/features/kubernetes/backstage-k8s-2-deployments.png b/docs/assets/features/kubernetes/backstage-k8s-2-deployments.png new file mode 100644 index 0000000000..4a9f2b06fa Binary files /dev/null and b/docs/assets/features/kubernetes/backstage-k8s-2-deployments.png differ diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md new file mode 100644 index 0000000000..3e138a7a0b --- /dev/null +++ b/docs/features/kubernetes/configuration.md @@ -0,0 +1,130 @@ +--- +id: configuration +title: Configuring Kubernetes integration +sidebar_label: Configuration +# prettier-ignore +description: Configuring the Kubernetes integration for Backstage expose your entity's objects +--- + +Configuring the Backstage Kubernetes integration involves two steps: + +1. Enabling the backend to collect objects from your Kubernetes cluster(s). +2. Surfacing your Kubernetes objects in catalog entities + +## Configuring Kubernetes Clusters + +The following is a full example entry in `app-config.yaml`: + +```yaml +kubernetes: + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + authProvider: 'serviceAccount' + serviceAccountToken: + $env: K8S_MINIKUBE_TOKEN + - url: http://127.0.0.2:9999 + name: gke-cluster-1 + authProvider: 'google' +``` + +### `serviceLocatorMethod` + +This configures how to determine which clusters a component is running in. + +Currently, the only valid value is: + +- `multiTenant` - This configuration assumes that all components run on all the + provided clusters. + +### `clusterLocatorMethods` + +This is an array used to determine where to retrieve cluster configuration from. + +Currently, the only valid cluster locator method is: + +- `config` - This cluster locator method will read cluster information from your + app-config (see below). + +### `clusters` + +Used by the `config` cluster locator method to construct Kubernetes clients. + +### `clusters.\*.url` + +The base URL to the Kubernetes control plane. Can be found by using the +"Kubernetes master" result from running the `kubectl cluster-info` command. + +### `clusters.\*.name` + +A name to represent this cluster, this must be unique within the `clusters` +array. Users will see this value in the Service Catalog Kubernetes plugin. + +### `clusters.\*.authProvider` + +This determines how the Kubernetes client authenticates with the Kubernetes +cluster. Valid values are: + +| Value | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | +| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | + +### `clusters.\*.serviceAccount` (optional) + +The service account token to be used when using the `serviceAccount` auth +provider. + +### Role Based Access Control + +The current RBAC permissions required are read-only cluster wide, for the +following objects: + +- pods +- services +- configmaps +- deployments +- replicasets +- horizontalpodautoscalers +- ingresses + +## Surfacing your Kubernetes components as part of an entity + +There are two ways to surface your Kubernetes components as part of an entity. +The label selector takes precedence over the annotation/service id. + +### Common `backstage.io/kubernetes-id` label + +#### Adding the entity annotation + +In order for Backstage to detect that an entity has Kubernetes components, the +following annotation should be added to the entity's `catalog-info.yaml`: + +```yaml +annotations: + 'backstage.io/kubernetes-id': dice-roller +``` + +#### Labeling Kubernetes components + +In order for Kubernetes components to show up in the service catalog as a part +of an entity, Kubernetes components themselves can have the following label: + +```yaml +'backstage.io/kubernetes-id': +``` + +### Label selector query annotation + +You can write your own custom label selector query that Backstage will use to +lookup the objects (similar to `kubectl --selector="your query here"`). Review +the +[labels and selectors Kubernetes documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) +for more info. + +```yaml +'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' +``` diff --git a/docs/features/kubernetes/index.md b/docs/features/kubernetes/index.md index c468fdb116..26794eca10 100644 --- a/docs/features/kubernetes/index.md +++ b/docs/features/kubernetes/index.md @@ -5,123 +5,26 @@ sidebar_label: Overview description: Monitoring Kubernetes based services with the service catalog --- -Kubernetes in Backstage is a way to monitor your service's current status when -it is deployed on Kubernetes. +Kubernetes in Backstage is a tool that's designed around the needs of service +owners, not cluster admins. Now developers can easily check the health of their +services no matter how or where those services are deployed — whether it's on a +local host for testing or in production on dozens of clusters around the world. -## Configuration +It will elevate the visibility of errors where identified, and provide drill +down about the deployments, pods, and other objects for a service. -Example: +![Kubernetes plugin screenshot](../../assets/features/kubernetes/backstage-k8s-2-deployments.png) -```yaml -kubernetes: - serviceLocatorMethod: 'multiTenant' - clusterLocatorMethods: - - 'config' - clusters: - - url: http://127.0.0.1:9999 - name: minikube - authProvider: 'serviceAccount' - serviceAccountToken: - $env: K8S_MINIKUBE_TOKEN - - url: http://127.0.0.2:9999 - name: gke-cluster-1 - authProvider: 'google' -``` +The feature is made up of two plugins: +[`@backstage/plugin-kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) +and +[`@backstage/plugin-kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend). -### serviceLocatorMethod +The frontend plugin exposes information to the end user in a digestible way, +while the backend wraps the mechanics to connect to Kubernetes clusters to +collect the relevant information. -This configures how to determine which clusters a component is running in. +## Let's use it! -Currently, the only valid value is: - -- `multiTenant` - This configuration assumes that all components run on all the - provided clusters. - -### clusterLocatorMethods - -This is an array used to determine where to retrieve cluster configuration from. - -Currently, the only valid cluster locator method is: - -- `config` - This cluster locator method will read cluster information from your - app-config (see below). - -### clusters - -Used by the `config` cluster locator method to construct Kubernetes clients. - -### clusters.\*.url - -The base URL to the Kubernetes control plane. Can be found by using the -"Kubernetes master" result from running the `kubectl cluster-info` command. - -### clusters.\*.name - -A name to represent this cluster, this must be unique within the `clusters` -array. Users will see this value in the Service Catalog Kubernetes plugin. - -### clusters.\*.authProvider - -This determines how the Kubernetes client authenticates with the Kubernetes -cluster. Valid values are: - -| Value | Description | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | -| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | - -### clusters.\*.serviceAccount (optional) - -The service account token to be used when using the `serviceAccount` auth -provider. - -## Role Based Access Control - -The current RBAC permissions required are read-only cluster wide, for the -following objects: - -- pods -- services -- configmaps -- deployments -- replicasets -- horizontalpodautoscalers -- ingresses - -## Surfacing your Kubernetes components as part of an entity - -There are two ways to surface your Kubernetes components as part of an entity. -The label selector takes precedence over the annotation/service id. - -### Common `backstage.io/kubernetes-id` label - -#### Adding the entity annotation - -In order for Backstage to detect that an entity has Kubernetes components, the -following annotation should be added to the entity's `catalog-info.yaml`: - -```yaml -annotations: - 'backstage.io/kubernetes-id': dice-roller -``` - -#### Labeling Kubernetes components - -In order for Kubernetes components to show up in the service catalog as a part -of an entity, Kubernetes components themselves can have the following label: - -```yaml -'backstage.io/kubernetes-id': -``` - -### Label selector query annotation - -You can write your own custom label selector query that Backstage will use to -lookup the objects (similar to `kubectl --selector="your query here"`). Review -the -[labels and selectors Kubernetes documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) -for more info. - -```yaml -'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' -``` +To get started, first you must [install the Kubernetes plugins](installation.md) +and then [configure them](configuration.md). diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md new file mode 100644 index 0000000000..9e531de655 --- /dev/null +++ b/docs/features/kubernetes/installation.md @@ -0,0 +1,119 @@ +--- +id: installation +title: Installation +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). + +## Adding the Kubernetes frontend plugin + +The first step is to add the frontend Kubernetes plugin to your Backstage +application. Navigate to your new Backstage application directory. And then to +your `packages/app` directory, and install the `@backstage/plugin-kubernetes` +package. + +```bash +cd my-backstage-app/ +cd packages/app +yarn add @backstage/plugin-kubernetes +``` + +Once the package has been installed, you need to import the plugin in your app. +Add the following to `packages/app/src/plugins.ts`: + +`plugins.ts`: + +```typescript +export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; +``` + +Now, add the "Kubernetes" tab to the catalog entity page. In +`packages/app/src/components/catalog/EntityPage.tsx`, you'll add a router to get +to the tab, and add the tab itself. + +`EntityPage.tsx`: + +```tsx +import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; + +// ... + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + // ... + } + /> + // ... + +); +``` + +That's it! But now, we need the Kubernetes Backend plugin for the frontend to +work. + +## Adding Kubernetes Backend plugin + +Navigate to `packages/backend` of your Backstage app, and install the +`@backstage/plugin-kubernetes-backend` package. + +```bash +cd my-backstage-app/ +cd packages/backend +yarn add @backstage/plugin-kubernetes-backend +``` + +Create a file called `kubernetes.ts` inside `packages/backend/src/plugins/` and +add the following + +`kubernetes.ts`: + +```typescript +import { createRouter } from '@backstage/plugin-kubernetes-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} +``` + +And import the plugin to `packages/backend/src/index.ts`. There are three lines +of code you'll need to add, and they should be added near similar code in your +existing Backstage backend. + +`index.ts`: + +```typescript +import kubernetes from './plugins/kubernetes'; + +// ... + +const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + +// ... + +apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); +``` + +That's it! The Kubernetes frontend and backend have now been added to your +Backstage app. + +## Running Backstage locally + +Start the frontend and the backend app by +[running backstage locally](../../getting-started/running-backstage-locally.md). + +## Configuration + +After installing the plugins in the code, you'll need to then +[configure them](configuration.md). 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/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/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/microsite/sidebars.json b/microsite/sidebars.json index c2ccea8740..99bd430b56 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -39,7 +39,11 @@ { "type": "subcategory", "label": "Kubernetes", - "ids": ["features/kubernetes/overview"] + "ids": [ + "features/kubernetes/overview", + "features/kubernetes/installation", + "features/kubernetes/configuration" + ] }, { "type": "subcategory", @@ -184,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/mkdocs.yml b/mkdocs.yml index a2cbf11b4c..6236d1c1a0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -119,6 +119,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/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index d6157f560f..d39f705546 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,26 @@ # example-app +## 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..36fe7651e6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,18 +1,18 @@ { "name": "example-app", - "version": "0.2.12", + "version": "0.2.13", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.7.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@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-catalog": "^0.2.14", + "@backstage/plugin-catalog-import": "^0.3.6", "@backstage/plugin-circleci": "^0.2.6", "@backstage/plugin-cloudbuild": "^0.2.7", - "@backstage/plugin-cost-insights": "^0.5.7", + "@backstage/plugin-cost-insights": "^0.6.0", "@backstage/plugin-explore": "^0.2.3", "@backstage/plugin-gcp-projects": "^0.2.3", "@backstage/plugin-github-actions": "^0.3.0", @@ -20,14 +20,14 @@ "@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-kafka": "^0.2.0", + "@backstage/plugin-kubernetes": "^0.3.7", "@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-scaffolder": "^0.4.1", "@backstage/plugin-sentry": "^0.3.3", "@backstage/plugin-search": "^0.2.6", "@backstage/plugin-tech-radar": "^0.3.3", diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 84265c9897..3cb57f9056 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/backend-common +## 0.5.1 + +### Patch Changes + +- 26a3a6cf0: Honor the branch ref in the url when cloning. + + This fixes a bug in the scaffolder prepare stage where a non-default branch + was specified in the scaffolder URL but the default branch was cloned. + For example, even though the `other` branch is specified in this example, the + `master` branch was actually cloned: + + ```yaml + catalog: + locations: + - type: url + target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + ``` + + This also fixes a 404 in the prepare stage for GitLab URLs. + +- 664dd08c9: URL Reader's readTree: Fix bug with github.com URLs. +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/integration@0.3.1 + - @backstage/config-loader@0.5.0 + ## 0.5.0 ### Minor Changes diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index c698351615..78077c2ce7 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.5.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -31,8 +31,8 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.1", - "@backstage/integration": "^0.3.0", + "@backstage/config-loader": "^0.5.0", + "@backstage/integration": "^0.3.1", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "archiver": "^5.0.2", @@ -43,7 +43,7 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", "knex": "^0.21.6", @@ -66,7 +66,7 @@ } }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", 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..5571a07898 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( 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..975be85ccc 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', { 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..90acfd1ab9 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', { diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 654f4f9a85..22316f6895 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -140,33 +140,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..e8ebd1dd8d --- /dev/null +++ b/packages/backend-common/src/reading/integration.test.ts @@ -0,0 +1,155 @@ +/* + * 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: `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`, + }, + ], + gitlab: [ + { + host: 'gitlab.com', + token: 'tveGtSHDBJM9ZRHZNRfm', + }, + ], + bitbucket: [ + { + host: 'bitbucket.org', + username: 'backstage-verification', + appPassword: 'H79MAAhtbZwCafkVTrrQ', + }, + ], + azure: [ + { + host: 'dev.azure.com', + // lasts until 2022-01-27 + token: 'bhs5cbukiuxrkc3ftuyt5h3eqewtkj37lmf3jx5aoajivq3f5jmq', + }, + ], + }, + }), +}); + +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', () => { + 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 05dd140b16..24bfa3f1a8 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts @@ -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(); }); @@ -138,7 +149,9 @@ export class TarArchiveResponse implements ReadTreeResponse { options?.targetDir ?? (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 15bc558132..323f7b4778 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -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(); } @@ -139,7 +152,9 @@ 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 entryPath = this.getInnerPath( + this.stripTopDirectory(entry.path), + ); const dirname = platformPath.dirname(entryPath); if (dirname) { await fs.mkdirp(platformPath.join(dir, dirname)); 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..215e4e7296 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,27 @@ # example-backend +## 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..aa00407952 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.13", "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/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@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.5", + "@backstage/plugin-auth-backend": "^0.2.12", + "@backstage/plugin-catalog-backend": "^0.5.5", "@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-scaffolder-backend": "^0.5.2", "@backstage/plugin-techdocs-backend": "^0.5.4", "@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.13", "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.5.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..c1cbd6157f 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -34,7 +34,7 @@ "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-model/examples/acme/backstage-group.yaml b/packages/catalog-model/examples/acme/backstage-group.yaml index 3aecfa0023..f83587532c 100644 --- a/packages/catalog-model/examples/acme/backstage-group.yaml +++ b/packages/catalog-model/examples/acme/backstage-group.yaml @@ -8,6 +8,6 @@ spec: profile: displayName: Backstage email: backstage@example.com - picture: https://example.com/groups/backstage.jpeg + picture: https://avatars.dicebear.com/api/identicon/backstage@example.com.svg?background=%23fff&margin=25 parent: infrastructure children: [team-a, team-b] diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml index 2b44183bc5..9a0d690b57 100644 --- a/packages/catalog-model/examples/acme/org.yaml +++ b/packages/catalog-model/examples/acme/org.yaml @@ -3,12 +3,17 @@ 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: displayName: ACME Corp email: info@example.com - picture: https://example.com/logo.jpeg + picture: https://avatars.dicebear.com/api/identicon/info@example.com.svg?background=%23fff&margin=25 children: [infrastructure] --- apiVersion: backstage.io/v1alpha1 diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml index ed736dce50..e343209d5f 100644 --- a/packages/catalog-model/examples/acme/team-a-group.yaml +++ b/packages/catalog-model/examples/acme/team-a-group.yaml @@ -8,7 +8,7 @@ spec: profile: # Intentional no displayName for testing email: team-a@example.com - picture: https://example.com/groups/team-a.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25 parent: backstage children: [] --- @@ -20,7 +20,7 @@ spec: profile: # Intentional no displayName for testing email: breanna-davison@example.com - picture: https://example.com/staff/breanna.jpeg + picture: https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg?background=%23fff memberOf: [team-a] --- apiVersion: backstage.io/v1alpha1 @@ -31,7 +31,7 @@ spec: profile: displayName: Janelle Dawe email: janelle-dawe@example.com - picture: https://example.com/staff/janelle.jpeg + picture: https://avatars.dicebear.com/api/avataaars/janelle-dawe@example.com.svg?background=%23fff memberOf: [team-a] --- apiVersion: backstage.io/v1alpha1 @@ -42,7 +42,7 @@ spec: profile: displayName: Nigel Manning email: nigel-manning@example.com - picture: https://example.com/staff/nigel.jpeg + picture: https://avatars.dicebear.com/api/avataaars/nigel-manning@example.com.svg?background=%23fff memberOf: [team-a] --- # This user is added as an example, to make it more easy for the "Guest" @@ -56,5 +56,5 @@ spec: profile: displayName: Guest User email: guest@example.com - picture: https://example.com/staff/the-ceos-dog.jpeg + picture: https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff memberOf: [team-a] diff --git a/packages/catalog-model/examples/acme/team-b-group.yaml b/packages/catalog-model/examples/acme/team-b-group.yaml index 9ed50b04c2..20ab8721ea 100644 --- a/packages/catalog-model/examples/acme/team-b-group.yaml +++ b/packages/catalog-model/examples/acme/team-b-group.yaml @@ -8,7 +8,7 @@ spec: profile: displayName: Team B email: team-b@example.com - picture: https://example.com/groups/team-b.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-b@example.com.svg?background=%23fff&margin=25 parent: backstage children: [] --- @@ -20,7 +20,7 @@ spec: profile: displayName: Amelia Park email: amelia-park@example.com - picture: https://example.com/staff/amelia.jpeg + picture: https://avatars.dicebear.com/api/avataaars/amelia-park@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -31,7 +31,7 @@ spec: profile: displayName: Colette Brock email: colette-brock@example.com - picture: https://example.com/staff/colette.jpeg + picture: https://avatars.dicebear.com/api/avataaars/colette-brock@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -42,7 +42,7 @@ spec: profile: displayName: Jenny Doe email: jenny-doe@example.com - picture: https://example.com/staff/jenny.jpeg + picture: https://avatars.dicebear.com/api/avataaars/jenny-doe@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -53,7 +53,7 @@ spec: profile: displayName: Jonathon Page email: jonathon-page@example.com - picture: https://example.com/staff/jonathon.jpeg + picture: https://avatars.dicebear.com/api/avataaars/jonathon-page@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -64,5 +64,5 @@ spec: profile: displayName: Justine Barrow email: justine-barrow@example.com - picture: https://example.com/staff/justine.jpeg + picture: https://avatars.dicebear.com/api/avataaars/justine-barrow@example.com.svg?background=%23fff memberOf: [team-b] diff --git a/packages/catalog-model/examples/acme/team-c-group.yaml b/packages/catalog-model/examples/acme/team-c-group.yaml index 7f151dbb1e..639b591d61 100644 --- a/packages/catalog-model/examples/acme/team-c-group.yaml +++ b/packages/catalog-model/examples/acme/team-c-group.yaml @@ -8,7 +8,7 @@ spec: profile: displayName: Team C email: team-c@example.com - picture: https://example.com/groups/team-c.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-c@example.com.svg?background=%23fff&margin=25 parent: boxoffice children: [] --- @@ -20,7 +20,7 @@ spec: profile: displayName: Calum Leavy email: calum-leavy@example.com - picture: https://example.com/staff/calum.jpeg + picture: https://avatars.dicebear.com/api/avataaars/calum-leavy@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -31,7 +31,7 @@ spec: profile: displayName: Frank Tiernan email: frank-tiernan@example.com - picture: https://example.com/staff/frank.jpeg + picture: https://avatars.dicebear.com/api/avataaars/frank-tiernan@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -42,7 +42,7 @@ spec: profile: displayName: Peadar MacMahon email: peadar-macmahon@example.com - picture: https://example.com/staff/peadar.jpeg + picture: https://avatars.dicebear.com/api/avataaars/peadar-macmahon@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -53,7 +53,7 @@ spec: profile: displayName: Sarah Gilroy email: sarah-gilroy@example.com - picture: https://example.com/staff/sarah.jpeg + picture: https://avatars.dicebear.com/api/avataaars/sarah-gilroy@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -64,5 +64,5 @@ spec: profile: displayName: Tara MacGovern email: tara-macgovern@example.com - picture: https://example.com/staff/tara.jpeg + picture: https://avatars.dicebear.com/api/avataaars/tara-macgovern@example.com.svg?background=%23fff memberOf: [team-c] diff --git a/packages/catalog-model/examples/acme/team-d-group.yaml b/packages/catalog-model/examples/acme/team-d-group.yaml index a0d3c2351a..898ed8be6c 100644 --- a/packages/catalog-model/examples/acme/team-d-group.yaml +++ b/packages/catalog-model/examples/acme/team-d-group.yaml @@ -8,7 +8,7 @@ spec: profile: displayName: Team D email: team-d@example.com - picture: https://example.com/groups/team-d.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-d@example.com.svg?background=%23fff&margin=25 parent: boxoffice children: [] --- @@ -20,7 +20,7 @@ spec: profile: displayName: Eva MacDowell email: eva-macdowell@example.com - picture: https://example.com/staff/eva.jpeg + picture: https://avatars.dicebear.com/api/avataaars/eva-macdowell@example.com.svg?background=%23fff memberOf: [team-d] --- apiVersion: backstage.io/v1alpha1 @@ -31,5 +31,5 @@ spec: profile: displayName: Lucy Sheehan email: lucy-sheehan@example.com - picture: https://example.com/staff/lucy.jpeg + picture: https://avatars.dicebear.com/api/avataaars/lucy-sheehan@example.com.svg?background=%23fff memberOf: [team-d] 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..e81bc3a9b6 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -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.5.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..4afb8f5c96 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -20,6 +20,7 @@ export { } from './constants'; export type { Entity, + EntityLink, EntityMeta, EntityRelation, EntityRelationSpec, 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/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..29cce97ae9 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/cli +## 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/package.json b/packages/cli/package.json index 0d1605f752..35ad049741 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.5.0", "private": false, "publishConfig": { "access": "public" @@ -30,7 +30,7 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.1", + "@backstage/config-loader": "^0.5.0", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", @@ -113,7 +113,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", "@backstage/dev-utils": "^0.1.8", @@ -125,7 +125,7 @@ "@types/html-webpack-plugin": "^3.2.2", "@types/http-proxy": "^1.17.4", "@types/inquirer": "^7.3.1", - "@types/mini-css-extract-plugin": "^0.9.1", + "@types/mini-css-extract-plugin": "^1.2.2", "@types/mock-fs": "^4.13.0", "@types/node": "^13.7.2", "@types/react-dev-utils": "^9.0.4", @@ -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/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index a5e34ad91e..2079c85096 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/config-loader +## 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..3387477231 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.0", "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/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..2607d51f80 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); } 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..bbea58e846 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -42,7 +42,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.5", + "@backstage/cli": "^0.5.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/package.json b/packages/core/package.json index a937502247..49090e1362 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,6 +35,7 @@ "@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.5.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/packages/core/src/layout/Sidebar/Items.test.tsx b/packages/core/src/layout/Sidebar/Items.test.tsx index 099a3477d3..fb0ee174f8 100644 --- a/packages/core/src/layout/Sidebar/Items.test.tsx +++ b/packages/core/src/layout/Sidebar/Items.test.tsx @@ -22,8 +22,18 @@ import HomeIcon from '@material-ui/icons/Home'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import { Sidebar } from './Bar'; import { SidebarItem } from './Items'; +import { renderHook } from '@testing-library/react-hooks'; +import { hexToRgb, makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + spotlight: { + backgroundColor: '#2b2a2a', + }, +}); async function renderSidebar() { + const { result } = renderHook(() => useStyles()); + await renderInTestApp( @@ -31,6 +41,7 @@ async function renderSidebar() { icon={CreateComponentIcon} onClick={() => {}} text="Create..." + className={result.current.spotlight} /> , ); @@ -54,5 +65,11 @@ describe('Items', () => { await screen.findByRole('button', { name: /create/i }), ).toBeInTheDocument(); }); + + it('should render a button with custom style', async () => { + expect( + await screen.findByRole('button', { name: /create/i }), + ).toHaveStyle(`background-color: ${hexToRgb('2b2a2a')}`); + }); }); }); diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index e954dcb4f8..57cf435651 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -128,6 +128,7 @@ type SidebarItemBaseProps = { text?: string; hasNotifications?: boolean; children?: ReactNode; + className?: string; }; type SidebarItemButtonProps = SidebarItemBaseProps & { @@ -154,6 +155,7 @@ export const SidebarItem = forwardRef((props, ref) => { hasNotifications = false, onClick, children, + className, } = props; const classes = useStyles(); // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component @@ -193,6 +195,7 @@ export const SidebarItem = forwardRef((props, ref) => { const childProps = { onClick, className: clsx( + className, classes.root, isOpen ? classes.open : classes.closed, isButtonItem(props) && classes.buttonItem, diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index e33d8cc633..6c449b33b5 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,45 @@ # @backstage/create-app +## 0.3.7 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [12a56cdfe] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [0b1182346] +- 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/cli@0.5.0 + - @backstage/plugin-catalog@0.2.14 + - @backstage/plugin-catalog-backend@0.5.5 + - @backstage/plugin-catalog-import@0.3.6 + - @backstage/plugin-scaffolder@0.4.1 + - @backstage/plugin-auth-backend@0.2.12 + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/test-utils@0.1.6 + - @backstage/theme@0.2.2 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-app-backend@0.3.5 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-user-settings@0.2.4 + ## 0.3.6 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 60dd150566..475b61dde8 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.6", + "version": "0.3.7", "private": false, "publishConfig": { "access": "public" @@ -44,26 +44,26 @@ "ts-node": "^8.6.2" }, "peerDependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", "@backstage/plugin-api-docs": "^0.4.3", - "@backstage/plugin-app-backend": "^0.3.4", - "@backstage/plugin-auth-backend": "^0.2.11", - "@backstage/plugin-catalog": "^0.2.13", - "@backstage/plugin-catalog-backend": "^0.5.4", - "@backstage/plugin-catalog-import": "^0.3.5", + "@backstage/plugin-app-backend": "^0.3.5", + "@backstage/plugin-auth-backend": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-backend": "^0.5.5", + "@backstage/plugin-catalog-import": "^0.3.6", "@backstage/plugin-circleci": "^0.2.6", "@backstage/plugin-explore": "^0.2.3", "@backstage/plugin-github-actions": "^0.3.0", "@backstage/plugin-lighthouse": "^0.2.8", "@backstage/plugin-proxy-backend": "^0.2.4", "@backstage/plugin-rollbar-backend": "^0.1.7", - "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/plugin-search": "^0.2.6", - "@backstage/plugin-scaffolder-backend": "^0.5.1", + "@backstage/plugin-scaffolder-backend": "^0.5.2", "@backstage/plugin-tech-radar": "^0.3.3", "@backstage/plugin-techdocs": "^0.5.4", "@backstage/plugin-techdocs-backend": "^0.5.4", diff --git a/packages/create-app/templates/default-app/catalog-info.yaml.hbs b/packages/create-app/templates/default-app/catalog-info.yaml.hbs index 0f8e4f5aeb..d66da552b7 100644 --- a/packages/create-app/templates/default-app/catalog-info.yaml.hbs +++ b/packages/create-app/templates/default-app/catalog-info.yaml.hbs @@ -6,7 +6,7 @@ metadata: # Example for optional annotations # annotations: # github.com/project-slug: backstage/backstage - # backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + # backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website owner: john@example.com diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 629bc7c87e..93a5247c82 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -31,7 +31,7 @@ "dependencies": { "@backstage/core": "^0.5.0", "@backstage/catalog-model": "^0.7.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -47,7 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 804fc0c63e..11eb164535 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/integration +## 0.3.1 + +### Patch Changes + +- 6800da78d: Fix default branch API url for custom hosted Bitbucket server +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. + ## 0.3.0 ### Minor Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index fd5268dced..06a83ea883 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,13 +31,13 @@ "dependencies": { "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "@octokit/rest": "^18.0.12", "@octokit/auth-app": "^2.10.5", "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 39707976fe..1287ab4d66 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -116,7 +116,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -144,7 +144,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -231,7 +231,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index ae61df497d..f5235d8189 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -31,9 +31,10 @@ export async function getBitbucketDefaultBranch( const { name: repoName, owner: project, resource } = parseGitUrl(url); const isHosted = resource === 'bitbucket.org'; + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 const branchUrl = isHosted ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` - : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); if (!response.ok) { @@ -50,7 +51,10 @@ export async function getBitbucketDefaultBranch( defaultBranch = displayId; } if (!defaultBranch) { - throw new Error(`Failed to read default branch from ${branchUrl}`); + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); } return defaultBranch; } diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index 29dcc60bac..c1d43c3a46 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -147,10 +147,15 @@ export async function getProjectId( repoIDLookup.toString(), getGitLabRequestOptions(config), ); - const projectIDJson = await response.json(); - const projectID = Number(projectIDJson.id); + const data = await response.json(); - return projectID; + if (!response.ok) { + throw new Error( + `GitLab Error '${data.error}', ${data.error_description}`, + ); + } + + return Number(data.id); } catch (e) { throw new Error(`Could not get GitLab project ID for: ${target}, ${e}`); } diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 7bff39d3c4..568df3157c 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/techdocs-common +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- db2328c88: Add rate limiter for concurrent execution of file uploads in AWS and Google publishers +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + ## 0.3.5 ### Patch Changes diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 93825511cb..64c5c19425 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -37,10 +37,10 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1.0", - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.0", + "@backstage/integration": "^0.3.1", "@google-cloud/storage": "^5.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", @@ -48,7 +48,7 @@ "dockerode": "^3.2.1", "express": "^4.17.1", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "js-yaml": "^4.0.0", "json5": "^2.1.3", "mime-types": "^2.1.27", @@ -59,7 +59,7 @@ }, "devDependencies": { "@aws-sdk/types": "3.1.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^3.12.5", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 8c5a7c1f39..10f0e89c14 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/theme/package.json b/packages/theme/package.json index 54f43a1443..24edf81bb6 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1" + "@backstage/cli": "^0.5.0" }, "files": [ "dist" diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index d9c0dc0968..5e3c42bfdf 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -32,7 +32,7 @@ "@asyncapi/react-component": "^0.18.2", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", @@ -49,7 +49,7 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 90a4eac95d..514e32633a 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-app-backend +## 0.3.5 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/backend-common@0.5.1 + - @backstage/config-loader@0.5.0 + ## 0.3.4 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index f55da9042a..f397ea1475 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", - "@backstage/config-loader": "^0.4.0", + "@backstage/backend-common": "^0.5.1", + "@backstage/config-loader": "^0.5.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^4.0.2" diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 0df0fa1938..e3ff6aaa43 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-auth-backend +## 0.2.12 + +### Patch Changes + +- d7b1d317f: Fixed serialization issue with caching of public keys in AWS ALB auth provider +- 39b05b9ae: Use .text instead of .json for ALB key response +- 4eaa06057: Fix AWS ALB issuer check +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.2.11 ### Patch Changes diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index c748090711..84f71b52b9 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -72,6 +72,10 @@ export interface Config { onelogin?: { development: { [key: string]: string }; }; + awsalb?: { + issuer?: string; + region: string; + }; }; }; } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 27cb725faa..b2198165a5 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.2.11", + "version": "0.2.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", @@ -65,7 +65,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index bae809971e..d05b6bbfaf 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -80,7 +80,7 @@ describe('AwsALBAuthProvider', () => { const mockResponseSend = jest.fn(); const mockRequest = ({ header: jest.fn(() => { - return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzc3VlciI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.zUkMYAuMwC1T0tyHMpxXrkbFDa4aGhB8d9um_tI2hsI'; + return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo'; }), } as unknown) as express.Request; const mockRequestWithoutJwt = ({ diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index 41dc5cf916..61ea10947e 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -34,7 +34,7 @@ const ALB_JWT_HEADER = 'x-amzn-oidc-data'; */ type AwsAlbAuthProviderOptions = { region: string; - issuer: string; + issuer?: string; identityResolutionCallback: ExperimentalIdentityResolver; }; export const getJWTHeaders = (input: string) => { @@ -70,10 +70,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { const key = await this.getKey(headers.kid); const payload = JWT.verify(jwt, key); - if ( - this.options.issuer !== '' && - headers.issuer !== this.options.issuer - ) { + if (this.options.issuer && headers.iss !== this.options.issuer) { throw new Error('issuer mismatch on JWT'); } @@ -98,13 +95,13 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { async getKey(keyId: string): Promise { const optionalCacheKey = this.keyCache.get(keyId); if (optionalCacheKey) { - return optionalCacheKey; + return crypto.createPublicKey(optionalCacheKey); } const keyText: string = await fetch( `https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`, ).then(response => response.text()); const keyValue = crypto.createPublicKey(keyText); - this.keyCache.set(keyId, keyValue); + this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' })); return keyValue; } } @@ -116,7 +113,7 @@ export const createAwsAlbProvider = ({ identityResolver, }: AuthProviderFactoryOptions) => { const region = config.getString('region'); - const issuer = config.getString('iss'); + const issuer = config.getOptionalString('iss'); if (identityResolver !== undefined) { return new AwsAlbAuthProvider(logger, catalogApi, { region, diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 77620c1cea..0e446bf0f7 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-backend +## 0.5.5 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- a91aa6bf2: Support supplying a custom catalog descriptor file parser +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.5.4 ### Patch Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index b2db27770b..264bda0556 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.5.4", + "version": "0.5.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "dependencies": { "@aws-sdk/client-organizations": "^3.2.0", "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@octokit/graphql": "^4.5.8", @@ -43,7 +43,7 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "knex": "^0.21.6", "ldapjs": "^2.2.0", "lodash": "^4.17.15", @@ -57,7 +57,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.6", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index cb703b8e15..046b6bdf76 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -32,6 +32,7 @@ import { CatalogProcessorEntityResult, CatalogProcessorErrorResult, CatalogProcessorLocationResult, + CatalogProcessorParser, CatalogProcessorResult, } from './processors/types'; import { LocationReader, ReadLocationResult } from './types'; @@ -41,6 +42,7 @@ const MAX_DEPTH = 10; type Options = { reader: UrlReader; + parser: CatalogProcessorParser; logger: Logger; config: Config; processors: CatalogProcessor[]; @@ -137,7 +139,6 @@ export class LocationReaders implements LocationReader { if (emitResult.type === 'relation') { throw new Error('readLocation may not emit entity relations'); } - emit(emitResult); }; @@ -149,6 +150,7 @@ export class LocationReaders implements LocationReader { item.location, item.optional, validatedEmit, + this.options.parser, ) ) { return; diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index aac4235e3a..49b6fe71e0 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -25,6 +25,7 @@ import { CatalogProcessorErrorResult, CatalogProcessorResult, } from './types'; +import { defaultEntityDataParser } from './util/parse'; describe('UrlReaderProcessor', () => { const mockApiOrigin = 'http://localhost'; @@ -52,7 +53,7 @@ describe('UrlReaderProcessor', () => { ); const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit), + processor.readLocation(spec, false, emit, defaultEntityDataParser), )) as CatalogProcessorEntityResult; expect(generated.type).toBe('entity'); @@ -81,7 +82,7 @@ describe('UrlReaderProcessor', () => { ); const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit), + processor.readLocation(spec, false, emit, defaultEntityDataParser), )) as CatalogProcessorErrorResult; expect(generated.type).toBe('error'); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index 04f5ee738a..9daae29d3b 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -18,8 +18,11 @@ import { UrlReader } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; import { Logger } from 'winston'; import * as result from './results'; -import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import { parseEntityYaml } from './util/parse'; +import { + CatalogProcessor, + CatalogProcessorEmit, + CatalogProcessorParser, +} from './types'; // TODO(Rugvip): Added for backwards compatibility when moving to UrlReader, this // can be removed in a bit @@ -43,6 +46,7 @@ export class UrlReaderProcessor implements CatalogProcessor { location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, + parser: CatalogProcessorParser, ): Promise { if (deprecatedTypes.includes(location.type)) { // TODO(Rugvip): Remove this warning a month or two into 2021, and remove support for the deprecated types. @@ -57,7 +61,7 @@ export class UrlReaderProcessor implements CatalogProcessor { try { const data = await this.options.reader.read(location.target); - for (const parseResult of parseEntityYaml(data, location)) { + for await (const parseResult of parser({ data, location })) { emit(parseResult); } } catch (error) { diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 1bf30567bc..f7e11d5616 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -27,12 +27,15 @@ export type CatalogProcessor = { * @param location The location to read * @param optional Whether a missing target should trigger an error * @param emit A sink for items resulting from the read + * @param parser A parser, that is able to take the raw catalog descriptor + * data and turn it into the actual result pieces. * @returns True if handled by this processor, false otherwise */ readLocation?( location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, + parser: CatalogProcessorParser, ): Promise; /** @@ -100,6 +103,16 @@ export type CatalogProcessor = { ): Promise; }; +/** + * A parser, that is able to take the raw catalog descriptor data and turn it + * into the actual result pieces. The default implementation performs a YAML + * document parsing. + */ +export type CatalogProcessorParser = (options: { + data: Buffer; + location: LocationSpec; +}) => AsyncIterable; + export type CatalogProcessorEmit = (generated: CatalogProcessorResult) => void; export type CatalogProcessorLocationResult = { diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts index c3dcd42d62..aa24968d6d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts @@ -18,7 +18,7 @@ import { Entity, LocationSpec } from '@backstage/catalog-model'; import lodash from 'lodash'; import yaml from 'yaml'; import * as result from '../results'; -import { CatalogProcessorResult } from '../types'; +import { CatalogProcessorParser, CatalogProcessorResult } from '../types'; export function* parseEntityYaml( data: Buffer, @@ -50,3 +50,12 @@ export function* parseEntityYaml( } } } + +export const defaultEntityDataParser: CatalogProcessorParser = async function* defaultEntityDataParser({ + data, + location, +}) { + for (const e of parseEntityYaml(data, location)) { + yield e; + } +}; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index 37337f76f1..695902fec9 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -20,6 +20,7 @@ import { ConfigReader } from '@backstage/config'; import Knex from 'knex'; import yaml from 'yaml'; import { DatabaseManager } from '../database'; +import { CatalogProcessorParser } from '../ingestion'; import * as result from '../ingestion/processors/results'; import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder'; @@ -209,4 +210,26 @@ describe('CatalogBuilder', () => { }), ]); }); + + it('setEntityDataParser works', async () => { + const mockParser: CatalogProcessorParser = jest + .fn() + .mockImplementation(() => {}); + + const builder = new CatalogBuilder(env) + .setEntityDataParser(mockParser) + .replaceProcessors([ + { + async readLocation(_location, _optional, _emit, parser) { + expect(parser).toBe(mockParser); + return true; + }, + }, + ]); + + const { higherOrderOperation } = await builder.build(); + await higherOrderOperation.addLocation({ type: 'x', target: 'y' }); + + expect.assertions(1); + }); }); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index f6170135fd..938a406e56 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -39,6 +39,7 @@ import { AnnotateLocationEntityProcessor, BuiltinKindsEntityProcessor, CatalogProcessor, + CatalogProcessorParser, CodeOwnersProcessor, FileReaderProcessor, GithubOrgReaderProcessor, @@ -60,6 +61,7 @@ import { textPlaceholderResolver, yamlPlaceholderResolver, } from '../ingestion/processors/PlaceholderProcessor'; +import { defaultEntityDataParser } from '../ingestion/processors/util/parse'; import { LocationAnalyzer } from '../ingestion/types'; export type CatalogEnvironment = { @@ -96,6 +98,7 @@ export class CatalogBuilder { private fieldFormatValidators: Partial; private processors: CatalogProcessor[]; private processorsReplace: boolean; + private parser: CatalogProcessorParser | undefined; constructor(env: CatalogEnvironment) { this.env = env; @@ -105,6 +108,7 @@ export class CatalogBuilder { this.fieldFormatValidators = {}; this.processors = []; this.processorsReplace = false; + this.parser = undefined; } /** @@ -197,6 +201,20 @@ export class CatalogBuilder { return this; } + /** + * Sets up the catalog to use a custom parser for entity data. + * + * This is the function that gets called immediately after some raw entity + * specification data has been read from a remote source, and needs to be + * parsed and emitted as structured data. + * + * @param parser The custom parser + */ + setEntityDataParser(parser: CatalogProcessorParser): CatalogBuilder { + this.parser = parser; + return this; + } + /** * Wires up and returns all of the component parts of the catalog */ @@ -211,9 +229,11 @@ export class CatalogBuilder { const policy = this.buildEntityPolicy(); const processors = this.buildProcessors(); const rulesEnforcer = CatalogRulesEnforcer.fromConfig(config); + const parser = this.parser || defaultEntityDataParser; const locationReader = new LocationReaders({ ...this.env, + parser, processors, rulesEnforcer, policy, diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 4533a541f0..c81c29fc3e 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@graphql-modules/core": "^0.7.17", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 2d75ae6af4..c6e4995c70 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-import +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/integration@0.3.1 + - @backstage/plugin-catalog@0.2.14 + ## 0.3.5 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index b2c9991bb8..5a4dbf8798 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,14 +32,14 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/integration": "^0.3.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/integration": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@octokit/rest": "^18.0.12", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^6.6.0", @@ -49,7 +49,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index efacf6fbad..3920d8696d 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog +## 0.2.14 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- 0b1182346: 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. + +- Updated dependencies [9dd057662] + - @backstage/plugin-scaffolder@0.4.1 + ## 0.2.13 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index be1681c596..90bdd2f3c7 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,14 +33,14 @@ "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", "classnames": "^2.2.6", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -51,7 +51,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@microsoft/microsoft-graph-types": "^1.25.0", diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index b2d029c927..82652b3986 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -21,7 +21,7 @@ import { } from '@backstage/catalog-model'; import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; -import { EntityRefLink } from '../EntityRefLink'; +import { EntityRefLinks } from '../EntityRefLink'; import { getEntityRelations } from '../getEntityRelations'; import { AboutField } from './AboutField'; @@ -41,17 +41,17 @@ export const AboutContent = ({ entity }: Props) => { const isDomain = entity.kind.toLowerCase() === 'domain'; const isResource = entity.kind.toLowerCase() === 'resource'; const isComponent = entity.kind.toLowerCase() === 'component'; - const [partOfSystemRelation] = getEntityRelations(entity, RELATION_PART_OF, { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { kind: 'system', }); - const [partOfComponentRelation] = getEntityRelations( + const partOfComponentRelations = getEntityRelations( entity, RELATION_PART_OF, { kind: 'component', }, ); - const [partOfDomainRelation] = getEntityRelations(entity, RELATION_PART_OF, { + const partOfDomainRelations = getEntityRelations(entity, RELATION_PART_OF, { kind: 'domain', }); const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); @@ -64,12 +64,7 @@ export const AboutContent = ({ entity }: Props) => { - {ownedByRelations.map((t, i) => ( - - {i > 0 && ', '} - - - ))} + {isSystem && ( { value="No Domain" gridSizes={{ xs: 12, sm: 6, lg: 4 }} > - {partOfDomainRelation && ( - - )} + )} {!isSystem && !isDomain && ( @@ -91,22 +84,20 @@ export const AboutContent = ({ entity }: Props) => { value="No System" gridSizes={{ xs: 12, sm: 6, lg: 4 }} > - {partOfSystemRelation && ( - - )} + )} - {isComponent && partOfComponentRelation && ( + {isComponent && partOfComponentRelations.length > 0 && ( - diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 8741cb6157..18c61c2a04 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -20,17 +20,19 @@ import { RELATION_PART_OF, } from '@backstage/catalog-model'; import { Table, TableColumn, TableProps } from '@backstage/core'; -import { Chip, Link } from '@material-ui/core'; +import { Chip } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; import { Alert } from '@material-ui/lab'; import React from 'react'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntities'; -import { entityRoute, entityRouteParams } from '../../routes'; import { createEditLink } from '../createEditLink'; -import { EntityRefLink, formatEntityRefTitle } from '../EntityRefLink'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, +} from '../EntityRefLink'; import { favouriteEntityIcon, favouriteEntityTooltip, @@ -40,7 +42,7 @@ import { getEntityRelations } from '../getEntityRelations'; type EntityRow = Entity & { row: { partOfSystemRelationTitle?: string; - partOfSystemRelation?: EntityName; + partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; ownedByRelations: EntityName[]; }; @@ -52,43 +54,27 @@ const columns: TableColumn[] = [ field: 'metadata.name', highlight: true, render: entity => ( - - {entity.metadata.name} - + {entity.metadata.name} ), }, { title: 'System', field: 'row.partOfSystemRelationTitle', render: entity => ( - <> - {entity.row.partOfSystemRelation && ( - - )} - + ), }, { title: 'Owner', field: 'row.ownedByRelationsTitle', render: entity => ( - <> - {entity.row.ownedByRelations.map((t, i) => ( - - {i > 0 && ', '} - - - ))} - + ), }, { @@ -182,7 +168,7 @@ export const CatalogTable = ({ ]; const rows = entities.map(e => { - const [partOfSystemRelation] = getEntityRelations(e, RELATION_PART_OF, { + const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { kind: 'system', }); const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); @@ -194,12 +180,13 @@ export const CatalogTable = ({ .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), ownedByRelations, - partOfSystemRelationTitle: partOfSystemRelation - ? formatEntityRefTitle(partOfSystemRelation, { - defaultKind: 'system', - }) - : undefined, - partOfSystemRelation, + partOfSystemRelationTitle: + partOfSystemRelations.length > 0 + ? formatEntityRefTitle(partOfSystemRelations[0], { + defaultKind: 'system', + }) + : undefined, + partOfSystemRelations, }, }; }); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx new file mode 100644 index 0000000000..9bc26b0954 --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { EntityRefLinks } from './EntityRefLinks'; + +describe('', () => { + it('renders a single link', () => { + const entityNames = [ + { + kind: 'Component', + namespace: 'default', + name: 'software', + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + }); + + it('renders multiple links', () => { + const entityNames = [ + { + kind: 'Component', + namespace: 'default', + name: 'software', + }, + { + kind: 'API', + namespace: 'default', + name: 'interface', + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText(',')).toBeInTheDocument(); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + expect(getByText('api:interface')).toHaveAttribute( + 'href', + '/catalog/default/api/interface', + ); + }); +}); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx new file mode 100644 index 0000000000..3c8beaec6f --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity, EntityName } from '@backstage/catalog-model'; +import React from 'react'; +import { EntityRefLink } from './EntityRefLink'; + +type EntityRefLinksProps = { + entityRefs: (Entity | EntityName)[]; + defaultKind?: string; +}; + +// TODO: Move into a shared helper package +export const EntityRefLinks = ({ + entityRefs, + defaultKind, +}: EntityRefLinksProps) => { + return ( + <> + {entityRefs.map((r, i) => ( + + {i > 0 && ', '} + + + ))} + + ); +}; diff --git a/plugins/catalog/src/components/EntityRefLink/index.ts b/plugins/catalog/src/components/EntityRefLink/index.ts index 76a7da38c5..9e6e440514 100644 --- a/plugins/catalog/src/components/EntityRefLink/index.ts +++ b/plugins/catalog/src/components/EntityRefLink/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ export { EntityRefLink } from './EntityRefLink'; +export { EntityRefLinks } from './EntityRefLinks'; export { formatEntityRefTitle } from './format'; diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index fbc6605419..ddae98a566 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 0826ac972a..9a05a652b9 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index b666c66864..a979f1476b 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cost-insights +## 0.6.0 + +### Minor Changes + +- fac91bcc5: Add support for additional breakdowns of daily cost data. + This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. + +### Patch Changes + +- 8b7ef9f8b: Allow expand functionality to top panel product chart tooltip. + ## 0.5.7 ### Patch Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index bd573d36de..72a9e92633 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.5.7", + "version": "0.6.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -55,7 +55,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 0ba57f76fa..fa9a1d23f5 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -33,11 +33,12 @@ import { UnlabeledDataflowAlert, } from '../src/utils/alerts'; import { - trendlineOf, + aggregationFor, changeOf, entityOf, getGroupedProducts, - aggregationFor, + getGroupedProjects, + trendlineOf, } from './utils/mockData'; export class ExampleCostInsightsClient implements CostInsightsApi { @@ -99,9 +100,12 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), - // Optional field on Cost which needs to be supplied in order to see - // the product breakdown view in the top panel. - groupedCosts: getGroupedProducts(intervals), + // Optional field providing cost groupings / breakdowns keyed by the type. In this example, + // daily cost grouped by cloud product OR by project / billing account. + groupedCosts: { + product: getGroupedProducts(intervals), + project: getGroupedProjects(intervals), + }, }, ); @@ -117,9 +121,11 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), - // Optional field on Cost which needs to be supplied in order to see - // the product breakdown view in the top panel. - groupedCosts: getGroupedProducts(intervals), + // Optional field providing cost groupings / breakdowns keyed by the type. In this example, + // daily project cost grouped by cloud product. + groupedCosts: { + product: getGroupedProducts(intervals), + }, }, ); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx similarity index 67% rename from plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx rename to plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx index b562434c5e..feec23933f 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx @@ -56,26 +56,26 @@ import { BarChartLegendOptions } from '../BarChart/BarChartLegend'; dayjs.extend(utc); -export type CostOverviewByProductChartProps = { - costsByProduct: Cost[]; +export type CostOverviewBreakdownChartProps = { + costBreakdown: Cost[]; }; const LOW_COST_THRESHOLD = 0.1; -export const CostOverviewByProductChart = ({ - costsByProduct, -}: CostOverviewByProductChartProps) => { +export const CostOverviewBreakdownChart = ({ + costBreakdown, +}: CostOverviewBreakdownChartProps) => { const theme = useTheme(); const classes = useStyles(theme); const lastCompleteBillingDate = useLastCompleteBillingDate(); const { duration } = useFilters(mapFiltersToProps); const [isExpanded, setExpanded] = useState(false); - if (!costsByProduct) { + if (!costBreakdown) { return null; } - const flattenedAggregation = costsByProduct + const flattenedAggregation = costBreakdown .map(cost => cost.aggregation) .flat(); @@ -87,44 +87,46 @@ export const CostOverviewByProductChart = ({ lastCompleteBillingDate, ); const currentPeriodTotal = totalCost - previousPeriodTotal; - const otherProducts: string[] = []; + const canExpand = costBreakdown.length >= 8; + const otherCategoryIds: string[] = []; - const productsByDate = costsByProduct.reduce((prodByDate, product) => { - const productTotal = aggregationSum(product.aggregation); - // Group products with less than 10% of the total cost into "Other" category - // when we have >= 8 products. - const isOtherProduct = - costsByProduct.length >= 8 && - productTotal < totalCost * LOW_COST_THRESHOLD; + const breakdownsByDate = costBreakdown.reduce( + (breakdownByDate, breakdown) => { + const breakdownTotal = aggregationSum(breakdown.aggregation); + // Group breakdown items with less than 10% of the total cost into "Other" category if needed + const isOtherCategory = + canExpand && breakdownTotal < totalCost * LOW_COST_THRESHOLD; - const updatedProdByDate = { ...prodByDate }; - if (isOtherProduct) { - otherProducts.push(product.id); - } - product.aggregation.forEach(curAggregation => { - const productCostsForDate = updatedProdByDate[curAggregation.date] || {}; + const updatedBreakdownByDate = { ...breakdownByDate }; + if (isOtherCategory) { + otherCategoryIds.push(breakdown.id); + } + breakdown.aggregation.forEach(curAggregation => { + const costsForDate = updatedBreakdownByDate[curAggregation.date] || {}; - updatedProdByDate[curAggregation.date] = { - ...productCostsForDate, - [product.id]: - (productCostsForDate[product.id] || 0) + curAggregation.amount, - }; - }); + updatedBreakdownByDate[curAggregation.date] = { + ...costsForDate, + [breakdown.id]: + (costsForDate[breakdown.id] || 0) + curAggregation.amount, + }; + }); - return updatedProdByDate; - }, {} as Record>); + return updatedBreakdownByDate; + }, + {} as Record>, + ); - const chartData: Record[] = Object.keys(productsByDate).map( + const chartData: Record[] = Object.keys(breakdownsByDate).map( date => { - const costsForDate = Object.keys(productsByDate[date]).reduce( - (dateCosts, product) => { - // Group costs for products that belong to 'Other' in the chart. - const cost = productsByDate[date][product]; - const productCost = - !isExpanded && otherProducts.includes(product) + const costsForDate = Object.keys(breakdownsByDate[date]).reduce( + (dateCosts, breakdown) => { + // Group costs for items that belong to 'Other' in the chart. + const cost = breakdownsByDate[date][breakdown]; + const breakdownCost = + !isExpanded && otherCategoryIds.includes(breakdown) ? { Other: (dateCosts.Other || 0) + cost } - : { [product]: cost }; - return { ...dateCosts, ...productCost }; + : { [breakdown]: cost }; + return { ...dateCosts, ...breakdownCost }; }, {} as Record, ); @@ -135,40 +137,41 @@ export const CostOverviewByProductChart = ({ }, ); - const sortedProducts = costsByProduct.sort( + const sortedBreakdowns = costBreakdown.sort( (a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation), ); const renderAreas = () => { - const separatedProducts = sortedProducts - // Check that product is a separate group and hasn't been added to 'Other' + const separatedBreakdowns = sortedBreakdowns + // Check that the breakdown is a separate group and hasn't been added to 'Other' .filter( - product => - product.id !== 'Other' && !otherProducts.includes(product.id), + breakdown => + breakdown.id !== 'Other' && !otherCategoryIds.includes(breakdown.id), ) - .map(product => product.id); + .map(breakdown => breakdown.id); // Keep 'Other' category at the bottom of the stack - const productsToDisplay = isExpanded - ? sortedProducts.map(product => product.id) - : ['Other', ...separatedProducts]; + const breakdownsToDisplay = isExpanded + ? sortedBreakdowns.map(breakdown => breakdown.id) + : ['Other', ...separatedBreakdowns]; - return productsToDisplay.map((product, i) => { - // Logic to handle case where there are more products than data viz colors. - const productColor = + return breakdownsToDisplay.map((breakdown, i) => { + // Logic to handle case where there are more items than data viz colors. + const color = theme.palette.dataViz[ - (productsToDisplay.length - 1 - i) % + (breakdownsToDisplay.length - 1 - i) % (theme.palette.dataViz.length - 1) ]; return ( setExpanded(true)} style={{ - cursor: product === 'Other' && !isExpanded ? 'pointer' : null, + cursor: breakdown === 'Other' && !isExpanded ? 'pointer' : null, }} /> ); @@ -206,7 +209,7 @@ export const CostOverviewByProductChart = ({ {items.reverse().map((item, index) => ( ))} - {!isExpanded ? expandText : null} + {canExpand && !isExpanded ? expandText : null} ); }; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx new file mode 100644 index 0000000000..6fca8f67c9 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { CostOverviewCard } from './CostOverviewCard'; +import { Cost } from '../../types'; +import { + changeOf, + getGroupedProducts, + getGroupedProjects, + MockAggregatedDailyCosts, + trendlineOf, +} from '../../utils/mockData'; +import { + MockBillingDateProvider, + MockConfigProvider, + MockFilterProvider, + MockScrollProvider, +} from '../../utils/tests'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockGroupDailyCost: Cost = { + id: 'test-group', + aggregation: MockAggregatedDailyCosts, + change: changeOf(MockAggregatedDailyCosts), + trendline: trendlineOf(MockAggregatedDailyCosts), +}; + +function renderInContext(children: JSX.Element) { + return renderInTestApp( + + + + + {children} + + + + , + ); +} + +describe('', () => { + it('Renders without exploding', async () => { + const { getByText } = await renderInContext( + , + ); + expect(getByText('Cloud Cost')).toBeInTheDocument(); + }); + + it('Shows breakdown tabs if provided', async () => { + const mockDailyCostWithBreakdowns = { + ...mockGroupDailyCost, + groupedCosts: { + product: getGroupedProducts('R2/P90D/2021-01-01'), + project: getGroupedProjects('R2/P90D/2021-01-01'), + }, + }; + const { getByText } = await renderInContext( + , + ); + expect(getByText('Cloud Cost')).toBeInTheDocument(); + expect(getByText('Breakdown by product')).toBeInTheDocument(); + expect(getByText('Breakdown by project')).toBeInTheDocument(); + + fireEvent.click(getByText('Breakdown by product')); + expect(getByText('Cloud Cost By Product')).toBeInTheDocument(); + + fireEvent.click(getByText('Breakdown by project')); + expect(getByText('Cloud Cost By Project')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 39adc4bbf1..56414c9a0e 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -14,22 +14,23 @@ * limitations under the License. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, + capitalize, Card, CardContent, Divider, - useTheme, Tab, Tabs, + useTheme, } from '@material-ui/core'; import { CostOverviewChart } from './CostOverviewChart'; -import { CostOverviewByProductChart } from './CostOverviewByProductChart'; +import { CostOverviewBreakdownChart } from './CostOverviewBreakdownChart'; import { CostOverviewHeader } from './CostOverviewHeader'; import { MetricSelect } from '../MetricSelect'; import { PeriodSelect } from '../PeriodSelect'; -import { useScroll, useFilters, useConfig } from '../../hooks'; +import { useConfig, useFilters, useScroll } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; import { findAlways } from '../../utils/assert'; @@ -49,6 +50,15 @@ export const CostOverviewCard = ({ const config = useConfig(); const [tabIndex, setTabIndex] = useState(0); + // Reset tabIndex if breakdowns available change + useEffect(() => { + // Intentionally off-by-one to account for the overview tab + const lastIndex = Object.keys(dailyCostData.groupedCosts ?? {}).length; + if (tabIndex > lastIndex) { + setTabIndex(0); + } + }, [dailyCostData, tabIndex, setTabIndex]); + const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, @@ -59,14 +69,18 @@ export const CostOverviewCard = ({ : null; const styles = useOverviewTabsStyles(theme); + const breakdownTabs = Object.keys(dailyCostData.groupedCosts ?? {}).map( + key => ({ + id: key, + label: `Breakdown by ${key}`, + title: `Cloud Cost By ${capitalize(key)}`, + }), + ); const tabs = [ { id: 'overview', label: 'Total cost', title: 'Cloud Cost' }, - { - id: 'breakdown', - label: 'Breakdown by product', - title: 'Cloud Cost By Product', - }, - ]; + ].concat(breakdownTabs); + // tabIndex can temporarily be invalid while the useEffect above processes + const safeTabIndex = tabIndex > tabs.length - 1 ? 0 : tabIndex; const OverviewTabs = () => { return ( @@ -74,7 +88,7 @@ export const CostOverviewCard = ({ setTabIndex(index)} - value={tabIndex} + value={safeTabIndex} > {tabs.map((tab, index) => ( {dailyCostData.groupedCosts && } - + - {tabIndex === 0 ? ( + {safeTabIndex === 0 ? ( ) : ( - )} diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts index dd28bba6b0..c3b63946af 100644 --- a/plugins/cost-insights/src/types/Cost.ts +++ b/plugins/cost-insights/src/types/Cost.ts @@ -23,5 +23,5 @@ export interface Cost { aggregation: DateAggregation[]; change?: ChangeStatistic; trendline?: Trendline; - groupedCosts?: Cost[]; + groupedCosts?: Record; } diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index f84473b84f..14ff9aecd1 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -1064,3 +1064,18 @@ export const getGroupedProducts = (intervals: string) => [ aggregation: aggregationFor(intervals, 250), }, ]; + +export const getGroupedProjects = (intervals: string) => [ + { + id: 'project-a', + aggregation: aggregationFor(intervals, 1_700), + }, + { + id: 'project-b', + aggregation: aggregationFor(intervals, 350), + }, + { + id: 'project-c', + aggregation: aggregationFor(intervals, 1_300), + }, +]; diff --git a/plugins/explore/package.json b/plugins/explore/package.json index e4acc34435..c6668c9c4d 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -42,7 +42,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 27f6ad0966..b69039fa00 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 71f1d83c99..085e08e2a3 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-actions/examples/sample.yaml b/plugins/github-actions/examples/sample.yaml index beda3c58ed..a765bd395d 100644 --- a/plugins/github-actions/examples/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -5,7 +5,7 @@ metadata: description: backstage.io annotations: github.com/project-slug: 'backstage/backstage' - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website lifecycle: production diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index c3b05b0369..7b7cf539f9 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -34,8 +34,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/integration": "^0.3.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/integration": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 2b98f0179d..447c3cbb81 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -42,7 +42,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 1b95db6e39..850b29108e 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index f5214a3210..99d369b1a7 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@backstage/plugin-catalog-graphql": "^0.2.6", "@graphql-modules/core": "^0.7.17", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index c511553699..a99b9937b1 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.13", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index f8dbdbd00b..1e4701af16 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-kafka-backend +## 0.2.0 + +### Minor Changes + +- 234e7d985: 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` + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.1.1 ### Patch Changes diff --git a/plugins/kafka-backend/README.md b/plugins/kafka-backend/README.md index b2271a2911..13078ea816 100644 --- a/plugins/kafka-backend/README.md +++ b/plugins/kafka-backend/README.md @@ -23,6 +23,8 @@ Example: ```yaml kafka: clientId: backstage - brokers: - - localhost:9092 + clusters: + name: prod + brokers: + - localhost:9092 ``` diff --git a/plugins/kafka-backend/config.d.ts b/plugins/kafka-backend/config.d.ts index c2ab5ea777..87254b152c 100644 --- a/plugins/kafka-backend/config.d.ts +++ b/plugins/kafka-backend/config.d.ts @@ -19,20 +19,22 @@ export interface Config { * Client ID used to Backstage uses to identify when connecting to the Kafka cluster. */ clientId: string; - /** - * List of brokers in the Kafka cluster to connect to. - */ - brokers: string[]; - - /** - * Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect. - * See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options - */ - ssl?: { - ca: string[]; - /** @visibility secret */ - key: string; - cert: string; - }; + clusters: { + name: string; + /** + * List of brokers in the Kafka cluster to connect to. + */ + brokers: string[]; + /** + * Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect. + * See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options + */ + ssl?: { + ca: string[]; + /** @visibility secret */ + key: string; + cert: string; + }; + }[]; }; } diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 0c7eff2193..311464b47d 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka-backend", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka-backend/src/config/ClusterReader.test.ts b/plugins/kafka-backend/src/config/ClusterReader.test.ts new file mode 100644 index 0000000000..0529684425 --- /dev/null +++ b/plugins/kafka-backend/src/config/ClusterReader.test.ts @@ -0,0 +1,45 @@ +/* + * 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 '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { getClusterDetails } from './ClusterReader'; + +describe('getClusterDetails', () => { + it('empty clusters return empty cluster details', async () => { + const config = new ConfigReader({ clusters: [] }); + + const result = getClusterDetails(config.getConfigArray('clusters')); + + expect(result).toStrictEqual([]); + }); + + it('two clusters return two cluster details', async () => { + const config = new ConfigReader({ + clusters: [ + { name: 'cluster1', brokers: ['a', 'b'] }, + { name: 'cluster2', brokers: ['d'] }, + ], + }); + + const result = getClusterDetails(config.getConfigArray('clusters')); + + expect(result).toStrictEqual([ + { name: 'cluster1', brokers: ['a', 'b'] }, + { name: 'cluster2', brokers: ['d'] }, + ]); + }); +}); diff --git a/plugins/kafka-backend/src/config/ClusterReader.ts b/plugins/kafka-backend/src/config/ClusterReader.ts new file mode 100644 index 0000000000..a4e6a4e4fe --- /dev/null +++ b/plugins/kafka-backend/src/config/ClusterReader.ts @@ -0,0 +1,37 @@ +/* + * 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 { Config } from '@backstage/config'; +import { ConnectionOptions } from 'tls'; +import { ClusterDetails } from '../types/types'; + +export function getClusterDetails(config: Config[]): ClusterDetails[] { + return config.map(clusterConfig => { + const clusterDetails = { + name: clusterConfig.getString('name'), + brokers: clusterConfig.getStringArray('brokers'), + }; + const sslConfig = clusterConfig.getOptional('kafka.ssl'); + if (sslConfig) { + return { + ...clusterDetails, + ssl: sslConfig as ConnectionOptions, + }; + } + + return clusterDetails; + }); +} diff --git a/plugins/kafka-backend/src/service/router.test.ts b/plugins/kafka-backend/src/service/router.test.ts index 64c4cf0916..42cd92ddfb 100644 --- a/plugins/kafka-backend/src/service/router.test.ts +++ b/plugins/kafka-backend/src/service/router.test.ts @@ -16,22 +16,34 @@ import request from 'supertest'; import express from 'express'; -import { makeRouter } from './router'; +import { makeRouter, ClusterApi } from './router'; import { getVoidLogger } from '@backstage/backend-common'; import { KafkaApi } from './KafkaApi'; import { when } from 'jest-when'; describe('router', () => { let app: express.Express; - let kafkaApi: jest.Mocked; + let apis: ClusterApi[]; + let devKafkaApi: jest.Mocked; + let prodKafkaApi: jest.Mocked; beforeAll(async () => { - kafkaApi = { + devKafkaApi = { fetchTopicOffsets: jest.fn(), fetchGroupOffsets: jest.fn(), }; - const router = makeRouter(getVoidLogger(), kafkaApi); + prodKafkaApi = { + fetchTopicOffsets: jest.fn(), + fetchGroupOffsets: jest.fn(), + }; + + apis = [ + { name: 'dev', api: devKafkaApi }, + { name: 'prod', api: prodKafkaApi }, + ]; + + const router = makeRouter(getVoidLogger(), apis); app = express().use(router); }); @@ -39,7 +51,7 @@ describe('router', () => { jest.resetAllMocks(); }); - describe('get /consumer/:consumerId/offsets', () => { + describe('get /consumers/clusterId/:consumerId/offsets', () => { it('returns topic and group offsets', async () => { const topic1Offsets = [ { id: 1, offset: '500' }, @@ -60,15 +72,17 @@ describe('router', () => { partitions: [{ id: 1, offset: '456' }], }, ]; - when(kafkaApi.fetchTopicOffsets) + when(prodKafkaApi.fetchTopicOffsets) .calledWith('topic1') .mockResolvedValue(topic1Offsets); - when(kafkaApi.fetchTopicOffsets) + when(prodKafkaApi.fetchTopicOffsets) .calledWith('topic2') .mockResolvedValue(topic2Offsets); - kafkaApi.fetchGroupOffsets.mockResolvedValue(groupOffsets); + when(prodKafkaApi.fetchGroupOffsets) + .calledWith('hey') + .mockResolvedValue(groupOffsets); - const response = await request(app).get('/consumer/hey/offsets'); + const response = await request(app).get('/consumers/prod/hey/offsets'); expect(response.status).toEqual(200); expect(response.body.consumerId).toEqual('hey'); @@ -98,11 +112,70 @@ describe('router', () => { }); it('handles internal error correctly', async () => { - kafkaApi.fetchGroupOffsets.mockRejectedValue(Error('oh no')); + prodKafkaApi.fetchGroupOffsets.mockRejectedValue(Error('oh no')); - const response = await request(app).get('/consumer/hey/offsets'); + const response = await request(app).get('/consumers/prod/hey/offsets'); expect(response.status).toEqual(500); }); + + it('uses correct kafka cluster', async () => { + const topic1ProdOffsets = [{ id: 1, offset: '500' }]; + const topic1DevOffsets = [{ id: 1, offset: '1234' }]; + const groupProdOffsets = [ + { + topic: 'topic1', + partitions: [{ id: 1, offset: '100' }], + }, + ]; + const groupDevOffsets = [ + { + topic: 'topic1', + partitions: [{ id: 1, offset: '567' }], + }, + ]; + when(prodKafkaApi.fetchTopicOffsets) + .calledWith('topic1') + .mockResolvedValue(topic1ProdOffsets); + when(prodKafkaApi.fetchGroupOffsets) + .calledWith('hey') + .mockResolvedValue(groupProdOffsets); + when(devKafkaApi.fetchTopicOffsets) + .calledWith('topic1') + .mockResolvedValue(topic1DevOffsets); + when(devKafkaApi.fetchGroupOffsets) + .calledWith('hey') + .mockResolvedValue(groupDevOffsets); + + const prodResponse = await request(app).get( + '/consumers/prod/hey/offsets', + ); + const devResponse = await request(app).get('/consumers/dev/hey/offsets'); + + expect(prodResponse.status).toEqual(200); + expect(prodResponse.body.consumerId).toEqual('hey'); + expect(new Set(prodResponse.body.offsets)).toStrictEqual( + new Set([ + { + topic: 'topic1', + partitionId: 1, + groupOffset: '100', + topicOffset: '500', + }, + ]), + ); + expect(devResponse.status).toEqual(200); + expect(devResponse.body.consumerId).toEqual('hey'); + expect(new Set(devResponse.body.offsets)).toStrictEqual( + new Set([ + { + topic: 'topic1', + partitionId: 1, + groupOffset: '567', + topicOffset: '1234', + }, + ]), + ); + }); }); }); diff --git a/plugins/kafka-backend/src/service/router.ts b/plugins/kafka-backend/src/service/router.ts index 2bbc877fe2..465347ed8f 100644 --- a/plugins/kafka-backend/src/service/router.ts +++ b/plugins/kafka-backend/src/service/router.ts @@ -20,31 +20,43 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { KafkaApi, KafkaJsApiImpl } from './KafkaApi'; import _ from 'lodash'; -import { ConnectionOptions } from 'tls'; +import { getClusterDetails } from '../config/ClusterReader'; export interface RouterOptions { logger: Logger; config: Config; } +export interface ClusterApi { + name: string; + api: KafkaApi; +} + export const makeRouter = ( logger: Logger, - kafkaApi: KafkaApi, + kafkaApis: ClusterApi[], ): express.Router => { const router = Router(); router.use(express.json()); - router.get('/consumer/:consumerId/offsets', async (req, res) => { + const kafkaApiByClusterName = _.keyBy(kafkaApis, item => item.name); + + router.get('/consumers/:clusterId/:consumerId/offsets', async (req, res) => { + const clusterId = req.params.clusterId; const consumerId = req.params.consumerId; - logger.debug(`Fetch consumer group ${consumerId} offsets`); + logger.info( + `Fetch consumer group ${consumerId} offsets from cluster ${clusterId}`, + ); - const groupOffsets = await kafkaApi.fetchGroupOffsets(consumerId); + const kafkaApi = kafkaApiByClusterName[clusterId]; + + const groupOffsets = await kafkaApi.api.fetchGroupOffsets(consumerId); const groupWithTopicOffsets = await Promise.all( groupOffsets.map(async ({ topic, partitions }) => { const topicOffsets = _.keyBy( - await kafkaApi.fetchTopicOffsets(topic), + await kafkaApi.api.fetchTopicOffsets(topic), partition => partition.id, ); @@ -70,12 +82,15 @@ export async function createRouter( logger.info('Initializing Kafka backend'); const clientId = options.config.getString('kafka.clientId'); - const brokers = options.config.getStringArray('kafka.brokers'); - const sslConfig = options.config.getOptional('kafka.ssl'); - const ssl = sslConfig ? (sslConfig as ConnectionOptions) : undefined; + const clusters = getClusterDetails( + options.config.getConfigArray('kafka.clusters'), + ); - const kafkaApi = new KafkaJsApiImpl({ clientId, brokers, logger, ssl }); + const kafkaApis = clusters.map(cluster => ({ + name: cluster.name, + api: new KafkaJsApiImpl({ clientId, logger, ...cluster }), + })); - return makeRouter(logger, kafkaApi); + return makeRouter(logger, kafkaApis); } diff --git a/plugins/kafka-backend/src/types/types.ts b/plugins/kafka-backend/src/types/types.ts new file mode 100644 index 0000000000..5f70998dd8 --- /dev/null +++ b/plugins/kafka-backend/src/types/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConnectionOptions } from 'tls'; + +export interface ClusterDetails { + name: string; + brokers: string[]; + ssl?: ConnectionOptions; +} diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md index 01479f8186..92f49a1d35 100644 --- a/plugins/kafka/CHANGELOG.md +++ b/plugins/kafka/CHANGELOG.md @@ -1,5 +1,42 @@ # @backstage/plugin-kafka +## 0.2.0 + +### Minor Changes + +- 234e7d985: 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` + +### Patch Changes + +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + ## 0.1.1 ### Patch Changes diff --git a/plugins/kafka/README.md b/plugins/kafka/README.md index 7d947ae41b..c41c328741 100644 --- a/plugins/kafka/README.md +++ b/plugins/kafka/README.md @@ -65,8 +65,10 @@ import { Router as KafkaRouter } from '@backstage/plugin-kafka'; ```yaml kafka: clientId: backstage - brokers: - - localhost:9092 + clusters: + - name: cluster-name + brokers: + - localhost:9092 ``` 6. Add `kafka.apache.org/consumer-groups` annotation to your services: @@ -77,7 +79,7 @@ kind: Component metadata: # ... annotations: - kafka.apache.org/consumer-groups: consumer-group-name + kafka.apache.org/consumer-groups: cluster-name/consumer-group-name spec: type: service ``` diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 306b65a653..eef14439fd 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kafka/src/api/KafkaBackendClient.ts b/plugins/kafka/src/api/KafkaBackendClient.ts index 4b41323098..8166da4601 100644 --- a/plugins/kafka/src/api/KafkaBackendClient.ts +++ b/plugins/kafka/src/api/KafkaBackendClient.ts @@ -43,8 +43,11 @@ export class KafkaBackendClient implements KafkaApi { } async getConsumerGroupOffsets( + clusterId: string, consumerGroup: string, ): Promise { - return await this.internalGet(`/consumer/${consumerGroup}/offsets`); + return await this.internalGet( + `/consumers/${clusterId}/${consumerGroup}/offsets`, + ); } } diff --git a/plugins/kafka/src/api/types.ts b/plugins/kafka/src/api/types.ts index 9c2239156b..c4307d5c1f 100644 --- a/plugins/kafka/src/api/types.ts +++ b/plugins/kafka/src/api/types.ts @@ -34,6 +34,7 @@ export type ConsumerGroupOffsetsResponse = { export interface KafkaApi { getConsumerGroupOffsets( + clusterId: string, consumerGroup: string, ): Promise; } diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.test.tsx index ea0f7e1ddb..9cedc5e135 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.test.tsx @@ -27,6 +27,7 @@ describe('ConsumerGroupOffsets', () => { const rendered = await renderInTestApp( {}} diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.tsx index 448d420bc0..7928393526 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.tsx @@ -15,7 +15,7 @@ */ import { Table, TableColumn } from '@backstage/core'; -import { Box, Typography } from '@material-ui/core'; +import { Box, Grid, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import React from 'react'; import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity'; @@ -62,6 +62,7 @@ const generatedColumns: TableColumn[] = [ type Props = { loading: boolean; retry: () => void; + clusterId: string; consumerGroup: string; topics?: TopicPartitionInfo[]; }; @@ -69,6 +70,7 @@ type Props = { export const ConsumerGroupOffsets = ({ loading, topics, + clusterId, consumerGroup, retry, }: Props) => { @@ -87,7 +89,7 @@ export const ConsumerGroupOffsets = ({ title={ - Consumed Topics for {consumerGroup} + Consumed Topics for {consumerGroup} ({clusterId}) } @@ -98,6 +100,15 @@ export const ConsumerGroupOffsets = ({ export const KafkaTopicsForConsumer = () => { const [tableProps, { retry }] = useConsumerGroupsOffsetsForEntity(); - - return ; + return ( + + {tableProps.consumerGroupsTopics?.map(consumerGroup => ( + + ))} + + ); }; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx index 44693d5bbc..3bee4e2e88 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx @@ -20,21 +20,7 @@ import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; describe('useConsumerGroupOffsets', () => { - const entity: Entity = { - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'test', - annotations: { - 'kafka.apache.org/consumer-groups': 'consumer', - }, - }, - spec: { - owner: 'guest', - type: 'Website', - lifecycle: 'development', - }, - }; + let entity: Entity; const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( @@ -46,8 +32,107 @@ describe('useConsumerGroupOffsets', () => { const subject = () => renderHook(useConsumerGroupsForEntity, { wrapper }); - it('returns correct consumer group for annotation', async () => { + it('returns correct cluster and consumer group for annotation', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': 'prod/consumer', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; const { result } = subject(); - expect(result.current).toBe('consumer'); + expect(result.current).toStrictEqual([ + { + clusterId: 'prod', + consumerGroup: 'consumer', + }, + ]); + }); + + it('returns correct cluster and consumer group for multiple consumers', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': + 'prod/consumer,dev/another-consumer', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + const { result } = subject(); + expect(result.current).toStrictEqual([ + { clusterId: 'prod', consumerGroup: 'consumer' }, + { + clusterId: 'dev', + consumerGroup: 'another-consumer', + }, + ]); + }); + + it('returns correct cluster and consumer group for annotation with extra spaces', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': + ' prod/consumer , dev/another-consumer ', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + const { result } = subject(); + expect(result.current).toStrictEqual([ + { clusterId: 'prod', consumerGroup: 'consumer' }, + { + clusterId: 'dev', + consumerGroup: 'another-consumer', + }, + ]); + }); + + it('fails on missing cluster', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': 'dev/another,consumer', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + const { result } = subject(); + expect(() => result.current).toThrowError(); + expect(result.error).toStrictEqual( + new Error( + `Failed to parse kafka consumer group annotation: got "dev/another,consumer"`, + ), + ); }); }); diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts index 5003c2f5b5..522a6273fe 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts @@ -15,10 +15,29 @@ */ import { useEntity } from '@backstage/plugin-catalog'; +import { useMemo } from 'react'; import { KAFKA_CONSUMER_GROUP_ANNOTATION } from '../../constants'; export const useConsumerGroupsForEntity = () => { const { entity } = useEntity(); + const annotation = + entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION] ?? ''; - return entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION] ?? ''; + const consumerList = useMemo(() => { + return annotation.split(',').map(consumer => { + const [clusterId, consumerGroup] = consumer.split('/'); + + if (!clusterId || !consumerGroup) { + throw new Error( + `Failed to parse kafka consumer group annotation: got "${annotation}"`, + ); + } + return { + clusterId: clusterId.trim(), + consumerGroup: consumerGroup.trim(), + }; + }); + }, [annotation]); + + return consumerList; }; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx index 28f0ff7c28..b7e3a29e80 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx @@ -45,7 +45,7 @@ describe('useConsumerGroupOffsets', () => { metadata: { name: 'test', annotations: { - 'kafka.apache.org/consumer-groups': consumerGroupOffsets.consumerId, + 'kafka.apache.org/consumer-groups': `prod/${consumerGroupOffsets.consumerId}`, }, }, spec: { @@ -74,16 +74,24 @@ describe('useConsumerGroupOffsets', () => { renderHook(useConsumerGroupsOffsetsForEntity, { wrapper }); it('returns correct consumer group for annotation', async () => { + mockKafkaApi.getConsumerGroupOffsets.mockResolvedValue( + consumerGroupOffsets, + ); when(mockKafkaApi.getConsumerGroupOffsets) - .calledWith(consumerGroupOffsets.consumerId) + .calledWith('prod', consumerGroupOffsets.consumerId) .mockResolvedValue(consumerGroupOffsets); const { result, waitForNextUpdate } = subject(); await waitForNextUpdate(); const [tableProps] = result.current; - expect(tableProps.consumerGroup).toBe(consumerGroupOffsets.consumerId); - expect(tableProps.topics).toBe(consumerGroupOffsets.offsets); + expect(tableProps.consumerGroupsTopics).toStrictEqual([ + { + clusterId: 'prod', + consumerGroup: consumerGroupOffsets.consumerId, + topics: consumerGroupOffsets.offsets, + }, + ]); }); it('posts an error to the error api', async () => { diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.ts b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.ts index eb7e105989..8cb30040e9 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.ts +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.ts @@ -20,25 +20,35 @@ import { kafkaApiRef } from '../../api/types'; import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; export const useConsumerGroupsOffsetsForEntity = () => { - const consumerGroup = useConsumerGroupsForEntity(); + const consumers = useConsumerGroupsForEntity(); const api = useApi(kafkaApiRef); const errorApi = useApi(errorApiRef); - const { loading, value: topics, retry } = useAsyncRetry(async () => { + const { + loading, + value: consumerGroupsTopics, + retry, + } = useAsyncRetry(async () => { try { - const response = await api.getConsumerGroupOffsets(consumerGroup); - return response.offsets; + return await Promise.all( + consumers.map(async ({ clusterId, consumerGroup }) => { + const response = await api.getConsumerGroupOffsets( + clusterId, + consumerGroup, + ); + return { clusterId, consumerGroup, topics: response.offsets }; + }), + ); } catch (e) { errorApi.post(e); throw e; } - }, [api, errorApi, consumerGroup]); + }, [consumers, api, errorApi]); return [ { loading, - consumerGroup, - topics, + consumerGroupsTopics, }, { retry, diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 279cb8a5ba..08a3a66657 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-kubernetes-backend +## 0.2.6 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.2.5 ### Patch Changes diff --git a/plugins/kubernetes-backend/README.md b/plugins/kubernetes-backend/README.md index a9fdc14707..b724babca3 100644 --- a/plugins/kubernetes-backend/README.md +++ b/plugins/kubernetes-backend/README.md @@ -1,83 +1,19 @@ # Kubernetes Backend -WORK IN PROGRESS +This is the backend part of the Kubernetes plugin for Backstage. It is called by and responds to requests from the frontend [`@backstage/plugin-kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) plugin. -This is the backend part of the Kubernetes plugin. +It directly interfaces with the Kubernetes API control plane to obtain information about objects that will then be presented at the front end. -It responds to Kubernetes requests from the frontend. +## Introduction -## Configuration +See our announcement blog post [New Backstage feature: Kubernetes for Service Owners](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners) to learn more about the motivation behind developing the plugin. -### serviceLocatorMethod +## Setup & Configuration -This configures how to determine which clusters a component is running in. +This plugin must be explicitly added to a Backstage app, along with it's peer frontend plugin. -Currently, the only valid serviceLocatorMethod is: +The plugin requires configuration in the Backstage `app-config.yaml` to connect to a Kubernetes API control plane. -#### multiTenant +In addition, configuration of an entity's `catalog-info.yaml` helps identify which specific Kubernetes object(s) should be presented on a specific entity catalog page. -This configuration assumes that all components run on all the provided clusters. - -### clusterLocatorMethods - -This is used to determine where to retrieve cluster configuration from. - -Currently, the only valid serviceLocatorMethod is: - -#### config - -This clusterLocatorMethod will read cluster information in from config - -Example: - -```yaml -kubernetes: - serviceLocatorMethod: 'multiTenant' - clusterLocatorMethods: - - 'config' - clusters: - - url: http://127.0.0.1:9999 - name: minikube - serviceAccountToken: - authProvider: 'serviceAccount' - - url: http://127.0.0.2:9999 - name: gke-cluster-1 - authProvider: 'google' -``` - -##### clusters - -Used by the `config` `clusterLocatorMethods` to construct Kubernetes clients. - -###### url - -The base url to the Kubernetes control plane. Can be found by using the `Kubernetes master` result from running the `kubectl cluster-info` command. - -###### name - -A name to represent this cluster, this must be unique within the `clusters` array. Users will see this value in the Service Catalog Kubernetes plugin. - -###### authProvider - -This determines how the Kubernetes client authenticate with the Kubernetes cluster. Valid values are: - -| Value | Description | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | -| `google` | This will use a user's google auth token from the [google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | - -###### serviceAccount (optional) - -The service account token to be used when using the `authProvider`, `serviceAccount`. - -## RBAC - -The current RBAC permissions required are read-only cluster wide, for the following objects: - -- pods -- services -- configmaps -- deployments -- replicasets -- horizontalpodautoscalers -- ingresses +For more information, see the [formal documentation about the Kubernetes feature in Backstage](https://backstage.io/docs/features/kubernetes/overview). diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index ec0869d522..e69a82b738 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@aws-sdk/credential-provider-node": "^3.3.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@kubernetes/client-node": "^0.13.2", "@types/express": "^4.17.6", + "aws4": "^1.11.0", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", @@ -49,7 +51,8 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", + "@types/aws4": "^1.5.1", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts index 862cb6d2ce..31b6a2022b 100644 --- a/plugins/kubernetes-backend/schema.d.ts +++ b/plugins/kubernetes-backend/schema.d.ts @@ -20,8 +20,8 @@ export interface Config { clusters: { url: string; name: string; - serviceAccountToken: string; - authProvider: 'serviceAccount'; + serviceAccountToken: string | undefined; + authProvider: 'aws' | 'google' | 'serviceAccount'; }[]; }; } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts new file mode 100644 index 0000000000..4411658ee6 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const mockCredentialProvider = jest.fn(); +jest.mock('@aws-sdk/credential-provider-node', () => { + return { + defaultProvider: () => mockCredentialProvider, + }; +}); + +import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; + +describe('AwsIamKubernetesAuthTranslator tests', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('returns a signed url for aws credentials', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator(); + + mockCredentialProvider.mockImplementation(async () => { + // These credentials are not real. + // Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html + return { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretKeyId: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }; + }); + + const clusterDetails = await authTranslator.decorateClusterDetailsWithAuth({ + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + expect(clusterDetails.serviceAccountToken).toBeDefined(); + }); + + it('throws when unable to get aws credentials', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator(); + + mockCredentialProvider.mockImplementation(async () => { + throw new Error('not implemented'); + }); + + const promise = authTranslator.decorateClusterDetailsWithAuth({ + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + await expect(promise).rejects.toThrow('not implemented'); + }); +}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..3b5ea59f18 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { sign } from 'aws4'; +import { KubernetesAuthTranslator } from './types'; +import { ClusterDetails } from '..'; + +const base64 = (str: string) => + Buffer.from(str.toString(), 'binary').toString('base64'); +const prepend = (prep: string) => (str: string) => prep + str; +const replace = (search: string | RegExp, substitution: string) => ( + str: string, +) => str.replace(search, substitution); +const pipe = (fns: ReadonlyArray) => (thing: string): string => + fns.reduce((val, fn) => fn(val), thing); +const removePadding = replace(/=+$/, ''); +const makeUrlSafe = pipe([replace('+', '-'), replace('/', '_')]); + +export class AwsIamKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async getBearerToken(clusterName: string): Promise { + const credentialProvider = defaultProvider(); + const credentials = await credentialProvider(); + const request = { + host: `sts.amazonaws.com`, + path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`, + headers: { + 'x-k8s-aws-id': clusterName, + }, + signQuery: true, + }; + const signedRequest = sign(request, { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }); + + return pipe([ + (signed: any) => `https://${signed.host}${signed.path}`, + base64, + removePadding, + makeUrlSafe, + prepend('k8s-aws-v1.'), + ])(signedRequest); + } + + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + ): Promise { + const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + {}, + clusterDetails, + ); + + clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken( + clusterDetails.name, + ); + return clusterDetailsWithAuthToken; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts index 3b27eb012a..ec2f6ef120 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -18,6 +18,7 @@ import { KubernetesAuthTranslator } from './types'; import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator'; import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; +import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; describe('getKubernetesAuthTranslatorInstance', () => { const sut = KubernetesAuthTranslatorGenerator; @@ -29,6 +30,13 @@ describe('getKubernetesAuthTranslatorInstance', () => { expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); }); + it('can return an auth translator for aws auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'aws', + ); + expect(authTranslator instanceof AwsIamKubernetesAuthTranslator).toBe(true); + }); + it('can return an auth translator for serviceAccount auth', () => { const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( 'serviceAccount', diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts index f7cfc92112..4ae2aff35a 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -17,6 +17,7 @@ import { KubernetesAuthTranslator } from './types'; import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; +import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; export class KubernetesAuthTranslatorGenerator { static getKubernetesAuthTranslatorInstance( @@ -26,6 +27,9 @@ export class KubernetesAuthTranslatorGenerator { case 'google': { return new GoogleKubernetesAuthTranslator(); } + case 'aws': { + return new AwsIamKubernetesAuthTranslator(); + } case 'serviceAccount': { return new ServiceAccountKubernetesAuthTranslator(); } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index cf6576153b..86191c6d72 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -148,4 +148,4 @@ export interface KubernetesFetchError { export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http export type ClusterLocatorMethod = 'config'; -export type AuthProviderType = 'google' | 'serviceAccount'; +export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index c1f09038ba..f16728e1fe 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kubernetes +## 0.3.7 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- a6e3b9596: Improve error reporting for plugin misconfiguration. +- Updated dependencies [681111228] + - @backstage/plugin-kubernetes-backend@0.2.6 + ## 0.3.6 ### Patch Changes diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md index a343764b70..15dfd16b17 100644 --- a/plugins/kubernetes/README.md +++ b/plugins/kubernetes/README.md @@ -1,9 +1,29 @@ -# kubernetes +# Kubernetes -Welcome to the kubernetes plugin! +Welcome to the Backstage Kubernetes frontend plugin! + +This plugin exposes information about your entity-specific Kubernetes objects with a desire to provide value to the service owner, rather than just a Kubernetes cluster administrator. + +It will elevate the visibility of errors where identified, and provide drill down about the deployments, pods, and other objects for a service. + +It directly interfaces with the [Kubernetes Backend Plugin (`@backstage-plugin-kubernetes-backend`)](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend). _This plugin was created through the Backstage CLI_ +## Introduction + +See our announcement blog post [New Backstage feature: Kubernetes for Service Owners](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners) to learn more about the motivation behind developing the plugin. + +## Setup & Configuration + +This plugin must be explicitly added to a Backstage app, along with it's peer backend plugin. + +It requires configuration in the Backstage `app-config.yaml` to connect to a Kubernetes API control plane. + +In addition, configuration of an entity's `catalog-info.yaml` helps identify which specific Kubernetes object(s) should be presented on a specific entity catalog page. + +For more information, see the [formal documentation about the Kubernetes feature in Backstage](https://backstage.io/docs/features/kubernetes/overview). + ## Getting started Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/kubernetes](http://localhost:3000/kubernetes). @@ -11,40 +31,3 @@ Your plugin has been added to the example app in this repository, meaning you'll You can also serve the plugin in isolation by running `yarn start` in the plugin directory. This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. - -## Surfacing your Kubernetes components as part of an entity - -There are two ways to surface your kubernetes components as part of an entity. -The label selector takes precedence over the annotation/service id. - -### Common `backstage.io/kubernetes-id` label - -#### Adding the entity annotation - -In order for Backstage to detect that an entity has Kubernetes components, -the following annotation should be added to the entity. - -```yaml -annotations: - 'backstage.io/kubernetes-id': dice-roller -``` - -#### Labeling Kubernetes components - -In order for Kubernetes components to show up in the service catalog -as a part of an entity, Kubernetes components must be labeled with the following label: - -```yaml -'backstage.io/kubernetes-id': -``` - -### label selector query annotation - -#### Adding a label selector query annotation - -You can write your own custom label selector query that backstage will use to lookup the objects (similar to `kubectl --selector="your query here"`) -review the documentation [here](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) for more info - -```yaml -'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' -``` diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 96d700c3bb..1d985c0d10 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.3.6", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-kubernetes-backend": "^0.2.5", + "@backstage/plugin-kubernetes-backend": "^0.2.6", "@backstage/theme": "^0.2.2", "@kubernetes/client-node": "^0.13.2", "@material-ui/core": "^4.11.0", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kubernetes/schema.d.ts b/plugins/kubernetes/schema.d.ts index f3f3c98f8c..39598edb39 100644 --- a/plugins/kubernetes/schema.d.ts +++ b/plugins/kubernetes/schema.d.ts @@ -35,11 +35,11 @@ export interface Config { /** * @visibility secret */ - serviceAccountToken: string; + serviceAccountToken: string | undefined; /** * @visibility frontend */ - authProvider: 'serviceAccount'; + authProvider: 'aws' | 'google' | 'serviceAccount'; }[]; }; } diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 162da40fd0..3463842bc5 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 57f146521b..2f43c9cbeb 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/package.json b/plugins/org/package.json index 976caffdbb..fbb3bd1afb 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 174f62d5e7..0e87bdc2d7 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -44,7 +44,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 94825e8691..ae828cb2ec 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -28,7 +28,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -42,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 99f7931c2b..29b629de9b 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 39a2b17369..522f5a3b4c 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index c37ca53a82..f3a21709db 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index bcf43cf229..353e2321cf 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/plugin-scaffolder-backend +## 0.5.2 + +### Patch Changes + +- 26a3a6cf0: Honor the branch ref in the url when cloning. + + This fixes a bug in the scaffolder prepare stage where a non-default branch + was specified in the scaffolder URL but the default branch was cloned. + For example, even though the `other` branch is specified in this example, the + `master` branch was actually cloned: + + ```yaml + catalog: + locations: + - type: url + target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + ``` + + This also fixes a 404 in the prepare stage for GitLab URLs. + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + ## 0.5.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index cd7dab3444..57b62be2cd 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.5.1", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.0", + "@backstage/integration": "^0.3.1", "@gitbeaker/core": "^28.0.2", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", @@ -48,7 +48,7 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "globby": "^11.0.0", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", @@ -59,7 +59,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/fs-extra": "^9.0.1", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 8dd72c35e9..89ca8e2f8f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -45,7 +45,7 @@ describe('AzurePreparer', () => { metadata: { annotations: { [LOCATION_ANNOTATION]: - 'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', + 'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml&version=GBmaster', }, name: 'graphql-starter', title: 'GraphQL Service', @@ -112,6 +112,7 @@ describe('AzurePreparer', () => { url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', dir: expect.any(String), + ref: 'master', }); }); @@ -124,6 +125,7 @@ describe('AzurePreparer', () => { url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', dir: expect.any(String), + ref: 'master', }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index 8b204fa799..67f089965d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -42,6 +42,7 @@ export class AzurePreparer implements PreparerBase { const parsedGitLocation = parseGitUrl(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); @@ -63,6 +64,7 @@ export class AzurePreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index d2fdedccf2..69ab77c32e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -89,6 +89,7 @@ describe('BitbucketPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', dir: expect.any(String), + ref: expect.any(String), }); }); @@ -113,6 +114,7 @@ describe('BitbucketPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', dir: expect.any(String), + ref: expect.any(String), }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 5020a5f658..04f48e3005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -49,15 +49,15 @@ export class BitbucketPreparer implements PreparerBase { const logger = opts.logger; const templateId = template.metadata.name; - const repo = parseGitUrl(location); - const repositoryCheckoutUrl = repo.toString('https'); - + const parsedGitLocation = parseGitUrl(location); + const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); const templateDirectory = path.join( - `${path.dirname(repo.filepath)}`, + `${path.dirname(parsedGitLocation.filepath)}`, template.spec.path ?? '.', ); @@ -73,6 +73,7 @@ export class BitbucketPreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index 3221c5d4ca..5ee8ef94aa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -89,6 +89,7 @@ describe('GitHubPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), + ref: expect.any(String), }); }); @@ -100,6 +101,7 @@ describe('GitHubPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), + ref: expect.any(String), }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index cfeff454e7..02cbb1c5cd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -42,6 +42,7 @@ export class GithubPreparer implements PreparerBase { const parsedGitLocation = parseGitUrl(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); @@ -63,6 +64,7 @@ export class GithubPreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 19cbf793e4..0b77fd813b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -87,8 +87,9 @@ describe('GitLabPreparer', () => { await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git', dir: expect.any(String), + ref: expect.any(String), }); }); @@ -111,8 +112,9 @@ describe('GitLabPreparer', () => { await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git', dir: expect.any(String), + ref: expect.any(String), }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 1fca2ff0f9..4368c1c242 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -41,7 +41,9 @@ export class GitlabPreparer implements PreparerBase { const templateId = template.metadata.name; const parsedGitLocation = parseGitUrl(location); + parsedGitLocation.git_suffix = true; const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); @@ -61,6 +63,7 @@ export class GitlabPreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 6d435a144c..11a9cfb75c 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-scaffolder +## 0.4.1 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + ## 0.4.0 ### Minor Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 3c98972aa8..d81d176232 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.4.0", + "version": "0.4.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -40,7 +40,7 @@ "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -51,7 +51,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/search/package.json b/plugins/search/package.json index 41e295aba4..9409a416a0 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/catalog-model": "^0.7.0", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 0bcc49ce35..52343ad332 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,7 +46,7 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 375b5a8312..c3be5e7415 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-sonarqube +## 0.1.9 + +### Patch Changes + +- 49a67732f: Ask the SonarQube server for all support metrics prior to querying them for a project. + ## 0.1.8 ### Patch Changes diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 0f7eca0f9a..b6701f9849 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sonarqube/src/api/SonarQubeClient.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts index 1b3b5c5709..e5cbc2ccdf 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -29,7 +29,31 @@ describe('SonarQubeClient', () => { const mockBaseUrl = 'http://backstage:9191/api/proxy'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - const setupHandlers = () => { + const setupHandlers = ( + metricKeys = [ + 'alert_status', + 'bugs', + 'reliability_rating', + 'vulnerabilities', + 'security_rating', + 'security_hotspots_reviewed', + 'security_review_rating', + 'code_smells', + 'sqale_rating', + 'coverage', + 'duplicated_lines_density', + ], + ) => { + server.use( + rest.get(`${mockBaseUrl}/sonarqube/metrics/search`, (_, res, ctx) => { + return res( + ctx.json({ + metrics: metricKeys.map(k => ({ key: k })), + }), + ); + }), + ); + server.use( rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe('component=our-service'); @@ -46,8 +70,9 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Csecurity_hotspots_reviewed%2Csecurity_review_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density', + `projectKeys=our-service&metricKeys=${metricKeys.join('%2C')}`, ); + return res( ctx.json({ measures: [ @@ -111,7 +136,7 @@ describe('SonarQubeClient', () => { value: '1.0', component: 'our-service', }, - ], + ].filter(m => metricKeys.includes(m.metric)), } as MeasuresWrapper), ); }), @@ -187,4 +212,32 @@ describe('SonarQubeClient', () => { 'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list', ); }); + + it('should only request selected metrics', async () => { + setupHandlers(['alert_status', 'bugs']); + + const client = new SonarQubeClient({ + discoveryApi, + baseUrl: 'http://a.instance.local', + }); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual( + expect.objectContaining({ + lastAnalysis: '2020-01-01T00:00:00Z', + metrics: { + alert_status: 'OK', + bugs: '2', + }, + projectUrl: 'http://a.instance.local/dashboard?id=our-service', + }) as FindingSummary, + ); + expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( + 'http://a.instance.local/project/issues?id=our-service&types=CODE_SMELL&resolved=false', + ); + expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( + 'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list', + ); + }); }); diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts index 061919dd41..35035b33c5 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -43,6 +43,13 @@ export class SonarQubeClient implements SonarQubeApi { return undefined; } + private async getSupportedMetrics(): Promise { + const result = await this.callApi<{ metrics: Array<{ key: string }> }>( + 'metrics/search', + ); + return result?.metrics?.map(m => m.key) ?? []; + } + async getFindingSummary( componentKey?: string, ): Promise { @@ -71,10 +78,16 @@ export class SonarQubeClient implements SonarQubeApi { duplicated_lines_density: undefined, }; + // select the metrics that are supported by the SonarQube instance + const supportedMetrics = await this.getSupportedMetrics(); + const metricKeys = Object.keys(metrics).filter(m => + supportedMetrics.includes(m), + ); + const measures = await this.callApi( - `measures/search?projectKeys=${componentKey}&metricKeys=${Object.keys( - metrics, - ).join(',')}`, + `measures/search?projectKeys=${componentKey}&metricKeys=${metricKeys.join( + ',', + )}`, ); if (!measures) { return undefined; diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index d9f627ac8c..9014d52931 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index eb3d70dee7..e6af442d20 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/techdocs-common": "^0.3.5", + "@backstage/techdocs-common": "^0.3.6", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", @@ -45,7 +45,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index ac439b8623..2c1e0e5911 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -33,10 +33,10 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", - "@backstage/techdocs-common": "^0.3.5", + "@backstage/techdocs-common": "^0.3.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -49,7 +49,7 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 2e6f5e3e95..78790a891e 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 440e8c2d47..9482e34743 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/yarn.lock b/yarn.lock index 28602e734f..c40b1ab675 100644 --- a/yarn.lock +++ b/yarn.lock @@ -278,6 +278,15 @@ "@aws-sdk/property-provider" "3.1.0" tslib "^1.8.0" +"@aws-sdk/credential-provider-env@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.3.0.tgz#7930e504a7a79ab98a9fd34befc5c84b8c4df679" + integrity sha512-kyqZMlGdH/05IhuXLBUXtj5+hhRfYiHFcJLc3ts/uiwCixswVHPAYHgyWm9ajFkmWtpz6ih+0LoYryhPbYu01A== + dependencies: + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + "@aws-sdk/credential-provider-imds@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.1.0.tgz#33d48753dc00bddce79d2aa8076a7cb5bf8562df" @@ -286,6 +295,15 @@ "@aws-sdk/property-provider" "3.1.0" tslib "^1.8.0" +"@aws-sdk/credential-provider-imds@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.3.0.tgz#ff0cf5489853c16d23fc99d7bae425587e836c40" + integrity sha512-Cx0YMnO/ScGQVDns006bLbqOxNURGN2Xm21bCY0l0ZUJCdJ2va1/9q1rljDyw2KvdzZNQVRQII3uUgj/Oq/K+g== + dependencies: + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + "@aws-sdk/credential-provider-ini@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.1.0.tgz#1a6cf9ab9fa1450d4472b9e371099b0c0283349b" @@ -295,6 +313,16 @@ "@aws-sdk/shared-ini-file-loader" "3.1.0" tslib "^1.8.0" +"@aws-sdk/credential-provider-ini@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.3.0.tgz#55fe8f391b72d30e650ba8bc680e82bbeacbbfe5" + integrity sha512-zawNFJoasXiaV5n0H3/KNOi7mAZ7mHpG1+nBEkoWhZ31lIUM9+heGPcxKCbf/pMQjiOebUqL1OpWe4uSWxIVMw== + dependencies: + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/shared-ini-file-loader" "3.1.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + "@aws-sdk/credential-provider-node@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.1.0.tgz#89bc8803752a3e580c6f2410306c7edad6be7fa2" @@ -307,6 +335,19 @@ "@aws-sdk/property-provider" "3.1.0" tslib "^1.8.0" +"@aws-sdk/credential-provider-node@^3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.3.0.tgz#5c97323fa7b23590070d06aa7b1be8d93b2bf4be" + integrity sha512-PPBNzPq8fHk9dEQTTE4iJi6ZWtmo057Lc+I8Rlzmvz6NthK9iKiU819tfaxVBb6ZR7bLP0BuDiCi4G1lD+rQnQ== + dependencies: + "@aws-sdk/credential-provider-env" "3.3.0" + "@aws-sdk/credential-provider-imds" "3.3.0" + "@aws-sdk/credential-provider-ini" "3.3.0" + "@aws-sdk/credential-provider-process" "3.3.0" + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + "@aws-sdk/credential-provider-process@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.1.0.tgz#ff817b29a9760c463b77be3ce49375eaeb753ef3" @@ -317,6 +358,17 @@ "@aws-sdk/shared-ini-file-loader" "3.1.0" tslib "^1.8.0" +"@aws-sdk/credential-provider-process@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.3.0.tgz#9de0984bd6dd0f5e40cff3672d7dd19e8cd43074" + integrity sha512-7oOF1j6ydUq43P3SsasiIpbxMKCmT0C+XwggHTGiVxNtX+QZiH1vdMf8otA7puLEey0iY5wTAIEcZhC6HenojA== + dependencies: + "@aws-sdk/credential-provider-ini" "3.3.0" + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/shared-ini-file-loader" "3.1.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + "@aws-sdk/eventstream-marshaller@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/eventstream-marshaller/-/eventstream-marshaller-3.1.0.tgz#65a217e37abcaa162276ccb1d4487d42431d1534" @@ -649,6 +701,14 @@ dependencies: tslib "^1.8.0" +"@aws-sdk/property-provider@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.3.0.tgz#49979cb1a3e5562d51807c7403c5fd48cb9f2cdc" + integrity sha512-JTyjtXVNhFczL9IfgwXD55F6DqXL50PhfZxFW92t5dDj5VtWpOL74BbuxHQxHBgnQv1FKLr6N9cr7gfXWexDug== + dependencies: + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + "@aws-sdk/protocol-http@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.1.0.tgz#7da0ebcf02a40a8300f3bd52f9206f25fdf1ca7f" @@ -2418,6 +2478,7 @@ "@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" @@ -2429,6 +2490,7 @@ "@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" @@ -2443,6 +2505,7 @@ "@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/prop-types" "^15.7.3" "@types/react" "^16.9" @@ -4774,9 +4837,9 @@ once "^1.4.0" "@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.11", "@octokit/request@^5.4.12": - version "5.4.12" - resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.12.tgz#b04826fa934670c56b135a81447be2c1723a2ffc" - integrity sha512-MvWYdxengUWTGFpfpefBBpVmmEYfkwMoxonIB3sUGp5rhdgwjXL1ejo6JbgzG/QD9B/NYt/9cJX1pxXeSIUCkg== + version "5.4.13" + resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.13.tgz#eec5987b3e96f984fc5f41967e001170c6d23a18" + integrity sha512-WcNRH5XPPtg7i1g9Da5U9dvZ6YbTffw9BN2rVezYiE7couoSyaRsw0e+Tl8uk1fArHE7Dn14U7YqUDy59WaqEw== dependencies: "@octokit/endpoint" "^6.0.1" "@octokit/request-error" "^2.0.0" @@ -5241,17 +5304,17 @@ global "^4.3.2" regenerator-runtime "^0.13.7" -"@storybook/addons@6.1.14", "@storybook/addons@^6.1.11": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.14.tgz#2b81304bbe696923df95cdcf85cfc592d10f4065" - integrity sha512-HlpmV7aejp/MeW8bo/WKME3i71gi0men9qcwoovjDjnSF6jXoNLT336a5udKXdHqYSZgzdyURlgLtilCWkWaJQ== +"@storybook/addons@6.1.15", "@storybook/addons@^6.1.11": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.15.tgz#09eb8d962f58bd20b4ac2f83b515831c83226352" + integrity sha512-ENyHapLFOG93VaoQXPX8O3IWjLRyVBox9C9P20LMruKX/SfXAXx20qsoAWKKPGssopyOin17aoQX9pj+lFmCZQ== dependencies: - "@storybook/api" "6.1.14" - "@storybook/channels" "6.1.14" - "@storybook/client-logger" "6.1.14" - "@storybook/core-events" "6.1.14" - "@storybook/router" "6.1.14" - "@storybook/theming" "6.1.14" + "@storybook/api" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" + "@storybook/router" "6.1.15" + "@storybook/theming" "6.1.15" core-js "^3.0.1" global "^4.3.2" regenerator-runtime "^0.13.7" @@ -5281,20 +5344,20 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/api@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.14.tgz#20035dd336aba1c5a0f8c83c8c14a2edaf4db891" - integrity sha512-gWcC/xEW8HL5DsocLujHBUdoNsl4YW1Zx1Y4SBbLCyrhj8v4JudJpylwJpOUBDe/GESXq1zqvNKvUPtI8DQNyw== +"@storybook/api@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.15.tgz#285ba42f7a8efcd3bd0e586a5e978487d826fbb4" + integrity sha512-C4D08e2ZbSe62nNKtmh9YBraoWb2j6Chw8VCkuj91kuKHh3YDNc1gjj5Fi+KYZwIcy0EllzW3RFQs+YR1/Vg1g== dependencies: "@reach/router" "^1.3.3" - "@storybook/channels" "6.1.14" - "@storybook/client-logger" "6.1.14" - "@storybook/core-events" "6.1.14" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" "@storybook/csf" "0.0.1" - "@storybook/router" "6.1.14" + "@storybook/router" "6.1.15" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.1.14" - "@types/reach__router" "^1.3.5" + "@storybook/theming" "6.1.15" + "@types/reach__router" "^1.3.7" core-js "^3.0.1" fast-deep-equal "^3.1.1" global "^4.3.2" @@ -5319,14 +5382,14 @@ qs "^6.6.0" telejson "^5.0.2" -"@storybook/channel-postmessage@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.14.tgz#41f3115895010dad9fb30f4ac381e4f904b1e50c" - integrity sha512-If83dXXW9mKIRuvuWhWa/zkEw/F0FDgikp33x8436J3rWCh3recp27kffFRrKG0YDMpFSk/Ci5G47E9zn9SCjw== +"@storybook/channel-postmessage@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.15.tgz#80ea2346d18496f9710dd7f87fd2a9eca46ef36f" + integrity sha512-Es4B5zpLrW28KSbY8FhGVEDgUnKspJ7wPuJyKExUpZ5L9w52RkTD6lRnVPzLUfoQ4luPsExy5fiuo878/Wc9ag== dependencies: - "@storybook/channels" "6.1.14" - "@storybook/client-logger" "6.1.14" - "@storybook/core-events" "6.1.14" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" core-js "^3.0.1" global "^4.3.2" qs "^6.6.0" @@ -5341,10 +5404,10 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/channels@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.14.tgz#c479190ebb853a603f3ed90fc470534a02eb46eb" - integrity sha512-vP19IB2FXj8SiFbQ9ETljEBienL+KRMLgMzz3Ta3nZj/OfjJJbIuj42ZfexQGV4mS0Bo+OW+qT7VMIY6fulnFw== +"@storybook/channels@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.15.tgz#22bb06a671a5ae09d2537bcf63aaf90d7f6b9f6b" + integrity sha512-HIKHDeL/0BDk9a7xc2PLiFFoHjUMKUd2djhUGdeKgdKqoWejp4JJ60fI68+2QuSRbkB8k+rAwmuWJzV7EfB5fg== dependencies: core-js "^3.0.1" ts-dedent "^2.0.0" @@ -5374,16 +5437,16 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.14.tgz#6daf56743cc72e13f05fff3d2ac554897cc9f9fd" - integrity sha512-pIDSlS48bhJdtgNg7sXV1NmLJtB0ebRHJI9htIiqtL7EGQenb4+Bbwflhj1j51OEkuM+bQsAAZxq5deiUQEGVw== +"@storybook/client-api@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.15.tgz#8f8ead111459b94621571bdb2276f8a0aace17b1" + integrity sha512-iwuDlgNdB6Y4OidlhWPob3tEIax9taymdKEe9by4rLJ3nfXu7viHcvCAjN24oI4NFW3NZsmtqJotgftRYk0r1Q== dependencies: - "@storybook/addons" "6.1.14" - "@storybook/channel-postmessage" "6.1.14" - "@storybook/channels" "6.1.14" - "@storybook/client-logger" "6.1.14" - "@storybook/core-events" "6.1.14" + "@storybook/addons" "6.1.15" + "@storybook/channel-postmessage" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" "@storybook/csf" "0.0.1" "@types/qs" "^6.9.0" "@types/webpack-env" "^1.15.3" @@ -5406,10 +5469,10 @@ core-js "^3.0.1" global "^4.3.2" -"@storybook/client-logger@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.14.tgz#216b9c1332ffa3a3473dad837780a3b14f686bae" - integrity sha512-NSO8nVsp6o0eoQ1Drlu66KXpl6DPuq02Kj8AhttGzvqSYB50SV4CV+wceBcg77tIVu5QmQ+71hAEVXhx7sjRHA== +"@storybook/client-logger@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.15.tgz#b558d6ecbee82c038d684717d8c598eaa4a9324d" + integrity sha512-lUpatG8SxzrUapWMsIPWiR+5qRVT5ebn8tGHQeBeRHXbdmEqyq5DOlrotLUemkA5nNTCs1pMFNvKSpCHznG+fg== dependencies: core-js "^3.0.1" global "^4.3.2" @@ -5440,15 +5503,15 @@ react-textarea-autosize "^8.1.1" ts-dedent "^2.0.0" -"@storybook/components@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.14.tgz#4ea47edfa0a3e4a26882aa5a1eb90c1ec86e6f71" - integrity sha512-Nxsp/9o1tqfY8s6RBWNHyM03A5D9k56Kr/4VNa++CbDrz1+TIxpYlDgS4sllUlXyvICLfk3sUtg3KS5CPl2iZA== +"@storybook/components@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.15.tgz#b4a2af23ee6b9cba4c255191eae3d3463e29bfb7" + integrity sha512-lPbA/zyBfctdlpDhRTcRFLWlZPJ3PB4+wI0FUvYs69iG3/bNbQPYu8vRmNhCZOsaGt+b+dik4Tfcth8Bu+eQug== dependencies: "@popperjs/core" "^2.5.4" - "@storybook/client-logger" "6.1.14" + "@storybook/client-logger" "6.1.15" "@storybook/csf" "0.0.1" - "@storybook/theming" "6.1.14" + "@storybook/theming" "6.1.15" "@types/overlayscrollbars" "^1.9.0" "@types/react-color" "^3.0.1" "@types/react-syntax-highlighter" "11.0.4" @@ -5473,17 +5536,17 @@ dependencies: core-js "^3.0.1" -"@storybook/core-events@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.14.tgz#a3165e32cefd6be7326bbad4b8140653bdfa0426" - integrity sha512-tpM3VDvzqgRY7S17CRglgt1625rxNoyEwrLQiNcZkUPyO0rpaacPqVEbPCtcTmUeboI1bLdnSQIjT9B0/Y2Pww== +"@storybook/core-events@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.15.tgz#f66e30cbed8afdb8df2254d2aa47fe139e641c60" + integrity sha512-2sz02hdGZshanoq83jaB+goAcapVEWrxe+RJZn/gu2OymlEioWNjPPtOVGgi5DNIiJFnYvc66adayNwX39+tDA== dependencies: core-js "^3.0.1" -"@storybook/core@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/core/-/core-6.1.14.tgz#17e724a5b94d6e1bb557e213b8176660d2d14762" - integrity sha512-lHKZmfLAo2VGtF/yrZkkWMYgmFRNKbzIDxYJGp8USyUQyTfEpz2qqJlBdoD6rxr1hFPM2954tIKwh8iPhT2PFQ== +"@storybook/core@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/core/-/core-6.1.15.tgz#7ff8c314d3857497bf2e26c69a1fa93ef37301aa" + integrity sha512-mQeKAXcowUwF+pOdWZEFwb5M6sz4yv5cOv1vTci3/1pMmB8QpYlH+P61p4lsRO17Vlak70h18TworPka/4+mhA== dependencies: "@babel/core" "^7.12.3" "@babel/plugin-proposal-class-properties" "^7.12.1" @@ -5507,20 +5570,20 @@ "@babel/preset-react" "^7.12.1" "@babel/preset-typescript" "^7.12.1" "@babel/register" "^7.12.1" - "@storybook/addons" "6.1.14" - "@storybook/api" "6.1.14" - "@storybook/channel-postmessage" "6.1.14" - "@storybook/channels" "6.1.14" - "@storybook/client-api" "6.1.14" - "@storybook/client-logger" "6.1.14" - "@storybook/components" "6.1.14" - "@storybook/core-events" "6.1.14" + "@storybook/addons" "6.1.15" + "@storybook/api" "6.1.15" + "@storybook/channel-postmessage" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-api" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/components" "6.1.15" + "@storybook/core-events" "6.1.15" "@storybook/csf" "0.0.1" - "@storybook/node-logger" "6.1.14" - "@storybook/router" "6.1.14" + "@storybook/node-logger" "6.1.15" + "@storybook/router" "6.1.15" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.1.14" - "@storybook/ui" "6.1.14" + "@storybook/theming" "6.1.15" + "@storybook/ui" "6.1.15" "@types/glob-base" "^0.3.0" "@types/micromatch" "^4.0.1" "@types/node-fetch" "^2.5.4" @@ -5594,10 +5657,10 @@ dependencies: lodash "^4.17.15" -"@storybook/node-logger@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.1.14.tgz#e5294f986e3ec5c67b2738895b9d16c9a2b667fa" - integrity sha512-3jrw7coAwFXZu4qK1vm54bCPhNRvxjG+7jISbhhocDoNIv0nLWL3+tJyrC5/k/XHQiUlLkhEzpMaASADmkttNw== +"@storybook/node-logger@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.1.15.tgz#fcf786d3a323feb6821e40e26f98a513a60d1a79" + integrity sha512-lrO0ei3W7BRci2iUkWTr/rXgHkzxwZTrlkx0iBzbQQRy7K1AJ9bjzhurCH9B8C9XGLmn60LXT81RWD3iCLZjcw== dependencies: "@types/npmlog" "^4.1.2" chalk "^4.0.0" @@ -5606,16 +5669,16 @@ pretty-hrtime "^1.0.3" "@storybook/react@^6.1.11": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/react/-/react-6.1.14.tgz#436e9b90096b1d7c83f7f073b5baf47212b2e425" - integrity sha512-M99wHjc/5z+Wz1FdFaScVs6dyAi/6PdcIx5Fyip6Qd8aKwm1XyYoOMql5Vu3Cf560feDYCKS4phzyEZ7EJy+EQ== + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/react/-/react-6.1.15.tgz#e17d00b05b8980ad381ba701805309ed46d1fdcd" + integrity sha512-7WoYLOZuAlzgQsL9oy4JCr9NcB4NBCuxslPSncN5l/7ewGXgfVXTAOMOfw+EVNrtUeVJU2fC8gFiHVl0SJpTZw== dependencies: "@babel/preset-flow" "^7.12.1" "@babel/preset-react" "^7.12.1" "@pmmmwh/react-refresh-webpack-plugin" "^0.4.2" - "@storybook/addons" "6.1.14" - "@storybook/core" "6.1.14" - "@storybook/node-logger" "6.1.14" + "@storybook/addons" "6.1.15" + "@storybook/core" "6.1.15" + "@storybook/node-logger" "6.1.15" "@storybook/semver" "^7.3.2" "@types/webpack-env" "^1.15.3" babel-plugin-add-react-displayname "^0.0.5" @@ -5644,13 +5707,13 @@ memoizerific "^1.11.3" qs "^6.6.0" -"@storybook/router@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.14.tgz#f6aef8c9dabf19bf06dddd80907e66369261fdde" - integrity sha512-rMaUCYzgfVLwFWo3A1Q/weSv8FBqCLmHY+3+t6ao7OV6NYjR0XgLKRzHrXq1uYdbMxWeIKhN2tIt/LR43bmDjQ== +"@storybook/router@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.15.tgz#e0cd7440a2ddc9b265e506b1cb590d3eeab56476" + integrity sha512-HlxDkGpiTSxXCJuqRoZ9Viq6Y/h/7efI8LPhhopr50qWRBTh/PEQzDqWBXG3sj8ISmi9GyUaTSAuqRwdA3lJQQ== dependencies: "@reach/router" "^1.3.3" - "@types/reach__router" "^1.3.5" + "@types/reach__router" "^1.3.7" core-js "^3.0.1" global "^4.3.2" memoizerific "^1.11.3" @@ -5699,15 +5762,15 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" -"@storybook/theming@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.14.tgz#fecb66cab22d3b3218b4a98a9c210eb8a7be91e8" - integrity sha512-S+t30y4FqBTXWoVr+dtxVJ/ywiQGHBclBd9aUunbdCV4mMFra5InNo2CWn+RJlNEauLZ93gRIEzSFchIbzLk1A== +"@storybook/theming@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.15.tgz#01083ab89904dd959429b0b3fd1c76bd0ecc59ef" + integrity sha512-88IdYaPzp4NMKf/GKBrPggxD6/d/lkdQ4SNowXxN9g9eONd9M7HtTbjuJGRCbGMJ52xGcbpj2exEnAqKQ2iodA== dependencies: "@emotion/core" "^10.1.1" "@emotion/is-prop-valid" "^0.8.6" "@emotion/styled" "^10.0.23" - "@storybook/client-logger" "6.1.14" + "@storybook/client-logger" "6.1.15" core-js "^3.0.1" deep-object-diff "^1.1.0" emotion-theming "^10.0.19" @@ -5717,21 +5780,21 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" -"@storybook/ui@6.1.14": - version "6.1.14" - resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.1.14.tgz#766d696480ee6f6a5a0454ccb2f101c38a0eb9d2" - integrity sha512-DTW2TM05jTMKxh8LzUGk3g5a528PgJxrtgODFU6zzwSg2+LwdmSDtd1HAxopt2vpfTyQyX+6WN2H+lMNwfQTAQ== +"@storybook/ui@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.1.15.tgz#a0f6c49fcf81cf172cd2de4c8dba2be1296891f6" + integrity sha512-quyhJWlOxhk95he7s5/TSYM3eEsaz3s4+98kUZE6r3ssME8u6zDvqa/qa6EWs5/nvZ2V3+12efIzCNbiiT3v3g== dependencies: "@emotion/core" "^10.1.1" - "@storybook/addons" "6.1.14" - "@storybook/api" "6.1.14" - "@storybook/channels" "6.1.14" - "@storybook/client-logger" "6.1.14" - "@storybook/components" "6.1.14" - "@storybook/core-events" "6.1.14" - "@storybook/router" "6.1.14" + "@storybook/addons" "6.1.15" + "@storybook/api" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/components" "6.1.15" + "@storybook/core-events" "6.1.15" + "@storybook/router" "6.1.15" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.1.14" + "@storybook/theming" "6.1.15" "@types/markdown-to-jsx" "^6.11.0" copy-to-clipboard "^3.0.8" core-js "^3.0.1" @@ -6183,6 +6246,11 @@ resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A== +"@types/aws4@^1.5.1": + version "1.5.1" + resolved "https://registry.npmjs.org/@types/aws4/-/aws4-1.5.1.tgz#361fadab198a030ab398269183ae3fa86e958ed9" + integrity sha1-Nh+tqxmKAwqzmCaRg64/qG6Vjtk= + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.9" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d" @@ -6828,10 +6896,10 @@ resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== -"@types/mini-css-extract-plugin@^0.9.1": - version "0.9.1" - resolved "https://registry.npmjs.org/@types/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.1.tgz#d4bdde5197326fca039d418f4bdda03dc74dc451" - integrity sha512-+mN04Oszdz9tGjUP/c1ReVwJXxSniLd7lF++sv+8dkABxVNthg6uccei+4ssKxRHGoMmPxdn7uBdJWONSJGTGQ== +"@types/mini-css-extract-plugin@^1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@types/mini-css-extract-plugin/-/mini-css-extract-plugin-1.2.2.tgz#e6031da8d60777b3da3f5b4daf285437d7b6580b" + integrity sha512-EoHBJ4rcrd5j7weAFE4yU1gxedx53EFCWKso03G7DW0h2YvtwjKYz/NnuFHudcQDI1HpTLqoQFTwEgfJxygYCw== dependencies: "@types/webpack" "*" @@ -7028,6 +7096,13 @@ "@types/history" "*" "@types/react" "*" +"@types/reach__router@^1.3.7": + version "1.3.7" + resolved "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.7.tgz#de8ab374259ae7f7499fc1373b9697a5f3cd6428" + integrity sha512-cyBEb8Ef3SJNH5NYEIDGPoMMmYUxROatuxbICusVRQIqZUB85UCt6R2Ok60tKS/TABJsJYaHyNTW3kqbpxlMjg== + dependencies: + "@types/react" "*" + "@types/react-color@^3.0.1": version "3.0.4" resolved "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz#c63daf012ad067ac0127bdd86725f079d02082bd" @@ -7898,6 +7973,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" + integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + alphanum-sort@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -8602,6 +8687,11 @@ aws-sign2@~0.7.0: resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= +aws4@^1.11.0: + version "1.11.0" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + aws4@^1.8.0: version "1.9.1" resolved "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" @@ -10710,9 +10800,9 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5: - version "3.6.5" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== + version "3.8.3" + resolved "https://registry.npmjs.org/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0" + integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q== core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: version "2.6.11" @@ -14103,10 +14193,10 @@ git-url-parse@^11.1.2: dependencies: git-up "^4.0.0" -git-url-parse@^11.4.3: - version "11.4.3" - resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.3.tgz#1610284edf1f14964180f5b3399ec68b692cfd87" - integrity sha512-LZTTk0nqJnKN48YRtOpR8H5SEfp1oM2tls90NuZmBxN95PnCvmuXGzqQ4QmVirBgKx2KPYfPGteX3/raWjKenQ== +git-url-parse@^11.4.4: + version "11.4.4" + resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.4.tgz#5d747debc2469c17bc385719f7d0427802d83d77" + integrity sha512-Y4o9o7vQngQDIU9IjyCmRJBin5iYjI5u9ZITnddRZpD7dcCFQj2sL2XuMNbLRE4b4B/4ENPsp2Q8P44fjAZ0Pw== dependencies: git-up "^4.0.0" @@ -16870,6 +16960,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -19125,9 +19220,9 @@ node-request-interceptor@^0.5.1: headers-utils "^1.2.0" nodemon@^2.0.2: - version "2.0.6" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d" - integrity sha512-4I3YDSKXg6ltYpcnZeHompqac4E6JeAMpGm8tJnB9Y3T0ehasLa4139dJOcCrB93HHrUMsCrKtoAlXTqT5n4AQ== + version "2.0.7" + resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.7.tgz#6f030a0a0ebe3ea1ba2a38f71bf9bab4841ced32" + integrity sha512-XHzK69Awgnec9UzHr1kc8EomQh4sjTQ8oRf8TsGrSmHDx9/UmiGG9E/mM3BuTfNeFwdNBvrqQq/RHL0xIeyFOA== dependencies: chokidar "^3.2.2" debug "^3.2.6" @@ -22609,6 +22704,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"