Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions workspaces/extensions/.changeset/khaki-points-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-catalog-backend-module-extensions': minor
'@red-hat-developer-hub/backstage-plugin-extensions-common': minor
---

Add catalog source annotations to entities
15 changes: 15 additions & 0 deletions workspaces/extensions/docs/catalog/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,21 @@ spec:

## Annotations

### Catalog source

Automatically set by the `BaseEntityProvider` to identify which catalog image each entity came from. The value is derived from the on-disk directory layout produced by `install-dynamic-plugins`:

- Entities under the primary `catalog-entities/` tree get `"primary"`
- Entities under `extra/<name>/catalog-entities/` get `<name>` as the source (matching the name from `EXTRA_CATALOG_INDEX_IMAGES`)

This annotation is always set and should not be manually specified in YAML files.
Comment thread
christoph-jerolimov marked this conversation as resolved.

```yaml
metadata:
annotations:
extensions.backstage.io/catalog-source: 'primary'
```

### Support type for Core and Community plugins

```yaml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,33 @@ extensions:
directory: /path/to/custom/extensions
```

### Multi-source catalog layout

When the `install-dynamic-plugins` init container is configured with `EXTRA_CATALOG_INDEX_IMAGES`, it extracts additional catalog entities into subdirectories under `extra/`:

```
<extensionsDir>/
catalog-entities/ # primary source
plugins/
plugin-a.yaml
extra/
community/ # extra source "community"
catalog-entities/
plugins/
plugin-b.yaml
partner/ # extra source "partner"
catalog-entities/
plugins/
plugin-c.yaml
```

The provider reads all YAML files recursively from the extensions directory and automatically sets the `extensions.backstage.io/catalog-source` annotation on each entity based on where it was found:

- Entities from the primary `catalog-entities/` tree get `catalog-source: "primary"`
- Entities from `extra/<name>/catalog-entities/` get `catalog-source: "<name>"`

This allows the Extensions UI to identify which catalog each plugin came from (e.g. Red Hat, Community, partner).

### Collision behavior

When multiple YAML sources define the same entity identity (`kind:namespace/name`), the provider handles collisions as follows:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export abstract class BaseEntityProvider<T extends Entity>
);
// (undocumented)
connect(connection: EntityProviderConnection): Promise<void>;
static readonly DEFAULT_CATALOG_SOURCE = 'primary';
static deriveCatalogSource(filePath: string, extensionsRoot: string): string;
// (undocumented)
getEntities(allEntities: JsonFileData<T>[]): T[];
getEntities(allEntities: JsonFileData<T>[], extensionsRoot?: string): T[];
// (undocumented)
abstract getKind(): string;
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
LoggerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
import { ExtensionsAnnotation } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
import { BaseEntityProvider } from './BaseEntityProvider';
import { JsonFileData } from '../types';

Expand Down Expand Up @@ -136,3 +137,194 @@ describe('BaseEntityProvider collision policy', () => {
expect(entities).toHaveLength(2);
});
});

describe('BaseEntityProvider.deriveCatalogSource', () => {
const root = '/extensions';

it('returns "primary" for paths in the main catalog-entities directory', () => {
expect(
BaseEntityProvider.deriveCatalogSource(
'/extensions/catalog-entities/plugin.yaml',
root,
),
).toBe('primary');
});

it('returns "primary" for paths without the extra/ segment', () => {
expect(
BaseEntityProvider.deriveCatalogSource(
'/extensions/plugins/foo.yaml',
root,
),
).toBe('primary');
});

it('returns the source name for paths under extra/<name>/', () => {
expect(
BaseEntityProvider.deriveCatalogSource(
'/extensions/extra/community/catalog-entities/plugin.yaml',
root,
),
).toBe('community');
});

it('returns the source name for a different extra source', () => {
expect(
BaseEntityProvider.deriveCatalogSource(
'/extensions/extra/partner/catalog-entities/plugins/plugin.yaml',
root,
),
).toBe('partner');
});

it('handles auto-derived subdirectory names with special characters', () => {
// imageRefToSubdirectory replaces /:@ with _ so names like this are common
expect(
BaseEntityProvider.deriveCatalogSource(
'/extensions/extra/quay.io_rhdh_index_1.10/catalog-entities/plugin.yaml',
root,
),
).toBe('quay.io_rhdh_index_1.10');
});

it('handles deeply nested files within a source directory', () => {
expect(
BaseEntityProvider.deriveCatalogSource(
'/extensions/extra/community/catalog-entities/nested/deep/plugin.yaml',
root,
),
).toBe('community');
});
});

describe('BaseEntityProvider source metadata annotations', () => {
beforeEach(() => {
jest.clearAllMocks();
});

afterEach(() => {
jest.restoreAllMocks();
});

const root = '/extensions';

it('sets catalog-source to "primary" for entities from the main catalog root', () => {
const provider = new TestEntityProvider(taskRunner, undefined, logger);
const entity = createEntity({ metadata: { name: 'primary-plugin' } });

const entities = provider.getEntities(
[createFileData('/extensions/catalog-entities/plugin.yaml', entity)],
root,
);

expect(entities).toHaveLength(1);
expect(
entities[0].metadata.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE],
).toBe('primary');
});

it('sets catalog-source to the extra source name for entities under extra/<name>/', () => {
const provider = new TestEntityProvider(taskRunner, undefined, logger);
const entity = createEntity({ metadata: { name: 'community-plugin' } });

const entities = provider.getEntities(
[
createFileData(
'/extensions/extra/community/catalog-entities/plugin.yaml',
entity,
),
],
root,
);

expect(entities).toHaveLength(1);
expect(
entities[0].metadata.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE],
).toBe('community');
});

it('sets distinct source annotations when entities come from different sources', () => {
const provider = new TestEntityProvider(taskRunner, undefined, logger);
const primaryPlugin = createEntity({
metadata: { name: 'plugin-a' },
});
const communityPlugin = createEntity({
metadata: { name: 'plugin-b' },
});

const entities = provider.getEntities(
[
createFileData(
'/extensions/catalog-entities/plugin-a.yaml',
primaryPlugin,
),
createFileData(
'/extensions/extra/community/catalog-entities/plugin-b.yaml',
communityPlugin,
),
],
root,
);

expect(entities).toHaveLength(2);
const sourceA =
entities[0].metadata.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
const sourceB =
entities[1].metadata.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
expect(sourceA).toBe('primary');
expect(sourceB).toBe('community');
});

it('preserves the winning entity source annotation on duplicate (first-wins)', () => {
const provider = new TestEntityProvider(taskRunner, undefined, logger);
const entity = createEntity({ metadata: { name: 'dup-plugin' } });

const entities = provider.getEntities(
[
createFileData(
'/extensions/extra/community/catalog-entities/plugin.yaml',
entity,
),
createFileData('/extensions/catalog-entities/plugin.yaml', entity),
],
root,
);

expect(entities).toHaveLength(1);
// First-wins: the community entity was seen first
expect(
entities[0].metadata.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE],
).toBe('community');
});

it('sets correct distinct sources when entities share name but differ by namespace', () => {
const provider = new TestEntityProvider(taskRunner, undefined, logger);
const primaryEntity = createEntity({
metadata: { name: 'shared-name' },
});
const communityEntity = createEntity({
metadata: { name: 'shared-name', namespace: 'community' },
});

const entities = provider.getEntities(
[
createFileData(
'/extensions/catalog-entities/plugin.yaml',
primaryEntity,
),
createFileData(
'/extensions/extra/community/catalog-entities/plugin.yaml',
communityEntity,
),
],
root,
);

expect(entities).toHaveLength(2);
const sources = entities.map(
e => e.metadata.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE],
);
expect(sources).toContain('primary');
expect(sources).toContain('community');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { Config } from '@backstage/config';
import { ExtensionsAnnotation } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
import { readYamlFiles } from '../utils/file-utils';
import { JsonFileData } from '../types';
import path from 'path';
Expand Down Expand Up @@ -61,7 +62,36 @@ export abstract class BaseEntityProvider<T extends Entity>
abstract getProviderName(): string;
abstract getKind(): string;

private addProviderAnnotations(entity: T): T {
/**
* The default source identifier used for entities that do not reside
* under an `extra/<name>/` subdirectory — i.e. entities from the
* primary catalog index image.
*/
static readonly DEFAULT_CATALOG_SOURCE = 'primary';

/**
* Derives a catalog source identifier from a file path.
*
* When `install-dynamic-plugins` extracts extra catalog index images
* (via `EXTRA_CATALOG_INDEX_IMAGES`), entities are placed under
* `<extensionsRoot>/extra/<name>/catalog-entities/…`. This method
* computes the relative path from `extensionsRoot` and checks whether
* the first segment is `extra/` — if so, the second segment is the
* source identifier. All other paths return `"primary"`.
*/
static deriveCatalogSource(filePath: string, extensionsRoot: string): string {
const relative = path.relative(extensionsRoot, filePath);
const [first, second] = relative.split(path.sep);
return first === 'extra' && second
? second
: BaseEntityProvider.DEFAULT_CATALOG_SOURCE;
}

private addProviderAnnotations(
entity: T,
filePath: string,
extensionsRoot: string,
): T {
return {
...entity,
metadata: {
Expand All @@ -70,12 +100,17 @@ export abstract class BaseEntityProvider<T extends Entity>
...entity.metadata.annotations,
[ANNOTATION_LOCATION]: `file:${this.getProviderName()}`,
[ANNOTATION_ORIGIN_LOCATION]: `file:${this.getProviderName()}`,
[ExtensionsAnnotation.CATALOG_SOURCE]:
BaseEntityProvider.deriveCatalogSource(filePath, extensionsRoot),
},
},
};
}

getEntities(allEntities: JsonFileData<T>[]): T[] {
getEntities(
allEntities: JsonFileData<T>[],
extensionsRoot: string = '',
): T[] {
if (allEntities.length === 0) {
return [];
}
Expand Down Expand Up @@ -116,8 +151,9 @@ export abstract class BaseEntityProvider<T extends Entity>
);
}

return Array.from(entitiesByEntityRef.values()).map(({ entity }) =>
this.addProviderAnnotations(entity),
return Array.from(entitiesByEntityRef.values()).map(
({ entity, filePath }) =>
this.addProviderAnnotations(entity, filePath, extensionsRoot),
);
}

Expand Down Expand Up @@ -213,7 +249,7 @@ export abstract class BaseEntityProvider<T extends Entity>
}
}

const entities: T[] = this.getEntities(yamlData);
const entities: T[] = this.getEntities(yamlData, extensionsFilePath ?? '');

await this.connection.applyMutation({
type: 'full',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export const EXTENSIONS_API_VERSION = 'extensions.backstage.io/v1alpha1';

// @public (undocumented)
export enum ExtensionsAnnotation {
// (undocumented)
CATALOG_SOURCE = 'extensions.backstage.io/catalog-source',
// (undocumented)
PRE_INSTALLED = 'extensions.backstage.io/pre-installed',
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@
*/
export enum ExtensionsAnnotation {
PRE_INSTALLED = 'extensions.backstage.io/pre-installed',
CATALOG_SOURCE = 'extensions.backstage.io/catalog-source',
}
Loading