diff --git a/.changeset/grumpy-mails-live.md b/.changeset/grumpy-mails-live.md new file mode 100644 index 0000000000..a335679795 --- /dev/null +++ b/.changeset/grumpy-mails-live.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Implement the action `get-catalog-entity` with the `ActionsRegistry` diff --git a/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.test.ts b/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.test.ts new file mode 100644 index 0000000000..ed8aec264d --- /dev/null +++ b/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2025 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 { createGetCatalogEntityAction } from './createGetCatalogEntityAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +describe('createGetCatalogEntityAction', () => { + it('should throw an error if the entity is not found', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + createGetCatalogEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-catalog-entity', + input: { name: 'test' }, + }), + ).rejects.toThrow(`No entity found with name "test"`); + }); + + it('should throw an error if theres multiple entities found', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock({ + entities: [ + { + kind: 'Component', + apiVersion: 'backstage.io/v1alpha1', + metadata: { + name: 'test', + namespace: 'default', + }, + spec: { + type: 'component', + }, + }, + { + kind: 'API', + apiVersion: 'backstage.io/v1alpha1', + metadata: { + name: 'test', + namespace: 'default', + }, + }, + ], + }); + + createGetCatalogEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-catalog-entity', + input: { name: 'test' }, + }), + ).rejects.toThrow( + `Multiple entities found with name "test", please provide more specific filters. Entities found: "component:default/test", "api:default/test"`, + ); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.ts b/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.ts new file mode 100644 index 0000000000..da33064c08 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2025 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; +import { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createGetCatalogEntityAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'get-catalog-entity', + title: 'Get Catalog Entity', + description: ` +This allows you to get a single entity from the software catalog. +Each entity in the software catalog has a unique name, kind, and namespace. The default namespace is "default". +Each entity is identified by a unique entity reference, which is a string of the form "kind:namespace/name". + `, + schema: { + input: z => + z.object({ + kind: z + .string() + .describe( + 'The kind of the entity to query. If the kind is unknown it can be omitted.', + ) + .optional(), + namespace: z + .string() + .describe( + 'The namespace of the entity to query. If the namespace is unknown it can be omitted.', + ) + .optional(), + name: z.string().describe('The name of the entity to query'), + }), + // TODO: is there a better way to do this? + output: z => z.object({}).passthrough(), + }, + action: async ({ input, credentials }) => { + const filter: Record = { 'metadata.name': input.name }; + + if (input.kind) { + filter.kind = input.kind; + } + + if (input.namespace) { + filter['metadata.namespace'] = input.namespace; + } + + const { items } = await catalog.queryEntities( + { filter }, + { + credentials, + }, + ); + + if (items.length === 0) { + throw new InputError(`No entity found with name "${input.name}"`); + } + + if (items.length > 1) { + throw new Error( + `Multiple entities found with name "${ + input.name + }", please provide more specific filters. Entities found: ${items + .map(item => `"${stringifyEntityRef(item)}"`) + .join(', ')}`, + ); + } + + const [entity] = items; + + return { + output: entity, + }; + }, + }); +}; diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 9925f11c7b..a9bb2c9285 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -22,6 +22,7 @@ import { ForwardedError } from '@backstage/errors'; import { CatalogProcessor, CatalogProcessorParser, + catalogServiceRef, EntityProvider, LocationAnalyzer, PlaceholderResolver, @@ -43,6 +44,8 @@ import { eventsServiceRef } from '@backstage/plugin-events-node'; import { Permission } from '@backstage/plugin-permission-common'; import { merge } from 'lodash'; import { CatalogBuilder } from './CatalogBuilder'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { createGetCatalogEntityAction } from '../actions/createGetCatalogEntityAction'; class CatalogLocationsExtensionPointImpl implements CatalogLocationsExtensionPoint @@ -237,6 +240,8 @@ export const catalogPlugin = createBackendPlugin({ httpAuth: coreServices.httpAuth, auditor: coreServices.auditor, events: eventsServiceRef, + catalog: catalogServiceRef, + actionsRegistry: actionsRegistryServiceRef, }, async init({ logger, @@ -250,6 +255,8 @@ export const catalogPlugin = createBackendPlugin({ scheduler, auth, httpAuth, + catalog, + actionsRegistry, auditor, events, }) { @@ -313,6 +320,11 @@ export const catalogPlugin = createBackendPlugin({ } httpRouter.use(router); + + createGetCatalogEntityAction({ + catalog, + actionsRegistry, + }); }, }); },