Add summary types and listing methods to CatalogModel
Introduce CatalogModelKindSummary, CatalogModelRelationSummary, CatalogModelAnnotationSummary, CatalogModelLabelSummary, and CatalogModelTagSummary as reduced views of the full model data. Add listKinds(), listRelations(), and getMetadata() methods to CatalogModel for retrieving these summaries. Add a new action for fetching a markdown-formatted catalog model description, useful for informing LLMs about the catalog structure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Fredrik Adelöw <freben@spotify.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
import { CatalogModel } from '@backstage/catalog-model/alpha';
|
||||
import { createGetCatalogModelDescriptionAction } from './createGetCatalogModelDescriptionAction';
|
||||
import { ModelHolder } from '../model/ModelHolder';
|
||||
|
||||
const model: CatalogModel = {
|
||||
listKinds: () => [
|
||||
{
|
||||
description: 'A software component',
|
||||
names: { kind: 'Component', singular: 'component', plural: 'components' },
|
||||
versions: [
|
||||
{ apiVersion: 'backstage.io/v1alpha1' },
|
||||
{ apiVersion: 'backstage.io/v1alpha1', specType: 'service' },
|
||||
],
|
||||
},
|
||||
],
|
||||
listRelations: () => [
|
||||
{
|
||||
fromKind: ['Component'],
|
||||
toKind: ['Group', 'User'],
|
||||
description: 'Ownership relation',
|
||||
forward: { type: 'ownedBy', title: 'owned by' },
|
||||
reverse: { type: 'ownerOf', title: 'owner of' },
|
||||
},
|
||||
],
|
||||
getMetadata: () => ({
|
||||
annotations: [
|
||||
{
|
||||
name: 'backstage.io/managed-by-location',
|
||||
title: 'Managed By Location',
|
||||
description: 'The location that manages this entity.',
|
||||
},
|
||||
],
|
||||
labels: [
|
||||
{
|
||||
name: 'backstage.io/orphan',
|
||||
description: 'Indicates that this entity is an orphan.',
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'java',
|
||||
title: 'Java',
|
||||
description: 'Relates to the Java programming language.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
getKind: () => undefined,
|
||||
getRelations: () => undefined,
|
||||
};
|
||||
|
||||
describe('createGetCatalogModelDescriptionAction', () => {
|
||||
it('should return a markdown description of the catalog model', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
|
||||
createGetCatalogModelDescriptionAction({
|
||||
modelHolder: ModelHolder.modelPassthroughForTest(model),
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:get-catalog-model-description',
|
||||
input: {},
|
||||
});
|
||||
|
||||
const description = result.output.description as string;
|
||||
|
||||
expect(description).toContain('# Catalog Model');
|
||||
expect(description).toContain('## Entity Kinds');
|
||||
expect(description).toContain('## Entity Relations');
|
||||
expect(description).toContain('## Entity Metadata field');
|
||||
|
||||
// Kind details
|
||||
expect(description).toContain('### Entity Kind "Component"');
|
||||
expect(description).toContain('A software component');
|
||||
expect(description).toContain('apiVersion: "backstage.io/v1alpha1"');
|
||||
expect(description).toContain('spec.type: "service"');
|
||||
expect(description).toContain('Singular in text: "component"');
|
||||
expect(description).toContain('Plural in text: "components"');
|
||||
|
||||
// Relation details
|
||||
expect(description).toContain('### Relation "ownedBy": owned by');
|
||||
expect(description).toContain('Ownership relation');
|
||||
expect(description).toContain('Reverse type: "ownerOf": owner of');
|
||||
|
||||
// Annotation details
|
||||
expect(description).toContain(
|
||||
'### Annotation "backstage.io/managed-by-location": Managed By Location',
|
||||
);
|
||||
expect(description).toContain('The location that manages this entity.');
|
||||
|
||||
// Label details
|
||||
expect(description).toContain('### Label "backstage.io/orphan"');
|
||||
expect(description).toContain('Indicates that this entity is an orphan.');
|
||||
|
||||
// Tag details
|
||||
expect(description).toContain('### Tag "java": Java');
|
||||
expect(description).toContain('Relates to the Java programming language.');
|
||||
});
|
||||
|
||||
it('should fall back to the default model when no model holder is provided', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
|
||||
createGetCatalogModelDescriptionAction({
|
||||
modelHolder: undefined,
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:get-catalog-model-description',
|
||||
input: {},
|
||||
});
|
||||
|
||||
const description = result.output.description as string;
|
||||
|
||||
expect(description).toContain('# Catalog Model');
|
||||
expect(description).toContain('## Entity Kinds');
|
||||
expect(description).toContain('### Entity Kind "Component"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import {
|
||||
CatalogModel,
|
||||
CatalogModelAnnotationSummary,
|
||||
CatalogModelKindSummary,
|
||||
CatalogModelLabelSummary,
|
||||
CatalogModelRelationSummary,
|
||||
CatalogModelTagSummary,
|
||||
compileCatalogModel,
|
||||
defaultCatalogEntityModel,
|
||||
} from '@backstage/catalog-model/alpha';
|
||||
import { ModelHolder } from '../model/ModelHolder';
|
||||
|
||||
/**
|
||||
* Lets users fetch a markdown formatted description of the catalog model. This
|
||||
* is useful for informing LLMs how to properly work with it.
|
||||
*/
|
||||
export const createGetCatalogModelDescriptionAction = ({
|
||||
modelHolder,
|
||||
actionsRegistry,
|
||||
}: {
|
||||
modelHolder: ModelHolder | undefined;
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
}) => {
|
||||
actionsRegistry.register({
|
||||
name: 'get-catalog-model-description',
|
||||
title: 'Get a Catalog Model Description',
|
||||
description:
|
||||
'Returns a markdown formatted description of the current catalog model, including all registered entity kinds, their spec fields, and available relations.',
|
||||
attributes: {
|
||||
destructive: false,
|
||||
readOnly: true,
|
||||
idempotent: true,
|
||||
},
|
||||
schema: {
|
||||
input: z => z.object({}),
|
||||
output: z =>
|
||||
z.object({
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
'Markdown description of the catalog model including entity kinds, spec fields, and relations.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
action: async () => {
|
||||
return {
|
||||
output: {
|
||||
description: describeCatalogModel(modelHolder?.model),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Compute the default description once (if needed) and cache it
|
||||
let defaultModelDescription: string | undefined;
|
||||
|
||||
function describeCatalogModel(model: CatalogModel | undefined): string {
|
||||
if (!model) {
|
||||
if (!defaultModelDescription) {
|
||||
defaultModelDescription = describeModel(
|
||||
compileCatalogModel([defaultCatalogEntityModel]),
|
||||
);
|
||||
}
|
||||
return defaultModelDescription;
|
||||
}
|
||||
|
||||
return describeModel(model);
|
||||
}
|
||||
|
||||
function describeModel(model: CatalogModel): string {
|
||||
return `# Catalog Model
|
||||
|
||||
The software catalog contains a few key concepts:
|
||||
|
||||
* Entities
|
||||
* rich objects with various kinds, with different schemas
|
||||
* can have relations between each other
|
||||
* Locations
|
||||
* registered as type/target pairs
|
||||
* typically in the form of URLs that the catalog has been tasked with keeping track of
|
||||
|
||||
## Entities
|
||||
|
||||
The catalog contains entities of different kinds. Every entity is an object with
|
||||
fields "kind", "apiVersion", "metadata", and optionally "spec" and "relations".
|
||||
|
||||
When querying the catalog you use dot path notation to address fields, e.g. "metadata.name".
|
||||
When querying for entity relationships, prefer using relations over spec fields, e.g. the
|
||||
special syntax "relations.ownedBy" instead of "spec.owner".
|
||||
|
||||
The unique identifying key for an entity is its so called entity reference (or entity ref
|
||||
for short), on the form of "kind:namespace/name", e.g. "component:default/my-service".
|
||||
These are always uniformly lowercased in the "en-US" locale.
|
||||
|
||||
## Entity Metadata field
|
||||
|
||||
The "metadata" object field on all entities has the same static schema. Some common fields
|
||||
there are:
|
||||
|
||||
* "name": The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair. This value is part of the technical identifier of the entity, and as such it will appear in URLs, database tables, entity references, and similar. It is subject to restrictions regarding what characters are allowed. If you want to use a different, more human readable string with fewer restrictions on it in user interfaces, see the "title" field below.
|
||||
* "namespace" (default value: "default"): The namespace that the entity belongs to.
|
||||
* "uid": A globally unique ID for the entity. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, but the server is free to reject requests that do so in such a way that it breaks semantics.
|
||||
* "etag": An opaque string that changes for each update operation to any part of the entity, including metadata. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, and the server will then reject the operation if it does not match the current stored value.
|
||||
* "title": A display name of the entity, to be presented in user interfaces instead of the name property, when available. This field is sometimes useful when the name is cumbersome or ends up being perceived as overly technical. The title generally does not have as stringent format requirements on it, so it may contain special characters and be more explanatory. Do keep it very short though, and avoid situations where a title can be confused with the name of another entity, or where two entities share a title. Note that this is only for display purposes, and may be ignored by some parts of the code. Entity references still always make use of the name property, not the title.
|
||||
* "description": A short (typically relatively few words, on one line) description of the entity.
|
||||
* "annotations": A map of annotations on the entity.
|
||||
* "labels": A map of labels on the entity.
|
||||
* "tags": A list of tags on the entity.
|
||||
* "links": A list of links on the entity.
|
||||
|
||||
${model.getMetadata().annotations.map(describeAnnotation).join('\n')}
|
||||
${model.getMetadata().labels.map(describeLabel).join('\n')}
|
||||
${model.getMetadata().tags.map(describeTag).join('\n')}
|
||||
|
||||
## Entity Kinds
|
||||
|
||||
The model contains the following entity kinds:
|
||||
|
||||
${model.listKinds().map(describeKind).join('\n')}
|
||||
|
||||
## Entity Relations
|
||||
|
||||
The model contains the following entity relations:
|
||||
|
||||
${model.listRelations().map(describeRelation).join('\n')}
|
||||
`;
|
||||
}
|
||||
|
||||
function describeKind(kind: CatalogModelKindSummary): string {
|
||||
return `### Entity Kind "${kind.names.kind}"
|
||||
|
||||
* Singular in text: "${kind.names.singular}"
|
||||
* Plural in text: "${kind.names.plural}"
|
||||
* Versions:
|
||||
${kind.versions
|
||||
.map(
|
||||
version =>
|
||||
` * apiVersion: "${version.apiVersion}"${
|
||||
version.specType ? ` (spec.type: "${version.specType}")` : ''
|
||||
}`,
|
||||
)
|
||||
.join('\n')}
|
||||
|
||||
${kind.description}
|
||||
`;
|
||||
}
|
||||
|
||||
function describeAnnotation(annotation: CatalogModelAnnotationSummary): string {
|
||||
const title = annotation.title ? `: ${annotation.title}` : '';
|
||||
return `### Annotation "${annotation.name}"${title}
|
||||
|
||||
${annotation.description}
|
||||
`;
|
||||
}
|
||||
|
||||
function describeLabel(label: CatalogModelLabelSummary): string {
|
||||
const title = label.title ? `: ${label.title}` : '';
|
||||
return `### Label "${label.name}"${title}
|
||||
|
||||
${label.description}
|
||||
`;
|
||||
}
|
||||
|
||||
function describeTag(tag: CatalogModelTagSummary): string {
|
||||
const title = tag.title ? `: ${tag.title}` : '';
|
||||
return `### Tag "${tag.name}"${title}
|
||||
|
||||
${tag.description}
|
||||
`;
|
||||
}
|
||||
|
||||
function describeRelation(relation: CatalogModelRelationSummary): string {
|
||||
return `### Relation "${relation.forward.type}": ${relation.forward.title}
|
||||
|
||||
* Reverse type: "${relation.reverse.type}": ${relation.reverse.title}
|
||||
|
||||
${relation.description}
|
||||
`;
|
||||
}
|
||||
@@ -13,8 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { CatalogService } from '@backstage/plugin-catalog-node';
|
||||
import { ModelHolder } from '../model/ModelHolder';
|
||||
import { createGetCatalogModelDescriptionAction } from './createGetCatalogModelDescriptionAction.ts';
|
||||
import { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts';
|
||||
import { createValidateEntityAction } from './createValidateEntityAction.ts';
|
||||
import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts';
|
||||
@@ -24,7 +27,9 @@ import { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAc
|
||||
export const createCatalogActions = (options: {
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
catalog: CatalogService;
|
||||
modelHolder: ModelHolder | undefined;
|
||||
}) => {
|
||||
createGetCatalogModelDescriptionAction(options);
|
||||
createGetCatalogEntityAction(options);
|
||||
createValidateEntityAction(options);
|
||||
createRegisterCatalogEntitiesAction(options);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ModelProcessor } from './ModelProcessor';
|
||||
import { ModelHolder } from '../model/ModelHolder';
|
||||
|
||||
const componentKind: CatalogModelKind = {
|
||||
description: 'A software component',
|
||||
apiVersions: ['backstage.io/v1alpha1'],
|
||||
names: { kind: 'Component', singular: 'component', plural: 'components' },
|
||||
relationFields: [
|
||||
@@ -80,6 +81,15 @@ function createModel(overrides?: {
|
||||
getRelations?: CatalogModel['getRelations'];
|
||||
}): ModelHolder {
|
||||
return ModelHolder.modelPassthroughForTest({
|
||||
listKinds: () => [
|
||||
{
|
||||
description: componentKind.description,
|
||||
names: componentKind.names,
|
||||
versions: componentKind.apiVersions.map(apiVersion => ({ apiVersion })),
|
||||
},
|
||||
],
|
||||
listRelations: () => [ownedByRelation, dependsOnRelation],
|
||||
getMetadata: () => ({ annotations: [], labels: [], tags: [] }),
|
||||
getKind: overrides?.getKind ?? (() => componentKind),
|
||||
getRelations:
|
||||
overrides?.getRelations ?? (() => [ownedByRelation, dependsOnRelation]),
|
||||
|
||||
@@ -287,6 +287,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
createCatalogActions({
|
||||
catalog,
|
||||
actionsRegistry,
|
||||
modelHolder,
|
||||
});
|
||||
|
||||
const scmEventsMessagesCounter = metrics.createCounter<{
|
||||
|
||||
Reference in New Issue
Block a user