Checkpoint

Signed-off-by: bnechyporenko <bnechyporenko@bol.com>
This commit is contained in:
bnechyporenko
2024-02-04 19:55:02 +01:00
parent 36dbf7e6a1
commit acb684e1c6
14 changed files with 237 additions and 66 deletions
@@ -37,7 +37,9 @@
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-scaffolder-backend": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@backstage/types": "workspace:^",
"@octokit/webhooks": "^10.0.0",
"libsodium-wrappers": "^0.7.11",
"octokit": "^3.0.0",
@@ -244,6 +244,7 @@ export function createPublishGithubAction(options: {
repoVariables,
secrets,
ctx.logger,
ctx.checkpoint,
);
const remoteUrl = newRepo.clone_url;
@@ -36,6 +36,8 @@ import {
enableBranchProtectionOnDefaultRepoBranch,
entityRefToName,
} from './gitHelpers';
import { JsonObject } from '@backstage/types';
import { defineCheckpoint } from '@backstage/plugin-scaffolder-backend';
const DEFAULT_TIMEOUT_MS = 60_000;
@@ -138,6 +140,10 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
repoVariables: { [key: string]: string } | undefined,
secrets: { [key: string]: string } | undefined,
logger: Logger,
checkpoint?: <U extends JsonObject>(
key: string,
fn: () => Promise<U>,
) => Promise<U>,
) {
// eslint-disable-next-line testing-library/no-await-sync-queries
const user = await client.rest.users.getByUsername({
@@ -148,59 +154,70 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
await validateAccessTeam(client, access);
}
const repoCreationPromise =
user.data.type === 'Organization'
? client.rest.repos.createInOrg({
name: repo,
org: owner,
private: repoVisibility === 'private',
// @ts-ignore https://github.com/octokit/types.ts/issues/522
visibility: repoVisibility,
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
allow_merge_commit: allowMergeCommit,
allow_squash_merge: allowSquashMerge,
squash_merge_commit_title: squashMergeCommitTitle,
squash_merge_commit_message: squashMergeCommitMessage,
allow_rebase_merge: allowRebaseMerge,
allow_auto_merge: allowAutoMerge,
homepage: homepage,
has_projects: hasProjects,
has_wiki: hasWiki,
has_issues: hasIssues,
})
: client.rest.repos.createForAuthenticatedUser({
name: repo,
private: repoVisibility === 'private',
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
allow_merge_commit: allowMergeCommit,
allow_squash_merge: allowSquashMerge,
squash_merge_commit_title: squashMergeCommitTitle,
squash_merge_commit_message: squashMergeCommitMessage,
allow_rebase_merge: allowRebaseMerge,
allow_auto_merge: allowAutoMerge,
homepage: homepage,
has_projects: hasProjects,
has_wiki: hasWiki,
has_issues: hasIssues,
});
const repoCreation = async () => {
const repoCreationPromise =
user.data.type === 'Organization'
? client.rest.repos.createInOrg({
name: repo,
org: owner,
private: repoVisibility === 'private',
// @ts-ignore https://github.com/octokit/types.ts/issues/522
visibility: repoVisibility,
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
allow_merge_commit: allowMergeCommit,
allow_squash_merge: allowSquashMerge,
squash_merge_commit_title: squashMergeCommitTitle,
squash_merge_commit_message: squashMergeCommitMessage,
allow_rebase_merge: allowRebaseMerge,
allow_auto_merge: allowAutoMerge,
homepage: homepage,
has_projects: hasProjects,
has_wiki: hasWiki,
has_issues: hasIssues,
})
: client.rest.repos.createForAuthenticatedUser({
name: repo,
private: repoVisibility === 'private',
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
allow_merge_commit: allowMergeCommit,
allow_squash_merge: allowSquashMerge,
squash_merge_commit_title: squashMergeCommitTitle,
squash_merge_commit_message: squashMergeCommitMessage,
allow_rebase_merge: allowRebaseMerge,
allow_auto_merge: allowAutoMerge,
homepage: homepage,
has_projects: hasProjects,
has_wiki: hasWiki,
has_issues: hasIssues,
});
let newRepo;
let newRepo;
try {
newRepo = (await repoCreationPromise).data;
} catch (e) {
assertError(e);
if (e.message === 'Resource not accessible by integration') {
logger.warn(
`The GitHub app or token provided may not have the required permissions to create the ${user.data.type} repository ${owner}/${repo}.`,
try {
newRepo = (await repoCreationPromise).data;
} catch (e) {
assertError(e);
if (e.message === 'Resource not accessible by integration') {
logger.warn(
`The GitHub app or token provided may not have the required permissions to create the ${user.data.type} repository ${owner}/${repo}.`,
);
}
throw new Error(
`Failed to create the ${user.data.type} repository ${owner}/${repo}, ${e.message}`,
);
}
throw new Error(
`Failed to create the ${user.data.type} repository ${owner}/${repo}, ${e.message}`,
);
}
return { newRepo };
};
const { newRepo } = await defineCheckpoint<{
newRepo: { clone_url: string; html_url: string };
}>({
key: 'v1.task.checkpoint.repo.creation',
checkpoint,
fn: repoCreation,
});
if (access?.startsWith(`${owner}/`)) {
const [, team] = access.split('/');
@@ -213,11 +230,19 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
});
// No need to add access if it's the person who owns the personal account
} else if (access && access !== owner) {
await client.rest.repos.addCollaborator({
owner,
repo,
username: access,
permission: 'admin',
const addCollaborator = async () => {
await client.rest.repos.addCollaborator({
owner,
repo,
username: access,
permission: 'admin',
});
return {};
};
await defineCheckpoint({
key: 'v1.task.checkpoint.add.collaborator',
checkpoint,
fn: addCollaborator,
});
}
@@ -225,11 +250,19 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
for (const collaborator of collaborators) {
try {
if ('user' in collaborator) {
await client.rest.repos.addCollaborator({
owner,
repo,
username: entityRefToName(collaborator.user),
permission: collaborator.access,
const addCollaborator = async () => {
await client.rest.repos.addCollaborator({
owner,
repo,
username: entityRefToName(collaborator.user),
permission: collaborator.access,
});
return {};
};
await defineCheckpoint({
key: `v1.task.checkpoint.add.collaborator.${collaborator.user}`,
checkpoint,
fn: addCollaborator,
});
} else if ('team' in collaborator) {
await client.rest.teams.addOrUpdateRepoPermissionsInOrg({
@@ -264,11 +297,19 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
}
for (const [key, value] of Object.entries(repoVariables ?? {})) {
await client.rest.actions.createRepoVariable({
owner,
repo,
name: key,
value: value,
const createRepoVariable = async () => {
await client.rest.actions.createRepoVariable({
owner,
repo,
name: key,
value: value,
});
return {};
};
await defineCheckpoint({
key: `v1.task.checkpoint.create.repo.variable.${key}`,
checkpoint,
fn: createRepoVariable,
});
}
@@ -0,0 +1,35 @@
/*
* Copyright 2020 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.
*/
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('tasks', table => {
table.text('state').nullable().comment('A state of the checkpoints');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('tasks', table => {
table.dropColumn('state');
});
};
+1
View File
@@ -23,5 +23,6 @@
export * from './scaffolder';
export * from './service/router';
export * from './lib';
export { defineCheckpoint } from './util/defineCheckpoint';
export * from './deprecated';
@@ -30,6 +30,7 @@ import {
TaskStoreCreateTaskResult,
TaskStoreShutDownTaskOptions,
TaskStoreRecoverTaskOptions,
TaskStoreStateOptions,
} from './types';
import {
SerializedTaskEvent,
@@ -52,6 +53,7 @@ export type RawDbTaskRow = {
id: string;
spec: string;
status: TaskStatus;
state?: string;
last_heartbeat_at?: string;
created_at: string;
created_by: string | null;
@@ -394,6 +396,17 @@ export class DatabaseTaskStore implements TaskStore {
});
}
async saveCheckpoint?(options: TaskStoreStateOptions): Promise<void> {
if (options.state) {
const serializedState = JSON.stringify(options.state);
await this.db<RawDbTaskRow>('tasks')
.where({ id: options.taskId })
.update({
state: serializedState,
});
}
}
async listEvents(
options: TaskStoreListEventsOptions,
): Promise<{ events: SerializedTaskEvent[] }> {
@@ -349,6 +349,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
logger: taskLogger,
logStream: streamLogger,
workspacePath,
async checkpoint<U extends JsonObject>(
key: string,
fn: () => Promise<U>,
) {
const value = await fn();
task.updateCheckpoint?.(key, value);
return value;
},
createTemporaryDirectory: async () => {
const tmpDir = await fs.mkdtemp(
`${workspacePath}_step-${step.id}-`,
@@ -16,7 +16,7 @@
import { Config } from '@backstage/config';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import { TaskSecrets } from '@backstage/plugin-scaffolder-node';
import { TaskSecrets, TaskState } from '@backstage/plugin-scaffolder-node';
import { JsonObject, Observable } from '@backstage/types';
import { Logger } from 'winston';
import ObservableImpl from 'zen-observable';
@@ -91,6 +91,14 @@ export class TaskManager implements TaskContext {
});
}
async updateCheckpoint?(key: string, value: JsonObject): Promise<void> {
this.task.state = { [key]: value };
await this.storage.saveCheckpoint?.({
taskId: this.task.taskId,
state: this.task.state,
});
}
async complete(
result: TaskCompletionState,
metadata?: JsonObject,
@@ -144,6 +152,10 @@ export interface CurrentClaimedTask {
* The secrets that are stored with the task.
*/
secrets?: TaskSecrets;
/**
* The state of checkpoints of the task.
*/
state?: TaskState;
/**
* The creator of the task.
*/
@@ -16,7 +16,7 @@
import { JsonValue, JsonObject, HumanDuration } from '@backstage/types';
import { TaskSpec, TaskStep } from '@backstage/plugin-scaffolder-common';
import { TaskSecrets } from '@backstage/plugin-scaffolder-node';
import { TaskSecrets, TaskState } from '@backstage/plugin-scaffolder-node';
import {
TemplateAction,
TaskStatus as _TaskStatus,
@@ -113,6 +113,11 @@ export type TaskStoreEmitOptions<TBody = JsonObject> = {
body: TBody;
};
export type TaskStoreStateOptions = {
taskId: string;
state?: TaskState;
};
/**
* TaskStoreListEventsOptions
*
@@ -194,6 +199,8 @@ export interface TaskStore {
emitLogEvent(options: TaskStoreEmitOptions): Promise<void>;
saveCheckpoint?(options: TaskStoreStateOptions): Promise<void>;
listEvents(
options: TaskStoreListEventsOptions,
): Promise<{ events: SerializedTaskEvent[] }>;
@@ -0,0 +1,34 @@
/*
* 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 { JsonObject } from '@backstage/types';
export type DefineCheckpointProps<U> = {
checkpoint?: (key: string, fn: () => Promise<U>) => Promise<U>;
key: string;
fn: () => Promise<U>;
};
export const defineCheckpoint = async <U extends JsonObject>(
props: DefineCheckpointProps<U>,
): Promise<U> => {
const { checkpoint, fn, key } = props;
return checkpoint
? checkpoint?.(key, async () => {
return await fn();
})
: fn();
};
@@ -35,6 +35,10 @@ export type ActionContext<
secrets?: TaskSecrets;
workspacePath: string;
input: TActionInput;
checkpoint?<U extends JsonObject>(
key: string,
fn: () => Promise<U>,
): Promise<U>;
output(
name: keyof TActionOutput,
value: TActionOutput[keyof TActionOutput],
@@ -24,5 +24,6 @@ export type {
TaskCompletionState,
TaskContext,
TaskEventType,
TaskState,
TaskStatus,
} from './types';
@@ -26,6 +26,13 @@ export type TaskSecrets = Record<string, string> & {
backstageToken?: string;
};
/**
* TaskState
*
* @public
*/
export type TaskState = Record<string, JsonObject>;
/**
* The status of each step of the Task
*
@@ -110,6 +117,7 @@ export interface TaskContext {
cancelSignal: AbortSignal;
spec: TaskSpec;
secrets?: TaskSecrets;
state?: TaskState;
createdBy?: string;
done: boolean;
isDryRun?: boolean;
@@ -118,6 +126,8 @@ export interface TaskContext {
emitLog(message: string, logMetadata?: JsonObject): Promise<void>;
updateCheckpoint?(key: string, value: JsonObject): Promise<void>;
getWorkspaceName(): Promise<string>;
}
+2
View File
@@ -8354,7 +8354,9 @@ __metadata:
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-scaffolder-backend": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@backstage/types": "workspace:^"
"@octokit/webhooks": ^10.0.0
"@types/libsodium-wrappers": ^0.7.10
fs-extra: 10.1.0