feat: 初始化DevOps平台后端
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AppError } from '../common/errors/app-error';
|
||||
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
||||
import { JenkinsBuildStatus } from '../integrations/jenkins/jenkins.types';
|
||||
import {
|
||||
DeployNotificationEvent,
|
||||
DeployNotificationService,
|
||||
} from '../notifications/wecom/deploy-notification.service';
|
||||
import {
|
||||
ProjectEnvironmentSummary,
|
||||
ProjectSummary,
|
||||
} from '../projects/project.types';
|
||||
import { DeployRunRepository } from './deploy-run.repository';
|
||||
import { DeployRunStepStatus, DeployRunSummary } from './deploy-run.types';
|
||||
|
||||
@Injectable()
|
||||
export class DeployExecutionService {
|
||||
constructor(
|
||||
private readonly deployRunRepository: DeployRunRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly deployNotificationService: DeployNotificationService,
|
||||
private readonly jenkinsClient: JenkinsClient,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
run: DeployRunSummary,
|
||||
project: ProjectSummary,
|
||||
): Promise<DeployRunSummary> {
|
||||
if (run.status !== 'pending') {
|
||||
return run;
|
||||
}
|
||||
|
||||
let currentRun = this.requireRun(
|
||||
run.id,
|
||||
this.deployRunRepository.updateMemoryRun(run.id, { status: 'running' }),
|
||||
);
|
||||
let currentStep: string | undefined;
|
||||
|
||||
this.auditService.record({
|
||||
action: 'DEPLOY_EXECUTION_STARTED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: currentRun.id,
|
||||
actorName: currentRun.operator,
|
||||
after: {
|
||||
projectKey: currentRun.projectKey,
|
||||
environment: currentRun.environment,
|
||||
ref: currentRun.ref,
|
||||
status: currentRun.status,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
currentStep = 'gitea-check';
|
||||
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
|
||||
|
||||
currentStep = 'jenkins-build';
|
||||
const jenkinsResult = await this.advanceJenkinsBuild(currentRun, project);
|
||||
currentRun = jenkinsResult.run;
|
||||
|
||||
if (!jenkinsResult.shouldContinue) {
|
||||
await this.recordNotificationResult(currentRun, 'queued');
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
currentStep = 'health-check';
|
||||
currentRun = 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 = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
|
||||
this.auditService.record({
|
||||
action: 'DEPLOY_SUCCEEDED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: currentRun.id,
|
||||
actorName: currentRun.operator,
|
||||
after: {
|
||||
status: currentRun.status,
|
||||
jenkinsQueueId: currentRun.jenkinsQueueId,
|
||||
jenkinsBuildNumber: currentRun.jenkinsBuildNumber,
|
||||
},
|
||||
});
|
||||
|
||||
return currentRun;
|
||||
} catch (error) {
|
||||
const failureSummary = this.toFailureSummary(error);
|
||||
|
||||
if (currentStep) {
|
||||
currentRun =
|
||||
this.deployRunRepository.updateMemoryStep(
|
||||
currentRun.id,
|
||||
currentStep,
|
||||
{
|
||||
status: 'failed',
|
||||
errorSummary: failureSummary,
|
||||
},
|
||||
) ?? currentRun;
|
||||
}
|
||||
|
||||
currentRun =
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
status: 'failed',
|
||||
failureSummary,
|
||||
}) ?? currentRun;
|
||||
|
||||
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||
|
||||
this.auditService.record({
|
||||
action: 'DEPLOY_FAILED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: currentRun.id,
|
||||
actorName: currentRun.operator,
|
||||
after: {
|
||||
status: currentRun.status,
|
||||
failedStep: currentStep,
|
||||
failureSummary,
|
||||
},
|
||||
});
|
||||
|
||||
return currentRun;
|
||||
}
|
||||
}
|
||||
|
||||
async syncJenkinsRun(
|
||||
run: DeployRunSummary,
|
||||
project: ProjectSummary,
|
||||
): Promise<DeployRunSummary> {
|
||||
if (['success', 'failed', 'canceled'].includes(run.status)) {
|
||||
return run;
|
||||
}
|
||||
|
||||
if (!run.jenkinsQueueId && !run.jenkinsBuildNumber) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const environment = this.findEnvironment(project, run);
|
||||
let currentRun = run;
|
||||
let buildNumber = currentRun.jenkinsBuildNumber;
|
||||
|
||||
if (!buildNumber && currentRun.jenkinsQueueId) {
|
||||
const queueItem = await this.jenkinsClient.getQueueItem(
|
||||
currentRun.jenkinsQueueId,
|
||||
);
|
||||
|
||||
if (!queueItem.buildNumber) {
|
||||
return this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
status: 'queued',
|
||||
jenkinsQueueId: queueItem.queueId,
|
||||
jenkinsBuildUrl: queueItem.executableUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
buildNumber = queueItem.buildNumber;
|
||||
currentRun = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
status: 'running',
|
||||
jenkinsQueueId: queueItem.queueId,
|
||||
jenkinsBuildNumber: queueItem.buildNumber,
|
||||
jenkinsBuildUrl: queueItem.executableUrl,
|
||||
}),
|
||||
);
|
||||
currentRun = this.markStep(
|
||||
currentRun.id,
|
||||
'jenkins-build',
|
||||
'running',
|
||||
`Jenkins build #${queueItem.buildNumber} is running.`,
|
||||
);
|
||||
|
||||
this.auditService.record({
|
||||
action: 'JENKINS_BUILD_STARTED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: currentRun.id,
|
||||
actorName: currentRun.operator,
|
||||
after: {
|
||||
queueId: queueItem.queueId,
|
||||
buildNumber: queueItem.buildNumber,
|
||||
buildUrl: queueItem.executableUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!buildNumber) {
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
const buildStatus = await this.jenkinsClient.getBuildStatus(
|
||||
environment.jenkinsJobPath,
|
||||
buildNumber,
|
||||
);
|
||||
|
||||
currentRun = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
status: buildStatus.building || !buildStatus.result ? 'running' : currentRun.status,
|
||||
jenkinsBuildNumber: buildStatus.buildNumber,
|
||||
jenkinsBuildUrl: buildStatus.url,
|
||||
}),
|
||||
);
|
||||
|
||||
if (buildStatus.building || !buildStatus.result) {
|
||||
return this.markStep(
|
||||
currentRun.id,
|
||||
'jenkins-build',
|
||||
'running',
|
||||
`Jenkins build #${buildStatus.buildNumber} is running.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (buildStatus.result === 'SUCCESS') {
|
||||
return this.completeSuccessfulJenkinsRun(currentRun);
|
||||
}
|
||||
|
||||
return this.failJenkinsRun(
|
||||
currentRun,
|
||||
buildStatus.result,
|
||||
environment.jenkinsJobPath,
|
||||
);
|
||||
}
|
||||
|
||||
private async advanceJenkinsBuild(
|
||||
run: DeployRunSummary,
|
||||
project: ProjectSummary,
|
||||
): Promise<{ run: DeployRunSummary; shouldContinue: boolean }> {
|
||||
const environment = this.findEnvironment(project, run);
|
||||
const health = this.jenkinsClient.healthSummary();
|
||||
|
||||
if (health.status === 'not_configured') {
|
||||
const reason =
|
||||
health.message ??
|
||||
'Jenkins is not configured; simulated build completed.';
|
||||
const currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
|
||||
|
||||
this.auditService.record({
|
||||
action: 'JENKINS_DRY_RUN_COMPLETED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: run.id,
|
||||
actorName: run.operator,
|
||||
after: {
|
||||
mode: 'dry-run',
|
||||
reason,
|
||||
jobPath: environment.jenkinsJobPath,
|
||||
},
|
||||
});
|
||||
|
||||
return { run: currentRun, shouldContinue: true };
|
||||
}
|
||||
|
||||
if (!environment.jenkinsJobPath) {
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
`Project ${project.key} ${run.environment} Jenkins job path is empty`,
|
||||
400,
|
||||
{ projectKey: project.key, environment: run.environment },
|
||||
);
|
||||
}
|
||||
|
||||
this.markStep(run.id, 'jenkins-build', 'running');
|
||||
const queueItem = await this.jenkinsClient.buildWithParameters(
|
||||
environment.jenkinsJobPath,
|
||||
this.buildParameters(run),
|
||||
);
|
||||
const nextStatus = queueItem.buildNumber ? 'running' : 'queued';
|
||||
let currentRun = this.requireRun(
|
||||
run.id,
|
||||
this.deployRunRepository.updateMemoryRun(run.id, {
|
||||
status: nextStatus,
|
||||
jenkinsQueueId: queueItem.queueId,
|
||||
jenkinsBuildNumber: queueItem.buildNumber,
|
||||
jenkinsBuildUrl: queueItem.executableUrl,
|
||||
}),
|
||||
);
|
||||
currentRun = this.markStep(
|
||||
run.id,
|
||||
'jenkins-build',
|
||||
nextStatus,
|
||||
queueItem.buildNumber
|
||||
? `Jenkins build #${queueItem.buildNumber} is running.`
|
||||
: `Jenkins queue ${queueItem.queueId} accepted.`,
|
||||
);
|
||||
|
||||
this.auditService.record({
|
||||
action: 'JENKINS_BUILD_TRIGGERED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: run.id,
|
||||
actorName: run.operator,
|
||||
after: {
|
||||
jobPath: environment.jenkinsJobPath,
|
||||
queueId: queueItem.queueId,
|
||||
buildNumber: queueItem.buildNumber,
|
||||
},
|
||||
parameterDigest: this.buildParameters(run),
|
||||
});
|
||||
|
||||
return { run: currentRun, shouldContinue: false };
|
||||
}
|
||||
|
||||
private async advanceNotification(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
): Promise<DeployRunSummary> {
|
||||
this.markStep(run.id, 'notify-wecom', 'running');
|
||||
const notification =
|
||||
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||
const stepStatus =
|
||||
notification.status === 'sent'
|
||||
? 'success'
|
||||
: notification.status === 'failed'
|
||||
? 'failed'
|
||||
: 'skipped';
|
||||
const currentRun = this.completeStep(
|
||||
run.id,
|
||||
'notify-wecom',
|
||||
stepStatus,
|
||||
notification.status === 'sent' ? undefined : notification.reason,
|
||||
);
|
||||
|
||||
this.auditService.record({
|
||||
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: run.id,
|
||||
actorName: run.operator,
|
||||
after: {
|
||||
event,
|
||||
channel: notification.channel,
|
||||
reason: notification.reason,
|
||||
},
|
||||
});
|
||||
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
private async completeSuccessfulJenkinsRun(
|
||||
run: DeployRunSummary,
|
||||
): Promise<DeployRunSummary> {
|
||||
let currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
|
||||
currentRun = this.completeStep(currentRun.id, 'health-check', 'success');
|
||||
currentRun = await this.advanceNotification(currentRun, 'succeeded');
|
||||
currentRun = this.completeStep(currentRun.id, 'finish-release', 'success');
|
||||
currentRun = this.requireRun(
|
||||
currentRun.id,
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
|
||||
this.auditService.record({
|
||||
action: 'DEPLOY_SUCCEEDED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: currentRun.id,
|
||||
actorName: currentRun.operator,
|
||||
after: {
|
||||
status: currentRun.status,
|
||||
jenkinsQueueId: currentRun.jenkinsQueueId,
|
||||
jenkinsBuildNumber: currentRun.jenkinsBuildNumber,
|
||||
},
|
||||
});
|
||||
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
private async failJenkinsRun(
|
||||
run: DeployRunSummary,
|
||||
result: Exclude<NonNullable<JenkinsBuildStatus['result']>, 'SUCCESS'>,
|
||||
jobPath: string,
|
||||
): Promise<DeployRunSummary> {
|
||||
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
|
||||
const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`;
|
||||
let currentRun =
|
||||
this.deployRunRepository.updateMemoryStep(run.id, 'jenkins-build', {
|
||||
status: 'failed',
|
||||
errorSummary: failureSummary,
|
||||
logExcerpt,
|
||||
}) ?? run;
|
||||
currentRun =
|
||||
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||
status: 'failed',
|
||||
failureSummary,
|
||||
}) ?? currentRun;
|
||||
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||
|
||||
this.auditService.record({
|
||||
action: 'DEPLOY_FAILED',
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: currentRun.id,
|
||||
actorName: currentRun.operator,
|
||||
after: {
|
||||
status: currentRun.status,
|
||||
failedStep: 'jenkins-build',
|
||||
failureSummary,
|
||||
},
|
||||
});
|
||||
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
private async readFailureLogExcerpt(
|
||||
run: DeployRunSummary,
|
||||
jobPath: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!run.jenkinsBuildNumber) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const log = await this.jenkinsClient.getProgressiveText(
|
||||
jobPath,
|
||||
run.jenkinsBuildNumber,
|
||||
);
|
||||
const logExcerpt = log.text
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.slice(-24)
|
||||
.join('\n')
|
||||
.slice(-4000);
|
||||
|
||||
return redactSensitive({ logExcerpt }).logExcerpt;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private findEnvironment(
|
||||
project: ProjectSummary,
|
||||
run: DeployRunSummary,
|
||||
): ProjectEnvironmentSummary {
|
||||
const environment = project.environments.find(
|
||||
(item) => item.name === run.environment,
|
||||
);
|
||||
|
||||
if (!environment) {
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
`Project ${project.key} does not expose ${run.environment} environment`,
|
||||
400,
|
||||
{ projectKey: project.key, environment: run.environment },
|
||||
);
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
private buildParameters(run: DeployRunSummary): Record<string, string> {
|
||||
return {
|
||||
DEPLOY_RUN_ID: run.id,
|
||||
PROJECT_KEY: run.projectKey,
|
||||
DEPLOY_ENVIRONMENT: run.environment,
|
||||
GIT_REF: run.ref,
|
||||
OPERATOR: run.operator,
|
||||
};
|
||||
}
|
||||
|
||||
private completeStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>,
|
||||
errorSummary?: string,
|
||||
): DeployRunSummary {
|
||||
return this.requireRun(
|
||||
runId,
|
||||
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
|
||||
status,
|
||||
errorSummary,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private markStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
status: Extract<DeployRunStepStatus, 'queued' | 'running'>,
|
||||
message?: string,
|
||||
): DeployRunSummary {
|
||||
return this.requireRun(
|
||||
runId,
|
||||
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
|
||||
status,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async recordNotificationResult(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
): Promise<void> {
|
||||
const notification =
|
||||
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||
|
||||
this.auditService.record({
|
||||
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||
resourceType: 'deploy_run',
|
||||
resourceId: run.id,
|
||||
actorName: run.operator,
|
||||
after: {
|
||||
event,
|
||||
channel: notification.channel,
|
||||
reason: notification.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private requireRun(
|
||||
id: string,
|
||||
run: DeployRunSummary | null,
|
||||
): DeployRunSummary {
|
||||
if (!run) {
|
||||
throw new AppError(
|
||||
'RESOURCE_NOT_FOUND',
|
||||
`Deploy run ${id} not found`,
|
||||
404,
|
||||
{
|
||||
id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
private toFailureSummary(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return redactSensitive({ message: error.message }).message;
|
||||
}
|
||||
|
||||
return 'Unknown deploy execution error';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user