feat: 支持取消 Jenkins 发布执行
This commit is contained in:
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user