Skip to content

Commit 1680359

Browse files
committed
feat(extensions): enforce collision policy for duplicate entity identities
1 parent 5508d6c commit 1680359

2 files changed

Lines changed: 185 additions & 13 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Entity } from '@backstage/catalog-model';
18+
import { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api';
19+
import { BaseEntityProvider } from './BaseEntityProvider';
20+
import { JsonFileData } from '../types';
21+
22+
class TestEntityProvider extends BaseEntityProvider<Entity> {
23+
getProviderName(): string {
24+
return 'test-entity-provider';
25+
}
26+
27+
getKind(): string {
28+
return 'Plugin';
29+
}
30+
}
31+
32+
const taskRunner: SchedulerServiceTaskRunner = {
33+
run: jest.fn(async ({ fn }) => fn()),
34+
};
35+
36+
const createEntity = (overrides?: Partial<Entity>): Entity => ({
37+
apiVersion: 'extensions.backstage.io/v1alpha1',
38+
kind: 'Plugin',
39+
metadata: {
40+
name: 'duplicate-plugin',
41+
...overrides?.metadata,
42+
},
43+
spec: {
44+
owner: 'test-owner',
45+
...(overrides?.spec as object),
46+
},
47+
...overrides,
48+
});
49+
50+
const createFileData = (
51+
filePath: string,
52+
entity: Entity,
53+
): JsonFileData<Entity> => ({
54+
filePath,
55+
content: entity,
56+
});
57+
58+
describe('BaseEntityProvider collision policy', () => {
59+
beforeEach(() => {
60+
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
61+
});
62+
63+
afterEach(() => {
64+
jest.restoreAllMocks();
65+
});
66+
67+
it('keeps first definition when duplicate entities are equivalent', () => {
68+
const provider = new TestEntityProvider(taskRunner);
69+
const duplicate = createEntity();
70+
71+
const entities = provider.getEntities([
72+
createFileData('/extensions/primary/plugin.yaml', duplicate),
73+
createFileData('/extensions/extra/community/plugin.yaml', duplicate),
74+
]);
75+
76+
expect(entities).toHaveLength(1);
77+
expect(console.warn).toHaveBeenCalledWith(
78+
expect.stringContaining(
79+
"Skipping duplicate Extensions entity 'plugin/default/duplicate-plugin'",
80+
),
81+
);
82+
});
83+
84+
it('throws when duplicate entities have conflicting definitions', () => {
85+
const provider = new TestEntityProvider(taskRunner);
86+
const firstEntity = createEntity({
87+
spec: { owner: 'owner-a' },
88+
});
89+
const secondEntity = createEntity({
90+
spec: { owner: 'owner-b' },
91+
});
92+
93+
expect(() =>
94+
provider.getEntities([
95+
createFileData('/extensions/primary/plugin.yaml', firstEntity),
96+
createFileData('/extensions/extra/community/plugin.yaml', secondEntity),
97+
]),
98+
).toThrow(
99+
"Conflicting Extensions entities detected for 'plugin/default/duplicate-plugin'",
100+
);
101+
});
102+
103+
it('keeps entities with same name when namespaces differ', () => {
104+
const provider = new TestEntityProvider(taskRunner);
105+
const defaultNamespaceEntity = createEntity({
106+
metadata: { name: 'shared-name' },
107+
});
108+
const customNamespaceEntity = createEntity({
109+
metadata: { name: 'shared-name', namespace: 'community' },
110+
});
111+
112+
const entities = provider.getEntities([
113+
createFileData(
114+
'/extensions/primary/plugin-default.yaml',
115+
defaultNamespaceEntity,
116+
),
117+
createFileData(
118+
'/extensions/extra/community/plugin-custom.yaml',
119+
customNamespaceEntity,
120+
),
121+
]);
122+
123+
expect(entities).toHaveLength(2);
124+
});
125+
});

workspaces/extensions/plugins/catalog-backend-module-extensions/src/providers/BaseEntityProvider.ts

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { readYamlFiles } from '../utils/file-utils';
2828
import { JsonFileData } from '../types';
2929
import path from 'path';
3030
import fs from 'fs';
31+
import { isDeepStrictEqual } from 'node:util';
3132

3233
/**
3334
* @public
@@ -50,23 +51,69 @@ export abstract class BaseEntityProvider<T extends Entity>
5051
abstract getProviderName(): string;
5152
abstract getKind(): string;
5253

54+
private getEntityIdentity(entity: Entity): string {
55+
const namespace = entity.metadata.namespace ?? 'default';
56+
return [
57+
entity.kind.toLocaleLowerCase('en-US'),
58+
namespace.toLocaleLowerCase('en-US'),
59+
entity.metadata.name.toLocaleLowerCase('en-US'),
60+
].join('/');
61+
}
62+
63+
private addProviderAnnotations(entity: T): T {
64+
return {
65+
...entity,
66+
metadata: {
67+
...entity.metadata,
68+
annotations: {
69+
...entity.metadata.annotations,
70+
[ANNOTATION_LOCATION]: `file:${this.getProviderName()}`,
71+
[ANNOTATION_ORIGIN_LOCATION]: `file:${this.getProviderName()}`,
72+
},
73+
},
74+
};
75+
}
76+
5377
getEntities(allEntities: JsonFileData<T>[]): T[] {
5478
if (allEntities.length === 0) {
5579
return [];
5680
}
57-
return allEntities
58-
.filter(d => d.content.kind === this.getKind())
59-
.map(file => ({
60-
...file.content,
61-
metadata: {
62-
...file.content.metadata,
63-
annotations: {
64-
...file.content.metadata.annotations,
65-
[ANNOTATION_LOCATION]: `file:${this.getProviderName()}`,
66-
[ANNOTATION_ORIGIN_LOCATION]: `file:${this.getProviderName()}`,
67-
},
68-
},
69-
}));
81+
82+
const entitiesByIdentity = new Map<
83+
string,
84+
{ entity: T; filePath: string }
85+
>();
86+
87+
for (const fileData of allEntities) {
88+
if (fileData.content.kind !== this.getKind()) {
89+
continue;
90+
}
91+
92+
const identity = this.getEntityIdentity(fileData.content);
93+
const existing = entitiesByIdentity.get(identity);
94+
if (!existing) {
95+
entitiesByIdentity.set(identity, {
96+
entity: fileData.content,
97+
filePath: fileData.filePath,
98+
});
99+
continue;
100+
}
101+
102+
if (isDeepStrictEqual(existing.entity, fileData.content)) {
103+
console.warn(
104+
`Skipping duplicate Extensions entity '${identity}' from '${fileData.filePath}'. Keeping first definition from '${existing.filePath}'.`,
105+
);
106+
continue;
107+
}
108+
109+
throw new Error(
110+
`Conflicting Extensions entities detected for '${identity}' in '${existing.filePath}' and '${fileData.filePath}'.`,
111+
);
112+
}
113+
114+
return Array.from(entitiesByIdentity.values()).map(({ entity }) =>
115+
this.addProviderAnnotations(entity),
116+
);
70117
}
71118

72119
async connect(connection: EntityProviderConnection): Promise<void> {

0 commit comments

Comments
 (0)