feat: 支持发布单 MySQL 持久化
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"lint": "eslint \"{src,prisma}/**/*.{ts,js}\"",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"prisma:seed": "node dist/prisma/seed.js",
|
||||
"prisma:studio": "prisma studio",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 将代码内的纳管项目清单同步到数据库,保持项目、环境和 Jenkins Job 可重复初始化。
|
||||
*/
|
||||
import { EnvironmentKind, PrismaClient, ProjectStatus } from '@prisma/client';
|
||||
import { seedProjects } from '../src/projects/project.seed';
|
||||
import type { ProjectEnvironmentSummary } from '../src/projects/project.types';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
for (const project of seedProjects) {
|
||||
const projectRecord = await prisma.project.upsert({
|
||||
where: {
|
||||
key: project.key,
|
||||
},
|
||||
create: {
|
||||
id: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
repositoryUrl: project.repositoryUrl,
|
||||
defaultBranch: project.defaultBranch,
|
||||
status:
|
||||
project.status === 'active'
|
||||
? ProjectStatus.ACTIVE
|
||||
: ProjectStatus.ARCHIVED,
|
||||
metadata: {
|
||||
source: 'seedProjects',
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: project.name,
|
||||
repositoryUrl: project.repositoryUrl,
|
||||
defaultBranch: project.defaultBranch,
|
||||
status:
|
||||
project.status === 'active'
|
||||
? ProjectStatus.ACTIVE
|
||||
: ProjectStatus.ARCHIVED,
|
||||
metadata: {
|
||||
source: 'seedProjects',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const environment of project.environments) {
|
||||
await upsertEnvironment(projectRecord.id, environment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertEnvironment(
|
||||
projectId: string,
|
||||
environment: ProjectEnvironmentSummary,
|
||||
): Promise<void> {
|
||||
const environmentName = toEnvironmentKind(environment.name);
|
||||
const environmentRecord = await prisma.environment.upsert({
|
||||
where: {
|
||||
projectId_name: {
|
||||
projectId,
|
||||
name: environmentName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
name: environmentName,
|
||||
displayName: environment.displayName,
|
||||
},
|
||||
update: {
|
||||
displayName: environment.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.deployJob.upsert({
|
||||
where: {
|
||||
projectId_environmentId: {
|
||||
projectId,
|
||||
environmentId: environmentRecord.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
environmentId: environmentRecord.id,
|
||||
jenkinsJobPath: environment.jenkinsJobPath,
|
||||
jenkinsParameters: {
|
||||
releasePolicy: environment.releasePolicy,
|
||||
refPattern: environment.refPattern,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
jenkinsJobPath: environment.jenkinsJobPath,
|
||||
jenkinsParameters: {
|
||||
releasePolicy: environment.releasePolicy,
|
||||
refPattern: environment.refPattern,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toEnvironmentKind(
|
||||
environment: ProjectEnvironmentSummary['name'],
|
||||
): EnvironmentKind {
|
||||
return environment === 'test'
|
||||
? EnvironmentKind.TEST
|
||||
: EnvironmentKind.PRODUCTION;
|
||||
}
|
||||
|
||||
void main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error(error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export class DeployExecutionService {
|
||||
|
||||
let currentRun = this.requireRun(
|
||||
run.id,
|
||||
this.deployRunRepository.updateMemoryRun(run.id, { status: 'running' }),
|
||||
await this.deployRunRepository.updateRun(run.id, { status: 'running' }),
|
||||
);
|
||||
let currentStep: string | undefined;
|
||||
|
||||
@@ -53,7 +53,7 @@ export class DeployExecutionService {
|
||||
|
||||
try {
|
||||
currentStep = 'gitea-check';
|
||||
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
|
||||
currentRun = await this.completeStep(currentRun.id, currentStep, 'success');
|
||||
|
||||
currentStep = 'jenkins-build';
|
||||
const jenkinsResult = await this.advanceJenkinsBuild(currentRun, project);
|
||||
@@ -65,16 +65,16 @@ export class DeployExecutionService {
|
||||
}
|
||||
|
||||
currentStep = 'health-check';
|
||||
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
|
||||
currentRun = await this.completeStep(currentRun.id, currentStep, 'success');
|
||||
|
||||
currentStep = 'notify-wecom';
|
||||
currentRun = await this.advanceNotification(currentRun, 'succeeded');
|
||||
|
||||
currentStep = 'finish-release';
|
||||
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
|
||||
currentRun = await this.completeStep(currentRun.id, currentStep, 'success');
|
||||
currentRun = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
await this.deployRunRepository.updateRun(currentRun.id, {
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
@@ -97,21 +97,21 @@ export class DeployExecutionService {
|
||||
|
||||
if (currentStep) {
|
||||
currentRun =
|
||||
this.deployRunRepository.updateMemoryStep(
|
||||
(await this.deployRunRepository.updateStep(
|
||||
currentRun.id,
|
||||
currentStep,
|
||||
{
|
||||
status: 'failed',
|
||||
errorSummary: failureSummary,
|
||||
},
|
||||
) ?? currentRun;
|
||||
)) ?? currentRun;
|
||||
}
|
||||
|
||||
currentRun =
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
(await this.deployRunRepository.updateRun(currentRun.id, {
|
||||
status: 'failed',
|
||||
failureSummary,
|
||||
}) ?? currentRun;
|
||||
})) ?? currentRun;
|
||||
|
||||
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||
|
||||
@@ -155,7 +155,7 @@ export class DeployExecutionService {
|
||||
if (!queueItem.buildNumber) {
|
||||
return this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
await this.deployRunRepository.updateRun(currentRun.id, {
|
||||
status: 'queued',
|
||||
jenkinsQueueId: queueItem.queueId,
|
||||
jenkinsBuildUrl: queueItem.executableUrl,
|
||||
@@ -166,14 +166,14 @@ export class DeployExecutionService {
|
||||
buildNumber = queueItem.buildNumber;
|
||||
currentRun = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
await this.deployRunRepository.updateRun(currentRun.id, {
|
||||
status: 'running',
|
||||
jenkinsQueueId: queueItem.queueId,
|
||||
jenkinsBuildNumber: queueItem.buildNumber,
|
||||
jenkinsBuildUrl: queueItem.executableUrl,
|
||||
}),
|
||||
);
|
||||
currentRun = this.markStep(
|
||||
currentRun = await this.markStep(
|
||||
currentRun.id,
|
||||
'jenkins-build',
|
||||
'running',
|
||||
@@ -204,7 +204,7 @@ export class DeployExecutionService {
|
||||
|
||||
currentRun = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
await this.deployRunRepository.updateRun(currentRun.id, {
|
||||
status: buildStatus.building || !buildStatus.result ? 'running' : currentRun.status,
|
||||
jenkinsBuildNumber: buildStatus.buildNumber,
|
||||
jenkinsBuildUrl: buildStatus.url,
|
||||
@@ -242,7 +242,7 @@ export class DeployExecutionService {
|
||||
const reason =
|
||||
health.message ??
|
||||
'Jenkins is not configured; simulated build completed.';
|
||||
const currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
|
||||
const currentRun = await this.completeStep(run.id, 'jenkins-build', 'success');
|
||||
|
||||
this.auditService.record({
|
||||
action: 'JENKINS_DRY_RUN_COMPLETED',
|
||||
@@ -268,7 +268,7 @@ export class DeployExecutionService {
|
||||
);
|
||||
}
|
||||
|
||||
this.markStep(run.id, 'jenkins-build', 'running');
|
||||
await this.markStep(run.id, 'jenkins-build', 'running');
|
||||
const queueItem = await this.jenkinsClient.buildWithParameters(
|
||||
environment.jenkinsJobPath,
|
||||
this.buildParameters(run),
|
||||
@@ -276,14 +276,14 @@ export class DeployExecutionService {
|
||||
const nextStatus = queueItem.buildNumber ? 'running' : 'queued';
|
||||
let currentRun = this.requireRun(
|
||||
run.id,
|
||||
this.deployRunRepository.updateMemoryRun(run.id, {
|
||||
await this.deployRunRepository.updateRun(run.id, {
|
||||
status: nextStatus,
|
||||
jenkinsQueueId: queueItem.queueId,
|
||||
jenkinsBuildNumber: queueItem.buildNumber,
|
||||
jenkinsBuildUrl: queueItem.executableUrl,
|
||||
}),
|
||||
);
|
||||
currentRun = this.markStep(
|
||||
currentRun = await this.markStep(
|
||||
run.id,
|
||||
'jenkins-build',
|
||||
nextStatus,
|
||||
@@ -312,7 +312,7 @@ export class DeployExecutionService {
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
): Promise<DeployRunSummary> {
|
||||
this.markStep(run.id, 'notify-wecom', 'running');
|
||||
await this.markStep(run.id, 'notify-wecom', 'running');
|
||||
const notification =
|
||||
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||
const stepStatus =
|
||||
@@ -321,7 +321,7 @@ export class DeployExecutionService {
|
||||
: notification.status === 'failed'
|
||||
? 'failed'
|
||||
: 'skipped';
|
||||
const currentRun = this.completeStep(
|
||||
const currentRun = await this.completeStep(
|
||||
run.id,
|
||||
'notify-wecom',
|
||||
stepStatus,
|
||||
@@ -346,13 +346,13 @@ export class DeployExecutionService {
|
||||
private async completeSuccessfulJenkinsRun(
|
||||
run: DeployRunSummary,
|
||||
): Promise<DeployRunSummary> {
|
||||
let currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
|
||||
currentRun = this.completeStep(currentRun.id, 'health-check', 'success');
|
||||
let currentRun = await this.completeStep(run.id, 'jenkins-build', 'success');
|
||||
currentRun = await this.completeStep(currentRun.id, 'health-check', 'success');
|
||||
currentRun = await this.advanceNotification(currentRun, 'succeeded');
|
||||
currentRun = this.completeStep(currentRun.id, 'finish-release', 'success');
|
||||
currentRun = await this.completeStep(currentRun.id, 'finish-release', 'success');
|
||||
currentRun = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
await this.deployRunRepository.updateRun(currentRun.id, {
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
@@ -380,16 +380,16 @@ export class DeployExecutionService {
|
||||
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
|
||||
const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`;
|
||||
let currentRun =
|
||||
this.deployRunRepository.updateMemoryStep(run.id, 'jenkins-build', {
|
||||
(await this.deployRunRepository.updateStep(run.id, 'jenkins-build', {
|
||||
status: 'failed',
|
||||
errorSummary: failureSummary,
|
||||
logExcerpt,
|
||||
}) ?? run;
|
||||
})) ?? run;
|
||||
currentRun =
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
(await this.deployRunRepository.updateRun(currentRun.id, {
|
||||
status: 'failed',
|
||||
failureSummary,
|
||||
}) ?? currentRun;
|
||||
})) ?? currentRun;
|
||||
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||
|
||||
this.auditService.record({
|
||||
@@ -463,30 +463,30 @@ export class DeployExecutionService {
|
||||
};
|
||||
}
|
||||
|
||||
private completeStep(
|
||||
private async completeStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>,
|
||||
errorSummary?: string,
|
||||
): DeployRunSummary {
|
||||
): Promise<DeployRunSummary> {
|
||||
return this.requireRun(
|
||||
runId,
|
||||
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
|
||||
await this.deployRunRepository.updateStep(runId, bpmnNodeId, {
|
||||
status,
|
||||
errorSummary,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private markStep(
|
||||
private async markStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
status: Extract<DeployRunStepStatus, 'queued' | 'running'>,
|
||||
message?: string,
|
||||
): DeployRunSummary {
|
||||
): Promise<DeployRunSummary> {
|
||||
return this.requireRun(
|
||||
runId,
|
||||
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
|
||||
await this.deployRunRepository.updateStep(runId, bpmnNodeId, {
|
||||
status,
|
||||
message,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
/**
|
||||
* 发布单仓储同时支持本地内存模式和 MySQL/Prisma 持久化模式。
|
||||
* 业务层只关心 run/step 状态流转,是否落库由环境配置统一控制。
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
DeployRunStatus as PrismaDeployRunStatus,
|
||||
DeployRunTrigger as PrismaDeployRunTrigger,
|
||||
EnvironmentKind,
|
||||
Prisma,
|
||||
RunStepStatus as PrismaRunStepStatus,
|
||||
} from '@prisma/client';
|
||||
import { EnvConfig } from '../config/env.schema';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { seedDeployRuns } from './deploy-run.seed';
|
||||
@@ -29,6 +40,24 @@ type DeployRunStepMemoryPatch = {
|
||||
logExcerpt?: string;
|
||||
};
|
||||
|
||||
type DeployRunRecord = Prisma.DeployRunGetPayload<{
|
||||
include: {
|
||||
project: true;
|
||||
environment: true;
|
||||
steps: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
type DeployRunMetadata = Prisma.InputJsonObject & {
|
||||
jenkinsBuildUrl?: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type RunStepMetadata = Prisma.InputJsonObject & {
|
||||
message?: string;
|
||||
logExcerpt?: string;
|
||||
};
|
||||
|
||||
const terminalRunStatuses = new Set<DeployRunStatus>([
|
||||
'success',
|
||||
'failed',
|
||||
@@ -40,6 +69,31 @@ const terminalStepStatuses = new Set<DeployRunStepStatus>([
|
||||
'canceled',
|
||||
'skipped',
|
||||
]);
|
||||
const runStatusToPrisma: Record<DeployRunStatus, PrismaDeployRunStatus> = {
|
||||
pending: PrismaDeployRunStatus.PENDING,
|
||||
queued: PrismaDeployRunStatus.QUEUED,
|
||||
running: PrismaDeployRunStatus.RUNNING,
|
||||
success: PrismaDeployRunStatus.SUCCESS,
|
||||
failed: PrismaDeployRunStatus.FAILED,
|
||||
canceled: PrismaDeployRunStatus.CANCELED,
|
||||
};
|
||||
const stepStatusToPrisma: Record<DeployRunStepStatus, PrismaRunStepStatus> = {
|
||||
pending: PrismaRunStepStatus.PENDING,
|
||||
queued: PrismaRunStepStatus.RUNNING,
|
||||
running: PrismaRunStepStatus.RUNNING,
|
||||
success: PrismaRunStepStatus.SUCCESS,
|
||||
failed: PrismaRunStepStatus.FAILED,
|
||||
canceled: PrismaRunStepStatus.CANCELED,
|
||||
skipped: PrismaRunStepStatus.SKIPPED,
|
||||
};
|
||||
const triggerToPrisma: Record<
|
||||
DeployRunSummary['trigger'],
|
||||
PrismaDeployRunTrigger
|
||||
> = {
|
||||
manual: PrismaDeployRunTrigger.MANUAL,
|
||||
gitea_webhook: PrismaDeployRunTrigger.GITEA_WEBHOOK,
|
||||
retry: PrismaDeployRunTrigger.RETRY,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DeployRunRepository {
|
||||
@@ -51,7 +105,7 @@ export class DeployRunRepository {
|
||||
) {}
|
||||
|
||||
async findMany(): Promise<DeployRunSummary[]> {
|
||||
if (!this.useDatabaseReads()) {
|
||||
if (!this.useDatabase()) {
|
||||
return [...this.memoryRuns].sort((left, right) =>
|
||||
right.createdAt.localeCompare(left.createdAt),
|
||||
);
|
||||
@@ -73,40 +127,28 @@ export class DeployRunRepository {
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return runs.map((run) => ({
|
||||
id: run.id,
|
||||
projectKey: run.project.key,
|
||||
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||
ref: run.ref,
|
||||
status: run.status.toLowerCase() as DeployRunStatus,
|
||||
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||
operator: run.operator,
|
||||
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||
failureSummary: run.failureSummary ?? undefined,
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
finishedAt: run.finishedAt?.toISOString(),
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
steps: run.steps.map((step) => ({
|
||||
id: step.id,
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||
order: step.order,
|
||||
startedAt: step.startedAt?.toISOString(),
|
||||
finishedAt: step.finishedAt?.toISOString(),
|
||||
errorSummary: step.errorSummary ?? undefined,
|
||||
})),
|
||||
}));
|
||||
return runs.map((run) => this.toSummary(run));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<DeployRunSummary | null> {
|
||||
if (!this.useDatabaseReads()) {
|
||||
if (!this.useDatabase()) {
|
||||
return this.memoryRuns.find((run) => run.id === id) ?? null;
|
||||
}
|
||||
|
||||
const run = await this.prisma.deployRun.findUnique({
|
||||
where: { id },
|
||||
const run = await this.findRecordById(id);
|
||||
|
||||
return run ? this.toSummary(run) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateDeployRunInput): Promise<DeployRunSummary> {
|
||||
const idempotencyKey = this.resolveIdempotencyKey(input);
|
||||
|
||||
if (!this.useDatabase()) {
|
||||
return this.createMemoryRun(input, idempotencyKey);
|
||||
}
|
||||
|
||||
const existing = await this.prisma.deployRun.findUnique({
|
||||
where: { idempotencyKey },
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
@@ -118,55 +160,284 @@ export class DeployRunRepository {
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
if (existing) {
|
||||
return this.toSummary(existing);
|
||||
}
|
||||
|
||||
const target = await this.findTargetProjectEnvironment(input);
|
||||
const initialRun = this.buildInitialRun(input, idempotencyKey);
|
||||
const metadata = this.inputMetadata(input);
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const run = await tx.deployRun.create({
|
||||
data: {
|
||||
projectId: target.project.id,
|
||||
environmentId: target.environment.id,
|
||||
jobId: target.deployJob?.id,
|
||||
status: PrismaDeployRunStatus.PENDING,
|
||||
trigger: triggerToPrisma[input.trigger ?? 'manual'],
|
||||
ref: input.ref,
|
||||
operator: input.operator,
|
||||
idempotencyKey,
|
||||
metadata,
|
||||
steps: {
|
||||
create: initialRun.steps.map((step) => ({
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: stepStatusToPrisma[step.status],
|
||||
order: step.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return run;
|
||||
});
|
||||
|
||||
return this.toSummary(created);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: DeployRunStatus,
|
||||
): Promise<DeployRunSummary | null> {
|
||||
return this.updateRun(id, { status });
|
||||
}
|
||||
|
||||
async updateRun(
|
||||
id: string,
|
||||
patch: DeployRunMemoryPatch,
|
||||
): Promise<DeployRunSummary | null> {
|
||||
if (!this.useDatabase()) {
|
||||
return this.updateMemoryRun(id, patch);
|
||||
}
|
||||
|
||||
const current = await this.prisma.deployRun.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const metadata = this.objectMetadata<DeployRunMetadata>(current.metadata);
|
||||
const data: Prisma.DeployRunUpdateInput = {
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
};
|
||||
|
||||
if (patch.status) {
|
||||
data.status = runStatusToPrisma[patch.status];
|
||||
|
||||
if (patch.status !== 'pending' && !current.startedAt) {
|
||||
data.startedAt = this.toDate(patch.startedAt) ?? now;
|
||||
}
|
||||
|
||||
if (terminalRunStatuses.has(patch.status)) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt) ?? now;
|
||||
}
|
||||
}
|
||||
|
||||
if ('jenkinsQueueId' in patch) {
|
||||
data.jenkinsQueueId = patch.jenkinsQueueId ?? null;
|
||||
}
|
||||
|
||||
if ('jenkinsBuildNumber' in patch) {
|
||||
data.jenkinsBuildNumber = patch.jenkinsBuildNumber ?? null;
|
||||
}
|
||||
|
||||
if ('jenkinsBuildUrl' in patch) {
|
||||
if (patch.jenkinsBuildUrl) {
|
||||
metadata.jenkinsBuildUrl = patch.jenkinsBuildUrl;
|
||||
} else {
|
||||
delete metadata.jenkinsBuildUrl;
|
||||
}
|
||||
data.metadata = metadata;
|
||||
}
|
||||
|
||||
if ('failureSummary' in patch) {
|
||||
data.failureSummary = patch.failureSummary ?? null;
|
||||
}
|
||||
|
||||
if ('startedAt' in patch) {
|
||||
data.startedAt = this.toDate(patch.startedAt);
|
||||
}
|
||||
|
||||
if ('finishedAt' in patch) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt);
|
||||
}
|
||||
|
||||
await this.prisma.deployRun.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async updateStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
patch: DeployRunStepMemoryPatch,
|
||||
): Promise<DeployRunSummary | null> {
|
||||
if (!this.useDatabase()) {
|
||||
return this.updateMemoryStep(runId, bpmnNodeId, patch);
|
||||
}
|
||||
|
||||
const current = await this.prisma.runStep.findUnique({
|
||||
where: {
|
||||
deployRunId_bpmnNodeId: {
|
||||
deployRunId: runId,
|
||||
bpmnNodeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const metadata = this.objectMetadata<RunStepMetadata>(current.metadata);
|
||||
const data: Prisma.RunStepUpdateInput = {};
|
||||
|
||||
if (patch.status) {
|
||||
data.status = stepStatusToPrisma[patch.status];
|
||||
|
||||
if (patch.status !== 'pending' && !current.startedAt) {
|
||||
data.startedAt = this.toDate(patch.startedAt) ?? now;
|
||||
}
|
||||
|
||||
if (terminalStepStatuses.has(patch.status)) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt) ?? now;
|
||||
}
|
||||
}
|
||||
|
||||
if ('startedAt' in patch) {
|
||||
data.startedAt = this.toDate(patch.startedAt);
|
||||
}
|
||||
|
||||
if ('finishedAt' in patch) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt);
|
||||
}
|
||||
|
||||
if ('message' in patch) {
|
||||
if (patch.message) {
|
||||
metadata.message = patch.message;
|
||||
} else {
|
||||
delete metadata.message;
|
||||
}
|
||||
data.metadata = metadata;
|
||||
}
|
||||
|
||||
if ('errorSummary' in patch) {
|
||||
data.errorSummary = patch.errorSummary ?? null;
|
||||
}
|
||||
|
||||
if ('logExcerpt' in patch) {
|
||||
if (patch.logExcerpt) {
|
||||
metadata.logExcerpt = patch.logExcerpt;
|
||||
} else {
|
||||
delete metadata.logExcerpt;
|
||||
}
|
||||
data.metadata = metadata;
|
||||
}
|
||||
|
||||
await this.prisma.runStep.update({
|
||||
where: {
|
||||
deployRunId_bpmnNodeId: {
|
||||
deployRunId: runId,
|
||||
bpmnNodeId,
|
||||
},
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
return this.findById(runId);
|
||||
}
|
||||
|
||||
private async findRecordById(id: string): Promise<DeployRunRecord | null> {
|
||||
return this.prisma.deployRun.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async findTargetProjectEnvironment(input: CreateDeployRunInput) {
|
||||
const project = await this.prisma.project.findUnique({
|
||||
where: { key: input.projectKey },
|
||||
include: {
|
||||
environments: {
|
||||
include: {
|
||||
deployJobs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const targetEnvironmentName =
|
||||
input.environment === 'test'
|
||||
? EnvironmentKind.TEST
|
||||
: EnvironmentKind.PRODUCTION;
|
||||
const environment = project?.environments.find(
|
||||
(item) => item.name === targetEnvironmentName,
|
||||
);
|
||||
const deployJob = environment?.deployJobs[0];
|
||||
|
||||
if (!project || !environment) {
|
||||
throw new Error(
|
||||
`Project ${input.projectKey} ${input.environment} is not seeded in database`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
projectKey: run.project.key,
|
||||
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||
ref: run.ref,
|
||||
status: run.status.toLowerCase() as DeployRunStatus,
|
||||
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||
operator: run.operator,
|
||||
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||
failureSummary: run.failureSummary ?? undefined,
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
finishedAt: run.finishedAt?.toISOString(),
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
steps: run.steps.map((step) => ({
|
||||
id: step.id,
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||
order: step.order,
|
||||
startedAt: step.startedAt?.toISOString(),
|
||||
finishedAt: step.finishedAt?.toISOString(),
|
||||
errorSummary: step.errorSummary ?? undefined,
|
||||
})),
|
||||
project,
|
||||
environment,
|
||||
deployJob,
|
||||
};
|
||||
}
|
||||
|
||||
create(input: CreateDeployRunInput): DeployRunSummary {
|
||||
const idempotencyKey =
|
||||
input.idempotencyKey ??
|
||||
[
|
||||
input.projectKey,
|
||||
input.environment,
|
||||
input.ref,
|
||||
input.operator,
|
||||
new Date().toISOString(),
|
||||
].join(':');
|
||||
private createMemoryRun(
|
||||
input: CreateDeployRunInput,
|
||||
idempotencyKey: string,
|
||||
): DeployRunSummary {
|
||||
const existing = this.memoryRuns.find((run) => run.id === idempotencyKey);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const run = this.buildInitialRun(input, idempotencyKey);
|
||||
this.memoryRuns.unshift(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
private buildInitialRun(
|
||||
input: CreateDeployRunInput,
|
||||
idempotencyKey: string,
|
||||
): DeployRunSummary {
|
||||
const now = new Date().toISOString();
|
||||
const run: DeployRunSummary = {
|
||||
|
||||
return {
|
||||
id: idempotencyKey,
|
||||
projectKey: input.projectKey,
|
||||
environment: input.environment,
|
||||
@@ -220,19 +491,9 @@ export class DeployRunRepository {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.memoryRuns.unshift(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
updateMemoryStatus(
|
||||
id: string,
|
||||
status: DeployRunStatus,
|
||||
): DeployRunSummary | null {
|
||||
return this.updateMemoryRun(id, { status });
|
||||
}
|
||||
|
||||
updateMemoryRun(
|
||||
private updateMemoryRun(
|
||||
id: string,
|
||||
patch: DeployRunMemoryPatch,
|
||||
): DeployRunSummary | null {
|
||||
@@ -283,7 +544,7 @@ export class DeployRunRepository {
|
||||
return run;
|
||||
}
|
||||
|
||||
updateMemoryStep(
|
||||
private updateMemoryStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
patch: DeployRunStepMemoryPatch,
|
||||
@@ -332,7 +593,84 @@ export class DeployRunRepository {
|
||||
return run;
|
||||
}
|
||||
|
||||
private useDatabaseReads(): boolean {
|
||||
private toSummary(run: DeployRunRecord): DeployRunSummary {
|
||||
const metadata = this.objectMetadata<DeployRunMetadata>(run.metadata);
|
||||
const sortedSteps = [...run.steps].sort((left, right) => left.order - right.order);
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
projectKey: run.project.key,
|
||||
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||
ref: run.ref,
|
||||
status: run.status.toLowerCase() as DeployRunStatus,
|
||||
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||
operator: run.operator,
|
||||
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||
jenkinsBuildUrl: metadata.jenkinsBuildUrl,
|
||||
failureSummary: run.failureSummary ?? undefined,
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
finishedAt: run.finishedAt?.toISOString(),
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
steps: sortedSteps.map((step) => {
|
||||
const stepMetadata = this.objectMetadata<RunStepMetadata>(
|
||||
step.metadata,
|
||||
);
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||
order: step.order,
|
||||
startedAt: step.startedAt?.toISOString(),
|
||||
finishedAt: step.finishedAt?.toISOString(),
|
||||
message: stepMetadata.message,
|
||||
errorSummary: step.errorSummary ?? undefined,
|
||||
logExcerpt: stepMetadata.logExcerpt,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private objectMetadata<T extends Record<string, unknown>>(
|
||||
value: Prisma.JsonValue | null | undefined,
|
||||
): T {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return { ...(value as Record<string, unknown>) } as T;
|
||||
}
|
||||
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
private inputMetadata(input: CreateDeployRunInput): Prisma.InputJsonObject {
|
||||
const metadata: DeployRunMetadata = {};
|
||||
|
||||
if (input.remark) {
|
||||
metadata.remark = input.remark;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private resolveIdempotencyKey(input: CreateDeployRunInput): string {
|
||||
return (
|
||||
input.idempotencyKey ??
|
||||
[
|
||||
input.projectKey,
|
||||
input.environment,
|
||||
input.ref,
|
||||
input.operator,
|
||||
new Date().toISOString(),
|
||||
].join(':')
|
||||
);
|
||||
}
|
||||
|
||||
private toDate(value: string | undefined): Date | null | undefined {
|
||||
return value ? new Date(value) : undefined;
|
||||
}
|
||||
|
||||
private useDatabase(): boolean {
|
||||
return (
|
||||
this.config.get('USE_DATABASE_READS', { infer: true }) &&
|
||||
Boolean(this.config.get('DATABASE_URL', { infer: true }))
|
||||
|
||||
@@ -62,7 +62,7 @@ export class DeployRunsService {
|
||||
|
||||
const project = await this.projectsService.getProject(input.projectKey);
|
||||
this.validateReleaseRequest(input, project);
|
||||
const run = this.deployRunRepository.create(input);
|
||||
const run = await this.deployRunRepository.create(input);
|
||||
|
||||
this.auditService.record({
|
||||
action: 'DEPLOY_REQUESTED',
|
||||
@@ -81,7 +81,7 @@ export class DeployRunsService {
|
||||
}
|
||||
|
||||
async cancelRun(id: string): Promise<DeployRunSummary> {
|
||||
const run = this.deployRunRepository.updateMemoryStatus(id, 'canceled');
|
||||
const run = await this.deployRunRepository.updateStatus(id, 'canceled');
|
||||
|
||||
if (!run) {
|
||||
throw new AppError(
|
||||
|
||||
Reference in New Issue
Block a user