diff --git a/.changeset/cold-bikes-beam.md b/.changeset/cold-bikes-beam.md new file mode 100644 index 0000000000..5a1c5ae7c5 --- /dev/null +++ b/.changeset/cold-bikes-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added a new `execute-template` actions registry action that executes a scaffolder template with provided input values and returns a task ID for tracking progress. diff --git a/docs/ai/well-known-actions.md b/docs/ai/well-known-actions.md index d6de8407cf..40b38f1773 100644 --- a/docs/ai/well-known-actions.md +++ b/docs/ai/well-known-actions.md @@ -27,4 +27,5 @@ This is a (non-exhaustive) list of actions that are known to be part of the Acti - `scaffolder.dry-run-template` (Dry Run Scaffolder Template): Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures. - `scaffolder.list-scaffolder-actions` (List Scaffolder Actions): Lists all installed Scaffolder actions. - `scaffolder.list-scaffolder-tasks` (List Scaffolder Tasks): This allows you to list scaffolder tasks that have been created. +- `scaffolder.execute-template` (Execute Scaffolder Template): Executes a Scaffolder template with its template ref and input parameter values. - `scaffolder.get-scaffolder-task-logs` (Get Scaffolder Task Logs): This allows you to fetch the logs of a given scaffolder task. diff --git a/plugins/scaffolder-backend/src/actions/createExecuteTemplateAction.test.ts b/plugins/scaffolder-backend/src/actions/createExecuteTemplateAction.test.ts new file mode 100644 index 0000000000..236ea7083f --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createExecuteTemplateAction.test.ts @@ -0,0 +1,132 @@ +/* + * 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 { createExecuteTemplateAction } from './createExecuteTemplateAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils'; + +describe('createExecuteTemplateAction', () => { + const mockScaffolderService = scaffolderServiceMock.mock(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should scaffold a template and return the taskId', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockScaffolderService.scaffold.mockResolvedValue({ + taskId: 'task-abc-123', + }); + + createExecuteTemplateAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:execute-template', + input: { + templateRef: 'template:default/my-template', + values: { name: 'my-app', owner: 'team-a' }, + }, + }); + + expect(result.output).toEqual({ taskId: 'task-abc-123' }); + + expect(mockScaffolderService.scaffold).toHaveBeenCalledWith( + { + templateRef: 'template:default/my-template', + values: { name: 'my-app', owner: 'team-a' }, + }, + { credentials: expect.anything() }, + ); + }); + + it('should forward empty values and pass secrets to the scaffolder service', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockScaffolderService.scaffold.mockResolvedValue({ + taskId: 'task-with-secrets', + }); + + createExecuteTemplateAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:execute-template', + input: { + templateRef: 'template:default/secret-template', + values: {}, + secrets: { apiKey: 'super-secret' }, + }, + }); + + expect(result.output).toEqual({ taskId: 'task-with-secrets' }); + expect(mockScaffolderService.scaffold).toHaveBeenCalledWith( + { + templateRef: 'template:default/secret-template', + values: {}, + secrets: { apiKey: 'super-secret' }, + }, + { credentials: expect.anything() }, + ); + }); + + it('should not include secrets when not provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockScaffolderService.scaffold.mockResolvedValue({ + taskId: 'task-no-secrets', + }); + + createExecuteTemplateAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + await mockActionsRegistry.invoke({ + id: 'test:execute-template', + input: { + templateRef: 'template:default/my-template', + values: { name: 'my-app' }, + }, + }); + + const scaffoldCall = mockScaffolderService.scaffold.mock.calls[0][0]; + expect(scaffoldCall).not.toHaveProperty('secrets'); + }); + + it('should propagate errors from the scaffolder service', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockScaffolderService.scaffold.mockRejectedValue( + new Error('Permission denied'), + ); + + createExecuteTemplateAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:execute-template', + input: { + templateRef: 'template:default/my-template', + values: { name: 'my-app' }, + }, + }), + ).rejects.toThrow('Permission denied'); + }); +}); diff --git a/plugins/scaffolder-backend/src/actions/createExecuteTemplateAction.ts b/plugins/scaffolder-backend/src/actions/createExecuteTemplateAction.ts new file mode 100644 index 0000000000..b523655a3b --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createExecuteTemplateAction.ts @@ -0,0 +1,81 @@ +/* + * 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 { JsonValue } from '@backstage/types'; + +export const createExecuteTemplateAction = ({ + actionsRegistry, + scaffolderService, +}: { + actionsRegistry: ActionsRegistryService; + scaffolderService: ScaffolderService; +}) => { + actionsRegistry.register({ + name: 'execute-template', + title: 'Execute Scaffolder Template', + attributes: { + destructive: true, + readOnly: false, + idempotent: false, + }, + description: `Executes a Scaffolder template with its template ref and input parameter values. +The template is run using the credentials provided to this action, and respects any RBAC permissions associated with those credentials. +Returns a taskId that can be used to track execution progress. +Use the catalog.get-catalog-entity action to fetch the Template entity and discover its parameter schema and secrets definition before calling this action.`, + schema: { + input: z => + z.object({ + templateRef: z + .string() + .describe( + 'The template entity reference to execute, e.g. "template:default/my-template"', + ), + values: z + .record(z.unknown()) + .describe( + 'Input parameter values required by the template. Use catalog.get-catalog-entity to discover the required parameters for the template.', + ), + secrets: z + .record(z.string()) + .optional() + .describe( + 'Optional secrets to pass to the template execution. Use catalog.get-catalog-entity to discover the secrets definition on the Template entity.', + ), + }), + output: z => + z.object({ + taskId: z + .string() + .describe( + 'The task ID for the scaffolder execution. Use this to track progress or retrieve logs.', + ), + }), + }, + action: async ({ input, credentials }) => { + const { taskId } = await scaffolderService.scaffold( + { + templateRef: input.templateRef, + values: input.values as Record, + ...(input.secrets && { secrets: input.secrets }), + }, + { credentials }, + ); + + return { output: { taskId } }; + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts index 2dee76d1da..239c7bcb78 100644 --- a/plugins/scaffolder-backend/src/actions/index.ts +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -19,6 +19,7 @@ import { createListScaffolderTasksAction } from './listScaffolderTasksAction'; import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; import { createDryRunTemplateAction } from './createDryRunTemplateAction'; import { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; +import { createExecuteTemplateAction } from './createExecuteTemplateAction'; import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction'; export const createScaffolderActions = (options: { @@ -33,5 +34,6 @@ export const createScaffolderActions = (options: { }); createDryRunTemplateAction(options); createListScaffolderActionsAction(options); + createExecuteTemplateAction(options); createGetScaffolderTaskLogsAction(options); };