diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index 6cf649aedf..00c8b0947d 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -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: diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index 578eb72b91..d607dc62fb 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -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 diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md new file mode 100644 index 0000000000..a9fc17ce75 --- /dev/null +++ b/docs/features/software-catalog/descriptor-format.md @@ -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. diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index cf23bcdc88..d761aa9f20 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -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(), ]), ]); } diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 80c5765ee1..70d2ba4d2b 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -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. */ diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts index 68658e5296..68f1cec649 100644 --- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts @@ -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 diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index 14b44108e5..80cf70fbab 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -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 diff --git a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.test.ts index 50496682e8..03b0ed2eb5 100644 --- a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.test.ts @@ -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 diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts index 8db33955a7..04acd58686 100644 --- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts @@ -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); + }); }); diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts index 57029ffa24..bbe374e811 100644 --- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts @@ -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 { - 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`, ); } } diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts index c53fbf3e46..2113772af4 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts @@ -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); diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index d367022ff1..e901e82d8b 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -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>().notRequired(), annotations: yup.object>().notRequired(), }) diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts new file mode 100644 index 0000000000..a60bb2606b --- /dev/null +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -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/); + }); +}); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts similarity index 61% rename from packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts rename to packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 2f5e8e079c..68cf0c61bb 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -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; constructor() { - this.schema = yup.object>({ + this.schema = yup.object>({ + 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 { - if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) { - throw new Error('Unsupported apiVersion / kind'); - } - return await this.schema.validate(envelope, { strict: true }); } } diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts new file mode 100644 index 0000000000..62837ed500 --- /dev/null +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts @@ -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/); + }); +}); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts similarity index 67% rename from packages/catalog-model/src/kinds/LocationEntityV1beta1.ts rename to packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index e68bb00aa0..a69a3a574a 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -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; constructor() { - this.schema = yup.object>({ + this.schema = yup.object>({ + 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 { - if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) { - throw new Error('Unsupported apiVersion / kind'); - } - return await this.schema.validate(envelope, { strict: true }); } } diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 2d2a0744c5..85e22f7029 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -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'; diff --git a/plugins/catalog-backend/examples/artist-lookup-component.yaml b/plugins/catalog-backend/examples/artist-lookup-component.yaml index 5fbb073c1a..bb7d8d5767 100644 --- a/plugins/catalog-backend/examples/artist-lookup-component.yaml +++ b/plugins/catalog-backend/examples/artist-lookup-component.yaml @@ -1,4 +1,4 @@ -apiVersion: backstage.io/v1beta1 +apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: artist-lookup diff --git a/plugins/catalog-backend/examples/playback-order-component.yaml b/plugins/catalog-backend/examples/playback-order-component.yaml index 4ee959f48f..fa9f68e77a 100644 --- a/plugins/catalog-backend/examples/playback-order-component.yaml +++ b/plugins/catalog-backend/examples/playback-order-component.yaml @@ -1,4 +1,4 @@ -apiVersion: backstage.io/v1beta1 +apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: playback-order diff --git a/plugins/catalog-backend/examples/podcast-api-component.yaml b/plugins/catalog-backend/examples/podcast-api-component.yaml index c2c7ee34dd..2a27a0a2c5 100644 --- a/plugins/catalog-backend/examples/podcast-api-component.yaml +++ b/plugins/catalog-backend/examples/podcast-api-component.yaml @@ -1,4 +1,4 @@ -apiVersion: backstage.io/v1beta1 +apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: podcast-api diff --git a/plugins/catalog-backend/examples/queue-proxy-component.yaml b/plugins/catalog-backend/examples/queue-proxy-component.yaml index 8298b19f93..a9f41b8776 100644 --- a/plugins/catalog-backend/examples/queue-proxy-component.yaml +++ b/plugins/catalog-backend/examples/queue-proxy-component.yaml @@ -1,4 +1,4 @@ -apiVersion: backstage.io/v1beta1 +apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: queue-proxy diff --git a/plugins/catalog-backend/examples/searcher-component.yaml b/plugins/catalog-backend/examples/searcher-component.yaml index ecfd92f470..d82f6e42c7 100644 --- a/plugins/catalog-backend/examples/searcher-component.yaml +++ b/plugins/catalog-backend/examples/searcher-component.yaml @@ -1,4 +1,4 @@ -apiVersion: backstage.io/v1beta1 +apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: searcher diff --git a/plugins/catalog-backend/examples/shuffle-api-component.yaml b/plugins/catalog-backend/examples/shuffle-api-component.yaml index d45a06e030..ed0517835c 100644 --- a/plugins/catalog-backend/examples/shuffle-api-component.yaml +++ b/plugins/catalog-backend/examples/shuffle-api-component.yaml @@ -1,4 +1,4 @@ -apiVersion: backstage.io/v1beta1 +apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: shuffle-api diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 6ab660a587..7c4b964b1b 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -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'; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index b4dd151e72..de68a16e9c 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -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' }, diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index d30361d0e7..7601031699 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -38,7 +38,7 @@ describe('CatalogPage', () => { metadata: { name: 'Entity1', }, - apiVersion: 'backstage.io/v1beta1', + apiVersion: 'backstage.io/v1alpha1', kind: 'Component', }, ] as Entity[]), diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index c04e4c95a8..b50da60a50 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -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' }, }, diff --git a/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx index e0027431ff..4bb64f221d 100644 --- a/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx +++ b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx @@ -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' }, };