diff --git a/.changeset/gold-squids-rescue.md b/.changeset/gold-squids-rescue.md new file mode 100644 index 0000000000..8ddc6503f1 --- /dev/null +++ b/.changeset/gold-squids-rescue.md @@ -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. diff --git a/docs/ai/well-known-actions.md b/docs/ai/well-known-actions.md index 14039214d4..d6de8407cf 100644 --- a/docs/ai/well-known-actions.md +++ b/docs/ai/well-known-actions.md @@ -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. diff --git a/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.test.ts b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.test.ts new file mode 100644 index 0000000000..7ddf498d9d --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.test.ts @@ -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'); + }); +}); diff --git a/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.ts b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.ts new file mode 100644 index 0000000000..08eb3a504f --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.ts @@ -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, + }, + })), + }, + }; + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts index 4beb39d832..2dee76d1da 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 { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction'; export const createScaffolderActions = (options: { actionsRegistry: ActionsRegistryService; @@ -32,4 +33,5 @@ export const createScaffolderActions = (options: { }); createDryRunTemplateAction(options); createListScaffolderActionsAction(options); + createGetScaffolderTaskLogsAction(options); };