diff --git a/.changeset/angry-crabs-relate.md b/.changeset/angry-crabs-relate.md new file mode 100644 index 0000000000..678093c1ac --- /dev/null +++ b/.changeset/angry-crabs-relate.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Add ability to dry run adding a new location to the catalog API. + +The location is now added in a transaction and afterwards rolled back. +This allows users to dry run this operation to see if there entity has issues. +This is probably done by automated tools in the CI/CD pipeline. diff --git a/.changeset/nervous-drinks-notice.md b/.changeset/nervous-drinks-notice.md new file mode 100644 index 0000000000..7c6af21f64 --- /dev/null +++ b/.changeset/nervous-drinks-notice.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog': minor +'@backstage/plugin-register-component': minor +--- + +Add a validate button to the register-component page + +This allows the user to validate his location before adding it. diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index 26990033aa..1e037461b4 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -122,6 +122,7 @@ export default async function createPlugin({ entitiesCatalog, locationsCatalog, locationReader, + db, logger, ); diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index dea2f16bae..076e644afd 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -43,7 +43,7 @@ "express-promise-router": "^3.0.3", "git-url-parse": "^11.4.0", "helmet": "^4.0.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "lodash": "^4.17.15", "logform": "^2.1.1", "minimist": "^1.2.5", diff --git a/packages/backend/package.json b/packages/backend/package.json index 9f8e66bc61..0d066d1139 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -38,7 +38,7 @@ "example-app": "^0.1.1-alpha.26", "express": "^4.17.1", "express-promise-router": "^3.0.3", - "knex": "^0.21.1", + "knex": "^0.21.6", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", "sqlite3": "^5.0.0", diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index dde04868b9..bf887dc014 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -31,7 +31,7 @@ "dockerode": "^3.2.0", "express": "^4.17.1", "express-promise-router": "^3.0.3", - "knex": "^0.21.1", + "knex": "^0.21.6", {{#if dbTypePG}} "pg": "^8.3.0", {{/if}} diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 6489c8547d..caa20e361b 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -35,7 +35,7 @@ "helmet": "^4.0.0", "jose": "^1.27.1", "jwt-decode": "2.2.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "moment": "^2.26.0", "morgan": "^1.10.0", "passport": "^0.4.1", diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index cd8e363901..d15779bf4e 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -32,7 +32,7 @@ "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", "git-url-parse": "^11.4.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "ldapjs": "^2.2.0", "lodash": "^4.17.15", "morgan": "^1.10.0", diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 6407000b6f..f3db4c8dc6 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -16,12 +16,13 @@ import { getVoidLogger } from '@backstage/backend-common'; import type { Entity } from '@backstage/catalog-model'; -import { Database, DatabaseManager } from '../database'; +import { Database, DatabaseManager, Transaction } from '../database'; import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; import { EntityUpsertRequest } from './types'; describe('DatabaseEntitiesCatalog', () => { let db: jest.Mocked; + let transaction: jest.Mocked; beforeAll(() => { db = { @@ -40,11 +41,14 @@ describe('DatabaseEntitiesCatalog', () => { locationHistory: jest.fn(), addLocationUpdateLogEvent: jest.fn(), }; + transaction = { + rollback: jest.fn(), + }; }); beforeEach(() => { jest.resetAllMocks(); - db.transaction.mockImplementation(async f => f('tx')); + db.transaction.mockImplementation(async f => f(transaction)); }); describe('batchAddOrUpdateEntities', () => { @@ -82,6 +86,84 @@ describe('DatabaseEntitiesCatalog', () => { expect(result).toEqual([{ entityId: 'u' }]); }); + it('dry run of add operation', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + }; + db.entities.mockResolvedValue([]); + db.addEntities.mockResolvedValue([ + { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, + ]); + + const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); + const result = await catalog.batchAddOrUpdateEntities( + [{ entity, relations: [] }], + { dryRun: true }, + ); + + expect(db.entities).toHaveBeenCalledTimes(1); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), [ + { + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': ['c'], + }, + ]); + expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); + expect(db.addEntities).toHaveBeenCalledTimes(1); + expect(transaction.rollback).toBeCalledTimes(1); + expect(result).toEqual([{ entityId: 'u' }]); + }); + + it('output modified entities', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + }; + const dbEntity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + uid: 'u', + }, + }; + db.entities.mockResolvedValue([ + { + entity: dbEntity, + }, + ]); + db.addEntities.mockResolvedValue([ + { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, + ]); + + const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); + const result = await catalog.batchAddOrUpdateEntities( + [{ entity, relations: [] }], + { outputEntities: true }, + ); + + expect(db.entities).toHaveBeenCalledTimes(2); + expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { + entityId: 'u', + entity: dbEntity, + }, + ]); + }); + it('updates when given uid', async () => { const entity: Entity = { apiVersion: 'a', @@ -131,10 +213,10 @@ describe('DatabaseEntitiesCatalog', () => { ]); expect(db.entityByName).not.toHaveBeenCalled(); expect(db.entityByUid).toHaveBeenCalledTimes(1); - expect(db.entityByUid).toHaveBeenCalledWith(expect.anything(), 'u'); + expect(db.entityByUid).toHaveBeenCalledWith(transaction, 'u'); expect(db.updateEntity).toHaveBeenCalledTimes(1); expect(db.updateEntity).toHaveBeenCalledWith( - expect.anything(), + transaction, { entity: { apiVersion: 'a', @@ -206,14 +288,14 @@ describe('DatabaseEntitiesCatalog', () => { }, ]); expect(db.entityByName).toHaveBeenCalledTimes(1); - expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { + expect(db.entityByName).toHaveBeenCalledWith(transaction, { kind: 'b', namespace: 'd', name: 'c', }); expect(db.updateEntity).toHaveBeenCalledTimes(1); expect(db.updateEntity).toHaveBeenCalledWith( - expect.anything(), + transaction, { entity: { apiVersion: 'a', diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 947736dc06..c97ee023e6 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -27,7 +27,12 @@ import { import { chunk, groupBy } from 'lodash'; import limiterFactory from 'p-limit'; import { Logger } from 'winston'; -import type { Database, DbEntityResponse, EntityFilters } from '../database'; +import type { + Database, + DbEntityResponse, + EntityFilters, + Transaction, +} from '../database'; import { durationText } from '../util/timing'; import type { EntitiesCatalog, @@ -69,35 +74,34 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async addOrUpdateEntity( entity: Entity, + tx: Transaction, locationId?: string, ): Promise { - return await this.database.transaction(async tx => { - // 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.database.entityByName(tx, getEntityName(entity)); + // 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.database.entityByName(tx, getEntityName(entity)); - // 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, - { locationId, entity: updated }, - existing.entity.metadata.etag, - existing.entity.metadata.generation, - ); - } else { - const added = await this.database.addEntities(tx, [ - { locationId, entity }, - ]); - response = added[0]; - } + // 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, + { locationId, entity: updated }, + existing.entity.metadata.etag, + existing.entity.metadata.generation, + ); + } else { + const added = await this.database.addEntities(tx, [ + { locationId, entity }, + ]); + response = added[0]; + } - return response.entity; - }); + return response.entity; } async removeEntityByUid(uid: string): Promise { @@ -131,92 +135,126 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { * Writes a number of entities efficiently to storage. * * @param entities Some entities - * @param locationId The location that they all belong to + * @param options.locationId The location that they all belong to + * @param options.tx A database transaction to execute the queries in */ async batchAddOrUpdateEntities( requests: EntityUpsertRequest[], - locationId?: string, + options?: { + locationId?: string; + dryRun?: boolean; + outputEntities?: boolean; + }, ): Promise { - // Group the entities by unique kind+namespace combinations - const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { - const name = getEntityName(entity); - return `${name.kind}:${name.namespace}`.toLowerCase(); - }); + const locationId = options?.locationId; - const limiter = limiterFactory(BATCH_CONCURRENCY); - const tasks: Promise[] = []; + return await this.database.transaction(async tx => { + // Group the entities by unique kind+namespace combinations + const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { + const name = getEntityName(entity); + return `${name.kind}:${name.namespace}`.toLowerCase(); + }); - for (const groupRequests of Object.values(entitiesByKindAndNamespace)) { - const { kind, namespace } = getEntityName(groupRequests[0].entity); + const limiter = limiterFactory(BATCH_CONCURRENCY); + const tasks: Promise[] = []; - // Go through the new entities in reasonable chunk sizes (sometimes, - // sources produce tens of thousands of entities, and those are too large - // batch sizes to reasonably send to the database) - for (const batch of chunk(groupRequests, BATCH_SIZE)) { - tasks.push( - limiter(async () => { - const first = serializeEntityRef(batch[0].entity); - const last = serializeEntityRef(batch[batch.length - 1].entity); - const modifiedEntityIds: EntityUpsertResponse[] = []; - this.logger.debug( - `Considering batch ${first}-${last} (${batch.length} entries)`, - ); + for (const groupRequests of Object.values(entitiesByKindAndNamespace)) { + const { kind, namespace } = getEntityName(groupRequests[0].entity); - // Retry the batch write a few times to deal with contention - const context = { kind, namespace, locationId }; - for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { - try { - const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( - batch, - context, - ); - if (toAdd.length) { - modifiedEntityIds.push( - ...(await this.batchAdd(toAdd, context)), + // Go through the new entities in reasonable chunk sizes (sometimes, + // sources produce tens of thousands of entities, and those are too large + // batch sizes to reasonably send to the database) + for (const batch of chunk(groupRequests, BATCH_SIZE)) { + tasks.push( + limiter(async () => { + const first = serializeEntityRef(batch[0].entity); + const last = serializeEntityRef(batch[batch.length - 1].entity); + let modifiedEntityIds: EntityUpsertResponse[] = []; + + this.logger.debug( + `Considering batch ${first}-${last} (${batch.length} entries)`, + ); + + // Retry the batch write a few times to deal with contention + const context = { + kind, + namespace, + locationId, + }; + for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { + try { + const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( + batch, + context, + tx, ); - } - if (toUpdate.length) { - modifiedEntityIds.push( - ...(await this.batchUpdate(toUpdate, context)), - ); - } - // TODO(Rugvip): We currently always update relations, but we - // likely want to figure out a way to avoid that - for (const { entity, relations } of toIgnore) { - const entityId = entity.metadata.uid!; - await this.setRelations(entityId, relations); - modifiedEntityIds.push({ entityId }); - } + if (toAdd.length) { + modifiedEntityIds.push( + ...(await this.batchAdd(toAdd, context, tx)), + ); + } + if (toUpdate.length) { + modifiedEntityIds.push( + ...(await this.batchUpdate(toUpdate, context, tx)), + ); + } + // TODO(Rugvip): We currently always update relations, but we + // likely want to figure out a way to avoid that + for (const { entity, relations } of toIgnore) { + const entityId = entity.metadata.uid!; + await this.setRelations(entityId, relations, tx); + modifiedEntityIds.push({ entityId }); + } - break; - } catch (e) { - if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { - this.logger.warn( - `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, - ); - } else { - throw e; + break; + } catch (e) { + if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { + this.logger.warn( + `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, + ); + } else { + throw e; + } } } - } - return modifiedEntityIds; - }), - ); + if (options?.outputEntities) { + const writtenEntities = await this.database.entities(tx, [ + { 'metadata.uid': modifiedEntityIds.map(e => e.entityId) }, + ]); + + modifiedEntityIds = writtenEntities.map(e => ({ + entityId: e.entity.metadata.uid!, + entity: e.entity, + })); + } + + return modifiedEntityIds; + }), + ); + } } - } - return (await Promise.all(tasks)).flat(); + const entityUpserts = (await Promise.all(tasks)).flat(); + + if (options?.dryRun) { + // If this is only a dry run, cancel the database transaction even if it was successful. + await tx.rollback(); + + this.logger.debug(`Perfomed successful dry run of adding entities`); + } + + return entityUpserts; + }); } // Set the relations originating from an entity using the DB layer private async setRelations( originatingEntityId: string, relations: EntityRelationSpec[], + tx: Transaction, ): Promise { - return await this.database.transaction(tx => - this.database.setRelations(tx, originatingEntityId, relations), - ); + await this.database.setRelations(tx, originatingEntityId, relations); } // Given a batch of entities that were just read from a location, take them @@ -226,6 +264,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async analyzeBatch( requests: EntityUpsertRequest[], { kind, namespace }: BatchContext, + tx: Transaction, ): Promise<{ toAdd: EntityUpsertRequest[]; toUpdate: EntityUpsertRequest[]; @@ -234,7 +273,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const markTimestamp = process.hrtime(); const names = requests.map(({ entity }) => entity.metadata.name); - const oldEntities = await this.entities([ + const oldEntities = await this.database.entities(tx, [ { kind: kind, 'metadata.namespace': namespace, @@ -243,7 +282,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { ]); const oldEntitiesByName = new Map( - oldEntities.map(e => [e.metadata.name, e]), + oldEntities.map(e => [e.entity.metadata.name, e.entity]), ); const toAdd: EntityUpsertRequest[] = []; @@ -279,15 +318,13 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async batchAdd( requests: EntityUpsertRequest[], { locationId }: BatchContext, + tx: Transaction, ): Promise { const markTimestamp = process.hrtime(); - const res = await this.database.transaction( - async tx => - await this.database.addEntities( - tx, - requests.map(({ entity }) => ({ locationId, entity })), - ), + const res = await this.database.addEntities( + tx, + requests.map(({ entity }) => ({ locationId, entity })), ); const entityIds = res.map(({ entity }) => ({ @@ -295,7 +332,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { })); for (const [index, { entityId }] of entityIds.entries()) { - await this.setRelations(entityId, requests[index].relations); + await this.setRelations(entityId, requests[index].relations, tx); } this.logger.debug( @@ -310,15 +347,16 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private async batchUpdate( requests: EntityUpsertRequest[], { locationId }: BatchContext, + tx: Transaction, ): Promise { const markTimestamp = process.hrtime(); const responseIds: EntityUpsertResponse[] = []; // TODO(freben): Still not batched for (const entity of requests) { - const res = await this.addOrUpdateEntity(entity.entity, locationId); + const res = await this.addOrUpdateEntity(entity.entity, tx, locationId); const entityId = res.metadata.uid!; responseIds.push({ entityId }); - await this.setRelations(entityId, entity.relations); + await this.setRelations(entityId, entity.relations, tx); } this.logger.debug( diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index f515829b91..9ce53d2850 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -26,8 +26,9 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { constructor(private readonly database: Database) {} async addLocation(location: Location): Promise { - const added = await this.database.addLocation(location); - return added; + return await this.database.transaction( + async tx => await this.database.addLocation(tx, location), + ); } async removeLocation(id: string): Promise { diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 539a0e531f..edc6ed2772 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -28,6 +28,7 @@ export type EntityUpsertRequest = { export type EntityUpsertResponse = { entityId: string; + entity?: Entity; }; export type EntitiesCatalog = { @@ -42,7 +43,11 @@ export type EntitiesCatalog = { */ batchAddOrUpdateEntities( entities: EntityUpsertRequest[], - locationId?: string, + options?: { + locationId?: string; + dryRun?: boolean; + outputEntities?: boolean; + }, ): Promise; }; diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index ac11f1dd1a..b2a238b111 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -91,7 +91,7 @@ describe('CommonDatabase', () => { timestamp: null, }; - await db.addLocation(input); + await db.transaction(async tx => await db.addLocation(tx, input)); const locations = await db.locations(); expect(locations).toEqual( @@ -238,7 +238,8 @@ describe('CommonDatabase', () => { type: 'a', target: 'b', }; - await db.addLocation(location); + + await db.transaction(async tx => await db.addLocation(tx, location)); await db.addLocationUpdateLogEvent( 'dd12620d-0436-422f-93bd-929aa0788123', diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 72e6bf0a5f..bd6dc29abc 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -46,6 +46,7 @@ import { DbLocationsRow, DbLocationsRowWithStatus, EntityFilters, + Transaction, } from './types'; // The number of items that are sent per batch to the database layer, when @@ -63,9 +64,23 @@ export class CommonDatabase implements Database { private readonly logger: Logger, ) {} - async transaction(fn: (tx: unknown) => Promise): Promise { + async transaction(fn: (tx: Transaction) => Promise): Promise { try { - return await this.database.transaction(fn); + let result: T | undefined = undefined; + + await this.database.transaction( + async tx => { + // We can't return here, as knex swallows the return type in case the transaction is rolled back: + // https://github.com/knex/knex/blob/e37aeaa31c8ef9c1b07d2e4d3ec6607e557d800d/lib/transaction.js#L136 + result = await fn(tx); + }, + { + // If we explicitly trigger a rollback, don't fail. + doNotRejectOnRollback: true, + }, + ); + + return result!; } catch (e) { this.logger.debug(`Error during transaction, ${e}`); @@ -81,7 +96,7 @@ export class CommonDatabase implements Database { } async addEntity( - txOpaque: unknown, + txOpaque: Transaction, request: DbEntityRequest, ): Promise { const tx = txOpaque as Knex.Transaction; @@ -110,7 +125,7 @@ export class CommonDatabase implements Database { } async addEntities( - txOpaque: unknown, + txOpaque: Transaction, request: DbEntityRequest[], ): Promise { const tx = txOpaque as Knex.Transaction; @@ -158,7 +173,7 @@ export class CommonDatabase implements Database { } async updateEntity( - txOpaque: unknown, + txOpaque: Transaction, request: DbEntityRequest, matchingEtag?: string, matchingGeneration?: number, @@ -217,7 +232,7 @@ export class CommonDatabase implements Database { } async entities( - txOpaque: unknown, + txOpaque: Transaction, filters?: EntityFilters[], ): Promise { const tx = txOpaque as Knex.Transaction; @@ -295,7 +310,7 @@ export class CommonDatabase implements Database { } async entityByName( - txOpaque: unknown, + txOpaque: Transaction, name: EntityName, ): Promise { const tx = txOpaque as Knex.Transaction; @@ -314,7 +329,7 @@ export class CommonDatabase implements Database { } async entityByUid( - txOpaque: unknown, + txOpaque: Transaction, uid: string, ): Promise { const tx = txOpaque as Knex.Transaction; @@ -330,7 +345,7 @@ export class CommonDatabase implements Database { return this.toEntityResponse(tx, rows[0]); } - async removeEntityByUid(txOpaque: unknown, uid: string): Promise { + async removeEntityByUid(txOpaque: Transaction, uid: string): Promise { const tx = txOpaque as Knex.Transaction; const result = await tx('entities').where({ id: uid }).del(); @@ -341,7 +356,7 @@ export class CommonDatabase implements Database { } async setRelations( - txOpaque: unknown, + txOpaque: Transaction, originatingEntityId: string, relations: EntityRelationSpec[], ): Promise { @@ -368,19 +383,22 @@ export class CommonDatabase implements Database { await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE); } - async addLocation(location: Location): Promise { - return await this.database.transaction(async tx => { - const row: DbLocationsRow = { - id: location.id, - type: location.type, - target: location.target, - }; - await tx('locations').insert(row); - return row; - }); + async addLocation( + txOpaque: Transaction, + location: Location, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const row: DbLocationsRow = { + id: location.id, + type: location.type, + target: location.target, + }; + await tx('locations').insert(row); + return row; } - async removeLocation(txOpaque: unknown, id: string): Promise { + async removeLocation(txOpaque: Transaction, id: string): Promise { const tx = txOpaque as Knex.Transaction; await tx('entities') diff --git a/plugins/catalog-backend/src/database/index.ts b/plugins/catalog-backend/src/database/index.ts index 565a41cfb2..0d84106815 100644 --- a/plugins/catalog-backend/src/database/index.ts +++ b/plugins/catalog-backend/src/database/index.ts @@ -22,4 +22,5 @@ export type { DbEntityResponse, EntityFilter, EntityFilters, + Transaction, } from './types'; diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index b8a2a6231a..62b2d55b88 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -101,6 +101,13 @@ export type EntityFilter = null | string | (null | string)[]; */ export type EntityFilters = Record; +/** + * An abstraction for transactions of the underlying database technology. + */ +export type Transaction = { + rollback(): Promise; +}; + /** * An abstraction on top of the underlying database, wrapping the basic CRUD * needs. @@ -114,7 +121,7 @@ export type Database = { * * @param fn The callback that implements the transaction */ - transaction(fn: (tx: unknown) => Promise): Promise; + transaction(fn: (tx: Transaction) => Promise): Promise; /** * Adds a set of new entities to the catalog. @@ -123,7 +130,7 @@ export type Database = { * @param request The entities being added */ addEntities( - tx: unknown, + tx: Transaction, request: DbEntityRequest[], ): Promise; @@ -147,22 +154,28 @@ export type Database = { * @returns The updated entity */ updateEntity( - tx: unknown, + tx: Transaction, request: DbEntityRequest, matchingEtag?: string, matchingGeneration?: number, ): Promise; - entities(tx: unknown, filters?: EntityFilters[]): Promise; + entities( + tx: Transaction, + filters?: EntityFilters[], + ): Promise; entityByName( - tx: unknown, + tx: Transaction, name: EntityName, ): Promise; - entityByUid(tx: unknown, uid: string): Promise; + entityByUid( + tx: Transaction, + uid: string, + ): Promise; - removeEntityByUid(tx: unknown, uid: string): Promise; + removeEntityByUid(tx: Transaction, uid: string): Promise; /** * Remove current relations for the entity and replace them with the new relations array @@ -171,14 +184,14 @@ export type Database = { * @param relations the relationships to be set */ setRelations( - tx: unknown, + tx: Transaction, entityUid: string, relations: EntityRelationSpec[], ): Promise; - addLocation(location: Location): Promise; + addLocation(tx: Transaction, location: Location): Promise; - removeLocation(tx: unknown, id: string): Promise; + removeLocation(tx: Transaction, id: string): Promise; location(id: string): Promise; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 933433b47e..40b48ca4ab 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -93,6 +93,68 @@ describe('HigherOrderOperations', () => { ); }); + it('insert the location and its entities', async () => { + const spec = { + type: 'a', + target: 'b', + }; + const location: LocationSpec = { type: '', target: '' }; + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x)); + locationsCatalog.locations.mockResolvedValue([]); + locationsCatalog.locations.mockResolvedValue([]); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([ + { + entityId: 'id', + entity, + }, + ]); + + locationReader.read.mockResolvedValue({ + entities: [ + { + location, + entity, + relations: [], + }, + ], + errors: [], + }); + + const result = await higherOrderOperation.addLocation(spec); + + expect(result.location).toEqual( + expect.objectContaining({ + id: expect.anything(), + ...spec, + }), + ); + expect(result.entities).toEqual([entity]); + expect(locationsCatalog.locations).toBeCalledTimes(1); + expect(locationsCatalog.addLocation).toBeCalledTimes(1); + expect(locationsCatalog.addLocation).toBeCalledWith( + expect.objectContaining({ + id: expect.anything(), + ...spec, + }), + ); + expect(locationReader.read).toBeCalledTimes(1); + expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); + expect(entitiesCatalog.batchAddOrUpdateEntities).toBeCalledTimes(1); + expect(entitiesCatalog.batchAddOrUpdateEntities).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + locationId: expect.anything(), + dryRun: false, + outputEntities: true, + }), + ); + }); + it('reuses the location if a match already existed', async () => { const spec = { type: 'a', @@ -150,6 +212,60 @@ describe('HigherOrderOperations', () => { expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled(); expect(locationsCatalog.addLocation).not.toBeCalled(); }); + + it('rollback everything after a dry run', async () => { + const spec = { + type: 'a', + target: 'b', + }; + const location: LocationSpec = { type: '', target: '' }; + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + locationsCatalog.locations.mockResolvedValue([]); + locationsCatalog.locations.mockResolvedValue([]); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([ + { + entityId: 'id', + entity, + }, + ]); + locationReader.read.mockResolvedValue({ + entities: [ + { + location, + entity, + relations: [], + }, + ], + errors: [], + }); + + const result = await higherOrderOperation.addLocation(spec, { + dryRun: true, + }); + + expect(result.location).toEqual( + expect.objectContaining({ + id: expect.anything(), + ...spec, + }), + ); + expect(result.entities).toEqual([entity]); + expect(locationsCatalog.locations).toBeCalledTimes(1); + expect(locationReader.read).toBeCalledTimes(1); + expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); + expect(entitiesCatalog.batchAddOrUpdateEntities).toBeCalledTimes(1); + expect(entitiesCatalog.batchAddOrUpdateEntities).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + dryRun: true, + outputEntities: true, + }), + ); + }); }); describe('refreshLocations', () => { @@ -213,7 +329,9 @@ describe('HigherOrderOperations', () => { relations: [], }), ], - '123', + { + locationId: '123', + }, ); }); diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 0e62815be5..902e26e0e3 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -33,22 +33,12 @@ import { * database more directly. */ export class HigherOrderOperations implements HigherOrderOperation { - private readonly entitiesCatalog: EntitiesCatalog; - private readonly locationsCatalog: LocationsCatalog; - private readonly locationReader: LocationReader; - private readonly logger: Logger; - constructor( - entitiesCatalog: EntitiesCatalog, - locationsCatalog: LocationsCatalog, - locationReader: LocationReader, - logger: Logger, - ) { - this.entitiesCatalog = entitiesCatalog; - this.locationsCatalog = locationsCatalog; - this.locationReader = locationReader; - this.logger = logger; - } + private readonly entitiesCatalog: EntitiesCatalog, + private readonly locationsCatalog: LocationsCatalog, + private readonly locationReader: LocationReader, + private readonly logger: Logger, + ) {} /** * Adds a single location to the catalog. @@ -62,7 +52,12 @@ export class HigherOrderOperations implements HigherOrderOperation { * * @param spec The location to add */ - async addLocation(spec: LocationSpec): Promise { + async addLocation( + spec: LocationSpec, + options?: { dryRun?: boolean }, + ): Promise { + const dryRun = options?.dryRun || false; + // Attempt to find a previous location matching the spec const previousLocations = await this.locationsCatalog.locations(); const previousLocation = previousLocations.find( @@ -88,7 +83,9 @@ export class HigherOrderOperations implements HigherOrderOperation { // in the entities list. But we aren't sure what to do about those yet. // Write - if (!previousLocation) { + if (!previousLocation && !dryRun) { + // TODO: We do not include location operations in the dryRun. We might perform + // this operation as a seperate dry run. await this.locationsCatalog.addLocation(location); } if (readerOutput.entities.length === 0) { @@ -97,12 +94,14 @@ export class HigherOrderOperations implements HigherOrderOperation { const writtenEntities = await this.entitiesCatalog.batchAddOrUpdateEntities( readerOutput.entities, - location.id, + { + locationId: dryRun ? undefined : location.id, + dryRun, + outputEntities: true, + }, ); - const entities = await this.entitiesCatalog.entities([ - { 'metadata.uid': writtenEntities.map(e => e.entityId) }, - ]); + const entities = writtenEntities.map(e => e.entity!); return { location, entities }; } @@ -168,7 +167,7 @@ export class HigherOrderOperations implements HigherOrderOperation { try { await this.entitiesCatalog.batchAddOrUpdateEntities( readerOutput.entities, - location.id, + { locationId: location.id }, ); } catch (e) { for (const entity of readerOutput.entities) { diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index c586e2d438..a83b4adecc 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -26,7 +26,10 @@ import type { // export type HigherOrderOperation = { - addLocation(spec: LocationSpec): Promise; + addLocation( + spec: LocationSpec, + options?: { dryRun?: boolean }, + ): Promise; refreshAllLocations(): Promise; }; diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index a6b22eaad9..1cf64945ee 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -285,7 +285,36 @@ describe('createRouter', () => { const response = await request(app).post('/locations').send(spec); expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1); - expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec); + expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec, { + dryRun: false, + }); + expect(response.status).toEqual(201); + expect(response.body).toEqual( + expect.objectContaining({ + location: { id: 'a', ...spec }, + }), + ); + }); + + it('supports dry run', async () => { + const spec: LocationSpec = { + type: 'b', + target: 'c', + }; + + higherOrderOperation.addLocation.mockResolvedValue({ + location: { id: 'a', ...spec }, + entities: [], + }); + + const response = await request(app) + .post('/locations?dryRun=true') + .send(spec); + + expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1); + expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec, { + dryRun: true, + }); expect(response.status).toEqual(201); expect(response.body).toEqual( expect.objectContaining({ diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 785e44f68c..96b343a184 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -20,6 +20,7 @@ import { locationSpecSchema } from '@backstage/catalog-model'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import yn from 'yn'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { HigherOrderOperation } from '../ingestion/types'; import { @@ -101,7 +102,8 @@ export async function createRouter( if (higherOrderOperation) { router.post('/locations', async (req, res) => { const input = await validateRequestBody(req, locationSpecSchema); - const output = await higherOrderOperation.addLocation(input); + const dryRun = yn(req.query.dryRun, { default: false }); + const output = await higherOrderOperation.addLocation(input, { dryRun }); res.status(201).send(output); }); } diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 40622da60a..85fd44cf01 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -95,9 +95,12 @@ export class CatalogClient implements CatalogApi { async addLocation({ type = 'url', target, + dryRun, }: AddLocationRequest): Promise { const response = await fetch( - `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, + `${await this.discoveryApi.getBaseUrl('catalog')}/locations${ + dryRun ? '?dryRun=true' : '' + }`, { headers: { 'Content-Type': 'application/json', diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 324a8d43fd..06030e6ac5 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -43,6 +43,7 @@ export interface CatalogApi { export type AddLocationRequest = { type?: string; target: string; + dryRun?: boolean; }; export type AddLocationResponse = { diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx index 875b1f8660..fae5d649b9 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx @@ -20,17 +20,18 @@ import React from 'react'; import { RegisterComponentForm } from './RegisterComponentForm'; describe('RegisterComponentForm', () => { - it('should initially render a disabled button', async () => { + it('should initially render disabled buttons', async () => { render(); expect( await screen.findByText(/Enter the full path to the catalog-info.yaml/), ).toBeInTheDocument(); - expect(screen.getByText('Submit').closest('button')).toBeDisabled(); + expect(screen.getByText('Validate').closest('button')).toBeDisabled(); + expect(screen.getByText('Register').closest('button')).toBeDisabled(); }); - it('should enable a submit button when the target url is set', async () => { + it('should enable the submit buttons when the target url is set', async () => { render(); await act(async () => { @@ -40,7 +41,8 @@ describe('RegisterComponentForm', () => { ); }); - expect(screen.getByText('Submit').closest('button')).not.toBeDisabled(); + expect(screen.getByText('Validate').closest('button')).not.toBeDisabled(); + expect(screen.getByText('Register').closest('button')).not.toBeDisabled(); }); it('should show spinner while submitting', async () => { diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index 56dd50ea67..de183869f7 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -33,8 +33,11 @@ const useStyles = makeStyles(theme => ({ display: 'flex', flexFlow: 'column nowrap', }, - submit: { - marginTop: theme.spacing(1), + buttonSpacing: { + marginLeft: theme.spacing(1), + }, + buttons: { + marginTop: theme.spacing(2), }, select: { minWidth: 120, @@ -54,12 +57,21 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { const hasErrors = !!errors.entityLocation; const dirty = formState?.isDirty; + const onSubmitValidate = handleSubmit(data => { + data.mode = 'validate'; + onSubmit(data); + }); + + const onSubmitRegister = handleSubmit(data => { + data.mode = 'register'; + onSubmit(data); + }); + return submitting ? ( ) : (
@@ -87,15 +99,27 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { )} - +
+ + +
); }; diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index c7f94e2060..d0b56598b2 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -75,27 +75,30 @@ export const RegisterComponentPage = ({ location: Location; } | null; error: null | Error; + dryRun: boolean; }>({ data: null, error: null, + dryRun: false, }); const handleSubmit = async (formData: Record) => { setFormState(FormStates.Submitting); - const { entityLocation: target } = formData; + const { entityLocation: target, mode } = formData; + const dryRun = mode === 'validate'; try { - const data = await catalogApi.addLocation({ target }); + const data = await catalogApi.addLocation({ target, dryRun }); if (!isMounted()) return; - setResult({ error: null, data }); + setResult({ error: null, data, dryRun }); setFormState(FormStates.Success); } catch (e) { errorApi.post(e); if (!isMounted()) return; - setResult({ error: e, data: null }); + setResult({ error: e, data: null, dryRun }); setFormState(FormStates.Idle); } }; @@ -124,6 +127,7 @@ export const RegisterComponentPage = ({ {formState === FormStates.Success && ( setFormState(FormStates.Idle)} classes={{ paper: classes.dialogPaper }} catalogRouteRef={catalogRouteRef} diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index 9631865ffb..2b57050661 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -29,7 +29,7 @@ import { List, ListItem, } from '@material-ui/core'; -import React from 'react'; +import React, { useState } from 'react'; import { generatePath, resolvePath } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; @@ -37,6 +37,7 @@ type Props = { onClose: () => void; classes?: Record; entities: Entity[]; + dryRun?: boolean; catalogRouteRef: RouteRef; }; @@ -64,49 +65,71 @@ export const RegisterComponentResultDialog = ({ onClose, classes, entities, + dryRun, catalogRouteRef, -}: Props) => ( - - Registration Result - - - The following entities have been successfully created: - - - {entities.map((entity: any, index: number) => { - const entityPath = getEntityCatalogPath({ entity, catalogRouteRef }); - return ( - - - - {entityPath} - - ), - }} - /> - - {index < entities.length - 1 && } - - ); - })} - - - - - - -); +}: Props) => { + const [open, setOpen] = useState(true); + const handleClose = () => { + setOpen(false); + }; + + return ( + + + {dryRun ? 'Validation Result' : 'Registration Result'} + + + + {dryRun + ? 'The following entities would be created:' + : 'The following entities have been successfully created:'} + + + {entities.map((entity: any, index: number) => { + const entityPath = getEntityCatalogPath({ + entity, + catalogRouteRef, + }); + return ( + + + + {entityPath} + + ), + }} + /> + + {index < entities.length - 1 && } + + ); + })} + + + + {dryRun && ( + + )} + {!dryRun && ( + + )} + + + ); +}; diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 5bdc7ba282..50eb0253cc 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -32,7 +32,7 @@ "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", "git-url-parse": "^11.4.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "nodegit": "^0.27.0", "winston": "^3.2.1" }, diff --git a/yarn.lock b/yarn.lock index 80d96ad134..92c3f7827a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13482,7 +13482,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -15217,23 +15217,21 @@ kleur@^3.0.3: resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -knex@^0.21.1: - version "0.21.5" - resolved "https://registry.npmjs.org/knex/-/knex-0.21.5.tgz#c4be1958488f348aed3510aa4b7115639ee1bd01" - integrity sha512-cQj7F2D/fu03eTr6ZzYCYKdB9w7fPYlvTiU/f2OeXay52Pq5PwD+NAkcf40WDnppt/4/4KukROwlMOaE7WArcA== +knex@^0.21.6: + version "0.21.8" + resolved "https://registry.npmjs.org/knex/-/knex-0.21.8.tgz#e5c07af61ee6aa006d3468e10e3a69351deb0c26" + integrity sha512-ziUu4vAlIGA8j2l0S4xcD1d3XdpJA4HYGhwHEhgAgefGCmB1OLSjUGCs/ebkJal42fSvAkyZaB0tcOtTXKgS5g== dependencies: colorette "1.2.1" commander "^5.1.0" debug "4.1.1" esm "^3.2.25" getopts "2.2.5" - inherits "~2.0.4" interpret "^2.2.0" liftoff "3.1.0" lodash "^4.17.20" - mkdirp "^1.0.4" pg-connection-string "2.3.0" - tarn "^3.0.0" + tarn "^3.0.1" tildify "2.0.0" uuid "^7.0.3" v8flags "^3.2.0" @@ -22445,10 +22443,10 @@ tar@^6.0.1, tar@^6.0.2: mkdirp "^1.0.3" yallist "^4.0.0" -tarn@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.0.tgz#a4082405216c0cce182b8b4cb2639c52c1e870d4" - integrity sha512-PKUnlDFODZueoA8owLehl8vLcgtA8u4dRuVbZc92tspDYZixjJL6TqYOmryf/PfP/EBX+2rgNcrj96NO+RPkdQ== +tarn@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz#ebac2c6dbc6977d34d4526e0a7814200386a8aec" + integrity sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw== tdigest@^0.1.1: version "0.1.1"