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 { AuditService } from '../audit/audit.service';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { ProjectSummary } from '../projects/project.types'; import { ProjectSummary } from '../projects/project.types';
import { ProjectsService } from '../projects/projects.service'; import { ProjectsService } from '../projects/projects.service';
import { DeployExecutionService } from './deploy-execution.service'; import { DeployExecutionService } from './deploy-execution.service';
@@ -66,17 +67,28 @@ describe('DeployRunsService', () => {
operator: input.operator, operator: input.operator,
trigger: input.trigger ?? 'manual', trigger: input.trigger ?? 'manual',
})), })),
updateMemoryStatus: jest.fn((id: string, status: DeployRunStatus) => updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) =>
id === baseRun.id ? { ...baseRun, status } : null, 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< } satisfies Pick<
DeployRunRepository, DeployRunRepository,
'findMany' | 'findById' | 'create' | 'updateMemoryStatus' 'findMany' | 'findById' | 'create' | 'updateRun' | 'updateStep'
>; >;
const projects = { const projects = {
getProject: jest.fn(() => getProject: jest.fn(() => Promise.resolve(resolvedProject)),
Promise.resolve(resolvedProject),
),
} satisfies Pick<ProjectsService, 'getProject'>; } satisfies Pick<ProjectsService, 'getProject'>;
const audit = { const audit = {
record: jest.fn(), record: jest.fn(),
@@ -98,6 +110,26 @@ describe('DeployRunsService', () => {
Promise.resolve({ ...run, status: 'success' as const }), Promise.resolve({ ...run, status: 'success' as const }),
), ),
} satisfies Pick<DeployExecutionService, 'execute' | 'syncJenkinsRun'>; } 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 { return {
audit, audit,
@@ -111,6 +143,7 @@ describe('DeployRunsService', () => {
audit as unknown as AuditService, audit as unknown as AuditService,
notification as unknown as DeployNotificationService, notification as unknown as DeployNotificationService,
execution as unknown as DeployExecutionService, execution as unknown as DeployExecutionService,
jenkins as unknown as JenkinsClient,
), ),
}; };
} }
@@ -262,17 +295,63 @@ describe('DeployRunsService', () => {
expect(execution.execute).toHaveBeenCalled(); expect(execution.execute).toHaveBeenCalled();
}); });
it('cancels an existing deploy run', async () => { it('cancels a queued Jenkins execution before marking the run canceled', async () => {
const { audit, service } = createService(); const queuedRun: DeployRunSummary = {
...baseRun,
status: 'queued',
jenkinsQueueId: 'queue_001',
};
const { audit, jenkins, service } = createService({
runs: [queuedRun],
});
const run = await service.cancelRun(baseRun.id); const run = await service.cancelRun(baseRun.id);
expect(run.status).toBe('canceled'); expect(run.status).toBe('canceled');
expect(jenkins.cancelQueueItem).toHaveBeenCalledWith('queue_001');
expect(jenkins.stopBuild).not.toHaveBeenCalled();
expect(audit.record).toHaveBeenCalledWith( expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_CANCELED' }), 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 () => { it('retries a deploy run with retry trigger', async () => {
const { repository, service } = createService(); const { repository, service } = createService();
+110 -5
View File
@@ -6,6 +6,8 @@ import {
DeployNotificationEvent, DeployNotificationEvent,
DeployNotificationService, DeployNotificationService,
} from '../notifications/wecom/deploy-notification.service'; } from '../notifications/wecom/deploy-notification.service';
import { JenkinsCancelResult } from '../integrations/jenkins/jenkins.types';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { import {
ProjectEnvironmentSummary, ProjectEnvironmentSummary,
ProjectSummary, ProjectSummary,
@@ -27,6 +29,7 @@ export class DeployRunsService {
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly deployNotificationService: DeployNotificationService, private readonly deployNotificationService: DeployNotificationService,
private readonly deployExecutionService: DeployExecutionService, private readonly deployExecutionService: DeployExecutionService,
private readonly jenkinsClient: JenkinsClient,
) {} ) {}
async listRuns(): Promise<DeployRunSummary[]> { async listRuns(): Promise<DeployRunSummary[]> {
@@ -81,25 +84,44 @@ export class DeployRunsService {
} }
async cancelRun(id: string): Promise<DeployRunSummary> { 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( throw new AppError(
'RESOURCE_NOT_FOUND', 'VALIDATION_FAILED',
`Deploy run ${id} not found`, `Deploy run ${id} is already ${source.status} and cannot be canceled`,
404, 400,
{ {
id, 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({ await this.auditService.record({
action: 'DEPLOY_CANCELED', action: 'DEPLOY_CANCELED',
resourceType: 'deploy_run', resourceType: 'deploy_run',
resourceId: run.id, resourceId: run.id,
after: { after: {
status: run.status, status: run.status,
jenkinsCancel,
}, },
}); });
await this.recordNotificationResult(run, 'canceled'); 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( private async validateReleaseRequest(
input: CreateDeployRunInput, input: CreateDeployRunInput,
project: ProjectSummary, project: ProjectSummary,
@@ -267,4 +354,22 @@ export class DeployRunsService {
return redactSensitive({ message }).message; 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 { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 { redactSensitive } from '../../common/security/redact-sensitive';
import { EnvConfig } from '../../config/env.schema'; import { EnvConfig } from '../../config/env.schema';
import { import {
JenkinsBuildStatus, JenkinsBuildStatus,
JenkinsCancelResult,
JenkinsProgressiveText, JenkinsProgressiveText,
JenkinsQueueItem, JenkinsQueueItem,
} from './jenkins.types'; } from './jenkins.types';
@@ -30,20 +34,21 @@ export class JenkinsClient {
this.assertConfigured(); this.assertConfigured();
const path = this.jobPath(jobPath, 'buildWithParameters'); const path = this.jobPath(jobPath, 'buildWithParameters');
const body = new URLSearchParams(parameters); const body = new URLSearchParams(parameters);
const response = await this.request(path, { const response = await this.request(
method: 'POST', path,
body, {
}, { method: 'POST',
allowFailure: true, body,
}); },
{
allowFailure: true,
},
);
if (!response.ok) { if (!response.ok) {
const responseText = await response.text(); const responseText = await response.text();
if ( if (response.status === 400 && /not parameterized/i.test(responseText)) {
response.status === 400 &&
/not parameterized/i.test(responseText)
) {
return this.buildWithoutParameters(jobPath); return this.buildWithoutParameters(jobPath);
} }
@@ -59,14 +64,13 @@ export class JenkinsClient {
return this.toQueueItem(response); 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 path = this.jobPath(jobPath, 'build');
const response = await this.request( const response = await this.request(path, {
path, method: 'POST',
{ });
method: 'POST',
},
);
return this.toQueueItem(response); 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( private async request(
path: string, path: string,
init: RequestInit = {}, init: RequestInit = {},
@@ -141,7 +174,8 @@ export class JenkinsClient {
): Promise<Response> { ): Promise<Response> {
const baseUrl = this.config.get('JENKINS_BASE_URL', { infer: true }) ?? ''; const baseUrl = this.config.get('JENKINS_BASE_URL', { infer: true }) ?? '';
const username = this.config.get('JENKINS_USERNAME', { 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}`, { const response = await fetch(`${baseUrl}${path}`, {
...init, ...init,
headers: { headers: {
@@ -186,9 +220,18 @@ export class JenkinsClient {
private missingConfig(): string[] { private missingConfig(): string[] {
const entries: Array<[string, string | undefined]> = [ 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_BASE_URL',
['JENKINS_API_TOKEN', this.config.get('JENKINS_API_TOKEN', { infer: true })], 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); return entries.filter(([, value]) => !value).map(([key]) => key);
@@ -17,3 +17,10 @@ export type JenkinsProgressiveText = {
nextStart: number; nextStart: number;
hasMore: boolean; hasMore: boolean;
}; };
export type JenkinsCancelResult = {
target: 'queue' | 'build';
queueId?: string;
buildNumber?: number;
requested: true;
};