Merge pull request #1209 from spotify/freben/model-more-fields
Add description, owner, and lifecycle to the catalog model
This commit is contained in:
@@ -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.
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
+13
-11
@@ -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/);
|
||||
});
|
||||
});
|
||||
+10
-12
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user