Merge branch 'master' into master
Signed-off-by: Justin Bryant <justintbry@gmail.com>
This commit is contained in:
@@ -1,5 +1,40 @@
|
||||
# @backstage/plugin-scaffolder-backend
|
||||
|
||||
## 3.5.0-next.1
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 77bee9f: Updated the `list-scaffolder-tasks` action to support the new "status" filter parameter, allowing the action to return tasks matching a specific status.
|
||||
- 07e08be: Added `always()` and `failure()` status check functions for scaffolder steps. These functions can be used in the if field of a step to control execution after failures. `always()` ensures a step runs regardless of previous step outcomes, while `failure()` runs a step only when a previous step has failed.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.8.1-next.1
|
||||
- @backstage/plugin-catalog-node@2.2.1-next.1
|
||||
- @backstage/plugin-scaffolder-node@0.13.3-next.1
|
||||
- @backstage/plugin-permission-common@0.9.9-next.1
|
||||
|
||||
## 3.4.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1-next.0
|
||||
- @backstage/integration@2.0.2-next.0
|
||||
- @backstage/backend-openapi-utils@0.6.9-next.0
|
||||
- @backstage/backend-plugin-api@1.9.1-next.0
|
||||
- @backstage/catalog-model@1.8.1-next.0
|
||||
- @backstage/config@1.3.8-next.0
|
||||
- @backstage/plugin-catalog-node@2.2.1-next.0
|
||||
- @backstage/plugin-events-node@0.4.22-next.0
|
||||
- @backstage/plugin-permission-common@0.9.9-next.0
|
||||
- @backstage/plugin-permission-node@0.10.13-next.0
|
||||
- @backstage/plugin-scaffolder-common@2.1.1-next.0
|
||||
- @backstage/plugin-scaffolder-node@0.13.3-next.0
|
||||
- @backstage/types@1.2.2
|
||||
|
||||
## 3.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/plugin-scaffolder-backend",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-next.1",
|
||||
"description": "The Backstage backend plugin that helps you create new things",
|
||||
"backstage": {
|
||||
"role": "backend-plugin",
|
||||
@@ -93,7 +93,6 @@
|
||||
"p-queue": "^6.6.2",
|
||||
"prom-client": "^15.0.0",
|
||||
"triple-beam": "^1.4.1",
|
||||
"uuid": "^11.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"winston-transport": "^4.7.0",
|
||||
"yaml": "^2.0.0",
|
||||
|
||||
@@ -63,7 +63,12 @@ describe('createListScaffolderTasksAction', () => {
|
||||
totalTasks: mockTasks.totalTasks ?? 0,
|
||||
});
|
||||
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
|
||||
{ createdBy: undefined, limit: undefined, offset: undefined },
|
||||
{
|
||||
createdBy: undefined,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
status: undefined,
|
||||
},
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
});
|
||||
@@ -106,7 +111,7 @@ describe('createListScaffolderTasksAction', () => {
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
|
||||
{ createdBy: undefined, limit: 2, offset: 1 },
|
||||
{ createdBy: undefined, limit: 2, offset: 1, status: undefined },
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
|
||||
@@ -188,11 +193,102 @@ describe('createListScaffolderTasksAction', () => {
|
||||
createdBy: 'user:default/alice',
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
status: undefined,
|
||||
},
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter tasks by a single status when status is provided', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
const completedTasks = generateMockTasks().tasks.filter(
|
||||
t => t.status === 'completed',
|
||||
);
|
||||
|
||||
mockScaffolderService.listTasks.mockResolvedValue({
|
||||
items: completedTasks as ScaffolderTask[],
|
||||
totalItems: completedTasks.length,
|
||||
});
|
||||
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
auth: mockAuth,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:list-scaffolder-tasks',
|
||||
input: { status: 'completed' },
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
|
||||
{
|
||||
createdBy: undefined,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
status: 'completed',
|
||||
},
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
expect(result.output).toEqual({
|
||||
tasks: completedTasks.map(task => ({
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
lastHeartbeatAt: task.lastHeartbeatAt,
|
||||
})),
|
||||
totalTasks: completedTasks.length,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter tasks by multiple statuses when an array is provided', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockScaffolderService = scaffolderServiceMock.mock();
|
||||
const matchingTasks = generateMockTasks().tasks.filter(
|
||||
t => t.status === 'completed' || t.status === 'failed',
|
||||
);
|
||||
|
||||
mockScaffolderService.listTasks.mockResolvedValue({
|
||||
items: matchingTasks as ScaffolderTask[],
|
||||
totalItems: matchingTasks.length,
|
||||
});
|
||||
|
||||
createListScaffolderTasksAction({
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
auth: mockAuth,
|
||||
scaffolderService: mockScaffolderService,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:list-scaffolder-tasks',
|
||||
input: { status: ['completed', 'failed'] },
|
||||
});
|
||||
|
||||
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
|
||||
{
|
||||
createdBy: undefined,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
status: ['completed', 'failed'],
|
||||
},
|
||||
expect.objectContaining({ credentials: expect.anything() }),
|
||||
);
|
||||
expect(result.output).toEqual({
|
||||
tasks: matchingTasks.map(task => ({
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
lastHeartbeatAt: task.lastHeartbeatAt,
|
||||
})),
|
||||
totalTasks: matchingTasks.length,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotAllowedError when owned is true without user identity', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
|
||||
@@ -40,7 +40,7 @@ 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.
|
||||
Filtering by one or multiple statuses is supported. Pagination is supported via limit and offset.
|
||||
`,
|
||||
schema: {
|
||||
input: z =>
|
||||
@@ -65,6 +65,20 @@ Pagination is supported via limit and offset.
|
||||
.min(0)
|
||||
.describe('The offset to start from for pagination')
|
||||
.optional(),
|
||||
status: (() => {
|
||||
const statusEnum = z.enum([
|
||||
'open',
|
||||
'processing',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'skipped',
|
||||
]);
|
||||
return z
|
||||
.union([statusEnum, z.array(statusEnum).nonempty()])
|
||||
.optional()
|
||||
.describe('Filter tasks by status, or an array of statuses');
|
||||
})(),
|
||||
}),
|
||||
output: z =>
|
||||
z
|
||||
@@ -112,6 +126,7 @@ Pagination is supported via limit and offset.
|
||||
createdBy,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
status: input.status,
|
||||
},
|
||||
{ credentials },
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ import { JsonObject } from '@backstage/types';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner';
|
||||
import { DecoratedActionsRegistry } from './DecoratedActionsRegistry';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import {
|
||||
TaskStore,
|
||||
TaskStoreCreateTaskOptions,
|
||||
|
||||
@@ -2297,4 +2297,351 @@ describe('NunjucksWorkflowRunner', () => {
|
||||
expect(mockedPermissionApi.authorizeConditional).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step status check functions (always/failure)', () => {
|
||||
let failingHandler: jest.Mock;
|
||||
let cleanupHandler: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
failingHandler = jest.fn().mockRejectedValue(new Error('step failed'));
|
||||
cleanupHandler = jest.fn();
|
||||
|
||||
actionRegistry.register(
|
||||
createTemplateAction({
|
||||
id: 'failing-action',
|
||||
description: 'Action that always fails',
|
||||
handler: failingHandler,
|
||||
}),
|
||||
);
|
||||
|
||||
actionRegistry.register(
|
||||
createTemplateAction({
|
||||
id: 'cleanup-action',
|
||||
description: 'Cleanup action',
|
||||
handler: cleanupHandler,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should run step with if: ${{ always() }} even when a previous step failed', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Failing step',
|
||||
action: 'failing-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Always runs',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(runner.execute(task)).rejects.toThrow('step failed');
|
||||
expect(cleanupHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should run step with if: ${{ failure() }} only when a previous step failed', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Failing step',
|
||||
action: 'failing-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Runs on failure',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ failure() }}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(runner.execute(task)).rejects.toThrow('step failed');
|
||||
expect(cleanupHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not run step with if: ${{ failure() }} when no step has failed', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Succeeding step',
|
||||
action: 'jest-mock-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Only on failure',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ failure() }}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runner.execute(task);
|
||||
expect(fakeActionHandler).toHaveBeenCalledTimes(1);
|
||||
expect(cleanupHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not run step with if: ${{ true }} after a previous step failed', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Failing step',
|
||||
action: 'failing-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Truthy but not a status check',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ true }}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(runner.execute(task)).rejects.toThrow('step failed');
|
||||
expect(cleanupHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still throw the original error after running ${{ always() }} steps', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Failing step',
|
||||
action: 'failing-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Always step',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Should be skipped',
|
||||
action: 'jest-mock-action',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(runner.execute(task)).rejects.toThrow('step failed');
|
||||
expect(cleanupHandler).toHaveBeenCalledTimes(1);
|
||||
// step3 should not run because it has no status check function
|
||||
expect(fakeActionHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue running always() steps even if a cleanup step also fails', async () => {
|
||||
const failingCleanup = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('cleanup failed'));
|
||||
actionRegistry.register(
|
||||
createTemplateAction({
|
||||
id: 'failing-cleanup',
|
||||
description: 'Failing cleanup',
|
||||
handler: failingCleanup,
|
||||
}),
|
||||
);
|
||||
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Failing step',
|
||||
action: 'failing-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Failing cleanup',
|
||||
action: 'failing-cleanup',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Another cleanup',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Should throw the first error (from step1)
|
||||
await expect(runner.execute(task)).rejects.toThrow('step failed');
|
||||
expect(failingCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(cleanupHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should log all errors when multiple cleanup steps fail', async () => {
|
||||
const secondCleanupError = new Error('second cleanup failed');
|
||||
const thirdCleanupError = new Error('third cleanup failed');
|
||||
|
||||
const failingCleanup2 = jest.fn().mockRejectedValue(secondCleanupError);
|
||||
const failingCleanup3 = jest.fn().mockRejectedValue(thirdCleanupError);
|
||||
|
||||
actionRegistry.register(
|
||||
createTemplateAction({
|
||||
id: 'failing-cleanup-2',
|
||||
description: 'Second failing cleanup',
|
||||
handler: failingCleanup2,
|
||||
}),
|
||||
);
|
||||
|
||||
actionRegistry.register(
|
||||
createTemplateAction({
|
||||
id: 'failing-cleanup-3',
|
||||
description: 'Third failing cleanup',
|
||||
handler: failingCleanup3,
|
||||
}),
|
||||
);
|
||||
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Failing step',
|
||||
action: 'failing-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'First cleanup',
|
||||
action: 'failing-cleanup-2',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Second cleanup',
|
||||
action: 'failing-cleanup-3',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Should throw the first error (from step1)
|
||||
await expect(runner.execute(task)).rejects.toThrow('step failed');
|
||||
|
||||
// All cleanup handlers should have been called
|
||||
expect(failingCleanup2).toHaveBeenCalledTimes(1);
|
||||
expect(failingCleanup3).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Subsequent errors should be logged
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Additional error in step step2 (First cleanup): second cleanup failed',
|
||||
secondCleanupError,
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Additional error in step step3 (Second cleanup): third cleanup failed',
|
||||
thirdCleanupError,
|
||||
);
|
||||
|
||||
// Summary warning should be logged
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Task failed with 3 errors. First error from step step1. Additional failures in: step2 (First cleanup), step3 (Second cleanup)',
|
||||
),
|
||||
);
|
||||
|
||||
// Task logs should contain additional error information
|
||||
expect(fakeTaskLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Additional error occurred'),
|
||||
{ stepId: 'step2', status: 'failed' },
|
||||
);
|
||||
expect(fakeTaskLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Additional error occurred'),
|
||||
{ stepId: 'step3', status: 'failed' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should support failure() and always() together across multiple steps', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'First step',
|
||||
action: 'jest-mock-action',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Should skip with template failure',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ failure() }}',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Should run with template always',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
name: 'Failing step',
|
||||
action: 'failing-action',
|
||||
},
|
||||
{
|
||||
id: 'step5',
|
||||
name: 'Should run with template failure after error',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ failure() }}',
|
||||
},
|
||||
{
|
||||
id: 'step6',
|
||||
name: 'Should run with template always after error',
|
||||
action: 'cleanup-action',
|
||||
if: '${{ always() }}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(runner.execute(task)).rejects.toThrow('step failed');
|
||||
|
||||
// Verify execution order and counts
|
||||
expect(fakeActionHandler).toHaveBeenCalledTimes(1); // step1
|
||||
expect(cleanupHandler).toHaveBeenCalledTimes(3); // step3, step5, step6
|
||||
|
||||
// Verify the correct steps ran in the right order
|
||||
const taskLogCalls = fakeTaskLog.mock.calls.map(args =>
|
||||
stripAnsi(args[0]),
|
||||
);
|
||||
|
||||
// step1 should run
|
||||
expect(taskLogCalls).toContain('Beginning step First step');
|
||||
expect(taskLogCalls).toContain('Finished step First step');
|
||||
|
||||
// step2 should be skipped (no failure yet)
|
||||
expect(taskLogCalls).toContain(
|
||||
'Skipping step step2 because its if condition was false',
|
||||
);
|
||||
|
||||
// step3 should run (always)
|
||||
expect(taskLogCalls).toContain(
|
||||
'Beginning step Should run with template always',
|
||||
);
|
||||
expect(taskLogCalls).toContain(
|
||||
'Finished step Should run with template always',
|
||||
);
|
||||
|
||||
// step4 should fail
|
||||
expect(taskLogCalls).toContain('Beginning step Failing step');
|
||||
|
||||
// step5 should run (failure condition met)
|
||||
expect(taskLogCalls).toContain(
|
||||
'Beginning step Should run with template failure after error',
|
||||
);
|
||||
expect(taskLogCalls).toContain(
|
||||
'Finished step Should run with template failure after error',
|
||||
);
|
||||
|
||||
// step6 should run (always)
|
||||
expect(taskLogCalls).toContain(
|
||||
'Beginning step Should run with template always after error',
|
||||
);
|
||||
expect(taskLogCalls).toContain(
|
||||
'Finished step Should run with template always after error',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -644,14 +644,29 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
|
||||
this.environment = await this.getEnvironmentConfig();
|
||||
|
||||
const { render: renderTemplate, dispose } =
|
||||
await SecureTemplater.loadRenderer({
|
||||
templateFilters: {
|
||||
...this.defaultTemplateFilters,
|
||||
...additionalTemplateFilters,
|
||||
// Track whether any step has failed, used by status check functions
|
||||
const taskState = { failed: false };
|
||||
|
||||
// Track whether a status check global (always/failure) was invoked during rendering
|
||||
const statusCheckInvoked = { value: false };
|
||||
|
||||
const { render: renderTemplate, dispose } = await SecureTemplater.loadRenderer({
|
||||
templateFilters: {
|
||||
...this.defaultTemplateFilters,
|
||||
...additionalTemplateFilters,
|
||||
},
|
||||
templateGlobals: {
|
||||
...additionalTemplateGlobals,
|
||||
always: () => {
|
||||
statusCheckInvoked.value = true;
|
||||
return true;
|
||||
},
|
||||
templateGlobals: additionalTemplateGlobals,
|
||||
});
|
||||
failure: () => {
|
||||
statusCheckInvoked.value = true;
|
||||
return taskState.failed;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await task.rehydrateWorkspace?.({ taskId, targetPath: workspacePath });
|
||||
@@ -682,16 +697,77 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
)
|
||||
: [{ result: AuthorizeResult.ALLOW }];
|
||||
|
||||
let firstError: Error | undefined;
|
||||
const allErrors: Array<{ step: TaskStep; error: Error }> = [];
|
||||
|
||||
for (const step of task.spec.steps) {
|
||||
await this.executeStep(
|
||||
task,
|
||||
step,
|
||||
context,
|
||||
renderTemplate,
|
||||
taskTrack,
|
||||
workspacePath,
|
||||
decision,
|
||||
);
|
||||
// If a previous step failed, only run steps whose `if` condition
|
||||
// invokes a status check global (${{ always() }} or ${{ failure() }})
|
||||
if (taskState.failed) {
|
||||
if (typeof step.if !== 'string') {
|
||||
await task.emitLog(
|
||||
`Skipping step ${step.id} because a previous step failed`,
|
||||
{ stepId: step.id, status: 'skipped' },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render the if condition to detect status check function usage
|
||||
statusCheckInvoked.value = false;
|
||||
this.render(step.if, context, renderTemplate);
|
||||
|
||||
if (!statusCheckInvoked.value) {
|
||||
await task.emitLog(
|
||||
`Skipping step ${step.id} because a previous step failed`,
|
||||
{ stepId: step.id, status: 'skipped' },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeStep(
|
||||
task,
|
||||
step,
|
||||
context,
|
||||
renderTemplate,
|
||||
taskTrack,
|
||||
workspacePath,
|
||||
decision,
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
allErrors.push({ step, error });
|
||||
|
||||
if (!firstError) {
|
||||
firstError = error;
|
||||
} else {
|
||||
// Log subsequent errors to preserve debugging information
|
||||
this.options.logger.error(
|
||||
`Additional error in step ${step.id} (${step.name}): ${error.message}`,
|
||||
error,
|
||||
);
|
||||
await task.emitLog(
|
||||
`Additional error occurred: ${error.message}\n${error.stack}`,
|
||||
{ stepId: step.id, status: 'failed' },
|
||||
);
|
||||
}
|
||||
taskState.failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstError) {
|
||||
// If there were multiple errors, add context to the first error
|
||||
if (allErrors.length > 1) {
|
||||
const additionalErrorSummary = allErrors
|
||||
.slice(1)
|
||||
.map(({ step }) => `${step.id} (${step.name})`)
|
||||
.join(', ');
|
||||
this.options.logger.warn(
|
||||
`Task failed with ${allErrors.length} errors. First error from step ${allErrors[0].step.id}. Additional failures in: ${additionalErrorSummary}`,
|
||||
);
|
||||
}
|
||||
throw firstError;
|
||||
}
|
||||
|
||||
const output = this.render(task.spec.output, context, renderTemplate);
|
||||
|
||||
@@ -84,7 +84,7 @@ import { HumanDuration, JsonObject } from '@backstage/types';
|
||||
import express from 'express';
|
||||
import { Duration } from 'luxon';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { z } from 'zod/v3';
|
||||
import {
|
||||
DatabaseTaskStore,
|
||||
|
||||
Reference in New Issue
Block a user