feat: 初始化DevOps平台后端
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
import { DeployEnvironment } from './deploy-run.types';
|
||||
|
||||
const optionalTrimmedString = (maxLength: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(maxLength)
|
||||
.optional()
|
||||
.or(z.literal('').transform(() => undefined));
|
||||
|
||||
/**
|
||||
* 创建发布单的运行时校验规则;真实密钥和第三方参数不允许从前端传入。
|
||||
*/
|
||||
export const createDeployRunSchema = z.object({
|
||||
projectKey: z.string().trim().min(1).max(80),
|
||||
environment: z.enum(['test', 'production']),
|
||||
ref: z.string().trim().min(1).max(200),
|
||||
operator: z.string().trim().min(1).max(120),
|
||||
remark: optionalTrimmedString(500),
|
||||
idempotencyKey: optionalTrimmedString(260),
|
||||
});
|
||||
|
||||
export class CreateDeployRunDto {
|
||||
@ApiProperty({ example: 'access-manage' })
|
||||
projectKey!: string;
|
||||
|
||||
@ApiProperty({ enum: ['test', 'production'], example: 'test' })
|
||||
environment!: DeployEnvironment;
|
||||
|
||||
@ApiProperty({ example: 'develop' })
|
||||
ref!: string;
|
||||
|
||||
@ApiProperty({ example: 'operator@example.com' })
|
||||
operator!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Manual test deploy before release.' })
|
||||
remark?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'access-manage:test:develop:2026-06-11T10:00:00Z',
|
||||
})
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
||||
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
||||
import { ProjectSummary } from '../projects/project.types';
|
||||
import { DeployExecutionService } from './deploy-execution.service';
|
||||
import { DeployRunRepository } from './deploy-run.repository';
|
||||
import { CreateDeployRunInput, DeployRunSummary } from './deploy-run.types';
|
||||
|
||||
describe('DeployExecutionService', () => {
|
||||
const project: ProjectSummary = {
|
||||
id: 'project_001',
|
||||
key: 'access-manage',
|
||||
name: 'access-manage',
|
||||
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||
defaultBranch: 'develop',
|
||||
status: 'active',
|
||||
environments: [
|
||||
{
|
||||
name: 'test',
|
||||
displayName: '测试环境',
|
||||
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||
},
|
||||
],
|
||||
};
|
||||
const input: CreateDeployRunInput = {
|
||||
projectKey: 'access-manage',
|
||||
environment: 'test',
|
||||
ref: 'develop',
|
||||
operator: 'ops-admin',
|
||||
};
|
||||
|
||||
function createRepository(): DeployRunRepository {
|
||||
return new DeployRunRepository(
|
||||
{
|
||||
get: jest.fn(() => false),
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
function createService(options: {
|
||||
jenkinsHealth: ReturnType<JenkinsClient['healthSummary']>;
|
||||
notificationStatus: 'sent' | 'skipped';
|
||||
queueId?: string;
|
||||
queueBuildNumber?: number;
|
||||
buildResult?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE';
|
||||
buildRunning?: boolean;
|
||||
logText?: string;
|
||||
}) {
|
||||
const repository = createRepository();
|
||||
const audit = {
|
||||
record: jest.fn(),
|
||||
} satisfies Pick<AuditService, 'record'>;
|
||||
const notification = {
|
||||
sendDeployRunEvent: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
status: options.notificationStatus,
|
||||
channel: 'wecom' as const,
|
||||
reason:
|
||||
options.notificationStatus === 'skipped'
|
||||
? 'Missing WECOM_WEBHOOK_URL'
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
} satisfies Pick<DeployNotificationService, 'sendDeployRunEvent'>;
|
||||
const jenkins = {
|
||||
healthSummary: jest.fn(() => options.jenkinsHealth),
|
||||
buildWithParameters: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
queueId: options.queueId ?? 'queue_001',
|
||||
}),
|
||||
),
|
||||
getQueueItem: jest.fn((queueId: string) =>
|
||||
Promise.resolve({
|
||||
queueId,
|
||||
buildNumber: options.queueBuildNumber,
|
||||
executableUrl: options.queueBuildNumber
|
||||
? `https://jenkins.example/job/${options.queueBuildNumber}/`
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
getBuildStatus: jest.fn((_jobPath: string, buildNumber: number) =>
|
||||
Promise.resolve({
|
||||
jobPath: 'my-project-dev/test-access-manage-develop',
|
||||
buildNumber,
|
||||
building: options.buildRunning ?? false,
|
||||
result: options.buildResult,
|
||||
url: `https://jenkins.example/job/${buildNumber}/`,
|
||||
}),
|
||||
),
|
||||
getProgressiveText: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
text: options.logText ?? '',
|
||||
nextStart: options.logText?.length ?? 0,
|
||||
hasMore: false,
|
||||
}),
|
||||
),
|
||||
} satisfies Pick<
|
||||
JenkinsClient,
|
||||
| 'healthSummary'
|
||||
| 'buildWithParameters'
|
||||
| 'getQueueItem'
|
||||
| 'getBuildStatus'
|
||||
| 'getProgressiveText'
|
||||
>;
|
||||
const service = new DeployExecutionService(
|
||||
repository,
|
||||
audit as unknown as AuditService,
|
||||
notification as unknown as DeployNotificationService,
|
||||
jenkins as unknown as JenkinsClient,
|
||||
);
|
||||
|
||||
return {
|
||||
audit,
|
||||
jenkins,
|
||||
notification,
|
||||
repository,
|
||||
service,
|
||||
};
|
||||
}
|
||||
|
||||
function createRun(
|
||||
repository: DeployRunRepository,
|
||||
idempotencyKey: string,
|
||||
): DeployRunSummary {
|
||||
return repository.create({
|
||||
...input,
|
||||
idempotencyKey,
|
||||
});
|
||||
}
|
||||
|
||||
function step(run: DeployRunSummary, bpmnNodeId: string) {
|
||||
return run.steps.find((item) => item.bpmnNodeId === bpmnNodeId);
|
||||
}
|
||||
|
||||
it('completes a dry-run release when Jenkins is not configured', async () => {
|
||||
const { audit, jenkins, repository, service } = createService({
|
||||
jenkinsHealth: {
|
||||
status: 'not_configured',
|
||||
message: 'Missing JENKINS_API_TOKEN',
|
||||
},
|
||||
notificationStatus: 'skipped',
|
||||
});
|
||||
const run = createRun(repository, 'run_dry_001');
|
||||
|
||||
const result = await service.execute(run, project);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.jenkinsQueueId).toBeUndefined();
|
||||
expect(step(result, 'gitea-check')?.status).toBe('success');
|
||||
expect(step(result, 'jenkins-build')?.status).toBe('success');
|
||||
expect(step(result, 'health-check')?.status).toBe('success');
|
||||
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
|
||||
expect(step(result, 'finish-release')?.status).toBe('success');
|
||||
expect(jenkins.buildWithParameters).not.toHaveBeenCalled();
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'JENKINS_DRY_RUN_COMPLETED' }),
|
||||
);
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('triggers Jenkins and keeps the run queued until status polling completes', async () => {
|
||||
const { audit, jenkins, repository, service } = createService({
|
||||
jenkinsHealth: { status: 'ok' },
|
||||
notificationStatus: 'sent',
|
||||
queueId: 'jenkins_queue_42',
|
||||
});
|
||||
const run = createRun(repository, 'run_queue_001');
|
||||
|
||||
const result = await service.execute(run, project);
|
||||
|
||||
expect(result.status).toBe('queued');
|
||||
expect(result.jenkinsQueueId).toBe('jenkins_queue_42');
|
||||
expect(step(result, 'jenkins-build')?.status).toBe('queued');
|
||||
expect(step(result, 'health-check')?.status).toBe('pending');
|
||||
expect(step(result, 'notify-wecom')?.status).toBe('pending');
|
||||
expect(jenkins.buildWithParameters).toHaveBeenCalledWith(
|
||||
'my-project-dev/test-access-manage-develop',
|
||||
expect.objectContaining({
|
||||
DEPLOY_RUN_ID: 'run_queue_001',
|
||||
DEPLOY_ENVIRONMENT: 'test',
|
||||
GIT_REF: 'develop',
|
||||
}),
|
||||
);
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'JENKINS_BUILD_TRIGGERED',
|
||||
after: expect.objectContaining({
|
||||
queueId: 'jenkins_queue_42',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(audit.record).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs a queued Jenkins run to success', async () => {
|
||||
const { audit, repository, service } = createService({
|
||||
jenkinsHealth: { status: 'ok' },
|
||||
notificationStatus: 'sent',
|
||||
queueId: 'queue_success',
|
||||
queueBuildNumber: 18,
|
||||
buildResult: 'SUCCESS',
|
||||
});
|
||||
const run = await service.execute(createRun(repository, 'run_success_001'), project);
|
||||
|
||||
const result = await service.syncJenkinsRun(run, project);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.jenkinsBuildNumber).toBe(18);
|
||||
expect(step(result, 'jenkins-build')?.status).toBe('success');
|
||||
expect(step(result, 'health-check')?.status).toBe('success');
|
||||
expect(step(result, 'notify-wecom')?.status).toBe('success');
|
||||
expect(step(result, 'finish-release')?.status).toBe('success');
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'JENKINS_BUILD_STARTED' }),
|
||||
);
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs a queued Jenkins run to failure with a log excerpt', async () => {
|
||||
const { audit, repository, service } = createService({
|
||||
jenkinsHealth: { status: 'ok' },
|
||||
notificationStatus: 'skipped',
|
||||
queueId: 'queue_failure',
|
||||
queueBuildNumber: 19,
|
||||
buildResult: 'FAILURE',
|
||||
logText: ['install ok', 'build failed', 'token=secret-value'].join('\n'),
|
||||
});
|
||||
const run = await service.execute(createRun(repository, 'run_failure_001'), project);
|
||||
|
||||
const result = await service.syncJenkinsRun(run, project);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.failureSummary).toBe('Jenkins build #19 FAILURE');
|
||||
expect(step(result, 'jenkins-build')?.status).toBe('failed');
|
||||
expect(step(result, 'jenkins-build')?.errorSummary).toBe(
|
||||
'Jenkins build #19 FAILURE',
|
||||
);
|
||||
expect(step(result, 'jenkins-build')?.logExcerpt).toContain('build failed');
|
||||
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'DEPLOY_FAILED' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EnvConfig } from '../config/env.schema';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { seedDeployRuns } from './deploy-run.seed';
|
||||
import {
|
||||
CreateDeployRunInput,
|
||||
DeployRunStepStatus,
|
||||
DeployRunSummary,
|
||||
DeployRunStatus,
|
||||
} from './deploy-run.types';
|
||||
|
||||
type DeployRunMemoryPatch = {
|
||||
status?: DeployRunStatus;
|
||||
jenkinsQueueId?: string;
|
||||
jenkinsBuildNumber?: number;
|
||||
jenkinsBuildUrl?: string;
|
||||
failureSummary?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
};
|
||||
|
||||
type DeployRunStepMemoryPatch = {
|
||||
status?: DeployRunStepStatus;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
message?: string;
|
||||
errorSummary?: string;
|
||||
logExcerpt?: string;
|
||||
};
|
||||
|
||||
const terminalRunStatuses = new Set<DeployRunStatus>([
|
||||
'success',
|
||||
'failed',
|
||||
'canceled',
|
||||
]);
|
||||
const terminalStepStatuses = new Set<DeployRunStepStatus>([
|
||||
'success',
|
||||
'failed',
|
||||
'canceled',
|
||||
'skipped',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class DeployRunRepository {
|
||||
private readonly memoryRuns = [...seedDeployRuns];
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService<EnvConfig, true>,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async findMany(): Promise<DeployRunSummary[]> {
|
||||
if (!this.useDatabaseReads()) {
|
||||
return [...this.memoryRuns].sort((left, right) =>
|
||||
right.createdAt.localeCompare(left.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
const runs = await this.prisma.deployRun.findMany({
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return runs.map((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> {
|
||||
if (!this.useDatabaseReads()) {
|
||||
return this.memoryRuns.find((run) => run.id === id) ?? null;
|
||||
}
|
||||
|
||||
const run = await this.prisma.deployRun.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
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 {
|
||||
const idempotencyKey =
|
||||
input.idempotencyKey ??
|
||||
[
|
||||
input.projectKey,
|
||||
input.environment,
|
||||
input.ref,
|
||||
input.operator,
|
||||
new Date().toISOString(),
|
||||
].join(':');
|
||||
const existing = this.memoryRuns.find((run) => run.id === idempotencyKey);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const run: DeployRunSummary = {
|
||||
id: idempotencyKey,
|
||||
projectKey: input.projectKey,
|
||||
environment: input.environment,
|
||||
ref: input.ref,
|
||||
status: 'pending',
|
||||
trigger: input.trigger ?? 'manual',
|
||||
operator: input.operator,
|
||||
createdAt: now,
|
||||
steps: [
|
||||
{
|
||||
id: `${idempotencyKey}:start-release`,
|
||||
bpmnNodeId: 'start-release',
|
||||
name: '提交发布单',
|
||||
status: 'success',
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: `${idempotencyKey}:gitea-check`,
|
||||
bpmnNodeId: 'gitea-check',
|
||||
name: '校验 Gitea ref',
|
||||
status: 'pending',
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: `${idempotencyKey}:jenkins-build`,
|
||||
bpmnNodeId: 'jenkins-build',
|
||||
name: '触发 Jenkins 构建',
|
||||
status: 'pending',
|
||||
order: 30,
|
||||
},
|
||||
{
|
||||
id: `${idempotencyKey}:health-check`,
|
||||
bpmnNodeId: 'health-check',
|
||||
name: '环境健康检查',
|
||||
status: 'pending',
|
||||
order: 40,
|
||||
},
|
||||
{
|
||||
id: `${idempotencyKey}:notify-wecom`,
|
||||
bpmnNodeId: 'notify-wecom',
|
||||
name: '发送通知',
|
||||
status: 'pending',
|
||||
order: 90,
|
||||
},
|
||||
{
|
||||
id: `${idempotencyKey}:finish-release`,
|
||||
bpmnNodeId: 'finish-release',
|
||||
name: '归档审计',
|
||||
status: 'pending',
|
||||
order: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.memoryRuns.unshift(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
updateMemoryStatus(
|
||||
id: string,
|
||||
status: DeployRunStatus,
|
||||
): DeployRunSummary | null {
|
||||
return this.updateMemoryRun(id, { status });
|
||||
}
|
||||
|
||||
updateMemoryRun(
|
||||
id: string,
|
||||
patch: DeployRunMemoryPatch,
|
||||
): DeployRunSummary | null {
|
||||
const run = this.memoryRuns.find((item) => item.id === id);
|
||||
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (patch.status) {
|
||||
run.status = patch.status;
|
||||
|
||||
if (patch.status !== 'pending' && !run.startedAt) {
|
||||
run.startedAt = patch.startedAt ?? now;
|
||||
}
|
||||
|
||||
if (terminalRunStatuses.has(patch.status)) {
|
||||
run.finishedAt = patch.finishedAt ?? now;
|
||||
}
|
||||
}
|
||||
|
||||
if ('jenkinsQueueId' in patch) {
|
||||
run.jenkinsQueueId = patch.jenkinsQueueId;
|
||||
}
|
||||
|
||||
if ('jenkinsBuildNumber' in patch) {
|
||||
run.jenkinsBuildNumber = patch.jenkinsBuildNumber;
|
||||
}
|
||||
|
||||
if ('jenkinsBuildUrl' in patch) {
|
||||
run.jenkinsBuildUrl = patch.jenkinsBuildUrl;
|
||||
}
|
||||
|
||||
if ('failureSummary' in patch) {
|
||||
run.failureSummary = patch.failureSummary;
|
||||
}
|
||||
|
||||
if ('startedAt' in patch) {
|
||||
run.startedAt = patch.startedAt;
|
||||
}
|
||||
|
||||
if ('finishedAt' in patch) {
|
||||
run.finishedAt = patch.finishedAt;
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
updateMemoryStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
patch: DeployRunStepMemoryPatch,
|
||||
): DeployRunSummary | null {
|
||||
const run = this.memoryRuns.find((item) => item.id === runId);
|
||||
const step = run?.steps.find((item) => item.bpmnNodeId === bpmnNodeId);
|
||||
|
||||
if (!run || !step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (patch.status) {
|
||||
step.status = patch.status;
|
||||
|
||||
if (patch.status !== 'pending' && !step.startedAt) {
|
||||
step.startedAt = patch.startedAt ?? now;
|
||||
}
|
||||
|
||||
if (terminalStepStatuses.has(patch.status)) {
|
||||
step.finishedAt = patch.finishedAt ?? now;
|
||||
}
|
||||
}
|
||||
|
||||
if ('startedAt' in patch) {
|
||||
step.startedAt = patch.startedAt;
|
||||
}
|
||||
|
||||
if ('finishedAt' in patch) {
|
||||
step.finishedAt = patch.finishedAt;
|
||||
}
|
||||
|
||||
if ('message' in patch) {
|
||||
step.message = patch.message;
|
||||
}
|
||||
|
||||
if ('errorSummary' in patch) {
|
||||
step.errorSummary = patch.errorSummary;
|
||||
}
|
||||
|
||||
if ('logExcerpt' in patch) {
|
||||
step.logExcerpt = patch.logExcerpt;
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
private useDatabaseReads(): boolean {
|
||||
return (
|
||||
this.config.get('USE_DATABASE_READS', { infer: true }) &&
|
||||
Boolean(this.config.get('DATABASE_URL', { infer: true }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DeployRunSummary } from './deploy-run.types';
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
export const seedDeployRuns: DeployRunSummary[] = [
|
||||
{
|
||||
id: 'run_seed_access_manage_test_001',
|
||||
projectKey: 'access-manage',
|
||||
environment: 'test',
|
||||
ref: 'develop',
|
||||
status: 'pending',
|
||||
trigger: 'manual',
|
||||
operator: 'system-seed',
|
||||
createdAt: now,
|
||||
steps: [
|
||||
{
|
||||
id: 'step_seed_access_manage_start',
|
||||
bpmnNodeId: 'start-release',
|
||||
name: '提交发布单',
|
||||
status: 'success',
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: 'step_seed_access_manage_gitea',
|
||||
bpmnNodeId: 'gitea-check',
|
||||
name: '校验 Gitea ref',
|
||||
status: 'pending',
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: 'step_seed_access_manage_jenkins',
|
||||
bpmnNodeId: 'jenkins-build',
|
||||
name: '触发 Jenkins 构建',
|
||||
status: 'pending',
|
||||
order: 30,
|
||||
},
|
||||
{
|
||||
id: 'step_seed_access_manage_health',
|
||||
bpmnNodeId: 'health-check',
|
||||
name: '环境健康检查',
|
||||
status: 'pending',
|
||||
order: 40,
|
||||
},
|
||||
{
|
||||
id: 'step_seed_access_manage_notify',
|
||||
bpmnNodeId: 'notify-wecom',
|
||||
name: '发送通知',
|
||||
status: 'pending',
|
||||
order: 90,
|
||||
},
|
||||
{
|
||||
id: 'step_seed_access_manage_archive',
|
||||
bpmnNodeId: 'finish-release',
|
||||
name: '归档审计',
|
||||
status: 'pending',
|
||||
order: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,63 @@
|
||||
export type DeployEnvironment = 'test' | 'production';
|
||||
export type DeployRunStatus =
|
||||
| 'pending'
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'canceled';
|
||||
export type DeployRunStepStatus = DeployRunStatus | 'skipped';
|
||||
|
||||
export type DeployRunStepSummary = {
|
||||
id: string;
|
||||
bpmnNodeId: string;
|
||||
name: string;
|
||||
status: DeployRunStepStatus;
|
||||
order: number;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
message?: string;
|
||||
errorSummary?: string;
|
||||
logExcerpt?: string;
|
||||
};
|
||||
|
||||
export type DeployRunSummary = {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
environment: DeployEnvironment;
|
||||
ref: string;
|
||||
status: DeployRunStatus;
|
||||
trigger: 'manual' | 'gitea_webhook' | 'retry';
|
||||
operator: string;
|
||||
jenkinsQueueId?: string;
|
||||
jenkinsBuildNumber?: number;
|
||||
jenkinsBuildUrl?: string;
|
||||
failureSummary?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
createdAt: string;
|
||||
steps: DeployRunStepSummary[];
|
||||
};
|
||||
|
||||
export type CreateDeployRunInput = {
|
||||
projectKey: string;
|
||||
environment: DeployEnvironment;
|
||||
ref: string;
|
||||
operator: string;
|
||||
remark?: string;
|
||||
idempotencyKey?: string;
|
||||
trigger?: DeployRunSummary['trigger'];
|
||||
};
|
||||
|
||||
export type DeployRunJenkinsSyncError = {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type DeployRunJenkinsSyncSummary = {
|
||||
checkedAt: string;
|
||||
totalCandidates: number;
|
||||
syncedRuns: DeployRunSummary[];
|
||||
failedRuns: DeployRunJenkinsSyncError[];
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
CreateDeployRunDto,
|
||||
createDeployRunSchema,
|
||||
} from './create-deploy-run.dto';
|
||||
import {
|
||||
CreateDeployRunInput,
|
||||
DeployRunJenkinsSyncSummary,
|
||||
DeployRunSummary,
|
||||
} from './deploy-run.types';
|
||||
import { DeployRunsService } from './deploy-runs.service';
|
||||
|
||||
@ApiTags('deploy-runs')
|
||||
@Controller('deploy-runs')
|
||||
export class DeployRunsController {
|
||||
constructor(private readonly deployRunsService: DeployRunsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ description: 'Latest deploy runs.' })
|
||||
async listRuns(): Promise<DeployRunSummary[]> {
|
||||
return this.deployRunsService.listRuns();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOkResponse({ description: 'Deploy run detail.' })
|
||||
async getRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||
return this.deployRunsService.getRun(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiBody({ type: CreateDeployRunDto })
|
||||
@ApiCreatedResponse({ description: 'Create a deploy run draft.' })
|
||||
async createRun(
|
||||
@Body(new ZodValidationPipe(createDeployRunSchema)) body: CreateDeployRunInput,
|
||||
): Promise<DeployRunSummary> {
|
||||
return this.deployRunsService.createRun(body);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@ApiOkResponse({ description: 'Cancel a deploy run.' })
|
||||
async cancelRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||
return this.deployRunsService.cancelRun(id);
|
||||
}
|
||||
|
||||
@Post(':id/retry')
|
||||
@ApiCreatedResponse({ description: 'Retry a deploy run.' })
|
||||
async retryRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||
return this.deployRunsService.retryRun(id);
|
||||
}
|
||||
|
||||
@Post('sync-jenkins')
|
||||
@ApiOkResponse({ description: 'Synchronize all queued/running Jenkins runs.' })
|
||||
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
|
||||
return this.deployRunsService.syncJenkinsRuns();
|
||||
}
|
||||
|
||||
@Post(':id/sync-jenkins')
|
||||
@ApiOkResponse({ description: 'Synchronize Jenkins queue/build state.' })
|
||||
async syncJenkinsRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||
return this.deployRunsService.syncJenkinsRun(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
|
||||
import { WeComModule } from '../notifications/wecom/wecom.module';
|
||||
import { ProjectsModule } from '../projects/projects.module';
|
||||
import { DeployExecutionService } from './deploy-execution.service';
|
||||
import { DeployRunRepository } from './deploy-run.repository';
|
||||
import { DeployRunsController } from './deploy-runs.controller';
|
||||
import { DeployRunsService } from './deploy-runs.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
|
||||
controllers: [DeployRunsController],
|
||||
providers: [DeployExecutionService, DeployRunRepository, DeployRunsService],
|
||||
exports: [DeployExecutionService, DeployRunRepository, DeployRunsService],
|
||||
})
|
||||
export class DeployRunsModule {}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AppError } from '../common/errors/app-error';
|
||||
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
||||
import { ProjectsService } from '../projects/projects.service';
|
||||
import { DeployExecutionService } from './deploy-execution.service';
|
||||
import { DeployRunRepository } from './deploy-run.repository';
|
||||
import {
|
||||
CreateDeployRunInput,
|
||||
DeployRunStatus,
|
||||
DeployRunSummary,
|
||||
} from './deploy-run.types';
|
||||
import { DeployRunsService } from './deploy-runs.service';
|
||||
|
||||
/**
|
||||
* 覆盖发布单第一阶段内存实现,后续接真实数据库时保持服务契约不变。
|
||||
*/
|
||||
describe('DeployRunsService', () => {
|
||||
const baseRun: DeployRunSummary = {
|
||||
id: 'run_001',
|
||||
projectKey: 'access-manage',
|
||||
environment: 'test',
|
||||
ref: 'develop',
|
||||
status: 'pending',
|
||||
trigger: 'manual',
|
||||
operator: 'ops-admin',
|
||||
createdAt: '2026-06-11T00:00:00.000Z',
|
||||
steps: [],
|
||||
};
|
||||
|
||||
function createService(options?: { runs?: DeployRunSummary[] }) {
|
||||
const runs = options?.runs ?? [baseRun];
|
||||
const repository = {
|
||||
findMany: jest.fn(() => Promise.resolve(runs)),
|
||||
findById: jest.fn((id: string) =>
|
||||
Promise.resolve(runs.find((run) => run.id === id) ?? null),
|
||||
),
|
||||
create: jest.fn((input: CreateDeployRunInput) => ({
|
||||
...baseRun,
|
||||
id: input.idempotencyKey ?? 'generated_run',
|
||||
projectKey: input.projectKey,
|
||||
environment: input.environment,
|
||||
ref: input.ref,
|
||||
operator: input.operator,
|
||||
trigger: input.trigger ?? 'manual',
|
||||
})),
|
||||
updateMemoryStatus: jest.fn((id: string, status: DeployRunStatus) =>
|
||||
id === baseRun.id ? { ...baseRun, status } : null,
|
||||
),
|
||||
} satisfies Pick<
|
||||
DeployRunRepository,
|
||||
'findMany' | 'findById' | 'create' | 'updateMemoryStatus'
|
||||
>;
|
||||
const projects = {
|
||||
getProject: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'project_001',
|
||||
key: 'access-manage',
|
||||
name: 'access-manage',
|
||||
repositoryUrl:
|
||||
'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||
defaultBranch: 'develop',
|
||||
status: 'active' as const,
|
||||
environments: [
|
||||
{
|
||||
name: 'test' as const,
|
||||
displayName: '测试环境',
|
||||
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
} satisfies Pick<ProjectsService, 'getProject'>;
|
||||
const audit = {
|
||||
record: jest.fn(),
|
||||
} satisfies Pick<AuditService, 'record'>;
|
||||
const notification = {
|
||||
sendDeployRunEvent: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
status: 'skipped' as const,
|
||||
channel: 'wecom' as const,
|
||||
reason: 'Missing WECOM_WEBHOOK_URL',
|
||||
}),
|
||||
),
|
||||
} satisfies Pick<DeployNotificationService, 'sendDeployRunEvent'>;
|
||||
const execution = {
|
||||
execute: jest.fn((run: DeployRunSummary) =>
|
||||
Promise.resolve({ ...run, status: 'success' as const }),
|
||||
),
|
||||
syncJenkinsRun: jest.fn((run: DeployRunSummary) =>
|
||||
Promise.resolve({ ...run, status: 'success' as const }),
|
||||
),
|
||||
} satisfies Pick<DeployExecutionService, 'execute' | 'syncJenkinsRun'>;
|
||||
|
||||
return {
|
||||
audit,
|
||||
execution,
|
||||
notification,
|
||||
projects,
|
||||
repository,
|
||||
service: new DeployRunsService(
|
||||
repository as unknown as DeployRunRepository,
|
||||
projects as unknown as ProjectsService,
|
||||
audit as unknown as AuditService,
|
||||
notification as unknown as DeployNotificationService,
|
||||
execution as unknown as DeployExecutionService,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
it('creates a manual deploy run and records audit data', async () => {
|
||||
const { audit, execution, repository, service } = createService();
|
||||
|
||||
const run = await service.createRun({
|
||||
projectKey: 'access-manage',
|
||||
environment: 'test',
|
||||
ref: 'develop',
|
||||
operator: 'ops-admin',
|
||||
idempotencyKey: 'idem_001',
|
||||
});
|
||||
|
||||
expect(run.id).toBe('idem_001');
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectKey: 'access-manage' }),
|
||||
);
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'DEPLOY_REQUESTED',
|
||||
resourceId: 'idem_001',
|
||||
}),
|
||||
);
|
||||
expect(execution.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'idem_001' }),
|
||||
expect.objectContaining({ key: 'access-manage' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsupported environments before creating a run', async () => {
|
||||
const { execution, repository, service } = createService();
|
||||
|
||||
await expect(
|
||||
service.createRun({
|
||||
projectKey: 'access-manage',
|
||||
environment: 'staging',
|
||||
ref: 'develop',
|
||||
operator: 'ops-admin',
|
||||
} as unknown as CreateDeployRunInput),
|
||||
).rejects.toBeInstanceOf(AppError);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
expect(execution.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels an existing deploy run', async () => {
|
||||
const { audit, service } = createService();
|
||||
|
||||
const run = await service.cancelRun(baseRun.id);
|
||||
|
||||
expect(run.status).toBe('canceled');
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'DEPLOY_CANCELED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a deploy run with retry trigger', async () => {
|
||||
const { repository, service } = createService();
|
||||
|
||||
const run = await service.retryRun(baseRun.id);
|
||||
|
||||
expect(run.trigger).toBe('retry');
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectKey: baseRun.projectKey,
|
||||
environment: baseRun.environment,
|
||||
trigger: 'retry',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs all queued or running Jenkins runs with queue/build identifiers', async () => {
|
||||
const queuedRun: DeployRunSummary = {
|
||||
...baseRun,
|
||||
id: 'run_queued',
|
||||
status: 'queued',
|
||||
jenkinsQueueId: 'queue_001',
|
||||
};
|
||||
const runningRun: DeployRunSummary = {
|
||||
...baseRun,
|
||||
id: 'run_running',
|
||||
status: 'running',
|
||||
jenkinsBuildNumber: 7,
|
||||
};
|
||||
const ignoredRun: DeployRunSummary = {
|
||||
...baseRun,
|
||||
id: 'run_running_without_jenkins',
|
||||
status: 'running',
|
||||
};
|
||||
const { audit, execution, service } = createService({
|
||||
runs: [queuedRun, runningRun, ignoredRun],
|
||||
});
|
||||
|
||||
const result = await service.syncJenkinsRuns();
|
||||
|
||||
expect(result.totalCandidates).toBe(2);
|
||||
expect(result.syncedRuns).toHaveLength(2);
|
||||
expect(result.failedRuns).toHaveLength(0);
|
||||
expect(execution.syncJenkinsRun).toHaveBeenCalledTimes(2);
|
||||
expect(execution.syncJenkinsRun).toHaveBeenCalledWith(
|
||||
queuedRun,
|
||||
expect.objectContaining({ key: queuedRun.projectKey }),
|
||||
);
|
||||
expect(execution.syncJenkinsRun).toHaveBeenCalledWith(
|
||||
runningRun,
|
||||
expect.objectContaining({ key: runningRun.projectKey }),
|
||||
);
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
|
||||
after: expect.objectContaining({
|
||||
totalCandidates: 2,
|
||||
syncedCount: 2,
|
||||
failedCount: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a redacted error when one Jenkins run fails during bulk sync', async () => {
|
||||
const queuedRun: DeployRunSummary = {
|
||||
...baseRun,
|
||||
id: 'run_queued',
|
||||
status: 'queued',
|
||||
jenkinsQueueId: 'queue_001',
|
||||
};
|
||||
const { execution, service } = createService({ runs: [queuedRun] });
|
||||
execution.syncJenkinsRun.mockRejectedValueOnce(
|
||||
new Error('abcdefghijklmnopqrstuvwxyz123456'),
|
||||
);
|
||||
|
||||
const result = await service.syncJenkinsRuns();
|
||||
|
||||
expect(result.totalCandidates).toBe(1);
|
||||
expect(result.syncedRuns).toHaveLength(0);
|
||||
expect(result.failedRuns).toEqual([
|
||||
{
|
||||
id: 'run_queued',
|
||||
projectKey: 'access-manage',
|
||||
message: '[REDACTED]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
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 { 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);
|
||||
const run = 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 = this.deployRunRepository.updateMemoryStatus(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: `WECOM_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 toSafeSyncError(error: unknown): string {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Jenkins sync failed';
|
||||
|
||||
return redactSensitive(message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user