268 lines
7.4 KiB
TypeScript
268 lines
7.4 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
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 {
|
|
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,
|
|
) {}
|
|
|
|
async listRuns(): Promise<DeployRunSummary[]> {
|
|
return this.deployRunRepository.findMany();
|
|
}
|
|
|
|
async getRun(id: string): Promise<DeployRunSummary> {
|
|
const run = await this.deployRunRepository.findById(id);
|
|
|
|
if (!run) {
|
|
throw new AppError(
|
|
'RESOURCE_NOT_FOUND',
|
|
`Deploy run ${id} not found`,
|
|
404,
|
|
{
|
|
id,
|
|
},
|
|
);
|
|
}
|
|
|
|
return run;
|
|
}
|
|
|
|
async createRun(input: CreateDeployRunInput): Promise<DeployRunSummary> {
|
|
if (!['test', 'production'].includes(input.environment)) {
|
|
throw new AppError(
|
|
'VALIDATION_FAILED',
|
|
'environment must be test or production',
|
|
400,
|
|
{ environment: input.environment },
|
|
);
|
|
}
|
|
|
|
const project = await this.projectsService.getProject(input.projectKey);
|
|
this.validateReleaseRequest(input, project);
|
|
const run = await this.deployRunRepository.create(input);
|
|
|
|
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,
|
|
remark: input.remark,
|
|
},
|
|
});
|
|
|
|
return this.deployExecutionService.execute(run, project);
|
|
}
|
|
|
|
async cancelRun(id: string): Promise<DeployRunSummary> {
|
|
const run = await this.deployRunRepository.updateStatus(id, 'canceled');
|
|
|
|
if (!run) {
|
|
throw new AppError(
|
|
'RESOURCE_NOT_FOUND',
|
|
`Deploy run ${id} not found`,
|
|
404,
|
|
{
|
|
id,
|
|
},
|
|
);
|
|
}
|
|
|
|
this.auditService.record({
|
|
action: 'DEPLOY_CANCELED',
|
|
resourceType: 'deploy_run',
|
|
resourceId: run.id,
|
|
after: {
|
|
status: run.status,
|
|
},
|
|
});
|
|
await this.recordNotificationResult(run, 'canceled');
|
|
|
|
return run;
|
|
}
|
|
|
|
async retryRun(id: string): Promise<DeployRunSummary> {
|
|
const source = await this.getRun(id);
|
|
return this.createRun({
|
|
projectKey: source.projectKey,
|
|
environment: source.environment,
|
|
ref: source.ref,
|
|
operator: source.operator,
|
|
remark: `Retry of ${source.id}`,
|
|
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
|
|
trigger: 'retry',
|
|
});
|
|
}
|
|
|
|
async syncJenkinsRun(id: string): Promise<DeployRunSummary> {
|
|
const run = await this.getRun(id);
|
|
const project = await this.projectsService.getProject(run.projectKey);
|
|
|
|
return this.deployExecutionService.syncJenkinsRun(run, project);
|
|
}
|
|
|
|
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
|
|
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),
|
|
});
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
this.auditService.record({
|
|
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
|
resourceType: 'deploy_run',
|
|
resourceId: run.id,
|
|
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 validateReleaseRequest(
|
|
input: CreateDeployRunInput,
|
|
project: ProjectSummary,
|
|
): ProjectEnvironmentSummary {
|
|
if (project.status !== 'active') {
|
|
this.recordDeployRejected(input, 'Project is archived');
|
|
throw new AppError(
|
|
'VALIDATION_FAILED',
|
|
`Project ${project.key} is archived and cannot be deployed`,
|
|
400,
|
|
{ projectKey: project.key },
|
|
);
|
|
}
|
|
|
|
const environment = project.environments.find(
|
|
(item) => item.name === input.environment,
|
|
);
|
|
|
|
if (!environment) {
|
|
this.recordDeployRejected(input, 'Environment is not configured');
|
|
throw new AppError(
|
|
'VALIDATION_FAILED',
|
|
`Project ${project.key} does not expose ${input.environment} environment`,
|
|
400,
|
|
{ projectKey: project.key, environment: input.environment },
|
|
);
|
|
}
|
|
|
|
if (!new RegExp(environment.refPattern).test(input.ref)) {
|
|
this.recordDeployRejected(input, 'Ref does not match release policy');
|
|
throw new AppError(
|
|
'VALIDATION_FAILED',
|
|
`Ref ${input.ref} does not match ${environment.releasePolicy} release policy`,
|
|
400,
|
|
{
|
|
projectKey: project.key,
|
|
environment: input.environment,
|
|
ref: input.ref,
|
|
releasePolicy: environment.releasePolicy,
|
|
refPattern: environment.refPattern,
|
|
},
|
|
);
|
|
}
|
|
|
|
return environment;
|
|
}
|
|
|
|
private recordDeployRejected(
|
|
input: CreateDeployRunInput,
|
|
reason: string,
|
|
): void {
|
|
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 sync failed';
|
|
|
|
return redactSensitive({ message }).message;
|
|
}
|
|
}
|