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 { 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 { 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 { 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 { 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, 'SUCCESS'>, jobPath: string, ): Promise { 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 { 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 { 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, errorSummary?: string, ): DeployRunSummary { return this.requireRun( runId, this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, { status, errorSummary, }), ); } private markStep( runId: string, bpmnNodeId: string, status: Extract, message?: string, ): DeployRunSummary { return this.requireRun( runId, this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, { status, message, }), ); } private async recordNotificationResult( run: DeployRunSummary, event: DeployNotificationEvent, ): Promise { 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'; } }