Merge pull request #1047 from spotify/freben/catalog-model

Move the model into a plugin-catalog-model for sharing outside the backend
This commit is contained in:
Fredrik Adelöw
2020-05-29 07:02:12 +02:00
committed by GitHub
62 changed files with 1596 additions and 920 deletions
+5 -3
View File
@@ -10,7 +10,7 @@
},
"scripts": {
"build": "tsc",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess nodemon",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"nodemon -r esm\\\"",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
@@ -18,13 +18,15 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
"@backstage/catalog-model": "^0.1.1-alpha.6",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.6",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.6",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.6",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.6",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.6",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.6",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"helmet": "^3.22.0",
"knex": "^0.21.1",
+10 -4
View File
@@ -21,22 +21,28 @@ import {
DatabaseManager,
DescriptorParsers,
LocationReaders,
IngestionModels,
runPeriodically,
} from '@backstage/plugin-catalog-backend';
import { PluginEnvironment } from '../types';
import { EntityPolicies } from '@backstage/catalog-model';
export default async function ({ logger, database }: PluginEnvironment) {
const reader = LocationReaders.create();
const parser = DescriptorParsers.create();
const policy = new EntityPolicies();
const ingestion = new IngestionModels(
new LocationReaders(),
new DescriptorParsers(),
new EntityPolicies(),
);
const db = await DatabaseManager.createDatabase(database, logger);
runPeriodically(
() => DatabaseManager.refreshLocations(db, reader, parser, logger),
() => DatabaseManager.refreshLocations(db, ingestion, policy, logger),
10000,
);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db, reader);
const locationsCatalog = new DatabaseLocationsCatalog(db, ingestion);
return await createRouter({ entitiesCatalog, locationsCatalog, logger });
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+12
View File
@@ -0,0 +1,12 @@
# Catalog Model
Contains the core model types and validators/policies used by the Backstage catalog functionality.
This package will be imported both by the frontend and backend parts of the catalog,
as well as by others that want to consume catalog data.
## Links
- (Default frontend part of the catalog)[https://github.com/spotify/backstage/tree/master/plugins/catalog]
- (Default backend part of the catalog)[https://github.com/spotify/backstage/tree/master/plugins/catalog-backend]
- (The Backstage homepage)[https://backstage.io]
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.6",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"lodash": "^4.17.15",
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"@types/yup": "^0.28.2",
"yaml": "^1.9.2"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
@@ -0,0 +1,88 @@
/*
* 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,
FieldFormatEntityPolicy,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
} from './entity';
import { ComponentV1beta1Policy } from './kinds';
import { EntityPolicy } from './types';
// Helper that requires that all of a set of policies can be successfully
// applied
class AllEntityPolicies implements EntityPolicy {
constructor(private readonly policies: EntityPolicy[]) {}
async enforce(entity: Entity): Promise<Entity> {
let result = entity;
for (const policy of this.policies) {
result = await policy.enforce(entity);
}
return result;
}
}
// Helper that requires that at least one of a set of policies can be
// successfully applied
class AnyEntityPolicy implements EntityPolicy {
constructor(private readonly policies: EntityPolicy[]) {}
async enforce(entity: Entity): Promise<Entity> {
for (const policy of this.policies) {
try {
return await policy.enforce(entity);
} catch {
continue;
}
}
throw new Error(`The entity did not match any known policy`);
}
}
export class EntityPolicies implements EntityPolicy {
private readonly policy: EntityPolicy;
static defaultPolicies(): EntityPolicy {
return EntityPolicies.allOf([
EntityPolicies.allOf([
new SchemaValidEntityPolicy(),
new NoForeignRootFieldsEntityPolicy(),
new FieldFormatEntityPolicy(),
new ReservedFieldsEntityPolicy(),
]),
EntityPolicies.anyOf([new ComponentV1beta1Policy()]),
]);
}
static allOf(policies: EntityPolicy[]): EntityPolicy {
return new AllEntityPolicies(policies);
}
static anyOf(policies: EntityPolicy[]): EntityPolicy {
return new AnyEntityPolicy(policies);
}
constructor(policy: EntityPolicy = EntityPolicies.defaultPolicies()) {
this.policy = policy;
}
enforce(entity: Entity): Promise<Entity> {
return this.policy.enforce(entity);
}
}
+108
View File
@@ -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.
*/
/**
* The format envelope that's common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type Entity = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Optional metadata related to the entity.
*/
metadata?: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace, for any given kind.
*/
name?: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
@@ -14,7 +14,5 @@
* limitations under the License.
*/
export * from './CommonValidatorFunctions';
export * from './KubernetesValidatorFunctions';
export * from './makeValidator';
export * from './types';
export type { Entity, EntityMeta } from './Entity';
export * from './policies';
@@ -0,0 +1,105 @@
/*
* 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 { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
describe('FieldFormatEntityPolicy', () => {
let data: any;
let policy: FieldFormatEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new FieldFormatEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad apiVersion', async () => {
data.apiVersion = 7;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
data.apiVersion = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects bad kind', async () => {
data.kind = 7;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
data.kind = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
it('handles missing metadata gracefully', async () => {
delete data.medatata;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('handles missing spec gracefully', async () => {
delete data.spec;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad name', async () => {
data.metadata.name = 7;
await expect(policy.enforce(data)).rejects.toThrow(/name.*7/);
data.metadata.name = 'a'.repeat(1000);
await expect(policy.enforce(data)).rejects.toThrow(/name.*aaaa/);
});
it('rejects bad namespace', async () => {
data.metadata.namespace = 7;
await expect(policy.enforce(data)).rejects.toThrow(/namespace.*7/);
data.metadata.namespace = 'a'.repeat(1000);
await expect(policy.enforce(data)).rejects.toThrow(/namespace.*aaaa/);
});
it('rejects bad label key', async () => {
data.metadata.labels['a#b'] = 'value';
await expect(policy.enforce(data)).rejects.toThrow(/label.*a#b/i);
});
it('rejects bad label value', async () => {
data.metadata.labels.a = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/label.*a#b/i);
});
it('rejects bad annotation key', async () => {
data.metadata.annotations['a#b'] = 'value';
await expect(policy.enforce(data)).rejects.toThrow(/annotation.*a#b/i);
});
it('rejects bad annotation value', async () => {
data.metadata.annotations.a = 7;
await expect(policy.enforce(data)).rejects.toThrow(/annotation.*7/i);
});
});
@@ -0,0 +1,91 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityPolicy } from '../../types';
import { makeValidator, Validators } from '../../validation';
import { Entity } from '../Entity';
/**
* Ensures that the format of individual fields of the entity envelope
* is valid.
*
* This does not take into account machine generated fields such as uid, etag
* and generation.
*/
export class FieldFormatEntityPolicy implements EntityPolicy {
private readonly validators: Validators;
constructor(validators: Validators = makeValidator()) {
this.validators = validators;
}
async enforce(entity: Entity): Promise<Entity> {
function require(
field: string,
value: any,
validator: (value: any) => boolean,
) {
if (value === undefined || value === null) {
throw new Error(`${field} must have a value`);
}
let isValid: boolean;
try {
isValid = validator(value);
} catch (e) {
throw new Error(`${field} could not be validated, ${e}`);
}
if (!isValid) {
throw new Error(`${field} "${value}" is not valid`);
}
}
function optional(
field: string,
value: any,
validator: (value: any) => boolean,
) {
return value === undefined || require(field, value, validator);
}
require('apiVersion', entity.apiVersion, this.validators.isValidApiVersion);
require('kind', entity.kind, this.validators.isValidKind);
optional(
'metadata.name',
entity.metadata?.name,
this.validators.isValidEntityName,
);
optional(
'metadata.namespace',
entity.metadata?.namespace,
this.validators.isValidNamespace,
);
for (const [k, v] of Object.entries(entity.metadata?.labels ?? [])) {
require(`labels.${k}`, k, this.validators.isValidLabelKey);
require(`labels.${k}`, v, this.validators.isValidLabelValue);
}
for (const [k, v] of Object.entries(entity.metadata?.annotations ?? [])) {
require(`annotations.${k}`, k, this.validators.isValidAnnotationKey);
require(`annotations.${k}`, v, this.validators.isValidAnnotationValue);
}
return entity;
}
}
@@ -0,0 +1,52 @@
/*
* 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 { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
describe('NoForeignRootFieldsEntityPolicy', () => {
let data: any;
let policy: NoForeignRootFieldsEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new NoForeignRootFieldsEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects unknown root fields', async () => {
data.spec2 = {};
await expect(policy.enforce(data)).rejects.toThrow(/spec2/i);
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const defaultKnownFields = ['apiVersion', 'kind', 'metadata', 'spec'];
/**
* Ensures that there are no foreign root fields in the entity.
*/
export class NoForeignRootFieldsEntityPolicy implements EntityPolicy {
private readonly knownFields: string[];
constructor(knownFields: string[] = defaultKnownFields) {
this.knownFields = knownFields;
}
async enforce(entity: Entity): Promise<Entity> {
for (const field of Object.keys(entity)) {
if (!this.knownFields.includes(field)) {
throw new Error(`Unknown field ${field}`);
}
}
return entity;
}
}
@@ -0,0 +1,64 @@
/*
* 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 { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
describe('ReservedFieldsEntityPolicy', () => {
let data: any;
let policy: ReservedFieldsEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new ReservedFieldsEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects reserved keys in the spec root', async () => {
data.spec.apiVersion = 'a/b';
await expect(policy.enforce(data)).rejects.toThrow(/spec.*apiVersion/i);
});
it('rejects reserved keys in labels', async () => {
data.metadata.labels.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(/label.*apiVersion/i);
});
it('rejects reserved keys in annotations', async () => {
data.metadata.annotations.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(
/annotation.*apiVersion/i,
);
});
});
@@ -0,0 +1,66 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const DEFAULT_RESERVED_ENTITY_FIELDS = [
'apiVersion',
'kind',
'uid',
'etag',
'generation',
'name',
'namespace',
'labels',
'annotations',
'spec',
];
/**
* Ensures that fields are not given certain reserved names.
*/
export class ReservedFieldsEntityPolicy implements EntityPolicy {
private readonly reservedFields: string[];
constructor(fields?: string[]) {
this.reservedFields = [
...(fields ?? []),
...DEFAULT_RESERVED_ENTITY_FIELDS,
];
}
async enforce(entity: Entity): Promise<Entity> {
for (const field of this.reservedFields) {
if (entity.spec?.hasOwnProperty(field)) {
throw new Error(
`The spec may not contain the field ${field}, because it has reserved meaning`,
);
}
if (entity.metadata?.labels?.hasOwnProperty(field)) {
throw new Error(
`A label may not have the field ${field}, because it has reserved meaning`,
);
}
if (entity.metadata?.annotations?.hasOwnProperty(field)) {
throw new Error(
`An annotation may not have the field ${field}, because it has reserved meaning`,
);
}
}
return entity;
}
}
@@ -0,0 +1,176 @@
/*
* 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 { Entity } from '../Entity';
import { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
describe('SchemaValidEntityPolicy', () => {
let data: any;
let policy: SchemaValidEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new SchemaValidEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
//
// apiVersion and kind
//
it('rejects wrong root type', async () => {
await expect(policy.enforce((7 as unknown) as Entity)).rejects.toThrow(
/object/,
);
});
it('rejects missing apiVersion', async () => {
delete data.apiVersion;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects bad apiVersion type', async () => {
data.apiVersion = 7;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects missing kind', async () => {
delete data.kind;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
it('rejects bad kind type', async () => {
data.kind = 7;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
//
// metadata
//
it('accepts missing metadata', async () => {
delete data.metadata;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad metadata type', async () => {
data.metadata = 7;
await expect(policy.enforce(data)).rejects.toThrow(/metadata/);
});
it('accepts missing uid', async () => {
delete data.metadata.uid;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad uid type', async () => {
data.metadata.uid = 7;
await expect(policy.enforce(data)).rejects.toThrow(/uid/);
});
it('accepts missing etag', async () => {
delete data.metadata.etag;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad etag type', async () => {
data.metadata.etag = 7;
await expect(policy.enforce(data)).rejects.toThrow(/etag/);
});
it('accepts missing generation', async () => {
delete data.metadata.generation;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad generation type', async () => {
data.metadata.generation = 'a';
await expect(policy.enforce(data)).rejects.toThrow(/generation/);
});
it('accepts missing name', async () => {
delete data.metadata.name;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad name type', async () => {
data.metadata.name = 7;
await expect(policy.enforce(data)).rejects.toThrow(/name/);
});
it('accepts missing namespace', async () => {
delete data.metadata.namespace;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad namespace type', async () => {
data.metadata.namespace = 7;
await expect(policy.enforce(data)).rejects.toThrow(/namespace/);
});
it('accepts missing labels', async () => {
delete data.metadata.labels;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad labels type', async () => {
data.metadata.labels = 7;
await expect(policy.enforce(data)).rejects.toThrow(/labels/);
});
it('accepts missing annotations', async () => {
delete data.metadata.annotations;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad annotations type', async () => {
data.metadata.annotations = 7;
await expect(policy.enforce(data)).rejects.toThrow(/annotations/);
});
//
// spec
//
it('accepts missing spec', async () => {
delete data.spec;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects non-object spec', async () => {
data.spec = 7;
await expect(policy.enforce(data)).rejects.toThrow(/spec/);
});
});
@@ -0,0 +1,80 @@
/*
* 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 { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const DEFAULT_ENTITY_SCHEMA = yup.object({
apiVersion: yup.string().required(),
kind: yup.string().required(),
metadata: yup
.object({
uid: yup
.string()
.notRequired()
.test(
'metadata.uid',
'The uid must not be empty',
value => value === undefined || value.length > 0,
),
etag: yup
.string()
.notRequired()
.test(
'metadata.etag',
'The etag must not be empty',
value => value === undefined || value.length > 0,
),
generation: yup
.number()
.notRequired()
.test(
'metadata.generation',
'The generation must be an integer greater than zero',
value => value === undefined || (value === (value | 0) && value > 0),
),
name: yup.string().notRequired(),
namespace: yup.string().notRequired(),
labels: yup.object<Record<string, string>>().notRequired(),
annotations: yup.object<Record<string, string>>().notRequired(),
})
.notRequired(),
spec: yup.object({}).notRequired(),
});
/**
* Ensures that the entity spec is valid according to a schema.
*
* This should be the first policy in the list, to ensure that other downstream
* policies can work with a structure that is at least valid in therms of the
* typescript type.
*/
export class SchemaValidEntityPolicy implements EntityPolicy {
private readonly schema: yup.Schema<Entity>;
constructor(schema: yup.Schema<Entity> = DEFAULT_ENTITY_SCHEMA) {
this.schema = schema;
}
async enforce(entity: Entity): Promise<Entity> {
try {
return await this.schema.validate(entity, { strict: true });
} catch (e) {
throw new Error(`Malformed envelope, ${e}`);
}
}
}
@@ -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 { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 './entity';
export { EntityPolicies } from './EntityPolicies';
export * from './kinds';
export type { EntityPolicy } from './types';
export * from './validation';
@@ -15,19 +15,28 @@
*/
import * as yup from 'yup';
import { DescriptorEnvelope, KindParser, ParserError } from '../types';
import type { Entity, EntityMeta } from '../entity/Entity';
import type { EntityPolicy } from '../types';
export interface ComponentDescriptorV1beta1 extends DescriptorEnvelope {
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Component';
export interface ComponentV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
metadata: EntityMeta & {
name: string;
};
spec: {
type: string;
};
}
export class ComponentDescriptorV1beta1Parser implements KindParser {
export class ComponentV1beta1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<ComponentDescriptorV1beta1>>({
this.schema = yup.object<Partial<ComponentV1beta1>>({
metadata: yup
.object({
name: yup.string().required(),
@@ -41,23 +50,14 @@ export class ComponentDescriptorV1beta1Parser implements KindParser {
});
}
async tryParse(
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined> {
async enforce(envelope: Entity): Promise<Entity> {
if (
envelope.apiVersion !== 'backstage.io/v1beta1' ||
envelope.kind !== 'Component'
) {
return undefined;
throw new Error('Unsupported apiVersion / kind');
}
try {
return await this.schema.validate(envelope, { strict: true });
} catch (e) {
throw new ParserError(
`Malformed component, ${e}`,
envelope.metadata?.name,
);
}
return await this.schema.validate(envelope, { strict: true });
}
}
+20
View File
@@ -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.
*/
import type { ComponentV1beta1 } from './ComponentV1beta1';
export { ComponentV1beta1Policy } from './ComponentV1beta1';
export { ComponentV1beta1 as Component };
export { ComponentV1beta1 };
+32
View File
@@ -0,0 +1,32 @@
/*
* 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 type { Entity } from './entity/Entity';
/**
* A policy for validation or mutation to be applied to entities as they are
* entering the system.
*/
export type EntityPolicy = {
/**
* Applies validation or mutation on an entity.
*
* @param entity The entity, as validated/mutated so far in the policy tree
* @returns The incoming entity, or a mutated version of the same
* @throws An error if the entity should be rejected
*/
enforce(entity: Entity): Promise<Entity>;
};
@@ -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 { CommonValidatorFunctions } from './CommonValidatorFunctions';
export { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions';
export { makeValidator } from './makeValidator';
export type { Validators } from './types';
+3 -2
View File
@@ -16,8 +16,7 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
"@backstage/catalog-model": "^0.1.1-alpha.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -38,6 +37,8 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
"@types/yup": "^0.28.2",
"jest-fetch-mock": "^3.0.3",
@@ -14,21 +14,21 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Database } from '../database';
import { DescriptorEnvelope } from '../ingestion/types';
import { EntitiesCatalog, EntityFilters } from './types';
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
constructor(private readonly database: Database) {}
async entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]> {
async entities(filters?: EntityFilters): Promise<Entity[]> {
const items = await this.database.transaction(tx =>
this.database.entities(tx, filters),
);
return items.map(i => i.entity);
}
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
async entityByUid(uid: string): Promise<Entity | undefined> {
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
);
@@ -40,7 +40,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
kind: string,
name: string,
namespace: string | undefined,
): Promise<DescriptorEnvelope | undefined> {
): Promise<Entity | undefined> {
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [
{ key: 'kind', values: [kind] },
@@ -13,13 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
import { getVoidLogger } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import knex from 'knex';
import path from 'path';
import { Database } from '../database';
import { ReaderOutput } from '../ingestion/types';
import { getVoidLogger } from '@backstage/backend-common';
import { IngestionModel } from '../ingestion/types';
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
class MockIngestionModel implements IngestionModel {
readLocation = jest.fn(async (type: string, target: string) => {
if (type !== 'valid_type') {
throw new Error(`Unknown location type ${type}`);
}
if (target === 'valid_target') {
return [{ type: 'data', data: {} as Entity } as const];
}
throw new Error(
`Can't read location at ${target} with error: Something is broken`,
);
});
}
describe('DatabaseLocationsCatalog', () => {
const database = knex({
@@ -32,20 +46,7 @@ describe('DatabaseLocationsCatalog', () => {
});
let db: Database;
let catalog: DatabaseLocationsCatalog;
const mockLocationReader = {
read: async (type: string, target: string): Promise<ReaderOutput[]> => {
if (type !== 'valid_type') {
throw new Error(`Unknown location type ${type}`);
}
if (target === 'valid_target') {
return Promise.resolve([{ type: 'data', data: {} }]);
}
throw new Error(
`Can't read location at ${target} with error: Something is broken`,
);
},
};
let ingestionModel: IngestionModel;
beforeEach(async () => {
await database.migrate.latest({
@@ -53,7 +54,8 @@ describe('DatabaseLocationsCatalog', () => {
loadExtensions: ['.ts'],
});
db = new Database(database, getVoidLogger());
catalog = new DatabaseLocationsCatalog(db, mockLocationReader);
ingestionModel = new MockIngestionModel();
catalog = new DatabaseLocationsCatalog(db, ingestionModel);
});
it('resolves to location with id', async () => {
@@ -15,17 +15,25 @@
*/
import { Database } from '../database';
import { IngestionModel } from '../ingestion/types';
import { AddLocation, Location, LocationsCatalog } from './types';
import { LocationReader } from '../ingestion';
export class DatabaseLocationsCatalog implements LocationsCatalog {
constructor(
private readonly database: Database,
private readonly reader: LocationReader,
private readonly ingestionModel: IngestionModel,
) {}
async addLocation(location: AddLocation): Promise<Location> {
const outputs = await this.reader.read(location.type, location.target);
const outputs = await this.ingestionModel.readLocation(
location.type,
location.target,
);
if (!outputs) {
throw new Error(
`Unknown location type ${location.type} ${location.target}`,
);
}
outputs.forEach(output => {
if (output.type === 'error') {
throw new Error(
@@ -15,22 +15,22 @@
*/
import { NotFoundError } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import lodash from 'lodash';
import { DescriptorEnvelope } from '../ingestion';
import { EntitiesCatalog } from './types';
export class StaticEntitiesCatalog implements EntitiesCatalog {
private _entities: DescriptorEnvelope[];
private _entities: Entity[];
constructor(entities: DescriptorEnvelope[]) {
constructor(entities: Entity[]) {
this._entities = entities;
}
async entities(): Promise<DescriptorEnvelope[]> {
async entities(): Promise<Entity[]> {
return lodash.cloneDeep(this._entities);
}
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
async entityByUid(uid: string): Promise<Entity | undefined> {
const item = this._entities.find(e => uid === e.metadata?.uid);
if (!item) {
throw new NotFoundError('Entity cannot be found');
@@ -42,7 +42,7 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
kind: string,
name: string,
namespace: string | undefined,
): Promise<DescriptorEnvelope | undefined> {
): Promise<Entity | undefined> {
const item = this._entities.find(
e =>
kind === e.kind &&
+4 -4
View File
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import * as yup from 'yup';
import { DescriptorEnvelope } from '../ingestion';
//
// Entities
@@ -28,13 +28,13 @@ export type EntityFilter = {
export type EntityFilters = EntityFilter[];
export type EntitiesCatalog = {
entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]>;
entityByUid(uid: string): Promise<DescriptorEnvelope | undefined>;
entities(filters?: EntityFilters): Promise<Entity[]>;
entityByUid(uid: string): Promise<Entity | undefined>;
entityByName(
kind: string,
namespace: string | undefined,
name: string,
): Promise<DescriptorEnvelope | undefined>;
): Promise<Entity | undefined>;
};
//
@@ -19,9 +19,9 @@ import {
getVoidLogger,
NotFoundError,
} from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { DescriptorEnvelope } from '../ingestion';
import { Database } from './Database';
import {
AddDatabaseLocation,
@@ -247,8 +247,8 @@ describe('Database', () => {
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
const catalog = new Database(database, getVoidLogger());
const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' };
const e2: DescriptorEnvelope = {
const e1: Entity = { apiVersion: 'a', kind: 'b' };
const e2: Entity = {
apiVersion: 'a',
kind: 'b',
spec: { c: null },
@@ -271,7 +271,7 @@ describe('Database', () => {
it('can get all specific entities for matching filters (naive case)', async () => {
const catalog = new Database(database, getVoidLogger());
const entities: DescriptorEnvelope[] = [
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'b' },
{
apiVersion: 'a',
@@ -305,7 +305,7 @@ describe('Database', () => {
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
const catalog = new Database(database, getVoidLogger());
const entities: DescriptorEnvelope[] = [
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'b' },
{
apiVersion: 'a',
@@ -19,12 +19,12 @@ import {
InputError,
NotFoundError,
} from '@backstage/backend-common';
import { Entity, EntityMeta } from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { EntityFilters } from '../catalog';
import { DescriptorEnvelope, EntityMeta } from '../ingestion';
import { buildEntitySearch } from './search';
import {
AddDatabaseLocation,
@@ -54,9 +54,7 @@ function serializeMetadata(metadata: EntityMeta | undefined): string | null {
return JSON.stringify(getStrippedMetadata(metadata));
}
function serializeSpec(
spec: DescriptorEnvelope['spec'],
): DbEntitiesRow['spec'] {
function serializeSpec(spec: Entity['spec']): DbEntitiesRow['spec'] {
if (!spec) {
return null;
}
@@ -66,7 +64,7 @@ function serializeSpec(
function toEntityRow(
locationId: string | undefined,
entity: DescriptorEnvelope,
entity: Entity,
): DbEntitiesRow {
return {
id: entity.metadata!.uid!,
@@ -83,7 +81,7 @@ function toEntityRow(
}
function toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
const entity: DescriptorEnvelope = {
const entity: Entity = {
apiVersion: row.api_version,
kind: row.kind,
metadata: {
@@ -94,7 +92,7 @@ function toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
};
if (row.metadata) {
const metadata = JSON.parse(row.metadata) as DescriptorEnvelope['metadata'];
const metadata = JSON.parse(row.metadata) as Entity['metadata'];
entity.metadata = { ...entity.metadata, ...metadata };
}
@@ -127,7 +125,9 @@ function generateUid(): string {
}
function generateEtag(): string {
return Buffer.from(uuidv4(), 'utf8').toString('base64').replace(/[^\w]/g, '');
return Buffer.from(uuidv4(), 'utf8')
.toString('base64')
.replace(/[^\w]/g, '');
}
/**
@@ -374,7 +374,9 @@ export class Database {
target,
});
return (await tx<DbLocationsRow>('locations').where({ id }).select())![0];
return (await tx<DbLocationsRow>('locations')
.where({ id })
.select())![0];
});
}
@@ -422,7 +424,7 @@ export class Database {
private async updateEntitiesSearch(
tx: Knex.Transaction<any, any>,
entityId: string,
data: DescriptorEnvelope,
data: Entity,
): Promise<void> {
try {
const entries = buildEntitySearch(entityId, data);
@@ -15,13 +15,9 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { Entity, EntityPolicy } from '@backstage/catalog-model';
import Knex from 'knex';
import {
ComponentDescriptor,
DescriptorParser,
LocationReader,
ParserError,
} from '../ingestion';
import { IngestionModel } from '../ingestion/types';
import { Database } from './Database';
import { DatabaseManager } from './DatabaseManager';
import { DatabaseLocationUpdateLogStatus, DbLocationsRow } from './types';
@@ -32,18 +28,18 @@ describe('DatabaseManager', () => {
const db = ({
locations: jest.fn().mockResolvedValue([]),
} as unknown) as Database;
const reader: LocationReader = {
read: jest.fn(),
const reader: IngestionModel = {
readLocation: jest.fn(),
};
const parser: DescriptorParser = {
parse: jest.fn(),
const policy: EntityPolicy = {
enforce: jest.fn(),
};
await expect(
DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
DatabaseManager.refreshLocations(db, reader, policy, getVoidLogger()),
).resolves.toBeUndefined();
expect(reader.read).not.toHaveBeenCalled();
expect(parser.parse).not.toHaveBeenCalled();
expect(reader.readLocation).not.toHaveBeenCalled();
expect(policy.enforce).not.toHaveBeenCalled();
});
it('can update a single location', async () => {
@@ -52,7 +48,7 @@ describe('DatabaseManager', () => {
type: 'some',
target: 'thing',
};
const desc: ComponentDescriptor = {
const desc: Entity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'c1' },
@@ -68,18 +64,20 @@ describe('DatabaseManager', () => {
addLocationUpdateLogEvent: jest.fn(),
} as Partial<Database>) as Database;
const reader: LocationReader = {
read: jest.fn(() => Promise.resolve([{ type: 'data', data: desc }])),
const reader: IngestionModel = {
readLocation: jest.fn(() =>
Promise.resolve([{ type: 'data', data: desc }]),
),
};
const parser: DescriptorParser = {
parse: jest.fn(() => Promise.resolve(desc)),
const policy: EntityPolicy = {
enforce: jest.fn(() => Promise.resolve(desc)),
};
await expect(
DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
DatabaseManager.refreshLocations(db, reader, policy, getVoidLogger()),
).resolves.toBeUndefined();
expect(reader.read).toHaveBeenCalledTimes(1);
expect(reader.read).toHaveBeenNthCalledWith(1, 'some', 'thing');
expect(reader.readLocation).toHaveBeenCalledTimes(1);
expect(reader.readLocation).toHaveBeenNthCalledWith(1, 'some', 'thing');
expect(db.addEntity).toHaveBeenCalledTimes(1);
expect(db.addEntity).toHaveBeenNthCalledWith(1, undefined, {
locationId: '123',
@@ -108,21 +106,23 @@ describe('DatabaseManager', () => {
addLocationUpdateLogEvent: jest.fn(),
} as unknown) as Database;
const desc: ComponentDescriptor = {
const desc: Entity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'c1' },
spec: { type: 'service' },
};
const reader: LocationReader = {
read: jest.fn(() => Promise.resolve([{ type: 'data', data: desc }])),
const reader: IngestionModel = {
readLocation: jest.fn(() =>
Promise.resolve([{ type: 'data', data: desc }]),
),
};
const parser: DescriptorParser = {
parse: jest.fn(() => Promise.resolve(desc)),
const policy: EntityPolicy = {
enforce: jest.fn(() => Promise.resolve(desc)),
};
await expect(
DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
DatabaseManager.refreshLocations(db, reader, policy, getVoidLogger()),
).resolves.toBeUndefined();
expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
@@ -158,23 +158,25 @@ describe('DatabaseManager', () => {
addLocationUpdateLogEvent: jest.fn(),
} as unknown) as Database;
const desc: ComponentDescriptor = {
const desc: Entity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'c1' },
spec: { type: 'service' },
};
const reader: LocationReader = {
read: jest.fn(() => Promise.resolve([{ type: 'data', data: desc }])),
const reader: IngestionModel = {
readLocation: jest.fn(() =>
Promise.resolve([{ type: 'data', data: desc }]),
),
};
const parser: DescriptorParser = {
parse: jest.fn(() =>
Promise.reject(new ParserError('parser error message', 'c1')),
const policy: EntityPolicy = {
enforce: jest.fn(() =>
Promise.reject(new Error('parser error message')),
),
};
await expect(
DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
DatabaseManager.refreshLocations(db, reader, policy, getVoidLogger()),
).resolves.toBeUndefined();
expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
@@ -211,19 +213,19 @@ describe('DatabaseManager', () => {
addLocationUpdateLogEvent: jest.fn(),
} as unknown) as Database;
const reader: LocationReader = {
read: jest.fn(() =>
const reader: IngestionModel = {
readLocation: jest.fn(() =>
Promise.reject([{ type: 'error', error: new Error('test message') }]),
),
};
const parser: DescriptorParser = {
parse: jest.fn(() =>
Promise.reject(new ParserError('parser error message', 'c1')),
const policy: EntityPolicy = {
enforce: jest.fn(() =>
Promise.reject(new Error('parser error message')),
),
};
await expect(
DatabaseManager.refreshLocations(db, reader, parser, getVoidLogger()),
DatabaseManager.refreshLocations(db, reader, policy, getVoidLogger()),
).resolves.toBeUndefined();
expect(db.addLocationUpdateLogEvent).toHaveBeenNthCalledWith(
@@ -14,16 +14,12 @@
* limitations under the License.
*/
import { Entity, EntityPolicy } from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import path from 'path';
import { Logger } from 'winston';
import {
DescriptorEnvelope,
DescriptorParser,
LocationReader,
ParserError,
} from '../ingestion';
import { IngestionModel } from '../ingestion/types';
import { Database } from './Database';
import { DatabaseLocationUpdateLogStatus, DbEntityRequest } from './types';
@@ -67,8 +63,8 @@ export class DatabaseManager {
public static async refreshLocations(
database: Database,
reader: LocationReader,
parser: DescriptorParser,
ingestionModel: IngestionModel,
entityPolicy: EntityPolicy,
logger: Logger,
): Promise<void> {
const locations = await database.locations();
@@ -78,7 +74,10 @@ export class DatabaseManager {
`Refreshing location id="${location.id}" type="${location.type}" target="${location.target}"`,
);
const readerOutput = await reader.read(location.type, location.target);
const readerOutput = await ingestionModel.readLocation(
location.type,
location.target,
);
for (const readerItem of readerOutput) {
if (readerItem.type === 'error') {
@@ -87,7 +86,7 @@ export class DatabaseManager {
}
try {
const entity = await parser.parse(readerItem.data);
const entity = await entityPolicy.enforce(readerItem.data);
await DatabaseManager.refreshSingleEntity(
database,
location.id,
@@ -100,15 +99,11 @@ export class DatabaseManager {
entity.metadata!.name,
);
} catch (error) {
let entityName;
if (error instanceof ParserError) {
entityName = error.entityName;
}
await DatabaseManager.logUpdateFailure(
database,
location.id,
error,
entityName,
readerItem.data.metadata?.name,
);
}
}
@@ -129,7 +124,7 @@ export class DatabaseManager {
private static async refreshSingleEntity(
database: Database,
locationId: string,
entity: DescriptorEnvelope,
entity: Entity,
logger: Logger,
): Promise<void> {
const { kind } = entity;
@@ -163,10 +158,7 @@ export class DatabaseManager {
});
}
private static entitiesAreEqual(
first: DescriptorEnvelope,
second: DescriptorEnvelope,
) {
private static entitiesAreEqual(first: Entity, second: Entity) {
const firstClone = lodash.cloneDeep(first);
const secondClone = lodash.cloneDeep(second);
@@ -19,7 +19,10 @@ export async function up(knex: Knex): Promise<any> {
return knex.schema.createTable('location_update_log', table => {
table.uuid('id').primary();
table.enum('status', ['success', 'fail']).notNullable();
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable();
table
.dateTime('created_at')
.defaultTo(knex.fn.now())
.notNullable();
table.string('message');
table
.uuid('location_id')
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DescriptorEnvelope } from '../ingestion';
import { Entity } from '@backstage/catalog-model';
import { buildEntitySearch, visitEntityPart } from './search';
import { DbEntitiesSearchRow } from './types';
@@ -99,7 +99,7 @@ describe('search', () => {
describe('buildEntitySearch', () => {
it('adds special keys even if missing', () => {
const input: DescriptorEnvelope = {
const input: Entity = {
apiVersion: 'a',
kind: 'b',
};
@@ -116,7 +116,7 @@ describe('search', () => {
});
it('adds prefix-stripped versions', () => {
const input: DescriptorEnvelope = {
const input: Entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DescriptorEnvelope } from '../ingestion';
import { Entity } from '@backstage/catalog-model';
import { DbEntitiesSearchRow } from './types';
// Search entries that start with these prefixes, also get a shorthand without
@@ -119,7 +119,7 @@ export function visitEntityPart(
*/
export function buildEntitySearch(
entityId: string,
entity: DescriptorEnvelope,
entity: Entity,
): DbEntitiesSearchRow[] {
// Start with some special keys that are always present because you want to
// be able to easily search for null specifically
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import * as yup from 'yup';
import { DescriptorEnvelope } from '../ingestion';
export type DbEntitiesRow = {
id: string;
@@ -32,12 +32,12 @@ export type DbEntitiesRow = {
export type DbEntityRequest = {
locationId?: string;
entity: DescriptorEnvelope;
entity: Entity;
};
export type DbEntityResponse = {
locationId?: string;
entity: DescriptorEnvelope;
entity: Entity;
};
export type DbEntitiesSearchRow = {
@@ -1,53 +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 { makeValidator } from '../validation';
import { ComponentDescriptorV1beta1Parser } from './descriptors/ComponentDescriptorV1beta1Parser';
import { DescriptorEnvelopeParser } from './descriptors/DescriptorEnvelopeParser';
import {
DescriptorEnvelope,
DescriptorParser,
KindParser,
ParserError,
} from './types';
export class DescriptorParsers implements DescriptorParser {
static create(): DescriptorParser {
const validators = makeValidator();
return new DescriptorParsers(new DescriptorEnvelopeParser(validators), [
new ComponentDescriptorV1beta1Parser(),
]);
}
constructor(
private readonly envelopeParser: DescriptorEnvelopeParser,
private readonly kindParsers: KindParser[],
) {}
async parse(descriptor: object): Promise<DescriptorEnvelope> {
const envelope = await this.envelopeParser.parse(descriptor);
for (const parser of this.kindParsers) {
const parsed = await parser.tryParse(envelope);
if (parsed) {
return parsed;
}
}
throw new ParserError(
`Unsupported object ${envelope.apiVersion}, ${envelope.kind}`,
envelope.metadata?.name,
);
}
}
@@ -0,0 +1,73 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityPolicy, EntityPolicies } from '@backstage/catalog-model';
import { DescriptorParser, ReaderOutput } from './descriptor/parsers/types';
import { LocationReader, LocationReaders } from './source';
import { IngestionModel } from './types';
import { DescriptorParsers } from './descriptor';
export class IngestionModels implements IngestionModel {
private readonly reader: LocationReader;
private readonly parser: DescriptorParser;
private readonly entityPolicy: EntityPolicy;
static default(): IngestionModel {
return new IngestionModels(
new LocationReaders(),
new DescriptorParsers(),
new EntityPolicies(),
);
}
constructor(
reader: LocationReader,
parser: DescriptorParser,
entityPolicy: EntityPolicy,
) {
this.reader = reader;
this.parser = parser;
this.entityPolicy = entityPolicy;
}
async readLocation(type: string, target: string): Promise<ReaderOutput[]> {
const buffer = await this.reader.tryRead(type, target);
if (!buffer) {
throw new Error(`No reader could handle location ${type} ${target}`);
}
const items = await this.parser.tryParse(buffer);
if (!items) {
throw new Error(`No parser could handle location ${type} ${target}`);
}
const result: ReaderOutput[] = [];
for (const item of items) {
if (item.type === 'error') {
result.push(item);
} else {
try {
const output = await this.entityPolicy.enforce(item.data);
result.push({ type: 'data', data: output });
} catch (e) {
result.push({ type: 'error', error: e });
}
}
}
return result;
}
}
@@ -1,39 +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 { FileLocationSource } from './sources/FileLocationSource';
import { GitHubLocationSource } from './sources/GitHubLocationSource';
import { LocationReader, LocationSource, ReaderOutput } from './types';
export class LocationReaders implements LocationReader {
static create(): LocationReader {
return new LocationReaders({
file: new FileLocationSource(),
github: new GitHubLocationSource(),
});
}
constructor(private readonly sources: Record<string, LocationSource>) {}
async read(type: string, target: string): Promise<ReaderOutput[]> {
const source = this.sources[type];
if (!source) {
throw new Error(`Unknown location type ${type}`);
}
return source.read(target);
}
}
@@ -0,0 +1,45 @@
/*
* 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 { DescriptorParser, ReaderOutput } from './parsers/types';
import { YamlDescriptorParser } from './parsers/YamlDescriptorParser';
/**
* Parses raw descriptor data (e.g. from a file or stream) into entities.
*/
export class DescriptorParsers implements DescriptorParser {
private readonly parsers: DescriptorParser[];
static defaultParsers(): DescriptorParser[] {
return [new YamlDescriptorParser()];
}
constructor(
parsers: DescriptorParser[] = DescriptorParsers.defaultParsers(),
) {
this.parsers = parsers;
}
async tryParse(data: Buffer): Promise<ReaderOutput[] | undefined> {
for (const parser of this.parsers) {
const result = await parser.tryParse(data);
if (result) {
return result;
}
}
throw new Error(`Unsupported descriptor format`);
}
}
@@ -0,0 +1,18 @@
/*
* 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 { DescriptorParsers } from './DescriptorParsers';
export { YamlDescriptorParser } from './parsers/YamlDescriptorParser';
@@ -0,0 +1,64 @@
/*
* 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 '@backstage/catalog-model';
import yaml from 'yaml';
import { DescriptorParser, ReaderOutput } from './types';
/**
* Parses descriptors on YAML format
*/
export class YamlDescriptorParser implements DescriptorParser {
async tryParse(data: Buffer): Promise<ReaderOutput[] | undefined> {
// TODO(freben): Should perhaps first do format detection, so the parse
// failure can be emitted as a proper error instead of just as if we
// weren't handling the format at all.
let documents;
try {
documents = yaml.parseAllDocuments(data.toString('utf8'));
} catch (e) {
return undefined;
}
const result: ReaderOutput[] = [];
for (const document of documents) {
if (document.contents) {
if (document.errors?.length) {
result.push({
type: 'error',
error: new Error(`Malformed YAML document, ${document.errors[0]}`),
});
} else {
const json = document.toJSON();
if (typeof json !== 'object' || Array.isArray(json)) {
result.push({
type: 'error',
error: new Error(`Malformed descriptor, expected object at root`),
});
} else {
result.push({
type: 'data',
data: json as Entity,
});
}
}
}
}
return result;
}
}
@@ -0,0 +1,42 @@
/*
* 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 '@backstage/catalog-model';
export type ReaderOutput =
| { type: 'error'; error: Error }
| { type: 'data'; data: Entity };
/**
* Parses raw descriptor data (e.g. from a file) into entities.
*/
export type DescriptorParser = {
/**
* Try to parse some raw data into an entity.
*
* Note that this is only the low level operation of parsing the raw file
* format, e.g. reading JSON or YAML or similar and emitting as structured
* but unvalidated data. The actual validation is performed by EntityPolicy
* and KindParser.
*
* @param data Raw descriptor data
* @returns A list of raw unvalidated entities / errors, or undefined if the
* given data is not meant to be handled by this parser
* @throws An Error if the format was handled and found to not be properly
* formed
*/
tryParse(data: Buffer): Promise<ReaderOutput[] | undefined>;
};
@@ -1,172 +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 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:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
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 uid', async () => {
delete data.metadata.uid;
await expect(parser.parse(data)).resolves.toBe(data);
});
it('rejects bad uid', async () => {
data.metadata.uid = 7;
await expect(parser.parse(data)).rejects.toThrow(/uid/);
});
it('accepts missing etag', async () => {
delete data.metadata.etag;
await expect(parser.parse(data)).resolves.toBe(data);
});
it('rejects bad etag', async () => {
data.metadata.etag = 7;
await expect(parser.parse(data)).rejects.toThrow(/etag/);
});
it('accepts missing generation', async () => {
delete data.metadata.generation;
await expect(parser.parse(data)).resolves.toBe(data);
});
it('rejects bad generation', async () => {
data.metadata.generation = 'a';
await expect(parser.parse(data)).rejects.toThrow(/generation/);
});
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);
});
});
@@ -1,206 +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';
import { Validators } from '../../validation';
import { DescriptorEnvelope } from '../types';
/**
* 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 uidSchema = yup
.string()
.notRequired()
.test(
'metadata.uid',
'The uid is not formatted according to schema',
value => value === undefined || value.length > 0,
);
const etagSchema = yup
.string()
.notRequired()
.test(
'metadata.etag',
'The etag value is not according to schema',
value => value === undefined || value.length > 0,
);
const generationSchema = yup
.number()
.notRequired()
.test(
'metadata.generation',
'The generation value is not according to schema',
value => value === undefined || value > 0,
);
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.isValidNamespace(value),
);
const labelsSchema = yup
.object<Record<string, string>>()
.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<Record<string, string>>()
.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({
uid: uidSchema,
etag: etagSchema,
generation: generationSchema,
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',
'uid',
'etag',
'generation',
'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;
}
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export * from './DescriptorParsers';
export * from './LocationReaders';
export * from './types';
export * from './descriptor';
export { IngestionModels } from './IngestionModels';
export * from './source';
export type { IngestionModel } from './types';
@@ -0,0 +1,41 @@
/*
* 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 { FileLocationReader } from './readers/FileLocationReader';
import { GitHubLocationReader } from './readers/GitHubLocationReader';
import { LocationReader } from './readers/types';
export class LocationReaders implements LocationReader {
private readonly readers: LocationReader[];
static defaultReaders(): LocationReader[] {
return [new FileLocationReader(), new GitHubLocationReader()];
}
constructor(readers: LocationReader[] = LocationReaders.defaultReaders()) {
this.readers = readers;
}
async tryRead(type: string, target: string): Promise<Buffer | undefined> {
for (const reader of this.readers) {
const result = await reader.tryRead(type, target);
if (result) {
return result;
}
}
throw new Error(`Could not read unknown location "${type}", "${target}"`);
}
}
@@ -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 { LocationReaders } from './LocationReaders';
export { FileLocationReader } from './readers/FileLocationReader';
export { GitHubLocationReader } from './readers/GitHubLocationReader';
export type { LocationReader } from './readers/types';
@@ -15,22 +15,21 @@
*/
import fs from 'fs-extra';
import { LocationSource, ReaderOutput } from '../types';
import { readDescriptorYaml } from './util';
import { LocationReader } from './types';
/**
* Reads a file from the local file system.
*/
export class FileLocationReader implements LocationReader {
async tryRead(type: string, target: string): Promise<Buffer | undefined> {
if (type !== 'file') {
return undefined;
}
export class FileLocationSource implements LocationSource {
async read(target: string): Promise<ReaderOutput[]> {
let rawYaml;
try {
rawYaml = await fs.readFile(target, 'utf8');
return await fs.readFile(target);
} catch (e) {
throw new Error(`Unable to read "${target}", ${e}`);
}
try {
return readDescriptorYaml(rawYaml);
} catch (e) {
throw new Error(`Malformed descriptor at "${target}", ${e}`);
}
}
}
@@ -16,43 +16,26 @@
jest.mock('node-fetch');
import fs from 'fs-extra';
import fetch from 'node-fetch';
import path from 'path';
import { GitHubLocationSource } from '../GitHubLocationSource';
import { GitHubLocationReader } from './GitHubLocationReader';
const { Response } = jest.requireActual('node-fetch');
const FIXTURES_DIR = path.resolve(
__dirname,
'..',
'..',
'..',
'..',
'fixtures',
);
const fixtures = fs.readdirSync(FIXTURES_DIR).reduce((acc, filename) => {
acc[filename] = fs.readFileSync(path.resolve(FIXTURES_DIR, filename), 'utf8');
return acc;
}, {} as Record<string, string>);
describe('Unit: GitHubLocationSource', () => {
describe('Unit: GitHubLocationReader', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches the file and parses it correctly', async () => {
(fetch as any).mockReturnValueOnce(
Promise.resolve(new Response(fixtures['one_component.yaml'])),
);
const reader = new GitHubLocationSource();
(fetch as any).mockResolvedValueOnce(new Response('hello'));
const result = await reader.read(
const reader = new GitHubLocationReader();
const buffer = await reader.tryRead(
'github',
'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/one_component.yaml',
);
expect(result[0].type).toBe('data');
expect((result[0] as any).data.metadata.name).toBe('component3');
expect(buffer?.toString('utf8')).toBe('hello');
});
it('changes the url to point to https://raw.githubusercontent.com', async () => {
@@ -61,12 +44,12 @@ describe('Unit: GitHubLocationSource', () => {
const folderPath = `master/plugins/catalog-backend/fixtures`;
const componentFilename = `one_component.yaml`;
const rawGitHubUrl = `https://raw.githubusercontent.com`;
const reader = new GitHubLocationSource();
(fetch as any).mockReturnValueOnce(
Promise.resolve(new Response(fixtures[componentFilename])),
);
await reader.read(
const reader = new GitHubLocationReader();
(fetch as any).mockResolvedValueOnce(new Response('hello'));
await reader.tryRead(
'github',
`${gitHubUrl}/${project}/blob/${folderPath}/${componentFilename}`,
);
@@ -76,7 +59,7 @@ describe('Unit: GitHubLocationSource', () => {
});
describe('rejects wrong urls', () => {
const reader = new GitHubLocationSource();
const reader = new GitHubLocationReader();
it.each([
['http://example.com/one_component.yaml'],
@@ -87,7 +70,7 @@ describe('Unit: GitHubLocationSource', () => {
])(
'%p',
async (url: string) =>
await expect(reader.read(url)).rejects.toThrow(/url/),
await expect(reader.tryRead('github', url)).rejects.toThrow(/url/),
);
});
});
@@ -100,11 +83,10 @@ describe('Integration: GitHubLocationSource', () => {
it('fetches the fixture from backstage repo', async () => {
const PERMANENT_LINK =
'https://github.com/spotify/backstage/blob/ee84a874f8e37f87940cbe515a86c07a2db29541/plugins/catalog-backend/fixtures/one_component.yaml';
const reader = new GitHubLocationSource();
const result = await reader.read(PERMANENT_LINK);
const reader = new GitHubLocationReader();
const result = await reader.tryRead('github', PERMANENT_LINK);
expect(result[0].type).toBe('data');
expect((result[0] as any).data.metadata.name).toBe('component3');
expect(result?.toString('utf8')).toContain('component3');
});
});
@@ -16,17 +16,31 @@
import fetch from 'node-fetch';
import { URL } from 'url';
import { LocationSource, ReaderOutput } from '../types';
import { readDescriptorYaml } from './util';
import { LocationReader } from './types';
// Pointing to raw.githubusercontent.com for now
// to be changed in the future, after auth and tokens are done
export class GitHubLocationSource implements LocationSource {
async read(target: string): Promise<ReaderOutput[]> {
let url: URL;
/**
* Reads a file whose target is a GitHub URL.
*
* Uses raw.githubusercontent.com for now, but this will probably change in the
* future when token auth is implemented.
*/
export class GitHubLocationReader implements LocationReader {
async tryRead(type: string, target: string): Promise<Buffer | undefined> {
if (type !== 'github') {
return undefined;
}
const url = this.buildRawUrl(target);
try {
url = new URL(target);
return await fetch(url.toString()).then(x => x.buffer());
} catch (e) {
throw new Error(`Unable to read "${target}", ${e}`);
}
}
private buildRawUrl(target: string): URL {
try {
const url = new URL(target);
const [
empty,
@@ -51,23 +65,10 @@ export class GitHubLocationSource implements LocationSource {
url.pathname = [empty, userOrOrg, repoName, ...restOfPath].join('/');
url.hostname = 'raw.githubusercontent.com';
url.protocol = 'https';
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
let rawYaml;
try {
rawYaml = await fetch(url.toString()).then(x => {
return x.text();
});
} catch (e) {
throw new Error(`Unable to read "${target}", ${e}`);
}
try {
return readDescriptorYaml(rawYaml);
} catch (e) {
throw new Error(`Malformed descriptor at "${target}", ${e}`);
}
}
}
@@ -0,0 +1,29 @@
/*
* 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 LocationReader = {
/**
* Reads the contents of a single location.
*
* @param type The type of location to read
* @param target The location target (type-specific)
* @returns The target contents, as a raw Buffer, or undefined if this type
* was not meant to be consumed by this reader
* @throws An error if the type was meant for this reader, but could not be
* read
*/
tryRead(type: string, target: string): Promise<Buffer | undefined>;
};
@@ -1,55 +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 yaml from 'yaml';
import { ReaderOutput } from '../types';
export function readDescriptorYaml(data: string): ReaderOutput[] {
let documents;
try {
documents = yaml.parseAllDocuments(data);
} catch (e) {
throw new Error(`Could not parse YAML data, ${e}`);
}
const result: ReaderOutput[] = [];
for (const document of documents) {
if (document.contents) {
if (document.errors?.length) {
result.push({
type: 'error',
error: new Error(`Malformed YAML document, ${document.errors[0]}`),
});
} else {
const json = document.toJSON();
if (typeof json !== 'object' || Array.isArray(json)) {
result.push({
type: 'error',
error: new Error(`Malformed descriptor, expected object at root`),
});
} else {
result.push({
type: 'data',
data: json,
});
}
}
}
}
return result;
}
+3 -167
View File
@@ -14,172 +14,8 @@
* limitations under the License.
*/
import { ComponentDescriptorV1beta1 } from './descriptors/ComponentDescriptorV1beta1Parser';
import { ReaderOutput } from './descriptor/parsers/types';
export type ComponentDescriptor = ComponentDescriptorV1beta1;
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace, for any given kind.
*/
name?: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
/**
* The format envelope that's common to all versions/kinds.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type DescriptorEnvelope = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Optional metadata related to the entity.
*/
metadata?: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Parses and validates descriptors.
*
* The output must be validated and well formed.
*/
export type DescriptorParser = {
/**
* Parses and validates a single raw descriptor.
*
* @param descriptor A raw descriptor object
* @returns A structure describing the parsed and validated descriptor
* @throws An Error if the descriptor was malformed
*/
parse(descriptor: object): Promise<DescriptorEnvelope>;
};
/**
* Parses and validates a single envelope into its materialized kind.
*
* These parsers may assume that the envelope is already validated and well
* formed.
*/
export type KindParser = {
/**
* Try to parse an envelope into a materialized kind.
*
* @param envelope A valid descriptor envelope
* @returns A materialized type, or undefined if the given version/kind is
* not meant to be handled by this parser
* @throws An Error if the type was handled and found to not be properly
* formatted
*/
tryParse(
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined>;
};
export class ParserError extends Error {
constructor(message?: string, private _entityName?: string | undefined) {
super(message);
}
get entityName() {
return this._entityName;
}
}
export type ReaderOutput =
| { type: 'error'; error: Error }
| { type: 'data'; data: object };
export type LocationReader = {
/**
* Reads the contents of a single location.
*
* @param type The type of location to read
* @param target The location target (type-specific)
* @returns The parsed contents, as an array of unverified descriptors or
* errors where the individual documents could not be parsed.
* @throws An error if the location as a whole could not be read
*/
read(type: string, target: string): Promise<ReaderOutput[]>;
};
export type LocationSource = {
/**
* Reads the contents of a single location.
*
* @param target The location target to read
* @returns The parsed contents, as an array of unverified descriptors
* @throws An error if the location target could not be read
*/
read(target: string): Promise<ReaderOutput[]>;
export type IngestionModel = {
readLocation(type: string, target: string): Promise<ReaderOutput[]>;
};
@@ -15,10 +15,10 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import request from 'supertest';
import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
import { DescriptorEnvelope } from '../ingestion';
import { createRouter } from './router';
class MockEntitiesCatalog implements EntitiesCatalog {
@@ -37,7 +37,7 @@ class MockLocationsCatalog implements LocationsCatalog {
describe('createRouter', () => {
describe('entities', () => {
it('happy path: lists entities', async () => {
const entities: DescriptorEnvelope[] = [{ apiVersion: 'a', kind: 'b' }];
const entities: Entity[] = [{ apiVersion: 'a', kind: 'b' }];
const catalog = new MockEntitiesCatalog();
catalog.entities.mockResolvedValueOnce(entities);
@@ -76,7 +76,7 @@ describe('createRouter', () => {
describe('entityByUid', () => {
it('can fetch entity by uid', async () => {
const entity: DescriptorEnvelope = {
const entity: Entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
@@ -117,7 +117,7 @@ describe('createRouter', () => {
describe('entityByName', () => {
it('can fetch entity by name', async () => {
const entity: DescriptorEnvelope = {
const entity: Entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
@@ -190,7 +190,9 @@ describe('createRouter', () => {
});
const app = express().use(router);
const response = await request(app).post('/locations').send(location);
const response = await request(app)
.post('/locations')
.send(location);
expect(response.status).toEqual(400);
});
@@ -22,7 +22,7 @@ import {
addLocationSchema,
EntitiesCatalog,
EntityFilters,
LocationsCatalog,
LocationsCatalog
} from '../catalog';
import { validateRequestBody } from './util';