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
+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(),