diff --git a/.changeset/curly-jobs-kneel.md b/.changeset/curly-jobs-kneel.md new file mode 100644 index 0000000000..f313beab88 --- /dev/null +++ b/.changeset/curly-jobs-kneel.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder-node': patch +'@backstage/plugin-scaffolder-react': patch +--- + +Added a possibility to cancel the running task (executing of a scaffolder template) diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 5911e192b1..fbccf8848c 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -58,6 +58,10 @@ It shouldn't take too long, and you'll have a success screen! If it fails, you'll be able to click on each section to get the log from the step that failed which can be helpful in debugging. +You can also cancel the running process. Once you clicked on button "Cancel", the abort signal +will be sent to a task and all next steps won't be executed. The current step will be cancelled +only if it supports it. + ![Templating failed](../../assets/software-templates/failed.png) ## View Component in Catalog diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 3e89e89582..7a841dfd0e 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -71,7 +71,7 @@ You can also choose to define your custom action using JSON schema instead of `z ```ts title="With JSON Schema" import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import fs from 'fs-extra'; +import { writeFile } from 'fs'; export const createNewFileAction = () => { return createTemplateAction<{ contents: string; filename: string }>({ @@ -95,9 +95,12 @@ export const createNewFileAction = () => { }, }, async handler(ctx) { - await fs.outputFile( + const { signal } = ctx; + await writeFile( `${ctx.workspacePath}/${ctx.input.filename}`, ctx.input.contents, + { signal }, + _ => {}, ); }, }); diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 452689c2ea..88a0f97992 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -11,9 +11,11 @@ import { CatalogProcessor } from '@backstage/plugin-catalog-backend'; import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend'; import { Config } from '@backstage/config'; import { createPullRequest } from 'octokit-plugin-create-pull-request'; +import { Duration } from 'luxon'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; import { GithubCredentialsProvider } from '@backstage/integration'; +import { HumanDuration } from '@backstage/types'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; @@ -528,6 +530,11 @@ export const createTemplateAction: < action: TemplateActionOptions, ) => TemplateAction_2; +// @public +export function createWaitAction(options?: { + maxWaitTime?: Duration | HumanDuration; +}): TemplateAction_2; + // @public export type CreateWorkerOptions = { taskBroker: TaskBroker; @@ -550,6 +557,14 @@ export interface CurrentClaimedTask { // @public export class DatabaseTaskStore implements TaskStore { + // (undocumented) + cancelTask( + options: TaskStoreEmitOptions< + { + message: string; + } & JsonObject + >, + ): Promise; // (undocumented) claimTask(): Promise; // (undocumented) @@ -694,6 +709,8 @@ export type SerializedTaskEvent = { // @public export interface TaskBroker { + // (undocumented) + cancel?(taskId: string): Promise; // (undocumented) claim(): Promise; // (undocumented) @@ -731,6 +748,8 @@ export type TaskCompletionState = 'failed' | 'completed'; // @public export interface TaskContext { + // (undocumented) + cancelSignal: AbortSignal; // (undocumented) complete(result: TaskCompletionState, metadata?: JsonObject): Promise; // (undocumented) @@ -750,16 +769,19 @@ export interface TaskContext { } // @public -export type TaskEventType = 'completion' | 'log'; +export type TaskEventType = 'completion' | 'log' | 'cancelled'; // @public export class TaskManager implements TaskContext { + // (undocumented) + get cancelSignal(): AbortSignal; // (undocumented) complete(result: TaskCompletionState, metadata?: JsonObject): Promise; // (undocumented) static create( task: CurrentClaimedTask, storage: TaskStore, + abortSignal: AbortSignal, logger: Logger, ): TaskManager; // (undocumented) @@ -781,14 +803,16 @@ export type TaskSecrets = TaskSecrets_2; // @public export type TaskStatus = - | 'open' - | 'processing' - | 'failed' | 'cancelled' - | 'completed'; + | 'completed' + | 'failed' + | 'open' + | 'processing'; // @public export interface TaskStore { + // (undocumented) + cancelTask?(options: TaskStoreEmitOptions): Promise; // (undocumented) claimTask(): Promise; // (undocumented) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index d7eae0580a..6928e048d1 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -64,6 +64,7 @@ "@gitbeaker/node": "^35.1.0", "@octokit/webhooks": "^10.0.0", "@types/express": "^4.17.6", + "@types/luxon": "^3.0.0", "azure-devops-node-api": "^11.0.1", "command-exists": "^1.2.9", "compression": "^1.7.4", @@ -109,6 +110,7 @@ "mock-fs": "^5.1.0", "msw": "^1.0.0", "supertest": "^6.1.3", + "wait-for-expect": "^3.0.2", "yaml": "^2.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 9ba4a27b1a..d4c8de3604 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -30,7 +30,7 @@ import { } from './catalog'; import { TemplateFilter, TemplateGlobal } from '../../../lib'; -import { createDebugLogAction } from './debug'; +import { createDebugLogAction, createWaitAction } from './debug'; import { createFetchPlainAction, createFetchTemplateAction } from './fetch'; import { createFilesystemDeleteAction, @@ -159,6 +159,7 @@ export const createBuiltinActions = ( config, }), createDebugLogAction(), + createWaitAction(), createCatalogRegisterAction({ catalogClient, integrations }), createFetchCatalogEntityAction({ catalogClient }), createCatalogWriteAction(), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/index.ts index 8cc50a7eba..3776ce7f03 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/index.ts @@ -15,3 +15,4 @@ */ export { createDebugLogAction } from './log'; +export { createWaitAction } from './wait'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts new file mode 100644 index 0000000000..0d4cdaba4b --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021 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 { getVoidLogger } from '@backstage/backend-common'; +import mockFs from 'mock-fs'; +import { createWaitAction } from './wait'; +import { Writable } from 'stream'; +import os from 'os'; + +describe('debug:wait', () => { + const action = createWaitAction(); + + const logStream = { + write: jest.fn(), + } as jest.Mocked> as jest.Mocked; + + const mockTmpDir = os.tmpdir(); + const mockContext = { + input: {}, + baseUrl: 'somebase', + workspacePath: mockTmpDir, + logger: getVoidLogger(), + logStream, + output: jest.fn(), + createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should wait for specified period of time', async () => { + const context = { + ...mockContext, + input: { + milliseconds: 50, + }, + }; + const start = new Date().getTime(); + await action.handler(context); + const end = new Date().getTime(); + expect(end - start).toBeGreaterThanOrEqual(50); + }); + + it('should not allow to set waiting time longer than the max waiting time', async () => { + const context = { + ...mockContext, + input: { + minutes: 11, + }, + }; + + await expect(async () => { + await action.handler(context); + }).rejects.toThrow( + 'Waiting duration is longer than the maximum threshold of 0 hours, 0 minutes, 30 seconds', + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.ts new file mode 100644 index 0000000000..02cab239cb --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2021 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 { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { HumanDuration } from '@backstage/types'; +import yaml from 'yaml'; +import { Duration } from 'luxon'; + +const id = 'debug:wait'; + +const MAX_WAIT_TIME_IN_ISO = 'T00:00:30'; + +const examples = [ + { + description: 'Waiting for 5 seconds', + example: yaml.stringify({ + steps: [ + { + action: id, + id: 'wait-5sec', + name: 'Waiting for 5 seconds', + input: { + seconds: 5, + }, + }, + ], + }), + }, + { + description: 'Waiting for 5 minutes', + example: yaml.stringify({ + steps: [ + { + action: id, + id: 'wait-5min', + name: 'Waiting for 5 minutes', + input: { + minutes: 5, + }, + }, + ], + }), + }, +]; + +/** + * Waits for a certain period of time. + * + * @remarks + * + * This task is useful to give some waiting time for manual intervention. + * Has to be used in a combination with other actions. + * + * @public + */ +export function createWaitAction(options?: { + maxWaitTime?: Duration | HumanDuration; +}) { + const toDuration = ( + maxWaitTime: Duration | HumanDuration | undefined, + ): Duration => { + if (maxWaitTime) { + if (maxWaitTime instanceof Duration) { + return maxWaitTime; + } + return Duration.fromObject(maxWaitTime); + } + return Duration.fromISOTime(MAX_WAIT_TIME_IN_ISO); + }; + + return createTemplateAction({ + id, + description: 'Waits for a certain period of time.', + examples, + schema: { + input: { + type: 'object', + properties: { + minutes: { + title: 'Waiting period in minutes.', + type: 'number', + }, + seconds: { + title: 'Waiting period in seconds.', + type: 'number', + }, + milliseconds: { + title: 'Waiting period in milliseconds.', + type: 'number', + }, + }, + }, + }, + async handler(ctx) { + const delayTime = Duration.fromObject(ctx.input); + const maxWait = toDuration(options?.maxWaitTime); + + if (delayTime.minus(maxWait).toMillis() > 0) { + throw new Error( + `Waiting duration is longer than the maximum threshold of ${maxWait.toHuman()}`, + ); + } + + await new Promise(resolve => { + const controller = new AbortController(); + const timeoutHandle = setTimeout(abort, delayTime.toMillis()); + ctx.signal?.addEventListener('abort', abort); + + function abort() { + ctx.signal?.removeEventListener('abort', abort); + clearTimeout(timeoutHandle!); + controller.abort(); + resolve('finished'); + } + }); + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index 1929e3b8ac..d9302f4d74 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -25,7 +25,7 @@ import { SerializedFile, serializeDirectoryContents, } from '../../lib/files'; -import { TemplateFilter, TemplateGlobal } from '../../lib/templating'; +import { TemplateFilter, TemplateGlobal } from '../../lib'; import { TemplateActionRegistry } from '../actions'; import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner'; import { DecoratedActionsRegistry } from './DecoratedActionsRegistry'; @@ -94,6 +94,8 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { try { await deserializeDirectoryContents(contentsPath, input.directoryContents); + const abortSignal = new AbortController().signal; + const result = await workflowRunner.execute({ spec: { ...input.spec, @@ -117,6 +119,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { done: false, isDryRun: true, getWorkspaceName: async () => `dry-run-${dryRunId}`, + cancelSignal: abortSignal, async emitLog(message: string, logMetadata?: JsonObject) { if (logMetadata?.stepId === dryRunId) { return; @@ -128,7 +131,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { }, }); }, - async complete() { + complete: async () => { throw new Error('Not implemented'); }, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.test.ts new file mode 100644 index 0000000000..4c0eb94c82 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright 2021 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 { DatabaseManager } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { DatabaseTaskStore } from './DatabaseTaskStore'; +import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { ConflictError } from '@backstage/errors'; + +const createStore = async () => { + const manager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('scaffolder'); + const store = await DatabaseTaskStore.create({ + database: manager, + }); + return { store, manager }; +}; + +describe('DatabaseTaskStore', () => { + it('should create the database store and run migration', async () => { + const { store, manager } = await createStore(); + expect(store).toBeDefined(); + + const client = await manager.getClient(); + expect(client.schema.hasTable('tasks')).toBeTruthy(); + expect(client.schema.hasTable('task_events')).toBeTruthy(); + }); + + it('should list all created tasks', async () => { + const { store } = await createStore(); + await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + + const { tasks } = await store.list({}); + expect(tasks.length).toBe(1); + expect(tasks[0].createdBy).toBe('me'); + expect(tasks[0].status).toBe('open'); + expect(tasks[0].id).toBeDefined(); + }); + + it('should list filtered created tasks by createdBy', async () => { + const { store } = await createStore(); + + await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + + await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'him', + }); + + const { tasks } = await store.list({ createdBy: 'him' }); + expect(tasks.length).toBe(1); + expect(tasks[0].createdBy).toBe('him'); + expect(tasks[0].status).toBe('open'); + expect(tasks[0].id).toBeDefined(); + }); + + it('should sent an event to start cancelling the task', async () => { + const { store } = await createStore(); + + const { taskId } = await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + const task = await store.getTask(taskId); + expect(task.status).toBe('open'); + + await store.cancelTask({ + taskId, + body: { + message: `Step 2 has been cancelled.`, + stepId: 2, + status: 'cancelled', + }, + }); + + const { events } = await store.listEvents({ taskId }); + const event = events[0]; + expect(event.taskId).toBe(taskId); + expect(event.body.status).toBe('cancelled'); + }); + + it('should emit a log event', async () => { + const { store } = await createStore(); + const { taskId } = await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + await store.emitLogEvent({ + taskId, + body: { + message: 'Step #2 failed', + stepId: 2, + status: 'failed', + }, + }); + const { events } = await store.listEvents({ taskId }); + const event = events[0]; + expect(event.taskId).toBe(taskId); + expect(event.body.status).toBe('failed'); + expect(event.type).toBe('log'); + }); + + it('should complete the task', async () => { + const { store } = await createStore(); + const { taskId } = await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + const task = await store.getTask(taskId); + expect(task.status).toBe('open'); + + const message = `This task was marked as stale as it exceeded its timeout`; + await store.completeTask({ + taskId, + status: 'cancelled', + eventBody: { message }, + }); + + const taskAfterCompletion = await store.getTask(taskId); + expect(taskAfterCompletion.status).toBe('cancelled'); + }); + + it('should claim a new task', async () => { + const { store } = await createStore(); + const { taskId } = await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + const task = await store.getTask(taskId); + expect(task.status).toBe('open'); + await store.claimTask(); + + const claimedTask = await store.getTask(taskId); + expect(claimedTask.status).toBe('processing'); + }); + + it('should shutdown the running task', async () => { + const { store } = await createStore(); + const { taskId } = await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + const task = await store.getTask(taskId); + expect(task.status).toBe('open'); + await store.claimTask(); + await store.shutdownTask({ taskId }); + + const claimedTask = await store.getTask(taskId); + expect(claimedTask.status).toBe('failed'); + }); + + it('should be not possible to shutdown not running task', async () => { + const { store } = await createStore(); + const { taskId } = await store.createTask({ + spec: {} as TaskSpec, + createdBy: 'me', + }); + const task = await store.getTask(taskId); + expect(task.status).toBe('open'); + await expect(async () => { + await store.shutdownTask({ taskId }); + }).rejects.toThrow(ConflictError); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts index 879b457544..f3cb9b645e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -220,7 +220,7 @@ export class DatabaseTaskStore implements TaskStore { .update({ status: 'processing', last_heartbeat_at: this.db.fn.now(), - // remove the secrets when moving moving to processing state. + // remove the secrets when moving to processing state. secrets: null, }); @@ -287,13 +287,14 @@ export class DatabaseTaskStore implements TaskStore { const { taskId, status, eventBody } = options; let oldStatus: string; - if (status === 'failed' || status === 'completed') { + if (['failed', 'completed', 'cancelled'].includes(status)) { oldStatus = 'processing'; } else { throw new Error( `Invalid status update of run '${taskId}' to status '${status}'`, ); } + await this.db.transaction(async tx => { const [task] = await tx('tasks') .where({ @@ -302,6 +303,40 @@ export class DatabaseTaskStore implements TaskStore { .limit(1) .select(); + const updateTask = async (criteria: { + id: string; + status?: TaskStatus; + }) => { + const updateCount = await tx('tasks') + .where(criteria) + .update({ + status, + }); + + if (updateCount !== 1) { + throw new ConflictError( + `Failed to update status to '${status}' for taskId ${taskId}`, + ); + } + + await tx('task_events').insert({ + task_id: taskId, + event_type: 'completion', + body: JSON.stringify(eventBody), + }); + }; + + if (status === 'cancelled') { + await updateTask({ + id: taskId, + }); + return; + } + + if (task.status === 'cancelled') { + return; + } + if (!task) { throw new Error(`No task with taskId ${taskId} found`); } @@ -311,25 +346,10 @@ export class DatabaseTaskStore implements TaskStore { `as it is currently '${task.status}', expected '${oldStatus}'`, ); } - const updateCount = await tx('tasks') - .where({ - id: taskId, - status: oldStatus, - }) - .update({ - status, - }); - if (updateCount !== 1) { - throw new ConflictError( - `Failed to update status to '${status}' for taskId ${taskId}`, - ); - } - - await tx('task_events').insert({ - task_id: taskId, - event_type: 'completion', - body: JSON.stringify(eventBody), + await updateTask({ + id: taskId, + status: oldStatus, }); }); } @@ -419,4 +439,16 @@ export class DatabaseTaskStore implements TaskStore { }, }); } + + async cancelTask( + options: TaskStoreEmitOptions<{ message: string } & JsonObject>, + ): Promise { + const { taskId, body } = options; + const serializedBody = JSON.stringify(body); + await this.db('task_events').insert({ + task_id: taskId, + event_type: 'cancelled', + body: serializedBody, + }); + } } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 0d2246ed36..3b6ec54249 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -70,6 +70,7 @@ describe('DefaultWorkflowRunner', () => { complete: async () => {}, done: false, emitLog: async () => {}, + cancelSignal: new AbortController().signal, getWorkspaceName: () => Promise.resolve('test-workspace'), }); @@ -166,7 +167,7 @@ describe('DefaultWorkflowRunner', () => { }); await expect(runner.execute(task)).rejects.toThrow( - /Invalid input passed to action jest-validated-action, instance requires property \"foo\"/, + /Invalid input passed to action jest-validated-action, instance requires property "foo"/, ); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 88abe05cad..629c730d48 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -15,7 +15,12 @@ */ import { ScmIntegrations } from '@backstage/integration'; -import { TaskContext, WorkflowResponse, WorkflowRunner } from './types'; +import { + TaskContext, + TaskTrackType, + WorkflowResponse, + WorkflowRunner, +} from './types'; import * as winston from 'winston'; import fs from 'fs-extra'; import path from 'path'; @@ -99,6 +104,7 @@ const createStepLogger = ({ export class NunjucksWorkflowRunner implements WorkflowRunner { constructor(private readonly options: NunjucksWorkflowRunnerOptions) {} + private readonly tracker = scaffoldingTracker(); private isSingleTemplateString(input: string) { @@ -180,6 +186,138 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { }); } + async executeStep( + task: TaskContext, + step: TaskStep, + context: TemplateContext, + renderTemplate: (template: string, values: unknown) => string, + taskTrack: TaskTrackType, + workspacePath: string, + ) { + const stepTrack = await this.tracker.stepStart(task, step); + + if (task.cancelSignal.aborted) { + throw new Error(`Step ${step.name} has been cancelled.`); + } + + try { + if (step.if) { + const ifResult = await this.render(step.if, context, renderTemplate); + if (!isTruthy(ifResult)) { + await stepTrack.skipFalsy(); + return; + } + } + + const action: TemplateAction = + this.options.actionRegistry.get(step.action); + const { taskLogger, streamLogger } = createStepLogger({ task, step }); + + if (task.isDryRun) { + const redactedSecrets = Object.fromEntries( + Object.entries(task.secrets ?? {}).map(secret => [ + secret[0], + '[REDACTED]', + ]), + ); + const debugInput = + (step.input && + this.render( + step.input, + { + ...context, + secrets: redactedSecrets, + }, + renderTemplate, + )) ?? + {}; + taskLogger.info( + `Running ${ + action.id + } in dry-run mode with inputs (secrets redacted): ${JSON.stringify( + debugInput, + undefined, + 2, + )}`, + ); + if (!action.supportsDryRun) { + await taskTrack.skipDryRun(step, action); + const outputSchema = action.schema?.output; + if (outputSchema) { + context.steps[step.id] = { + output: generateExampleOutput(outputSchema) as { + [name in string]: JsonValue; + }, + }; + } else { + context.steps[step.id] = { output: {} }; + } + return; + } + } + + // Secrets are only passed when templating the input to actions for security reasons + const input = + (step.input && + this.render( + step.input, + { ...context, secrets: task.secrets ?? {} }, + renderTemplate, + )) ?? + {}; + + if (action.schema?.input) { + const validateResult = validateJsonSchema(input, action.schema.input); + if (!validateResult.valid) { + const errors = validateResult.errors.join(', '); + throw new InputError( + `Invalid input passed to action ${action.id}, ${errors}`, + ); + } + } + + const tmpDirs = new Array(); + const stepOutput: { [outputName: string]: JsonValue } = {}; + + await action.handler({ + input, + secrets: task.secrets ?? {}, + logger: taskLogger, + logStream: streamLogger, + workspacePath, + createTemporaryDirectory: async () => { + const tmpDir = await fs.mkdtemp(`${workspacePath}_step-${step.id}-`); + tmpDirs.push(tmpDir); + return tmpDir; + }, + output(name: string, value: JsonValue) { + stepOutput[name] = value; + }, + templateInfo: task.spec.templateInfo, + user: task.spec.user, + isDryRun: task.isDryRun, + signal: task.cancelSignal, + }); + + // Remove all temporary directories that were created when executing the action + for (const tmpDir of tmpDirs) { + await fs.remove(tmpDir); + } + + context.steps[step.id] = { output: stepOutput }; + + if (task.cancelSignal.aborted) { + throw new Error(`Step ${step.name} has been cancelled.`); + } + + await stepTrack.markSuccessful(); + } catch (err) { + await taskTrack.markFailed(step, err); + await stepTrack.markFailed(); + throw err; + } + } + async execute(task: TaskContext): Promise { if (!isValidTaskSpec(task.spec)) { throw new InputError( @@ -191,7 +329,12 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { await task.getWorkspaceName(), ); - const { integrations } = this.options; + const { + additionalTemplateFilters, + additionalTemplateGlobals, + integrations, + } = this.options; + const renderTemplate = await SecureTemplater.loadRenderer({ // TODO(blam): let's work out how we can deprecate this. // We shouldn't really need to be exposing these now we can deal with @@ -200,8 +343,8 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { parseRepoUrl(url: string) { return parseRepoUrl(url, integrations); }, - additionalTemplateFilters: this.options.additionalTemplateFilters, - additionalTemplateGlobals: this.options.additionalTemplateGlobals, + additionalTemplateFilters, + additionalTemplateGlobals, }); try { @@ -215,126 +358,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { }; for (const step of task.spec.steps) { - const stepTrack = await this.tracker.stepStart(task, step); - try { - if (step.if) { - const ifResult = await this.render( - step.if, - context, - renderTemplate, - ); - if (!isTruthy(ifResult)) { - await stepTrack.skipFalsy(); - continue; - } - } - - const action = this.options.actionRegistry.get(step.action); - const { taskLogger, streamLogger } = createStepLogger({ task, step }); - - if (task.isDryRun) { - const redactedSecrets = Object.fromEntries( - Object.entries(task.secrets ?? {}).map(secret => [ - secret[0], - '[REDACTED]', - ]), - ); - const debugInput = - (step.input && - this.render( - step.input, - { - ...context, - secrets: redactedSecrets, - }, - renderTemplate, - )) ?? - {}; - taskLogger.info( - `Running ${ - action.id - } in dry-run mode with inputs (secrets redacted): ${JSON.stringify( - debugInput, - undefined, - 2, - )}`, - ); - if (!action.supportsDryRun) { - await taskTrack.skipDryRun(step, action); - const outputSchema = action.schema?.output; - if (outputSchema) { - context.steps[step.id] = { - output: generateExampleOutput(outputSchema) as { - [name in string]: JsonValue; - }, - }; - } else { - context.steps[step.id] = { output: {} }; - } - continue; - } - } - - // Secrets are only passed when templating the input to actions for security reasons - const input = - (step.input && - this.render( - step.input, - { ...context, secrets: task.secrets ?? {} }, - renderTemplate, - )) ?? - {}; - - if (action.schema?.input) { - const validateResult = validateJsonSchema( - input, - action.schema.input, - ); - if (!validateResult.valid) { - const errors = validateResult.errors.join(', '); - throw new InputError( - `Invalid input passed to action ${action.id}, ${errors}`, - ); - } - } - - const tmpDirs = new Array(); - const stepOutput: { [outputName: string]: JsonValue } = {}; - - await action.handler({ - input, - secrets: task.secrets ?? {}, - logger: taskLogger, - logStream: streamLogger, - workspacePath, - createTemporaryDirectory: async () => { - const tmpDir = await fs.mkdtemp( - `${workspacePath}_step-${step.id}-`, - ); - tmpDirs.push(tmpDir); - return tmpDir; - }, - output(name: string, value: JsonValue) { - stepOutput[name] = value; - }, - templateInfo: task.spec.templateInfo, - user: task.spec.user, - isDryRun: task.isDryRun, - }); - - // Remove all temporary directories that were created when executing the action - for (const tmpDir of tmpDirs) { - await fs.remove(tmpDir); - } - - context.steps[step.id] = { output: stepOutput }; - - await stepTrack.markSuccessful(); - } catch (err) { - await taskTrack.markFailed(step, err); - await stepTrack.markFailed(); - throw err; - } + await this.executeStep( + task, + step, + context, + renderTemplate, + taskTrack, + workspacePath, + ); } const output = this.render(task.spec.output, context, renderTemplate); @@ -380,7 +411,10 @@ function scaffoldingTracker() { template, }); - async function skipDryRun(step: TaskStep, action: TemplateAction) { + async function skipDryRun( + step: TaskStep, + action: TemplateAction, + ) { task.emitLog(`Skipping because ${action.id} does not support dry-run`, { stepId: step.id, status: 'skipped', @@ -409,8 +443,22 @@ function scaffoldingTracker() { taskTimer({ result: 'failed' }); } + async function markCancelled(step: TaskStep) { + await task.emitLog(`Step ${step.id} has been cancelled.`, { + stepId: step.id, + status: 'cancelled', + }); + taskCount.inc({ + template, + user, + result: 'cancelled', + }); + taskTimer({ result: 'cancelled' }); + } + return { skipDryRun, + markCancelled, markSuccessful, markFailed, }; @@ -441,6 +489,15 @@ function scaffoldingTracker() { stepTimer({ result: 'ok' }); } + async function markCancelled() { + stepCount.inc({ + template, + step: step.name, + result: 'cancelled', + }); + stepTimer({ result: 'cancelled' }); + } + async function markFailed() { stepCount.inc({ template, @@ -459,8 +516,9 @@ function scaffoldingTracker() { } return { - markSuccessful, + markCancelled, markFailed, + markSuccessful, skipFalsy, }; } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index d01351d8ae..fc5a724e47 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -39,8 +39,13 @@ export class TaskManager implements TaskContext { private heartbeatTimeoutId?: ReturnType; - static create(task: CurrentClaimedTask, storage: TaskStore, logger: Logger) { - const agent = new TaskManager(task, storage, logger); + static create( + task: CurrentClaimedTask, + storage: TaskStore, + abortSignal: AbortSignal, + logger: Logger, + ) { + const agent = new TaskManager(task, storage, abortSignal, logger); agent.startTimeout(); return agent; } @@ -49,6 +54,7 @@ export class TaskManager implements TaskContext { private constructor( private readonly task: CurrentClaimedTask, private readonly storage: TaskStore, + private readonly signal: AbortSignal, private readonly logger: Logger, ) {} @@ -56,6 +62,10 @@ export class TaskManager implements TaskContext { return this.task.spec; } + get cancelSignal() { + return this.signal; + } + get secrets() { return this.task.secrets; } @@ -165,6 +175,33 @@ export class StorageTaskBroker implements TaskBroker { private deferredDispatch = defer(); + private async registerCancellable( + taskId: string, + abortController: AbortController, + ) { + let shouldUnsubscribe = false; + const subscription = this.event$({ taskId, after: undefined }).subscribe({ + error: _ => { + subscription.unsubscribe(); + }, + next: ({ events }) => { + for (const event of events) { + if (event.type === 'cancelled') { + abortController.abort(); + shouldUnsubscribe = true; + } + + if (event.type === 'completion') { + shouldUnsubscribe = true; + } + } + if (shouldUnsubscribe) { + subscription.unsubscribe(); + } + }, + }); + } + /** * {@inheritdoc TaskBroker.claim} */ @@ -172,6 +209,8 @@ export class StorageTaskBroker implements TaskBroker { for (;;) { const pendingTask = await this.storage.claimTask(); if (pendingTask) { + const abortController = new AbortController(); + await this.registerCancellable(pendingTask.id, abortController); return TaskManager.create( { taskId: pendingTask.id, @@ -180,6 +219,7 @@ export class StorageTaskBroker implements TaskBroker { createdBy: pendingTask.createdBy, }, this.storage, + abortController.signal, this.logger, ); } @@ -271,4 +311,24 @@ export class StorageTaskBroker implements TaskBroker { this.deferredDispatch.resolve(); this.deferredDispatch = defer(); } + + async cancel(taskId: string) { + const { events } = await this.storage.listEvents({ taskId }); + const currentStepId = + events.length > 0 + ? events + .filter(({ body }) => body?.stepId) + .reduce((prev, curr) => (prev.id > curr.id ? prev : curr)).body + .stepId + : 0; + + await this.storage.cancelTask?.({ + taskId, + body: { + message: `Step ${currentStepId} has been cancelled.`, + stepId: currentStepId, + status: 'cancelled', + }, + }); + } } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index d39671cb67..e886b31369 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts @@ -15,7 +15,7 @@ */ import os from 'os'; -import { getVoidLogger, DatabaseManager } from '@backstage/backend-common'; +import { DatabaseManager, getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DatabaseTaskStore } from './DatabaseTaskStore'; import { StorageTaskBroker } from './StorageTaskBroker'; @@ -23,7 +23,14 @@ import { TaskWorker, TaskWorkerOptions } from './TaskWorker'; import { ScmIntegrations } from '@backstage/integration'; import { TemplateActionRegistry } from '../actions'; import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner'; -import { TaskBroker, TaskContext, WorkflowRunner } from './types'; +import { + SerializedTaskEvent, + TaskBroker, + TaskContext, + WorkflowRunner, +} from './types'; +import ObservableImpl from 'zen-observable'; +import waitForExpect from 'wait-for-expect'; jest.mock('./NunjucksWorkflowRunner'); const MockedNunjucksWorkflowRunner = @@ -198,6 +205,67 @@ describe('Concurrent TaskWorker', () => { }); }); +describe('Cancellable TaskWorker', () => { + let storage: DatabaseTaskStore; + const integrations: ScmIntegrations = {} as ScmIntegrations; + const actionRegistry: TemplateActionRegistry = {} as TemplateActionRegistry; + const workingDirectory = os.tmpdir(); + + let myTask: TaskContext | undefined = undefined; + + const workflowRunner: NunjucksWorkflowRunner = { + execute: (task: TaskContext) => { + myTask = task; + }, + } as unknown as NunjucksWorkflowRunner; + + beforeAll(async () => { + storage = await createStore(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + MockedNunjucksWorkflowRunner.mockImplementation(() => workflowRunner); + }); + + const logger = getVoidLogger(); + + it('should be able to cancel the running task', async () => { + const taskBroker = new StorageTaskBroker(storage, logger); + const taskWorker = await TaskWorker.create({ + logger, + workingDirectory, + integrations, + taskBroker, + actionRegistry, + }); + + const steps = [...Array(10)].map(n => ({ + id: `test${n}`, + name: `test${n}`, + action: 'not-found-action', + })); + + const { taskId } = await taskBroker.dispatch({ + spec: { + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps, + output: { + result: '{{ steps.test.output.testOutput }}', + }, + parameters: {}, + }, + }); + + await taskWorker.start(); + await taskBroker.cancel(taskId); + + await waitForExpect(() => { + expect(myTask?.cancelSignal.aborted).toBeTruthy(); + }); + }); +}); + describe('TaskWorker internals', () => { const TaskWorkerConstructor = TaskWorker as unknown as { new (options: TaskWorkerOptions): TaskWorker; @@ -219,10 +287,24 @@ describe('TaskWorker internals', () => { }, }; + const subscribers = new Set< + ZenObservable.SubscriptionObserver<{ events: SerializedTaskEvent[] }> + >(); + let claimedTaskCount = 0; const taskWorker = new TaskWorkerConstructor({ runners: { workflowRunner }, taskBroker: { + event$() { + return new ObservableImpl<{ events: SerializedTaskEvent[] }>( + subscriber => { + subscribers.add(subscriber); + return () => { + subscribers.delete(subscriber); + }; + }, + ); + }, async claim() { claimedTaskCount++; return { @@ -233,7 +315,7 @@ describe('TaskWorker internals', () => { async complete(_result, _metadata) {}, } as TaskContext; }, - } as TaskBroker, + } as unknown as TaskBroker, concurrentTasksLimit: 2, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 82829665a3..5723e7136f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -21,10 +21,7 @@ import { Logger } from 'winston'; import { TemplateActionRegistry } from '../actions'; import { ScmIntegrations } from '@backstage/integration'; import { assertError } from '@backstage/errors'; -import { - TemplateFilter, - TemplateGlobal, -} from '../../lib/templating/SecureTemplater'; +import { TemplateFilter, TemplateGlobal } from '../../lib'; /** * TaskWorkerOptions diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 5a91802f3e..112276b9dd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -15,8 +15,9 @@ */ import { JsonValue, JsonObject, Observable } from '@backstage/types'; -import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { TaskSpec, TaskStep } from '@backstage/plugin-scaffolder-common'; import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; /** * The status of each step of the Task @@ -24,11 +25,11 @@ import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; * @public */ export type TaskStatus = - | 'open' - | 'processing' - | 'failed' | 'cancelled' - | 'completed'; + | 'completed' + | 'failed' + | 'open' + | 'processing'; /** * The state of a completed task. @@ -57,7 +58,7 @@ export type SerializedTask = { * * @public */ -export type TaskEventType = 'completion' | 'log'; +export type TaskEventType = 'completion' | 'log' | 'cancelled'; /** * SerializedTaskEvent @@ -99,13 +100,17 @@ export type TaskBrokerDispatchOptions = { * @public */ export interface TaskContext { + cancelSignal: AbortSignal; spec: TaskSpec; secrets?: TaskSecrets; createdBy?: string; done: boolean; isDryRun?: boolean; - emitLog(message: string, logMetadata?: JsonObject): Promise; + complete(result: TaskCompletionState, metadata?: JsonObject): Promise; + + emitLog(message: string, logMetadata?: JsonObject): Promise; + getWorkspaceName(): Promise; } @@ -115,16 +120,23 @@ export interface TaskContext { * @public */ export interface TaskBroker { + cancel?(taskId: string): Promise; + claim(): Promise; + dispatch( options: TaskBrokerDispatchOptions, ): Promise; + vacuumTasks(options: { timeoutS: number }): Promise; + event$(options: { taskId: string; after: number | undefined; }): Observable<{ events: SerializedTaskEvent[] }>; + get(taskId: string): Promise; + list?(options?: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>; } @@ -181,30 +193,51 @@ export type TaskStoreCreateTaskResult = { * @public */ export interface TaskStore { + cancelTask?(options: TaskStoreEmitOptions): Promise; + createTask( options: TaskStoreCreateTaskOptions, ): Promise; + getTask(taskId: string): Promise; + claimTask(): Promise; + completeTask(options: { taskId: string; status: TaskStatus; eventBody: JsonObject; }): Promise; + heartbeatTask(taskId: string): Promise; + listStaleTasks(options: { timeoutS: number }): Promise<{ tasks: { taskId: string }[]; }>; + list?(options: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>; emitLogEvent(options: TaskStoreEmitOptions): Promise; + listEvents( options: TaskStoreListEventsOptions, ): Promise<{ events: SerializedTaskEvent[] }>; + shutdownTask?(options: TaskStoreShutDownTaskOptions): Promise; } export type WorkflowResponse = { output: { [key: string]: JsonValue } }; + export interface WorkflowRunner { execute(task: TaskContext): Promise; } + +export type TaskTrackType = { + markCancelled: (step: TaskStep) => Promise; + markFailed: (step: TaskStep, err: Error) => Promise; + markSuccessful: () => Promise; + skipDryRun: ( + step: TaskStep, + action: TemplateAction, + ) => Promise; +}; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index dfccc4dc43..b2be3e2749 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -414,6 +414,11 @@ export async function createRouter( delete task.secrets; res.status(200).json(task); }) + .post('/v2/tasks/:taskId/cancel', async (req, res) => { + const { taskId } = req.params; + await taskBroker.cancel?.(taskId); + res.status(200).json({ status: 'cancelled' }); + }) .get('/v2/tasks/:taskId/eventstream', async (req, res) => { const { taskId } = req.params; const after = diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index 89b7171ef9..d5b9b73767 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -30,6 +30,7 @@ export type ActionContext = { entity?: UserEntity; ref?: string; }; + signal?: AbortSignal; }; // @public diff --git a/plugins/scaffolder-node/src/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts index cce149adbe..8827aaf98c 100644 --- a/plugins/scaffolder-node/src/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -60,6 +60,11 @@ export type ActionContext = { */ ref?: string; }; + + /** + * Implement the signal to make your custom step abortable https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal + */ + signal?: AbortSignal; }; /** @public */ diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index 0fbc8d0eed..da8309d5ec 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -113,7 +113,7 @@ export type ListActionsResponse = Array; // @public export type LogEvent = { - type: 'log' | 'completion'; + type: 'log' | 'completion' | 'cancelled'; body: { message: string; stepId?: string; @@ -126,6 +126,7 @@ export type LogEvent = { // @public export interface ScaffolderApi { + cancelTask(taskId: string): Promise; // (undocumented) dryRun?(options: ScaffolderDryRunOptions): Promise; // (undocumented) @@ -266,10 +267,11 @@ export type ScaffolderTaskOutput = { // @public export type ScaffolderTaskStatus = + | 'cancelled' + | 'completed' + | 'failed' | 'open' | 'processing' - | 'failed' - | 'completed' | 'skipped'; // @public @@ -287,6 +289,7 @@ export const SecretsContextProvider: ( // @public export type TaskStream = { + cancelled: boolean; loading: boolean; error?: Error; stepLogs: { diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index 0a517e7bff..b39555c14f 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -24,10 +24,11 @@ import { TemplateParameterSchema } from '../types'; * @public */ export type ScaffolderTaskStatus = + | 'cancelled' + | 'completed' + | 'failed' | 'open' | 'processing' - | 'failed' - | 'completed' | 'skipped'; /** @@ -96,7 +97,7 @@ export type ScaffolderTaskOutput = { * @public */ export type LogEvent = { - type: 'log' | 'completion'; + type: 'log' | 'completion' | 'cancelled'; body: { message: string; stepId?: string; @@ -196,6 +197,13 @@ export interface ScaffolderApi { getTask(taskId: string): Promise; + /** + * Sends a signal to a task broker to cancel the running task by taskId. + * + * @param taskId - the id of the task + */ + cancelTask(taskId: string): Promise; + listTasks?(options: { filterByOwnership: 'owned' | 'all'; }): Promise<{ tasks: ScaffolderTask[] }>; diff --git a/plugins/scaffolder-react/src/hooks/useEventStream.ts b/plugins/scaffolder-react/src/hooks/useEventStream.ts index d8c9cf4c1f..ec49911471 100644 --- a/plugins/scaffolder-react/src/hooks/useEventStream.ts +++ b/plugins/scaffolder-react/src/hooks/useEventStream.ts @@ -44,6 +44,7 @@ export type ScaffolderStep = { * @public */ export type TaskStream = { + cancelled: boolean; loading: boolean; error?: Error; stepLogs: { [stepId in string]: string[] }; @@ -66,6 +67,7 @@ type ReducerLogEntry = { type ReducerAction = | { type: 'INIT'; data: ScaffolderTask } + | { type: 'CANCELLED' } | { type: 'LOGS'; data: ReducerLogEntry[] } | { type: 'COMPLETED'; data: ReducerLogEntry } | { type: 'ERROR'; data: Error }; @@ -111,7 +113,7 @@ function reducer(draft: TaskStream, action: ReducerAction) { } if ( - ['cancelled', 'failed', 'completed'].includes(currentStep.status) + ['cancelled', 'completed', 'failed'].includes(currentStep.status) ) { currentStep.endedAt = entry.createdAt; } @@ -131,6 +133,11 @@ function reducer(draft: TaskStream, action: ReducerAction) { return; } + case 'CANCELLED': { + draft.cancelled = true; + return; + } + case 'ERROR': { draft.error = action.data; draft.loading = false; @@ -151,6 +158,7 @@ function reducer(draft: TaskStream, action: ReducerAction) { export const useTaskEventStream = (taskId: string): TaskStream => { const scaffolderApi = useApi(scaffolderApiRef); const [state, dispatch] = useImmerReducer(reducer, { + cancelled: false, loading: true, completed: false, stepLogs: {} as { [stepId in string]: string[] }, @@ -196,6 +204,9 @@ export const useTaskEventStream = (taskId: string): TaskStream => { switch (event.type) { case 'log': return collectedLogEvents.push(event); + case 'cancelled': + dispatch({ type: 'CANCELLED' }); + return undefined; case 'completion': emitLogs(); dispatch({ type: 'COMPLETED', data: event }); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index 03604881d2..81c5a697e0 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -24,10 +24,10 @@ import { act, fireEvent } from '@testing-library/react'; import React from 'react'; import { Workflow } from './Workflow'; import { analyticsApiRef } from '@backstage/core-plugin-api'; -import { ScaffolderApi } from '../../../api/types'; -import { scaffolderApiRef } from '../../../api/ref'; +import { ScaffolderApi, scaffolderApiRef } from '../../../api'; const scaffolderApiMock: jest.Mocked = { + cancelTask: jest.fn(), scaffold: jest.fn(), getTemplateParameterSchema: jest.fn(), getIntegrationsList: jest.fn(), diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 6b8d6d345f..45d0b2675c 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -360,6 +360,8 @@ export class ScaffolderClient implements ScaffolderApi_2 { useLongPollingLogs?: boolean; }); // (undocumented) + cancelTask(taskId: string): Promise; + // (undocumented) dryRun( options: ScaffolderDryRunOptions_2, ): Promise; diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 15bd1925b3..7a3f6c14bb 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -229,24 +229,22 @@ export class ScaffolderClient implements ScaffolderApi { const url = `${baseUrl}/v2/tasks/${encodeURIComponent( taskId, )}/eventstream`; + + const processEvent = (event: any) => { + if (event.data) { + try { + subscriber.next(JSON.parse(event.data)); + } catch (ex) { + subscriber.error(ex); + } + } + }; + const eventSource = new EventSource(url, { withCredentials: true }); - eventSource.addEventListener('log', (event: any) => { - if (event.data) { - try { - subscriber.next(JSON.parse(event.data)); - } catch (ex) { - subscriber.error(ex); - } - } - }); + eventSource.addEventListener('log', processEvent); + eventSource.addEventListener('cancelled', processEvent); eventSource.addEventListener('completion', (event: any) => { - if (event.data) { - try { - subscriber.next(JSON.parse(event.data)); - } catch (ex) { - subscriber.error(ex); - } - } + processEvent(event); eventSource.close(); subscriber.complete(); }); @@ -310,4 +308,19 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } + + async cancelTask(taskId: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}/cancel`; + + const response = await this.fetchApi.fetch(url, { + method: 'POST', + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } } diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 552d0cea6a..90f6f9b393 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -25,6 +25,7 @@ import { rootRouteRef } from '../../routes'; const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), + cancelTask: jest.fn(), getTemplateParameterSchema: jest.fn(), getIntegrationsList: jest.fn(), getTask: jest.fn(), diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index f78d48e686..78cdc5d935 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -19,11 +19,15 @@ import { Content, ErrorPage, Header, - Page, LogViewer, + Page, Progress, } from '@backstage/core-components'; -import { useRouteRef, useRouteRefParams } from '@backstage/core-plugin-api'; +import { + useApi, + useRouteRef, + useRouteRefParams, +} from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { Button, @@ -59,6 +63,7 @@ import { scaffolderTaskRouteRef, selectedTemplateRouteRef, } from '../../routes'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -188,9 +193,10 @@ export const TaskStatusStepper = memo( nonLinear > {steps.map((step, index) => { + const isCancelled = step.status === 'cancelled'; + const isActive = step.status === 'processing'; const isCompleted = step.status === 'completed'; const isFailed = step.status === 'failed'; - const isActive = step.status === 'processing'; const isSkipped = step.status === 'skipped'; return ( @@ -199,7 +205,7 @@ export const TaskStatusStepper = memo( { const { loadingText } = props; - const classes = useStyles(); const navigate = useNavigate(); const rootPath = useRouteRef(rootRouteRef); + const scaffolderApi = useApi(scaffolderApiRef); const templateRoute = useRouteRef(selectedTemplateRouteRef); const [userSelectedStepId, setUserSelectedStepId] = useState< string | undefined >(undefined); + const [clickedToCancel, setClickedToCancel] = useState(false); const [lastActiveStepId, setLastActiveStepId] = useState( undefined, ); const { taskId } = useRouteRefParams(scaffolderTaskRouteRef); const taskStream = useTaskEventStream(taskId); const completed = taskStream.completed; + const taskCancelled = taskStream.cancelled; const steps = useMemo( () => taskStream.task?.spec.steps.map(step => ({ @@ -295,9 +302,7 @@ export const TaskPage = (props: TaskPageProps) => { }, [taskStream.stepLogs, currentStepId, loadingText]); const taskNotFound = - taskStream.completed === true && - taskStream.loading === false && - !taskStream.task; + taskStream.completed && !taskStream.loading && !taskStream.task; const { output } = taskStream; @@ -320,6 +325,11 @@ export const TaskPage = (props: TaskPageProps) => { ); }; + const handleCancel = async () => { + setClickedToCancel(true); + await scaffolderApi.cancelTask(taskId); + }; + return (
{ > Start Over + diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index b4358146fd..344929fa43 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -47,6 +47,7 @@ jest.mock('react-router-dom', () => { }); const scaffolderApiMock: jest.Mocked = { + cancelTask: jest.fn(), scaffold: jest.fn(), getTemplateParameterSchema: jest.fn(), getIntegrationsList: jest.fn(), @@ -338,10 +339,7 @@ describe('TemplatePage', () => { it('should display a section or property based on a feature flag', async () => { featureFlagsApiMock.isActive.mockImplementation(flag => { - if (flag === 'experimental-feature') { - return true; - } - return false; + return flag === 'experimental-feature'; }); scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue( schemaMockValue, diff --git a/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx b/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx index d4c771a9f8..985dd98903 100644 --- a/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx +++ b/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx @@ -23,15 +23,21 @@ import { MenuList, Popover, } from '@material-ui/core'; +import { useAsync } from '@react-hookz/web'; +import Cancel from '@material-ui/icons/Cancel'; import Retry from '@material-ui/icons/Repeat'; import Toc from '@material-ui/icons/Toc'; import MoreVert from '@material-ui/icons/MoreVert'; import React, { useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; type ContextMenuProps = { + cancelEnabled?: boolean; logsVisible?: boolean; - onToggleLogs?: (state: boolean) => void; onStartOver?: () => void; + onToggleLogs?: (state: boolean) => void; + taskId?: string; }; const useStyles = makeStyles((theme: BackstageTheme) => ({ @@ -41,10 +47,18 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ })); export const ContextMenu = (props: ContextMenuProps) => { - const { logsVisible, onToggleLogs, onStartOver } = props; + const { cancelEnabled, logsVisible, onStartOver, onToggleLogs, taskId } = + props; const classes = useStyles(); + const scaffolderApi = useApi(scaffolderApiRef); const [anchorEl, setAnchorEl] = useState(); + const [{ status: cancelStatus }, { execute: cancel }] = useAsync(async () => { + if (taskId) { + await scaffolderApi.cancelTask(taskId); + } + }); + return ( <> { + + + + + + diff --git a/plugins/scaffolder/src/next/OngoingTask/OngoingTask.test.tsx b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.test.tsx new file mode 100644 index 0000000000..9e9212b9e8 --- /dev/null +++ b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2022 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 { OngoingTask } from './OngoingTask'; +import React from 'react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { act, fireEvent, waitFor } from '@testing-library/react'; +import { nextRouteRef } from '../routes'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ taskId: 'my-task' }), +})); + +jest.mock('@backstage/plugin-scaffolder-react', () => ({ + ...jest.requireActual('@backstage/plugin-scaffolder-react'), + useTaskEventStream: () => ({ + cancelled: false, + loading: true, + stepLogs: {}, + completed: false, + steps: {}, + task: { + spec: { + steps: [], + templateInfo: { entity: { metadata: { name: 'my-template' } } }, + }, + }, + }), +})); + +describe('OngoingTask', () => { + const mockScaffolderApi = { + cancelTask: jest.fn(), + getTask: jest.fn().mockImplementation(async () => {}), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should trigger cancel api on "Cancel" click in context menu', async () => { + const cancelOptionLabel = 'Cancel'; + const rendered = await renderInTestApp( + + + , + { mountedRoutes: { '/': nextRouteRef } }, + ); + const { getByText, getByTestId } = rendered; + + await act(async () => { + fireEvent.click(getByTestId('menu-button')); + }); + expect(getByTestId('cancel-task')).not.toHaveClass('Mui-disabled'); + + await act(async () => { + fireEvent.click(getByText(cancelOptionLabel)); + }); + + expect(mockScaffolderApi.cancelTask).toHaveBeenCalled(); + await act(async () => { + fireEvent.click(getByTestId('menu-button')); + }); + + await waitFor(() => { + expect(getByTestId('cancel-task')).toHaveClass('Mui-disabled'); + }); + }); +}); diff --git a/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx index fc1c1a7413..9c64e2179b 100644 --- a/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect, useMemo, useState, useCallback } from 'react'; -import { Page, Header, Content, ErrorPanel } from '@backstage/core-components'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Content, ErrorPanel, Header, Page } from '@backstage/core-components'; import { useNavigate, useParams } from 'react-router-dom'; import { Box, makeStyles, Paper } from '@material-ui/core'; import { @@ -105,6 +105,8 @@ export const OngoingTask = (props: { const templateName = taskStream.task?.spec.templateInfo?.entity?.metadata.name; + const cancelEnabled = !(taskStream.cancelled || taskStream.completed); + return (
diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx index c4f9c75ceb..da4112979c 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -41,6 +41,7 @@ jest.mock('react-router-dom', () => { }); const scaffolderApiMock: jest.Mocked = { + cancelTask: jest.fn(), scaffold: jest.fn(), getTemplateParameterSchema: jest.fn(), getIntegrationsList: jest.fn(), diff --git a/yarn.lock b/yarn.lock index 01a2937093..41b5c0fd31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7500,6 +7500,7 @@ __metadata: "@types/express": ^4.17.6 "@types/fs-extra": ^9.0.1 "@types/git-url-parse": ^9.0.0 + "@types/luxon": ^3.0.0 "@types/mock-fs": ^4.13.0 "@types/nunjucks": ^3.1.4 "@types/supertest": ^2.0.8 @@ -7534,6 +7535,7 @@ __metadata: supertest: ^6.1.3 uuid: ^8.2.0 vm2: ^3.9.11 + wait-for-expect: ^3.0.2 winston: ^3.2.1 yaml: ^2.0.0 zen-observable: ^0.10.0