diff --git a/packages/backend/package.json b/packages/backend/package.json index 6be6678803..8e541a2194 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -2,6 +2,7 @@ "name": "example-backend", "version": "0.1.1-alpha.5", "main": "dist", + "types": "src/index.ts", "private": true, "license": "Apache-2.0", "engines": { diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 0f255b6d2c..015a967f76 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -6,9 +6,10 @@ "sourceMap": true, "declaration": true, "strict": true, - "target": "es5", + "target": "es2019", "module": "commonjs", "esModuleInterop": true, + "lib": ["es2019"], "types": ["node", "jest"] } } diff --git a/plugins/catalog-backend/fixtures/one_component.yaml b/plugins/catalog-backend/fixtures/one_component.yaml index e25d9bfdc2..421f66f7a8 100644 --- a/plugins/catalog-backend/fixtures/one_component.yaml +++ b/plugins/catalog-backend/fixtures/one_component.yaml @@ -1,4 +1,4 @@ -apiVersion: catalog.backstage.io/v1 +apiVersion: backstage.io/v1beta1 kind: Component metadata: name: component3 diff --git a/plugins/catalog-backend/fixtures/two_components.yaml b/plugins/catalog-backend/fixtures/two_components.yaml index 83c2aad6ea..d0e51ca79e 100644 --- a/plugins/catalog-backend/fixtures/two_components.yaml +++ b/plugins/catalog-backend/fixtures/two_components.yaml @@ -1,12 +1,12 @@ --- -apiVersion: catalog.backstage.io/v1 +apiVersion: backstage.io/v1beta1 kind: Component metadata: name: component1 spec: type: service --- -apiVersion: catalog.backstage.io/v1 +apiVersion: backstage.io/v1beta1 kind: Component metadata: name: component2 diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index b4aa397883..f4c724db7e 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -23,6 +23,7 @@ "fs-extra": "^9.0.0", "helmet": "^3.22.0", "knex": "^0.21.1", + "lodash": "^4.17.15", "morgan": "^1.10.0", "sqlite3": "^4.2.0", "uuid": "^8.0.0", @@ -33,6 +34,7 @@ }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.5", + "@types/lodash": "^4.14.151", "@types/uuid": "^7.0.3", "@types/yup": "^0.28.2", "jest-fetch-mock": "^3.0.3", diff --git a/plugins/catalog-backend/src/catalog/StaticItemsCatalog.ts b/plugins/catalog-backend/src/catalog/StaticItemsCatalog.ts index 0544ea9ba8..30b5f554c0 100644 --- a/plugins/catalog-backend/src/catalog/StaticItemsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/StaticItemsCatalog.ts @@ -29,7 +29,7 @@ export class StaticItemsCatalog implements ItemsCatalog { } async component(name: string): Promise { - const item = this._components.find((i) => i.name === name); + const item = this._components.find(i => i.name === name); if (!item) { throw new NotFoundError(`Found no component with name ${name}`); } diff --git a/plugins/catalog-backend/src/database/Database.ts b/plugins/catalog-backend/src/database/Database.ts index 19fdd38ff0..d9278783ef 100644 --- a/plugins/catalog-backend/src/database/Database.ts +++ b/plugins/catalog-backend/src/database/Database.ts @@ -16,7 +16,7 @@ import Knex from 'knex'; import { v4 as uuidv4 } from 'uuid'; -import { NotFoundError } from '../../../../packages/backend-common/src/errors'; +import { NotFoundError } from '@backstage/backend-common'; import { AddDatabaseComponent, AddDatabaseLocation, @@ -28,7 +28,7 @@ export class Database { constructor(private readonly database: Knex) {} async addOrUpdateComponent(component: AddDatabaseComponent): Promise { - await this.database.transaction(async (tx) => { + await this.database.transaction(async tx => { // TODO(freben): Currently, several locations can compete for the same component // TODO(freben): If locationId is unset in the input, it won't be overwritten - should we instead replace with null? const count = await tx('components') @@ -60,7 +60,7 @@ export class Database { } async addLocation(location: AddDatabaseLocation): Promise { - return await this.database.transaction(async (tx) => { + return await this.database.transaction(async tx => { const existingLocation = await tx('locations') .where({ target: location.target, @@ -79,9 +79,9 @@ export class Database { target, }); - return ( - await tx('locations').where({ id }).select() - )![0]; + return (await tx('locations') + .where({ id }) + .select())![0]; }); } diff --git a/plugins/catalog-backend/src/database/DatabaseManager.test.ts b/plugins/catalog-backend/src/database/DatabaseManager.test.ts index 77c9a463e2..20b241555a 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.test.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.test.ts @@ -65,6 +65,8 @@ describe('DatabaseManager', () => { } as unknown) as Database; const desc: ComponentDescriptor = { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', metadata: { name: 'c1' }, spec: { type: 'service' }, }; diff --git a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts b/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts index 4c71fefddd..ae45ad3ddb 100644 --- a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts +++ b/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts @@ -18,7 +18,7 @@ import * as Knex from 'knex'; export async function up(knex: Knex): Promise { return knex.schema - .createTable('locations', (table) => { + .createTable('locations', table => { table.comment( 'Registered locations that shall be contiuously scanned for catalog item updates', ); @@ -29,7 +29,7 @@ export async function up(knex: Knex): Promise { .notNullable() .comment('The actual target of the location'); }) - .createTable('components', (table) => { + .createTable('components', table => { table.comment('All components currently stored in the catalog'); table.uuid('id').primary().comment('Auto-generated ID of the component'); table diff --git a/plugins/catalog-backend/src/ingestion/DescriptorParsers.ts b/plugins/catalog-backend/src/ingestion/DescriptorParsers.ts index 37f7ac1233..151e2053e9 100644 --- a/plugins/catalog-backend/src/ingestion/DescriptorParsers.ts +++ b/plugins/catalog-backend/src/ingestion/DescriptorParsers.ts @@ -14,22 +14,28 @@ * limitations under the License. */ -import { ComponentDescriptorV1Parser } from './descriptors/ComponentDescriptorV1Parser'; -import { parseDescriptorEnvelope } from './descriptors/DescriptorEnvelope'; -import { EnvelopeParser } from './descriptors/types'; +import { DescriptorEnvelopeParser } from './descriptors/DescriptorEnvelopeParser'; +import { ComponentDescriptorV1beta1Parser } from './descriptors/ComponentDescriptorV1beta1Parser'; +import { KindParser } from './descriptors/types'; import { DescriptorParser, ParserOutput } from './types'; +import { makeValidator } from '../validation'; export class DescriptorParsers implements DescriptorParser { static create(): DescriptorParser { - return new DescriptorParsers([new ComponentDescriptorV1Parser()]); + const validators = makeValidator(); + return new DescriptorParsers(new DescriptorEnvelopeParser(validators), [ + new ComponentDescriptorV1beta1Parser(), + ]); } - constructor(private readonly parsers: EnvelopeParser[]) {} + constructor( + private readonly envelopeParser: DescriptorEnvelopeParser, + private readonly kindParsers: KindParser[], + ) {} async parse(descriptor: object): Promise { - const envelope = await parseDescriptorEnvelope(descriptor); - - for (const parser of this.parsers) { + const envelope = await this.envelopeParser.parse(descriptor); + for (const parser of this.kindParsers) { const parsed = await parser.tryParse(envelope); if (parsed) { return parsed; diff --git a/plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1Parser.ts b/plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1beta1Parser.ts similarity index 56% rename from plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1Parser.ts rename to plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1beta1Parser.ts index bb08dd15e4..f7331f9838 100644 --- a/plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1Parser.ts +++ b/plugins/catalog-backend/src/ingestion/descriptors/ComponentDescriptorV1beta1Parser.ts @@ -16,48 +16,53 @@ import * as yup from 'yup'; import { ParserOutput } from '../types'; -import { DescriptorEnvelope } from './DescriptorEnvelope'; -import { EnvelopeParser } from './types'; +import { DescriptorEnvelope } from './DescriptorEnvelopeParser'; +import { KindParser } from './types'; -export type ComponentDescriptorV1 = { +export interface ComponentDescriptorV1beta1 extends DescriptorEnvelope { metadata: { name: string; }; spec: { type: string; }; -}; +} -const schema: yup.Schema = yup.object({ - metadata: yup.object({ - name: yup.string().required(), - }), - spec: yup.object({ - type: yup.string().required(), - }), -}); +export class ComponentDescriptorV1beta1Parser implements KindParser { + private schema: yup.Schema; + + constructor() { + this.schema = yup.object>({ + metadata: yup + .object({ + name: yup.string().required(), + }) + .required(), + spec: yup + .object({ + type: yup.string().required(), + }) + .required(), + }); + } -export class ComponentDescriptorV1Parser implements EnvelopeParser { async tryParse( envelope: DescriptorEnvelope, ): Promise { if ( - envelope.apiVersion !== 'catalog.backstage.io/v1' || + envelope.apiVersion !== 'backstage.io/v1beta1' || envelope.kind !== 'Component' ) { return undefined; } - let component; try { - component = await schema.validate(envelope, { strict: true }); + return { + kind: 'Component', + component: await this.schema.validate(envelope, { strict: true }), + }; } catch (e) { throw new Error(`Malformed component, ${e}`); } - - return { - kind: 'Component', - component, - }; } } diff --git a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelope.ts b/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelope.ts deleted file mode 100644 index 90193bcc7e..0000000000 --- a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelope.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 * as yup from 'yup'; - -export type DescriptorEnvelope = { - apiVersion: string; - kind: string; - metadata?: object; - spec?: object; -}; - -// The schema of the envelope that's common to all versions/kinds -const descriptorEnvelopeSchema: yup.Schema = yup - .object({ - apiVersion: yup.string().required(), - kind: yup.string().required(), - metadata: yup.object(), - spec: yup.object(), - }) - .noUnknown(); - -// Validate some raw structured data as a descriptor envelope -export async function parseDescriptorEnvelope( - data: object, -): Promise { - try { - return await descriptorEnvelopeSchema.validate(data, { strict: true }); - } catch (e) { - throw new Error(`Malformed envelope, ${e}`); - } -} diff --git a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.test.ts b/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.test.ts new file mode 100644 index 0000000000..1398e08ce2 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.test.ts @@ -0,0 +1,139 @@ +/* + * 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 yaml from 'yaml'; +import { makeValidator } from '../../validation'; +import { DescriptorEnvelopeParser } from './DescriptorEnvelopeParser'; + +describe('DescriptorEnvelopeParser', () => { + let data: any; + let parser: DescriptorEnvelopeParser; + + beforeEach(() => { + data = yaml.parse(` + apiVersion: backstage.io/v1beta1 + kind: Component + metadata: + name: my-component-yay + namespace: the-namespace + labels: + backstage.io/custom: ValueStuff + annotations: + example.com/bindings: are-secret + spec: + custom: stuff + `); + parser = new DescriptorEnvelopeParser(makeValidator()); + }); + + it('works for the happy path', async () => { + await expect(parser.parse(data)).resolves.toBe(data); + }); + + it('rejects missing apiVersion', async () => { + delete data.apiVersion; + await expect(parser.parse(data)).rejects.toThrow(/apiVersion/); + }); + + it('rejects wrong root type', async () => { + await expect(parser.parse(7)).rejects.toThrow(/object/); + }); + + it('rejects bad apiVersion', async () => { + data.apiVersion = 'a#b'; + await expect(parser.parse(data)).rejects.toThrow(/apiVersion/); + }); + + it('rejects missing kind', async () => { + delete data.kind; + await expect(parser.parse(data)).rejects.toThrow(/kind/); + }); + + it('rejects bad kind', async () => { + data.kind = 'a#b'; + await expect(parser.parse(data)).rejects.toThrow(/kind/); + }); + + it('accepts missing metadata', async () => { + delete data.medatata; + await expect(parser.parse(data)).resolves.toBe(data); + }); + + it('rejects non-object metadata', async () => { + data.metadata = 7; + await expect(parser.parse(data)).rejects.toThrow(/metadata/); + }); + + it('accepts missing spec', async () => { + delete data.spec; + await expect(parser.parse(data)).resolves.toBe(data); + }); + + it('rejects non-object spec', async () => { + data.spec = 7; + await expect(parser.parse(data)).rejects.toThrow(/spec/); + }); + + it('rejects bad name', async () => { + data.metadata.name = 7; + await expect(parser.parse(data)).rejects.toThrow(/name/); + }); + + it('rejects bad namespace', async () => { + data.metadata.namespace = 7; + await expect(parser.parse(data)).rejects.toThrow(/namespace/); + }); + + it('rejects bad label key', async () => { + data.metadata.labels['a#b'] = 'value'; + await expect(parser.parse(data)).rejects.toThrow(/label.*key/i); + }); + + it('rejects bad label value', async () => { + data.metadata.labels.a = 'a#b'; + await expect(parser.parse(data)).rejects.toThrow(/label.*value/i); + }); + + it('rejects bad annotation key', async () => { + data.metadata.annotations['a#b'] = 'value'; + await expect(parser.parse(data)).rejects.toThrow(/annotation.*key/i); + }); + + it('rejects bad annotation value', async () => { + data.metadata.annotations.a = []; + await expect(parser.parse(data)).rejects.toThrow(/annotation.*value/i); + }); + + it('rejects unknown root keys', async () => { + data.spec2 = {}; + await expect(parser.parse(data)).rejects.toThrow(/spec2/i); + }); + + it('rejects reserved keys in the spec root', async () => { + data.spec.apiVersion = 'a/b'; + await expect(parser.parse(data)).rejects.toThrow(/spec.*apiVersion/i); + }); + + it('rejects reserved keys in labels', async () => { + data.metadata.labels.apiVersion = 'a'; + await expect(parser.parse(data)).rejects.toThrow(/label.*apiVersion/i); + }); + + it('rejects reserved keys in annotations', async () => { + data.metadata.annotations.apiVersion = 'a'; + await expect(parser.parse(data)).rejects.toThrow(/annotation.*apiVersion/i); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.ts b/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.ts new file mode 100644 index 0000000000..634041d8a3 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/descriptors/DescriptorEnvelopeParser.ts @@ -0,0 +1,187 @@ +/* + * 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 * as yup from 'yup'; +import { Validators } from '../../validation'; + +/** + * The format envelope that's common to all versions/kinds + */ +export type DescriptorEnvelope = { + apiVersion: string; + kind: string; + metadata?: { + name?: string; + namespace?: string; + labels?: object; + annotations?: object; + }; + spec?: object; +}; + +/** + * Parses some raw structured data as a descriptor envelope + */ +export class DescriptorEnvelopeParser { + private schema: yup.Schema; + + constructor(validators: Validators) { + const apiVersionSchema = yup + .string() + .required() + .test( + 'apiVersion', + 'The apiVersion is not formatted according to schema', + validators.isValidApiVersion, + ); + + const kindSchema = yup + .string() + .required() + .test( + 'kind', + 'The kind is not formatted according to schema', + validators.isValidKind, + ); + + const nameSchema = yup + .string() + .notRequired() + .test( + 'metadata.name', + 'The name is not formatted according to schema', + value => value === undefined || validators.isValidEntityName(value), + ); + + const namespaceSchema = yup + .string() + .notRequired() + .test( + 'metadata.namespace', + 'The namespace is malformed', + value => value === undefined || validators.isValidEntityName(value), + ); + + const labelsSchema = yup + .object() + .notRequired() + .test({ + name: 'metadata.labels.keys', + message: 'Label keys not formatted according to schema', + test(value: object) { + return ( + value === undefined || + Object.keys(value).every(validators.isValidLabelKey) + ); + }, + }) + .test({ + name: 'metadata.labels.values', + message: 'Label values not formatted according to schema', + test(value: object) { + return ( + value === undefined || + Object.values(value).every(validators.isValidLabelValue) + ); + }, + }); + + const annotationsSchema = yup + .object() + .notRequired() + .test({ + name: 'metadata.annotations.keys', + message: 'Annotation keys not formatted according to schema', + test(value: object) { + return ( + value === undefined || + Object.keys(value).every(validators.isValidAnnotationKey) + ); + }, + }) + .test({ + name: 'metadata.annotations.values', + message: 'Annotation values not formatted according to schema', + test(value: object) { + return ( + value === undefined || + Object.values(value).every(validators.isValidAnnotationValue) + ); + }, + }); + + const metadataSchema = yup + .object({ + name: nameSchema, + namespace: namespaceSchema, + labels: labelsSchema, + annotations: annotationsSchema, + }) + .notRequired(); + + const specSchema = yup.object({}).notRequired(); + + this.schema = yup + .object({ + apiVersion: apiVersionSchema, + kind: kindSchema, + metadata: metadataSchema, + spec: specSchema, + }) + .noUnknown(); + } + + async parse(data: any): Promise { + let result: DescriptorEnvelope; + try { + result = await this.schema.validate(data, { strict: true }); + } catch (e) { + throw new Error(`Malformed envelope, ${e}`); + } + + // These are keys with specific semantic meaning in a document, that we do + // not want to appear in the root of the spec, or as labels or as + // annotations, because they will lead to confusion. + const reservedKeys = [ + 'apiVersion', + 'kind', + 'name', + 'namespace', + 'labels', + 'annotations', + 'spec', + ]; + for (const key of reservedKeys) { + if (result.spec?.hasOwnProperty(key)) { + throw new Error( + `The spec may not contain the key ${key}, because it has reserved meaning`, + ); + } + if (result.metadata?.labels?.hasOwnProperty(key)) { + throw new Error( + `A label may not have the key ${key}, because it has reserved meaning`, + ); + } + if (result.metadata?.annotations?.hasOwnProperty(key)) { + throw new Error( + `An annotation may not have the key ${key}, because it has reserved meaning`, + ); + } + } + + return result; + } +} diff --git a/plugins/catalog-backend/src/ingestion/descriptors/types.ts b/plugins/catalog-backend/src/ingestion/descriptors/types.ts index 64900c9ad4..da444f3020 100644 --- a/plugins/catalog-backend/src/ingestion/descriptors/types.ts +++ b/plugins/catalog-backend/src/ingestion/descriptors/types.ts @@ -15,16 +15,20 @@ */ import { ParserOutput } from '../types'; -import { DescriptorEnvelope } from './DescriptorEnvelope'; +import { DescriptorEnvelope } from './DescriptorEnvelopeParser'; -export type EnvelopeParser = { +export type KindParser = { /** - * Parses and validates a single envelope into its materialized type. + * Parses and validates a single envelope into its materialized kind. * - * @param envelope A descriptor envelope + * These parsers may assume that the envelope is already validated and + * well formed. + * + * @param envelope A valid descriptor envelope * @returns A materialized type, or undefined if the given version/kind is * not handled by this parser - * @throws An Error if the type was not properly formatted + * @throws An Error if the type was handled and found to not be properly + * formatted */ tryParse(envelope: DescriptorEnvelope): Promise; }; diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index 11de959967..b6bd062383 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { ComponentDescriptorV1 } from './descriptors/ComponentDescriptorV1Parser'; +import { ComponentDescriptorV1beta1 } from './descriptors/ComponentDescriptorV1beta1Parser'; -export type ComponentDescriptor = ComponentDescriptorV1; +export type ComponentDescriptor = ComponentDescriptorV1beta1; export type ParserOutput = { kind: 'Component'; diff --git a/plugins/catalog-backend/src/run.ts b/plugins/catalog-backend/src/run.ts index 9c086081a7..f4845168aa 100644 --- a/plugins/catalog-backend/src/run.ts +++ b/plugins/catalog-backend/src/run.ts @@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); -startStandaloneServer({ port, enableCors, logger }).catch((err) => { +startStandaloneServer({ port, enableCors, logger }).catch(err => { logger.error(err); process.exit(1); }); diff --git a/plugins/catalog-backend/src/util/runPeriodically.ts b/plugins/catalog-backend/src/util/runPeriodically.ts index 2d02287540..2f65d0e6f3 100644 --- a/plugins/catalog-backend/src/util/runPeriodically.ts +++ b/plugins/catalog-backend/src/util/runPeriodically.ts @@ -27,7 +27,7 @@ export function runPeriodically(fn: () => any, delayMs: number): () => void { let cancel: () => void; let cancelled = false; - const cancellationPromise = new Promise((resolve) => { + const cancellationPromise = new Promise(resolve => { cancel = () => { resolve(); cancelled = true; @@ -43,7 +43,7 @@ export function runPeriodically(fn: () => any, delayMs: number): () => void { } await Promise.race([ - new Promise((resolve) => setTimeout(resolve, delayMs)), + new Promise(resolve => setTimeout(resolve, delayMs)), cancellationPromise, ]); } diff --git a/plugins/catalog-backend/src/validation/CommonValidatorFunctions.test.ts b/plugins/catalog-backend/src/validation/CommonValidatorFunctions.test.ts new file mode 100644 index 0000000000..e3f15b915f --- /dev/null +++ b/plugins/catalog-backend/src/validation/CommonValidatorFunctions.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { CommonValidatorFunctions } from './CommonValidatorFunctions'; + +describe('CommonValidatorFunctions', () => { + describe('isValidPrefixAndOrSuffix', () => { + it('only accepts strings', () => { + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + null, + '/', + () => true, + () => true, + ), + ).toBe(false); + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + 7, + '/', + () => true, + () => true, + ), + ).toBe(false); + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + () => 'hello', + '/', + () => true, + () => true, + ), + ).toBe(false); + }); + + it('only accepts one or two parts', () => { + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + 'a', + '/', + () => true, + () => true, + ), + ).toBe(true); + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + 'a/b', + '/', + () => true, + () => true, + ), + ).toBe(true); + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + 'a/b/c', + '/', + () => true, + () => true, + ), + ).toBe(false); + }); + + it('checks the prefix and suffix', () => { + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + 'a/b', + '/', + () => true, + () => true, + ), + ).toBe(true); + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + 'a/b', + '/', + () => false, + () => true, + ), + ).toBe(false); + expect( + CommonValidatorFunctions.isValidPrefixAndOrSuffix( + 'a/b', + '/', + () => true, + () => false, + ), + ).toBe(false); + }); + }); + + it.each([ + [null, true], + [undefined, false], + [1, true], + ['a', true], + [() => 'a', false], + [Symbol('a'), false], + [[], true], + [[1], true], + [[undefined], false], + [{}, true], + [{ a: 1 }, true], + [{ a: undefined }, false], + ] as [any, boolean][])('isJsonSafe', (value, result) => { + expect(CommonValidatorFunctions.isJsonSafe(value)).toBe(result); + }); + + it.each([ + [null, false], + [7, false], + ['', false], + ['a', true], + ['a-b', true], + ['-a-b', false], + ['a-b-', false], + ['a--b', false], + ['a_b', false], + ['adam.bertil.caesar', true], + ['adam.ber-til.caesar', true], + ['adam.-bertil.caesar', false], + ['adam.bertil-.caesar', false], + ['adam/bertil.caesar', false], + [`a.${'b'.repeat(63)}.c`, true], + [`a.${'b'.repeat(64)}.c`, false], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(61)}`, + true, + ], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(62)}`, + false, + ], + ])('isValidDnsSubdomain', (value, result) => { + expect(CommonValidatorFunctions.isValidDnsSubdomain(value)).toBe(result); + }); + + it.each([ + [null, false], + [7, false], + ['', false], + ['a', true], + ['a-b', true], + ['-a-b', false], + ['a-b-', false], + ['a--b', false], + ['a_b', false], + [`${'a'.repeat(63)}`, true], + [`${'a'.repeat(64)}`, false], + ])('isValidDnsLabel', (value, result) => { + expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result); + }); + + it.each([ + ['', ''], + ['a', 'a'], + ['a-b', 'ab'], + ['-a-b', 'ab'], + ['a_b', 'ab'], + [`${'a'.repeat(6000)}`, `${'a'.repeat(6000)}`], + ['_:;>!"#€', ''], + ])('normalizeToLowercaseAlphanum', (value, result) => { + expect(CommonValidatorFunctions.normalizeToLowercaseAlphanum(value)).toBe( + result, + ); + }); +}); diff --git a/plugins/catalog-backend/src/validation/CommonValidatorFunctions.ts b/plugins/catalog-backend/src/validation/CommonValidatorFunctions.ts new file mode 100644 index 0000000000..96a91aca06 --- /dev/null +++ b/plugins/catalog-backend/src/validation/CommonValidatorFunctions.ts @@ -0,0 +1,108 @@ +/* + * 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 lodash from 'lodash'; + +/** + * Contains various helper validation and normalization functions that can be + * composed to form a Validator. + */ +export class CommonValidatorFunctions { + /** + * Checks that the value is on the form or , and validates + * those parts separately. + * + * @param value The value to check + * @param separator The separator between parts + * @param isValidPrefix Checks that the part before the separator is valid, if present + * @param isValidSuffix Checks that the part after the separator (or the entire value if there is no separator) is valid + */ + static isValidPrefixAndOrSuffix( + value: any, + separator: string, + isValidPrefix: (value: string) => boolean, + isValidSuffix: (value: string) => boolean, + ): boolean { + if (typeof value !== 'string') { + return false; + } + + const parts = value.split(separator); + if (parts.length === 1) { + return isValidSuffix(parts[0]); + } else if (parts.length === 2) { + return isValidPrefix(parts[0]) && isValidSuffix(parts[1]); + } + + return false; + } + + /** + * Checks that the value can be safely transferred as JSON. + * + * @param value The value to check + */ + static isJsonSafe(value: any): boolean { + try { + return lodash.isEqual(value, JSON.parse(JSON.stringify(value))); + } catch { + return false; + } + } + + /** + * Checks that the value is a valid DNS subdomain name. + * + * @param value The value to check + * @see https://tools.ietf.org/html/rfc1123 + */ + static isValidDnsSubdomain(value: any): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 253 && + value.split('.').every(CommonValidatorFunctions.isValidDnsLabel) + ); + } + + /** + * Checks that the value is a valid DNS label. + * + * @param value The value to check + * @see https://tools.ietf.org/html/rfc1123 + */ + static isValidDnsLabel(value: any): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 63 && + /^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value) + ); + } + + /** + * Normalizes by keeping only a-z, A-Z, and 0-9; and converts to lowercase. + * + * @param value The value to normalize + */ + static normalizeToLowercaseAlphanum(value: string): string { + return value + .split('') + .filter(x => /[a-zA-Z0-9]/.test(x)) + .join('') + .toLowerCase(); + } +} diff --git a/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.test.ts b/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.test.ts new file mode 100644 index 0000000000..946888dc91 --- /dev/null +++ b/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.test.ts @@ -0,0 +1,190 @@ +/* + * 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 { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions'; + +describe('KubernetesValidatorFunctions', () => { + it.each([ + [7, false], + [null, false], + ['', false], + ['a', true], + ['AZ09', true], + ['a'.repeat(63), true], + ['a'.repeat(64), false], + ['a-b', false], + ['a_b', false], + ['a.b', false], + ['a/a', true], + ['a/aAb5C', true], + ['a-b.c/v1', true], + ['a--b.c/v1', false], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat( + 61, + )}/v1`, + true, + ], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat( + 62, + )}/v1`, + false, + ], + [`a/${'a'.repeat(63)}`, true], + [`a/${'a'.repeat(64)}`, false], + ])('isValidApiVersion', (value, matches) => { + expect(KubernetesValidatorFunctions.isValidApiVersion(value)).toBe(matches); + }); + + it.each([ + [7, false], + [null, false], + ['', false], + ['a', true], + ['AZ09', true], + ['9AZ', false], + ['a'.repeat(63), true], + ['a'.repeat(64), false], + ['a-b', false], + ])('isValidKind', (value, matches) => { + expect(KubernetesValidatorFunctions.isValidKind(value)).toBe(matches); + }); + + it.each([ + [7, false], + [null, false], + ['', false], + ['a', true], + ['AZ09', true], + ['a'.repeat(63), true], + ['a'.repeat(64), false], + ['a/b', false], + ['a-b', true], + ['-a-b', false], + ['a-b-', false], + ['a--b', false], + ['a_b', true], + ['a.b', true], + ])('isValidObjectName', (value, matches) => { + expect(KubernetesValidatorFunctions.isValidObjectName(value)).toBe(matches); + }); + + it.each([ + [7, false], + [null, false], + ['', false], + ['a', true], + ['AZ09', true], + ['a'.repeat(63), true], + ['a'.repeat(64), false], + ['a/b', true], + ['a-b', true], + ['-a-b', false], + ['a-b-', false], + ['a--b', false], + ['a_b', true], + ['a.b', true], + ['a/a', true], + ['a-b.c/a', true], + ['a--b.c/a', false], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat( + 61, + )}/a`, + true, + ], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat( + 62, + )}/a`, + false, + ], + [`a/${'a'.repeat(63)}`, true], + [`a/${'a'.repeat(64)}`, false], + ])('isValidLabelKey', (value, matches) => { + expect(KubernetesValidatorFunctions.isValidLabelKey(value)).toBe(matches); + }); + + it.each([ + [7, false], + [null, false], + ['', true], + ['a', true], + ['AZ09', true], + ['a'.repeat(63), true], + ['a'.repeat(64), false], + ['a/b', false], + ['a-b', true], + ['-a-b', false], + ['a-b-', false], + ['a--b', false], + ['a_b', true], + ['a.b', true], + ])('isValidLabelValue', (value, matches) => { + expect(KubernetesValidatorFunctions.isValidLabelValue(value)).toBe(matches); + }); + + it.each([ + [7, false], + [null, false], + ['', false], + ['a', true], + ['AZ09', true], + ['a'.repeat(63), true], + ['a'.repeat(64), false], + ['a/b', true], + ['a-b', true], + ['-a-b', false], + ['a-b-', false], + ['a--b', false], + ['a_b', true], + ['a.b', true], + ['a/a', true], + ['a-b.c/a', true], + ['a--b.c/a', false], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat( + 61, + )}/a`, + true, + ], + [ + `${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat( + 62, + )}/a`, + false, + ], + [`a/${'a'.repeat(63)}`, true], + [`a/${'a'.repeat(64)}`, false], + ])('isValidAnnotationKey', (value, matches) => { + expect(KubernetesValidatorFunctions.isValidAnnotationKey(value)).toBe( + matches, + ); + }); + + it.each([ + [7, false], + [null, false], + ['', true], + ['a', true], + ['/'.repeat(6000), true], + ])('isValidAnnotationValue', (value, matches) => { + expect(KubernetesValidatorFunctions.isValidAnnotationValue(value)).toBe( + matches, + ); + }); +}); diff --git a/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.ts b/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.ts new file mode 100644 index 0000000000..dff3216969 --- /dev/null +++ b/plugins/catalog-backend/src/validation/KubernetesValidatorFunctions.ts @@ -0,0 +1,82 @@ +/* + * 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 { CommonValidatorFunctions } from './CommonValidatorFunctions'; + +/** + * Contains validation functions that match the Kubernetes spec, usable to + * build a catalog that is compatible with those rule sets. + * + * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/ + * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set + * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set + */ +export class KubernetesValidatorFunctions { + static isValidApiVersion(value: any): boolean { + return CommonValidatorFunctions.isValidPrefixAndOrSuffix( + value, + '/', + CommonValidatorFunctions.isValidDnsSubdomain, + n => n.length >= 1 && n.length <= 63 && /^[a-z0-9A-Z]+$/.test(n), + ); + } + + static isValidKind(value: any): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 63 && + /^[a-zA-Z][a-z0-9A-Z]*$/.test(value) + ); + } + + static isValidObjectName(value: any): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 63 && + /^[a-z0-9A-Z]+([-_.][a-z0-9A-Z]+)*$/.test(value) + ); + } + + static isValidLabelKey(value: any): boolean { + return CommonValidatorFunctions.isValidPrefixAndOrSuffix( + value, + '/', + CommonValidatorFunctions.isValidDnsSubdomain, + KubernetesValidatorFunctions.isValidObjectName, + ); + } + + static isValidLabelValue(value: any): boolean { + return ( + value === '' || KubernetesValidatorFunctions.isValidObjectName(value) + ); + } + + static isValidAnnotationKey(value: any): boolean { + return CommonValidatorFunctions.isValidPrefixAndOrSuffix( + value, + '/', + CommonValidatorFunctions.isValidDnsSubdomain, + KubernetesValidatorFunctions.isValidObjectName, + ); + } + + static isValidAnnotationValue(value: any): boolean { + return typeof value === 'string'; + } +} diff --git a/plugins/catalog-backend/src/validation/index.ts b/plugins/catalog-backend/src/validation/index.ts new file mode 100644 index 0000000000..be607e43ec --- /dev/null +++ b/plugins/catalog-backend/src/validation/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export * from './CommonValidatorFunctions'; +export * from './KubernetesValidatorFunctions'; +export * from './makeValidator'; +export * from './types'; diff --git a/plugins/catalog-backend/src/validation/makeValidator.ts b/plugins/catalog-backend/src/validation/makeValidator.ts new file mode 100644 index 0000000000..3f538c23e7 --- /dev/null +++ b/plugins/catalog-backend/src/validation/makeValidator.ts @@ -0,0 +1,37 @@ +/* + * 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 { Validators } from './types'; +import { CommonValidatorFunctions } from './CommonValidatorFunctions'; +import { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions'; + +const defaultValidators: Validators = { + isValidApiVersion: KubernetesValidatorFunctions.isValidApiVersion, + isValidKind: KubernetesValidatorFunctions.isValidKind, + isValidEntityName: KubernetesValidatorFunctions.isValidObjectName, + normalizeEntityName: CommonValidatorFunctions.normalizeToLowercaseAlphanum, + isValidLabelKey: KubernetesValidatorFunctions.isValidLabelKey, + isValidLabelValue: KubernetesValidatorFunctions.isValidLabelValue, + isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey, + isValidAnnotationValue: KubernetesValidatorFunctions.isValidAnnotationValue, +}; + +export function makeValidator(overrides: Partial = {}): Validators { + return { + ...defaultValidators, + ...overrides, + }; +} diff --git a/plugins/catalog-backend/src/validation/types.ts b/plugins/catalog-backend/src/validation/types.ts new file mode 100644 index 0000000000..f659d41da9 --- /dev/null +++ b/plugins/catalog-backend/src/validation/types.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +export type Validators = { + isValidApiVersion(value: any): boolean; + isValidKind(value: any): boolean; + isValidEntityName(value: any): boolean; + normalizeEntityName(value: string): string; + isValidLabelKey(value: any): boolean; + isValidLabelValue(value: any): boolean; + isValidAnnotationKey(value: any): boolean; + isValidAnnotationValue(value: any): boolean; +}; diff --git a/yarn.lock b/yarn.lock index 8b7749a06c..0d2cda2b05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4052,6 +4052,11 @@ resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440" integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ== +"@types/lodash@^4.14.151": + version "4.14.151" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.151.tgz#7d58cac32bedb0ec37cb7f99094a167d6176c9d5" + integrity sha512-Zst90IcBX5wnwSu7CAS0vvJkTjTELY4ssKbHiTnGcJgi170uiS8yQDdc3v6S77bRqYQIN1App5a1Pc2lceE5/g== + "@types/mime@*": version "2.0.1" resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"