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 <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-09-25 15:33:33 +03:00
parent 9e76ddb8f9
commit 77516c5afb
5 changed files with 421 additions and 2 deletions
+9
View File
@@ -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.
@@ -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 },
);
});
});
@@ -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}`],
},
};
}
},
});
};
@@ -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);
};
@@ -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,
});