From 77516c5afb6f2305eb26db901001702ae62b7b6b Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 25 Sep 2025 15:33:33 +0300 Subject: [PATCH] feat(catalog): add entity validation action one of the most requested feature as in validating your catalog-info files locally would be possible with this action using the backstage mcp server. additionally refactored the actions so that no need to add them individually in the plugin. Signed-off-by: Hellgren Heikki --- .changeset/red-dodos-work.md | 9 + .../createValidateEntityAction.test.ts | 284 ++++++++++++++++++ .../src/actions/createValidateEntityAction.ts | 99 ++++++ plugins/catalog-backend/src/actions/index.ts | 27 ++ .../src/service/CatalogPlugin.ts | 4 +- 5 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 .changeset/red-dodos-work.md create mode 100644 plugins/catalog-backend/src/actions/createValidateEntityAction.test.ts create mode 100644 plugins/catalog-backend/src/actions/createValidateEntityAction.ts create mode 100644 plugins/catalog-backend/src/actions/index.ts diff --git a/.changeset/red-dodos-work.md b/.changeset/red-dodos-work.md new file mode 100644 index 0000000000..1bc92d2d59 --- /dev/null +++ b/.changeset/red-dodos-work.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Added new `catalog:validate-entity` action to actions registry. + +This action can be used to validate entities against the software catalog. +This is useful for validating `catalog-info.yaml` file changes locally using the +Backstage MCP server. diff --git a/plugins/catalog-backend/src/actions/createValidateEntityAction.test.ts b/plugins/catalog-backend/src/actions/createValidateEntityAction.test.ts new file mode 100644 index 0000000000..19ec699cb7 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createValidateEntityAction.test.ts @@ -0,0 +1,284 @@ +/* + * 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 { createValidateEntityAction } from './createValidateEntityAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; + +describe('createValidateEntityAction', () => { + const validEntityYaml = ` +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: test-component + namespace: default +spec: + type: service + lifecycle: production +`; + + const invalidYaml = ` +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: test-component + namespace: default +spec: + type: service + lifecycle: production + invalid: yaml: syntax: error +`; + + const validEntityObject = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + namespace: 'default', + }, + spec: { + type: 'service', + lifecycle: 'production', + }, + }; + + it('should validate a valid entity YAML and return success', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + // Mock validateEntity to return valid response + mockCatalog.validateEntity.mockResolvedValue({ + valid: true, + }); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { entity: validEntityYaml }, + }); + + expect(result.output).toEqual({ + isValid: true, + isValidYaml: true, + errors: [], + entity: validEntityObject, + }); + + expect(mockCatalog.validateEntity).toHaveBeenCalledWith( + validEntityObject, + 'url:https://localhost/entity-validator', + { credentials: expect.any(Object) }, + ); + }); + + it('should validate entity with custom location', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + mockCatalog.validateEntity.mockResolvedValue({ + valid: true, + }); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const customLocation = 'https://github.com/example/repo/catalog-info.yaml'; + + await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { + entity: validEntityYaml, + location: customLocation, + }, + }); + + expect(mockCatalog.validateEntity).toHaveBeenCalledWith( + validEntityObject, + customLocation, + { credentials: expect.any(Object) }, + ); + }); + + it('should return validation errors when entity is invalid', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + const validationErrors = [ + { name: 'ValidationError', message: 'Missing required field: spec.type' }, + { name: 'ValidationError', message: 'Invalid lifecycle value' }, + ]; + + mockCatalog.validateEntity.mockResolvedValue({ + valid: false, + errors: validationErrors, + }); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { entity: validEntityYaml }, + }); + + expect(result.output).toEqual({ + isValid: false, + isValidYaml: true, + errors: ['Missing required field: spec.type', 'Invalid lifecycle value'], + entity: undefined, + }); + }); + + it('should handle invalid YAML syntax', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { entity: invalidYaml }, + }); + + expect(result.output).toEqual({ + isValid: false, + isValidYaml: false, + errors: [expect.stringContaining('YAML parsing error')], + }); + + // validateEntity should not be called if YAML parsing fails + expect(mockCatalog.validateEntity).not.toHaveBeenCalled(); + }); + + it('should handle catalog service errors gracefully', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + const catalogError = new Error('Catalog service unavailable'); + mockCatalog.validateEntity.mockRejectedValue(catalogError); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { entity: validEntityYaml }, + }); + + expect(result.output).toEqual({ + isValid: false, + isValidYaml: false, + errors: ['Validation error: Catalog service unavailable'], + }); + }); + + it('should handle empty YAML input', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { entity: '' }, + }); + + expect(result.output).toEqual({ + isValid: false, + isValidYaml: false, + errors: [expect.stringContaining('Validation error')], + }); + }); + + it('should handle malformed YAML with special characters', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const malformedYaml = ` +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: test-component + annotations: + invalid: "unclosed quote +spec: + type: service +`; + + const result = await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { entity: malformedYaml }, + }); + + expect(result.output).toEqual({ + isValid: false, + isValidYaml: false, + errors: [expect.stringContaining('YAML parsing error')], + }); + }); + + it('should pass credentials to catalog service', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock.mock(); + + mockCatalog.validateEntity.mockResolvedValue({ + valid: true, + }); + + createValidateEntityAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const mockCredentials: BackstageCredentials = { + $$type: '@backstage/BackstageCredentials', + principal: { type: 'user', userEntityRef: 'user:default/test' }, + }; + + await mockActionsRegistry.invoke({ + id: 'test:validate-entity', + input: { entity: validEntityYaml }, + credentials: mockCredentials, + }); + + expect(mockCatalog.validateEntity).toHaveBeenCalledWith( + validEntityObject, + 'url:https://localhost/entity-validator', + { credentials: mockCredentials }, + ); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createValidateEntityAction.ts b/plugins/catalog-backend/src/actions/createValidateEntityAction.ts new file mode 100644 index 0000000000..a77a932e25 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createValidateEntityAction.ts @@ -0,0 +1,99 @@ +/* + * 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 yaml from 'yaml'; +import { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createValidateEntityAction = (options: { + actionsRegistry: ActionsRegistryService; + catalog: CatalogService; +}) => { + const { actionsRegistry, catalog } = options; + actionsRegistry.register({ + name: 'validate-entity', + title: 'Validate Catalog Entity', + description: ` + This action can be used to validate catalog-info.yaml file contents meant to be used with the software catalog. + It checks both that the YAML syntax is correct, and that the entity content is valid according to the catalog's rules. + `, + attributes: { + readOnly: true, + idempotent: true, + destructive: false, + }, + schema: { + input: z => + z.object({ + entity: z.string().describe('Entity YAML content to validate'), + location: z + .string() + .url() + .optional() + .describe('Location to validate'), + }), + output: z => + z.object({ + isValid: z.boolean().describe('Whether the entity is valid'), + isValidYaml: z.boolean().describe('Whether the YAML syntax is valid'), + errors: z.array(z.string()).describe('Array of validation errors'), + entity: z + .record(z.any()) + .optional() + .describe('Parsed entity object if valid'), + }), + }, + action: async ({ input, credentials }) => { + const { entity: content, location } = input; + try { + let entity; + try { + entity = yaml.parse(content); + } catch (yamlError) { + return { + output: { + isValid: false, + isValidYaml: false, + errors: [`YAML parsing error: ${yamlError.message}`], + }, + }; + } + + const resp = await catalog.validateEntity( + entity, + location ?? 'url:https://localhost/entity-validator', + { credentials }, + ); + + return { + output: { + isValid: resp.valid, + isValidYaml: true, + errors: resp.valid ? [] : resp.errors.map(e => e.message), + entity: resp.valid ? entity : undefined, + }, + }; + } catch (error) { + return { + output: { + isValid: false, + isValidYaml: false, + errors: [`Validation error: ${error.message}`], + }, + }; + } + }, + }); +}; diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts new file mode 100644 index 0000000000..c53f956f62 --- /dev/null +++ b/plugins/catalog-backend/src/actions/index.ts @@ -0,0 +1,27 @@ +/* + * 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'; +import { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts'; +import { createValidateEntityAction } from './createValidateEntityAction.ts'; + +export const createCatalogActions = (options: { + actionsRegistry: ActionsRegistryService; + catalog: CatalogService; +}) => { + createGetCatalogEntityAction(options); + createValidateEntityAction(options); +}; diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 24147e3cc5..cfe05712d9 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -45,7 +45,7 @@ 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'; +import { createCatalogActions } from '../actions'; class CatalogLocationsExtensionPointImpl implements CatalogLocationsExtensionPoint @@ -320,7 +320,7 @@ export const catalogPlugin = createBackendPlugin({ httpRouter.use(router); - createGetCatalogEntityAction({ + createCatalogActions({ catalog, actionsRegistry, });