feat: 支持取消 Jenkins 发布执行

This commit is contained in:
湛兮
2026-06-11 23:22:17 +08:00
parent 588caa4209
commit 6ce1543a0a
4 changed files with 269 additions and 35 deletions
+87 -8
View File
@@ -1,6 +1,7 @@
import { AuditService } from '../audit/audit.service';
import { AppError } from '../common/errors/app-error';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { ProjectSummary } from '../projects/project.types';
import { ProjectsService } from '../projects/projects.service';
import { DeployExecutionService } from './deploy-execution.service';
@@ -66,17 +67,28 @@ describe('DeployRunsService', () => {
operator: input.operator,
trigger: input.trigger ?? 'manual',
})),
updateMemoryStatus: jest.fn((id: string, status: DeployRunStatus) =>
id === baseRun.id ? { ...baseRun, status } : null,
updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) =>
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
...patch,
}
: null,
),
updateStep: jest.fn((id: string) =>
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
status: 'canceled' as const,
}
: null,
),
} satisfies Pick<
DeployRunRepository,
'findMany' | 'findById' | 'create' | 'updateMemoryStatus'
'findMany' | 'findById' | 'create' | 'updateRun' | 'updateStep'
>;
const projects = {
getProject: jest.fn(() =>
Promise.resolve(resolvedProject),
),
getProject: jest.fn(() => Promise.resolve(resolvedProject)),
} satisfies Pick<ProjectsService, 'getProject'>;
const audit = {
record: jest.fn(),
@@ -98,6 +110,26 @@ describe('DeployRunsService', () => {
Promise.resolve({ ...run, status: 'success' as const }),
),
} satisfies Pick<DeployExecutionService, 'execute' | 'syncJenkinsRun'>;
const jenkins = {
healthSummary: jest.fn(() => ({ status: 'ok' as const })),
cancelQueueItem: jest.fn(() =>
Promise.resolve({
target: 'queue' as const,
queueId: 'queue_001',
requested: true as const,
}),
),
stopBuild: jest.fn(() =>
Promise.resolve({
target: 'build' as const,
buildNumber: 7,
requested: true as const,
}),
),
} satisfies Pick<
JenkinsClient,
'healthSummary' | 'cancelQueueItem' | 'stopBuild'
>;
return {
audit,
@@ -111,6 +143,7 @@ describe('DeployRunsService', () => {
audit as unknown as AuditService,
notification as unknown as DeployNotificationService,
execution as unknown as DeployExecutionService,
jenkins as unknown as JenkinsClient,
),
};
}
@@ -262,17 +295,63 @@ describe('DeployRunsService', () => {
expect(execution.execute).toHaveBeenCalled();
});
it('cancels an existing deploy run', async () => {
const { audit, service } = createService();
it('cancels a queued Jenkins execution before marking the run canceled', async () => {
const queuedRun: DeployRunSummary = {
...baseRun,
status: 'queued',
jenkinsQueueId: 'queue_001',
};
const { audit, jenkins, service } = createService({
runs: [queuedRun],
});
const run = await service.cancelRun(baseRun.id);
expect(run.status).toBe('canceled');
expect(jenkins.cancelQueueItem).toHaveBeenCalledWith('queue_001');
expect(jenkins.stopBuild).not.toHaveBeenCalled();
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_CANCELED' }),
);
});
it('cancels a running Jenkins build before marking the run canceled', async () => {
const runningRun: DeployRunSummary = {
...baseRun,
status: 'running',
jenkinsBuildNumber: 7,
};
const { jenkins, service } = createService({
runs: [runningRun],
});
const run = await service.cancelRun(baseRun.id);
expect(run.status).toBe('canceled');
expect(jenkins.stopBuild).toHaveBeenCalledWith(
'my-project-dev/test-access-manage-develop',
7,
);
expect(jenkins.cancelQueueItem).not.toHaveBeenCalled();
});
it('rejects already terminal deploy runs before calling Jenkins', async () => {
const canceledRun: DeployRunSummary = {
...baseRun,
status: 'canceled',
jenkinsBuildNumber: 7,
};
const { jenkins, service } = createService({
runs: [canceledRun],
});
await expect(service.cancelRun(baseRun.id)).rejects.toBeInstanceOf(
AppError,
);
expect(jenkins.stopBuild).not.toHaveBeenCalled();
expect(jenkins.cancelQueueItem).not.toHaveBeenCalled();
});
it('retries a deploy run with retry trigger', async () => {
const { repository, service } = createService();
+110 -5
View File
@@ -6,6 +6,8 @@ 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,
@@ -27,6 +29,7 @@ export class DeployRunsService {
private readonly auditService: AuditService,
private readonly deployNotificationService: DeployNotificationService,
private readonly deployExecutionService: DeployExecutionService,
private readonly jenkinsClient: JenkinsClient,
) {}
async listRuns(): Promise<DeployRunSummary[]> {
@@ -81,25 +84,44 @@ export class DeployRunsService {
}
async cancelRun(id: string): Promise<DeployRunSummary> {
const run = await this.deployRunRepository.updateStatus(id, 'canceled');
const source = await this.getRun(id);
if (!run) {
if (['success', 'failed', 'canceled'].includes(source.status)) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`Deploy run ${id} not found`,
404,
'VALIDATION_FAILED',
`Deploy run ${id} is already ${source.status} and cannot be canceled`,
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,
after: {
status: run.status,
jenkinsCancel,
},
});
await this.recordNotificationResult(run, 'canceled');
@@ -194,6 +216,71 @@ export class DeployRunsService {
);
}
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 is not configured',
};
}
if (run.jenkinsBuildNumber) {
return this.jenkinsClient.stopBuild(
environment.jenkinsJobPath,
run.jenkinsBuildNumber,
);
}
if (run.jenkinsQueueId) {
return this.jenkinsClient.cancelQueueItem(run.jenkinsQueueId);
}
return {
requested: false,
reason: 'Deploy run has no Jenkins queue id or build number',
};
}
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 cancelSummary(
result: JenkinsCancelResult | { requested: false; reason: string },
): string {
if (!result.requested) {
return `Cancellation recorded locally: ${result.reason}`;
}
if (result.target === 'build') {
return `Jenkins build #${result.buildNumber ?? '-'} stop requested.`;
}
return `Jenkins queue ${result.queueId ?? '-'} cancel requested.`;
}
private async validateReleaseRequest(
input: CreateDeployRunInput,
project: ProjectSummary,
@@ -267,4 +354,22 @@ export class DeployRunsService {
return redactSensitive({ message }).message;
}
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;
}
}
+65 -22
View File
@@ -3,11 +3,15 @@
*/
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IntegrationRequestError, IntegrationUnavailableError } from '../../common/errors/integration-error';
import {
IntegrationRequestError,
IntegrationUnavailableError,
} from '../../common/errors/integration-error';
import { redactSensitive } from '../../common/security/redact-sensitive';
import { EnvConfig } from '../../config/env.schema';
import {
JenkinsBuildStatus,
JenkinsCancelResult,
JenkinsProgressiveText,
JenkinsQueueItem,
} from './jenkins.types';
@@ -30,20 +34,21 @@ export class JenkinsClient {
this.assertConfigured();
const path = this.jobPath(jobPath, 'buildWithParameters');
const body = new URLSearchParams(parameters);
const response = await this.request(path, {
method: 'POST',
body,
}, {
allowFailure: true,
});
const response = await this.request(
path,
{
method: 'POST',
body,
},
{
allowFailure: true,
},
);
if (!response.ok) {
const responseText = await response.text();
if (
response.status === 400 &&
/not parameterized/i.test(responseText)
) {
if (response.status === 400 && /not parameterized/i.test(responseText)) {
return this.buildWithoutParameters(jobPath);
}
@@ -59,14 +64,13 @@ export class JenkinsClient {
return this.toQueueItem(response);
}
private async buildWithoutParameters(jobPath: string): Promise<JenkinsQueueItem> {
private async buildWithoutParameters(
jobPath: string,
): Promise<JenkinsQueueItem> {
const path = this.jobPath(jobPath, 'build');
const response = await this.request(
path,
{
method: 'POST',
},
);
const response = await this.request(path, {
method: 'POST',
});
return this.toQueueItem(response);
}
@@ -134,6 +138,35 @@ export class JenkinsClient {
};
}
async cancelQueueItem(queueId: string): Promise<JenkinsCancelResult> {
this.assertConfigured();
await this.request(`/queue/cancelItem?id=${encodeURIComponent(queueId)}`, {
method: 'POST',
});
return {
target: 'queue',
queueId,
requested: true,
};
}
async stopBuild(
jobPath: string,
buildNumber: number,
): Promise<JenkinsCancelResult> {
this.assertConfigured();
await this.request(`${this.jobPath(jobPath)}/${buildNumber}/stop`, {
method: 'POST',
});
return {
target: 'build',
buildNumber,
requested: true,
};
}
private async request(
path: string,
init: RequestInit = {},
@@ -141,7 +174,8 @@ export class JenkinsClient {
): Promise<Response> {
const baseUrl = this.config.get('JENKINS_BASE_URL', { infer: true }) ?? '';
const username = this.config.get('JENKINS_USERNAME', { infer: true }) ?? '';
const apiToken = this.config.get('JENKINS_API_TOKEN', { infer: true }) ?? '';
const apiToken =
this.config.get('JENKINS_API_TOKEN', { infer: true }) ?? '';
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
@@ -186,9 +220,18 @@ export class JenkinsClient {
private missingConfig(): string[] {
const entries: Array<[string, string | undefined]> = [
['JENKINS_BASE_URL', this.config.get('JENKINS_BASE_URL', { infer: true })],
['JENKINS_USERNAME', this.config.get('JENKINS_USERNAME', { infer: true })],
['JENKINS_API_TOKEN', this.config.get('JENKINS_API_TOKEN', { infer: true })],
[
'JENKINS_BASE_URL',
this.config.get('JENKINS_BASE_URL', { infer: true }),
],
[
'JENKINS_USERNAME',
this.config.get('JENKINS_USERNAME', { infer: true }),
],
[
'JENKINS_API_TOKEN',
this.config.get('JENKINS_API_TOKEN', { infer: true }),
],
];
return entries.filter(([, value]) => !value).map(([key]) => key);
@@ -17,3 +17,10 @@ export type JenkinsProgressiveText = {
nextStart: number;
hasMore: boolean;
};
export type JenkinsCancelResult = {
target: 'queue' | 'build';
queueId?: string;
buildNumber?: number;
requested: true;
};