diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index 3f5739eed0..f571a20cfa 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -14,9 +14,15 @@ * limitations under the License. */ -import { Database } from '../database'; import { AddLocation, Location, LocationsCatalog } from './types'; import { LocationReader } from '../ingestion'; +import { Database, DatabaseLocationUpdateLogEvent } from '../database'; +import { + AddLocation, + LocationEnvelope, + Location, + LocationsCatalog, +} from './types'; export class DatabaseLocationsCatalog implements LocationsCatalog { constructor( @@ -42,13 +48,36 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { await this.database.removeLocation(id); } - async locations(): Promise { + async locations(): Promise { const items = await this.database.locations(); - return items; + return items.map(({ message, status, timestamp, ...data }) => ({ + lastUpdate: { + message, + status, + timestamp, + }, + data, + })); } - async location(id: string): Promise { - const item = await this.location(id); - return item; + async locationHistory(id: string): Promise { + return this.database.locationHistory(id); + } + + async location(id: string): Promise { + const { + message, + status, + timestamp, + ...data + } = await this.database.location(id); + return { + lastUpdate: { + message, + status, + timestamp, + }, + data, + }; } } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 92c059d25b..ad7969405b 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -16,6 +16,7 @@ import * as yup from 'yup'; import { DescriptorEnvelope } from '../ingestion'; +import { DatabaseLocationUpdateLogEvent } from '../database'; // // Items @@ -34,12 +35,23 @@ export type EntitiesCatalog = { // Locations // +export type status = { + timestamp: DatabaseLocationUpdateLogEvent['created_at'] | null; + status: DatabaseLocationUpdateLogEvent['status'] | null; + message: DatabaseLocationUpdateLogEvent['message'] | null; +}; + export type Location = { id: string; type: string; target: string; }; +export type LocationEnvelope = { + data: Location; + lastUpdate: status; +}; + export type AddLocation = { type: string; target: string; @@ -55,6 +67,7 @@ export const addLocationSchema: yup.Schema = yup export type LocationsCatalog = { addLocation(location: AddLocation): Promise; removeLocation(id: string): Promise; - locations(): Promise; - location(id: string): Promise; + locations(): Promise; + location(id: string): Promise; + locationHistory(id: string): Promise; }; diff --git a/plugins/catalog-backend/src/database/Database.test.ts b/plugins/catalog-backend/src/database/Database.test.ts index 0eb64916e5..b02bbab668 100644 --- a/plugins/catalog-backend/src/database/Database.test.ts +++ b/plugins/catalog-backend/src/database/Database.test.ts @@ -27,6 +27,8 @@ import { DbEntityRequest, DbEntityResponse, DbLocationsRow, + DbLocationsRowWithStatus, + DatabaseLocationUpdateLogStatus, } from './types'; describe('Database', () => { @@ -73,7 +75,10 @@ describe('Database', () => { name: 'c', namespace: 'd', labels: { e: 'f' }, - annotations: { g: 'h' }, + annotations: { + g: 'h', + 'backstage.io/managed-by-location': undefined, + }, }, spec: { i: 'j' }, }, @@ -83,10 +88,13 @@ describe('Database', () => { it('manages locations', async () => { const db = new Database(database, getVoidLogger()); const input: AddDatabaseLocation = { type: 'a', target: 'b' }; - const output: DbLocationsRow = { + const output: DbLocationsRowWithStatus = { id: expect.anything(), type: 'a', target: 'b', + message: null, + status: null, + timestamp: null, }; await db.addLocation(input); @@ -117,7 +125,7 @@ describe('Database', () => { // Output is the same expect(output2).toEqual(output1); // Locations contain only one record - expect(locations).toEqual([output1]); + expect(locations[0]).toMatchObject(output1); }); describe('addEntity', () => { @@ -149,6 +157,45 @@ describe('Database', () => { }); }); + describe('locationHistory', () => { + it('outputs the history correctly', async () => { + const catalog = new Database(database, getVoidLogger()); + const location: AddDatabaseLocation = { type: 'a', target: 'b' }; + const { id: locationId } = await catalog.addLocation(location); + + await catalog.addLocationUpdateLogEvent( + locationId, + DatabaseLocationUpdateLogStatus.SUCCESS, + ); + await catalog.addLocationUpdateLogEvent( + locationId, + DatabaseLocationUpdateLogStatus.FAIL, + undefined, + 'Something went wrong', + ); + + const result = await catalog.locationHistory(locationId); + expect(result).toEqual([ + { + created_at: expect.anything(), + entity_name: null, + id: expect.anything(), + location_id: locationId, + message: null, + status: DatabaseLocationUpdateLogStatus.SUCCESS, + }, + { + created_at: expect.anything(), + entity_name: null, + id: expect.anything(), + location_id: locationId, + message: 'Something went wrong', + status: DatabaseLocationUpdateLogStatus.FAIL, + }, + ]); + }); + }); + describe('updateEntity', () => { it('can read and no-op-update an entity', async () => { const catalog = new Database(database, getVoidLogger()); diff --git a/plugins/catalog-backend/src/database/Database.ts b/plugins/catalog-backend/src/database/Database.ts index cd4ca731a3..9e2b69bce2 100644 --- a/plugins/catalog-backend/src/database/Database.ts +++ b/plugins/catalog-backend/src/database/Database.ts @@ -34,6 +34,7 @@ import { DbEntityRequest, DbEntityResponse, DbLocationsRow, + DbLocationsRowWithStatus, } from './types'; function getStrippedMetadata(metadata: EntityMeta): EntityMeta { @@ -190,6 +191,10 @@ export class Database { uid: generateUid(), etag: generateEtag(), generation: 1, + annotations: { + ...(newEntity.metadata?.annotations ?? {}), + 'backstage.io/managed-by-location': request.locationId, + }, }); const newRow = toEntityRow(request.locationId, newEntity); @@ -367,18 +372,65 @@ export class Database { } } - async location(id: string): Promise { - const items = await this.database('locations') - .where({ id }) - .select(); + async location(id: string): Promise { + const items = await this.database('locations') + .where('locations.id', id) + .leftJoin( + 'location_update_log', + 'locations.id', + 'location_update_log.location_id', + ) + .orderBy('location_update_log.created_at', 'desc') + .select({ + status: 'location_update_log.status', + timestamp: 'location_update_log.created_at', + message: 'location_update_log.message', + id: 'locations.id', + type: 'locations.type', + target: 'locations.target', + }); + if (!items.length) { throw new NotFoundError(`Found no location with ID ${id}`); } return items[0]; } - async locations(): Promise { - return this.database('locations').select(); + async locations(): Promise { + const query = this.database + .select({ + status: 'location_update_log.status', + timestamp: 'location_update_log.created_at', + message: 'location_update_log.message', + id: 'locations.id', + type: 'locations.type', + target: 'locations.target', + }) + .from('locations') + .leftJoin( + 'location_update_log', + 'locations.id', + 'location_update_log.location_id', + ) + .orderBy('location_update_log.created_at', 'desc'); + + const result = await this.database(query) + .select() + .groupBy('id'); + + return result; + } + + async locationHistory(id: string): Promise { + const result = await this.database( + 'location_update_log', + ) + .where('location_id', id) + .orderBy('created_at', 'desc') + .limit(10) + .select(); + + return result; } async addLocationUpdateLogEvent( @@ -391,7 +443,7 @@ export class Database { 'location_update_log', ).insert({ id: uuidv4(), - status: status, + status, location_id: locationId, entity_name: entityName, message, diff --git a/plugins/catalog-backend/src/database/DatabaseManager.test.ts b/plugins/catalog-backend/src/database/DatabaseManager.test.ts index 2da3c33a00..efae9e3516 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.test.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.test.ts @@ -23,7 +23,11 @@ import { } from '../ingestion'; import { Database } from './Database'; import { DatabaseManager } from './DatabaseManager'; -import { DatabaseLocationUpdateLogStatus, DbLocationsRow } from './types'; +import { + DatabaseLocationUpdateLogStatus, + DbLocationsRow, + DbLocationsRowWithStatus, +} from './types'; import Knex from 'knex'; describe('DatabaseManager', () => { @@ -47,10 +51,13 @@ describe('DatabaseManager', () => { }); it('can update a single location', async () => { - const location: DbLocationsRow = { + const location: DbLocationsRowWithStatus = { id: '123', type: 'some', target: 'thing', + message: '', + status: DatabaseLocationUpdateLogStatus.SUCCESS, + timestamp: new Date(314159265).toISOString(), }; const desc: ComponentDescriptor = { apiVersion: 'backstage.io/v1beta1', @@ -58,6 +65,7 @@ describe('DatabaseManager', () => { metadata: { name: 'c1' }, spec: { type: 'service' }, }; + const tx = (undefined as unknown) as Knex.Transaction; const db = ({ diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 06e234545f..e20ba59517 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -52,6 +52,12 @@ export type DbLocationsRow = { target: string; }; +export type DbLocationsRowWithStatus = DbLocationsRow & { + status: DatabaseLocationUpdateLogEvent['status'] | null; + timestamp: DatabaseLocationUpdateLogEvent['created_at'] | null; + message: DatabaseLocationUpdateLogEvent['message'] | null; +}; + export type AddDatabaseLocation = { type: string; target: string; diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index e5083a65f1..87a6f19f1f 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -79,7 +79,7 @@ export type EntityMeta = { * Key/value pairs of non-identifying auxiliary information attached to the * entity. */ - annotations?: Record; + annotations?: Record; }; /** diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index af714e4026..80ba84d0f0 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -56,6 +56,11 @@ export async function createRouter( const output = await locationsCatalog.locations(); res.status(200).send(output); }) + .get('/locations/:id/history', async (req, res) => { + const { id } = req.params; + const output = await locationsCatalog.locationHistory(id); + res.status(200).send(output); + }) .get('/locations/:id', async (req, res) => { const { id } = req.params; const output = await locationsCatalog.location(id);