diff --git a/.changeset/nine-symbols-shout.md b/.changeset/nine-symbols-shout.md new file mode 100644 index 0000000000..21a7838a1f --- /dev/null +++ b/.changeset/nine-symbols-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +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 new file mode 100644 index 0000000000..31a89a31f3 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts @@ -0,0 +1,105 @@ +/* + * 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'; +import { ForwardedError } from '@backstage/errors'; + +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-entity', + input: { + locationUrl: + 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + }); + + expect(result.output).toEqual({ + locationId: mockLocationId, + }); + 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 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-entity', + input: { locationUrl: 'not-a-valid-url' }, + }), + ).rejects.toThrow('not-a-valid-url is an invalid URL'); + }); + + it('should throw a ForwardedError 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, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:register-entity', + 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 new file mode 100644 index 0000000000..c521672ae1 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts @@ -0,0 +1,89 @@ +/* + * 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 { InputError } from '@backstage/errors'; +import { CatalogService } from '@backstage/plugin-catalog-node'; + +const isValidUrl = (url: string) => { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch (error) { + return false; + } +}; + +export const createRegisterCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'register-entity', + title: 'Register entity in the Catalog', + attributes: { + destructive: false, + readOnly: false, + idempotent: false, + }, + 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 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. + +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 + .string() + .describe( + `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 + .string() + .describe('The ID of the entity that was registered'), + }), + }, + action: async ({ input, credentials }) => { + if (!isValidUrl(input.locationUrl)) { + throw new InputError(`${input.locationUrl} is an invalid URL`); + } + + const result = await catalog.addLocation( + { + type: 'url', + target: input.locationUrl, + }, + { + credentials, + }, + ); + + 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 new file mode 100644 index 0000000000..492a427630 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts @@ -0,0 +1,274 @@ +/* + * 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', () => { + 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); + + 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), + }), + ); + }); + + it('should throw 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, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:unregister-entity', + input: { type: { locationId: 'test-location-id-1234' } }, + }), + ).rejects.toThrow(errorMessage); + }); + }); + + describe('with locationUrl', () => { + it('should successfully unregister a catalog location with a valid locationUrl', 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: '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), + }), + ); + }); + + it('should match locationUrl case-insensitively', 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); + + 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), + }), + ); + }); + + 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 new file mode 100644 index 0000000000..542fc79ac5 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts @@ -0,0 +1,102 @@ +/* + * 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 { NotFoundError } from '@backstage/errors'; +import { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createUnregisterCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'unregister-entity', + title: 'Unregister entity from the Catalog', + attributes: { + destructive: true, + readOnly: false, + idempotent: true, + }, + 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. 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. +`, + schema: { + input: z => + 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: { type }, credentials }) => { + if ('locationId' in type) { + await catalog.removeLocationById(type.locationId, { + credentials, + }); + } 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: {} }; + }, + }); +}; 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); };