feat(scaffolder): Create basic scaffolder task query action (#32989)
* feat(scaffolder): Create basic scaffolder task query action Signed-off-by: John Collier <jcollier@redhat.com> * Add changeset Signed-off-by: John Collier <jcollier@redhat.com> * Fix test Signed-off-by: John Collier <jcollier@redhat.com> * Address review feedback Signed-off-by: John Collier <jcollier@redhat.com> * Update plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier <jcollier@redhat.com> * Update plugins/scaffolder-backend/src/actions/listScaffolderTaskAction.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier <jcollier@redhat.com> * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier <jcollier@redhat.com> * lint Signed-off-by: John Collier <jcollier@redhat.com> * fix conflict markers, remove unused discovery dep, add input validation bounds Signed-off-by: benjdlambert <ben@blam.sh> * address PR feedback: fix status enum, add int validation, improve changeset Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: John Collier <jcollier@redhat.com> Signed-off-by: benjdlambert <ben@blam.sh> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Added a new `list-scaffolder-tasks` action that allows querying scaffolder tasks with optional ownership filtering and pagination support
|
||||
@@ -223,6 +223,7 @@ export const scaffolderPlugin = createBackendPlugin({
|
||||
createScaffolderActions({
|
||||
actionsRegistry: actionsRegistryService,
|
||||
scaffolderService,
|
||||
auth,
|
||||
});
|
||||
|
||||
const router = await createRouter({
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { createListScaffolderTasksAction } from './listScaffolderTasksAction';
|
||||
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
|
||||
import { createDryRunTemplateAction } from './createDryRunTemplateAction';
|
||||
import { createListScaffolderActionsAction } from './createListScaffolderActionsAction';
|
||||
@@ -21,7 +23,13 @@ import { createListScaffolderActionsAction } from './createListScaffolderActions
|
||||
export const createScaffolderActions = (options: {
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
scaffolderService: ScaffolderService;
|
||||
auth: AuthService;
|
||||
}) => {
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: options.actionsRegistry,
|
||||
auth: options.auth,
|
||||
scaffolderService: options.scaffolderService,
|
||||
});
|
||||
createDryRunTemplateAction(options);
|
||||
createListScaffolderActionsAction(options);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright 2025 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 { mockServices, mockCredentials } from '@backstage/backend-test-utils';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { ScaffolderTask } from '@backstage/plugin-scaffolder-common';
|
||||
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
|
||||
import { createListScaffolderTasksAction } from './listScaffolderTasksAction';
|
||||
import { ListTasksResponse } from '../schema/openapi/generated/models/ListTasksResponse.model';
|
||||
|
||||
describe('createListScaffolderTasksAction', () => {
|
||||
it('should list tasks successfully', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
const mockTasks = generateMockTasks();
|
||||
|
||||
mockScaffolderService.listTasks.mockResolvedValue({
|
||||
items: mockTasks.tasks.map(task => ({
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
lastHeartbeatAt: task.lastHeartbeatAt,
|
||||
})) as ScaffolderTask[],
|
||||
totalItems: mockTasks.totalTasks ?? 0,
|
||||
});
|
||||
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
auth: mockAuth,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:list-scaffolder-tasks',
|
||||
input: {},
|
||||
});
|
||||
|
||||
const expectedTasks: ScaffolderTask[] = mockTasks.tasks.map(task => ({
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
lastHeartbeatAt: task.lastHeartbeatAt,
|
||||
}));
|
||||
|
||||
expect(result.output).toEqual({
|
||||
tasks: expectedTasks,
|
||||
totalTasks: mockTasks.totalTasks ?? 0,
|
||||
});
|
||||
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
|
||||
{ createdBy: undefined, limit: undefined, offset: undefined },
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass limit and offset through to the API and return paginated results', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
const paginatedTasks = [
|
||||
{
|
||||
id: 'task-2',
|
||||
spec: {},
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastHeartbeatAt: '2025-01-01T00:01:00Z',
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
spec: {},
|
||||
status: 'processing',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastHeartbeatAt: '2025-01-01T00:02:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockScaffolderService.listTasks.mockResolvedValue({
|
||||
items: paginatedTasks as ScaffolderTask[],
|
||||
totalItems: 10,
|
||||
});
|
||||
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
auth: mockAuth,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:list-scaffolder-tasks',
|
||||
input: { limit: 2, offset: 1 },
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
|
||||
{ createdBy: undefined, limit: 2, offset: 1 },
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
|
||||
const expectedTasks = paginatedTasks.map(task => ({
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
lastHeartbeatAt: task.lastHeartbeatAt,
|
||||
}));
|
||||
|
||||
expect(result.output).toEqual({
|
||||
tasks: expectedTasks,
|
||||
totalTasks: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if the service call fails', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
|
||||
mockScaffolderService.listTasks.mockRejectedValue(
|
||||
new Error('Internal Server Error'),
|
||||
);
|
||||
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
auth: mockAuth,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mockActionsRegistry.invoke({
|
||||
id: 'test:list-scaffolder-tasks',
|
||||
input: {},
|
||||
}),
|
||||
).rejects.toThrow('Internal Server Error');
|
||||
});
|
||||
|
||||
it('should use createdBy filter when owned is true with user identity', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
const mockTasks = generateMockTasks();
|
||||
|
||||
mockAuth.isPrincipal.mockImplementation(
|
||||
(creds, type) =>
|
||||
type === 'user' &&
|
||||
(creds?.principal as { type?: string })?.type === 'user' &&
|
||||
typeof (creds.principal as { userEntityRef?: string }).userEntityRef ===
|
||||
'string',
|
||||
);
|
||||
mockScaffolderService.listTasks.mockResolvedValue({
|
||||
items: mockTasks.tasks.map(task => ({
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
lastHeartbeatAt: task.lastHeartbeatAt,
|
||||
})) as ScaffolderTask[],
|
||||
totalItems: mockTasks.totalTasks ?? 0,
|
||||
});
|
||||
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
auth: mockAuth,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
await mockActionsRegistry.invoke({
|
||||
id: 'test:list-scaffolder-tasks',
|
||||
input: { owned: true },
|
||||
credentials: mockCredentials.user('user:default/alice'),
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
|
||||
{
|
||||
createdBy: 'user:default/alice',
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
},
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotAllowedError when owned is true without user identity', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
|
||||
mockAuth.isPrincipal.mockReturnValue(false);
|
||||
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
auth: mockAuth,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mockActionsRegistry.invoke({
|
||||
id: 'test:list-scaffolder-tasks',
|
||||
input: { owned: true },
|
||||
credentials: mockCredentials.service(),
|
||||
}),
|
||||
).rejects.toThrow(NotAllowedError);
|
||||
|
||||
expect(mockScaffolderService.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Return a mocked ListTasksResponse that contains a number of different mocked tasks
|
||||
function generateMockTasks(): ListTasksResponse {
|
||||
return {
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
spec: {},
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastHeartbeatAt: '2025-01-01T00:01:00Z',
|
||||
createdBy: 'user:default/guest',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
spec: {},
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastHeartbeatAt: '2025-01-01T00:01:00Z',
|
||||
createdBy: 'user:default/guest',
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
spec: {},
|
||||
status: 'processing',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastHeartbeatAt: '2025-01-01T00:02:00Z',
|
||||
createdBy: 'user:default/admin',
|
||||
},
|
||||
{
|
||||
id: 'task-4',
|
||||
spec: {},
|
||||
status: 'failed',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastHeartbeatAt: '2025-01-01T00:02:00Z',
|
||||
createdBy: 'user:default/admin',
|
||||
},
|
||||
{
|
||||
id: 'task-5',
|
||||
spec: {},
|
||||
status: 'cancelled',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
lastHeartbeatAt: '2025-01-01T00:02:00Z',
|
||||
createdBy: 'user:default/admin',
|
||||
},
|
||||
],
|
||||
totalTasks: 5,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2025 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 { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
|
||||
|
||||
export const createListScaffolderTasksAction = ({
|
||||
actionsRegistry,
|
||||
auth,
|
||||
scaffolderService,
|
||||
}: {
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
auth: AuthService;
|
||||
scaffolderService: ScaffolderService;
|
||||
}) => {
|
||||
actionsRegistry.register({
|
||||
name: 'list-scaffolder-tasks',
|
||||
title: 'List Scaffolder Tasks',
|
||||
attributes: {
|
||||
destructive: false,
|
||||
readOnly: true,
|
||||
idempotent: true,
|
||||
},
|
||||
description: `
|
||||
This allows you to list scaffolder tasks that have been created.
|
||||
Each task has a unique id, specification, and status (one of open, processing, completed, failed, cancelled, skipped).
|
||||
Each task includes a timestamp for when it was created, and an optional last heartbeat timestamp indicating the most recent activity.
|
||||
Set owned to true to return only tasks created by the current user; omit or set to false for all tasks the credentials can see.
|
||||
Pagination is supported via limit and offset.
|
||||
`,
|
||||
schema: {
|
||||
input: z =>
|
||||
z.object({
|
||||
owned: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
'If true, return only tasks created by the current user. Requires a user identity.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe('The maximum number of tasks to return for pagination')
|
||||
.optional(),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe('The offset to start from for pagination')
|
||||
.optional(),
|
||||
}),
|
||||
output: z =>
|
||||
z
|
||||
.object({
|
||||
tasks: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe('The task identifier'),
|
||||
spec: z.unknown().describe('The task specification'),
|
||||
status: z
|
||||
.string()
|
||||
.describe(
|
||||
'Task status: open, processing, completed, failed, cancelled, or skipped',
|
||||
),
|
||||
createdAt: z
|
||||
.string()
|
||||
.describe('Timestamp when the task was created'),
|
||||
lastHeartbeatAt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Timestamp of the last heartbeat'),
|
||||
}),
|
||||
)
|
||||
.describe('The list of scaffolder tasks'),
|
||||
totalTasks: z
|
||||
.number()
|
||||
.describe('Total number of tasks matching the filter'),
|
||||
})
|
||||
.describe('Object containing a tasks array and totalTasks count'),
|
||||
},
|
||||
action: async ({ input, credentials }) => {
|
||||
if (input.owned && !auth.isPrincipal(credentials, 'user')) {
|
||||
throw new NotAllowedError(
|
||||
'Filtering by owned tasks requires a user identity.',
|
||||
);
|
||||
}
|
||||
|
||||
const createdBy =
|
||||
input.owned && auth.isPrincipal(credentials, 'user')
|
||||
? credentials.principal.userEntityRef
|
||||
: undefined;
|
||||
|
||||
const { items, totalItems } = await scaffolderService.listTasks(
|
||||
{
|
||||
createdBy,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
},
|
||||
{ credentials },
|
||||
);
|
||||
|
||||
return {
|
||||
output: {
|
||||
tasks: items.map(task => ({
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
lastHeartbeatAt: task.lastHeartbeatAt,
|
||||
})),
|
||||
totalTasks: totalItems,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user