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:
@@ -31,7 +31,14 @@ export interface CatalogModel {
|
||||
type?: string;
|
||||
};
|
||||
}): CatalogModelKind | undefined;
|
||||
getMetadata(): {
|
||||
annotations: CatalogModelAnnotationSummary[];
|
||||
labels: CatalogModelLabelSummary[];
|
||||
tags: CatalogModelTagSummary[];
|
||||
};
|
||||
getRelations(options: { kind: string }): CatalogModelRelation[] | undefined;
|
||||
listKinds(): CatalogModelKindSummary[];
|
||||
listRelations(): CatalogModelRelationSummary[];
|
||||
}
|
||||
|
||||
// @alpha
|
||||
@@ -44,9 +51,17 @@ export interface CatalogModelAnnotationDefinition {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelAnnotationSummary {
|
||||
description: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelKind {
|
||||
apiVersions: string[];
|
||||
description: string;
|
||||
jsonSchema: JsonObject;
|
||||
names: {
|
||||
kind: string;
|
||||
@@ -131,6 +146,20 @@ export interface CatalogModelKindRootSchema extends JsonObject {
|
||||
type: 'object';
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelKindSummary {
|
||||
description: string;
|
||||
names: {
|
||||
kind: string;
|
||||
singular: string;
|
||||
plural: string;
|
||||
};
|
||||
versions: Array<{
|
||||
apiVersion: string;
|
||||
specType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelKindVersionDefinition {
|
||||
description?: string;
|
||||
@@ -153,6 +182,13 @@ export interface CatalogModelLabelDefinition {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelLabelSummary {
|
||||
description: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelLayer {
|
||||
// (undocumented)
|
||||
@@ -209,6 +245,21 @@ export interface CatalogModelRelationPairDefinition {
|
||||
toKind: string | string[];
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelRelationSummary {
|
||||
description: string;
|
||||
forward: {
|
||||
type: string;
|
||||
title: string;
|
||||
};
|
||||
fromKind: string[];
|
||||
reverse: {
|
||||
type: string;
|
||||
title: string;
|
||||
};
|
||||
toKind: string[];
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelRemoveAnnotationDefinition {
|
||||
name: string;
|
||||
@@ -255,6 +306,13 @@ export interface CatalogModelTagDefinition {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelTagSummary {
|
||||
description: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelUpdateAnnotationDefinition {
|
||||
description?: string;
|
||||
|
||||
@@ -316,6 +316,7 @@ describe('compileCatalogModel integration', () => {
|
||||
apiVersion: 'example.com/v1alpha1',
|
||||
});
|
||||
expect(kind1).toEqual({
|
||||
description: 'A widget',
|
||||
apiVersions: ['example.com/v1alpha1'],
|
||||
names: { kind: 'Widget', singular: 'widget', plural: 'widgets' },
|
||||
relationFields: [
|
||||
@@ -490,6 +491,7 @@ describe('compileCatalogModel integration', () => {
|
||||
apiVersion: 'example.com/v1alpha1',
|
||||
});
|
||||
expect(kind2).toEqual({
|
||||
description: 'An updated widget',
|
||||
apiVersions: ['example.com/v1alpha1'],
|
||||
names: { kind: 'Widget', singular: 'gizmo', plural: 'gizmos' },
|
||||
relationFields: [
|
||||
@@ -630,6 +632,7 @@ describe('compileCatalogModel integration', () => {
|
||||
apiVersion: 'example.com/v1alpha1',
|
||||
});
|
||||
expect(kind3).toEqual({
|
||||
description: 'An updated widget',
|
||||
apiVersions: ['example.com/v1alpha1'],
|
||||
names: { kind: 'Widget', singular: 'gizmo', plural: 'gizmos' },
|
||||
relationFields: [
|
||||
|
||||
@@ -38,9 +38,14 @@ import { OpUpdateRelationV1 } from './operations/updateRelation';
|
||||
import { OpUpdateTagV1 } from './operations/updateTag';
|
||||
import {
|
||||
CatalogModel,
|
||||
CatalogModelLayer,
|
||||
CatalogModelAnnotationSummary,
|
||||
CatalogModelKind,
|
||||
CatalogModelKindSummary,
|
||||
CatalogModelLabelSummary,
|
||||
CatalogModelLayer,
|
||||
CatalogModelRelation,
|
||||
CatalogModelRelationSummary,
|
||||
CatalogModelTagSummary,
|
||||
OpaqueCatalogModelLayer,
|
||||
} from './types';
|
||||
|
||||
@@ -604,6 +609,7 @@ export function compileCatalogModel(
|
||||
for (const [specType, specificKind] of version.specTypes) {
|
||||
const key = `${kindName}\0${version.apiVersion}\0${specType ?? ''}`;
|
||||
compiledKinds.set(key, {
|
||||
description: specificKind.description ?? kindState.description,
|
||||
apiVersions: [version.apiVersion],
|
||||
names: {
|
||||
kind: kindName,
|
||||
@@ -654,7 +660,85 @@ export function compileCatalogModel(
|
||||
);
|
||||
}
|
||||
|
||||
// Precompute kind summaries, one per unique kind (not per version/specType)
|
||||
const kindSummaries: CatalogModelKindSummary[] = [...kinds.entries()].map(
|
||||
([kindName, kindState]) => ({
|
||||
description: kindState.description,
|
||||
names: {
|
||||
kind: kindName,
|
||||
singular: kindState.singular,
|
||||
plural: kindState.plural,
|
||||
},
|
||||
versions: [...kindState.versions.values()].flatMap(version =>
|
||||
[...version.specTypes.keys()].map(specType => ({
|
||||
apiVersion: version.apiVersion,
|
||||
...(specType !== undefined ? { specType } : undefined),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Precompute annotation summaries
|
||||
const annotationSummaries: CatalogModelAnnotationSummary[] = [
|
||||
...annotations.entries(),
|
||||
].map(([name, state]) => ({
|
||||
name,
|
||||
...(state.title !== undefined ? { title: state.title } : undefined),
|
||||
description: state.description,
|
||||
}));
|
||||
|
||||
// Precompute label summaries
|
||||
const labelSummaries: CatalogModelLabelSummary[] = [...labels.entries()].map(
|
||||
([name, state]) => ({
|
||||
name,
|
||||
...(state.title !== undefined ? { title: state.title } : undefined),
|
||||
description: state.description,
|
||||
}),
|
||||
);
|
||||
|
||||
// Precompute tag summaries
|
||||
const tagSummaries: CatalogModelTagSummary[] = [...tags.entries()].map(
|
||||
([name, state]) => ({
|
||||
name,
|
||||
...(state.title !== undefined ? { title: state.title } : undefined),
|
||||
description: state.description,
|
||||
}),
|
||||
);
|
||||
|
||||
// Collect all unique relation summaries
|
||||
const relationSummaries: CatalogModelRelationSummary[] = [
|
||||
...relations.values(),
|
||||
].map(r => {
|
||||
const reverseEntry = relations.get(r.reverse.type);
|
||||
return {
|
||||
fromKind: [...r.fromKinds],
|
||||
toKind: [...r.toKinds],
|
||||
description: r.description,
|
||||
forward: r.forward,
|
||||
reverse: {
|
||||
type: r.reverse.type,
|
||||
title: reverseEntry?.forward.title ?? r.reverse.title,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
listKinds() {
|
||||
return kindSummaries;
|
||||
},
|
||||
|
||||
listRelations() {
|
||||
return relationSummaries;
|
||||
},
|
||||
|
||||
getMetadata() {
|
||||
return {
|
||||
annotations: annotationSummaries,
|
||||
labels: labelSummaries,
|
||||
tags: tagSummaries,
|
||||
};
|
||||
},
|
||||
|
||||
getKind(options) {
|
||||
const type = options.spec?.type;
|
||||
|
||||
|
||||
@@ -25,7 +25,12 @@ export * from './modelActions';
|
||||
export * from './sources';
|
||||
export type {
|
||||
CatalogModel,
|
||||
CatalogModelLayer,
|
||||
CatalogModelAnnotationSummary,
|
||||
CatalogModelKind,
|
||||
CatalogModelKindSummary,
|
||||
CatalogModelLabelSummary,
|
||||
CatalogModelLayer,
|
||||
CatalogModelRelation,
|
||||
CatalogModelRelationSummary,
|
||||
CatalogModelTagSummary,
|
||||
} from './types';
|
||||
|
||||
@@ -72,6 +72,26 @@ export const OpaqueCatalogModelLayer = OpaqueType.create<{
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModel {
|
||||
/**
|
||||
* Lists all kinds in the model.
|
||||
*/
|
||||
listKinds(): CatalogModelKindSummary[];
|
||||
|
||||
/**
|
||||
* Lists all relations in the model.
|
||||
*/
|
||||
listRelations(): CatalogModelRelationSummary[];
|
||||
|
||||
/**
|
||||
* Returns summaries of the shared metadata fields in the model, including
|
||||
* all declared annotations, labels, and tags.
|
||||
*/
|
||||
getMetadata(): {
|
||||
annotations: CatalogModelAnnotationSummary[];
|
||||
labels: CatalogModelLabelSummary[];
|
||||
tags: CatalogModelTagSummary[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a kind in the model.
|
||||
*
|
||||
@@ -104,6 +124,11 @@ export interface CatalogModel {
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelKind {
|
||||
/**
|
||||
* A human-readable description of the kind.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* The API version(s) of the kind that this schema applies to, e.g.
|
||||
* "backstage.io/v1alpha1".
|
||||
@@ -171,6 +196,63 @@ export interface CatalogModelKind {
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region CatalogModelKindSummary
|
||||
|
||||
/**
|
||||
* A summary of a catalog model kind, without version-specific details such as
|
||||
* schemas and relation fields.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelKindSummary {
|
||||
/**
|
||||
* A human-readable description of the kind.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* The names used for this kind.
|
||||
*/
|
||||
names: {
|
||||
/**
|
||||
* The name of the kind with proper casing, e.g. "Component".
|
||||
*/
|
||||
kind: string;
|
||||
|
||||
/**
|
||||
* The singular form of the kind name, e.g. "component".
|
||||
*/
|
||||
singular: string;
|
||||
|
||||
/**
|
||||
* The plural form of the kind name, e.g. "components".
|
||||
*/
|
||||
plural: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The available versions and spec types for this kind.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Each entry represents a unique apiVersion/specType combination that can be
|
||||
* passed to {@link CatalogModel.getKind} to retrieve the full kind details.
|
||||
*/
|
||||
versions: Array<{
|
||||
/**
|
||||
* The API version, e.g. "backstage.io/v1alpha1".
|
||||
*/
|
||||
apiVersion: string;
|
||||
/**
|
||||
* The spec type, if any, e.g. "service". Undefined means the default
|
||||
* (untyped) version.
|
||||
*/
|
||||
specType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region CatalogModelRelation
|
||||
|
||||
/**
|
||||
@@ -208,3 +290,113 @@ export interface CatalogModelRelation {
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region CatalogModelAnnotationSummary
|
||||
|
||||
/**
|
||||
* A summary of a catalog model annotation.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelAnnotationSummary {
|
||||
/**
|
||||
* The annotation key, e.g. "backstage.io/managed-by-location".
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* A short human-readable title for the annotation.
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* A human-readable description of the annotation.
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region CatalogModelLabelSummary
|
||||
|
||||
/**
|
||||
* A summary of a catalog model label.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelLabelSummary {
|
||||
/**
|
||||
* The label key, e.g. "backstage.io/orphan".
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* A short human-readable title for the label.
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* A human-readable description of the label.
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region CatalogModelTagSummary
|
||||
|
||||
/**
|
||||
* A summary of a catalog model tag.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelTagSummary {
|
||||
/**
|
||||
* The tag value, e.g. "java".
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* A short human-readable title for the tag.
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* A human-readable description of the tag.
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region CatalogModelRelationSummary
|
||||
|
||||
/**
|
||||
* A summary of a catalog model relation.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelRelationSummary {
|
||||
/**
|
||||
* The kinds that this relation can originate from.
|
||||
*/
|
||||
fromKind: string[];
|
||||
/**
|
||||
* The kinds that this relation can point to.
|
||||
*/
|
||||
toKind: string[];
|
||||
/**
|
||||
* A human-readable description of the relation.
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* The forward direction of this relation.
|
||||
*/
|
||||
forward: {
|
||||
type: string;
|
||||
title: string;
|
||||
};
|
||||
/**
|
||||
* The reverse direction of this relation.
|
||||
*/
|
||||
reverse: {
|
||||
type: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -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