From 7054908e963ee126db0875d665aafe34e80ca7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 16:36:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=93=E9=80=9A=20Jenkins=20?= =?UTF-8?q?=E7=9B=B4=E6=9E=84=E5=AF=BC=E5=85=A5=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +- RTK.md | 3 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + prisma/seed.js | 6 + src/auth/auth.service.spec.ts | 18 +- src/auth/auth.service.ts | 8 + src/auth/reset-super-admin-password.ts | 16 +- .../deploy-execution.service.spec.ts | 7 + src/deploy-runs/deploy-execution.service.ts | 33 +- src/deploy-runs/deploy-run.repository.ts | 47 +++ src/deploy-runs/deploy-run.types.ts | 26 +- src/deploy-runs/deploy-runs.controller.ts | 11 + src/deploy-runs/deploy-runs.service.spec.ts | 251 +++++++++-- src/deploy-runs/deploy-runs.service.ts | 392 +++++++++++++++++- .../jenkins/jenkins.client.spec.ts | 57 +++ src/integrations/jenkins/jenkins.client.ts | 78 ++++ src/integrations/jenkins/jenkins.types.ts | 8 + 18 files changed, 920 insertions(+), 55 deletions(-) create mode 100644 prisma/migrations/20260612093000_add_jenkins_external_trigger/migration.sql diff --git a/README.md b/README.md index 826a7bd..b4184f3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ pnpm prisma:seed - 迁移历史从 `prisma/migrations/20260611000000_baseline_current_schema` 开始,空库可以直接执行 `pnpm prisma migrate deploy` 初始化当前 schema。 - 生产库在启用 Prisma 迁移历史前已经存在表结构;`deploy/deploy-devops-platform-api-from-jenkins.sh` 会检测完整表结构并自动将基线迁移标记为已应用,再执行 `pnpm prisma migrate deploy`。 +- `20260612093000_add_jenkins_external_trigger` 会扩展发布记录触发来源,用于保存 Jenkins 直接构建导入的 run。 - Jenkins 服务器使用 `/usr/local/bin/deploy-devops-platform-api-from-jenkins`,该脚本应与仓库 `deploy/deploy-devops-platform-api-from-jenkins.sh` 保持一致。 - 如需紧急跳过迁移,可在服务器环境中设置 `PRISMA_MIGRATE_DEPLOY_ENABLED=false`,但不能在存在 schema 变更的版本长期使用该开关。 - 新增表或字段时必须提交新的 `prisma/migrations/_/migration.sql`,不要再用手工 SQL 直接改生产库。 @@ -42,7 +43,8 @@ pnpm prisma:seed ## 内置账号 - 超级管理员账号:`zhanxi` -- 超级管理员初始密码:`zx123456@`,可通过服务器环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 覆盖。 +- 本地开发默认超级管理员初始密码:`zx123456@`。 +- 生产环境必须通过服务器环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 提供超级管理员初始密码;未配置时服务会拒绝使用公开默认密码初始化。 - 普通成员初始密码:`111111` 超级管理员初始化配置集中在 `src/auth/auth.constants.ts`、`prisma/seed.js` 和服务器环境变量。服务启动和种子数据只负责确保超级管理员账号存在并保持最高权限;如果超级管理员已修改密码,后续初始化不会覆盖密码。 @@ -53,7 +55,7 @@ pnpm prisma:seed pnpm reset:super-admin-password ``` -该命令面向已构建的服务器部署目录,会自动读取当前目录的 `.env.production`。默认把 `zhanxi` 的密码重置为 `zx123456@`。如需临时指定一次性重置密码,可在服务器环境中设置 `SUPER_ADMIN_RESET_PASSWORD` 后执行命令。脚本不会输出明文密码。源码开发调试时可先执行 `pnpm build`,或使用 `pnpm reset:super-admin-password:dev`。 +该命令面向已构建的服务器部署目录,会自动读取当前目录的 `.env.production`。生产环境必须临时设置 `SUPER_ADMIN_RESET_PASSWORD` 后执行命令,脚本不会输出明文密码。本地开发未设置该变量时仍可回退到开发默认密码,源码调试可先执行 `pnpm build`,或使用 `pnpm reset:super-admin-password:dev`。 ## 关键接口 @@ -80,6 +82,7 @@ pnpm reset:super-admin-password - `POST /deploy-runs/:id/retry`:重试发布单,需要构建权限。 - `POST /deploy-runs/:id/sync-jenkins`:同步单个发布单 Jenkins 状态,需要构建权限。 - `POST /deploy-runs/sync-jenkins`:同步全部 Jenkins 状态,仅超级管理员可用。 +- `POST /deploy-runs/import-jenkins`:扫描已纳管 Jenkins job 最近构建并导入平台外直接构建记录,仅超级管理员可用。 - `GET /audit-logs`:超级管理员查询脱敏审计日志。 - `GET /settings/integration-config`:超级管理员查询集成配置状态。 - `GET /health`:运行健康检查。 @@ -106,8 +109,8 @@ pnpm reset:super-admin-password - `AUTH_TOKEN_SECRET`:登录 token 签名密钥。 - `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。 -- `SUPER_ADMIN_INITIAL_PASSWORD`:覆盖超级管理员初始化密码;不设置时使用集中初始化配置中的 `zx123456@`。 -- `SUPER_ADMIN_RESET_PASSWORD`:仅服务器重置脚本使用的一次性超级管理员重置密码。 +- `SUPER_ADMIN_INITIAL_PASSWORD`:生产环境必填的超级管理员初始化密码;本地开发不设置时才使用默认值。 +- `SUPER_ADMIN_RESET_PASSWORD`:服务器重置脚本使用的一次性超级管理员重置密码;生产环境执行重置时必填。 - `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。 - `DATABASE_URL`:MySQL 连接地址。 - `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 diff --git a/RTK.md b/RTK.md index 712e9f4..76327ca 100644 --- a/RTK.md +++ b/RTK.md @@ -16,7 +16,7 @@ - `members`:成员管理和项目权限分配。 - `messages`:平台消息通知,当前用于普通成员忘记密码请求。 - `projects`:项目配置 API 和 Repository。 -- `deploy-runs`:发布记录 API、状态模型和 BPMN 步骤摘要。 +- `deploy-runs`:发布记录 API、Jenkins 直构导入、状态模型和 BPMN 步骤摘要。 - `integrations/jenkins`:Jenkins API 客户端边界。 - `integrations/gitea`:Gitea API 客户端边界。 - `integrations/redis`:Redis PING 健康检查和 BullMQ 连接参数边界,Jenkins 自动同步与通知 outbox 调度复用该模块。 @@ -57,3 +57,4 @@ pnpm build - Jenkins API 部署脚本的仓库版本在 `deploy/deploy-devops-platform-api-from-jenkins.sh`。 - 脚本会在切换 `/srv/www/devops-platform-api` 前运行 `pnpm prisma migrate deploy`,迁移失败时不会替换旧服务目录。 - 生产库早期通过手工 SQL 初始化,首次启用迁移时脚本会在确认 12 张核心表都存在后把基线迁移标记为 applied。 +- Jenkins 直接构建导入依赖 `DeployRunTrigger.JENKINS_EXTERNAL`,生产部署必须执行最新 Prisma migration。 diff --git a/prisma/migrations/20260612093000_add_jenkins_external_trigger/migration.sql b/prisma/migrations/20260612093000_add_jenkins_external_trigger/migration.sql new file mode 100644 index 0000000..e678078 --- /dev/null +++ b/prisma/migrations/20260612093000_add_jenkins_external_trigger/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `deploy_runs` + MODIFY `trigger` ENUM('MANUAL', 'GITEA_WEBHOOK', 'RETRY', 'JENKINS_EXTERNAL') NOT NULL DEFAULT 'MANUAL'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 549406e..f6a008d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,6 +30,7 @@ enum DeployRunTrigger { MANUAL GITEA_WEBHOOK RETRY + JENKINS_EXTERNAL } enum RunStepStatus { diff --git a/prisma/seed.js b/prisma/seed.js index d1f7cc2..6368ce1 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -92,6 +92,12 @@ function resolveSuperAdminInitialPassword() { return process.env.SUPER_ADMIN_INITIAL_PASSWORD; } + if (process.env.NODE_ENV === 'production') { + throw new Error( + '生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能使用公开默认密码初始化超级管理员', + ); + } + return SUPER_ADMIN_INITIAL_PASSWORD; } diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 0015a2f..6dd4a05 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -16,7 +16,10 @@ import { ConfigService } from '@nestjs/config'; describe('AuthService', () => { const passwordService = new PasswordService(); - async function createService(options?: { superAdminPassword?: string }) { + async function createService(options?: { + superAdminPassword?: string; + nodeEnv?: EnvConfig['NODE_ENV']; + }) { const adminPassword = options?.superAdminPassword ?? SUPER_ADMIN_INITIAL_PASSWORD; const adminHash = await passwordService.hashPassword(adminPassword); @@ -113,7 +116,7 @@ describe('AuthService', () => { } if (key === 'NODE_ENV') { - return 'test'; + return options?.nodeEnv ?? 'test'; } return undefined; @@ -163,6 +166,17 @@ describe('AuthService', () => { expect(result.user.role).toBe('super_admin'); }); + it('生产环境拒绝使用公开默认密码初始化超级管理员', async () => { + const { repository, service } = await createService({ + nodeEnv: 'production', + }); + + await expect(service.onModuleInit()).rejects.toMatchObject({ + code: 'CONFIG_MISSING', + }); + expect(repository.ensureSuperAdmin).not.toHaveBeenCalled(); + }); + it('拒绝超级管理员使用忘记密码流程', async () => { const { service } = await createService(); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index cdd7bc0..0fff7cd 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -285,6 +285,14 @@ export class AuthService implements OnModuleInit { return password; } + if (this.config.get('NODE_ENV', { infer: true }) === 'production') { + throw new AppError( + 'CONFIG_MISSING', + '生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能使用公开默认密码初始化超级管理员', + 500, + ); + } + return SUPER_ADMIN_INITIAL_PASSWORD; } } diff --git a/src/auth/reset-super-admin-password.ts b/src/auth/reset-super-admin-password.ts index 7a1fdce..b38175f 100644 --- a/src/auth/reset-super-admin-password.ts +++ b/src/auth/reset-super-admin-password.ts @@ -13,9 +13,19 @@ const prisma = new PrismaClient(); const passwordService = new PasswordService(); async function main(): Promise { - const password = process.env.SUPER_ADMIN_RESET_PASSWORD?.trim() - ? process.env.SUPER_ADMIN_RESET_PASSWORD.trim() - : SUPER_ADMIN_INITIAL_PASSWORD; + const configuredPassword = process.env.SUPER_ADMIN_RESET_PASSWORD?.trim(); + const password = + configuredPassword || + (process.env.NODE_ENV === 'production' + ? undefined + : SUPER_ADMIN_INITIAL_PASSWORD); + + if (!password) { + throw new Error( + '生产环境缺少 SUPER_ADMIN_RESET_PASSWORD,不能使用公开默认密码重置超级管理员', + ); + } + const passwordHash = await passwordService.hashPassword(password); await prisma.user.upsert({ diff --git a/src/deploy-runs/deploy-execution.service.spec.ts b/src/deploy-runs/deploy-execution.service.spec.ts index 20efab6..50358f7 100644 --- a/src/deploy-runs/deploy-execution.service.spec.ts +++ b/src/deploy-runs/deploy-execution.service.spec.ts @@ -52,6 +52,8 @@ describe('DeployExecutionService', () => { matchedBuildNumber?: number; buildResult?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE'; buildRunning?: boolean; + buildTimestamp?: string; + buildDurationMs?: number; logText?: string; }) { const repository = createRepository(); @@ -135,6 +137,8 @@ describe('DeployExecutionService', () => { building: options.buildRunning ?? false, result: options.buildResult, url: `https://jenkins.example/job/${buildNumber}/`, + timestamp: options.buildTimestamp, + durationMs: options.buildDurationMs, }), ), getProgressiveText: jest.fn(() => @@ -279,6 +283,8 @@ describe('DeployExecutionService', () => { queueId: 'queue_success', queueBuildNumber: 18, buildResult: 'SUCCESS', + buildTimestamp: '2026-06-12T08:00:00.000Z', + buildDurationMs: 90_000, }); const run = await service.execute( await createRun(repository, 'run_success_001'), @@ -289,6 +295,7 @@ describe('DeployExecutionService', () => { expect(result.status).toBe('success'); expect(result.jenkinsBuildNumber).toBe(18); + expect(result.finishedAt).toBe('2026-06-12T08:01:30.000Z'); expect(step(result, 'jenkins-build')?.status).toBe('success'); expect(step(result, 'health-check')?.status).toBe('success'); expect(step(result, 'notify-wecom')?.status).toBe('success'); diff --git a/src/deploy-runs/deploy-execution.service.ts b/src/deploy-runs/deploy-execution.service.ts index 4fa4ddd..5759282 100644 --- a/src/deploy-runs/deploy-execution.service.ts +++ b/src/deploy-runs/deploy-execution.service.ts @@ -235,6 +235,8 @@ export class DeployExecutionService { currentRun, buildStatus.result, environment.jenkinsJobPath, + undefined, + this.jenkinsBuildFinishedAt(buildStatus), ); } @@ -680,6 +682,7 @@ export class DeployExecutionService { private async completeSuccessfulJenkinsRun( run: DeployRunSummary, + finishedAt?: string, ): Promise { let currentRun = await this.completeStep(run, 'jenkins-build', 'success'); currentRun = await this.completeStep(currentRun, 'health-check', 'success'); @@ -693,11 +696,12 @@ export class DeployExecutionService { currentRun.id, await this.deployRunRepository.updateRun( currentRun.id, - { - status: 'success', - }, - currentRun.version, - ), + { + status: 'success', + finishedAt, + }, + currentRun.version, + ), ); await this.auditService.record({ @@ -720,6 +724,7 @@ export class DeployExecutionService { result: Exclude, jobPath: string, logText?: string, + finishedAt?: string, ): Promise { const logExcerpt = logText ? this.toLogExcerpt(logText) @@ -743,6 +748,7 @@ export class DeployExecutionService { { status: 'failed', failureSummary, + finishedAt, }, currentRun.version, )) ?? currentRun; @@ -837,12 +843,25 @@ export class DeployExecutionService { result: JenkinsTerminalResult, jobPath: string, logText?: string, + finishedAt?: string, ): Promise { if (result === 'SUCCESS') { - return this.completeSuccessfulJenkinsRun(run); + return this.completeSuccessfulJenkinsRun(run, finishedAt); } - return this.failJenkinsRun(run, result, jobPath, logText); + return this.failJenkinsRun(run, result, jobPath, logText, finishedAt); + } + + private jenkinsBuildFinishedAt( + build: Pick, + ): string | undefined { + if (!build.timestamp || typeof build.durationMs !== 'number') { + return undefined; + } + + return new Date( + new Date(build.timestamp).getTime() + build.durationMs, + ).toISOString(); } private extractJenkinsFinishedResult( diff --git a/src/deploy-runs/deploy-run.repository.ts b/src/deploy-runs/deploy-run.repository.ts index 118aa8a..42999c0 100644 --- a/src/deploy-runs/deploy-run.repository.ts +++ b/src/deploy-runs/deploy-run.repository.ts @@ -104,6 +104,7 @@ const triggerToPrisma: Record< manual: PrismaDeployRunTrigger.MANUAL, gitea_webhook: PrismaDeployRunTrigger.GITEA_WEBHOOK, retry: PrismaDeployRunTrigger.RETRY, + jenkins_external: PrismaDeployRunTrigger.JENKINS_EXTERNAL, }; @Injectable() @@ -151,6 +152,52 @@ export class DeployRunRepository { return run ? this.toSummary(run) : null; } + async findByJenkinsBuild( + projectKey: string, + environment: DeployRunSummary['environment'], + buildNumber: number, + ): Promise { + if (!this.useDatabase()) { + return ( + this.memoryRuns.find( + (run) => + run.projectKey === projectKey && + run.environment === environment && + run.jenkinsBuildNumber === buildNumber, + ) ?? null + ); + } + + const run = await this.prisma.deployRun.findFirst({ + where: { + project: { + key: projectKey, + }, + environment: { + name: + environment === 'test' + ? EnvironmentKind.TEST + : EnvironmentKind.PRODUCTION, + }, + jenkinsBuildNumber: buildNumber, + }, + include: { + project: true, + environment: true, + steps: { + orderBy: { + order: 'asc', + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return run ? this.toSummary(run) : null; + } + async create(input: CreateDeployRunInput): Promise { const idempotencyKey = this.resolveIdempotencyKey(input); diff --git a/src/deploy-runs/deploy-run.types.ts b/src/deploy-runs/deploy-run.types.ts index 16d651f..148b6ef 100644 --- a/src/deploy-runs/deploy-run.types.ts +++ b/src/deploy-runs/deploy-run.types.ts @@ -28,7 +28,7 @@ export type DeployRunSummary = { environment: DeployEnvironment; ref: string; status: DeployRunStatus; - trigger: 'manual' | 'gitea_webhook' | 'retry'; + trigger: 'manual' | 'gitea_webhook' | 'retry' | 'jenkins_external'; operator: string; jenkinsQueueId?: string; jenkinsBuildNumber?: number; @@ -70,6 +70,30 @@ export type DeployRunJenkinsSyncSummary = { failedRuns: DeployRunJenkinsSyncError[]; }; +export type DeployRunJenkinsImportSkippedBuild = { + projectKey: string; + environment: DeployEnvironment; + buildNumber: number; + reason: 'already_imported' | 'linked_platform_run' | 'parameter_mismatch'; +}; + +export type DeployRunJenkinsImportFailedBuild = { + projectKey: string; + environment: DeployEnvironment; + buildNumber?: number; + message: string; +}; + +export type DeployRunJenkinsImportSummary = { + checkedAt: string; + totalJobs: number; + totalBuilds: number; + importedRuns: DeployRunSummary[]; + syncedRuns: DeployRunSummary[]; + skippedBuilds: DeployRunJenkinsImportSkippedBuild[]; + failedBuilds: DeployRunJenkinsImportFailedBuild[]; +}; + export type DeployRunJenkinsLog = { runId: string; jobPath: string; diff --git a/src/deploy-runs/deploy-runs.controller.ts b/src/deploy-runs/deploy-runs.controller.ts index cd78ea4..7809a46 100644 --- a/src/deploy-runs/deploy-runs.controller.ts +++ b/src/deploy-runs/deploy-runs.controller.ts @@ -26,6 +26,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { PasswordChangeGuard } from '../auth/password-change.guard'; import { SuperAdminGuard } from '../auth/super-admin.guard'; import { + DeployRunJenkinsImportSummary, DeployRunJenkinsLog, DeployRunJenkinsSyncSummary, DeployRunSummary, @@ -110,6 +111,16 @@ export class DeployRunsController { return this.deployRunsService.syncJenkinsRuns(user); } + @Post('import-jenkins') + @UseGuards(SuperAdminGuard) + @AdminOnlyMessage('无权限导入 Jenkins 直构记录') + @ApiOkResponse({ description: '扫描已纳管 Jenkins job 并导入平台外直构记录。' }) + async importJenkinsBuilds( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.importJenkinsBuilds(user); + } + @Post(':id/sync-jenkins') @ApiOkResponse({ description: '同步单个发布单的 Jenkins 队列或构建状态。' }) async syncJenkinsRun( diff --git a/src/deploy-runs/deploy-runs.service.spec.ts b/src/deploy-runs/deploy-runs.service.spec.ts index 08464ed..158019d 100644 --- a/src/deploy-runs/deploy-runs.service.spec.ts +++ b/src/deploy-runs/deploy-runs.service.spec.ts @@ -9,7 +9,6 @@ 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'; @@ -60,47 +59,82 @@ describe('DeployRunsService', () => { findById: jest.fn((id: string) => Promise.resolve(runs.find((run) => run.id === id) ?? null), ), - create: jest.fn((input: CreateDeployRunInput) => - Promise.resolve({ - run: { - ...baseRun, - id: input.idempotencyKey ?? 'generated_run', - projectKey: input.projectKey, - environment: input.environment, - ref: input.ref, - operator: input.operator, - trigger: input.trigger ?? 'manual', - }, + findByJenkinsBuild: jest.fn( + ( + projectKey: string, + environment: DeployRunSummary['environment'], + buildNumber: number, + ) => + Promise.resolve( + runs.find( + (run) => + run.projectKey === projectKey && + run.environment === environment && + run.jenkinsBuildNumber === buildNumber, + ) ?? null, + ), + ), + create: jest.fn((input: CreateDeployRunInput) => { + const run: DeployRunSummary = { + ...baseRun, + id: input.idempotencyKey ?? 'generated_run', + projectKey: input.projectKey, + environment: input.environment, + ref: input.ref, + operator: input.operator, + trigger: input.trigger ?? 'manual', + }; + runs.unshift(run); + + return Promise.resolve({ + run, created: true, - }), - ), - updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) => - Promise.resolve( - runs.some((run) => run.id === id) - ? { - ...(runs.find((run) => run.id === id) ?? baseRun), - ...patch, - } - : null, - ), - ), - updateStep: jest.fn((id: string) => - Promise.resolve( - runs.some((run) => run.id === id) - ? { - ...(runs.find((run) => run.id === id) ?? baseRun), - status: 'canceled' as const, - } - : null, - ), - ), + }); + }), + updateRun: jest.fn((id: string, patch: Partial) => { + const index = runs.findIndex((run) => run.id === id); + + if (index === -1) { + return Promise.resolve(null); + } + + const updated = { + ...runs[index], + ...patch, + version: runs[index].version + 1, + } satisfies DeployRunSummary; + runs[index] = updated; + + return Promise.resolve(updated); + }), + updateStep: jest.fn((id: string) => { + const index = runs.findIndex((run) => run.id === id); + + if (index === -1) { + return Promise.resolve(null); + } + + const updated = { + ...runs[index], + version: runs[index].version + 1, + } satisfies DeployRunSummary; + runs[index] = updated; + + return Promise.resolve(updated); + }), } satisfies Pick< DeployRunRepository, - 'findMany' | 'findById' | 'create' | 'updateRun' | 'updateStep' + | 'findMany' + | 'findById' + | 'findByJenkinsBuild' + | 'create' + | 'updateRun' + | 'updateStep' >; const projects = { getProject: jest.fn(() => Promise.resolve(resolvedProject)), - } satisfies Pick; + listProjects: jest.fn(() => Promise.resolve([resolvedProject])), + } satisfies Pick; const audit = { record: jest.fn(), } satisfies Pick; @@ -165,6 +199,11 @@ describe('DeployRunsService', () => { ); }, ), + listRecentBuilds: jest.fn(() => + Promise.resolve( + [] as Awaited>, + ), + ), getProgressiveText: jest.fn(() => Promise.resolve({ text: 'build log', @@ -179,6 +218,7 @@ describe('DeployRunsService', () => { | 'stopBuild' | 'findBuildByQueueId' | 'findLatestBuildByParameter' + | 'listRecentBuilds' | 'getProgressiveText' >; const auth = { @@ -829,4 +869,143 @@ describe('DeployRunsService', () => { }, ]); }); + + it('imports a direct Jenkins build and syncs it through the deploy state machine', async () => { + const { audit, execution, jenkins, repository, service } = createService({ + runs: [], + }); + jenkins.listRecentBuilds.mockResolvedValueOnce([ + { + jobPath: 'my-project-dev/test-access-manage-develop', + buildNumber: 33, + queueId: 'queue_033', + building: false, + result: 'SUCCESS', + url: 'https://jenkins.example/job/my-project-dev/job/test-access-manage-develop/33/', + timestamp: '2026-06-12T06:20:00.000Z', + parameters: { + GIT_REF: 'feature/jenkins-direct', + DEPLOY_ENVIRONMENT: 'test', + BUILD_USER_ID: 'jenkins-user', + }, + }, + ]); + + const result = await service.importJenkinsBuilds({ + id: 'user_admin', + account: 'ops-admin', + displayName: 'ops-admin', + role: 'super_admin', + status: 'active', + mustChangePassword: false, + passwordVersion: 1, + }); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + projectKey: 'access-manage', + environment: 'test', + ref: 'feature/jenkins-direct', + operator: 'jenkins-user', + trigger: 'jenkins_external', + idempotencyKey: 'jenkins-external:access-manage:test:33', + }), + ); + expect(repository.updateStep).toHaveBeenCalledWith( + 'jenkins-external:access-manage:test:33', + 'gitea-check', + expect.objectContaining({ + status: 'skipped', + }), + expect.any(Number), + ); + expect(repository.updateRun).toHaveBeenCalledWith( + 'jenkins-external:access-manage:test:33', + expect.objectContaining({ + status: 'running', + jenkinsQueueId: 'queue_033', + jenkinsBuildNumber: 33, + }), + expect.any(Number), + ); + expect(execution.syncJenkinsRun).toHaveBeenCalledTimes(1); + expect(result.importedRuns).toHaveLength(1); + expect(result.syncedRuns).toHaveLength(1); + expect(result.failedBuilds).toHaveLength(0); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'JENKINS_EXTERNAL_BUILD_IMPORTED', + parameterDigest: { + parameterNames: ['BUILD_USER_ID', 'DEPLOY_ENVIRONMENT', 'GIT_REF'], + }, + }), + ); + }); + + it('skips an already represented Jenkins build and syncs the existing run', async () => { + const runningRun: DeployRunSummary = { + ...baseRun, + id: 'run_existing', + status: 'running', + jenkinsBuildNumber: 41, + }; + const { execution, jenkins, repository, service } = createService({ + runs: [runningRun], + }); + jenkins.listRecentBuilds.mockResolvedValueOnce([ + { + jobPath: 'my-project-dev/test-access-manage-develop', + buildNumber: 41, + building: true, + parameters: { + GIT_REF: 'develop', + }, + }, + ]); + + const result = await service.importJenkinsBuilds(); + + expect(repository.create).not.toHaveBeenCalled(); + expect(execution.syncJenkinsRun).toHaveBeenCalledWith( + runningRun, + expect.objectContaining({ key: 'access-manage' }), + ); + expect(result.importedRuns).toHaveLength(0); + expect(result.skippedBuilds).toEqual([ + { + projectKey: 'access-manage', + environment: 'test', + buildNumber: 41, + reason: 'already_imported', + }, + ]); + }); + + it('skips Jenkins builds whose platform parameters point to another environment', async () => { + const { jenkins, repository, service } = createService(); + jenkins.listRecentBuilds.mockResolvedValueOnce([ + { + jobPath: 'my-project-dev/test-access-manage-develop', + buildNumber: 52, + building: false, + result: 'SUCCESS', + parameters: { + PROJECT_KEY: 'access-manage', + DEPLOY_ENVIRONMENT: 'production', + }, + }, + ]); + + const result = await service.importJenkinsBuilds(); + + expect(repository.create).not.toHaveBeenCalled(); + expect(result.skippedBuilds).toEqual([ + { + projectKey: 'access-manage', + environment: 'test', + buildNumber: 52, + reason: 'parameter_mismatch', + }, + ]); + }); }); diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index 63359f5..115e8fd 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -11,7 +11,10 @@ import { DeployNotificationEvent, DeployNotificationService, } from '../notifications/wecom/deploy-notification.service'; -import { JenkinsCancelResult } from '../integrations/jenkins/jenkins.types'; +import { + JenkinsCancelResult, + JenkinsRecentBuild, +} from '../integrations/jenkins/jenkins.types'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { ProjectEnvironmentSummary, @@ -22,6 +25,8 @@ import { DeployExecutionService } from './deploy-execution.service'; import { DeployRunRepository } from './deploy-run.repository'; import { CreateDeployRunInput, + DeployEnvironment, + DeployRunJenkinsImportSummary, DeployRunJenkinsLog, DeployRunJenkinsSyncSummary, DeployRunSummary, @@ -34,6 +39,22 @@ type ReadRunsOptions = { const jenkinsSyncConflictRetryCount = 2; const jenkinsLogBuildLinkMaxAttempts = 5; const jenkinsLogBuildLinkRetryDelayMs = 1000; +const jenkinsExternalBuildScanLimit = 20; +const jenkinsExternalOperator = 'jenkins-direct'; +const jenkinsExternalRefParameterNames = [ + 'GIT_REF', + 'DEPLOY_REF', + 'BRANCH_NAME', + 'TAG_NAME', + 'REF', +]; +const jenkinsExternalOperatorParameterNames = [ + 'OPERATOR', + 'BUILD_USER_ID', + 'BUILD_USER', + 'BUILD_USER_EMAIL', + 'CAUSE_USER', +]; const escapeControl = String.fromCharCode(27); const jenkinsConsoleNotePattern = new RegExp( `${escapeControl}\\[8mha:/{4}[A-Za-z0-9+/=]+${escapeControl}\\[0m`, @@ -400,6 +421,131 @@ export class DeployRunsService { }; } + async importJenkinsBuilds( + user?: AuthenticatedUser, + ): Promise { + if (user) { + this.authService.assertSuperAdmin(user, '无权限导入 Jenkins 直构记录'); + } + + const projects = await this.projectsService.listProjects(); + const importedRuns: DeployRunSummary[] = []; + const syncedRuns: DeployRunSummary[] = []; + const skippedBuilds: DeployRunJenkinsImportSummary['skippedBuilds'] = []; + const failedBuilds: DeployRunJenkinsImportSummary['failedBuilds'] = []; + let totalJobs = 0; + let totalBuilds = 0; + + for (const project of projects) { + for (const environment of project.environments) { + if (!environment.jenkinsJobPath) { + continue; + } + + totalJobs += 1; + + let builds: JenkinsRecentBuild[]; + try { + builds = await this.jenkinsClient.listRecentBuilds( + environment.jenkinsJobPath, + jenkinsExternalBuildScanLimit, + ); + totalBuilds += builds.length; + } catch (error) { + failedBuilds.push({ + projectKey: project.key, + environment: environment.name, + message: this.toSafeSyncError(error), + }); + continue; + } + + for (const build of builds) { + try { + const mismatchReason = this.externalBuildParameterMismatch( + project, + environment, + build, + ); + + if (mismatchReason) { + skippedBuilds.push({ + projectKey: project.key, + environment: environment.name, + buildNumber: build.buildNumber, + reason: 'parameter_mismatch', + }); + continue; + } + + const existing = await this.findExistingJenkinsBuildRun( + project, + environment, + build, + ); + + if (existing) { + skippedBuilds.push({ + projectKey: project.key, + environment: environment.name, + buildNumber: build.buildNumber, + reason: existing.reason, + }); + + const syncedRun = await this.syncLinkedJenkinsRun( + existing.run, + project, + ); + syncedRuns.push(syncedRun); + continue; + } + + let run = await this.createExternalJenkinsRun( + project, + environment, + build, + ); + importedRuns.push(run); + + run = await this.syncJenkinsRunWithRetry(run, project); + syncedRuns.push(run); + } catch (error) { + failedBuilds.push({ + projectKey: project.key, + environment: environment.name, + buildNumber: build.buildNumber, + message: this.toSafeSyncError(error), + }); + } + } + } + } + + await this.auditService.record({ + action: 'JENKINS_EXTERNAL_IMPORT_COMPLETED', + resourceType: 'deploy_run', + actorName: user?.account, + after: { + totalJobs, + totalBuilds, + importedCount: importedRuns.length, + syncedCount: syncedRuns.length, + skippedCount: skippedBuilds.length, + failedCount: failedBuilds.length, + }, + }); + + return { + checkedAt: new Date().toISOString(), + totalJobs, + totalBuilds, + importedRuns, + syncedRuns, + skippedBuilds, + failedBuilds, + }; + } + private async recordNotificationResult( run: DeployRunSummary, event: DeployNotificationEvent, @@ -428,6 +574,250 @@ export class DeployRunsService { ); } + private externalBuildParameterMismatch( + project: ProjectSummary, + environment: ProjectEnvironmentSummary, + build: JenkinsRecentBuild, + ): string | undefined { + const parameterProject = this.trimOptional( + build.parameters.PROJECT_KEY, + ); + const parameterEnvironment = this.normalizeDeployEnvironment( + build.parameters.DEPLOY_ENVIRONMENT, + ); + + if (parameterProject && parameterProject !== project.key) { + return `PROJECT_KEY=${parameterProject}`; + } + + if (parameterEnvironment && parameterEnvironment !== environment.name) { + return `DEPLOY_ENVIRONMENT=${parameterEnvironment}`; + } + + return undefined; + } + + private async findExistingJenkinsBuildRun( + project: ProjectSummary, + environment: ProjectEnvironmentSummary, + build: JenkinsRecentBuild, + ): Promise< + | { + run: DeployRunSummary; + reason: 'already_imported' | 'linked_platform_run'; + } + | undefined + > { + const deployRunId = this.trimOptional(build.parameters.DEPLOY_RUN_ID); + + if (deployRunId) { + const linkedRun = await this.deployRunRepository.findById(deployRunId); + + if (linkedRun) { + return { + run: linkedRun, + reason: 'linked_platform_run', + }; + } + } + + const existingRun = await this.deployRunRepository.findByJenkinsBuild( + project.key, + environment.name, + build.buildNumber, + ); + + if (!existingRun) { + return undefined; + } + + return { + run: existingRun, + reason: 'already_imported', + }; + } + + private async createExternalJenkinsRun( + project: ProjectSummary, + environment: ProjectEnvironmentSummary, + build: JenkinsRecentBuild, + ): Promise { + const inferredRef = this.inferExternalBuildRef(project, environment, build); + const creation = await this.deployRunRepository.create({ + projectKey: project.key, + environment: environment.name, + ref: inferredRef.ref, + operator: this.inferExternalBuildOperator(build), + remark: `Jenkins 直构 #${build.buildNumber} 导入,ref 来源:${inferredRef.source}`, + idempotencyKey: this.externalJenkinsIdempotencyKey( + project.key, + environment.name, + build.buildNumber, + ), + trigger: 'jenkins_external', + }); + + let run = creation.run; + + if (!creation.created) { + return run; + } + + run = this.requireRun( + run.id, + await this.deployRunRepository.updateStep( + run.id, + 'gitea-check', + { + status: 'skipped', + message: 'Jenkins 直接构建导入,平台未执行 Gitea ref 校验。', + }, + run.version, + ), + ); + run = this.requireRun( + run.id, + await this.deployRunRepository.updateRun( + run.id, + { + status: 'running', + jenkinsQueueId: build.queueId, + jenkinsBuildNumber: build.buildNumber, + jenkinsBuildUrl: build.url, + startedAt: build.timestamp, + }, + run.version, + ), + ); + run = this.requireRun( + run.id, + await this.deployRunRepository.updateStep( + run.id, + 'jenkins-build', + { + status: 'running', + message: build.result + ? `Jenkins 直构 #${build.buildNumber} 已完成,正在回收结果。` + : `Jenkins 直构 #${build.buildNumber} 正在运行。`, + }, + run.version, + ), + ); + + await this.auditService.record({ + action: 'JENKINS_EXTERNAL_BUILD_IMPORTED', + resourceType: 'deploy_run', + resourceId: run.id, + actorName: run.operator, + after: { + projectKey: project.key, + environment: environment.name, + jobPath: environment.jenkinsJobPath, + buildNumber: build.buildNumber, + buildUrl: build.url, + queueId: build.queueId, + ref: inferredRef.ref, + refSource: inferredRef.source, + }, + parameterDigest: { + parameterNames: Object.keys(build.parameters).sort(), + }, + }); + + return run; + } + + private async syncLinkedJenkinsRun( + run: DeployRunSummary, + project: ProjectSummary, + ): Promise { + if (['success', 'failed', 'canceled'].includes(run.status)) { + return run; + } + + return this.syncJenkinsRunWithRetry(run, project); + } + + private inferExternalBuildRef( + project: ProjectSummary, + environment: ProjectEnvironmentSummary, + build: JenkinsRecentBuild, + ): { ref: string; source: string } { + for (const parameterName of jenkinsExternalRefParameterNames) { + const parameterValue = this.trimOptional(build.parameters[parameterName]); + + if (parameterValue) { + return { + ref: parameterValue, + source: parameterName, + }; + } + } + + if (environment.releasePolicy === 'master') { + return { + ref: 'master', + source: 'environment.releasePolicy', + }; + } + + const jobLeaf = environment.jenkinsJobPath.split('/').filter(Boolean).pop(); + + if (jobLeaf && jobLeaf.endsWith(`-${project.defaultBranch}`)) { + return { + ref: project.defaultBranch, + source: 'jenkinsJobPath', + }; + } + + return { + ref: project.defaultBranch, + source: 'project.defaultBranch', + }; + } + + private inferExternalBuildOperator(build: JenkinsRecentBuild): string { + for (const parameterName of jenkinsExternalOperatorParameterNames) { + const parameterValue = this.trimOptional(build.parameters[parameterName]); + + if (parameterValue) { + return parameterValue; + } + } + + return jenkinsExternalOperator; + } + + private externalJenkinsIdempotencyKey( + projectKey: string, + environment: DeployEnvironment, + buildNumber: number, + ): string { + return `jenkins-external:${projectKey}:${environment}:${buildNumber}`; + } + + private normalizeDeployEnvironment( + value: string | undefined, + ): DeployEnvironment | undefined { + const normalized = this.trimOptional(value)?.toLowerCase(); + + if (normalized === 'test' || normalized === 'testing') { + return 'test'; + } + + if (normalized === 'production' || normalized === 'prod') { + return 'production'; + } + + return undefined; + } + + private trimOptional(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + + return trimmed ? trimmed : undefined; + } + private async syncJenkinsCandidatesForRead( runs: DeployRunSummary[], ): Promise { diff --git a/src/integrations/jenkins/jenkins.client.spec.ts b/src/integrations/jenkins/jenkins.client.spec.ts index e60f3cf..b8bbe2a 100644 --- a/src/integrations/jenkins/jenkins.client.spec.ts +++ b/src/integrations/jenkins/jenkins.client.spec.ts @@ -168,6 +168,63 @@ describe('JenkinsClient', () => { }), ); }); + + it('lists recent builds with queue ids, timestamps and parameters', async () => { + const client = createClient(); + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + builds: [ + { + number: 33, + queueId: 701, + url: 'https://jenkins.example/job/my-project-dev/job/test-access-manage-develop/33/', + building: true, + result: null, + timestamp: 1781245200000, + duration: 120000, + actions: [ + { + parameters: [ + { + name: 'GIT_REF', + value: 'develop', + }, + { + name: 'DEPLOY_ENVIRONMENT', + value: 'test', + }, + ], + }, + ], + }, + ], + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + + const builds = await client.listRecentBuilds( + 'my-project-dev/test-access-manage-develop', + ); + + expect(builds).toEqual([ + expect.objectContaining({ + buildNumber: 33, + queueId: '701', + building: true, + timestamp: '2026-06-12T06:20:00.000Z', + durationMs: 120000, + parameters: { + GIT_REF: 'develop', + DEPLOY_ENVIRONMENT: 'test', + }, + }), + ]); + }); }); function createClient(): JenkinsClient { diff --git a/src/integrations/jenkins/jenkins.client.ts b/src/integrations/jenkins/jenkins.client.ts index cbd35a8..30e2062 100644 --- a/src/integrations/jenkins/jenkins.client.ts +++ b/src/integrations/jenkins/jenkins.client.ts @@ -17,6 +17,7 @@ import { JenkinsJobInfo, JenkinsProgressiveText, JenkinsQueueItem, + JenkinsRecentBuild, } from './jenkins.types'; type JenkinsConfig = { @@ -135,6 +136,8 @@ export class JenkinsClient { building?: boolean; result?: JenkinsBuildStatus['result']; url?: string; + timestamp?: number; + duration?: number; }; return { @@ -143,6 +146,9 @@ export class JenkinsClient { building: Boolean(payload.building), result: payload.result, url: payload.url, + timestamp: this.toJenkinsTimestamp(payload.timestamp), + durationMs: + typeof payload.duration === 'number' ? payload.duration : undefined, }; } @@ -238,6 +244,58 @@ export class JenkinsClient { return undefined; } + async listRecentBuilds( + jobPath: string, + limit = 20, + ): Promise { + this.assertConfigured(); + const normalizedLimit = Math.min(Math.max(Math.trunc(limit), 1), 50); + const tree = `builds[number,url,building,result,queueId,timestamp,duration,actions[parameters[name,value]]]{0,${normalizedLimit}}`; + const response = await this.request( + `${this.jobPath(jobPath)}/api/json?tree=${encodeURIComponent(tree)}`, + ); + const payload = (await response.json()) as { + builds?: Array<{ + number?: number; + queueId?: number | string; + url?: string; + building?: boolean; + result?: JenkinsBuildStatus['result']; + timestamp?: number; + duration?: number; + actions?: Array<{ + parameters?: Array<{ name?: string; value?: unknown }>; + }>; + }>; + }; + + return (payload.builds ?? []) + .filter((build) => typeof build.number === 'number') + .slice(0, normalizedLimit) + .map((build) => ({ + jobPath, + buildNumber: build.number as number, + building: Boolean(build.building), + result: build.result, + url: build.url, + queueId: + build.queueId === undefined || build.queueId === null + ? undefined + : String(build.queueId), + timestamp: + this.toJenkinsTimestamp(build.timestamp), + durationMs: + typeof build.duration === 'number' ? build.duration : undefined, + parameters: this.toJenkinsParameterMap(build.actions), + })); + } + + private toJenkinsTimestamp(timestamp: number | undefined): string | undefined { + return typeof timestamp === 'number' + ? new Date(timestamp).toISOString() + : undefined; + } + private jenkinsParameterValueToString(value: unknown): string { if (value === undefined || value === null) { return ''; @@ -255,6 +313,26 @@ export class JenkinsClient { return JSON.stringify(value) ?? ''; } + private toJenkinsParameterMap( + actions: Array<{ + parameters?: Array<{ name?: string; value?: unknown }>; + }> = [], + ): Record { + return actions + .flatMap((action) => action.parameters ?? []) + .reduce>((result, parameter) => { + if (!parameter.name) { + return result; + } + + result[parameter.name] = this.jenkinsParameterValueToString( + parameter.value, + ); + + return result; + }, {}); + } + async getJobInfo(jobPath: string): Promise { this.assertConfigured(); const response = await this.request( diff --git a/src/integrations/jenkins/jenkins.types.ts b/src/integrations/jenkins/jenkins.types.ts index fd97282..6ff181f 100644 --- a/src/integrations/jenkins/jenkins.types.ts +++ b/src/integrations/jenkins/jenkins.types.ts @@ -10,6 +10,8 @@ export type JenkinsBuildStatus = { building: boolean; result?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE'; url?: string; + timestamp?: string; + durationMs?: number; }; export type JenkinsBuildParameterMatch = JenkinsBuildStatus & { @@ -21,6 +23,12 @@ export type JenkinsBuildQueueMatch = JenkinsBuildStatus & { queueId: string; }; +export type JenkinsRecentBuild = JenkinsBuildStatus & { + queueId?: string; + timestamp?: string; + parameters: Record; +}; + export type JenkinsJobInfo = { jobPath: string; name?: string;