Merge pull request #1334 from spotify/freben/entity-logic

chore(catalog-model): move shared entity model logic here
This commit is contained in:
Fredrik Adelöw
2020-06-18 14:01:09 +02:00
committed by GitHub
15 changed files with 582 additions and 281 deletions
+2
View File
@@ -20,8 +20,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.9",
"@types/yup": "^0.28.2",
"lodash": "^4.17.15",
"uuid": "^8.0.0",
"yup": "^0.28.5"
},
"devDependencies": {
+9 -2
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { JsonObject } from '@backstage/config';
/**
* The format envelope that's common to all versions/kinds of entity.
*
@@ -39,7 +41,7 @@ export type Entity = {
/**
* The specification data describing the entity itself.
*/
spec?: object;
spec?: JsonObject;
};
/**
@@ -48,7 +50,7 @@ export type 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 = {
export type EntityMeta = JsonObject & {
/**
* A globally unique ID for the entity.
*
@@ -112,3 +114,8 @@ export type EntityMeta = {
*/
annotations?: Record<string, string>;
};
/**
* The keys of EntityMeta that are auto-generated.
*/
export const entityMetaGeneratedFields = ['uid', 'etag', 'generation'] as const;
@@ -14,5 +14,12 @@
* limitations under the License.
*/
export { entityMetaGeneratedFields } from './Entity';
export type { Entity, EntityMeta } from './Entity';
export * from './policies';
export {
entityHasChanges,
generateEntityEtag,
generateEntityUid,
generateUpdatedEntity,
} from './util';
@@ -0,0 +1,204 @@
/*
* 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';
import {
generateEntityEtag,
generateEntityUid,
entityHasChanges,
generateUpdatedEntity,
} from './util';
import { Entity } from './Entity';
describe('util', () => {
describe('generateEntityUid', () => {
it('generates randomness', () => {
expect(generateEntityUid()).not.toEqual('');
expect(generateEntityUid()).not.toEqual(generateEntityUid());
});
});
describe('generateEntityEtag', () => {
it('generates randomness', () => {
expect(generateEntityEtag()).not.toEqual('');
expect(generateEntityEtag()).not.toEqual(generateEntityEtag());
});
});
describe('entityHasChanges', () => {
let a: Entity;
beforeEach(() => {
a = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'name',
custom: 'custom',
labels: {
labelKey: 'labelValue',
},
annotations: {
annotationKey: 'annotationValue',
},
},
spec: {
a: 'a',
},
};
});
it('happy path: clone has no changes', () => {
const b = lodash.cloneDeep(a);
expect(entityHasChanges(a, b)).toBe(false);
});
it('detects root field changes', () => {
let b: any = lodash.cloneDeep(a);
b.apiVersion += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.apiVersion;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.kind += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.kind;
expect(entityHasChanges(a, b)).toBe(true);
});
it('detects metadata changes', () => {
let b: any = lodash.cloneDeep(a);
b.metadata.name += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.metadata.custom;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.metadata.custom;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.metadata.labels.n = 'n';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.metadata.labels.labelKey += 'a';
expect(entityHasChanges(a, b)).toBe(true);
});
it('detects annotation changes, but not removals', () => {
let b: any = lodash.cloneDeep(a);
b.metadata.annotations.annotationKey += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.metadata.annotations.n = 'n';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.metadata.annotations.annotationKey;
expect(entityHasChanges(a, b)).toBe(false);
});
it('detects spec changes', () => {
let b: any = lodash.cloneDeep(a);
b.spec.a += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.spec.a;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.spec.n = 'n';
expect(entityHasChanges(a, b)).toBe(true);
});
});
describe('generateUpdatedEntity', () => {
let a: Entity;
let b: any;
beforeEach(() => {
a = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
uid: 'da921f56-f655-4e6e-9b8b-bb19a57818d8',
etag: 'NzY5NDA5NzQtYmEwNC00MDY0LWFiYmItNTYxYzQxM2JhZDcx',
generation: 2,
name: 'name',
custom: 'custom',
labels: {
labelKey: 'labelValue',
},
annotations: {
annotationKey: 'annotationValue',
},
},
spec: {
a: 'a',
},
};
b = lodash.cloneDeep(a);
delete b.metadata.uid;
delete b.metadata.etag;
delete b.metadata.generation;
});
it('happy path: running on itself leaves it unchanged', () => {
const result = generateUpdatedEntity(a, b);
expect(result).toEqual(a);
});
it('bumps etag and generation when spec is changed', () => {
b.spec.a += 'a';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation! + 1);
expect(result.spec).toEqual({ a: 'aa' });
});
it('bumps only etag when other things than spec are changed', () => {
b.metadata.n = 'n';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation);
expect(result.metadata.n).toEqual('n');
});
it('retains new annotations', () => {
b.metadata.annotations.annotationKey = 'changedValue';
b.metadata.annotations.newKey = 'newValue';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation);
expect(result.metadata.annotations).toEqual({
annotationKey: 'changedValue',
newKey: 'newValue',
});
});
it('retains old annotations', () => {
b.metadata.annotations.newKey = 'newValue';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation);
expect(result.metadata.annotations).toEqual({
annotationKey: 'annotationValue',
newKey: 'newValue',
});
});
});
});
+140
View File
@@ -0,0 +1,140 @@
/*
* 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';
import { v4 as uuidv4 } from 'uuid';
import { Entity } from './Entity';
/**
* Generates a new random UID for an entity.
*
* @returns A string with enough randomness to uniquely identify an entity
*/
export function generateEntityUid(): string {
return uuidv4();
}
/**
* Generates a new random Etag for an entity.
*
* @returns A string with enough randomness to uniquely identify an entity
* revision
*/
export function generateEntityEtag(): string {
return Buffer.from(uuidv4(), 'utf8').toString('base64').replace(/[^\w]/g, '');
}
/**
* Checks whether there are any significant changes going from the previous to
* the next version of this entity.
*
* Significance, in this case, means that we do not compare generated fields
* such as uid, etag and generation, and we only check that no new annotations
* are added or existing annotations were changed (since they are effectively
* merged when doing updates).
*
* @param previous The old state of the entity
* @param next The new state of the entity
*/
export function entityHasChanges(previous: Entity, next: Entity): boolean {
if (entityHasAnnotationChanges(previous, next)) {
return true;
}
const e1 = lodash.cloneDeep(previous);
const e2 = lodash.cloneDeep(next);
if (!e1.metadata.labels) {
e1.metadata.labels = {};
}
if (!e2.metadata.labels) {
e2.metadata.labels = {};
}
// Remove generated fields
delete e1.metadata.uid;
delete e1.metadata.etag;
delete e1.metadata.generation;
delete e2.metadata.uid;
delete e2.metadata.etag;
delete e2.metadata.generation;
// Remove already compared things
delete e1.metadata.annotations;
delete e2.metadata.annotations;
return !lodash.isEqual(e1, e2);
}
/**
* Takes an old revision of an entity and a new desired state, and merges
* them into a complete new state.
*
* The previous revision is expected to be a complete model loaded from the
* catalog, including the uid, etag and generation fields.
*
* @param previous The old state of the entity
* @param next The new state of the entity
* @returns An entity with the merged state of both
*/
export function generateUpdatedEntity(previous: Entity, next: Entity): Entity {
const { uid, etag, generation } = previous.metadata;
if (!uid || !etag || !generation) {
throw new Error('Previous entity must have uid, etag and generation');
}
const result = lodash.cloneDeep(next);
// Annotations are merged, with the new ones taking precedence
if (previous.metadata.annotations) {
next.metadata.annotations = {
...previous.metadata.annotations,
...next.metadata.annotations,
};
}
// Generated fields are copied and updated
const bumpEtag = entityHasChanges(previous, result);
const bumpGeneration = !lodash.isEqual(previous.spec, result.spec);
result.metadata.uid = uid;
result.metadata.etag = bumpEtag ? generateEntityEtag() : etag;
result.metadata.generation = bumpGeneration ? generation + 1 : generation;
return result;
}
function entityHasAnnotationChanges(previous: Entity, next: Entity): boolean {
// Since the next annotations get merged into the previous, extract only
// the overlapping keys and check if their values match.
if (next.metadata.annotations) {
if (!previous.metadata.annotations) {
return true;
}
if (
!lodash.isEqual(
next.metadata.annotations,
lodash.pick(
previous.metadata.annotations,
Object.keys(next.metadata.annotations),
),
)
) {
return true;
}
}
return false;
}
+9 -6
View File
@@ -14,11 +14,11 @@
* limitations under the License.
*/
import yaml from 'yaml';
import { AppConfig, JsonObject, JsonValue } from '@backstage/config';
import { basename } from 'path';
import { isObject } from './utils';
import { JsonValue, JsonObject, AppConfig } from '@backstage/config';
import yaml from 'yaml';
import { ReaderContext } from './types';
import { isObject } from './utils';
/**
* Reads and parses, and validates, and transforms a single config file.
@@ -67,9 +67,12 @@ export async function readConfigFile(
const out: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
const result = await transform(value, `${path}.${key}`);
if (result !== undefined) {
out[key] = result;
// undefined covers optional fields
if (value !== undefined) {
const result = await transform(value, `${path}.${key}`);
if (result !== undefined) {
out[key] = result;
}
}
}
+2 -1
View File
@@ -122,7 +122,7 @@ export async function readSecret(
const { path } = secret;
const parts = typeof path === 'string' ? path.split('.') : path;
let value: JsonValue = await parser(content);
let value: JsonValue | undefined = await parser(content);
for (const [index, part] of parts.entries()) {
if (!isObject(value)) {
const errPath = parts.slice(0, index).join('.');
@@ -132,6 +132,7 @@ export async function readSecret(
}
value = value[part];
}
return String(value);
}
+1 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
export type JsonObject = { [key in string]: JsonValue };
export type JsonObject = { [key in string]?: JsonValue };
export type JsonArray = JsonValue[];
export type JsonValue =
| JsonObject
@@ -62,6 +62,11 @@ describe('DatabaseEntitiesCatalog', () => {
const result = await catalog.addOrUpdateEntity(entity);
expect(db.entities).toHaveBeenCalledTimes(1);
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
{ key: 'kind', values: ['b'] },
{ key: 'name', values: ['c'] },
{ key: 'namespace', values: ['d'] },
]);
expect(db.addEntity).toHaveBeenCalledTimes(1);
expect(result).toBe(entity);
});
@@ -71,20 +76,52 @@ describe('DatabaseEntitiesCatalog', () => {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'uuuu',
uid: 'u',
name: 'c',
namespace: 'd',
},
};
db.entities.mockResolvedValue([]);
db.entityByUid.mockResolvedValue({
entity: {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
},
});
db.updateEntity.mockResolvedValue({ entity });
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(entity);
expect(db.entities).toHaveBeenCalledTimes(0);
expect(db.entityByUid).toHaveBeenCalledTimes(1);
expect(db.entityByUid).toHaveBeenCalledWith(expect.anything(), 'u');
expect(db.updateEntity).toHaveBeenCalledTimes(1);
expect(db.updateEntity).toHaveBeenCalledWith(
expect.anything(),
{
entity: {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
},
},
'e',
1,
);
expect(result).toBe(entity);
});
@@ -101,19 +138,45 @@ describe('DatabaseEntitiesCatalog', () => {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
};
db.entities.mockResolvedValue([{ entity: existing }]);
db.updateEntity.mockResolvedValue({ entity: added });
db.updateEntity.mockResolvedValue({ entity: existing });
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(added);
expect(db.entities).toHaveBeenCalledTimes(1);
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
{ key: 'kind', values: ['b'] },
{ key: 'name', values: ['c'] },
{ key: 'namespace', values: ['d'] },
]);
expect(db.updateEntity).toHaveBeenCalledTimes(1);
expect(db.updateEntity).toHaveBeenCalledWith(
expect.anything(),
{
entity: {
apiVersion: 'a',
kind: 'b',
metadata: {
uid: 'u',
etag: 'e',
generation: 1,
name: 'c',
namespace: 'd',
},
},
},
'e',
1,
);
expect(result).toEqual(existing);
});
});
@@ -15,7 +15,10 @@
*/
import { NotFoundError } from '@backstage/backend-common';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import {
generateUpdatedEntity,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -43,9 +46,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
namespace: string | undefined,
name: string,
): Promise<Entity | undefined> {
return await this.database.transaction(tx =>
const response = await this.database.transaction(tx =>
this.entityByNameInternal(tx, kind, name, namespace),
);
return response?.entity;
}
async addOrUpdateEntity(
@@ -53,25 +57,30 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
locationId?: string,
): Promise<Entity> {
return await this.database.transaction(async tx => {
let response: DbEntityResponse;
// Find a matching (by uid, or by compound name, depending on the given
// entity) existing entity, to know whether to update or add
const existing = entity.metadata.uid
? await this.database.entityByUid(tx, entity.metadata.uid)
: await this.entityByNameInternal(
tx,
entity.kind,
entity.metadata.name,
entity.metadata.namespace,
);
if (entity.metadata.uid) {
response = await this.database.updateEntity(tx, { locationId, entity });
} else {
const existing = await this.entityByNameInternal(
// If it's an update, run the algorithm for annotation merging, updating
// etag/generation, etc.
let response: DbEntityResponse;
if (existing) {
const updated = generateUpdatedEntity(existing.entity, entity);
response = await this.database.updateEntity(
tx,
entity.kind,
entity.metadata.name,
entity.metadata.namespace,
{ locationId, entity: updated },
existing.entity.metadata.etag,
existing.entity.metadata.generation,
);
if (existing) {
response = await this.database.updateEntity(tx, {
locationId,
entity,
});
} else {
response = await this.database.addEntity(tx, { locationId, entity });
}
} else {
response = await this.database.addEntity(tx, { locationId, entity });
}
return response.entity;
@@ -110,7 +119,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
kind: string,
name: string,
namespace: string | undefined,
): Promise<Entity | undefined> {
): Promise<DbEntityResponse | undefined> {
const matches = await this.database.entities(tx, [
{ key: 'kind', values: [kind] },
{ key: 'name', values: [name] },
@@ -123,6 +132,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
},
]);
return matches.length ? matches[0].entity : undefined;
return matches.length ? matches[0] : undefined;
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import { ConflictError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import { DatabaseManager } from './DatabaseManager';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
@@ -199,9 +199,7 @@ describe('CommonDatabase', () => {
);
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
expect(updated.entity.kind).toEqual(added.entity.kind);
expect(updated.entity.metadata.etag).not.toEqual(
added.entity.metadata.etag,
);
expect(updated.entity.metadata.etag).toEqual(added.entity.metadata.etag);
expect(updated.entity.metadata.generation).toEqual(
added.entity.metadata.generation,
);
@@ -220,41 +218,21 @@ describe('CommonDatabase', () => {
expect(updated.entity.metadata.name).toEqual('new!');
});
it('can update fields if kind, name, and namespace match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual('something.new');
});
it('rejects if kind, name, but not namespace match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
added.entity.metadata.namespace = 'something.wrong';
await expect(
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(NotFoundError);
});
it('fails to update an entity if etag does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.etag = 'garbage';
await expect(
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, 'garbage'),
),
).rejects.toThrow(ConflictError);
});
it('fails to update an entity if generation does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.generation! += 100;
await expect(
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, undefined, 1e20),
),
).rejects.toThrow(ConflictError);
});
});
@@ -19,7 +19,14 @@ import {
InputError,
NotFoundError,
} from '@backstage/backend-common';
import { Entity, EntityMeta, Location } from '@backstage/catalog-model';
import {
Entity,
EntityMeta,
entityMetaGeneratedFields,
generateEntityEtag,
generateEntityUid,
Location,
} from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@@ -38,88 +45,9 @@ import type {
EntityFilters,
} from './types';
function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
const output = lodash.cloneDeep(metadata);
delete output.uid;
delete output.etag;
delete output.generation;
return output;
}
function serializeMetadata(metadata: EntityMeta): string {
return JSON.stringify(getStrippedMetadata(metadata));
}
function serializeSpec(spec: Entity['spec']): DbEntitiesRow['spec'] {
if (!spec) {
return null;
}
return JSON.stringify(spec);
}
function toEntityRow(
locationId: string | undefined,
entity: Entity,
): DbEntitiesRow {
return {
id: entity.metadata.uid!,
location_id: locationId || null,
etag: entity.metadata.etag!,
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: serializeMetadata(entity.metadata),
spec: serializeSpec(entity.spec),
};
}
function toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
const entity: Entity = {
apiVersion: row.api_version,
kind: row.kind,
metadata: {
...(JSON.parse(row.metadata) as Entity['metadata']),
uid: row.id,
etag: row.etag,
generation: Number(row.generation), // cast because of sqlite
},
};
if (row.spec) {
const spec = JSON.parse(row.spec);
entity.spec = spec;
}
return {
locationId: row.location_id || undefined,
entity,
};
}
function specsAreEqual(
first: string | null,
second: object | undefined,
): boolean {
if (!first && !second) {
return true;
} else if (!first || !second) {
return false;
}
return lodash.isEqual(JSON.parse(first), second);
}
function generateUid(): string {
return uuidv4();
}
function generateEtag(): string {
return Buffer.from(uuidv4(), 'utf8').toString('base64').replace(/[^\w]/g, '');
}
/**
* The core database implementation.
*/
export class CommonDatabase implements Database {
constructor(
private readonly database: Knex,
@@ -163,12 +91,12 @@ export class CommonDatabase implements Database {
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
uid: generateUid(),
etag: generateEtag(),
uid: generateEntityUid(),
etag: generateEntityEtag(),
generation: 1,
};
const newRow = toEntityRow(request.locationId, newEntity);
const newRow = this.toEntityRow(request.locationId, newEntity);
await tx<DbEntitiesRow>('entities').insert(newRow);
await this.updateEntitiesSearch(tx, newRow.id, newEntity);
@@ -178,35 +106,20 @@ export class CommonDatabase implements Database {
async updateEntity(
txOpaque: unknown,
request: DbEntityRequest,
matchingEtag?: string,
matchingGeneration?: number,
): Promise<DbEntityResponse> {
const tx = txOpaque as Knex.Transaction<any, any>;
const { kind } = request.entity;
const {
uid,
etag: expectedOldEtag,
generation: expectedOldGeneration,
name,
namespace,
} = request.entity.metadata ?? {};
const { uid } = request.entity.metadata;
// Find existing entities that match the given metadata
let entitySelector: Partial<DbEntitiesRow>;
if (uid) {
entitySelector = { id: uid };
} else if (kind && name) {
entitySelector = {
kind,
name: name,
namespace: namespace || null,
};
} else {
throw new InputError(
'Must specify either uid, or kind + name + namespace to be able to identify an entity',
);
if (uid === undefined) {
throw new InputError('Must specify uid when updating entities');
}
// Find existing entity
const oldRows = await tx<DbEntitiesRow>('entities')
.where(entitySelector)
.where({ id: uid })
.select();
if (oldRows.length !== 1) {
throw new NotFoundError('No matching entity found');
@@ -217,51 +130,26 @@ export class CommonDatabase implements Database {
// The Number cast is here because sqlite reads it as a string, no matter
// what the table actually says
oldRow.generation = Number(oldRow.generation);
if (expectedOldEtag) {
if (expectedOldEtag !== oldRow.etag) {
if (matchingEtag) {
if (matchingEtag !== oldRow.etag) {
throw new ConflictError(
`Etag mismatch, expected="${expectedOldEtag}" found="${oldRow.etag}"`,
`Etag mismatch, expected="${matchingEtag}" found="${oldRow.etag}"`,
);
}
}
if (expectedOldGeneration) {
if (expectedOldGeneration !== oldRow.generation) {
if (matchingGeneration) {
if (matchingGeneration !== oldRow.generation) {
throw new ConflictError(
`Generation mismatch, expected="${expectedOldGeneration}" found="${oldRow.generation}"`,
`Generation mismatch, expected="${matchingGeneration}" found="${oldRow.generation}"`,
);
}
}
// Build the new shape of the entity
const newEtag = generateEtag();
const newGeneration = specsAreEqual(oldRow.spec, request.entity.spec)
? oldRow.generation
: oldRow.generation + 1;
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
uid: oldRow.id,
etag: newEtag,
generation: newGeneration,
};
// Preserve annotations that were set on the old version of the entity,
// unless the new version overwrites them
if (oldRow.metadata) {
const oldMetadata = JSON.parse(oldRow.metadata) as EntityMeta;
if (oldMetadata.annotations) {
newEntity.metadata.annotations = {
...oldMetadata.annotations,
...newEntity.metadata.annotations,
};
}
}
await this.ensureNoSimilarNames(tx, newEntity);
await this.ensureNoSimilarNames(tx, request.entity);
// Store the updated entity; select on the old etag to ensure that we do
// not lose to another writer
const newRow = toEntityRow(request.locationId, newEntity);
const newRow = this.toEntityRow(request.locationId, request.entity);
const updatedRows = await tx<DbEntitiesRow>('entities')
.where({ id: oldRow.id, etag: oldRow.etag })
.update(newRow);
@@ -271,8 +159,9 @@ export class CommonDatabase implements Database {
throw new ConflictError(`Failed to update entity`);
}
await this.updateEntitiesSearch(tx, oldRow.id, newEntity);
return { locationId: request.locationId, entity: newEntity };
await this.updateEntitiesSearch(tx, oldRow.id, request.entity);
return request;
}
async entities(
@@ -328,7 +217,7 @@ export class CommonDatabase implements Database {
.orderBy('name', 'asc')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
return rows.map(row => this.toEntityResponse(row));
}
async entity(
@@ -347,7 +236,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return toEntityResponse(rows[0]);
return this.toEntityResponse(rows[0]);
}
async entityByUid(
@@ -362,7 +251,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return toEntityResponse(rows[0]);
return this.toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
@@ -524,4 +413,47 @@ export class CommonDatabase implements Database {
}
}
}
private toEntityRow(
locationId: string | undefined,
entity: Entity,
): DbEntitiesRow {
return {
id: entity.metadata.uid!,
location_id: locationId || null,
etag: entity.metadata.etag!,
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: JSON.stringify(
lodash.omit(entity.metadata, ...entityMetaGeneratedFields),
),
spec: entity.spec ? JSON.stringify(entity.spec) : null,
};
}
private toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
const entity: Entity = {
apiVersion: row.api_version,
kind: row.kind,
metadata: {
...(JSON.parse(row.metadata) as EntityMeta),
uid: row.id,
etag: row.etag,
generation: Number(row.generation), // cast because of sqlite
},
};
if (row.spec) {
const spec = JSON.parse(row.spec);
entity.spec = spec;
}
return {
locationId: row.location_id || undefined,
entity,
};
}
}
+12 -6
View File
@@ -104,21 +104,27 @@ export type Database = {
/**
* Updates an existing entity in the catalog.
*
* The given entity must contain enough information to identify an already
* stored entity in the catalog - either by uid, or by kind + namespace +
* name. If no matching entity is found, the operation fails.
* The given entity must contain an uid to identify an already stored entity
* in the catalog. If it is missing or if no matching entity is found, the
* operation fails.
*
* If etag or generation are given, they are taken into account. Attempts to
* update a matching entity, but where the etag and/or generation are not
* equal to the passed values, will fail.
* If matchingEtag or matchingGeneration are given, they are taken into
* account. Attempts to update a matching entity, but where the etag and/or
* generation are not equal to the passed values, will fail.
*
* @param tx An ongoing transaction
* @param request The entity being updated
* @param matchingEtag If specified, reject with ConflictError if not
* matching the entry in the database
* @param matchingGeneration If specified, reject with ConflictError if not
* matching the entry in the database
* @returns The updated entity
*/
updateEntity(
tx: unknown,
request: DbEntityRequest,
matchingEtag?: string,
matchingGeneration?: number,
): Promise<DbEntityResponse>;
entities(tx: unknown, filters?: EntityFilters): Promise<DbEntityResponse[]>;
@@ -15,8 +15,12 @@
*/
import { InputError } from '@backstage/backend-common';
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
import lodash from 'lodash';
import {
Entity,
entityHasChanges,
Location,
LocationSpec,
} from '@backstage/catalog-model';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
@@ -173,7 +177,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
if (!previous) {
this.logger.debug(`No such entity found, adding`);
await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
} else if (!this.entitiesAreEqual(previous, entity)) {
} else if (entityHasChanges(previous, entity)) {
this.logger.debug(`Different from existing entity, updating`);
await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
} else {
@@ -199,60 +203,4 @@ export class HigherOrderOperations implements HigherOrderOperation {
}
}
}
// Compares entities, ignoring generated and irrelevant data
private entitiesAreEqual(previous: Entity, next: Entity): boolean {
if (
previous.apiVersion !== next.apiVersion ||
previous.kind !== next.kind ||
!lodash.isEqual(previous.spec, next.spec) // Accept that {} !== undefined
) {
return false;
}
// Since the next annotations get merged into the previous, extract only
// the overlapping keys and check if their values match.
if (next.metadata.annotations) {
if (!previous.metadata.annotations) {
return false;
}
if (
!lodash.isEqual(
next.metadata.annotations,
lodash.pick(
previous.metadata.annotations,
Object.keys(next.metadata.annotations),
),
)
) {
return false;
}
}
const e1 = lodash.cloneDeep(previous);
const e2 = lodash.cloneDeep(next);
if (!e1.metadata.labels) {
e1.metadata.labels = {};
}
if (!e2.metadata.labels) {
e2.metadata.labels = {};
}
// Remove generated fields
delete e1.metadata.uid;
delete e1.metadata.etag;
delete e1.metadata.generation;
delete e2.metadata.uid;
delete e2.metadata.etag;
delete e2.metadata.generation;
// Remove already compared things
delete e1.metadata.annotations;
delete e1.spec;
delete e2.metadata.annotations;
delete e2.spec;
return lodash.isEqual(e1, e2);
}
}
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import React, { ComponentProps } from 'react';
import { render, cleanup } from '@testing-library/react';
import { RegisterComponentResultDialog } from './RegisterComponentResultDialog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { cleanup, render } from '@testing-library/react';
import React, { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Entity } from '@backstage/catalog-model';
import { RegisterComponentResultDialog } from './RegisterComponentResultDialog';
const setup = (
props?: Partial<ComponentProps<typeof RegisterComponentResultDialog>>,
@@ -52,6 +51,7 @@ it('should show a list of components if success', async () => {
const { rendered } = setup({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Component1',
@@ -61,6 +61,7 @@ it('should show a list of components if success', async () => {
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Component2',
@@ -69,7 +70,7 @@ it('should show a list of components if success', async () => {
type: 'service',
},
},
] as Entity[],
],
});
expect(