Merge pull request #31279 from drodil/validate_entity_action

feat(catalog): add entity validation action
This commit is contained in:
Ben Lambert
2025-09-26 13:35:57 +02:00
committed by GitHub
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,
});