Files
devops-platform-api/src/deploy-runs/deploy-runs.service.ts
T
2026-06-12 01:16:41 +08:00

462 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { redactSensitive } from '../common/security/redact-sensitive';
import { AuditService } from '../audit/audit.service';
import {
DeployNotificationEvent,
DeployNotificationService,
} from '../notifications/wecom/deploy-notification.service';
import { JenkinsCancelResult } from '../integrations/jenkins/jenkins.types';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import {
ProjectEnvironmentSummary,
ProjectSummary,
} from '../projects/project.types';
import { ProjectsService } from '../projects/projects.service';
import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository';
import {
CreateDeployRunInput,
DeployRunJenkinsSyncSummary,
DeployRunSummary,
} from './deploy-run.types';
@Injectable()
export class DeployRunsService {
constructor(
private readonly deployRunRepository: DeployRunRepository,
private readonly projectsService: ProjectsService,
private readonly auditService: AuditService,
private readonly deployNotificationService: DeployNotificationService,
private readonly deployExecutionService: DeployExecutionService,
private readonly jenkinsClient: JenkinsClient,
private readonly authService: AuthService,
) {}
async listRuns(user?: AuthenticatedUser): Promise<DeployRunSummary[]> {
const runs = await this.deployRunRepository.findMany();
if (!user || user.role === 'super_admin') {
return runs;
}
const allowedProjectKeys = new Set(
await this.authService.listAllowedProjectKeys(user),
);
return runs.filter((run) => allowedProjectKeys.has(run.projectKey));
}
async getRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const run = await this.deployRunRepository.findById(id);
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`发布记录不存在:${id}`,
404,
{
id,
},
);
}
if (user) {
await this.authService.requireProjectPermission(
user,
run.projectKey,
'read',
);
}
return run;
}
async createRun(
input: CreateDeployRunInput,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
if (!['test', 'production'].includes(input.environment)) {
throw new AppError(
'VALIDATION_FAILED',
'发布环境必须为 test 或 production',
400,
{ environment: input.environment },
);
}
if (user) {
await this.authService.requireProjectPermission(
user,
input.projectKey,
'build',
);
}
const project = await this.projectsService.getProject(input.projectKey);
await this.validateReleaseRequest(input, project);
const run = await this.deployRunRepository.create(input);
await this.auditService.record({
action: 'DEPLOY_REQUESTED',
resourceType: 'deploy_run',
resourceId: run.id,
actorName: input.operator,
after: {
projectKey: input.projectKey,
environment: input.environment,
ref: input.ref,
trigger: input.trigger ?? 'manual',
remark: input.remark,
},
});
return this.deployExecutionService.execute(run, project);
}
async cancelRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const source = await this.getRun(id, user);
if (user) {
await this.authService.requireProjectPermission(
user,
source.projectKey,
'build',
);
}
if (['success', 'failed', 'canceled'].includes(source.status)) {
throw new AppError(
'VALIDATION_FAILED',
`发布记录 ${id} 已是终态,不能取消`,
400,
{
id,
status: source.status,
},
);
}
const project = await this.projectsService.getProject(source.projectKey);
const jenkinsCancel = await this.cancelJenkinsExecution(source, project);
let run = this.requireRun(
id,
await this.deployRunRepository.updateRun(id, {
status: 'canceled',
}),
);
run = this.requireRun(
id,
await this.deployRunRepository.updateStep(id, 'jenkins-build', {
status: 'canceled',
message: undefined,
errorSummary: this.cancelSummary(jenkinsCancel),
}),
);
await this.auditService.record({
action: 'DEPLOY_CANCELED',
resourceType: 'deploy_run',
resourceId: run.id,
actorName: run.operator,
after: {
status: run.status,
jenkinsCancel,
},
});
await this.recordNotificationResult(run, 'canceled');
return run;
}
async retryRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const source = await this.getRun(id, user);
if (!['failed', 'canceled'].includes(source.status)) {
throw new AppError(
'VALIDATION_FAILED',
`发布记录 ${id} 当前状态为 ${source.status},只能重试失败或已取消的发布单`,
400,
{
id,
status: source.status,
},
);
}
return this.createRun(
{
projectKey: source.projectKey,
environment: source.environment,
ref: source.ref,
operator: user?.account ?? source.operator,
remark: `重试发布记录 ${source.id}`,
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
trigger: 'retry',
},
user,
);
}
async syncJenkinsRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const run = await this.getRun(id, user);
if (user) {
await this.authService.requireProjectPermission(
user,
run.projectKey,
'build',
);
}
const project = await this.projectsService.getProject(run.projectKey);
return this.deployExecutionService.syncJenkinsRun(run, project);
}
async syncJenkinsRuns(
user?: AuthenticatedUser,
): Promise<DeployRunJenkinsSyncSummary> {
if (user) {
this.authService.assertSuperAdmin(user, '无权限同步全部 Jenkins 状态');
}
const runs = await this.listRuns();
const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run));
const syncedRuns: DeployRunSummary[] = [];
const failedRuns: DeployRunJenkinsSyncSummary['failedRuns'] = [];
for (const run of candidates) {
try {
const project = await this.projectsService.getProject(run.projectKey);
const syncedRun = await this.deployExecutionService.syncJenkinsRun(
run,
project,
);
syncedRuns.push(syncedRun);
} catch (error) {
failedRuns.push({
id: run.id,
projectKey: run.projectKey,
message: this.toSafeSyncError(error),
});
}
}
await this.auditService.record({
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
resourceType: 'deploy_run',
after: {
totalCandidates: candidates.length,
syncedCount: syncedRuns.length,
failedCount: failedRuns.length,
},
});
return {
checkedAt: new Date().toISOString(),
totalCandidates: candidates.length,
syncedRuns,
failedRuns,
};
}
private async recordNotificationResult(
run: DeployRunSummary,
event: DeployNotificationEvent,
): Promise<void> {
const notification =
await this.deployNotificationService.sendDeployRunEvent(run, event);
await this.auditService.record({
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
resourceType: 'deploy_run',
resourceId: run.id,
actorName: run.operator,
after: {
event,
channel: notification.channel,
reason: notification.reason,
},
});
}
private isJenkinsSyncCandidate(run: DeployRunSummary): boolean {
return (
['queued', 'running'].includes(run.status) &&
Boolean(run.jenkinsQueueId || run.jenkinsBuildNumber)
);
}
private async cancelJenkinsExecution(
run: DeployRunSummary,
project: ProjectSummary,
): Promise<JenkinsCancelResult | { requested: false; reason: string }> {
const environment = this.findEnvironment(project, run);
const jenkinsHealth = this.jenkinsClient.healthSummary();
if (jenkinsHealth.status === 'not_configured') {
return {
requested: false,
reason: jenkinsHealth.message ?? 'Jenkins 尚未配置',
};
}
if (run.jenkinsBuildNumber) {
return this.jenkinsClient.stopBuild(
environment.jenkinsJobPath,
run.jenkinsBuildNumber,
);
}
if (run.jenkinsQueueId) {
return this.jenkinsClient.cancelQueueItem(run.jenkinsQueueId);
}
return {
requested: false,
reason: '发布记录缺少 Jenkins 队列编号或构建编号',
};
}
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.key} 未配置 ${run.environment} 环境`,
400,
{ projectKey: project.key, environment: run.environment },
);
}
return environment;
}
private cancelSummary(
result: JenkinsCancelResult | { requested: false; reason: string },
): string {
if (!result.requested) {
return `已在本地记录取消结果:${result.reason}`;
}
if (result.target === 'build') {
return `已请求停止 Jenkins 构建 #${result.buildNumber ?? '-'}`;
}
return `已请求取消 Jenkins 队列 ${result.queueId ?? '-'}`;
}
private async validateReleaseRequest(
input: CreateDeployRunInput,
project: ProjectSummary,
): Promise<ProjectEnvironmentSummary> {
if (project.status !== 'active') {
await this.recordDeployRejected(input, '项目已归档');
throw new AppError(
'VALIDATION_FAILED',
`项目 ${project.key} 已归档,不能发布`,
400,
{ projectKey: project.key },
);
}
const environment = project.environments.find(
(item) => item.name === input.environment,
);
if (!environment) {
await this.recordDeployRejected(input, '环境未配置');
throw new AppError(
'VALIDATION_FAILED',
`项目 ${project.key} 未配置 ${input.environment} 环境`,
400,
{ projectKey: project.key, environment: input.environment },
);
}
if (!new RegExp(environment.refPattern).test(input.ref)) {
await this.recordDeployRejected(
input,
'Ref 不符合发布策略',
);
throw new AppError(
'VALIDATION_FAILED',
`Ref ${input.ref} 不符合 ${environment.releasePolicy} 发布策略`,
400,
{
projectKey: project.key,
environment: input.environment,
ref: input.ref,
releasePolicy: environment.releasePolicy,
refPattern: environment.refPattern,
},
);
}
return environment;
}
private async recordDeployRejected(
input: CreateDeployRunInput,
reason: string,
): Promise<void> {
await this.auditService.record({
action: 'DEPLOY_REJECTED',
resourceType: 'deploy_run',
actorName: input.operator,
after: {
projectKey: input.projectKey,
environment: input.environment,
ref: input.ref,
reason,
},
});
}
private toSafeSyncError(error: unknown): string {
const message =
error instanceof Error ? error.message : 'Jenkins 同步失败';
return redactSensitive({ message }).message;
}
private requireRun(
id: string,
run: DeployRunSummary | null,
): DeployRunSummary {
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`发布记录不存在:${id}`,
404,
{
id,
},
);
}
return run;
}
}