fix(catalog): sort default spec fields for stable hash

sort fields of known spec relation fields so that the default processing
engine hash calculation is more stable leading to less re-stitching of
entities

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-08-18 11:01:55 +03:00
parent d2d6d325f9
commit 96587035e1
4 changed files with 75 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Sort built-in relation fields for more stable entity hash in the processing engine
+2
View File
@@ -61,6 +61,8 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
emit: CatalogProcessorEmit,
): Promise<Entity>;
// (undocumented)
preProcessEntity(entity: Entity): Promise<Entity>;
// (undocumented)
validateEntityKind(entity: Entity): Promise<boolean>;
}
@@ -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' };
@@ -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<Entity> {
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,