Merge pull request #1209 from spotify/freben/model-more-fields

Add description, owner, and lifecycle to the catalog model
This commit is contained in:
Fredrik Adelöw
2020-06-16 13:34:24 +02:00
committed by GitHub
28 changed files with 666 additions and 106 deletions
@@ -52,7 +52,7 @@ This is an example entity definition with some mocked data.
```yaml
---
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: frobs-awesome
@@ -96,8 +96,8 @@ Backstage specific entities have an `apiVersion` that is prefixed with
same type of structure. This may be relevant when co-hosting these
specifications with e.g. kubernetes object manifests.
Early versions of the catalog will be using beta versions, e.g.
`backstage.io/v1beta1`, to signal that the format may still change. After that,
Early versions of the catalog will be using alpha/beta versions, e.g.
`backstage.io/v1alpha1`, to signal that the format may still change. After that,
we will be using `backstage.io/v1` and up.
### `metadata`
@@ -213,10 +213,10 @@ Values can be of any length, but are limited to being strings.
## Component
| Field | Value |
| ------------ | ---------------------- |
| `apiVersion` | `backstage.io/v1beta1` |
| `kind` | `Component` |
| Field | Value |
| ------------ | ----------------------- |
| `apiVersion` | `backstage.io/v1alpha1` |
| `kind` | `Component` |
The `spec` object for this kind is as follows:
@@ -37,7 +37,7 @@ Component entities are typically defined in YAML descriptor files next to the
code of the component, and could look like this (actual schema will evolve):
```yaml
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-component-name
@@ -63,7 +63,7 @@ wouldn't need their own descriptor files, but would be stored in the catalog
somewhat like this (actual schema will evolve):
```yaml
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: my-component-api
@@ -97,7 +97,7 @@ files, but would be stored in the catalog somewhat like this (actual schema will
evolve):
```yaml
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: my-component-db
@@ -0,0 +1,313 @@
# Descriptor Format of Catalog Entities
This section describes the default data shape and semantics of catalog entities.
This both applies to objects given to and returned from the software catalog
API, as well as to the descriptor files that the software catalog can ingest
natively. In the API request/response cycle, a JSON representation is used,
while the descriptor files are on YAML format to be more easily maintainable by
humans. However, the structure and semantics is the same in both cases.
## Contents
- [Overall Shape Of An Entity](#overall-shape-of-an-entity)
- [Common to All Kinds: The Envelope](#common-to-all-kinds-the-envelope)
- [Common to All Kinds: The Metadata](#common-to-all-kinds-the-metadata)
- [Kind: Component](#kind-component)
## Overall Shape Of An Entity
The following is an example of the shape of an entity as returned from the
software catalog API.
```js
{
"apiVersion": "backstage.io/v1alpha1",
"kind": "Component",
"metadata": {
"annotations": {
"backstage.io/managed-by-location": "file:/tmp/component-info.yaml",
"example.com/service-discovery": "artistweb",
"circleci.com/project-slug": "gh/example-org/artist-website"
},
"description": "The place to be, for great artists",
"etag": "ZjU2MWRkZWUtMmMxZS00YTZiLWFmMWMtOTE1NGNiZDdlYzNk",
"generation": 1,
"labels": {
"system": "public-websites"
},
"name": "artist-web",
"uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2"
},
"spec": {
"lifecycle": "production",
"owner": "artist-relations@example.com",
"type": "website"
}
}
```
The corresponding descriptor file that generated it may look as follows:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: artist-web
description: The place to be, for great artists
labels:
system: public-websites
annotations:
example.com/service-discovery: artistweb
circleci.com/project-slug: gh/example-org/artist-website
spec:
type: website
lifecycle: production
owner: artist-relations@example.com
```
The root fields `apiVersion`, `kind`, `metadata`, and `spec` are part of the
_envelope_, defining the overall structure of all kinds of entity. Likewise,
some metadata fields like `name`, `labels`, and `annotations` are of special
significance and have reserved purposes and distinct shapes.
See below for details about these fields.
## Common to All Kinds: The Envelope
The root envelope object has the following structure.
### `apiVersion` and `kind` [required]
The `kind` is the high level entity type being described.
[ADR005](/docs/architecture-decisions/adr005-catalog-core-entities.md) describes
a number of core kinds that plugins can know of and understand, but an
organization using Backstage is free to also add entities of other kinds to the
catalog.
The perhaps most central kind of entity, that the catalog focuses on in the
initial phase, is `Component` ([see below](#kind-component)).
The `apiVersion` is the version of specification format for that particular
entity that the specification is made against. The version is used for being
able to evolve the format, and the tuple of `apiVersion` and `kind` should be
enough for a parser to know how to interpret the contents of the rest of the
data.
Backstage specific entities have an `apiVersion` that is prefixed with
`backstage.io/`, to distinguish them from other types of object that share the
same type of structure. This may be relevant when co-hosting these
specifications with e.g. kubernetes object manifests, or when an organization
adds their own specific kinds of entity to the catalog.
Early versions of the catalog will be using alpha/beta versions, e.g.
`backstage.io/v1alpha1`, to signal that the format may still change. After that,
we will be using `backstage.io/v1` and up.
### `metadata` [required]
A structure that contains metadata about the entity, i.e. things that aren't
directly part of the entity specification itself. See below for more details
about this structure.
### `spec` [varies]
The actual specification data that describes the entity.
The precise structure of the `spec` depends on the `apiVersion` and `kind`
combination, and some kinds may not even have a `spec` at all. See further down
in this document for the specification structure of specific kinds.
## Common to All Kinds: The Metadata
The `metadata` root field has a number of reserved fields with specific meaning,
described below.
In addition to these, you may add any number of other fields directly under
`metadata`, but be aware that general plugins and tools may not be able to
understand their semantics.
### `name` [required]
The name of the entity. This name is both meant for human eyes to recognize the
entity, and for machines and other components to reference the entity (e.g. in
URLs or from other entity specification files).
Names must be unique per kind, within a given namespace (if specified), at any
point in time. Names may be reused at a later time, after an entity is deleted
from the registry.
Names are required to follow a certain format. Entities that do not follow those
rules will not be accepted for registration in the catalog. The ruleset is
configurable to fit your organization's needs, but the default behavior is as
follows.
- Strings of length at least 1, and at most 63
- Must consist of sequences of `[a-z0-9A-Z]` possibly separated by one of
`[-_.]`
Example: `visits-tracking-service`, `CircleciBuildsDump_avro_gcs`
In addition to this, names are passed through a normalization function and then
compared to the same normalized form of other entity names and made sure to not
collide. This rule of uniqueness exists to avoid situations where e.g. both
`my-component` and `MyComponent` are registered side by side, which leads to
confusion and risk. The normalization function is also configurable, but the
default behavior is as follows.
- Strip out all characters outside of the set `[a-zA-Z0-9]`
- Convert to lowercase
Example: `CircleciBuildsDs_avro_gcs` -> `circlecibuildsdsavrogcs`
### `namespace` [optional]
The ID of a namespace that the entity belongs to. This is a string that follows
the same format restrictions as `name` above.
This field is optional, and currently has no special semantics apart from
bounding the name uniqueness constraint if specified. It is reserved for future
use and may get broader semantic implication later. For now, it is recommended
to not specify a namespace unless you have specific need to do so.
Namespaces may also be part of the catalog, and are `v1` / `Namespace` entities,
i.e. not Backstage specific but the same as in Kubernetes.
### `description` [optional]
A human readable description of the entity, to be shown in Backstage. Should be
kept short and informative, suitable to give an overview of the entity's purpose
at a glance. More detailed explanations and documentation should be placed
elsewhere.
### `labels` [optional]
Labels are optional key/value pairs of that are attached to the entity, and
their use is identical to
[Kubernetes object labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/).
Their main purpose is for references to other entities, and for information that
is in one way or another classifying for the current entity. They are often used
as values in queries or filters.
Both the key and the value are strings, subject to the following restrictions.
Keys have an optional prefix followed by a slash, and then the name part which
is required. The prefix, if present, must be a valid lowercase domain name, at
most 253 characters in total. The name part must be sequences of `[a-zA-Z0-9]`
separated by any of `[-_.]`, at most 63 characters in total.
The `backstage.io/` prefix is reserved for use by Backstage core components.
Some keys such as `system` also have predefined semantics.
Values are strings that follow the same restrictions as `name` above.
### `annotations` [optional]
An object with arbitrary non-identifying metadata attached to the entity,
identical in use to
[Kubernetes object annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/).
Their purpose is mainly, but not limited, to reference into external systems.
This could for example be a reference to the git ref the entity was ingested
from, to monitoring and logging systems, to pagerduty schedules, etc. Users may
add these to descriptor YAML files, but in addition to this automated systems
may also add annotations, either during ingestion into the catalog, or at a
later time.
Both the key and the value are strings, subject to the following restrictions.
Keys have an optional prefix followed by a slash, and then the name part which
is required. The prefix must be a valid lowercase domain name if specified, at
most 253 characters in total. The name part must be sequences of `[a-zA-Z0-9]`
separated by any of `[-_.]`, at most 63 characters in total.
The `backstage.io/` prefix is reserved for use by Backstage core components.
Values can be of any length, but are limited to being strings.
## Kind: Component
Describes the following entity kind:
| Field | Value |
| ------------ | ----------------------- |
| `apiVersion` | `backstage.io/v1alpha1` |
| `kind` | `Component` |
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.
Descriptor files for this kind may look as follows.
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: artist-web
description: The place to be, for great artists
spec:
type: website
lifecycle: production
owner: artist-relations@example.com
```
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
shape, this kind has the following structure.
### `apiVersion` and `kind` [required]
Exactly equal to `backstage.io/v1alpha1` and `Component`, respectively.
### `spec.type` [required]
The type of component as a string, e.g. `website`. This field is required.
The software catalog accepts any type value, but an organisation 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.
The current set of well-known and common values for this field is:
- `service` - a backend service, typically exposing an API
- `website` - a website
- `library` - a software library, such as an NPM module or a Java library
### `spec.lifecycle` [required]
The lifecyle state of the component, e.g. `production`. This field is required.
The software catalog accepts any lifecycle value, but an organisation should
take great care to establish a proper taxonomy for these.
The current set of well-known and common values for this field is:
- `experimental` - an experiment or early, non-production component, signaling
that users may not prefer to consume it over other more established
components, or that there are low or no reliability guarantees
- `production` - an established, owned, maintained component
- `deprecated` - a component that is at the end of its lifecycle, and may
disappear at a later point in time
### `spec.owner` [required]
The owner of the component, e.g. `artist-relations@example.com`. This field is
required.
In Backstage, the owner of a component is the singular entity (commonly a team)
that bears ultimate responsibility for the component, and has the authority and
capability to develop and maintain it. They will be the point of contact if
something goes wrong, or if features are to be requested. The main purpose of
this field is for display purposes in Backstage, so that people looking at
catalog items can get an understanding of to whom this component belongs. It is
not to be used by automated processes to for example assign authorization in
runtime systems. There may be others that also develop or otherwise touch the
component, but there will always be one ultimate owner.
Apart from being a string, the software catalog leaves the format of this field
open to implementers to choose. Most commonly, it is set to the ID or email of a
group of people in an organizational structure.
+4 -4
View File
@@ -23,8 +23,8 @@ import {
SchemaValidEntityPolicy,
} from './entity';
import {
ComponentEntityV1beta1Policy,
LocationEntityV1beta1Policy,
ComponentEntityV1alpha1Policy,
LocationEntityV1alpha1Policy,
} from './kinds';
import { EntityPolicy } from './types';
@@ -72,8 +72,8 @@ export class EntityPolicies implements EntityPolicy {
new ReservedFieldsEntityPolicy(),
]),
EntityPolicies.anyOf([
new ComponentEntityV1beta1Policy(),
new LocationEntityV1beta1Policy(),
new ComponentEntityV1alpha1Policy(),
new LocationEntityV1alpha1Policy(),
]),
]);
}
@@ -95,6 +95,12 @@ export type EntityMeta = {
*/
namespace?: string;
/**
* A short (typically relatively few words, on one line) description of the
* entity.
*/
description?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
@@ -24,13 +24,13 @@ describe('DefaultNamespaceEntityPolicy', () => {
beforeEach(() => {
withoutNamespace = yaml.parse(`
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-component-yay
`);
withNamespace = yaml.parse(`
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-component-yay
@@ -23,7 +23,7 @@ describe('FieldFormatEntityPolicy', () => {
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
@@ -23,7 +23,7 @@ describe('NoForeignRootFieldsEntityPolicy', () => {
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
@@ -23,7 +23,7 @@ describe('ReservedFieldsEntityPolicy', () => {
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
@@ -61,4 +61,9 @@ describe('ReservedFieldsEntityPolicy', () => {
/annotation.*apiVersion/i,
);
});
it('rejects core fields mistakenly placed in metadata', async () => {
data.metadata.owner = 'emma';
await expect(policy.enforce(data)).rejects.toThrow(/owner/i);
});
});
@@ -17,17 +17,23 @@
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const DEFAULT_RESERVED_ENTITY_FIELDS = [
const DEFAULT_RESERVED_ENTITY_FIELDS: string[] = [
'apiVersion',
'kind',
'uid',
'etag',
'generation',
'name',
'namespace',
'labels',
'annotations',
'spec',
'metadata.uid',
'metadata.etag',
'metadata.generation',
'metadata.name',
'metadata.namespace',
'metadata.description',
'metadata.labels',
'metadata.annotations',
// The below items are known to appear in core kinds, and therefore should
// not be appearing in metadata (which would indicate that the user made a
// mistake in where to place them).
'spec.lifecycle',
'spec.owner',
];
/**
@@ -44,20 +50,32 @@ export class ReservedFieldsEntityPolicy implements EntityPolicy {
}
async enforce(entity: Entity): Promise<Entity> {
for (const field of this.reservedFields) {
if (entity.spec?.hasOwnProperty(field)) {
for (const path of this.reservedFields) {
const [where, name] = path.includes('.')
? path.split('.')
: [undefined, path];
if (where !== 'metadata' && entity.metadata.hasOwnProperty(name)) {
throw new Error(
`The spec may not contain the field ${field}, because it has reserved meaning`,
`The metadata may not contain the field ${name}, because it has reserved meaning`,
);
}
if (entity.metadata.labels?.hasOwnProperty(field)) {
if (where !== 'spec' && entity.spec?.hasOwnProperty(name)) {
throw new Error(
`A label may not have the field ${field}, because it has reserved meaning`,
`The spec may not contain the field ${name}, because it has reserved meaning`,
);
}
if (entity.metadata.annotations?.hasOwnProperty(field)) {
if (where !== 'labels' && entity.metadata.labels?.hasOwnProperty(name)) {
throw new Error(
`An annotation may not have the field ${field}, because it has reserved meaning`,
`A label may not have the field ${name}, because it has reserved meaning`,
);
}
if (
where !== 'annotations' &&
entity.metadata.annotations?.hasOwnProperty(name)
) {
throw new Error(
`An annotation may not have the field ${name}, because it has reserved meaning`,
);
}
}
@@ -24,7 +24,7 @@ describe('SchemaValidEntityPolicy', () => {
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
@@ -100,6 +100,11 @@ describe('SchemaValidEntityPolicy', () => {
await expect(policy.enforce(data)).rejects.toThrow(/uid/);
});
it('rejects empty uid', async () => {
data.metadata.uid = '';
await expect(policy.enforce(data)).rejects.toThrow(/uid/);
});
it('accepts missing etag', async () => {
delete data.metadata.etag;
await expect(policy.enforce(data)).resolves.toBe(data);
@@ -110,6 +115,11 @@ describe('SchemaValidEntityPolicy', () => {
await expect(policy.enforce(data)).rejects.toThrow(/etag/);
});
it('rejects empty etag', async () => {
data.metadata.etag = '';
await expect(policy.enforce(data)).rejects.toThrow(/etag/);
});
it('accepts missing generation', async () => {
delete data.metadata.generation;
await expect(policy.enforce(data)).resolves.toBe(data);
@@ -120,6 +130,16 @@ describe('SchemaValidEntityPolicy', () => {
await expect(policy.enforce(data)).rejects.toThrow(/generation/);
});
it('rejects zero generation', async () => {
data.metadata.generation = 0;
await expect(policy.enforce(data)).rejects.toThrow(/generation/);
});
it('rejects non-integer generation', async () => {
data.metadata.generation = 1.5;
await expect(policy.enforce(data)).rejects.toThrow(/generation/);
});
it('rejects missing name', async () => {
delete data.metadata.name;
await expect(policy.enforce(data)).rejects.toThrow(/name/);
@@ -140,6 +160,16 @@ describe('SchemaValidEntityPolicy', () => {
await expect(policy.enforce(data)).rejects.toThrow(/namespace/);
});
it('accepts missing description', async () => {
delete data.metadata.description;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad description type', async () => {
data.metadata.description = 7;
await expect(policy.enforce(data)).rejects.toThrow(/description/);
});
it('accepts missing labels', async () => {
delete data.metadata.labels;
await expect(policy.enforce(data)).resolves.toBe(data);
@@ -23,32 +23,12 @@ const DEFAULT_ENTITY_SCHEMA = yup.object({
kind: yup.string().required(),
metadata: yup
.object({
uid: yup
.string()
.notRequired()
.test(
'metadata.uid',
'The uid must not be empty',
value => value === undefined || value.length > 0,
),
etag: yup
.string()
.notRequired()
.test(
'metadata.etag',
'The etag must not be empty',
value => value === undefined || value.length > 0,
),
generation: yup
.number()
.notRequired()
.test(
'metadata.generation',
'The generation must be an integer greater than zero',
value => value === undefined || (value === (value | 0) && value > 0),
),
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(),
})
@@ -0,0 +1,106 @@
/*
* 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 { EntityPolicy } from '../types';
import {
ComponentEntityV1alpha1,
ComponentEntityV1alpha1Policy,
} from './ComponentEntityV1alpha1';
describe('ComponentV1alpha1Policy', () => {
let entity: ComponentEntityV1alpha1;
let policy: EntityPolicy;
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test',
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'me',
},
};
policy = new ComponentEntityV1alpha1Policy();
});
it('happy path: accepts valid data', async () => {
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/);
});
it('rejects unknown kind', async () => {
(entity as any).kind = 'Wizard';
await expect(policy.enforce(entity)).rejects.toThrow(/kind/);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
await expect(policy.enforce(entity)).rejects.toThrow(/type/);
});
it('rejects wrong type', async () => {
(entity as any).spec.type = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/type/);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
await expect(policy.enforce(entity)).rejects.toThrow(/type/);
});
it('rejects missing lifecycle', async () => {
delete (entity as any).spec.lifecycle;
await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects wrong lifecycle', async () => {
(entity as any).spec.lifecycle = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects empty lifecycle', async () => {
(entity as any).spec.lifecycle = '';
await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects missing owner', async () => {
delete (entity as any).spec.owner;
await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
});
it('rejects wrong owner', async () => {
(entity as any).spec.owner = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
});
it('rejects empty owner', async () => {
(entity as any).spec.owner = '';
await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
});
});
@@ -18,35 +18,37 @@ import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Component';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Component' as const;
export interface ComponentEntityV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
export interface ComponentEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
spec: {
type: string;
lifecycle: string;
owner: string;
};
}
export class ComponentEntityV1beta1Policy implements EntityPolicy {
export class ComponentEntityV1alpha1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<ComponentEntityV1beta1>>({
this.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(),
type: yup.string().required().min(1),
lifecycle: yup.string().required().min(1),
owner: yup.string().required().min(1),
})
.required(),
});
}
async enforce(envelope: Entity): Promise<Entity> {
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
throw new Error('Unsupported apiVersion / kind');
}
return await this.schema.validate(envelope, { strict: true });
}
}
@@ -0,0 +1,103 @@
/*
* 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 { EntityPolicy } from '../types';
import {
LocationEntityV1alpha1,
LocationEntityV1alpha1Policy,
} from './LocationEntityV1alpha1';
describe('LocationV1alpha1Policy', () => {
let entity: LocationEntityV1alpha1;
let policy: EntityPolicy;
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
name: 'test',
},
spec: {
type: 'github',
},
};
policy = new LocationEntityV1alpha1Policy();
});
it('happy path: accepts valid data', async () => {
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/);
});
it('rejects unknown kind', async () => {
(entity as any).kind = 'Wizard';
await expect(policy.enforce(entity)).rejects.toThrow(/kind/);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
await expect(policy.enforce(entity)).rejects.toThrow(/type/);
});
it('rejects wrong type', async () => {
(entity as any).spec.type = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/type/);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
await expect(policy.enforce(entity)).rejects.toThrow(/type/);
});
it('accepts good target', async () => {
(entity as any).spec.target =
'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects wrong target', async () => {
(entity as any).spec.target = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/target/);
});
it('rejects empty target', async () => {
(entity as any).spec.target = '';
await expect(policy.enforce(entity)).rejects.toThrow(/target/);
});
it('accepts good targets', async () => {
(entity as any).spec.targets = [
'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml',
'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/playback-order-component.yaml',
];
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects wrong targets', async () => {
(entity as any).spec.targets = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/targets/);
});
});
@@ -18,11 +18,11 @@ import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Location';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Location' as const;
export interface LocationEntityV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
export interface LocationEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
spec: {
type: string;
@@ -31,15 +31,17 @@ export interface LocationEntityV1beta1 extends Entity {
};
}
export class LocationEntityV1beta1Policy implements EntityPolicy {
export class LocationEntityV1alpha1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<LocationEntityV1beta1>>({
this.schema = yup.object<Partial<LocationEntityV1alpha1>>({
apiVersion: yup.string().required().oneOf(API_VERSION),
kind: yup.string().required().equals([KIND]),
spec: yup
.object({
type: yup.string().required(),
target: yup.string().notRequired(),
type: yup.string().required().min(1),
target: yup.string().notRequired().min(1),
targets: yup.array(yup.string()).notRequired(),
})
.required(),
@@ -47,10 +49,6 @@ export class LocationEntityV1beta1Policy implements EntityPolicy {
}
async enforce(envelope: Entity): Promise<Entity> {
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
throw new Error('Unsupported apiVersion / kind');
}
return await this.schema.validate(envelope, { strict: true });
}
}
+8 -8
View File
@@ -14,13 +14,13 @@
* limitations under the License.
*/
export { ComponentEntityV1beta1Policy } from './ComponentEntityV1beta1';
export { ComponentEntityV1alpha1Policy } from './ComponentEntityV1alpha1';
export type {
ComponentEntityV1beta1 as ComponentEntity,
ComponentEntityV1beta1,
} from './ComponentEntityV1beta1';
export { LocationEntityV1beta1Policy } from './LocationEntityV1beta1';
ComponentEntityV1alpha1 as ComponentEntity,
ComponentEntityV1alpha1,
} from './ComponentEntityV1alpha1';
export { LocationEntityV1alpha1Policy } from './LocationEntityV1alpha1';
export type {
LocationEntityV1beta1 as LocationEntity,
LocationEntityV1beta1,
} from './LocationEntityV1beta1';
LocationEntityV1alpha1 as LocationEntity,
LocationEntityV1alpha1,
} from './LocationEntityV1alpha1';
@@ -1,4 +1,4 @@
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: artist-lookup
@@ -1,4 +1,4 @@
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: playback-order
@@ -1,4 +1,4 @@
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: podcast-api
@@ -1,4 +1,4 @@
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: queue-proxy
@@ -1,4 +1,4 @@
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: searcher
@@ -1,4 +1,4 @@
apiVersion: backstage.io/v1beta1
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: shuffle-api
@@ -14,10 +14,9 @@
* limitations under the License.
*/
import type { Entity } from '@backstage/catalog-model';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { NotFoundError } from '@backstage/backend-common';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -173,7 +173,7 @@ describe('HigherOrderOperations', () => {
target: 'thing',
};
const desc: Entity = {
apiVersion: 'backstage.io/v1beta1',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'c1' },
spec: { type: 'service' },
@@ -228,7 +228,7 @@ describe('HigherOrderOperations', () => {
target: 'thing',
};
const desc: Entity = {
apiVersion: 'backstage.io/v1beta1',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'c1' },
spec: { type: 'service' },
@@ -38,7 +38,7 @@ describe('CatalogPage', () => {
metadata: {
name: 'Entity1',
},
apiVersion: 'backstage.io/v1beta1',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
},
] as Entity[]),
@@ -22,17 +22,17 @@ import { CatalogTable } from './CatalogTable';
const entites: Entity[] = [
{
apiVersion: 'backstage.io/v1beta1',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'component1' },
},
{
apiVersion: 'backstage.io/v1beta1',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'component2' },
},
{
apiVersion: 'backstage.io/v1beta1',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'component3' },
},
@@ -22,7 +22,7 @@ import { EntityMetadataCard } from './EntityMetadataCard';
describe('EntityMetadataCard component', () => {
it('should display entity name if provided', async () => {
const testEntity: Entity = {
apiVersion: 'backstage.io/v1beta1',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test' },
};