From ccc20cf1bc6ad686c8b6f64d14782db367df3e2a Mon Sep 17 00:00:00 2001 From: Stephanie Cao Date: Mon, 2 Mar 2026 04:41:42 -0500 Subject: [PATCH] feat(scaffolder): Create scaffolder mcp action to dry run scaffolder template (#32914) * dry run action Signed-off-by: Stephanie * add tests Signed-off-by: Stephanie * add changeset Signed-off-by: Stephanie * adjust review comments Signed-off-by: Stephanie * update error handling Signed-off-by: Stephanie * remove unnecessary import Signed-off-by: Stephanie * replace ScaffolderClient with scaffolderServiceRef Signed-off-by: benjdlambert --------- Signed-off-by: Stephanie Signed-off-by: benjdlambert Co-authored-by: benjdlambert --- .changeset/two-lies-leave.md | 5 + .../src/ScaffolderPlugin.ts | 16 +- .../createDryRunTemplateAction.test.ts | 267 ++++++++++++++++++ .../src/actions/createDryRunTemplateAction.ts | 156 ++++++++++ .../scaffolder-backend/src/actions/index.ts | 25 ++ 5 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 .changeset/two-lies-leave.md create mode 100644 plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.test.ts create mode 100644 plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.ts create mode 100644 plugins/scaffolder-backend/src/actions/index.ts diff --git a/.changeset/two-lies-leave.md b/.changeset/two-lies-leave.md new file mode 100644 index 0000000000..2d83758940 --- /dev/null +++ b/.changeset/two-lies-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +create scaffolder MCP action to dry run a provided scaffolder template diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 39bab85c7e..6fdbb3e269 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -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, diff --git a/plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.test.ts b/plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.test.ts new file mode 100644 index 0000000000..cdd69d1541 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.test.ts @@ -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; + 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 }, + ]); + }); +}); diff --git a/plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.ts b/plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.ts new file mode 100644 index 0000000000..a96e52fb4d --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createDryRunTemplateAction.ts @@ -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('', '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, + }, + }; + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts new file mode 100644 index 0000000000..8539ae5b78 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -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); +};