diff --git a/.changeset/slow-trees-compare.md b/.changeset/slow-trees-compare.md new file mode 100644 index 0000000000..18e1a82ff9 --- /dev/null +++ b/.changeset/slow-trees-compare.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder-react': patch +'@backstage/plugin-scaffolder-node': patch +'@backstage/plugin-scaffolder': patch +--- + +Make it possible to manually retry the scaffolder template from the step it failed diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index 112d58046b..9ea60fcfdd 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -398,7 +398,6 @@ export interface CurrentClaimedTask { spec: TaskSpec; state?: JsonObject; taskId: string; - // (undocumented) workspace?: Promise; } @@ -488,6 +487,8 @@ export class DatabaseTaskStore implements TaskStore { targetPath: string; }): Promise; // (undocumented) + retryTask?(options: { taskId: string }): Promise; + // (undocumented) saveTaskState(options: { taskId: string; state?: JsonObject }): Promise; // (undocumented) serializeWorkspace(options: { path: string; taskId: string }): Promise; @@ -740,6 +741,8 @@ export interface TaskStore { targetPath: string; }): Promise; // (undocumented) + retryTask?(options: { taskId: string }): Promise; + // (undocumented) saveTaskState?(options: { taskId: string; state?: JsonObject; @@ -776,6 +779,7 @@ export type TaskStoreEmitOptions = { // @public export type TaskStoreListEventsOptions = { + isTaskRecoverable?: boolean; taskId: string; after?: number | undefined; }; @@ -884,7 +888,8 @@ export type TemplatePermissionRuleInput< // src/scaffolder/tasks/DatabaseTaskStore.d.ts:104:5 - (ae-undocumented) Missing documentation for "cleanWorkspace". // src/scaffolder/tasks/DatabaseTaskStore.d.ts:107:5 - (ae-undocumented) Missing documentation for "serializeWorkspace". // src/scaffolder/tasks/DatabaseTaskStore.d.ts:111:5 - (ae-undocumented) Missing documentation for "cancelTask". -// src/scaffolder/tasks/DatabaseTaskStore.d.ts:114:5 - (ae-undocumented) Missing documentation for "recoverTasks". +// src/scaffolder/tasks/DatabaseTaskStore.d.ts:114:5 - (ae-undocumented) Missing documentation for "retryTask". +// src/scaffolder/tasks/DatabaseTaskStore.d.ts:117:5 - (ae-undocumented) Missing documentation for "recoverTasks". // src/scaffolder/tasks/StorageTaskBroker.d.ts:24:5 - (ae-undocumented) Missing documentation for "create". // src/scaffolder/tasks/StorageTaskBroker.d.ts:26:5 - (ae-undocumented) Missing documentation for "spec". // src/scaffolder/tasks/StorageTaskBroker.d.ts:27:5 - (ae-undocumented) Missing documentation for "cancelSignal". @@ -900,31 +905,31 @@ export type TemplatePermissionRuleInput< // src/scaffolder/tasks/StorageTaskBroker.d.ts:52:5 - (ae-undocumented) Missing documentation for "cleanWorkspace". // src/scaffolder/tasks/StorageTaskBroker.d.ts:53:5 - (ae-undocumented) Missing documentation for "complete". // src/scaffolder/tasks/StorageTaskBroker.d.ts:55:5 - (ae-undocumented) Missing documentation for "getInitiatorCredentials". -// src/scaffolder/tasks/StorageTaskBroker.d.ts:83:5 - (ae-undocumented) Missing documentation for "workspace". // src/scaffolder/tasks/TaskWorker.d.ts:60:5 - (ae-undocumented) Missing documentation for "create". // src/scaffolder/tasks/TaskWorker.d.ts:61:5 - (ae-undocumented) Missing documentation for "recoverTasks". // src/scaffolder/tasks/TaskWorker.d.ts:62:5 - (ae-undocumented) Missing documentation for "start". // src/scaffolder/tasks/TaskWorker.d.ts:63:5 - (ae-undocumented) Missing documentation for "stop". // src/scaffolder/tasks/TaskWorker.d.ts:64:5 - (ae-undocumented) Missing documentation for "onReadyToClaimTask". // src/scaffolder/tasks/TaskWorker.d.ts:65:5 - (ae-undocumented) Missing documentation for "runOneTask". -// src/scaffolder/tasks/types.d.ts:123:5 - (ae-undocumented) Missing documentation for "cancelTask". -// src/scaffolder/tasks/types.d.ts:124:5 - (ae-undocumented) Missing documentation for "createTask". -// src/scaffolder/tasks/types.d.ts:125:5 - (ae-undocumented) Missing documentation for "recoverTasks". -// src/scaffolder/tasks/types.d.ts:128:5 - (ae-undocumented) Missing documentation for "getTask". -// src/scaffolder/tasks/types.d.ts:129:5 - (ae-undocumented) Missing documentation for "claimTask". -// src/scaffolder/tasks/types.d.ts:130:5 - (ae-undocumented) Missing documentation for "completeTask". -// src/scaffolder/tasks/types.d.ts:135:5 - (ae-undocumented) Missing documentation for "heartbeatTask". -// src/scaffolder/tasks/types.d.ts:136:5 - (ae-undocumented) Missing documentation for "listStaleTasks". -// src/scaffolder/tasks/types.d.ts:143:5 - (ae-undocumented) Missing documentation for "list". -// src/scaffolder/tasks/types.d.ts:163:5 - (ae-undocumented) Missing documentation for "list". -// src/scaffolder/tasks/types.d.ts:182:5 - (ae-undocumented) Missing documentation for "emitLogEvent". -// src/scaffolder/tasks/types.d.ts:183:5 - (ae-undocumented) Missing documentation for "getTaskState". -// src/scaffolder/tasks/types.d.ts:188:5 - (ae-undocumented) Missing documentation for "saveTaskState". -// src/scaffolder/tasks/types.d.ts:192:5 - (ae-undocumented) Missing documentation for "listEvents". -// src/scaffolder/tasks/types.d.ts:195:5 - (ae-undocumented) Missing documentation for "shutdownTask". -// src/scaffolder/tasks/types.d.ts:196:5 - (ae-undocumented) Missing documentation for "rehydrateWorkspace". -// src/scaffolder/tasks/types.d.ts:200:5 - (ae-undocumented) Missing documentation for "cleanWorkspace". -// src/scaffolder/tasks/types.d.ts:203:5 - (ae-undocumented) Missing documentation for "serializeWorkspace". +// src/scaffolder/tasks/types.d.ts:124:5 - (ae-undocumented) Missing documentation for "cancelTask". +// src/scaffolder/tasks/types.d.ts:125:5 - (ae-undocumented) Missing documentation for "createTask". +// src/scaffolder/tasks/types.d.ts:126:5 - (ae-undocumented) Missing documentation for "retryTask". +// src/scaffolder/tasks/types.d.ts:129:5 - (ae-undocumented) Missing documentation for "recoverTasks". +// src/scaffolder/tasks/types.d.ts:132:5 - (ae-undocumented) Missing documentation for "getTask". +// src/scaffolder/tasks/types.d.ts:133:5 - (ae-undocumented) Missing documentation for "claimTask". +// src/scaffolder/tasks/types.d.ts:134:5 - (ae-undocumented) Missing documentation for "completeTask". +// src/scaffolder/tasks/types.d.ts:139:5 - (ae-undocumented) Missing documentation for "heartbeatTask". +// src/scaffolder/tasks/types.d.ts:140:5 - (ae-undocumented) Missing documentation for "listStaleTasks". +// src/scaffolder/tasks/types.d.ts:147:5 - (ae-undocumented) Missing documentation for "list". +// src/scaffolder/tasks/types.d.ts:167:5 - (ae-undocumented) Missing documentation for "list". +// src/scaffolder/tasks/types.d.ts:186:5 - (ae-undocumented) Missing documentation for "emitLogEvent". +// src/scaffolder/tasks/types.d.ts:187:5 - (ae-undocumented) Missing documentation for "getTaskState". +// src/scaffolder/tasks/types.d.ts:192:5 - (ae-undocumented) Missing documentation for "saveTaskState". +// src/scaffolder/tasks/types.d.ts:196:5 - (ae-undocumented) Missing documentation for "listEvents". +// src/scaffolder/tasks/types.d.ts:199:5 - (ae-undocumented) Missing documentation for "shutdownTask". +// src/scaffolder/tasks/types.d.ts:200:5 - (ae-undocumented) Missing documentation for "rehydrateWorkspace". +// src/scaffolder/tasks/types.d.ts:204:5 - (ae-undocumented) Missing documentation for "cleanWorkspace". +// src/scaffolder/tasks/types.d.ts:207:5 - (ae-undocumented) Missing documentation for "serializeWorkspace". // src/service/router.d.ts:17:1 - (ae-undocumented) Missing documentation for "TemplatePermissionRuleInput". // src/service/router.d.ts:22:1 - (ae-undocumented) Missing documentation for "ActionPermissionRuleInput". // src/service/router.d.ts:30:5 - (ae-undocumented) Missing documentation for "logger". diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.test.ts index a58c5cb863..fbd314cbbc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.test.ts @@ -16,7 +16,7 @@ import { DatabaseManager } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { DatabaseTaskStore } from './DatabaseTaskStore'; +import { DatabaseTaskStore, RawDbTaskEventRow } from './DatabaseTaskStore'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { ConflictError } from '@backstage/errors'; import { createMockDirectory } from '@backstage/backend-test-utils'; @@ -267,6 +267,48 @@ describe('DatabaseTaskStore', () => { expect(event.type).toBe('log'); }); + it('should be able to retied cancelled recoverable task', async () => { + const { store, manager } = await createStore(); + const client = await manager.getClient(); + + const { taskId } = await store.createTask({ + spec: { + EXPERIMENTAL_recovery: { EXPERIMENTAL_strategy: 'startOver' }, + } as TaskSpec, + createdBy: 'me#too', + }); + await store.completeTask({ taskId, status: 'cancelled', eventBody: {} }); + + await store.retryTask?.({ taskId }); + + const taskAfterRetry = await store.getTask(taskId); + expect(taskAfterRetry.status).toBe('open'); + + expect( + await client('task_events') + .where({ + task_id: taskId, + event_type: 'recovered', + }) + .select(['body', 'event_type', 'task_id']), + ).toEqual([ + { + body: JSON.stringify({ recoverStrategy: 'startOver' }), + event_type: 'recovered', + task_id: taskId, + }, + ]); + + expect( + await client('task_events') + .where({ + task_id: taskId, + }) + .andWhere(q => q.whereIn('event_type', ['cancelled', 'completion'])) + .select(['body', 'event_type', 'task_id']), + ).toEqual([]); + }); + it('should complete the task', async () => { const { store } = await createStore(); const { taskId } = await store.createTask({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts index 20e9672f04..65bafca619 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -484,7 +484,7 @@ export class DatabaseTaskStore implements TaskStore { async listEvents( options: TaskStoreListEventsOptions, ): Promise<{ events: SerializedTaskEvent[] }> { - const { taskId, after } = options; + const { isTaskRecoverable, taskId, after } = options; const rawEvents = await this.db('task_events') .where({ task_id: taskId, @@ -502,6 +502,7 @@ export class DatabaseTaskStore implements TaskStore { const body = JSON.parse(event.body) as JsonObject; return { id: Number(event.id), + isTaskRecoverable, taskId, body, type: event.event_type, @@ -602,6 +603,44 @@ export class DatabaseTaskStore implements TaskStore { }); } + async retryTask?(options: { taskId: string }): Promise { + await this.db.transaction(async tx => { + const result = await tx('tasks') + .where('id', options.taskId) + .update( + { + status: 'open', + last_heartbeat_at: this.db.fn.now(), + }, + ['id', 'spec'], + ); + + for (const { id, spec } of result) { + const taskSpec = JSON.parse(spec as string) as TaskSpec; + + /** + * Once task is picked up, all event types are replayed. + * We have to remove cancelled or completion event_type as these are as actions for frontend to perform. + * In contrary, we send 'recovered' event_type to reset the state on the frontend side. + * + */ + await tx('task_events') + .where('task_id', id) + .andWhere(q => q.whereIn('event_type', ['cancelled', 'completion'])) + .del(); + + await tx('task_events').insert({ + task_id: id, + event_type: 'recovered', + body: JSON.stringify({ + recoverStrategy: + taskSpec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy ?? 'none', + }), + }); + } + }); + } + async recoverTasks( options: TaskStoreRecoverTaskOptions, ): Promise<{ ids: string[] }> { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index 3960228529..1da2b4b349 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -254,7 +254,9 @@ export interface CurrentClaimedTask { * The creator of the task. */ createdBy?: string; - + /** + * The workspace of the task. + */ workspace?: Promise; } @@ -317,7 +319,7 @@ export class StorageTaskBroker implements TaskBroker { shouldUnsubscribe = true; } - if (event.type === 'completion') { + if (event.type === 'completion' && !event.isTaskRecoverable) { shouldUnsubscribe = true; } } @@ -416,8 +418,17 @@ export class StorageTaskBroker implements TaskBroker { let cancelled = false; (async () => { + const task = await this.storage.getTask(taskId); + const isTaskRecoverable = + task.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy === + 'startOver'; + while (!cancelled) { - const result = await this.storage.listEvents({ taskId, after }); + const result = await this.storage.listEvents({ + isTaskRecoverable, + taskId, + after, + }); const { events } = result; if (events.length) { after = events[events.length - 1].id; @@ -485,4 +496,9 @@ export class StorageTaskBroker implements TaskBroker { }, }); } + + async retry?(taskId: string): Promise { + await this.storage.retryTask?.({ taskId }); + this.signalDispatch(); + } } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/WorkspaceService.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/WorkspaceService.ts index ab424806bf..bb3ce448fc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/WorkspaceService.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/WorkspaceService.ts @@ -19,6 +19,7 @@ import { CurrentClaimedTask } from './StorageTaskBroker'; import { WorkspaceProvider } from '@backstage/plugin-scaffolder-node/alpha'; import { DatabaseWorkspaceProvider } from './DatabaseWorkspaceProvider'; import { TaskStore } from './types'; +import fs from 'fs-extra'; export interface WorkspaceService { serializeWorkspace(options: { path: string }): Promise; @@ -74,6 +75,7 @@ export class DefaultWorkspaceService implements WorkspaceService { targetPath: string; }): Promise { if (this.isWorkspaceSerializationEnabled()) { + await fs.mkdirp(options.targetPath); await this.workspaceProvider.rehydrateWorkspace(options); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index b87e0c4b9b..874793922f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -119,6 +119,7 @@ export type TaskStoreEmitOptions = { * @public */ export type TaskStoreListEventsOptions = { + isTaskRecoverable?: boolean; taskId: string; after?: number | undefined; }; @@ -170,6 +171,8 @@ export interface TaskStore { options: TaskStoreCreateTaskOptions, ): Promise; + retryTask?(options: { taskId: string }): Promise; + recoverTasks?( options: TaskStoreRecoverTaskOptions, ): Promise<{ ids: string[] }>; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 4584d64257..80406496d2 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -652,6 +652,19 @@ export async function createRouter( await taskBroker.cancel?.(taskId); res.status(200).json({ status: 'cancelled' }); }) + .post('/v2/tasks/:taskId/retry', async (req, res) => { + const credentials = await httpAuth.credentials(req); + // Requires both read and cancel permissions + await checkPermission({ + credentials, + permissions: [taskCreatePermission, taskReadPermission], + permissionService: permissions, + }); + + const { taskId } = req.params; + await taskBroker.retry?.(taskId); + res.status(201).json({ id: taskId }); + }) .get('/v2/tasks/:taskId/eventstream', async (req, res) => { const credentials = await httpAuth.credentials(req); await checkPermission({ @@ -687,7 +700,7 @@ export async function createRouter( res.write( `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`, ); - if (event.type === 'completion') { + if (event.type === 'completion' && !event.isTaskRecoverable) { shouldUnsubscribe = true; } } diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index ca48a25aa8..ed4f19c7d8 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -292,6 +292,7 @@ export type SerializedTask = { // @public export type SerializedTaskEvent = { id: number; + isTaskRecoverable?: boolean; taskId: string; body: JsonObject; type: TaskEventType; @@ -340,6 +341,8 @@ export interface TaskBroker { // (undocumented) recoverTasks?(): Promise; // (undocumented) + retry?(taskId: string): Promise; + // (undocumented) vacuumTasks(options: { timeoutS: number }): Promise; } @@ -498,30 +501,31 @@ export type TemplateGlobal = // src/files/types.d.ts:7:5 - (ae-undocumented) Missing documentation for "content". // src/files/types.d.ts:8:5 - (ae-undocumented) Missing documentation for "executable". // src/files/types.d.ts:9:5 - (ae-undocumented) Missing documentation for "symlink". -// src/tasks/types.d.ts:82:5 - (ae-undocumented) Missing documentation for "cancelSignal". -// src/tasks/types.d.ts:83:5 - (ae-undocumented) Missing documentation for "spec". -// src/tasks/types.d.ts:84:5 - (ae-undocumented) Missing documentation for "secrets". -// src/tasks/types.d.ts:85:5 - (ae-undocumented) Missing documentation for "createdBy". -// src/tasks/types.d.ts:86:5 - (ae-undocumented) Missing documentation for "done". -// src/tasks/types.d.ts:87:5 - (ae-undocumented) Missing documentation for "isDryRun". -// src/tasks/types.d.ts:88:5 - (ae-undocumented) Missing documentation for "complete". -// src/tasks/types.d.ts:89:5 - (ae-undocumented) Missing documentation for "emitLog". -// src/tasks/types.d.ts:90:5 - (ae-undocumented) Missing documentation for "getTaskState". -// src/tasks/types.d.ts:93:5 - (ae-undocumented) Missing documentation for "updateCheckpoint". -// src/tasks/types.d.ts:102:5 - (ae-undocumented) Missing documentation for "serializeWorkspace". -// src/tasks/types.d.ts:105:5 - (ae-undocumented) Missing documentation for "cleanWorkspace". -// src/tasks/types.d.ts:106:5 - (ae-undocumented) Missing documentation for "rehydrateWorkspace". -// src/tasks/types.d.ts:110:5 - (ae-undocumented) Missing documentation for "getWorkspaceName". -// src/tasks/types.d.ts:111:5 - (ae-undocumented) Missing documentation for "getInitiatorCredentials". -// src/tasks/types.d.ts:119:5 - (ae-undocumented) Missing documentation for "cancel". -// src/tasks/types.d.ts:120:5 - (ae-undocumented) Missing documentation for "claim". -// src/tasks/types.d.ts:121:5 - (ae-undocumented) Missing documentation for "recoverTasks". -// src/tasks/types.d.ts:122:5 - (ae-undocumented) Missing documentation for "dispatch". -// src/tasks/types.d.ts:123:5 - (ae-undocumented) Missing documentation for "vacuumTasks". -// src/tasks/types.d.ts:126:5 - (ae-undocumented) Missing documentation for "event$". -// src/tasks/types.d.ts:132:5 - (ae-undocumented) Missing documentation for "get". -// src/tasks/types.d.ts:133:5 - (ae-undocumented) Missing documentation for "list". -// src/tasks/types.d.ts:153:5 - (ae-undocumented) Missing documentation for "list". +// src/tasks/types.d.ts:83:5 - (ae-undocumented) Missing documentation for "cancelSignal". +// src/tasks/types.d.ts:84:5 - (ae-undocumented) Missing documentation for "spec". +// src/tasks/types.d.ts:85:5 - (ae-undocumented) Missing documentation for "secrets". +// src/tasks/types.d.ts:86:5 - (ae-undocumented) Missing documentation for "createdBy". +// src/tasks/types.d.ts:87:5 - (ae-undocumented) Missing documentation for "done". +// src/tasks/types.d.ts:88:5 - (ae-undocumented) Missing documentation for "isDryRun". +// src/tasks/types.d.ts:89:5 - (ae-undocumented) Missing documentation for "complete". +// src/tasks/types.d.ts:90:5 - (ae-undocumented) Missing documentation for "emitLog". +// src/tasks/types.d.ts:91:5 - (ae-undocumented) Missing documentation for "getTaskState". +// src/tasks/types.d.ts:94:5 - (ae-undocumented) Missing documentation for "updateCheckpoint". +// src/tasks/types.d.ts:103:5 - (ae-undocumented) Missing documentation for "serializeWorkspace". +// src/tasks/types.d.ts:106:5 - (ae-undocumented) Missing documentation for "cleanWorkspace". +// src/tasks/types.d.ts:107:5 - (ae-undocumented) Missing documentation for "rehydrateWorkspace". +// src/tasks/types.d.ts:111:5 - (ae-undocumented) Missing documentation for "getWorkspaceName". +// src/tasks/types.d.ts:112:5 - (ae-undocumented) Missing documentation for "getInitiatorCredentials". +// src/tasks/types.d.ts:120:5 - (ae-undocumented) Missing documentation for "cancel". +// src/tasks/types.d.ts:121:5 - (ae-undocumented) Missing documentation for "retry". +// src/tasks/types.d.ts:122:5 - (ae-undocumented) Missing documentation for "claim". +// src/tasks/types.d.ts:123:5 - (ae-undocumented) Missing documentation for "recoverTasks". +// src/tasks/types.d.ts:124:5 - (ae-undocumented) Missing documentation for "dispatch". +// src/tasks/types.d.ts:125:5 - (ae-undocumented) Missing documentation for "vacuumTasks". +// src/tasks/types.d.ts:128:5 - (ae-undocumented) Missing documentation for "event$". +// src/tasks/types.d.ts:134:5 - (ae-undocumented) Missing documentation for "get". +// src/tasks/types.d.ts:135:5 - (ae-undocumented) Missing documentation for "list". +// src/tasks/types.d.ts:155:5 - (ae-undocumented) Missing documentation for "list". // src/types.d.ts:3:1 - (ae-undocumented) Missing documentation for "TemplateFilter". // src/types.d.ts:5:1 - (ae-undocumented) Missing documentation for "TemplateGlobal". ``` diff --git a/plugins/scaffolder-node/src/tasks/types.ts b/plugins/scaffolder-node/src/tasks/types.ts index 27a131c513..b830c0633a 100644 --- a/plugins/scaffolder-node/src/tasks/types.ts +++ b/plugins/scaffolder-node/src/tasks/types.ts @@ -76,6 +76,7 @@ export type TaskEventType = 'completion' | 'log' | 'cancelled' | 'recovered'; */ export type SerializedTaskEvent = { id: number; + isTaskRecoverable?: boolean; taskId: string; body: JsonObject; type: TaskEventType; @@ -163,6 +164,8 @@ export interface TaskContext { export interface TaskBroker { cancel?(taskId: string): Promise; + retry?(taskId: string): Promise; + claim(): Promise; recoverTasks?(): Promise; diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index cf5f08dae8..d7cf450bf5 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -235,6 +235,7 @@ export interface ScaffolderApi { listTasks?(options: { filterByOwnership: 'owned' | 'all' }): Promise<{ tasks: ScaffolderTask[]; }>; + retry?(taskId: string): Promise; scaffold( options: ScaffolderScaffoldOptions, ): Promise; @@ -454,6 +455,8 @@ export interface ScaffolderStreamLogsOptions { // (undocumented) after?: number; // (undocumented) + isTaskRecoverable?: boolean; + // (undocumented) taskId: string; } @@ -562,25 +565,26 @@ export const useTemplateSecrets: () => ScaffolderUseTemplateSecrets; // src/api/types.d.ts:105:5 - (ae-undocumented) Missing documentation for "taskId". // src/api/types.d.ts:113:5 - (ae-undocumented) Missing documentation for "allowedHosts". // src/api/types.d.ts:121:5 - (ae-undocumented) Missing documentation for "integrations". -// src/api/types.d.ts:133:5 - (ae-undocumented) Missing documentation for "taskId". -// src/api/types.d.ts:134:5 - (ae-undocumented) Missing documentation for "after". -// src/api/types.d.ts:137:1 - (ae-undocumented) Missing documentation for "ScaffolderDryRunOptions". -// src/api/types.d.ts:138:5 - (ae-undocumented) Missing documentation for "template". -// src/api/types.d.ts:139:5 - (ae-undocumented) Missing documentation for "values". -// src/api/types.d.ts:140:5 - (ae-undocumented) Missing documentation for "secrets". -// src/api/types.d.ts:141:5 - (ae-undocumented) Missing documentation for "directoryContents". -// src/api/types.d.ts:147:1 - (ae-undocumented) Missing documentation for "ScaffolderDryRunResponse". -// src/api/types.d.ts:148:5 - (ae-undocumented) Missing documentation for "directoryContents". -// src/api/types.d.ts:153:5 - (ae-undocumented) Missing documentation for "log". -// src/api/types.d.ts:154:5 - (ae-undocumented) Missing documentation for "steps". -// src/api/types.d.ts:155:5 - (ae-undocumented) Missing documentation for "output". -// src/api/types.d.ts:163:5 - (ae-undocumented) Missing documentation for "getTemplateParameterSchema". -// src/api/types.d.ts:171:5 - (ae-undocumented) Missing documentation for "getTask". -// src/api/types.d.ts:178:5 - (ae-undocumented) Missing documentation for "listTasks". -// src/api/types.d.ts:183:5 - (ae-undocumented) Missing documentation for "getIntegrationsList". -// src/api/types.d.ts:188:5 - (ae-undocumented) Missing documentation for "streamLogs". -// src/api/types.d.ts:189:5 - (ae-undocumented) Missing documentation for "dryRun". -// src/api/types.d.ts:190:5 - (ae-undocumented) Missing documentation for "autocomplete". +// src/api/types.d.ts:133:5 - (ae-undocumented) Missing documentation for "isTaskRecoverable". +// src/api/types.d.ts:134:5 - (ae-undocumented) Missing documentation for "taskId". +// src/api/types.d.ts:135:5 - (ae-undocumented) Missing documentation for "after". +// src/api/types.d.ts:138:1 - (ae-undocumented) Missing documentation for "ScaffolderDryRunOptions". +// src/api/types.d.ts:139:5 - (ae-undocumented) Missing documentation for "template". +// src/api/types.d.ts:140:5 - (ae-undocumented) Missing documentation for "values". +// src/api/types.d.ts:141:5 - (ae-undocumented) Missing documentation for "secrets". +// src/api/types.d.ts:142:5 - (ae-undocumented) Missing documentation for "directoryContents". +// src/api/types.d.ts:148:1 - (ae-undocumented) Missing documentation for "ScaffolderDryRunResponse". +// src/api/types.d.ts:149:5 - (ae-undocumented) Missing documentation for "directoryContents". +// src/api/types.d.ts:154:5 - (ae-undocumented) Missing documentation for "log". +// src/api/types.d.ts:155:5 - (ae-undocumented) Missing documentation for "steps". +// src/api/types.d.ts:156:5 - (ae-undocumented) Missing documentation for "output". +// src/api/types.d.ts:164:5 - (ae-undocumented) Missing documentation for "getTemplateParameterSchema". +// src/api/types.d.ts:172:5 - (ae-undocumented) Missing documentation for "getTask". +// src/api/types.d.ts:185:5 - (ae-undocumented) Missing documentation for "listTasks". +// src/api/types.d.ts:190:5 - (ae-undocumented) Missing documentation for "getIntegrationsList". +// src/api/types.d.ts:195:5 - (ae-undocumented) Missing documentation for "streamLogs". +// src/api/types.d.ts:196:5 - (ae-undocumented) Missing documentation for "dryRun". +// src/api/types.d.ts:197:5 - (ae-undocumented) Missing documentation for "autocomplete". // src/components/types.d.ts:7:1 - (ae-undocumented) Missing documentation for "TemplateGroupFilter". // src/extensions/types.d.ts:13:5 - (ae-undocumented) Missing documentation for "uiSchema". // src/extensions/types.d.ts:30:5 - (ae-undocumented) Missing documentation for ""ui:options"". diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index eaef54ff1b..3816c4bcbf 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -161,6 +161,7 @@ export interface ScaffolderGetIntegrationsListResponse { * @public */ export interface ScaffolderStreamLogsOptions { + isTaskRecoverable?: boolean; taskId: string; after?: number; } @@ -213,6 +214,13 @@ export interface ScaffolderApi { */ cancelTask(taskId: string): Promise; + /** + * Starts the task again from the point where it failed. + * + * @param taskId - the id of the task + */ + retry?(taskId: string): Promise; + listTasks?(options: { filterByOwnership: 'owned' | 'all'; }): Promise<{ tasks: ScaffolderTask[] }>; diff --git a/plugins/scaffolder-react/src/hooks/useEventStream.ts b/plugins/scaffolder-react/src/hooks/useEventStream.ts index f63f6024e6..7c5ce09ab7 100644 --- a/plugins/scaffolder-react/src/hooks/useEventStream.ts +++ b/plugins/scaffolder-react/src/hooks/useEventStream.ts @@ -143,6 +143,11 @@ function reducer(draft: TaskStream, action: ReducerAction) { } case 'RECOVERED': { + draft.cancelled = false; + draft.completed = false; + draft.output = undefined; + draft.error = undefined; + for (const stepId in draft.steps) { if (draft.steps.hasOwnProperty(stepId)) { draft.steps[stepId].startedAt = undefined; @@ -185,12 +190,16 @@ export const useTaskEventStream = (taskId: string): TaskStream => { let subscription: Subscription | undefined; let logPusher: NodeJS.Timeout | undefined; let retryCount = 1; + let isTaskRecoverable = false; const startStreamLogProcess = () => scaffolderApi.getTask(taskId).then( task => { if (didCancel) { return; } + isTaskRecoverable = + task.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy === + 'startOver'; dispatch({ type: 'INIT', data: task }); // TODO(blam): Use a normal fetch to fetch the current log for the event stream @@ -199,7 +208,10 @@ export const useTaskEventStream = (taskId: string): TaskStream => { // stream logs. Without this, if you have a lot of logs, it can look like the // task is being rebuilt on load as it progresses through the steps at a slower // rate whilst it builds the status from the event logs - const observable = scaffolderApi.streamLogs({ taskId }); + const observable = scaffolderApi.streamLogs({ + isTaskRecoverable, + taskId, + }); const collectedLogEvents = new Array(); @@ -270,12 +282,14 @@ export const useTaskEventStream = (taskId: string): TaskStream => { ); void startStreamLogProcess(); return () => { - didCancel = true; - if (subscription) { - subscription.unsubscribe(); - } - if (logPusher) { - clearInterval(logPusher); + if (!isTaskRecoverable) { + didCancel = true; + if (subscription) { + subscription.unsubscribe(); + } + if (logPusher) { + clearInterval(logPusher); + } } }; }, [scaffolderApi, dispatch, taskId]); diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 051bfee955..4319362ef9 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -259,6 +259,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'ongoingTask.title': 'Run of'; readonly 'ongoingTask.contextMenu.cancel': 'Cancel'; readonly 'ongoingTask.contextMenu.startOver': 'Start Over'; + readonly 'ongoingTask.contextMenu.retry': 'Retry'; readonly 'ongoingTask.contextMenu.hideLogs': 'Hide Logs'; readonly 'ongoingTask.contextMenu.showLogs': 'Show Logs'; readonly 'ongoingTask.contextMenu.hideButtonBar': 'Hide Button Bar'; @@ -267,6 +268,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'ongoingTask.pageTitle.hasTemplateName': 'Run of {{templateName}}'; readonly 'ongoingTask.pageTitle.noTemplateName': 'Scaffolder Run'; readonly 'ongoingTask.cancelButtonTitle': 'Cancel'; + readonly 'ongoingTask.retryButtonTitle': 'Retry'; readonly 'ongoingTask.startOverButtonTitle': 'Start Over'; readonly 'ongoingTask.hideLogsButtonTitle': 'Hide Logs'; readonly 'ongoingTask.showLogsButtonTitle': 'Show Logs'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 1adaa8f167..b93a59a82c 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -556,6 +556,8 @@ export class ScaffolderClient implements ScaffolderApi_2 { tasks: ScaffolderTask_2[]; }>; // (undocumented) + retry?(taskId: string): Promise; + // (undocumented) scaffold( options: ScaffolderScaffoldOptions_2, ): Promise; @@ -676,7 +678,8 @@ export const useTemplateSecrets: () => ScaffolderUseTemplateSecrets_2; // src/api.d.ts:33:5 - (ae-undocumented) Missing documentation for "dryRun". // src/api.d.ts:36:5 - (ae-undocumented) Missing documentation for "listActions". // src/api.d.ts:37:5 - (ae-undocumented) Missing documentation for "cancelTask". -// src/api.d.ts:38:5 - (ae-undocumented) Missing documentation for "autocomplete". +// src/api.d.ts:38:5 - (ae-undocumented) Missing documentation for "retry". +// src/api.d.ts:39:5 - (ae-undocumented) Missing documentation for "autocomplete". // src/components/OngoingTask/OngoingTask.d.ts:6:22 - (ae-undocumented) Missing documentation for "OngoingTask". // src/components/fields/EntityPicker/schema.d.ts:15:22 - (ae-undocumented) Missing documentation for "EntityPickerFieldSchema". // src/components/fields/EntityTagsPicker/schema.d.ts:4:22 - (ae-undocumented) Missing documentation for "EntityTagsPickerFieldSchema". diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 1809ed4c15..93db7386f3 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -217,12 +217,10 @@ export class ScaffolderClient implements ScaffolderApi { } private streamLogsEventStream({ + isTaskRecoverable, taskId, after, - }: { - taskId: string; - after?: number; - }): Observable { + }: ScaffolderStreamLogsOptions): Observable { return new ObservableImpl(subscriber => { const params = new URLSearchParams(); if (after !== undefined) { @@ -246,14 +244,14 @@ export class ScaffolderClient implements ScaffolderApi { }; const ctrl = new AbortController(); - fetchEventSource(url, { + void fetchEventSource(url, { fetch: this.fetchApi.fetch, signal: ctrl.signal, onmessage(e: EventSourceMessage) { if (e.event === 'log') { processEvent(e); return; - } else if (e.event === 'completion') { + } else if (e.event === 'completion' && !isTaskRecoverable) { processEvent(e); subscriber.complete(); ctrl.abort(); @@ -338,6 +336,21 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } + async retry?(taskId: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}/retry`; + + const response = await this.fetchApi.fetch(url, { + method: 'POST', + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } + async autocomplete({ token, resource, diff --git a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx index e0ab01fc67..f189a6a03c 100644 --- a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx @@ -23,7 +23,8 @@ import Popover from '@material-ui/core/Popover'; import { makeStyles, Theme, useTheme } from '@material-ui/core/styles'; import { useAsync } from '@react-hookz/web'; import Cancel from '@material-ui/icons/Cancel'; -import Retry from '@material-ui/icons/Repeat'; +import Repeat from '@material-ui/icons/Repeat'; +import Replay from '@material-ui/icons/Replay'; import Toc from '@material-ui/icons/Toc'; import ControlPointIcon from '@material-ui/icons/ControlPoint'; import MoreVert from '@material-ui/icons/MoreVert'; @@ -41,8 +42,11 @@ import { scaffolderTranslationRef } from '../../translation'; type ContextMenuProps = { cancelEnabled?: boolean; + canRetry: boolean; + isRetryableTask: boolean; logsVisible?: boolean; buttonBarVisible?: boolean; + onRetry?: () => void; onStartOver?: () => void; onToggleLogs?: (state: boolean) => void; onToggleButtonBar?: (state: boolean) => void; @@ -58,8 +62,11 @@ const useStyles = makeStyles(() => ({ export const ContextMenu = (props: ContextMenuProps) => { const { cancelEnabled, + canRetry, + isRetryableTask, logsVisible, buttonBarVisible, + onRetry, onStartOver, onToggleLogs, onToggleButtonBar, @@ -147,10 +154,22 @@ export const ContextMenu = (props: ContextMenuProps) => { data-testid="start-over-task" > - + + {isRetryableTask && ( + + + + + + + )} ({ cancelButton: { marginRight: theme.spacing(1), }, + retryButton: { + marginRight: theme.spacing(1), + }, logsVisibilityButton: { marginRight: theme.spacing(1), }, @@ -130,6 +133,12 @@ export const OngoingTask = (props: { return 0; }, [steps]); + const isRetryableTask = + taskStream.task?.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy === + 'startOver'; + + const canRetry = canReadTask && canCreateTask && isRetryableTask; + const startOver = useCallback(() => { const { namespace, name } = taskStream.task?.spec.templateInfo?.entity?.metadata ?? {}; @@ -157,6 +166,13 @@ export const OngoingTask = (props: { templateRouteRef, ]); + const [{ status: _ }, { execute: triggerRetry }] = useAsync(async () => { + if (taskId) { + analytics.captureEvent('retried', 'Template has been retried'); + await scaffolderApi.retry?.(taskId); + } + }); + const [{ status: cancelStatus }, { execute: triggerCancel }] = useAsync( async () => { if (taskId) { @@ -190,9 +206,12 @@ export const OngoingTask = (props: { > {t('ongoingTask.cancelButtonTitle')} + {isRetryableTask && ( + + )}