From 07e08beac1a675ab42e8d32e539536f40aef4985 Mon Sep 17 00:00:00 2001 From: Ferin Patel <43402168+Ferin79@users.noreply.github.com> Date: Tue, 28 Apr 2026 06:16:59 -0300 Subject: [PATCH] feat: Add status check functions for scaffolder steps (#32890) * feat: Add status check functions for scaffolder steps - Introduced `always()` and `failure()` functions to control step execution after failures. - Updated documentation to explain usage of new status check functions. - Enhanced NunjucksWorkflowRunner to process these functions in step conditions. - Added tests to verify behavior of steps using `always()` and `failure()`. Signed-off-by: ferin79 * feat: Enhance status check functions in scaffolder steps - Updated documentation to clarify usage of status check functions with template expressions. - Modified tests to reflect changes in syntax for status checks. - Refactored NunjucksWorkflowRunner to ensure proper handling of status check functions in step conditions. Signed-off-by: ferin79 * docs: Clarify usage of status check functions in writing templates - Removed redundant explanation about truthy conditions after step failure. - Streamlined the description for better clarity on status check functions. Signed-off-by: ferin79 --------- Signed-off-by: ferin79 --- .changeset/lazy-rings-end.md | 5 + .../software-templates/writing-templates.md | 69 ++++ .../tasks/NunjucksWorkflowRunner.test.ts | 347 ++++++++++++++++++ .../tasks/NunjucksWorkflowRunner.ts | 97 ++++- 4 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 .changeset/lazy-rings-end.md diff --git a/.changeset/lazy-rings-end.md b/.changeset/lazy-rings-end.md new file mode 100644 index 0000000000..33c8dd4ccd --- /dev/null +++ b/.changeset/lazy-rings-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +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. diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index fe80235c54..10cfb1ea57 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -746,6 +746,75 @@ input: When `each` is used, the outputs of a repeated step are returned as an array of outputs from each iteration. +### Status Check Functions - `always()` and `failure()` + +By default, when a step fails during a scaffolder run, all subsequent steps are skipped and the task is marked as failed. This can be problematic when your template creates external resources (repositories, cloud infrastructure, deployments) that need to be cleaned up if a later step fails. + +Status check functions give you control over which steps run even after a failure. You use them inside a `${{ ... }}` template expression in the `if` field of a step. + +| Function | Description | +| ----------- | ---------------------------------------------------------------------------- | +| `always()` | Always runs the step, regardless of whether previous steps passed or failed. | +| `failure()` | Runs the step only when a previous step has failed. | + +These functions must be used as template expressions such as `${{ always() }}` or `${{ failure() }}`. + +After a step has failed, the scaffolder only attempts later steps whose `if` expression invokes one of these status check functions. + +#### Usage + +```yaml +steps: + - id: cleanup + name: Cleanup Resources + action: my:cleanup:action + if: ${{ always() }} +``` + +#### Example: Cleanup on failure + +A common pattern is to create resources in early steps and add cleanup steps +that only run if something goes wrong: + +```yaml +steps: + - id: create-repo + name: Create Repository + action: publish:github + input: + repoUrl: ${{ parameters.repoUrl }} + + - id: deploy + name: Deploy to Kubernetes + action: deploy:kubernetes + input: + manifest: ./k8s/deployment.yaml + + # Only runs when a previous step failed — cleans up the repository + - id: cleanup-repo + name: Delete Repository + action: github:repo:delete + if: ${{ failure() }} + input: + repoUrl: ${{ parameters.repoUrl }} + + # Always runs — post an audit event regardless of outcome + - id: audit + name: Post Audit Event + action: debug:log + if: ${{ always() }} + input: + message: 'Scaffolder run completed for ${{ parameters.repoUrl }}' + + # Does not run after a failure, because it does not invoke a status check function + - id: plain-truthy-condition + name: Plain Truthy Condition + action: debug:log + if: ${{ true }} + input: + message: 'This step is skipped after a previous failure' +``` + ## Outputs Each individual step can output some variables that can be used in the diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 8187070ffa..c621224730 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -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', + ); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index dbf0ed58b1..ebede4dbc3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -644,12 +644,28 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { this.environment = await this.getEnvironmentConfig(); + // 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 renderTemplate = await SecureTemplater.loadRenderer({ templateFilters: { ...this.defaultTemplateFilters, ...additionalTemplateFilters, }, - templateGlobals: additionalTemplateGlobals, + templateGlobals: { + ...additionalTemplateGlobals, + always: () => { + statusCheckInvoked.value = true; + return true; + }, + failure: () => { + statusCheckInvoked.value = true; + return taskState.failed; + }, + }, }); try { @@ -681,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);