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:
Stephanie Cao
2026-03-02 04:41:42 -05:00
committed by GitHub
parent 889aee97a6
commit ccc20cf1bc
5 changed files with 468 additions and 1 deletions
+5
View File
@@ -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);
};