Merge pull request #15461 from acierto/abort-task

Cancelling the running task (executing of a scaffolder template)
This commit is contained in:
Fredrik Adelöw
2023-03-15 14:25:03 +01:00
committed by GitHub
35 changed files with 1107 additions and 217 deletions
+8
View File
@@ -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)
@@ -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
@@ -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 },
_ => {},
);
},
});
+29 -5
View File
@@ -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<TActionInput, TInputSchema, TOutputSchema>,
) => TemplateAction_2<TActionInput>;
// @public
export function createWaitAction(options?: {
maxWaitTime?: Duration | HumanDuration;
}): TemplateAction_2<HumanDuration>;
// @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<void>;
// (undocumented)
claimTask(): Promise<SerializedTask | undefined>;
// (undocumented)
@@ -694,6 +709,8 @@ export type SerializedTaskEvent = {
// @public
export interface TaskBroker {
// (undocumented)
cancel?(taskId: string): Promise<void>;
// (undocumented)
claim(): Promise<TaskContext>;
// (undocumented)
@@ -731,6 +748,8 @@ export type TaskCompletionState = 'failed' | 'completed';
// @public
export interface TaskContext {
// (undocumented)
cancelSignal: AbortSignal;
// (undocumented)
complete(result: TaskCompletionState, metadata?: JsonObject): Promise<void>;
// (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<void>;
// (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<void>;
// (undocumented)
claimTask(): Promise<SerializedTask | undefined>;
// (undocumented)
+2
View File
@@ -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": [
@@ -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(),
@@ -15,3 +15,4 @@
*/
export { createDebugLogAction } from './log';
export { createWaitAction } from './wait';
@@ -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<Partial<Writable>> as jest.Mocked<Writable>;
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',
);
});
});
@@ -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<HumanDuration>({
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');
}
});
},
});
}
@@ -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');
},
});
@@ -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);
});
});
@@ -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<RawDbTaskRow>('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<RawDbTaskRow>('tasks')
.where(criteria)
.update({
status,
});
if (updateCount !== 1) {
throw new ConflictError(
`Failed to update status to '${status}' for taskId ${taskId}`,
);
}
await tx<RawDbTaskEventRow>('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<RawDbTaskRow>('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<RawDbTaskEventRow>('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<void> {
const { taskId, body } = options;
const serializedBody = JSON.stringify(body);
await this.db<RawDbTaskEventRow>('task_events').insert({
task_id: taskId,
event_type: 'cancelled',
body: serializedBody,
});
}
}
@@ -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"/,
);
});
@@ -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<JsonObject> =
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<string>();
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<WorkflowResponse> {
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<string>();
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<JsonObject>,
) {
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,
};
}
@@ -39,8 +39,13 @@ export class TaskManager implements TaskContext {
private heartbeatTimeoutId?: ReturnType<typeof setInterval>;
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',
},
});
}
}
@@ -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,
});
@@ -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
@@ -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<void>;
complete(result: TaskCompletionState, metadata?: JsonObject): Promise<void>;
emitLog(message: string, logMetadata?: JsonObject): Promise<void>;
getWorkspaceName(): Promise<string>;
}
@@ -115,16 +120,23 @@ export interface TaskContext {
* @public
*/
export interface TaskBroker {
cancel?(taskId: string): Promise<void>;
claim(): Promise<TaskContext>;
dispatch(
options: TaskBrokerDispatchOptions,
): Promise<TaskBrokerDispatchResult>;
vacuumTasks(options: { timeoutS: number }): Promise<void>;
event$(options: {
taskId: string;
after: number | undefined;
}): Observable<{ events: SerializedTaskEvent[] }>;
get(taskId: string): Promise<SerializedTask>;
list?(options?: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>;
}
@@ -181,30 +193,51 @@ export type TaskStoreCreateTaskResult = {
* @public
*/
export interface TaskStore {
cancelTask?(options: TaskStoreEmitOptions): Promise<void>;
createTask(
options: TaskStoreCreateTaskOptions,
): Promise<TaskStoreCreateTaskResult>;
getTask(taskId: string): Promise<SerializedTask>;
claimTask(): Promise<SerializedTask | undefined>;
completeTask(options: {
taskId: string;
status: TaskStatus;
eventBody: JsonObject;
}): Promise<void>;
heartbeatTask(taskId: string): Promise<void>;
listStaleTasks(options: { timeoutS: number }): Promise<{
tasks: { taskId: string }[];
}>;
list?(options: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>;
emitLogEvent(options: TaskStoreEmitOptions): Promise<void>;
listEvents(
options: TaskStoreListEventsOptions,
): Promise<{ events: SerializedTaskEvent[] }>;
shutdownTask?(options: TaskStoreShutDownTaskOptions): Promise<void>;
}
export type WorkflowResponse = { output: { [key: string]: JsonValue } };
export interface WorkflowRunner {
execute(task: TaskContext): Promise<WorkflowResponse>;
}
export type TaskTrackType = {
markCancelled: (step: TaskStep) => Promise<void>;
markFailed: (step: TaskStep, err: Error) => Promise<void>;
markSuccessful: () => Promise<void>;
skipDryRun: (
step: TaskStep,
action: TemplateAction<JsonObject>,
) => Promise<void>;
};
@@ -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 =
+1
View File
@@ -30,6 +30,7 @@ export type ActionContext<TActionInput extends JsonObject> = {
entity?: UserEntity;
ref?: string;
};
signal?: AbortSignal;
};
// @public
@@ -60,6 +60,11 @@ export type ActionContext<TActionInput extends JsonObject> = {
*/
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 */
+6 -3
View File
@@ -113,7 +113,7 @@ export type ListActionsResponse = Array<Action>;
// @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<void>;
// (undocumented)
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
// (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: {
+11 -3
View File
@@ -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<ScaffolderTask>;
/**
* 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<void>;
listTasks?(options: {
filterByOwnership: 'owned' | 'all';
}): Promise<{ tasks: ScaffolderTask[] }>;
@@ -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 });
@@ -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<ScaffolderApi> = {
cancelTask: jest.fn(),
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
+2
View File
@@ -360,6 +360,8 @@ export class ScaffolderClient implements ScaffolderApi_2 {
useLongPollingLogs?: boolean;
});
// (undocumented)
cancelTask(taskId: string): Promise<void>;
// (undocumented)
dryRun(
options: ScaffolderDryRunOptions_2,
): Promise<ScaffolderDryRunResponse_2>;
+29 -16
View File
@@ -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<void> {
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();
}
}
@@ -25,6 +25,7 @@ import { rootRouteRef } from '../../routes';
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
cancelTask: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
@@ -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(
<StepLabel
StepIconProps={{
completed: isCompleted,
error: isFailed,
error: isFailed || isCancelled,
active: isActive,
}}
StepIconComponent={TaskStepIconComponent}
@@ -239,26 +245,27 @@ export type TaskPageProps = {
/**
* TaskPage for showing the status of the taskId provided as a param
* @param loadingText - Optional loading text shown before a task begins executing.
*
* @public
*/
export const TaskPage = (props: TaskPageProps) => {
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<boolean>(false);
const [lastActiveStepId, setLastActiveStepId] = useState<string | undefined>(
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 (
<Page themeId="home">
<Header
@@ -356,6 +366,17 @@ export const TaskPage = (props: TaskPageProps) => {
>
Start Over
</Button>
<Button
className={classes.button}
onClick={handleCancel}
disabled={completed || taskCancelled || clickedToCancel}
variant="outlined"
color="secondary"
>
{(taskCancelled || clickedToCancel) && !completed
? 'Cancelling...'
: 'Cancel'}
</Button>
</Paper>
</Grid>
<Grid item xs={9}>
@@ -47,6 +47,7 @@ jest.mock('react-router-dom', () => {
});
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
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,
@@ -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<HTMLButtonElement>();
const [{ status: cancelStatus }, { execute: cancel }] = useAsync(async () => {
if (taskId) {
await scaffolderApi.cancelTask(taskId);
}
});
return (
<>
<IconButton
@@ -80,6 +94,16 @@ export const ContextMenu = (props: ContextMenuProps) => {
</ListItemIcon>
<ListItemText primary="Start Over" />
</MenuItem>
<MenuItem
onClick={cancel}
disabled={!cancelEnabled || cancelStatus !== 'not-executed'}
data-testid="cancel-task"
>
<ListItemIcon>
<Cancel fontSize="small" />
</ListItemIcon>
<ListItemText primary="Cancel" />
</MenuItem>
</MenuList>
</Popover>
</>
@@ -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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<OngoingTask />
</TestApiProvider>,
{ 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');
});
});
});
@@ -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 (
<Page themeId="website">
<Header
@@ -117,9 +119,11 @@ export const OngoingTask = (props: {
subtitle={`Task ${taskId}`}
>
<ContextMenu
onToggleLogs={setLogVisibleState}
onStartOver={startOver}
cancelEnabled={cancelEnabled}
logsVisible={logsVisible}
onStartOver={startOver}
onToggleLogs={setLogVisibleState}
taskId={taskId}
/>
</Header>
<Content className={classes.contentWrapper}>
@@ -41,6 +41,7 @@ jest.mock('react-router-dom', () => {
});
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
cancelTask: jest.fn(),
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
+2
View File
@@ -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