Merge pull request #15461 from acierto/abort-task
Cancelling the running task (executing of a scaffolder template)
This commit is contained in:
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 },
|
||||
_ => {},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user