feat: 支持发布单 MySQL 持久化

This commit is contained in:
湛兮
2026-06-11 22:33:50 +08:00
parent b5ee9b3648
commit d0375257ce
5 changed files with 565 additions and 112 deletions
+1
View File
@@ -16,6 +16,7 @@
"lint": "eslint \"{src,prisma}/**/*.{ts,js}\"", "lint": "eslint \"{src,prisma}/**/*.{ts,js}\"",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:dev": "prisma migrate dev",
"prisma:seed": "node dist/prisma/seed.js",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
+114
View File
@@ -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);
});
+33 -33
View File
@@ -34,7 +34,7 @@ export class DeployExecutionService {
let currentRun = this.requireRun( let currentRun = this.requireRun(
run.id, run.id,
this.deployRunRepository.updateMemoryRun(run.id, { status: 'running' }), await this.deployRunRepository.updateRun(run.id, { status: 'running' }),
); );
let currentStep: string | undefined; let currentStep: string | undefined;
@@ -53,7 +53,7 @@ export class DeployExecutionService {
try { try {
currentStep = 'gitea-check'; currentStep = 'gitea-check';
currentRun = this.completeStep(currentRun.id, currentStep, 'success'); currentRun = await this.completeStep(currentRun.id, currentStep, 'success');
currentStep = 'jenkins-build'; currentStep = 'jenkins-build';
const jenkinsResult = await this.advanceJenkinsBuild(currentRun, project); const jenkinsResult = await this.advanceJenkinsBuild(currentRun, project);
@@ -65,16 +65,16 @@ export class DeployExecutionService {
} }
currentStep = 'health-check'; currentStep = 'health-check';
currentRun = this.completeStep(currentRun.id, currentStep, 'success'); currentRun = await this.completeStep(currentRun.id, currentStep, 'success');
currentStep = 'notify-wecom'; currentStep = 'notify-wecom';
currentRun = await this.advanceNotification(currentRun, 'succeeded'); currentRun = await this.advanceNotification(currentRun, 'succeeded');
currentStep = 'finish-release'; currentStep = 'finish-release';
currentRun = this.completeStep(currentRun.id, currentStep, 'success'); currentRun = await this.completeStep(currentRun.id, currentStep, 'success');
currentRun = this.requireRun( currentRun = this.requireRun(
currentRun.id, currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, { await this.deployRunRepository.updateRun(currentRun.id, {
status: 'success', status: 'success',
}), }),
); );
@@ -97,21 +97,21 @@ export class DeployExecutionService {
if (currentStep) { if (currentStep) {
currentRun = currentRun =
this.deployRunRepository.updateMemoryStep( (await this.deployRunRepository.updateStep(
currentRun.id, currentRun.id,
currentStep, currentStep,
{ {
status: 'failed', status: 'failed',
errorSummary: failureSummary, errorSummary: failureSummary,
}, },
) ?? currentRun; )) ?? currentRun;
} }
currentRun = currentRun =
this.deployRunRepository.updateMemoryRun(currentRun.id, { (await this.deployRunRepository.updateRun(currentRun.id, {
status: 'failed', status: 'failed',
failureSummary, failureSummary,
}) ?? currentRun; })) ?? currentRun;
currentRun = await this.advanceNotification(currentRun, 'failed'); currentRun = await this.advanceNotification(currentRun, 'failed');
@@ -155,7 +155,7 @@ export class DeployExecutionService {
if (!queueItem.buildNumber) { if (!queueItem.buildNumber) {
return this.requireRun( return this.requireRun(
currentRun.id, currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, { await this.deployRunRepository.updateRun(currentRun.id, {
status: 'queued', status: 'queued',
jenkinsQueueId: queueItem.queueId, jenkinsQueueId: queueItem.queueId,
jenkinsBuildUrl: queueItem.executableUrl, jenkinsBuildUrl: queueItem.executableUrl,
@@ -166,14 +166,14 @@ export class DeployExecutionService {
buildNumber = queueItem.buildNumber; buildNumber = queueItem.buildNumber;
currentRun = this.requireRun( currentRun = this.requireRun(
currentRun.id, currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, { await this.deployRunRepository.updateRun(currentRun.id, {
status: 'running', status: 'running',
jenkinsQueueId: queueItem.queueId, jenkinsQueueId: queueItem.queueId,
jenkinsBuildNumber: queueItem.buildNumber, jenkinsBuildNumber: queueItem.buildNumber,
jenkinsBuildUrl: queueItem.executableUrl, jenkinsBuildUrl: queueItem.executableUrl,
}), }),
); );
currentRun = this.markStep( currentRun = await this.markStep(
currentRun.id, currentRun.id,
'jenkins-build', 'jenkins-build',
'running', 'running',
@@ -204,7 +204,7 @@ export class DeployExecutionService {
currentRun = this.requireRun( currentRun = this.requireRun(
currentRun.id, currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, { await this.deployRunRepository.updateRun(currentRun.id, {
status: buildStatus.building || !buildStatus.result ? 'running' : currentRun.status, status: buildStatus.building || !buildStatus.result ? 'running' : currentRun.status,
jenkinsBuildNumber: buildStatus.buildNumber, jenkinsBuildNumber: buildStatus.buildNumber,
jenkinsBuildUrl: buildStatus.url, jenkinsBuildUrl: buildStatus.url,
@@ -242,7 +242,7 @@ export class DeployExecutionService {
const reason = const reason =
health.message ?? health.message ??
'Jenkins is not configured; simulated build completed.'; '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({ this.auditService.record({
action: 'JENKINS_DRY_RUN_COMPLETED', 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( const queueItem = await this.jenkinsClient.buildWithParameters(
environment.jenkinsJobPath, environment.jenkinsJobPath,
this.buildParameters(run), this.buildParameters(run),
@@ -276,14 +276,14 @@ export class DeployExecutionService {
const nextStatus = queueItem.buildNumber ? 'running' : 'queued'; const nextStatus = queueItem.buildNumber ? 'running' : 'queued';
let currentRun = this.requireRun( let currentRun = this.requireRun(
run.id, run.id,
this.deployRunRepository.updateMemoryRun(run.id, { await this.deployRunRepository.updateRun(run.id, {
status: nextStatus, status: nextStatus,
jenkinsQueueId: queueItem.queueId, jenkinsQueueId: queueItem.queueId,
jenkinsBuildNumber: queueItem.buildNumber, jenkinsBuildNumber: queueItem.buildNumber,
jenkinsBuildUrl: queueItem.executableUrl, jenkinsBuildUrl: queueItem.executableUrl,
}), }),
); );
currentRun = this.markStep( currentRun = await this.markStep(
run.id, run.id,
'jenkins-build', 'jenkins-build',
nextStatus, nextStatus,
@@ -312,7 +312,7 @@ export class DeployExecutionService {
run: DeployRunSummary, run: DeployRunSummary,
event: DeployNotificationEvent, event: DeployNotificationEvent,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
this.markStep(run.id, 'notify-wecom', 'running'); await this.markStep(run.id, 'notify-wecom', 'running');
const notification = const notification =
await this.deployNotificationService.sendDeployRunEvent(run, event); await this.deployNotificationService.sendDeployRunEvent(run, event);
const stepStatus = const stepStatus =
@@ -321,7 +321,7 @@ export class DeployExecutionService {
: notification.status === 'failed' : notification.status === 'failed'
? 'failed' ? 'failed'
: 'skipped'; : 'skipped';
const currentRun = this.completeStep( const currentRun = await this.completeStep(
run.id, run.id,
'notify-wecom', 'notify-wecom',
stepStatus, stepStatus,
@@ -346,13 +346,13 @@ export class DeployExecutionService {
private async completeSuccessfulJenkinsRun( private async completeSuccessfulJenkinsRun(
run: DeployRunSummary, run: DeployRunSummary,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
let currentRun = this.completeStep(run.id, 'jenkins-build', 'success'); let currentRun = await this.completeStep(run.id, 'jenkins-build', 'success');
currentRun = this.completeStep(currentRun.id, 'health-check', 'success'); currentRun = await this.completeStep(currentRun.id, 'health-check', 'success');
currentRun = await this.advanceNotification(currentRun, 'succeeded'); 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 = this.requireRun(
currentRun.id, currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, { await this.deployRunRepository.updateRun(currentRun.id, {
status: 'success', status: 'success',
}), }),
); );
@@ -380,16 +380,16 @@ export class DeployExecutionService {
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath); const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`; const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`;
let currentRun = let currentRun =
this.deployRunRepository.updateMemoryStep(run.id, 'jenkins-build', { (await this.deployRunRepository.updateStep(run.id, 'jenkins-build', {
status: 'failed', status: 'failed',
errorSummary: failureSummary, errorSummary: failureSummary,
logExcerpt, logExcerpt,
}) ?? run; })) ?? run;
currentRun = currentRun =
this.deployRunRepository.updateMemoryRun(currentRun.id, { (await this.deployRunRepository.updateRun(currentRun.id, {
status: 'failed', status: 'failed',
failureSummary, failureSummary,
}) ?? currentRun; })) ?? currentRun;
currentRun = await this.advanceNotification(currentRun, 'failed'); currentRun = await this.advanceNotification(currentRun, 'failed');
this.auditService.record({ this.auditService.record({
@@ -463,30 +463,30 @@ export class DeployExecutionService {
}; };
} }
private completeStep( private async completeStep(
runId: string, runId: string,
bpmnNodeId: string, bpmnNodeId: string,
status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>, status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>,
errorSummary?: string, errorSummary?: string,
): DeployRunSummary { ): Promise<DeployRunSummary> {
return this.requireRun( return this.requireRun(
runId, runId,
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, { await this.deployRunRepository.updateStep(runId, bpmnNodeId, {
status, status,
errorSummary, errorSummary,
}), }),
); );
} }
private markStep( private async markStep(
runId: string, runId: string,
bpmnNodeId: string, bpmnNodeId: string,
status: Extract<DeployRunStepStatus, 'queued' | 'running'>, status: Extract<DeployRunStepStatus, 'queued' | 'running'>,
message?: string, message?: string,
): DeployRunSummary { ): Promise<DeployRunSummary> {
return this.requireRun( return this.requireRun(
runId, runId,
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, { await this.deployRunRepository.updateStep(runId, bpmnNodeId, {
status, status,
message, message,
}), }),
+415 -77
View File
@@ -1,5 +1,16 @@
/**
* 发布单仓储同时支持本地内存模式和 MySQL/Prisma 持久化模式。
* 业务层只关心 run/step 状态流转,是否落库由环境配置统一控制。
*/
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 { EnvConfig } from '../config/env.schema';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { seedDeployRuns } from './deploy-run.seed'; import { seedDeployRuns } from './deploy-run.seed';
@@ -29,6 +40,24 @@ type DeployRunStepMemoryPatch = {
logExcerpt?: string; 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>([ const terminalRunStatuses = new Set<DeployRunStatus>([
'success', 'success',
'failed', 'failed',
@@ -40,6 +69,31 @@ const terminalStepStatuses = new Set<DeployRunStepStatus>([
'canceled', 'canceled',
'skipped', '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() @Injectable()
export class DeployRunRepository { export class DeployRunRepository {
@@ -51,7 +105,7 @@ export class DeployRunRepository {
) {} ) {}
async findMany(): Promise<DeployRunSummary[]> { async findMany(): Promise<DeployRunSummary[]> {
if (!this.useDatabaseReads()) { if (!this.useDatabase()) {
return [...this.memoryRuns].sort((left, right) => return [...this.memoryRuns].sort((left, right) =>
right.createdAt.localeCompare(left.createdAt), right.createdAt.localeCompare(left.createdAt),
); );
@@ -73,40 +127,28 @@ export class DeployRunRepository {
take: 50, take: 50,
}); });
return runs.map((run) => ({ return runs.map((run) => this.toSummary(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,
})),
}));
} }
async findById(id: string): Promise<DeployRunSummary | null> { async findById(id: string): Promise<DeployRunSummary | null> {
if (!this.useDatabaseReads()) { if (!this.useDatabase()) {
return this.memoryRuns.find((run) => run.id === id) ?? null; return this.memoryRuns.find((run) => run.id === id) ?? null;
} }
const run = await this.prisma.deployRun.findUnique({ const run = await this.findRecordById(id);
where: { 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: { include: {
project: true, project: true,
environment: 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; 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 { return {
id: run.id, project,
projectKey: run.project.key, environment,
environment: run.environment.name === 'TEST' ? 'test' : 'production', deployJob,
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,
})),
}; };
} }
create(input: CreateDeployRunInput): DeployRunSummary { private createMemoryRun(
const idempotencyKey = input: CreateDeployRunInput,
input.idempotencyKey ?? idempotencyKey: string,
[ ): DeployRunSummary {
input.projectKey,
input.environment,
input.ref,
input.operator,
new Date().toISOString(),
].join(':');
const existing = this.memoryRuns.find((run) => run.id === idempotencyKey); const existing = this.memoryRuns.find((run) => run.id === idempotencyKey);
if (existing) { if (existing) {
return 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 now = new Date().toISOString();
const run: DeployRunSummary = {
return {
id: idempotencyKey, id: idempotencyKey,
projectKey: input.projectKey, projectKey: input.projectKey,
environment: input.environment, environment: input.environment,
@@ -220,19 +491,9 @@ export class DeployRunRepository {
}, },
], ],
}; };
this.memoryRuns.unshift(run);
return run;
} }
updateMemoryStatus( private updateMemoryRun(
id: string,
status: DeployRunStatus,
): DeployRunSummary | null {
return this.updateMemoryRun(id, { status });
}
updateMemoryRun(
id: string, id: string,
patch: DeployRunMemoryPatch, patch: DeployRunMemoryPatch,
): DeployRunSummary | null { ): DeployRunSummary | null {
@@ -283,7 +544,7 @@ export class DeployRunRepository {
return run; return run;
} }
updateMemoryStep( private updateMemoryStep(
runId: string, runId: string,
bpmnNodeId: string, bpmnNodeId: string,
patch: DeployRunStepMemoryPatch, patch: DeployRunStepMemoryPatch,
@@ -332,7 +593,84 @@ export class DeployRunRepository {
return run; 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 ( return (
this.config.get('USE_DATABASE_READS', { infer: true }) && this.config.get('USE_DATABASE_READS', { infer: true }) &&
Boolean(this.config.get('DATABASE_URL', { infer: true })) Boolean(this.config.get('DATABASE_URL', { infer: true }))
+2 -2
View File
@@ -62,7 +62,7 @@ export class DeployRunsService {
const project = await this.projectsService.getProject(input.projectKey); const project = await this.projectsService.getProject(input.projectKey);
this.validateReleaseRequest(input, project); this.validateReleaseRequest(input, project);
const run = this.deployRunRepository.create(input); const run = await this.deployRunRepository.create(input);
this.auditService.record({ this.auditService.record({
action: 'DEPLOY_REQUESTED', action: 'DEPLOY_REQUESTED',
@@ -81,7 +81,7 @@ export class DeployRunsService {
} }
async cancelRun(id: string): Promise<DeployRunSummary> { async cancelRun(id: string): Promise<DeployRunSummary> {
const run = this.deployRunRepository.updateMemoryStatus(id, 'canceled'); const run = await this.deployRunRepository.updateStatus(id, 'canceled');
if (!run) { if (!run) {
throw new AppError( throw new AppError(