Merge branch 'master' of https://github.com/backstage/backstage into migrate-scaffolder-to-metrics-service

This commit is contained in:
Kurt King
2026-04-02 22:47:04 -06:00
760 changed files with 22569 additions and 3253 deletions
+37
View File
@@ -1,5 +1,42 @@
# @backstage/plugin-scaffolder-backend
## 3.3.0-next.1
### Minor Changes
- 309b712: 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.
### Patch Changes
- 4559806: Removed unnecessary empty `examples` array from actions bridged via the actions registry.
- Updated dependencies
- @backstage/backend-plugin-api@1.9.0-next.1
- @backstage/backend-openapi-utils@0.6.8-next.1
- @backstage/plugin-catalog-node@2.1.1-next.1
- @backstage/plugin-events-node@0.4.21-next.1
- @backstage/plugin-permission-node@0.10.12-next.1
- @backstage/plugin-scaffolder-node@0.13.1-next.1
## 3.2.1-next.0
### Patch Changes
- 79453c0: Updated dependency `wait-for-expect` to `^4.0.0`.
- Updated dependencies
- @backstage/backend-plugin-api@1.8.1-next.0
- @backstage/plugin-permission-node@0.10.12-next.0
- @backstage/backend-openapi-utils@0.6.8-next.0
- @backstage/plugin-catalog-node@2.1.1-next.0
- @backstage/plugin-events-node@0.4.21-next.0
- @backstage/plugin-scaffolder-node@0.13.1-next.0
- @backstage/catalog-model@1.7.7
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/integration@2.0.0
- @backstage/types@1.2.2
- @backstage/plugin-permission-common@0.9.7
- @backstage/plugin-scaffolder-common@2.0.0
## 3.2.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"version": "3.2.0",
"version": "3.3.0-next.1",
"description": "The Backstage backend plugin that helps you create new things",
"backstage": {
"role": "backend-plugin",
@@ -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');
});
});
@@ -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<string, JsonValue>,
...(input.secrets && { secrets: input.secrets }),
},
{ credentials },
);
return { output: { taskId } };
},
});
};
@@ -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);
};
@@ -93,7 +93,6 @@ export class DefaultTemplateActionRegistry implements TemplateActionRegistry {
ret.set(action.id, {
id: action.id,
description: action.description,
examples: [],
supportsDryRun:
action.attributes?.readOnly === true &&
action.attributes?.destructive === false,
@@ -285,7 +285,6 @@ describe('scaffolder router', () => {
expect(response.body).toContainEqual({
description: 'Test',
examples: [],
id: 'test:my-demo-action',
schema: {
input: {