feat(scaffolder): implement a get scaffolder task log action (#33185)

* feat(scaffolder): create get scaffolder task log action

Adds a new action to allow retrieving logs from scaffolder tasks, by way of the scaffolderService.getLogs function

Signed-off-by: John Collier <jcollier@redhat.com>

* Fix typo after rename

Signed-off-by: John Collier <jcollier@redhat.com>

* Add to list of known actions

Signed-off-by: John Collier <jcollier@redhat.com>

---------

Signed-off-by: John Collier <jcollier@redhat.com>
This commit is contained in:
John Collier
2026-03-17 03:48:59 -04:00
committed by GitHub
parent dee4283ccf
commit 1b42218ca3
5 changed files with 268 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Adds a new `get-scaffolder-task-logs` action to `@backstage/plugin-scaffolder-backend` that retrieves log events for a given scaffolder task, with optional support for retrieving only new events after a given event ID.
+1
View File
@@ -27,3 +27,4 @@ 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.get-scaffolder-task-logs` (Get Scaffolder Task Logs): This allows you to fetch the logs of a given scaffolder task.
@@ -0,0 +1,148 @@
/*
* 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 { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
import { LogEvent } from '@backstage/plugin-scaffolder-common';
import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction';
describe('createGetScaffolderTaskLogsAction', () => {
it('should return log events for a task', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
const mockEvents: LogEvent[] = [
{
id: 1,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:00Z',
type: 'log',
body: { message: 'Starting step', stepId: 'step-1' },
},
{
id: 2,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:01Z',
type: 'log',
body: { message: 'Step complete', stepId: 'step-1' },
},
{
id: 3,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:02Z',
type: 'completion',
body: { message: 'Task completed', status: 'completed' },
},
];
mockScaffolderService.getLogs.mockResolvedValue(mockEvents);
createGetScaffolderTaskLogsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:get-scaffolder-task-logs',
input: { taskId: 'task-1' },
});
expect(result.output).toEqual({
events: [
{
id: 1,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:00Z',
type: 'log',
body: {
message: 'Starting step',
stepId: 'step-1',
status: undefined,
},
},
{
id: 2,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:01Z',
type: 'log',
body: {
message: 'Step complete',
stepId: 'step-1',
status: undefined,
},
},
{
id: 3,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:02Z',
type: 'completion',
body: {
message: 'Task completed',
stepId: undefined,
status: 'completed',
},
},
],
});
expect(mockScaffolderService.getLogs).toHaveBeenCalledWith(
{ taskId: 'task-1', after: undefined },
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should pass the after parameter through to the service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.getLogs.mockResolvedValue([]);
createGetScaffolderTaskLogsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:get-scaffolder-task-logs',
input: { taskId: 'task-2', after: 42 },
});
expect(result.output).toEqual({ events: [] });
expect(mockScaffolderService.getLogs).toHaveBeenCalledWith(
{ taskId: 'task-2', after: 42 },
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should throw when the service call fails', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.getLogs.mockRejectedValue(
new Error('Internal Server Error'),
);
createGetScaffolderTaskLogsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:get-scaffolder-task-logs',
input: { taskId: 'task-3' },
}),
).rejects.toThrow('Internal Server Error');
});
});
@@ -0,0 +1,112 @@
/*
* 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';
export const createGetScaffolderTaskLogsAction = ({
actionsRegistry,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'get-scaffolder-task-logs',
title: 'Get Scaffolder Task Logs',
attributes: {
destructive: false,
readOnly: true,
idempotent: true,
},
description: `
Retrieve the log events for a scaffolder task.
Each log event has a type (log, completion, cancelled, or recovered), a body containing a message and optional step ID and status.
Use the after parameter to fetch only events after a specific event ID for incremental polling.
`,
schema: {
input: z =>
z.object({
taskId: z.string().describe('The ID of the scaffolder task'),
after: z
.number()
.int()
.min(0)
.optional()
.describe(
'Return only log events after this event ID for incremental polling',
),
}),
output: z =>
z
.object({
events: z
.array(
z.object({
id: z.number().describe('The event ID'),
taskId: z
.string()
.describe('The ID of the task this event belongs to'),
createdAt: z
.string()
.describe('Timestamp when the event was created'),
type: z
.string()
.describe(
'Event type: log, completion, cancelled, or recovered',
),
body: z
.object({
message: z.string().describe('The log message'),
stepId: z
.string()
.optional()
.describe('The step ID associated with this event'),
status: z
.string()
.optional()
.describe('The task status at the time of this event'),
})
.describe('The event body'),
}),
)
.describe('The list of log events for the task'),
})
.describe('Object containing the events array'),
},
action: async ({ input, credentials }) => {
const events = await scaffolderService.getLogs(
{ taskId: input.taskId, after: input.after },
{ credentials },
);
return {
output: {
events: events.map(event => ({
id: event.id,
taskId: event.taskId,
createdAt: event.createdAt,
type: event.type,
body: {
message: event.body.message,
stepId: event.body.stepId,
status: event.body.status,
},
})),
},
};
},
});
};
@@ -19,6 +19,7 @@ import { createListScaffolderTasksAction } from './listScaffolderTasksAction';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
import { createDryRunTemplateAction } from './createDryRunTemplateAction';
import { createListScaffolderActionsAction } from './createListScaffolderActionsAction';
import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction';
export const createScaffolderActions = (options: {
actionsRegistry: ActionsRegistryService;
@@ -32,4 +33,5 @@ export const createScaffolderActions = (options: {
});
createDryRunTemplateAction(options);
createListScaffolderActionsAction(options);
createGetScaffolderTaskLogsAction(options);
};