diff --git a/.changeset/huge-spoons-remain.md b/.changeset/huge-spoons-remain.md new file mode 100644 index 0000000000..c39875c210 --- /dev/null +++ b/.changeset/huge-spoons-remain.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Sort built-in relation fields for more stable entity hash in the processing engine diff --git a/plugins/catalog-backend/report.api.md b/plugins/catalog-backend/report.api.md index 6e5ca956bc..9fc3041d6f 100644 --- a/plugins/catalog-backend/report.api.md +++ b/plugins/catalog-backend/report.api.md @@ -61,6 +61,8 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { emit: CatalogProcessorEmit, ): Promise; // (undocumented) + preProcessEntity(entity: Entity): Promise; + // (undocumented) validateEntityKind(entity: Entity): Promise; } diff --git a/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.test.ts index 8fefb62bfc..3a6236dfa0 100644 --- a/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.test.ts @@ -26,6 +26,49 @@ import { import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; describe('BuiltinKindsEntityProcessor', () => { + describe('preProcessEntity', () => { + const processor = new BuiltinKindsEntityProcessor(); + afterEach(() => jest.resetAllMocks()); + + it('should order relation fields correctly', async () => { + const entity: ComponentEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + spec: { + type: 'service', + owner: 'o', + subcomponentOf: 's', + lifecycle: 'l', + providesApis: ['b', 'a'], + consumesApis: ['c', 'x'], + dependsOn: ['resource:r', 'component:d'], + dependencyOf: ['resource:f', 'component:g'], + system: 's', + }, + }; + + const ret = await processor.preProcessEntity(entity); + + expect(ret).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + spec: { + type: 'service', + owner: 'o', + subcomponentOf: 's', + lifecycle: 'l', + providesApis: ['a', 'b'], + consumesApis: ['c', 'x'], + dependsOn: ['component:d', 'resource:r'], + dependencyOf: ['component:g', 'resource:f'], + system: 's', + }, + }); + }); + }); + describe('postProcessEntity', () => { const processor = new BuiltinKindsEntityProcessor(); const location = { type: 'a', target: 'b' }; diff --git a/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.ts index fd1195a13a..3e193cc959 100644 --- a/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/processors/BuiltinKindsEntityProcessor.ts @@ -54,6 +54,7 @@ import { CatalogProcessorEmit, processingResult, } from '@backstage/plugin-catalog-node'; +import { get, set } from 'lodash'; /** @public */ export class BuiltinKindsEntityProcessor implements CatalogProcessor { @@ -83,6 +84,30 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { return false; } + async preProcessEntity(entity: Entity): Promise { + function sortField(field: string) { + const value = get(entity, field); + if ( + value && + Array.isArray(value) && + value.every(v => typeof v === 'string') + ) { + set(entity, field, value.sort()); + } + } + + // Sort the fields of the entity to ensure consistent hash + sortField('spec.providesApis'); + sortField('spec.consumesApis'); + sortField('spec.dependsOn'); + sortField('spec.dependencyOf'); + sortField('spec.memberOf'); + sortField('spec.children'); + sortField('spec.members'); + + return entity; + } + async postProcessEntity( entity: Entity, _location: LocationSpec,