From dce18246e4d1fdf31b94e9b00a645da7de4f3cc7 Mon Sep 17 00:00:00 2001 From: gabemontero Date: Fri, 5 Dec 2025 15:36:11 -0500 Subject: [PATCH 1/4] add MCP tools for register/unregister catalog entities Signed-off-by: gabemontero --- .changeset/nine-symbols-shout.md | 5 + ...reateRegisterCatalogEntitiesAction.test.ts | 128 ++++++++++++++++++ .../createRegisterCatalogEntitiesAction.ts | 123 +++++++++++++++++ ...ateUnregisterCatalogEntitiesAction.test.ts | 90 ++++++++++++ .../createUnregisterCatalogEntitiesAction.ts | 93 +++++++++++++ plugins/catalog-backend/src/actions/index.ts | 4 + 6 files changed, 443 insertions(+) create mode 100644 .changeset/nine-symbols-shout.md create mode 100644 plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts create mode 100644 plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts create mode 100644 plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts create mode 100644 plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts diff --git a/.changeset/nine-symbols-shout.md b/.changeset/nine-symbols-shout.md new file mode 100644 index 0000000000..87fff3091b --- /dev/null +++ b/.changeset/nine-symbols-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Add MCP actions to 1) register existing components from a code respository, 2) unregister components with the location ID return from a prior register existing components invocation. diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts new file mode 100644 index 0000000000..0c6d35b297 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts @@ -0,0 +1,128 @@ +/* + * 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 { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +describe('createRegisterCatalogEntitiesAction', () => { + it('should successfully register a catalog location with a valid URL', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const mockLocationId = 'test-location-id-1234'; + mockCatalog.addLocation = jest.fn().mockResolvedValue({ + location: { + id: mockLocationId, + type: 'url', + target: 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + entities: [], + exists: false, + }); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { + locationURL: + 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + }); + + expect(result.output).toEqual({ + locationID: mockLocationId, + error: undefined, + }); + expect(mockCatalog.addLocation).toHaveBeenCalledWith( + { + type: 'url', + target: 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); + }); + + it('should return an error if locationURL is not provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { locationURL: '' }, + }); + + expect(result.output).toEqual({ + error: 'a location URL must be specified', + }); + }); + + it('should return an error if locationURL is not a valid URL', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { locationURL: 'not-a-valid-url' }, + }); + + expect(result.output).toEqual({ + error: 'location URL must be a valid URL string', + }); + }); + + it('should return an error if catalog.addLocation throws an error', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const errorMessage = 'Failed to add location'; + mockCatalog.addLocation = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { + locationURL: + 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + }); + + expect(result.output).toEqual({ + error: errorMessage, + }); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts new file mode 100644 index 0000000000..167a1bd66a --- /dev/null +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts @@ -0,0 +1,123 @@ +/* + * 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 { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createRegisterCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'register-catalog-entities', + title: 'Register Catalog Entities', + attributes: { + destructive: false, + readOnly: false, + idempotent: true, + }, + description: `Register entities defined remotely (create new Locations). + +This tool is analogous to the "register existing components" function you see in the Backstage dashboard, +where you supply a URL link that allows for the downloading of a 'catalog-info.yaml' file that +defines the various Entities (Components, Systems, Resources, APIs, Users, and Groups) you want to +create or import into the Backstage catalog. + +The action within the catalog results in the creation of a Location entity that is the parent of owner +of all the additional entities defined in the 'catalog-info.yaml' file. + +The dynamically generated, random UUID created to go along with the newly created Location entity is returned +to the caller. That UUID can be later used if you want to unregister or delete the Location entity and all +of its children entities. + +If an error occurred during processing, the 'locationID' field will be blank, and the 'error' field will contain +a message describing the error. + +Example invocation and the output from the invocation: + # Register a Location from a GitHub URL + register-catalog-entities locationURL: https://github.com/redhat-ai-dev/model-catalog-example/blob/main/developer-model-service/catalog-info.yaml + Output: { + "locationID": "aaaa-bbbb-cccc-dddd" + } +`, + schema: { + input: z => + z.object({ + locationURL: z + .string() + .describe( + `URL reference to the catalog-info.yaml file that describes what entities to import.`, + ), + }), + output: z => + z.object({ + locationID: z + .string() + .optional() + .describe('The location ID generated by the registration'), + error: z + .string() + .optional() + .describe('Error message if creation fails'), + }), + }, + action: async ({ input, credentials }) => { + if (!input.locationURL || input.locationURL === '') { + return { + output: { + error: 'a location URL must be specified', + }, + }; + } + + // Validate that the locationURL is a valid URL + try { + // eslint-disable-next-line no-new + new URL(input.locationURL); + } catch { + return { + output: { + error: 'location URL must be a valid URL string', + }, + }; + } + try { + const locReq = { + type: 'url', + target: input.locationURL, + }; + const result = await catalog.addLocation(locReq, { + credentials, + }); + + return { + output: { + locationID: result.location.id, + error: undefined, + }, + }; + } catch (error) { + return { + output: { + error: error.message, + }, + }; + } + }, + }); +}; diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts new file mode 100644 index 0000000000..abf03a2f36 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +describe('createUnregisterCatalogEntitiesAction', () => { + it('should successfully unregister a catalog location with a valid locationId', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + mockCatalog.removeLocationById = jest.fn().mockResolvedValue(undefined); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: 'test-location-id-1234' }, + }); + + expect(result.output).toEqual({ + error: undefined, + }); + expect(mockCatalog.removeLocationById).toHaveBeenCalledWith( + 'test-location-id-1234', + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); + }); + + it('should return an error if locationId is not provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: '' }, + }); + + expect(result.output).toEqual({ + error: 'a location ID must be specified', + }); + }); + + it('should return an error if catalog.removeLocationById throws an error', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const errorMessage = 'Failed to remove location'; + mockCatalog.removeLocationById = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: 'test-location-id-1234' }, + }); + + expect(result.output).toEqual({ + error: errorMessage, + }); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts new file mode 100644 index 0000000000..2164e15f5e --- /dev/null +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts @@ -0,0 +1,93 @@ +/* + * 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 { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createUnregisterCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'unregister-catalog-entities', + title: 'Unregister Catalog Entities', + attributes: { + destructive: true, + readOnly: false, + idempotent: true, + }, + description: `Unregister a Location and the entities it ownws. + +This tool is analogous to the "unregister location" function you see in the Backstage dashboard, +where you supply the UUID for a Location entity link that allows for the removal of the Location and +the various Entities (Components, Systems, Resources, APIs, Users, and Groups) that were also created +when the Location was imported. + +If an error occurred during processing, the 'error' field will contain a message describing the error. + +Example invocation and the output from the invocation: + # Register a Location from a GitHub URL + unregister-catalog-entities locationId: aaa-bbb-ccc-ddd + Output: {} +`, + schema: { + input: z => + z.object({ + locationId: z + .string() + .describe( + `Location ID returned from the 'register-catalog-entities' call.`, + ), + }), + output: z => + z.object({ + error: z + .string() + .optional() + .describe('Error message if creation fails'), + }), + }, + action: async ({ input, credentials }) => { + if (!input.locationId || input.locationId === '') { + return { + output: { + error: 'a location ID must be specified', + }, + }; + } + + try { + await catalog.removeLocationById(input.locationId, { + credentials, + }); + + return { + output: { + error: undefined, + }, + }; + } catch (error) { + return { + output: { + error: error.message, + }, + }; + } + }, + }); +}; diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts index c53f956f62..5940bc32f1 100644 --- a/plugins/catalog-backend/src/actions/index.ts +++ b/plugins/catalog-backend/src/actions/index.ts @@ -17,6 +17,8 @@ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { CatalogService } from '@backstage/plugin-catalog-node'; import { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts'; import { createValidateEntityAction } from './createValidateEntityAction.ts'; +import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts'; +import { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction.ts'; export const createCatalogActions = (options: { actionsRegistry: ActionsRegistryService; @@ -24,4 +26,6 @@ export const createCatalogActions = (options: { }) => { createGetCatalogEntityAction(options); createValidateEntityAction(options); + createRegisterCatalogEntitiesAction(options); + createUnregisterCatalogEntitiesAction(options); }; From 643f60e667ad8394311f95eb9991209f4552a85b Mon Sep 17 00:00:00 2001 From: gabemontero Date: Mon, 8 Dec 2025 15:14:32 -0500 Subject: [PATCH 2/4] benjdlambert-1 review changes Signed-off-by: gabemontero --- ...reateRegisterCatalogEntitiesAction.test.ts | 56 +++++++++---------- .../createRegisterCatalogEntitiesAction.ts | 34 ++++------- ...ateUnregisterCatalogEntitiesAction.test.ts | 37 ++++++------ .../createUnregisterCatalogEntitiesAction.ts | 33 +++-------- 4 files changed, 59 insertions(+), 101 deletions(-) diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts index 0c6d35b297..a85456ff42 100644 --- a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts @@ -16,6 +16,7 @@ import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { ForwardedError } from '@backstage/errors'; describe('createRegisterCatalogEntitiesAction', () => { it('should successfully register a catalog location with a valid URL', async () => { @@ -48,7 +49,6 @@ describe('createRegisterCatalogEntitiesAction', () => { expect(result.output).toEqual({ locationID: mockLocationId, - error: undefined, }); expect(mockCatalog.addLocation).toHaveBeenCalledWith( { @@ -61,7 +61,7 @@ describe('createRegisterCatalogEntitiesAction', () => { ); }); - it('should return an error if locationURL is not provided', async () => { + it('should throw an error if locationURL is not provided', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); @@ -70,17 +70,15 @@ describe('createRegisterCatalogEntitiesAction', () => { actionsRegistry: mockActionsRegistry, }); - const result = await mockActionsRegistry.invoke({ - id: 'test:register-catalog-entities', - input: { locationURL: '' }, - }); - - expect(result.output).toEqual({ - error: 'a location URL must be specified', - }); + await expect( + mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { locationURL: '' }, + }), + ).rejects.toThrow('a location URL must be specified'); }); - it('should return an error if locationURL is not a valid URL', async () => { + it('should throw an error if locationURL is not a valid URL', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); @@ -89,17 +87,15 @@ describe('createRegisterCatalogEntitiesAction', () => { actionsRegistry: mockActionsRegistry, }); - const result = await mockActionsRegistry.invoke({ - id: 'test:register-catalog-entities', - input: { locationURL: 'not-a-valid-url' }, - }); - - expect(result.output).toEqual({ - error: 'location URL must be a valid URL string', - }); + await expect( + mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { locationURL: 'not-a-valid-url' }, + }), + ).rejects.toThrow('locationURL "not-a-valid-url" an invalid URL'); }); - it('should return an error if catalog.addLocation throws an error', async () => { + it('should throw a ForwardedError if catalog.addLocation throws an error', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); @@ -113,16 +109,14 @@ describe('createRegisterCatalogEntitiesAction', () => { actionsRegistry: mockActionsRegistry, }); - const result = await mockActionsRegistry.invoke({ - id: 'test:register-catalog-entities', - input: { - locationURL: - 'https://github.com/example/repo/blob/main/catalog-info.yaml', - }, - }); - - expect(result.output).toEqual({ - error: errorMessage, - }); + await expect( + mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { + locationURL: + 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + }), + ).rejects.toThrow(ForwardedError); }); }); diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts index 167a1bd66a..e2a8fb2984 100644 --- a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts @@ -15,6 +15,7 @@ */ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { CatalogService } from '@backstage/plugin-catalog-node'; +import { InputError, ForwardedError } from '@backstage/errors'; export const createRegisterCatalogEntitiesAction = ({ catalog, @@ -45,10 +46,7 @@ The dynamically generated, random UUID created to go along with the newly create to the caller. That UUID can be later used if you want to unregister or delete the Location entity and all of its children entities. -If an error occurred during processing, the 'locationID' field will be blank, and the 'error' field will contain -a message describing the error. - -Example invocation and the output from the invocation: +/eExample invocation and the output from the invocation: # Register a Location from a GitHub URL register-catalog-entities locationURL: https://github.com/redhat-ai-dev/model-catalog-example/blob/main/developer-model-service/catalog-info.yaml Output: { @@ -70,19 +68,11 @@ Example invocation and the output from the invocation: .string() .optional() .describe('The location ID generated by the registration'), - error: z - .string() - .optional() - .describe('Error message if creation fails'), }), }, action: async ({ input, credentials }) => { if (!input.locationURL || input.locationURL === '') { - return { - output: { - error: 'a location URL must be specified', - }, - }; + throw new InputError('a location URL must be specified'); } // Validate that the locationURL is a valid URL @@ -90,11 +80,9 @@ Example invocation and the output from the invocation: // eslint-disable-next-line no-new new URL(input.locationURL); } catch { - return { - output: { - error: 'location URL must be a valid URL string', - }, - }; + throw new InputError( + `locationURL "${input.locationURL}" an invalid URL`, + ); } try { const locReq = { @@ -108,15 +96,13 @@ Example invocation and the output from the invocation: return { output: { locationID: result.location.id, - error: undefined, }, }; } catch (error) { - return { - output: { - error: error.message, - }, - }; + throw new ForwardedError( + `catalog import of "${input.locationURL} failed"`, + error, + ); } }, }); diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts index abf03a2f36..225497c315 100644 --- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts @@ -16,6 +16,7 @@ import { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { ForwardedError } from '@backstage/errors'; describe('createUnregisterCatalogEntitiesAction', () => { it('should successfully unregister a catalog location with a valid locationId', async () => { @@ -34,9 +35,7 @@ describe('createUnregisterCatalogEntitiesAction', () => { input: { locationId: 'test-location-id-1234' }, }); - expect(result.output).toEqual({ - error: undefined, - }); + expect(result.output).toEqual({}); expect(mockCatalog.removeLocationById).toHaveBeenCalledWith( 'test-location-id-1234', expect.objectContaining({ @@ -45,7 +44,7 @@ describe('createUnregisterCatalogEntitiesAction', () => { ); }); - it('should return an error if locationId is not provided', async () => { + it('should throw an error if locationId is not provided', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); @@ -54,17 +53,15 @@ describe('createUnregisterCatalogEntitiesAction', () => { actionsRegistry: mockActionsRegistry, }); - const result = await mockActionsRegistry.invoke({ - id: 'test:unregister-catalog-entities', - input: { locationId: '' }, - }); - - expect(result.output).toEqual({ - error: 'a location ID must be specified', - }); + await expect( + mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: '' }, + }), + ).rejects.toThrow('a locationID must be specified'); }); - it('should return an error if catalog.removeLocationById throws an error', async () => { + it('should throw a ForwardedError if catalog.removeLocationById throws an error', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); @@ -78,13 +75,11 @@ describe('createUnregisterCatalogEntitiesAction', () => { actionsRegistry: mockActionsRegistry, }); - const result = await mockActionsRegistry.invoke({ - id: 'test:unregister-catalog-entities', - input: { locationId: 'test-location-id-1234' }, - }); - - expect(result.output).toEqual({ - error: errorMessage, - }); + await expect( + mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: 'test-location-id-1234' }, + }), + ).rejects.toThrow(ForwardedError); }); }); diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts index 2164e15f5e..a7ed9b6357 100644 --- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts @@ -15,6 +15,7 @@ */ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { CatalogService } from '@backstage/plugin-catalog-node'; +import { ForwardedError, InputError } from '@backstage/errors'; export const createUnregisterCatalogEntitiesAction = ({ catalog, @@ -38,8 +39,6 @@ where you supply the UUID for a Location entity link that allows for the removal the various Entities (Components, Systems, Resources, APIs, Users, and Groups) that were also created when the Location was imported. -If an error occurred during processing, the 'error' field will contain a message describing the error. - Example invocation and the output from the invocation: # Register a Location from a GitHub URL unregister-catalog-entities locationId: aaa-bbb-ccc-ddd @@ -54,40 +53,24 @@ Example invocation and the output from the invocation: `Location ID returned from the 'register-catalog-entities' call.`, ), }), - output: z => - z.object({ - error: z - .string() - .optional() - .describe('Error message if creation fails'), - }), + output: z => z.object({}), }, action: async ({ input, credentials }) => { if (!input.locationId || input.locationId === '') { - return { - output: { - error: 'a location ID must be specified', - }, - }; + throw new InputError('a locationID must be specified'); } try { await catalog.removeLocationById(input.locationId, { credentials, }); - - return { - output: { - error: undefined, - }, - }; } catch (error) { - return { - output: { - error: error.message, - }, - }; + throw new ForwardedError( + `catalog removal of "${input.locationId} failed"`, + error, + ); } + return { output: {} }; }, }); }; From cf7506ab84aef4254b5f24079064e677965a3bb4 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 10 Dec 2025 10:34:07 +0100 Subject: [PATCH 3/4] chore: refactor slightly to bring into similar patterns to the scaffolder action. Signed-off-by: benjdlambert --- .changeset/nine-symbols-shout.md | 2 +- ...reateRegisterCatalogEntitiesAction.test.ts | 35 +-- .../createRegisterCatalogEntitiesAction.ts | 90 +++--- ...ateUnregisterCatalogEntitiesAction.test.ts | 285 +++++++++++++++--- .../createUnregisterCatalogEntitiesAction.ts | 88 ++++-- 5 files changed, 339 insertions(+), 161 deletions(-) diff --git a/.changeset/nine-symbols-shout.md b/.changeset/nine-symbols-shout.md index 87fff3091b..21a7838a1f 100644 --- a/.changeset/nine-symbols-shout.md +++ b/.changeset/nine-symbols-shout.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': minor --- -Add MCP actions to 1) register existing components from a code respository, 2) unregister components with the location ID return from a prior register existing components invocation. +Added `ActionsRegistry` actions for `register-entity` and `unregister-entity` diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts index a85456ff42..31a89a31f3 100644 --- a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts @@ -40,15 +40,15 @@ describe('createRegisterCatalogEntitiesAction', () => { }); const result = await mockActionsRegistry.invoke({ - id: 'test:register-catalog-entities', + id: 'test:register-entity', input: { - locationURL: + locationUrl: 'https://github.com/example/repo/blob/main/catalog-info.yaml', }, }); expect(result.output).toEqual({ - locationID: mockLocationId, + locationId: mockLocationId, }); expect(mockCatalog.addLocation).toHaveBeenCalledWith( { @@ -61,7 +61,7 @@ describe('createRegisterCatalogEntitiesAction', () => { ); }); - it('should throw an error if locationURL is not provided', async () => { + it('should throw an error if locationUrl is not a valid URL', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockCatalog = catalogServiceMock(); @@ -72,27 +72,10 @@ describe('createRegisterCatalogEntitiesAction', () => { await expect( mockActionsRegistry.invoke({ - id: 'test:register-catalog-entities', - input: { locationURL: '' }, + id: 'test:register-entity', + input: { locationUrl: 'not-a-valid-url' }, }), - ).rejects.toThrow('a location URL must be specified'); - }); - - it('should throw an error if locationURL is not a valid URL', async () => { - const mockActionsRegistry = actionsRegistryServiceMock(); - const mockCatalog = catalogServiceMock(); - - createRegisterCatalogEntitiesAction({ - catalog: mockCatalog, - actionsRegistry: mockActionsRegistry, - }); - - await expect( - mockActionsRegistry.invoke({ - id: 'test:register-catalog-entities', - input: { locationURL: 'not-a-valid-url' }, - }), - ).rejects.toThrow('locationURL "not-a-valid-url" an invalid URL'); + ).rejects.toThrow('not-a-valid-url is an invalid URL'); }); it('should throw a ForwardedError if catalog.addLocation throws an error', async () => { @@ -111,9 +94,9 @@ describe('createRegisterCatalogEntitiesAction', () => { await expect( mockActionsRegistry.invoke({ - id: 'test:register-catalog-entities', + id: 'test:register-entity', input: { - locationURL: + locationUrl: 'https://github.com/example/repo/blob/main/catalog-info.yaml', }, }), diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts index e2a8fb2984..c521672ae1 100644 --- a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts @@ -14,8 +14,18 @@ * limitations under the License. */ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { InputError } from '@backstage/errors'; import { CatalogService } from '@backstage/plugin-catalog-node'; -import { InputError, ForwardedError } from '@backstage/errors'; + +const isValidUrl = (url: string) => { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch (error) { + return false; + } +}; export const createRegisterCatalogEntitiesAction = ({ catalog, @@ -25,85 +35,55 @@ export const createRegisterCatalogEntitiesAction = ({ actionsRegistry: ActionsRegistryService; }) => { actionsRegistry.register({ - name: 'register-catalog-entities', - title: 'Register Catalog Entities', + name: 'register-entity', + title: 'Register entity in the Catalog', attributes: { destructive: false, readOnly: false, - idempotent: true, + idempotent: false, }, - description: `Register entities defined remotely (create new Locations). + description: `Registers one or more entities in the Backstage catalog by creating a Location entity that points to a remote catalog-info.yaml file. -This tool is analogous to the "register existing components" function you see in the Backstage dashboard, -where you supply a URL link that allows for the downloading of a 'catalog-info.yaml' file that -defines the various Entities (Components, Systems, Resources, APIs, Users, and Groups) you want to -create or import into the Backstage catalog. +This action is similar to the "Register existing component" flow in the Backstage UI, where you provide a URL to a catalog-info.yaml file describing catalog entities such as Components, Systems, Resources, APIs, Users, and Groups. The action will create a new Location entity that references the provided file; all entities defined within that file will be imported into the catalog. -The action within the catalog results in the creation of a Location entity that is the parent of owner -of all the additional entities defined in the 'catalog-info.yaml' file. - -The dynamically generated, random UUID created to go along with the newly created Location entity is returned -to the caller. That UUID can be later used if you want to unregister or delete the Location entity and all -of its children entities. - -/eExample invocation and the output from the invocation: - # Register a Location from a GitHub URL - register-catalog-entities locationURL: https://github.com/redhat-ai-dev/model-catalog-example/blob/main/developer-model-service/catalog-info.yaml - Output: { - "locationID": "aaaa-bbbb-cccc-dddd" - } +A unique identifier (locationId) will be returned for the newly created Location. You can use this identifier in the future to unregister or delete the Location and all entities it owns. `, schema: { input: z => z.object({ - locationURL: z + locationUrl: z .string() .describe( - `URL reference to the catalog-info.yaml file that describes what entities to import.`, + `URL reference to the catalog-info.yaml file that describes the entity to register. For example: https://github.com/backstage/demo/blob/master/catalog-info.yaml`, ), }), output: z => z.object({ - locationID: z + locationId: z .string() - .optional() - .describe('The location ID generated by the registration'), + .describe('The ID of the entity that was registered'), }), }, action: async ({ input, credentials }) => { - if (!input.locationURL || input.locationURL === '') { - throw new InputError('a location URL must be specified'); + if (!isValidUrl(input.locationUrl)) { + throw new InputError(`${input.locationUrl} is an invalid URL`); } - // Validate that the locationURL is a valid URL - try { - // eslint-disable-next-line no-new - new URL(input.locationURL); - } catch { - throw new InputError( - `locationURL "${input.locationURL}" an invalid URL`, - ); - } - try { - const locReq = { + const result = await catalog.addLocation( + { type: 'url', - target: input.locationURL, - }; - const result = await catalog.addLocation(locReq, { + target: input.locationUrl, + }, + { credentials, - }); + }, + ); - return { - output: { - locationID: result.location.id, - }, - }; - } catch (error) { - throw new ForwardedError( - `catalog import of "${input.locationURL} failed"`, - error, - ); - } + return { + output: { + locationId: result.location.id, + }, + }; }, }); }; diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts index 225497c315..492a427630 100644 --- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts @@ -16,70 +16,259 @@ import { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; -import { ForwardedError } from '@backstage/errors'; describe('createUnregisterCatalogEntitiesAction', () => { - it('should successfully unregister a catalog location with a valid locationId', async () => { - const mockActionsRegistry = actionsRegistryServiceMock(); - const mockCatalog = catalogServiceMock(); + describe('with locationId', () => { + it('should successfully unregister a catalog location with a valid locationId', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); - mockCatalog.removeLocationById = jest.fn().mockResolvedValue(undefined); + mockCatalog.removeLocationById = jest.fn().mockResolvedValue(undefined); - createUnregisterCatalogEntitiesAction({ - catalog: mockCatalog, - actionsRegistry: mockActionsRegistry, + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { type: { locationId: 'test-location-id-1234' } }, + }); + + expect(result.output).toEqual({}); + expect(mockCatalog.removeLocationById).toHaveBeenCalledWith( + 'test-location-id-1234', + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); }); - const result = await mockActionsRegistry.invoke({ - id: 'test:unregister-catalog-entities', - input: { locationId: 'test-location-id-1234' }, - }); + it('should throw an error if catalog.removeLocationById throws an error', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); - expect(result.output).toEqual({}); - expect(mockCatalog.removeLocationById).toHaveBeenCalledWith( - 'test-location-id-1234', - expect.objectContaining({ - credentials: expect.any(Object), - }), - ); + const errorMessage = 'Failed to remove location'; + mockCatalog.removeLocationById = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { type: { locationId: 'test-location-id-1234' } }, + }), + ).rejects.toThrow(errorMessage); + }); }); - it('should throw an error if locationId is not provided', async () => { - const mockActionsRegistry = actionsRegistryServiceMock(); - const mockCatalog = catalogServiceMock(); + describe('with locationUrl', () => { + it('should successfully unregister a catalog location with a valid locationUrl', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); - createUnregisterCatalogEntitiesAction({ - catalog: mockCatalog, - actionsRegistry: mockActionsRegistry, + const locationUrl = + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml'; + + mockCatalog.getLocations = jest.fn().mockResolvedValue({ + items: [ + { id: 'location-id-1', target: locationUrl }, + { id: 'location-id-2', target: 'https://other-url.com/catalog.yaml' }, + ], + }); + mockCatalog.removeLocationById = jest.fn().mockResolvedValue(undefined); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { type: { locationUrl } }, + }); + + expect(result.output).toEqual({}); + expect(mockCatalog.getLocations).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); + expect(mockCatalog.removeLocationById).toHaveBeenCalledTimes(1); + expect(mockCatalog.removeLocationById).toHaveBeenCalledWith( + 'location-id-1', + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); }); - await expect( - mockActionsRegistry.invoke({ - id: 'test:unregister-catalog-entities', - input: { locationId: '' }, - }), - ).rejects.toThrow('a locationID must be specified'); - }); + it('should match locationUrl case-insensitively', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); - it('should throw a ForwardedError if catalog.removeLocationById throws an error', async () => { - const mockActionsRegistry = actionsRegistryServiceMock(); - const mockCatalog = catalogServiceMock(); + mockCatalog.getLocations = jest.fn().mockResolvedValue({ + items: [ + { + id: 'location-id-1', + target: + 'https://github.com/Backstage/Demo/blob/master/catalog-info.yaml', + }, + ], + }); + mockCatalog.removeLocationById = jest.fn().mockResolvedValue(undefined); - const errorMessage = 'Failed to remove location'; - mockCatalog.removeLocationById = jest - .fn() - .mockRejectedValue(new Error(errorMessage)); + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); - createUnregisterCatalogEntitiesAction({ - catalog: mockCatalog, - actionsRegistry: mockActionsRegistry, + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { + type: { + locationUrl: + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml', + }, + }, + }); + + expect(result.output).toEqual({}); + expect(mockCatalog.removeLocationById).toHaveBeenCalledTimes(1); + expect(mockCatalog.removeLocationById).toHaveBeenCalledWith( + 'location-id-1', + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); }); - await expect( - mockActionsRegistry.invoke({ - id: 'test:unregister-catalog-entities', - input: { locationId: 'test-location-id-1234' }, - }), - ).rejects.toThrow(ForwardedError); + it('should unregister multiple locations with the same URL', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const locationUrl = + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml'; + + mockCatalog.getLocations = jest.fn().mockResolvedValue({ + items: [ + { id: 'location-id-1', target: locationUrl }, + { id: 'location-id-2', target: locationUrl }, + ], + }); + mockCatalog.removeLocationById = jest.fn().mockResolvedValue(undefined); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { type: { locationUrl } }, + }); + + expect(result.output).toEqual({}); + expect(mockCatalog.removeLocationById).toHaveBeenCalledTimes(2); + expect(mockCatalog.removeLocationById).toHaveBeenNthCalledWith( + 1, + 'location-id-1', + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); + expect(mockCatalog.removeLocationById).toHaveBeenNthCalledWith( + 2, + 'location-id-2', + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); + }); + + it('should throw NotFoundError if no location matches the URL', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + mockCatalog.getLocations = jest.fn().mockResolvedValue({ + items: [ + { id: 'location-id-1', target: 'https://other-url.com/catalog.yaml' }, + ], + }); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { + type: { + locationUrl: + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml', + }, + }, + }), + ).rejects.toThrow(/NotFoundError/); + }); + + it('should throw NotFoundError with descriptive message when location not found', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const locationUrl = + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml'; + + mockCatalog.getLocations = jest.fn().mockResolvedValue({ + items: [], + }); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { type: { locationUrl } }, + }), + ).rejects.toThrow(`Location with URL ${locationUrl} not found`); + }); + + it('should throw an error if catalog.getLocations throws an error', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const errorMessage = 'Failed to get locations'; + mockCatalog.getLocations = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { + type: { + locationUrl: + 'https://github.com/backstage/demo/blob/master/catalog-info.yaml', + }, + }, + }), + ).rejects.toThrow(errorMessage); + }); }); }); diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts index a7ed9b6357..831cc7410a 100644 --- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts @@ -14,8 +14,8 @@ * limitations under the License. */ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { NotFoundError } from '@backstage/errors'; import { CatalogService } from '@backstage/plugin-catalog-node'; -import { ForwardedError, InputError } from '@backstage/errors'; export const createUnregisterCatalogEntitiesAction = ({ catalog, @@ -25,51 +25,77 @@ export const createUnregisterCatalogEntitiesAction = ({ actionsRegistry: ActionsRegistryService; }) => { actionsRegistry.register({ - name: 'unregister-catalog-entities', - title: 'Unregister Catalog Entities', + name: 'unregister-entity', + title: 'Unregister entity from the Catalog', attributes: { destructive: true, readOnly: false, idempotent: true, }, - description: `Unregister a Location and the entities it ownws. + description: `Unregisters a Location entity and all entities it owns from the Backstage catalog. -This tool is analogous to the "unregister location" function you see in the Backstage dashboard, -where you supply the UUID for a Location entity link that allows for the removal of the Location and -the various Entities (Components, Systems, Resources, APIs, Users, and Groups) that were also created -when the Location was imported. +This action is similar to the "Unregister location" function in the Backstage UI, where you provide the unique identifier (locationId) of a Location entity. The action will remove the specified Location from the catalog as well as all entities that were created when the Location was imported. -Example invocation and the output from the invocation: - # Register a Location from a GitHub URL - unregister-catalog-entities locationId: aaa-bbb-ccc-ddd - Output: {} +Once completed, all entities associated with the Location will be deleted from the catalog. `, schema: { input: z => - z.object({ - locationId: z - .string() - .describe( - `Location ID returned from the 'register-catalog-entities' call.`, - ), - }), + z + .object({ + type: z.union([ + z.object({ + locationId: z + .string() + .describe(`Location ID of the Entity to unregister`), + }), + z.object({ + locationUrl: z + .string() + .describe( + `URL of the catalog-info.yaml file to unregister for example: https://github.com/backstage/demo/blob/master/catalog-info.yaml`, + ), + }), + ]), + }) + .describe( + 'The type to the unregister-entity action. Either locationId or locationUrl must be provided.', + ), output: z => z.object({}), }, - action: async ({ input, credentials }) => { - if (!input.locationId || input.locationId === '') { - throw new InputError('a locationID must be specified'); - } - - try { - await catalog.removeLocationById(input.locationId, { + action: async ({ input: { type }, credentials }) => { + if ('locationId' in type) { + await catalog.removeLocationById(type.locationId, { credentials, }); - } catch (error) { - throw new ForwardedError( - `catalog removal of "${input.locationId} failed"`, - error, - ); + } else { + const locations = await catalog + .getLocations( + {}, + { + credentials, + }, + ) + .then(response => + response.items.filter( + location => + location.target.toLowerCase() === + type.locationUrl.toLowerCase(), + ), + ); + + if (locations.length === 0) { + throw new NotFoundError( + `Location with URL ${type.locationUrl} not found`, + ); + } + + for (const location of locations) { + await catalog.removeLocationById(location.id, { + credentials, + }); + } } + return { output: {} }; }, }); From a67c888faa288773fa9356746d8a8f487006b854 Mon Sep 17 00:00:00 2001 From: gabemontero Date: Mon, 15 Dec 2025 10:02:37 -0500 Subject: [PATCH 4/4] update the 'unregister-entity' description to include use of URL to identify location Signed-off-by: gabemontero --- .../src/actions/createUnregisterCatalogEntitiesAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts index 831cc7410a..542fc79ac5 100644 --- a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts @@ -34,7 +34,7 @@ export const createUnregisterCatalogEntitiesAction = ({ }, description: `Unregisters a Location entity and all entities it owns from the Backstage catalog. -This action is similar to the "Unregister location" function in the Backstage UI, where you provide the unique identifier (locationId) of a Location entity. The action will remove the specified Location from the catalog as well as all entities that were created when the Location was imported. +This action is similar to the "Unregister location" function in the Backstage UI, where you provide the unique identifier (locationId) of a Location entity. Alternatively, you can provide the URL used to register the location. The action will remove the specified Location from the catalog as well as all entities that were created when the Location was imported. Once completed, all entities associated with the Location will be deleted from the catalog. `,