feat(scaffolder): Create scaffolder mcp action to dry run scaffolder template (#32914)
* dry run action Signed-off-by: Stephanie <yangcao@redhat.com> * add tests Signed-off-by: Stephanie <yangcao@redhat.com> * add changeset Signed-off-by: Stephanie <yangcao@redhat.com> * adjust review comments Signed-off-by: Stephanie <yangcao@redhat.com> * update error handling Signed-off-by: Stephanie <yangcao@redhat.com> * remove unnecessary import Signed-off-by: Stephanie <yangcao@redhat.com> * replace ScaffolderClient with scaffolderServiceRef Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: Stephanie <yangcao@redhat.com> Signed-off-by: benjdlambert <ben@blam.sh> Co-authored-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
create scaffolder MCP action to dry run a provided scaffolder template
|
||||
@@ -23,6 +23,7 @@ import { catalogServiceRef } from '@backstage/plugin-catalog-node';
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
import {
|
||||
scaffolderActionsExtensionPoint,
|
||||
scaffolderServiceRef,
|
||||
TaskBroker,
|
||||
TemplateAction,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
@@ -59,7 +60,11 @@ import {
|
||||
convertFiltersToRecord,
|
||||
convertGlobalsToRecord,
|
||||
} from './util/templating';
|
||||
import { actionsServiceRef } from '@backstage/backend-plugin-api/alpha';
|
||||
import {
|
||||
actionsServiceRef,
|
||||
actionsRegistryServiceRef,
|
||||
} from '@backstage/backend-plugin-api/alpha';
|
||||
import { createScaffolderActions } from './actions';
|
||||
|
||||
/**
|
||||
* Scaffolder plugin
|
||||
@@ -144,6 +149,8 @@ export const scaffolderPlugin = createBackendPlugin({
|
||||
catalog: catalogServiceRef,
|
||||
events: eventsServiceRef,
|
||||
actionsRegistry: actionsServiceRef,
|
||||
actionsRegistryService: actionsRegistryServiceRef,
|
||||
scaffolderService: scaffolderServiceRef,
|
||||
},
|
||||
async init({
|
||||
logger,
|
||||
@@ -159,6 +166,8 @@ export const scaffolderPlugin = createBackendPlugin({
|
||||
events,
|
||||
auditor,
|
||||
actionsRegistry,
|
||||
actionsRegistryService,
|
||||
scaffolderService,
|
||||
}) {
|
||||
const log = loggerToWinstonLogger(logger);
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
@@ -211,6 +220,11 @@ export const scaffolderPlugin = createBackendPlugin({
|
||||
`Starting scaffolder with the following actions enabled ${actionIds}`,
|
||||
);
|
||||
|
||||
createScaffolderActions({
|
||||
actionsRegistry: actionsRegistryService,
|
||||
scaffolderService,
|
||||
});
|
||||
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright 2026 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 { createDryRunTemplateAction } from './createDryRunTemplateAction';
|
||||
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
|
||||
|
||||
type DryRunTemplateOutput = {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
errors?: string[];
|
||||
log?: Array<{ message: string; stepId?: string; status?: string }>;
|
||||
output?: Record<string, unknown>;
|
||||
steps?: Array<{ id: string; name: string; action: string }>;
|
||||
};
|
||||
|
||||
const validTemplateYaml = `
|
||||
apiVersion: scaffolder.backstage.io/v1beta3
|
||||
kind: Template
|
||||
metadata:
|
||||
name: test-template
|
||||
namespace: default
|
||||
title: Test Template
|
||||
spec:
|
||||
type: service
|
||||
steps:
|
||||
- id: step-1
|
||||
name: Step One
|
||||
action: debug:log
|
||||
input:
|
||||
message: hello
|
||||
`;
|
||||
|
||||
const invalidYaml = `
|
||||
apiVersion: scaffolder.backstage.io/v1beta3
|
||||
kind: Template
|
||||
metadata:
|
||||
name: test
|
||||
invalid: yaml: syntax: error
|
||||
spec:
|
||||
type: service
|
||||
steps: [
|
||||
`;
|
||||
|
||||
describe('createDryRunTemplateAction', () => {
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return success with logs when dry-run succeeds', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const dryRunResult = {
|
||||
log: [
|
||||
{
|
||||
body: {
|
||||
message: 'Step completed',
|
||||
stepId: 'step-1',
|
||||
status: 'completed' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
output: { result: 'ok' },
|
||||
steps: [{ id: 'step-1', name: 'Step One', action: 'debug:log' }],
|
||||
directoryContents: [],
|
||||
};
|
||||
|
||||
mockScaffolderService.dryRun.mockResolvedValue(dryRunResult);
|
||||
|
||||
createDryRunTemplateAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:dry-run-template',
|
||||
input: { templateYaml: validTemplateYaml },
|
||||
});
|
||||
|
||||
expect(result.output).toEqual({
|
||||
valid: true,
|
||||
message: 'Template validation successful',
|
||||
log: [
|
||||
{
|
||||
message: 'Step completed',
|
||||
stepId: 'step-1',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
output: { result: 'ok' },
|
||||
steps: [{ id: 'step-1', name: 'Step One', action: 'debug:log' }],
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.dryRun).toHaveBeenCalledWith(
|
||||
{
|
||||
template: expect.objectContaining({
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: expect.objectContaining({
|
||||
name: 'test-template',
|
||||
}),
|
||||
}),
|
||||
values: {},
|
||||
directoryContents: [],
|
||||
},
|
||||
{ credentials: expect.anything() },
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass values and files to the scaffolder service', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
mockScaffolderService.dryRun.mockResolvedValue({
|
||||
log: [],
|
||||
output: {},
|
||||
steps: [],
|
||||
directoryContents: [],
|
||||
});
|
||||
|
||||
createDryRunTemplateAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const values = { name: 'my-app' };
|
||||
const files = [
|
||||
{
|
||||
path: 'README.md',
|
||||
content: 'hello',
|
||||
},
|
||||
];
|
||||
|
||||
await mockActionsRegistry.invoke({
|
||||
id: 'test:dry-run-template',
|
||||
input: {
|
||||
templateYaml: validTemplateYaml,
|
||||
values,
|
||||
files,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.dryRun).toHaveBeenCalledWith(
|
||||
{
|
||||
template: expect.any(Object),
|
||||
values,
|
||||
directoryContents: [
|
||||
{
|
||||
path: 'README.md',
|
||||
base64Content: Buffer.from('hello').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ credentials: expect.anything() },
|
||||
);
|
||||
});
|
||||
|
||||
it('should return validation errors when YAML is invalid', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
|
||||
createDryRunTemplateAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:dry-run-template',
|
||||
input: { templateYaml: invalidYaml },
|
||||
});
|
||||
|
||||
expect(result.output).toEqual({
|
||||
valid: false,
|
||||
message: 'Failed to parse YAML template',
|
||||
errors: expect.arrayContaining([
|
||||
expect.stringContaining('YAML parsing error'),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.dryRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should propagate errors from the scaffolder service', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
|
||||
mockScaffolderService.dryRun.mockRejectedValue(
|
||||
new Error('Authentication error'),
|
||||
);
|
||||
|
||||
createDryRunTemplateAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mockActionsRegistry.invoke({
|
||||
id: 'test:dry-run-template',
|
||||
input: { templateYaml: validTemplateYaml },
|
||||
}),
|
||||
).rejects.toThrow('Authentication error');
|
||||
});
|
||||
|
||||
it('should use default empty values and files when not provided', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
mockScaffolderService.dryRun.mockResolvedValue({
|
||||
log: [],
|
||||
output: {},
|
||||
steps: [],
|
||||
directoryContents: [],
|
||||
});
|
||||
|
||||
createDryRunTemplateAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
await mockActionsRegistry.invoke({
|
||||
id: 'test:dry-run-template',
|
||||
input: { templateYaml: validTemplateYaml },
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.dryRun).toHaveBeenCalledWith(
|
||||
{
|
||||
template: expect.any(Object),
|
||||
values: {},
|
||||
directoryContents: [],
|
||||
},
|
||||
{ credentials: expect.anything() },
|
||||
);
|
||||
});
|
||||
|
||||
it('should map log entries from body fields', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
mockScaffolderService.dryRun.mockResolvedValue({
|
||||
log: [{ body: { message: 'Plain log message' } }],
|
||||
output: {},
|
||||
steps: [],
|
||||
directoryContents: [],
|
||||
});
|
||||
|
||||
createDryRunTemplateAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:dry-run-template',
|
||||
input: { templateYaml: validTemplateYaml },
|
||||
});
|
||||
|
||||
const output = result.output as DryRunTemplateOutput;
|
||||
expect(output.valid).toBe(true);
|
||||
expect(output.log).toEqual([
|
||||
{ message: 'Plain log message', stepId: undefined, status: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright 2026 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 { ScaffolderService } from '@backstage/plugin-scaffolder-node';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const MAX_CONTENT_SIZE = 64 * 1024;
|
||||
|
||||
function base64EncodeContent(content: string): string {
|
||||
if (content.length > MAX_CONTENT_SIZE) {
|
||||
return Buffer.from('<file too large>', 'utf8').toString('base64');
|
||||
}
|
||||
return Buffer.from(content, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
export const createDryRunTemplateAction = ({
|
||||
actionsRegistry,
|
||||
scaffolderService,
|
||||
}: {
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
scaffolderService: ScaffolderService;
|
||||
}) => {
|
||||
actionsRegistry.register({
|
||||
name: 'dry-run-template',
|
||||
title: 'Dry Run Scaffolder Template',
|
||||
attributes: {
|
||||
destructive: false,
|
||||
readOnly: true,
|
||||
idempotent: true,
|
||||
},
|
||||
description:
|
||||
'Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.',
|
||||
schema: {
|
||||
input: z =>
|
||||
z.object({
|
||||
templateYaml: z
|
||||
.string()
|
||||
.describe(
|
||||
'The YAML content of the scaffolder template to validate',
|
||||
),
|
||||
values: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe('Input values for template parameters'),
|
||||
files: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe('File path relative to template root'),
|
||||
content: z.string().describe('File content'),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Files required for running the template'),
|
||||
}),
|
||||
output: z =>
|
||||
z.object({
|
||||
valid: z.boolean().describe('Whether the template is valid'),
|
||||
message: z
|
||||
.string()
|
||||
.describe('Success message or validation error details'),
|
||||
errors: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('List of validation errors'),
|
||||
log: z
|
||||
.array(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
stepId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Execution log from dry-run'),
|
||||
output: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe('Template output values'),
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
action: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Parsed template steps'),
|
||||
}),
|
||||
},
|
||||
action: async ({ input, credentials }) => {
|
||||
const { templateYaml, values = {}, files = [] } = input;
|
||||
|
||||
let template;
|
||||
try {
|
||||
template = yaml.parse(templateYaml);
|
||||
} catch (parseError) {
|
||||
const yamlError = parseError as yaml.YAMLParseError;
|
||||
return {
|
||||
output: {
|
||||
valid: false,
|
||||
message: 'Failed to parse YAML template',
|
||||
errors: [
|
||||
`YAML parsing error: ${yamlError.message}`,
|
||||
yamlError.linePos
|
||||
? `At line ${yamlError.linePos[0].line}, column ${yamlError.linePos[0].col}`
|
||||
: '',
|
||||
].filter(Boolean),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await scaffolderService.dryRun(
|
||||
{
|
||||
template,
|
||||
values: values as JsonObject,
|
||||
directoryContents: files.map(file => ({
|
||||
path: file.path,
|
||||
base64Content: base64EncodeContent(file.content),
|
||||
})),
|
||||
},
|
||||
{ credentials },
|
||||
);
|
||||
|
||||
return {
|
||||
output: {
|
||||
valid: true,
|
||||
message: 'Template validation successful',
|
||||
log: result.log?.map(entry => ({
|
||||
message: entry.body.message,
|
||||
stepId: entry.body.stepId,
|
||||
status: entry.body.status,
|
||||
})),
|
||||
output: result.output,
|
||||
steps: result.steps,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 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 { ScaffolderService } from '@backstage/plugin-scaffolder-node';
|
||||
import { createDryRunTemplateAction } from './createDryRunTemplateAction';
|
||||
|
||||
export const createScaffolderActions = (options: {
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
scaffolderService: ScaffolderService;
|
||||
}) => {
|
||||
createDryRunTemplateAction(options);
|
||||
};
|
||||
Reference in New Issue
Block a user