Merge pull request #28642 from backstage/rugvip/processing

catalog-backend: allow processors to include location key when emitting entities
This commit is contained in:
Patrik Oldsberg
2025-01-28 13:52:41 +01:00
committed by GitHub
13 changed files with 204 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Added support for emitting entities with an explicit location key during processing.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Fixed an bug in the entity processing caching that would prevent entities that were emitted during processing to be restored after being overridden.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': minor
---
Added the `locationKey` option to `processingResult.entity(...)`.
+5
View File
@@ -479,6 +479,11 @@ export const processingResult: Readonly<{
readonly entity: (
atLocation: LocationSpec_2,
newEntity: Entity,
options?:
| {
locationKey?: string | null | undefined;
}
| undefined,
) => CatalogProcessorResult_2;
readonly relation: (spec: EntityRelationSpec_2) => CatalogProcessorResult_2;
readonly refresh: (key: string) => CatalogProcessorResult_2;
@@ -784,7 +784,7 @@ describe('DefaultProcessingDatabase', () => {
});
const result1 = await db.transaction(async tx =>
db.listParents(tx, { entityRef: 'component:default/foobar' }),
db.listParents(tx, { entityRefs: ['component:default/foobar'] }),
);
expect(result1.entityRefs).toEqual([
'location:default/root-1',
@@ -792,12 +792,12 @@ describe('DefaultProcessingDatabase', () => {
]);
const result2 = await db.transaction(async tx =>
db.listParents(tx, { entityRef: 'location:default/root-1' }),
db.listParents(tx, { entityRefs: ['location:default/root-1'] }),
);
expect(result2.entityRefs).toEqual(['location:default/root-2']);
const result3 = await db.transaction(async tx =>
db.listParents(tx, { entityRef: 'location:default/root-2' }),
db.listParents(tx, { entityRefs: ['location:default/root-2'] }),
);
expect(result3.entityRefs).toEqual([]);
},
@@ -272,7 +272,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
const rows = await tx<DbRefreshStateReferencesRow>(
'refresh_state_references',
)
.where({ target_entity_ref: options.entityRef })
.whereIn('target_entity_ref', options.entityRefs)
.select();
const entityRefs = rows.map(r => r.source_entity_ref!).filter(Boolean);
@@ -101,7 +101,7 @@ export type ListAncestorsResult = {
};
export type ListParentsOptions = {
entityRef: string;
entityRefs: string[];
};
export type ListParentsResult = {
@@ -215,7 +215,12 @@ export class DefaultCatalogProcessingEngine {
const { entityRefs: parents } =
await this.processingDatabase.transaction(tx =>
this.processingDatabase.listParents(tx, {
entityRef,
entityRefs: [
entityRef,
...result.deferredEntities.map(e =>
stringifyEntityRef(e.entity),
),
],
}),
);
@@ -137,7 +137,9 @@ export class ProcessorOutputCollector {
},
};
this.deferredEntities.push({ entity, locationKey: location });
const locationKey =
i.locationKey === undefined ? location : i.locationKey ?? undefined;
this.deferredEntities.push({ entity, locationKey });
} else if (i.type === 'location') {
const entity = locationSpecToLocationEntity({
location: i.location,
@@ -1166,4 +1166,151 @@ describe('Catalog Backend Integration', () => {
'component:default/b': withOutputFields(entityB),
});
});
it('should be able to emit entities during processing with custom location keys', async () => {
const baseEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
annotations: {
'backstage.io/managed-by-location': 'url:.',
'backstage.io/managed-by-origin-location': 'url:.',
},
},
};
const entityA = merge({ metadata: { name: 'a' } }, baseEntity);
const entityB = merge({ metadata: { name: 'b' } }, baseEntity);
const entityBOverride = merge({ metadata: { override: true } }, entityB);
const processEntity = jest.fn(
async (
entity: Entity,
_location: LocationSpec,
_emit: CatalogProcessorEmit,
) => entity,
);
const harness = await TestHarness.create({ processEntity });
processEntity.mockImplementation(async (entity, location, emit) => {
if (entity.metadata.name === entityA.metadata.name) {
emit(
processingResult.entity(location, entityB, {
locationKey: null,
}),
);
}
return entity;
});
// Start with just A, which outputs B via processing
await harness.setInputEntities([entityA]);
await expect(harness.process()).resolves.toEqual({});
await expect(harness.getOutputEntities()).resolves.toEqual({
'component:default/a': withOutputFields(entityA),
'component:default/b': withOutputFields(entityB),
});
// Now apply A and B', which should override B since it has a null location key
await harness.setInputEntities([
entityA,
{ ...entityBOverride, locationKey: 'test' },
]);
await expect(harness.process()).resolves.toEqual({});
await expect(harness.getOutputEntities()).resolves.toEqual({
'component:default/a': withOutputFields(entityA),
'component:default/b': withOutputFields(entityBOverride),
});
});
it('should resolve handle location key conflicts across processed and provided entities', async () => {
const baseEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
annotations: {
'backstage.io/managed-by-location': 'url:.',
'backstage.io/managed-by-origin-location': 'url:.',
},
},
};
const entityA = merge({ metadata: { name: 'a' } }, baseEntity);
const entityB = merge({ metadata: { name: 'b' } }, baseEntity);
const entityBOverride = merge({ metadata: { override: true } }, entityB);
const processEntity = jest.fn(
async (
entity: Entity,
_location: LocationSpec,
_emit: CatalogProcessorEmit,
) => entity,
);
const harness = await TestHarness.create({ processEntity });
processEntity.mockImplementation(async (entity, location, emit) => {
if (entity.metadata.name === entityA.metadata.name) {
emit(
processingResult.entity(location, entityB, {
locationKey: 'my-key',
}),
);
}
return entity;
});
// Start with just A, which outputs B via processing
await harness.setInputEntities([entityA]);
await expect(harness.process()).resolves.toEqual({});
await expect(harness.getOutputEntities()).resolves.toEqual({
'component:default/a': withOutputFields(entityA),
'component:default/b': withOutputFields(entityB),
});
// Now apply A and B', but since B already has a different location key, B' should not be used
await harness.setInputEntities([entityA, entityBOverride]);
await expect(harness.process()).resolves.toEqual({});
await expect(harness.getOutputEntities()).resolves.toEqual({
'component:default/a': withOutputFields(entityA),
'component:default/b': withOutputFields(entityB),
});
// Stop emitting B from A
processEntity.mockImplementation(async e => e);
// Apply A and B', but since B still has a location key set it should remain, but now orphaned
await harness.setInputEntities([entityA, entityBOverride]);
await expect(harness.process()).resolves.toEqual({});
await expect(harness.getOutputEntities()).resolves.toEqual({
'component:default/a': withOutputFields(entityA),
'component:default/b': withOutputFields(
merge(entityB, {
metadata: { annotations: { 'backstage.io/orphan': 'true' } },
}),
),
});
// Apply A and B' but this time with the matching location key, causing B' to override B
await harness.setInputEntities([
entityA,
{ ...entityBOverride, locationKey: 'my-key' },
]);
await expect(harness.process()).resolves.toEqual({});
await expect(harness.getOutputEntities()).resolves.toEqual({
'component:default/a': withOutputFields(entityA),
'component:default/b': withOutputFields(entityBOverride),
});
// Finally, remove B', leaving just A
await harness.setInputEntities([entityA]);
await expect(harness.process()).resolves.toEqual({});
await expect(harness.getOutputEntities()).resolves.toEqual({
'component:default/a': withOutputFields(entityA),
});
});
});
+4
View File
@@ -76,6 +76,7 @@ export type CatalogProcessorEntityResult = {
type: 'entity';
entity: Entity;
location: LocationSpec_2;
locationKey?: string | null;
};
// @public (undocumented)
@@ -334,6 +335,9 @@ export const processingResult: Readonly<{
readonly entity: (
atLocation: LocationSpec_2,
newEntity: Entity,
options?: {
locationKey?: string | null;
},
) => CatalogProcessorResult;
readonly relation: (spec: EntityRelationSpec) => CatalogProcessorResult;
readonly refresh: (key: string) => CatalogProcessorResult;
@@ -76,8 +76,24 @@ export const processingResult = Object.freeze({
/**
* Emits a child of the current entity, associated with a certain location.
*/
entity(atLocation: LocationSpec, newEntity: Entity): CatalogProcessorResult {
return { type: 'entity', location: atLocation, entity: newEntity };
entity(
atLocation: LocationSpec,
newEntity: Entity,
options?: {
/**
* Sets the location key of the emitted entity, overriding the default one derived from the location.
*
* To set a `null` location key the value `null` must be used.
*/
locationKey?: string | null;
},
): CatalogProcessorResult {
return {
type: 'entity',
location: atLocation,
entity: newEntity,
locationKey: options?.locationKey,
};
},
/**
@@ -155,6 +155,7 @@ export type CatalogProcessorEntityResult = {
type: 'entity';
entity: Entity;
location: LocationSpec;
locationKey?: string | null;
};
/** @public */