Merge pull request #918 from spotify/freben/annotations

catalog backend: parse but do not store labels + annotations
This commit is contained in:
Fredrik Adelöw
2020-05-20 10:30:37 +02:00
committed by GitHub
26 changed files with 1045 additions and 97 deletions
@@ -1,4 +1,4 @@
apiVersion: catalog.backstage.io/v1
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: component3
@@ -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
+2
View File
@@ -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",
@@ -29,7 +29,7 @@ export class StaticItemsCatalog implements ItemsCatalog {
}
async component(name: string): Promise<Component> {
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}`);
}
@@ -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<void> {
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<DatabaseComponent>('components')
@@ -60,7 +60,7 @@ export class Database {
}
async addLocation(location: AddDatabaseLocation): Promise<DatabaseLocation> {
return await this.database.transaction<DatabaseLocation>(async (tx) => {
return await this.database.transaction<DatabaseLocation>(async tx => {
const existingLocation = await tx<DatabaseLocation>('locations')
.where({
target: location.target,
@@ -79,9 +79,9 @@ export class Database {
target,
});
return (
await tx<DatabaseLocation>('locations').where({ id }).select()
)![0];
return (await tx<DatabaseLocation>('locations')
.where({ id })
.select())![0];
});
}
@@ -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' },
};
@@ -18,7 +18,7 @@ import * as Knex from 'knex';
export async function up(knex: Knex): Promise<any> {
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<any> {
.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
@@ -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<ParserOutput> {
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;
@@ -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<ComponentDescriptorV1> = 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<any>;
constructor() {
this.schema = yup.object<Partial<ComponentDescriptorV1beta1>>({
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<ParserOutput | undefined> {
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,
};
}
}
@@ -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<DescriptorEnvelope> = 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<DescriptorEnvelope> {
try {
return await descriptorEnvelopeSchema.validate(data, { strict: true });
} catch (e) {
throw new Error(`Malformed envelope, ${e}`);
}
}
@@ -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);
});
});
@@ -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<DescriptorEnvelope>;
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<DescriptorEnvelope> {
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;
}
}
@@ -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<ParserOutput | undefined>;
};
@@ -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';
+1 -1
View File
@@ -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);
});
@@ -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,
]);
}
@@ -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,
);
});
});
@@ -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 <suffix> or <prefix><separator><suffix>, 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();
}
}
@@ -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,
);
});
});
@@ -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';
}
}
@@ -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';
@@ -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> = {}): Validators {
return {
...defaultValidators,
...overrides,
};
}
@@ -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;
};