diff --git a/.changeset/3113.md b/.changeset/3113.md new file mode 100644 index 0000000000..7c53d01c16 --- /dev/null +++ b/.changeset/3113.md @@ -0,0 +1,11 @@ +--- +'@backstage/catalog-model': minor +'@backstage/plugin-catalog-backend': minor +--- + +Changes the various kind policies into a new type `KindValidator`. + +Adds `CatalogProcessor#validateEntityKind` that makes use of the above +validators. This moves entity schema validity checking away from entity +policies and into processors, centralizing the extension points into the +processor chain. diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts index 142a8d8160..a5d5152fab 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { ApiEntityV1alpha1, - apiEntityV1alpha1Policy as policy, + apiEntityV1alpha1Validator as validator, } from './ApiEntityV1alpha1'; -describe('ApiV1alpha1Policy', () => { +describe('ApiV1alpha1Validator', () => { let entity: ApiEntityV1alpha1; beforeEach(() => { @@ -75,81 +75,81 @@ components: }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects missing lifecycle', async () => { delete (entity as any).spec.lifecycle; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects wrong lifecycle', async () => { (entity as any).spec.lifecycle = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects empty lifecycle', async () => { (entity as any).spec.lifecycle = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects missing owner', async () => { delete (entity as any).spec.owner; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects wrong owner', async () => { (entity as any).spec.owner = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects empty owner', async () => { (entity as any).spec.owner = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects missing definition', async () => { delete (entity as any).spec.definition; - await expect(policy.enforce(entity)).rejects.toThrow(/definition/); + await expect(validator.check(entity)).rejects.toThrow(/definition/); }); it('rejects wrong definition', async () => { (entity as any).spec.definition = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/definition/); + await expect(validator.check(entity)).rejects.toThrow(/definition/); }); it('rejects empty definition', async () => { (entity as any).spec.definition = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/definition/); + await expect(validator.check(entity)).rejects.toThrow(/definition/); }); }); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 052172d0a1..660cd71cd8 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'API' as const; @@ -45,4 +45,8 @@ export interface ApiEntityV1alpha1 extends Entity { }; } -export const apiEntityV1alpha1Policy = schemaPolicy(KIND, API_VERSION, schema); +export const apiEntityV1alpha1Validator = schemaValidator( + KIND, + API_VERSION, + schema, +); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts index 11434937cd..64f9d05978 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { ComponentEntityV1alpha1, - componentEntityV1alpha1Policy as policy, + componentEntityV1alpha1Validator as validator, } from './ComponentEntityV1alpha1'; -describe('ComponentV1alpha1Policy', () => { +describe('ComponentV1alpha1Validator', () => { let entity: ComponentEntityV1alpha1; beforeEach(() => { @@ -39,86 +39,86 @@ describe('ComponentV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects missing lifecycle', async () => { delete (entity as any).spec.lifecycle; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects wrong lifecycle', async () => { (entity as any).spec.lifecycle = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects empty lifecycle', async () => { (entity as any).spec.lifecycle = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects missing owner', async () => { delete (entity as any).spec.owner; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects wrong owner', async () => { (entity as any).spec.owner = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects empty owner', async () => { (entity as any).spec.owner = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('accepts missing implementsApis', async () => { delete (entity as any).spec.implementsApis; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects empty implementsApis', async () => { (entity as any).spec.implementsApis = ['']; - await expect(policy.enforce(entity)).rejects.toThrow(/implementsApis/); + await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); }); it('rejects undefined implementsApis', async () => { (entity as any).spec.implementsApis = [undefined]; - await expect(policy.enforce(entity)).rejects.toThrow(/implementsApis/); + await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); }); it('accepts no implementsApis', async () => { (entity as any).spec.implementsApis = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); }); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 662ce28b52..9d46dea03f 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Component' as const; @@ -45,7 +45,7 @@ export interface ComponentEntityV1alpha1 extends Entity { }; } -export const componentEntityV1alpha1Policy = schemaPolicy( +export const componentEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts index 9137727c6e..8b7bef8ff7 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { GroupEntityV1alpha1, - groupEntityV1alpha1Policy as policy, + groupEntityV1alpha1Validator as validator, } from './GroupEntityV1alpha1'; -describe('GroupV1alpha1Policy', () => { +describe('GroupV1alpha1Validator', () => { let entity: GroupEntityV1alpha1; beforeEach(() => { @@ -42,106 +42,106 @@ describe('GroupV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('accepts missing parent', async () => { delete (entity as any).spec.parent; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects empty parent', async () => { (entity as any).spec.parent = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/parent/); + await expect(validator.check(entity)).rejects.toThrow(/parent/); }); it('rejects missing ancestors', async () => { delete (entity as any).spec.ancestors; - await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); + await expect(validator.check(entity)).rejects.toThrow(/ancestor/); }); it('rejects empty ancestors', async () => { (entity as any).spec.ancestors = ['']; - await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); + await expect(validator.check(entity)).rejects.toThrow(/ancestor/); }); it('rejects undefined ancestors', async () => { (entity as any).spec.ancestors = [undefined]; - await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); + await expect(validator.check(entity)).rejects.toThrow(/ancestor/); }); it('accepts no ancestors', async () => { (entity as any).spec.ancestors = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects missing children', async () => { delete (entity as any).spec.children; - await expect(policy.enforce(entity)).rejects.toThrow(/children/); + await expect(validator.check(entity)).rejects.toThrow(/children/); }); it('rejects empty children', async () => { (entity as any).spec.children = ['']; - await expect(policy.enforce(entity)).rejects.toThrow(/children/); + await expect(validator.check(entity)).rejects.toThrow(/children/); }); it('rejects undefined children', async () => { (entity as any).spec.children = [undefined]; - await expect(policy.enforce(entity)).rejects.toThrow(/children/); + await expect(validator.check(entity)).rejects.toThrow(/children/); }); it('accepts no children', async () => { (entity as any).spec.children = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects missing descendants', async () => { delete (entity as any).spec.descendants; - await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); + await expect(validator.check(entity)).rejects.toThrow(/descendants/); }); it('rejects empty descendants', async () => { (entity as any).spec.descendants = ['']; - await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); + await expect(validator.check(entity)).rejects.toThrow(/descendants/); }); it('rejects undefined descendants', async () => { (entity as any).spec.descendants = [undefined]; - await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); + await expect(validator.check(entity)).rejects.toThrow(/descendants/); }); it('accepts no descendants', async () => { (entity as any).spec.descendants = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); }); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index f0e91ddce8..f6345741f7 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Group' as const; @@ -63,7 +63,7 @@ export interface GroupEntityV1alpha1 extends Entity { }; } -export const groupEntityV1alpha1Policy = schemaPolicy( +export const groupEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts index 865ceda53e..daa18b00cc 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { LocationEntityV1alpha1, - locationEntityV1alpha1Policy as policy, + locationEntityV1alpha1Validator as validator, } from './LocationEntityV1alpha1'; -describe('LocationV1alpha1Policy', () => { +describe('LocationV1alpha1Validator', () => { let entity: LocationEntityV1alpha1; beforeEach(() => { @@ -36,53 +36,53 @@ describe('LocationV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(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); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects wrong target', async () => { (entity as any).spec.target = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/target/); + await expect(validator.check(entity)).rejects.toThrow(/target/); }); it('rejects empty target', async () => { (entity as any).spec.target = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/target/); + await expect(validator.check(entity)).rejects.toThrow(/target/); }); it('accepts good targets', async () => { @@ -90,16 +90,16 @@ describe('LocationV1alpha1Policy', () => { '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); + await expect(validator.check(entity)).resolves.toBe(true); }); it('accepts empty targets', async () => { (entity as any).spec.targets = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects wrong targets', async () => { (entity as any).spec.targets = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/targets/); + await expect(validator.check(entity)).rejects.toThrow(/targets/); }); }); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index dc2485f11b..e536be7922 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Location' as const; @@ -43,7 +43,7 @@ export interface LocationEntityV1alpha1 extends Entity { }; } -export const locationEntityV1alpha1Policy = schemaPolicy( +export const locationEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts index 86351b41aa..e3d29aad4f 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { TemplateEntityV1alpha1, - templateEntityV1alpha1Policy as policy, + templateEntityV1alpha1Validator as validator, } from './TemplateEntityV1alpha1'; -describe('templateEntityV1alpha1', () => { +describe('templateEntityV1alpha1Validator', () => { let entity: TemplateEntityV1alpha1; beforeEach(() => { @@ -53,41 +53,41 @@ describe('templateEntityV1alpha1', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('accepts any other type', async () => { (entity as any).spec.type = 'hallo'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects missing templater', async () => { (entity as any).spec.templater = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/templater/); + await expect(validator.check(entity)).rejects.toThrow(/templater/); }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index a26540fda2..b16fcdc7e4 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -17,7 +17,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; import type { JSONSchema } from '../types'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Template' as const; @@ -46,7 +46,7 @@ export interface TemplateEntityV1alpha1 extends Entity { }; } -export const templateEntityV1alpha1Policy = schemaPolicy( +export const templateEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts index 97fe0888f1..075f97e92c 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { UserEntityV1alpha1, - userEntityV1alpha1Policy as policy, + userEntityV1alpha1Validator as validator, } from './UserEntityV1alpha1'; -describe('userEntityV1alpha1Policy', () => { +describe('userEntityV1alpha1Validator', () => { let entity: UserEntityV1alpha1; beforeEach(() => { @@ -41,117 +41,117 @@ describe('userEntityV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); // root it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('spec accepts unknown additional fields', async () => { (entity as any).spec.foo = 'data'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); // profile it('accepts missing profile', async () => { delete (entity as any).spec.profile; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects wrong profile', async () => { (entity as any).spec.profile = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/profile/); + await expect(validator.check(entity)).rejects.toThrow(/profile/); }); it('profile accepts missing displayName', async () => { delete (entity as any).spec.profile.displayName; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('profile rejects wrong displayName', async () => { (entity as any).spec.profile.displayName = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/displayName/); + await expect(validator.check(entity)).rejects.toThrow(/displayName/); }); it('profile rejects empty displayName', async () => { (entity as any).spec.profile.displayName = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/displayName/); + await expect(validator.check(entity)).rejects.toThrow(/displayName/); }); it('profile accepts missing email', async () => { delete (entity as any).spec.profile.email; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('profile rejects wrong email', async () => { (entity as any).spec.profile.email = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/email/); + await expect(validator.check(entity)).rejects.toThrow(/email/); }); it('profile rejects empty email', async () => { (entity as any).spec.profile.email = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/email/); + await expect(validator.check(entity)).rejects.toThrow(/email/); }); it('profile accepts missing picture', async () => { delete (entity as any).spec.profile.picture; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('profile rejects wrong picture', async () => { (entity as any).spec.profile.picture = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/picture/); + await expect(validator.check(entity)).rejects.toThrow(/picture/); }); it('profile rejects empty picture', async () => { (entity as any).spec.profile.picture = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/picture/); + await expect(validator.check(entity)).rejects.toThrow(/picture/); }); it('profile accepts unknown additional fields', async () => { (entity as any).spec.profile.foo = 'data'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); // memberOf it('rejects missing memberOf', async () => { delete (entity as any).spec.memberOf; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); it('rejects wrong memberOf', async () => { (entity as any).spec.memberOf = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); it('rejects wrong memberOf item', async () => { (entity as any).spec.memberOf[0] = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); it('accepts empty memberOf', async () => { (entity as any).spec.memberOf = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects null memberOf', async () => { (entity as any).spec.memberOf = null; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); }); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index 6086b3776e..16a5a86e05 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'User' as const; @@ -59,4 +59,8 @@ export interface UserEntityV1alpha1 extends Entity { }; } -export const userEntityV1alpha1Policy = schemaPolicy(KIND, API_VERSION, schema); +export const userEntityV1alpha1Validator = schemaValidator( + KIND, + API_VERSION, + schema, +); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 4a3faf977b..914d7efea7 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -14,34 +14,35 @@ * limitations under the License. */ -export { apiEntityV1alpha1Policy } from './ApiEntityV1alpha1'; +export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1'; export type { ApiEntityV1alpha1 as ApiEntity, ApiEntityV1alpha1, } from './ApiEntityV1alpha1'; -export { componentEntityV1alpha1Policy } from './ComponentEntityV1alpha1'; +export { componentEntityV1alpha1Validator } from './ComponentEntityV1alpha1'; export type { ComponentEntityV1alpha1 as ComponentEntity, ComponentEntityV1alpha1, } from './ComponentEntityV1alpha1'; -export { groupEntityV1alpha1Policy } from './GroupEntityV1alpha1'; +export { groupEntityV1alpha1Validator } from './GroupEntityV1alpha1'; export type { GroupEntityV1alpha1 as GroupEntity, GroupEntityV1alpha1, } from './GroupEntityV1alpha1'; -export { locationEntityV1alpha1Policy } from './LocationEntityV1alpha1'; +export { locationEntityV1alpha1Validator } from './LocationEntityV1alpha1'; export type { LocationEntityV1alpha1 as LocationEntity, LocationEntityV1alpha1, } from './LocationEntityV1alpha1'; -export { templateEntityV1alpha1Policy } from './TemplateEntityV1alpha1'; +export * from './relations'; +export { templateEntityV1alpha1Validator } from './TemplateEntityV1alpha1'; export type { TemplateEntityV1alpha1 as TemplateEntity, TemplateEntityV1alpha1, } from './TemplateEntityV1alpha1'; -export { userEntityV1alpha1Policy } from './UserEntityV1alpha1'; +export type { KindValidator } from './types'; +export { userEntityV1alpha1Validator } from './UserEntityV1alpha1'; export type { UserEntityV1alpha1 as UserEntity, UserEntityV1alpha1, } from './UserEntityV1alpha1'; -export * from './relations'; diff --git a/packages/catalog-model/src/kinds/types.ts b/packages/catalog-model/src/kinds/types.ts new file mode 100644 index 0000000000..4b947680c7 --- /dev/null +++ b/packages/catalog-model/src/kinds/types.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '../entity'; + +/** + * Validates entities of a certain kind. + */ +export type KindValidator = { + /** + * Validates the entity as a known entity kind. + * + * @param entity The entity to validate + * @returns Resolves to true, if the entity was of a kind that was known and + * handled by this validator, and was found to be valid. Resolves to false, + * if the entity was not of a kind that was known by this validator. + * Rejects to an Error describing the problem, if the entity was of a kind + * that was known by this validator and was not valid. + */ + check(entity: Entity): Promise; +}; diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts index 491b2c5e2f..f1b02cfba1 100644 --- a/packages/catalog-model/src/kinds/util.ts +++ b/packages/catalog-model/src/kinds/util.ts @@ -15,23 +15,23 @@ */ import * as yup from 'yup'; -import { Entity } from '../entity'; -import { EntityPolicy } from '../types'; +import { KindValidator } from './types'; -export function schemaPolicy( +export function schemaValidator( kind: string, apiVersion: readonly string[], schema: yup.Schema, -): EntityPolicy { +): KindValidator { return { - async enforce(envelope: Entity): Promise { + async check(envelope) { if ( kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion as any) ) { - return undefined; + return false; } - return await schema.validate(envelope, { strict: true }); + await schema.validate(envelope, { strict: true }); + return true; }, }; } diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 45bf2e8ae0..8031b8ff0d 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -211,6 +211,29 @@ export class LocationReaders implements LocationReader { return undefined; } + let handled = false; + for (const processor of processors) { + if (processor.validateEntityKind) { + try { + handled = await processor.validateEntityKind(current); + if (handled) { + break; + } + } catch (e) { + const message = `Processor ${processor.constructor.name} threw an error while validating the entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; + emit(result.inputError(item.location, message)); + logger.warn(message); + return undefined; + } + } + } + if (!handled) { + const message = `No processor recognized the entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}`; + emit(result.inputError(item.location, message)); + logger.warn(message); + return undefined; + } + for (const processor of processors) { if (processor.postProcessEntity) { try { diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts new file mode 100644 index 0000000000..4d1eb5e583 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -0,0 +1,48 @@ +/* + * 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 { + apiEntityV1alpha1Validator, + componentEntityV1alpha1Validator, + Entity, + groupEntityV1alpha1Validator, + locationEntityV1alpha1Validator, + templateEntityV1alpha1Validator, + userEntityV1alpha1Validator, +} from '@backstage/catalog-model'; +import { CatalogProcessor } from './types'; + +export class BuiltinKindsEntityProcessor implements CatalogProcessor { + private readonly validators = [ + apiEntityV1alpha1Validator, + componentEntityV1alpha1Validator, + groupEntityV1alpha1Validator, + locationEntityV1alpha1Validator, + templateEntityV1alpha1Validator, + userEntityV1alpha1Validator, + ]; + + async validateEntityKind(entity: Entity): Promise { + for (const validator of this.validators) { + const result = await validator.check(entity); + if (result) { + return true; + } + } + + return false; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 5d353d5af0..5da3510f7a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -54,6 +54,19 @@ export type CatalogProcessor = { emit: CatalogProcessorEmit, ): Promise; + /** + * Validates the entity as a known entity kind, after it has been pre- + * processed and has passed through basic overall validation. + * + * @param entity The entity to validate + * @returns Resolves to true, if the entity was of a kind that was known and + * handled by this processor, and was found to be valid. Resolves to false, + * if the entity was not of a kind that was known by this processor. + * Rejects to an Error describing the problem, if the entity was of a kind + * that was known by this processor and was not valid. + */ + validateEntityKind?(entity: Entity): Promise; + /** * Post-processes an emitted entity, after it has been validated. * diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index f41d2805f2..45d92e6555 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -76,14 +76,6 @@ describe('CatalogBuilder', () => { }, }, ]) - .replaceEntityKinds([ - { - async enforce(entity: Entity) { - expect(entity.metadata.namespace).toBe('ns'); - return entity; - }, - }, - ]) .setPlaceholderResolver('t', async ({ value }) => { expect(value).toBe('tt'); return 'tt2'; @@ -100,15 +92,16 @@ describe('CatalogBuilder', () => { expect(location.type).toBe('test'); emit( result.entity(location, { - apiVersion: 'av', + apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'n', replaced: { $t: 'tt' } }, + spec: { type: 't', owner: 'o', lifecycle: 'l' }, }), ); return true; }, async preProcessEntity(entity) { - expect(entity.apiVersion).toBe('av'); + expect(entity.apiVersion).toBe('backstage.io/v1alpha1'); return { ...entity, metadata: { ...entity.metadata, namespace: 'ns' }, @@ -129,10 +122,10 @@ describe('CatalogBuilder', () => { type: 'test', target: '', }); - expect.assertions(7); + expect.assertions(6); expect(added.entities).toEqual([ { - apiVersion: 'av', + apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'n', @@ -143,6 +136,11 @@ describe('CatalogBuilder', () => { etag: expect.any(String), generation: expect.any(Number), }, + spec: { + type: 't', + owner: 'o', + lifecycle: 'l', + }, relations: [], }, ]); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 546dda6dca..b041dac5ab 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -16,19 +16,13 @@ import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; import { - apiEntityV1alpha1Policy, - componentEntityV1alpha1Policy, DefaultNamespaceEntityPolicy, EntityPolicies, EntityPolicy, FieldFormatEntityPolicy, - groupEntityV1alpha1Policy, - locationEntityV1alpha1Policy, makeValidator, NoForeignRootFieldsEntityPolicy, SchemaValidEntityPolicy, - templateEntityV1alpha1Policy, - userEntityV1alpha1Policy, Validators, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; @@ -47,17 +41,18 @@ import { CodeOwnersProcessor, FileReaderProcessor, GithubOrgReaderProcessor, - OwnerRelationProcessor, HigherOrderOperation, HigherOrderOperations, LocationReaders, LocationRefProcessor, + OwnerRelationProcessor, PlaceholderProcessor, PlaceholderResolver, StaticLocationProcessor, UrlReaderProcessor, } from '../ingestion'; import { CatalogRulesEnforcer } from '../ingestion/CatalogRules'; +import { BuiltinKindsEntityProcessor } from '../ingestion/processors/BuiltinKindsEntityProcessor'; import { LdapOrgReaderProcessor } from '../ingestion/processors/LdapOrgReaderProcessor'; import { jsonPlaceholderResolver, @@ -81,11 +76,6 @@ export type CatalogEnvironment = { * after the processors' pre-processing steps. All policies are given the * chance to inspect the entity, and all of them have to pass in order for * the entity to be considered valid from an overall point of view. - * - Entity kinds can be added or replaced. These are the second line of - * validation that is applied after the entity policies, which adds - * additional kind-specific validation (usually based on a schema). Only one - * of the entity kinds has to accept the entity, but if none of them do, the - * entity is rejected as a whole. * - Placeholder resolvers can be replaced or added. These run on the raw * structured data between the parsing and pre-processing steps, to replace * dollar-prefixed entries with their actual values (like $file). @@ -93,15 +83,13 @@ export type CatalogEnvironment = { * individual core fields such as metadata.name, to ensure that they adhere * to certain rules. * - Processors can be added or replaced. These implement the functionality of - * reading, parsing and processing the entity data before it is persisted in - * the catalog. + * reading, parsing, validating, and processing the entity data before it is + * persisted in the catalog. */ export class CatalogBuilder { private readonly env: CatalogEnvironment; private entityPolicies: EntityPolicy[]; private entityPoliciesReplace: boolean; - private entityKinds: EntityPolicy[]; - private entityKindsReplace: boolean; private placeholderResolvers: Record; private fieldFormatValidators: Partial; private processors: CatalogProcessor[]; @@ -111,8 +99,6 @@ export class CatalogBuilder { this.env = env; this.entityPolicies = []; this.entityPoliciesReplace = false; - this.entityKinds = []; - this.entityKindsReplace = false; this.placeholderResolvers = {}; this.fieldFormatValidators = {}; this.processors = []; @@ -154,33 +140,6 @@ export class CatalogBuilder { return this; } - /** - * Adds entity kinds that are used to validate a certain apiVersion/kind. One - * of the entity kind policies must match a given entity for it to be - * considered valid. - * - * @param policies One or more policies - */ - addEntityKind(...policies: EntityPolicy[]): CatalogBuilder { - this.entityKinds.push(...policies); - return this; - } - - /** - * Sets what entity policies that are used to validate a certain apiVersion/ - * kind. One of the entity kind policies must match a given entity for it to - * be considered valid. - * - * This function replaces the default set of kinds; use with care. - * - * @param policies One or more policies - */ - replaceEntityKinds(policies: EntityPolicy[]): CatalogBuilder { - this.entityKinds = [...policies]; - this.entityKindsReplace = true; - return this; - } - /** * Adds, or overwrites, a handler for placeholders (e.g. $file) in entity * definition files. @@ -291,22 +250,7 @@ export class CatalogBuilder { ...this.entityPolicies, ]; - const entityKinds: EntityPolicy[] = this.entityKindsReplace - ? this.entityKinds - : [ - componentEntityV1alpha1Policy, - groupEntityV1alpha1Policy, - userEntityV1alpha1Policy, - locationEntityV1alpha1Policy, - templateEntityV1alpha1Policy, - apiEntityV1alpha1Policy, - ...this.entityKinds, - ]; - - return EntityPolicies.allOf([ - EntityPolicies.allOf(entityPolicies), - EntityPolicies.oneOf(entityKinds), - ]); + return EntityPolicies.allOf(entityPolicies); } private buildProcessors(): CatalogProcessor[] { @@ -325,6 +269,7 @@ export class CatalogBuilder { const processors: CatalogProcessor[] = [ StaticLocationProcessor.fromConfig(config), new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }), + new BuiltinKindsEntityProcessor(), ]; // These are only added unless the user replaced them all