Merge branch 'master' of https://github.com/spotify/backstage into lintMod

This commit is contained in:
Debajyoti Halder
2021-01-29 14:05:27 +05:30
244 changed files with 4425 additions and 1286 deletions
-18
View File
@@ -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 :(
@@ -1,5 +0,0 @@
---
'@backstage/plugin-cost-insights': patch
---
Allow expand functionality to top panel product chart tooltip.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Add className to the SidebarItem
-5
View File
@@ -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.
+5
View File
@@ -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.
-12
View File
@@ -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}
```
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-kubernetes': patch
---
Improve error reporting for plugin misconfiguration.
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-auth-backend': patch
---
Use .text instead of .json for ALB key response
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-model': patch
---
Replace `yup` with `ajv`, for validation of catalog entities.
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/config-loader': minor
---
Removed support for the deprecated `$data` placeholder.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': patch
---
Bump `config-loader` to `ajv` 7, to enable v7 feature use elsewhere
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Properly forward errors that occur when looking up GitLab project IDs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-model': patch
---
Introduce json schema variants of the `yup` validation schemas
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/techdocs-common': patch
---
Add rate limiter for concurrent execution of file uploads in AWS and Google publishers
-5
View File
@@ -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.
+110
View File
@@ -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}}"
});
+15
View File
@@ -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
@@ -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
+7 -5
View File
@@ -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
@@ -4,8 +4,6 @@ title: ADR000: [TITLE]
description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION]
---
# ADR000: [title]
<!-- These documents have names that are short noun phrases. For example, "ADR001: Deployment on Ruby on Rails 3.0.10" or "ADR009: LDAP for Multitenant Integration" -->
## Context
@@ -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.
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+130
View File
@@ -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': <BACKSTAGE_ENTITY_NAME>
```
### 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'
```
+17 -114
View File
@@ -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': <BACKSTAGE_ENTITY_NAME>
```
### 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).
+119
View File
@@ -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 }) => (
<EntityPageLayout>
// ...
<EntityPageLayout.Content
path="/kubernetes/*"
title="Kubernetes"
element={<KubernetesRouter entity={entity} />}
/>
// ...
</EntityPageLayout>
);
```
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).
@@ -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
@@ -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
```
+86 -8
View File
@@ -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
+7 -2
View File
@@ -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"],
+1
View File
@@ -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'
+21
View File
@@ -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
+8 -8
View File
@@ -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",
+30
View File
@@ -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
+5 -5
View File
@@ -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",
@@ -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(
@@ -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(
@@ -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=(?<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<string> {
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}`);
}
}
@@ -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', {
@@ -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=(?<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,
});
@@ -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', {
@@ -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="(?<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,
});
@@ -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<void>) {
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),
},
]);
}),
);
});
@@ -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<ReadTreeResponse> {
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<ReadTreeResponse> {
return new ZipArchiveResponse(
options.stream,
options.path ?? '',
options.subpath ?? '',
this.workDir,
options.etag,
options.filter,
@@ -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' });
@@ -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) {
@@ -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' });
@@ -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));
@@ -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<ReadTreeResponseFile[]>;
archive(): Promise<NodeJS.ReadableStream>;
+10 -1
View File
@@ -86,13 +86,22 @@ export class Git {
return git.commit({ fs, dir, message, author, committer });
}
async clone({ url, dir }: { url: string; dir: string }): Promise<void> {
async clone({
url,
dir,
ref,
}: {
url: string;
dir: string;
ref?: string;
}): Promise<void> {
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(),
+22
View File
@@ -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
+10 -10
View File
@@ -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"
+1 -1
View File
@@ -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"
},
@@ -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]
@@ -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
@@ -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]
@@ -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]
@@ -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]
@@ -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]
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+2 -1
View File
@@ -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",
@@ -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<EntityPolicy> = { enforce: jest.fn() };
+1 -2
View File
@@ -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
@@ -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;
};
@@ -20,6 +20,7 @@ export {
} from './constants';
export type {
Entity,
EntityLink,
EntityMeta,
EntityRelation,
EntityRelationSpec,
@@ -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';
@@ -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,
);
});
});
@@ -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;
}
}
@@ -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'];
@@ -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
//
@@ -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<Record<string, string>>().notRequired(),
annotations: yup.object<Record<string, string>>().notRequired(),
tags: yup.array<string>().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<Entity>;
constructor(schema: yup.Schema<Entity> = DEFAULT_ENTITY_SCHEMA) {
this.schema = schema;
}
private validate: ValidateFunction<Entity> | undefined;
async enforce(entity: Entity): Promise<Entity> {
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<Entity>(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 || '<root>'} ${error.message}`,
);
}
}
@@ -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';
@@ -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<Entity | undefined>;
};
+1 -1
View File
@@ -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';
@@ -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<Partial<ApiEntityV1alpha1>>({
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],
);
@@ -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<Partial<ComponentEntityV1alpha1>>({
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],
);
@@ -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<Partial<DomainEntityV1alpha1>>({
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],
);
@@ -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<Partial<GroupEntityV1alpha1>>({
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<string, object>,
})
.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],
);
@@ -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<Partial<LocationEntityV1alpha1>>({
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],
);
@@ -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<Partial<ResourceEntityV1alpha1>>({
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],
);
@@ -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<Partial<SystemEntityV1alpha1>>({
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],
);
@@ -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<Partial<TemplateEntityV1alpha1>>({
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],
);
@@ -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<Partial<UserEntityV1alpha1>>({
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<string, object>,
})
.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],
);
+40 -4
View File
@@ -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 || '<root>'} ${error.message}`,
);
},
};
}
@@ -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"
}
}
}
}
@@ -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
}
}
}
}
}
}
@@ -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
}
}
}
}
}
]
}
@@ -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
}
}
}
}
}
}
]
}
@@ -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
}
}
}
}
}
]
}
@@ -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
}
}
}
}
}
}
]
}
@@ -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
}
}
}
}
}
}
]
}
@@ -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
}
}
}
}
}
]
}
@@ -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
}
}
}
}
}
]
}
@@ -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."
}
}
}
}
}
]
}
@@ -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
}
}
}
}
}
}
]
}

Some files were not shown because too many files have changed in this diff Show More