diff --git a/src/deploy-runs/deploy-runs.service.spec.ts b/src/deploy-runs/deploy-runs.service.spec.ts index 5723ed2..c1df7e3 100644 --- a/src/deploy-runs/deploy-runs.service.spec.ts +++ b/src/deploy-runs/deploy-runs.service.spec.ts @@ -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; const audit = { record: jest.fn(), @@ -98,6 +110,26 @@ describe('DeployRunsService', () => { Promise.resolve({ ...run, status: 'success' as const }), ), } satisfies Pick; + 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(); diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index d04ac45..205ebd5 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -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 { @@ -81,25 +84,44 @@ export class DeployRunsService { } async cancelRun(id: string): Promise { - 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 { + 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; + } } diff --git a/src/integrations/jenkins/jenkins.client.ts b/src/integrations/jenkins/jenkins.client.ts index 469df7a..146ebd5 100644 --- a/src/integrations/jenkins/jenkins.client.ts +++ b/src/integrations/jenkins/jenkins.client.ts @@ -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 { + private async buildWithoutParameters( + jobPath: string, + ): Promise { 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 { + 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 { + 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 { 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); diff --git a/src/integrations/jenkins/jenkins.types.ts b/src/integrations/jenkins/jenkins.types.ts index e65fed0..e9bf793 100644 --- a/src/integrations/jenkins/jenkins.types.ts +++ b/src/integrations/jenkins/jenkins.types.ts @@ -17,3 +17,10 @@ export type JenkinsProgressiveText = { nextStart: number; hasMore: boolean; }; + +export type JenkinsCancelResult = { + target: 'queue' | 'build'; + queueId?: string; + buildNumber?: number; + requested: true; +};