feat: 初始化DevOps平台后端
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# Runtime
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=4300
|
||||||
|
CORS_ORIGIN=http://localhost:4301
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# Do not commit real credentials. Use a local-only .env file or deployment secret manager.
|
||||||
|
DATABASE_URL=mysql://devops_user:devops_password@127.0.0.1:3306/devops_platform
|
||||||
|
USE_DATABASE_READS=false
|
||||||
|
PRISMA_CONNECT_ON_BOOT=false
|
||||||
|
|
||||||
|
# Redis and queues, reserved for async polling/notification workers.
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
|
||||||
|
# Jenkins integration, server-side only.
|
||||||
|
JENKINS_BASE_URL=https://jenkins.example.internal
|
||||||
|
JENKINS_USERNAME=devops-bot
|
||||||
|
JENKINS_API_TOKEN=replace-with-secret-manager-value
|
||||||
|
|
||||||
|
# Gitea integration, server-side only.
|
||||||
|
GITEA_BASE_URL=https://gitea.example.internal
|
||||||
|
GITEA_TOKEN=replace-with-secret-manager-value
|
||||||
|
GITEA_WEBHOOK_SECRET=replace-with-secret-manager-value
|
||||||
|
|
||||||
|
# Public platform URL used in notification links.
|
||||||
|
DEVOPS_PUBLIC_URL=https://devops.mrzhan.top
|
||||||
|
|
||||||
|
# Notification provider, server-side only. Supported plan: wecom, feishu, generic.
|
||||||
|
NOTIFICATION_PROVIDER=replace-with-provider
|
||||||
|
NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-with-secret-manager-value
|
||||||
|
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-with-secret-manager-value
|
||||||
|
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-secret-manager-value
|
||||||
|
|
||||||
|
# LLM proxy configuration, server-side only.
|
||||||
|
LLM_BASE_URL=https://llm.example.internal/v1
|
||||||
|
LLM_API_KEY=replace-with-secret-manager-value
|
||||||
|
LLM_MODEL=replace-with-model-name
|
||||||
|
|
||||||
|
# Secret encryption key placeholder. Production should provide this via secret manager.
|
||||||
|
SECRET_ENCRYPTION_KEY=replace-with-32-byte-key
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@RTK.md
|
||||||
|
|
||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
This backend owns the DevOps platform API only. Do not edit sibling projects from this directory.
|
||||||
|
|
||||||
|
## Hard Boundaries
|
||||||
|
|
||||||
|
- Write only inside `devops-platform-api` unless the user explicitly expands the scope.
|
||||||
|
- Do not write `devops-platform-web`.
|
||||||
|
- Do not write `devops-platform-docs/devops-platform-implementation-tracker.md`; the coordinating thread owns tracker updates.
|
||||||
|
- Do not revert unrelated user or worker changes.
|
||||||
|
|
||||||
|
## Backend Standards
|
||||||
|
|
||||||
|
- Use NestJS modules and dependency injection for all business boundaries.
|
||||||
|
- Keep controllers thin. Validation, orchestration, and audit decisions belong in services.
|
||||||
|
- Use Prisma as the default data access layer. Business code must not create mysql2 pools or scatter handwritten SQL.
|
||||||
|
- If native SQL is unavoidable, place it in a Repository, parameterize it, document why Prisma is insufficient, and keep the method narrow.
|
||||||
|
- Keep external clients behind integration modules: Jenkins, Gitea, notification providers, and LLM.
|
||||||
|
|
||||||
|
## Security Standards
|
||||||
|
|
||||||
|
- Never commit real credentials.
|
||||||
|
- Redact tokens, webhook URLs, cookies, authorization headers, private keys, and long opaque secret-like strings before logging, auditing, returning errors, or passing content to LLMs.
|
||||||
|
- Store future persisted secrets encrypted server-side only.
|
||||||
|
- Frontend must never receive Jenkins/Gitea/notification/LLM secret values.
|
||||||
|
|
||||||
|
## Audit Standards
|
||||||
|
|
||||||
|
- Record actor, resource, action, before/after state summaries, request source, and parameter digests for deploy, cancel, retry, secret change, and agent invocation operations.
|
||||||
|
- Deploy run status transitions must be idempotent and concurrency-safe when database writes are enabled.
|
||||||
|
|
||||||
|
## Agent Standards
|
||||||
|
|
||||||
|
- Agent features must be tied to DevOps workflows, not general chat.
|
||||||
|
- Agent calls are read-only unless a future product decision explicitly changes this.
|
||||||
|
- Save prompt version, purpose, operator, input digest, output digest, token estimate, and sanitized failure summary for every real invocation.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# DevOps Platform API
|
||||||
|
|
||||||
|
NestJS + TypeScript backend for the DevOps operations platform. The current local MVP provides the enterprise project skeleton, Prisma data model, configuration validation, integration client boundaries, Gitea webhook handling, Jenkins queue/build synchronization, notification provider boundaries, audit logs, BPMN process definitions, and runnable seed-backed APIs.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Manage projects, deploy runs, BPMN step state, Jenkins/Gitea integration state, notification delivery, audit records, and agent invocations.
|
||||||
|
- Keep all external credentials server-side.
|
||||||
|
- Use Prisma as the default data access layer. Native SQL is allowed only inside Repository methods with a documented reason.
|
||||||
|
|
||||||
|
## Local Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp .env.example .env
|
||||||
|
pnpm prisma:generate
|
||||||
|
pnpm start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The API listens on `http://localhost:4300` by default and runs without a local MySQL database when `USE_DATABASE_READS=false`. In that mode `/projects` and `/deploy-runs` use seed-backed repository data while keeping the same service and controller contracts intended for Prisma-backed reads.
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
pnpm check
|
||||||
|
pnpm prisma:migrate:dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /health`: dependency configuration and health summary.
|
||||||
|
- `GET /settings/integration-config`: Jenkins/Gitea/notification/LLM/Database/Redis/Secret Encryption Key configuration status. The response only exposes environment variable names, never secret values.
|
||||||
|
- `GET /projects`: configured project list.
|
||||||
|
- `GET /projects/:key`: one project by key.
|
||||||
|
- `GET /deploy-runs`: latest deploy runs.
|
||||||
|
- `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`.
|
||||||
|
- `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure.
|
||||||
|
- `POST /deploy-runs/:id/cancel`: cancel a deploy run.
|
||||||
|
- `POST /deploy-runs/:id/retry`: create a retry deploy run.
|
||||||
|
- `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs.
|
||||||
|
- `GET /process-definitions/release`: BPMN release process definition.
|
||||||
|
- `GET /audit-logs`: latest sanitized audit records.
|
||||||
|
- `GET /agent/invocations` and `POST /agent/invocations`: DevOps-scoped agent invocation records and mock results.
|
||||||
|
- `GET /docs`: Swagger UI.
|
||||||
|
|
||||||
|
## Data Access Rules
|
||||||
|
|
||||||
|
- Business modules access data through Repository classes, not direct Prisma calls in controllers.
|
||||||
|
- Prisma owns connection pooling, transactions, migrations, and typed models.
|
||||||
|
- Deploy run creation, status transition, step transition, and audit writes must use explicit Prisma transactions once DB writes are enabled.
|
||||||
|
- Idempotency keys are mandatory for webhook callbacks, Jenkins queue/build tracking, and manual deploy retry paths.
|
||||||
|
- Raw SQL must be parameterized, isolated in a Repository method, covered by tests, and justified in the method comment.
|
||||||
|
|
||||||
|
## Secret Rules
|
||||||
|
|
||||||
|
- Never commit real `.env`, Jenkins tokens, Gitea tokens, notification webhook URLs, LLM keys, private keys, or cookies.
|
||||||
|
- Persisted secrets belong in the `secrets` table encrypted with a deployment-provided key or in the deployment secret manager.
|
||||||
|
- Logs, audit records, integration errors, and LLM prompts must pass through redaction before storage or return.
|
||||||
|
|
||||||
|
## Agent Boundary
|
||||||
|
|
||||||
|
The Agent module is not a generic chat entrypoint. It can only support DevOps workflows:
|
||||||
|
|
||||||
|
- release risk summaries,
|
||||||
|
- Jenkins failure diagnosis,
|
||||||
|
- runbook Q&A against whitelisted sources,
|
||||||
|
- release notes drafts,
|
||||||
|
- incident review drafts.
|
||||||
|
|
||||||
|
Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Backend RTK
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Framework: NestJS + TypeScript.
|
||||||
|
- Package manager: pnpm.
|
||||||
|
- Data access: Prisma Client and Prisma migrations.
|
||||||
|
- Default local behavior: seed/mock data for runnable APIs until MySQL/Jenkins/Gitea credentials are provided.
|
||||||
|
|
||||||
|
## Module Map
|
||||||
|
|
||||||
|
- `config`: environment schema and validation.
|
||||||
|
- `prisma`: PrismaService and DB health boundary.
|
||||||
|
- `health`: runtime and dependency summary.
|
||||||
|
- `projects`: project configuration API and repository.
|
||||||
|
- `deploy-runs`: deploy run API, status model, BPMN step summary.
|
||||||
|
- `integrations/jenkins`: Jenkins API client boundary.
|
||||||
|
- `integrations/gitea`: Gitea API client boundary.
|
||||||
|
- `notifications/wecom`: current WeCom adapter boundary; notification provider abstraction is kept for future Feishu or generic webhook adapters.
|
||||||
|
- `agent`: LLM proxy client boundary for DevOps-only workflows.
|
||||||
|
- `audit`: sanitized audit record boundary.
|
||||||
|
|
||||||
|
## Data Access Rules
|
||||||
|
|
||||||
|
1. Controllers call services.
|
||||||
|
2. Services call repositories and integration clients.
|
||||||
|
3. Repositories call Prisma.
|
||||||
|
4. Raw SQL is exceptional and repository-only.
|
||||||
|
5. Transactions are required for deploy run writes once persistence is enabled.
|
||||||
|
|
||||||
|
## Environment Rules
|
||||||
|
|
||||||
|
- `.env.example` may contain placeholders only.
|
||||||
|
- `.env` is local-only and ignored by git.
|
||||||
|
- Missing external integration configuration should degrade to `not_configured` in health checks, not crash local development.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run these before handing off backend changes when dependencies are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm prisma:generate
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist', 'coverage', 'node_modules', 'prisma/generated'],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: './tsconfig.json',
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
checksVoidReturn: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'off'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "devops-platform-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "DevOps operations platform API for Jenkins, Gitea, WeCom notifications, BPMN deploy state, and agent-assisted operations.",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"packageManager": "pnpm@9.15.4",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.11.0",
|
||||||
|
"pnpm": ">=9.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"check": "pnpm lint && pnpm test && pnpm build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.prisma\" \"*.md\"",
|
||||||
|
"lint": "eslint \"{src,prisma}/**/*.{ts,js}\"",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main.js",
|
||||||
|
"test": "jest --passWithNoTests"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.1.6",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
|
"@nestjs/core": "^11.1.6",
|
||||||
|
"@nestjs/platform-express": "^11.1.6",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
|
"@prisma/client": "^6.14.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"zod": "^4.0.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@nestjs/cli": "^11.0.10",
|
||||||
|
"@nestjs/schematics": "^11.0.7",
|
||||||
|
"@nestjs/testing": "^11.1.6",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"jest": "^30.0.5",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"prisma": "^6.14.0",
|
||||||
|
"ts-jest": "^29.4.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"typescript-eslint": "^8.39.1"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6319
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- "."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Prisma Migration Structure
|
||||||
|
|
||||||
|
This project uses Prisma as the default data access layer.
|
||||||
|
|
||||||
|
Migration policy:
|
||||||
|
|
||||||
|
- Create schema changes with `pnpm prisma:migrate:dev --name <change-name>` after the final MySQL connection details are confirmed.
|
||||||
|
- Commit generated folders under `prisma/migrations/<timestamp>_<change-name>/migration.sql`.
|
||||||
|
- Run migration checks in deployment before starting the API.
|
||||||
|
- Keep raw SQL exceptional. If native SQL is required for performance or vendor-specific behavior, place it behind a Repository method and document why Prisma Client is not sufficient.
|
||||||
|
|
||||||
|
The first-stage schema is defined in `prisma/schema.prisma`; no live migration is executed until a local or staging MySQL database is explicitly provided.
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProjectStatus {
|
||||||
|
ACTIVE
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EnvironmentKind {
|
||||||
|
TEST
|
||||||
|
PRODUCTION
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeployRunStatus {
|
||||||
|
PENDING
|
||||||
|
QUEUED
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeployRunTrigger {
|
||||||
|
MANUAL
|
||||||
|
GITEA_WEBHOOK
|
||||||
|
RETRY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RunStepStatus {
|
||||||
|
PENDING
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
SKIPPED
|
||||||
|
CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SecretProvider {
|
||||||
|
JENKINS
|
||||||
|
GITEA
|
||||||
|
WECOM
|
||||||
|
LLM
|
||||||
|
INTERNAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentInvocationPurpose {
|
||||||
|
RISK_SUMMARY
|
||||||
|
FAILURE_DIAGNOSIS
|
||||||
|
RUNBOOK_QA
|
||||||
|
RELEASE_NOTES
|
||||||
|
INCIDENT_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OutboxStatus {
|
||||||
|
PENDING
|
||||||
|
SENT
|
||||||
|
FAILED
|
||||||
|
DEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
name String
|
||||||
|
repositoryUrl String? @db.VarChar(512)
|
||||||
|
defaultBranch String @default("develop")
|
||||||
|
status ProjectStatus @default(ACTIVE)
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
environments Environment[]
|
||||||
|
deployJobs DeployJob[]
|
||||||
|
deployRuns DeployRun[]
|
||||||
|
|
||||||
|
@@map("projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Environment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
name EnvironmentKind
|
||||||
|
displayName String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
deployJobs DeployJob[]
|
||||||
|
deployRuns DeployRun[]
|
||||||
|
|
||||||
|
@@unique([projectId, name])
|
||||||
|
@@index([projectId])
|
||||||
|
@@map("environments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DeployJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
environmentId String
|
||||||
|
jenkinsJobPath String
|
||||||
|
jenkinsParameters Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||||
|
deployRuns DeployRun[]
|
||||||
|
|
||||||
|
@@unique([projectId, environmentId])
|
||||||
|
@@index([environmentId])
|
||||||
|
@@map("deploy_jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DeployRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
environmentId String
|
||||||
|
jobId String?
|
||||||
|
status DeployRunStatus @default(PENDING)
|
||||||
|
trigger DeployRunTrigger @default(MANUAL)
|
||||||
|
ref String
|
||||||
|
operator String
|
||||||
|
idempotencyKey String @unique
|
||||||
|
jenkinsQueueId String?
|
||||||
|
jenkinsBuildNumber Int?
|
||||||
|
failureSummary String? @db.Text
|
||||||
|
metadata Json?
|
||||||
|
version Int @default(1)
|
||||||
|
startedAt DateTime?
|
||||||
|
finishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
|
||||||
|
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Restrict)
|
||||||
|
job DeployJob? @relation(fields: [jobId], references: [id], onDelete: SetNull)
|
||||||
|
steps RunStep[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
agentInvocations AgentInvocation[]
|
||||||
|
outboxMessages OutboxMessage[]
|
||||||
|
|
||||||
|
@@index([projectId, environmentId, createdAt])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@map("deploy_runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RunStep {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
deployRunId String
|
||||||
|
bpmnNodeId String
|
||||||
|
name String
|
||||||
|
status RunStepStatus @default(PENDING)
|
||||||
|
order Int @default(0)
|
||||||
|
errorSummary String? @db.Text
|
||||||
|
metadata Json?
|
||||||
|
startedAt DateTime?
|
||||||
|
finishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
deployRun DeployRun @relation(fields: [deployRunId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([deployRunId, bpmnNodeId])
|
||||||
|
@@index([deployRunId, order])
|
||||||
|
@@map("run_steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
deployRunId String?
|
||||||
|
actorId String?
|
||||||
|
actorName String?
|
||||||
|
action String
|
||||||
|
resourceType String
|
||||||
|
resourceId String?
|
||||||
|
requestId String?
|
||||||
|
sourceIp String?
|
||||||
|
userAgent String?
|
||||||
|
before Json?
|
||||||
|
after Json?
|
||||||
|
parameterDigest Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
deployRun DeployRun? @relation(fields: [deployRunId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([resourceType, resourceId, createdAt])
|
||||||
|
@@index([actorId, createdAt])
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Secret {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
provider SecretProvider
|
||||||
|
scopeType String
|
||||||
|
scopeId String
|
||||||
|
name String
|
||||||
|
encryptedValue String @db.Text
|
||||||
|
version Int @default(1)
|
||||||
|
rotatedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([provider, scopeType, scopeId, name])
|
||||||
|
@@index([scopeType, scopeId])
|
||||||
|
@@map("secrets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AgentInvocation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
deployRunId String?
|
||||||
|
purpose AgentInvocationPurpose
|
||||||
|
actorId String?
|
||||||
|
actorName String?
|
||||||
|
model String?
|
||||||
|
promptVersion String
|
||||||
|
inputDigest Json
|
||||||
|
outputDigest Json?
|
||||||
|
tokenEstimate Int?
|
||||||
|
status String
|
||||||
|
errorSummary String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
deployRun DeployRun? @relation(fields: [deployRunId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([purpose, createdAt])
|
||||||
|
@@index([deployRunId, createdAt])
|
||||||
|
@@map("agent_invocations")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OutboxMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
deployRunId String?
|
||||||
|
channel String
|
||||||
|
template String
|
||||||
|
payload Json
|
||||||
|
status OutboxStatus @default(PENDING)
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
nextAttemptAt DateTime?
|
||||||
|
lastError String? @db.Text
|
||||||
|
idempotencyKey String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
deployRun DeployRun? @relation(fields: [deployRunId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([status, nextAttemptAt])
|
||||||
|
@@map("outbox_messages")
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBody,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
|
import { AgentInvocation } from './agent.types';
|
||||||
|
import { AgentService } from './agent.service';
|
||||||
|
import {
|
||||||
|
CreateAgentInvocationDto,
|
||||||
|
CreateAgentInvocationInput,
|
||||||
|
createAgentInvocationSchema,
|
||||||
|
} from './create-agent-invocation.dto';
|
||||||
|
|
||||||
|
@ApiTags('agent')
|
||||||
|
@Controller('agent/invocations')
|
||||||
|
export class AgentController {
|
||||||
|
constructor(private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ description: 'Agent invocation audit list.' })
|
||||||
|
listInvocations(): AgentInvocation[] {
|
||||||
|
return this.agentService.listInvocations();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiBody({ type: CreateAgentInvocationDto })
|
||||||
|
@ApiCreatedResponse({ description: 'Create a DevOps-scoped agent invocation.' })
|
||||||
|
async createInvocation(
|
||||||
|
@Body(new ZodValidationPipe(createAgentInvocationSchema))
|
||||||
|
body: CreateAgentInvocationInput,
|
||||||
|
): Promise<AgentInvocation> {
|
||||||
|
return this.agentService.createInvocation(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { AgentController } from './agent.controller';
|
||||||
|
import { AgentService } from './agent.service';
|
||||||
|
import { LlmClient } from './llm.client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule],
|
||||||
|
controllers: [AgentController],
|
||||||
|
providers: [AgentService, LlmClient],
|
||||||
|
exports: [AgentService, LlmClient],
|
||||||
|
})
|
||||||
|
export class AgentModule {}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { AgentService } from './agent.service';
|
||||||
|
import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
|
||||||
|
import { LlmClient } from './llm.client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁定 Agent 入口只服务 DevOps 场景,避免后续误扩成泛聊天接口。
|
||||||
|
*/
|
||||||
|
describe('AgentService', () => {
|
||||||
|
function createService(options?: {
|
||||||
|
llmConfigured?: boolean;
|
||||||
|
llmText?: string;
|
||||||
|
}) {
|
||||||
|
const audit = {
|
||||||
|
record: jest.fn(),
|
||||||
|
} satisfies Pick<AuditService, 'record'>;
|
||||||
|
const llm = {
|
||||||
|
healthSummary: jest.fn(() =>
|
||||||
|
options?.llmConfigured
|
||||||
|
? { status: 'ok' as const }
|
||||||
|
: { status: 'not_configured' as const, message: 'Missing LLM_API_KEY' },
|
||||||
|
),
|
||||||
|
complete: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
text: options?.llmText ?? 'LLM generated DevOps answer',
|
||||||
|
model: 'test-model',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} satisfies Pick<LlmClient, 'healthSummary' | 'complete'>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
audit,
|
||||||
|
llm,
|
||||||
|
service: new AgentService(
|
||||||
|
audit as unknown as AuditService,
|
||||||
|
llm as unknown as LlmClient,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a DevOps-scoped mock invocation when LLM is not configured', async () => {
|
||||||
|
const { audit, llm, service } = createService();
|
||||||
|
|
||||||
|
const invocation = await service.createInvocation({
|
||||||
|
type: 'failure-diagnosis',
|
||||||
|
runId: 'run_001',
|
||||||
|
projectKey: 'role-user',
|
||||||
|
promptSummary: 'Analyze latest failed Jenkins log excerpt.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invocation.status).toBe('success');
|
||||||
|
expect(invocation.resultMarkdown).toContain('失败诊断摘要');
|
||||||
|
expect(llm.complete).not.toHaveBeenCalled();
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'AGENT_INVOCATION_CREATED',
|
||||||
|
resourceType: 'agent_invocation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a DevOps-scoped LLM invocation when configured', async () => {
|
||||||
|
const { audit, llm, service } = createService({
|
||||||
|
llmConfigured: true,
|
||||||
|
llmText: '真实 LLM 运维分析结果',
|
||||||
|
});
|
||||||
|
|
||||||
|
const invocation = await service.createInvocation({
|
||||||
|
type: 'release-risk',
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
promptSummary: 'Analyze production tag risk.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invocation.status).toBe('success');
|
||||||
|
expect(invocation.resultMarkdown).toBe('真实 LLM 运维分析结果');
|
||||||
|
expect(llm.complete).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
purpose: 'release-risk',
|
||||||
|
promptVersion: 'devops-agent-v1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
after: expect.objectContaining({
|
||||||
|
mode: 'llm',
|
||||||
|
model: 'test-model',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-DevOps invocation types', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createInvocation({
|
||||||
|
type: 'general-chat',
|
||||||
|
promptSummary: 'Tell me anything.',
|
||||||
|
} as unknown as CreateAgentInvocationInput),
|
||||||
|
).rejects.toBeInstanceOf(AppError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
|
import { AgentInvocation, AgentPurpose } from './agent.types';
|
||||||
|
import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
|
||||||
|
import { LlmClient } from './llm.client';
|
||||||
|
|
||||||
|
const allowedTypes: AgentPurpose[] = [
|
||||||
|
'release-risk',
|
||||||
|
'failure-diagnosis',
|
||||||
|
'runbook-qa',
|
||||||
|
'release-note',
|
||||||
|
'incident-review',
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockResults: Record<AgentPurpose, string> = {
|
||||||
|
'release-risk':
|
||||||
|
'发布风险摘要:当前为第一阶段 mock 分析。真实接入后会读取 Gitea ref diff、历史失败记录和项目部署策略。',
|
||||||
|
'failure-diagnosis':
|
||||||
|
'失败诊断摘要:当前为第一阶段 mock 分析。真实接入后会基于 Jenkins progressiveText 日志和 run steps 生成建议。',
|
||||||
|
'runbook-qa':
|
||||||
|
'Runbook 回答:当前仅允许围绕 DevOps 平台文档、项目 README 和白名单运维资料回答。',
|
||||||
|
'release-note':
|
||||||
|
'发布说明草稿:当前为 mock 草稿。真实接入后会根据 commit、PR 和 tag 信息生成。',
|
||||||
|
'incident-review':
|
||||||
|
'事故复盘草稿:当前为 mock 草稿。真实接入后会汇总失败 run、耗时、日志摘要和人工处理记录。',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentService {
|
||||||
|
private readonly invocations: AgentInvocation[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
private readonly llmClient: LlmClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listInvocations(): AgentInvocation[] {
|
||||||
|
return [...this.invocations].sort((left, right) =>
|
||||||
|
right.createdAt.localeCompare(left.createdAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvocation(input: CreateAgentInvocationInput): Promise<AgentInvocation> {
|
||||||
|
if (!allowedTypes.includes(input.type)) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', 'Unsupported agent invocation type', 400, {
|
||||||
|
type: input.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmHealth = this.llmClient.healthSummary();
|
||||||
|
const baseInvocation = {
|
||||||
|
id: `agent_${Date.now()}_${this.invocations.length + 1}`,
|
||||||
|
runId: input.runId,
|
||||||
|
projectKey: input.projectKey,
|
||||||
|
type: input.type,
|
||||||
|
promptSummary: input.promptSummary,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
let invocation: AgentInvocation;
|
||||||
|
|
||||||
|
if (llmHealth.status === 'ok') {
|
||||||
|
try {
|
||||||
|
const completion = await this.llmClient.complete({
|
||||||
|
purpose: input.type,
|
||||||
|
promptVersion: 'devops-agent-v1',
|
||||||
|
systemPrompt: this.systemPromptFor(input.type),
|
||||||
|
userPrompt: this.userPromptFor(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
invocation = {
|
||||||
|
...baseInvocation,
|
||||||
|
status: 'success',
|
||||||
|
resultMarkdown: completion.text || mockResults[input.type],
|
||||||
|
};
|
||||||
|
this.recordInvocationAudit(invocation, 'llm', completion.model);
|
||||||
|
} catch (error) {
|
||||||
|
invocation = {
|
||||||
|
...baseInvocation,
|
||||||
|
status: 'failed',
|
||||||
|
resultMarkdown:
|
||||||
|
error instanceof Error
|
||||||
|
? redactSensitive({ message: error.message }).message
|
||||||
|
: 'LLM invocation failed',
|
||||||
|
};
|
||||||
|
this.recordInvocationAudit(invocation, 'llm_failed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
invocation = {
|
||||||
|
...baseInvocation,
|
||||||
|
status: 'success',
|
||||||
|
resultMarkdown: mockResults[input.type],
|
||||||
|
};
|
||||||
|
this.recordInvocationAudit(invocation, 'mock', undefined, llmHealth.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.invocations.unshift(invocation);
|
||||||
|
return invocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordInvocationAudit(
|
||||||
|
invocation: AgentInvocation,
|
||||||
|
mode: 'mock' | 'llm' | 'llm_failed',
|
||||||
|
model?: string,
|
||||||
|
reason?: string,
|
||||||
|
): void {
|
||||||
|
const action =
|
||||||
|
invocation.status === 'failed'
|
||||||
|
? 'AGENT_INVOCATION_FAILED'
|
||||||
|
: 'AGENT_INVOCATION_CREATED';
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action,
|
||||||
|
resourceType: 'agent_invocation',
|
||||||
|
resourceId: invocation.id,
|
||||||
|
after: {
|
||||||
|
type: invocation.type,
|
||||||
|
runId: invocation.runId,
|
||||||
|
projectKey: invocation.projectKey,
|
||||||
|
mode,
|
||||||
|
model,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
parameterDigest: {
|
||||||
|
promptSummary: invocation.promptSummary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private systemPromptFor(type: AgentPurpose): string {
|
||||||
|
return [
|
||||||
|
'你是 DevOps 运维平台内的受限 Agent,只能围绕发布、构建、Jenkins、Gitea、通知平台、BPMN 流程、回滚和运行记录回答。',
|
||||||
|
'不要输出或猜测任何密钥、token、webhook、cookie、私钥、环境变量原文。',
|
||||||
|
'只给出与当前项目运维目标有关的结论、风险、排查步骤或草稿。',
|
||||||
|
`当前任务类型:${type}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private userPromptFor(input: CreateAgentInvocationInput): string {
|
||||||
|
return [
|
||||||
|
`项目:${input.projectKey ?? '未指定'}`,
|
||||||
|
`Run:${input.runId ?? '未指定'}`,
|
||||||
|
`用户输入:${input.promptSummary}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type AgentPurpose =
|
||||||
|
| 'release-risk'
|
||||||
|
| 'failure-diagnosis'
|
||||||
|
| 'runbook-qa'
|
||||||
|
| 'release-note'
|
||||||
|
| 'incident-review';
|
||||||
|
|
||||||
|
export type LlmCompletionInput = {
|
||||||
|
purpose: AgentPurpose;
|
||||||
|
promptVersion: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
operator?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LlmCompletionResult = {
|
||||||
|
text: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentInvocationStatus = 'pending' | 'running' | 'success' | 'failed';
|
||||||
|
|
||||||
|
export type AgentInvocation = {
|
||||||
|
id: string;
|
||||||
|
runId?: string;
|
||||||
|
projectKey?: string;
|
||||||
|
type: AgentPurpose;
|
||||||
|
status: AgentInvocationStatus;
|
||||||
|
promptSummary: string;
|
||||||
|
resultMarkdown?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AgentPurpose } from './agent.types';
|
||||||
|
|
||||||
|
const agentPurposeSchema = z.enum([
|
||||||
|
'release-risk',
|
||||||
|
'failure-diagnosis',
|
||||||
|
'runbook-qa',
|
||||||
|
'release-note',
|
||||||
|
'incident-review',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const optionalTrimmedString = (maxLength: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(maxLength)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('').transform(() => undefined));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 入口只接受 DevOps 场景参数,防止演变成通用聊天代理。
|
||||||
|
*/
|
||||||
|
export const createAgentInvocationSchema = z.object({
|
||||||
|
type: agentPurposeSchema,
|
||||||
|
runId: optionalTrimmedString(120),
|
||||||
|
projectKey: optionalTrimmedString(80),
|
||||||
|
promptSummary: z.string().trim().min(1).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateAgentInvocationInput = z.infer<
|
||||||
|
typeof createAgentInvocationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export class CreateAgentInvocationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: [
|
||||||
|
'release-risk',
|
||||||
|
'failure-diagnosis',
|
||||||
|
'runbook-qa',
|
||||||
|
'release-note',
|
||||||
|
'incident-review',
|
||||||
|
],
|
||||||
|
example: 'failure-diagnosis',
|
||||||
|
})
|
||||||
|
type!: AgentPurpose;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'run-1052' })
|
||||||
|
runId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'access-manage' })
|
||||||
|
projectKey?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Analyze latest failed Jenkins log excerpt for access-manage test.',
|
||||||
|
})
|
||||||
|
promptSummary!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IntegrationRequestError, IntegrationUnavailableError } from '../common/errors/integration-error';
|
||||||
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { LlmCompletionInput, LlmCompletionResult } from './agent.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LlmClient {
|
||||||
|
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
||||||
|
|
||||||
|
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
|
||||||
|
const missing = this.missingConfig();
|
||||||
|
return missing.length === 0
|
||||||
|
? { status: 'ok' }
|
||||||
|
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(input: LlmCompletionInput): Promise<LlmCompletionResult> {
|
||||||
|
this.assertConfigured();
|
||||||
|
const baseUrl = this.config.get('LLM_BASE_URL', { infer: true }) ?? '';
|
||||||
|
const apiKey = this.config.get('LLM_API_KEY', { infer: true }) ?? '';
|
||||||
|
const model = this.config.get('LLM_MODEL', { infer: true }) ?? '';
|
||||||
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${apiKey}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: input.systemPrompt },
|
||||||
|
{ role: 'user', content: input.userPrompt },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new IntegrationRequestError('llm', 'LLM request failed', {
|
||||||
|
status: response.status,
|
||||||
|
purpose: input.purpose,
|
||||||
|
request: redactSensitive(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: payload.choices?.[0]?.message?.content ?? '',
|
||||||
|
model: payload.model ?? model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConfigured(): void {
|
||||||
|
const missing = this.missingConfig();
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new IntegrationUnavailableError('llm', missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private missingConfig(): string[] {
|
||||||
|
const entries: Array<[string, string | undefined]> = [
|
||||||
|
['LLM_BASE_URL', this.config.get('LLM_BASE_URL', { infer: true })],
|
||||||
|
['LLM_API_KEY', this.config.get('LLM_API_KEY', { infer: true })],
|
||||||
|
['LLM_MODEL', this.config.get('LLM_MODEL', { infer: true })],
|
||||||
|
];
|
||||||
|
|
||||||
|
return entries.filter(([, value]) => !value).map(([key]) => key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AgentModule } from './agent/agent.module';
|
||||||
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { DeployRunsModule } from './deploy-runs/deploy-runs.module';
|
||||||
|
import { validateEnv } from './config/env.schema';
|
||||||
|
import { GiteaModule } from './integrations/gitea/gitea.module';
|
||||||
|
import { GiteaWebhookModule } from './webhooks/gitea/gitea-webhook.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { JenkinsModule } from './integrations/jenkins/jenkins.module';
|
||||||
|
import { ProcessDefinitionsModule } from './process-definitions/process-definitions.module';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { ProjectsModule } from './projects/projects.module';
|
||||||
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
import { WeComModule } from './notifications/wecom/wecom.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
cache: true,
|
||||||
|
expandVariables: true,
|
||||||
|
isGlobal: true,
|
||||||
|
validate: validateEnv,
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
AuditModule,
|
||||||
|
AgentModule,
|
||||||
|
JenkinsModule,
|
||||||
|
GiteaModule,
|
||||||
|
GiteaWebhookModule,
|
||||||
|
WeComModule,
|
||||||
|
HealthModule,
|
||||||
|
SettingsModule,
|
||||||
|
ProcessDefinitionsModule,
|
||||||
|
ProjectsModule,
|
||||||
|
DeployRunsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AuditRecordSummary, AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@ApiTags('audit')
|
||||||
|
@Controller('audit-logs')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly auditService: AuditService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ description: 'Recent sanitized audit records.' })
|
||||||
|
listRecentRecords(): AuditRecordSummary[] {
|
||||||
|
return this.auditService.getRecentRecords();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计记录必须先脱敏再暴露给前端或 Agent。
|
||||||
|
*/
|
||||||
|
describe('AuditService', () => {
|
||||||
|
it('stores recent audit records with sensitive values redacted', () => {
|
||||||
|
const service = new AuditService();
|
||||||
|
|
||||||
|
service.record({
|
||||||
|
action: 'SECRET_CHANGED',
|
||||||
|
resourceType: 'secret',
|
||||||
|
parameterDigest: {
|
||||||
|
token: 'abc123456789',
|
||||||
|
normal: 'visible',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [record] = service.getRecentRecords();
|
||||||
|
|
||||||
|
expect(record.action).toBe('SECRET_CHANGED');
|
||||||
|
expect(record.parameterDigest).toEqual({
|
||||||
|
token: '[REDACTED]',
|
||||||
|
normal: 'visible',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
|
|
||||||
|
export type AuditRecordInput = {
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId?: string;
|
||||||
|
actorId?: string;
|
||||||
|
actorName?: string;
|
||||||
|
before?: Record<string, unknown>;
|
||||||
|
after?: Record<string, unknown>;
|
||||||
|
parameterDigest?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditRecordSummary = AuditRecordInput & { createdAt: string };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
private readonly memoryRecords: AuditRecordSummary[] = [];
|
||||||
|
|
||||||
|
record(input: AuditRecordInput): void {
|
||||||
|
this.memoryRecords.push({
|
||||||
|
...input,
|
||||||
|
before: input.before ? redactSensitive(input.before) : undefined,
|
||||||
|
after: input.after ? redactSensitive(input.after) : undefined,
|
||||||
|
parameterDigest: input.parameterDigest
|
||||||
|
? redactSensitive(input.parameterDigest)
|
||||||
|
: undefined,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentRecords(): AuditRecordSummary[] {
|
||||||
|
return [...this.memoryRecords].slice(-50).reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type AppErrorCode =
|
||||||
|
| 'CONFIG_MISSING'
|
||||||
|
| 'INTEGRATION_UNAVAILABLE'
|
||||||
|
| 'INTEGRATION_REQUEST_FAILED'
|
||||||
|
| 'RESOURCE_NOT_FOUND'
|
||||||
|
| 'VALIDATION_FAILED'
|
||||||
|
| 'UNEXPECTED_ERROR';
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: AppErrorCode,
|
||||||
|
message: string,
|
||||||
|
public readonly statusCode = 500,
|
||||||
|
public readonly details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { AppError } from './app-error';
|
||||||
|
|
||||||
|
export class IntegrationUnavailableError extends AppError {
|
||||||
|
constructor(integration: string, missing: string[]) {
|
||||||
|
super(
|
||||||
|
'INTEGRATION_UNAVAILABLE',
|
||||||
|
`${integration} integration is not configured`,
|
||||||
|
503,
|
||||||
|
{ integration, missing },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IntegrationRequestError extends AppError {
|
||||||
|
constructor(
|
||||||
|
integration: string,
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super('INTEGRATION_REQUEST_FAILED', message, 502, {
|
||||||
|
integration,
|
||||||
|
...details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import { redactSensitive } from '../security/redact-sensitive';
|
||||||
|
import {
|
||||||
|
RequestWithHeaders,
|
||||||
|
ResponseWithHeaders,
|
||||||
|
resolveRequestId,
|
||||||
|
} from './request-id';
|
||||||
|
|
||||||
|
type JsonResponse = ResponseWithHeaders & {
|
||||||
|
status: (statusCode: number) => {
|
||||||
|
json: (body: unknown) => unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const httpContext = host.switchToHttp();
|
||||||
|
const request = httpContext.getRequest<RequestWithHeaders>();
|
||||||
|
const response = httpContext.getResponse<JsonResponse>();
|
||||||
|
const payload = this.toPayload(exception);
|
||||||
|
const requestId = resolveRequestId(request.headers);
|
||||||
|
|
||||||
|
response.setHeader('x-request-id', requestId);
|
||||||
|
|
||||||
|
response.status(payload.statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: payload.code,
|
||||||
|
message: payload.message,
|
||||||
|
details: redactSensitive(payload.details ?? {}),
|
||||||
|
},
|
||||||
|
requestId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPayload(exception: unknown): {
|
||||||
|
statusCode: number;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
if (exception instanceof AppError) {
|
||||||
|
return {
|
||||||
|
statusCode: exception.statusCode,
|
||||||
|
code: exception.code,
|
||||||
|
message: exception.message,
|
||||||
|
details: exception.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
return {
|
||||||
|
statusCode: exception.getStatus(),
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
message: exception.message,
|
||||||
|
details: { response: exception.getResponse() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof Error) {
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
code: 'UNEXPECTED_ERROR',
|
||||||
|
message: exception.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
code: 'UNEXPECTED_ERROR',
|
||||||
|
message: 'Unexpected error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
import {
|
||||||
|
RequestWithHeaders,
|
||||||
|
ResponseWithHeaders,
|
||||||
|
resolveRequestId,
|
||||||
|
} from './request-id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将所有成功响应收敛成前后端共享的 enterprise envelope。
|
||||||
|
*/
|
||||||
|
export type ApiSuccessEnvelope<T> = {
|
||||||
|
success: true;
|
||||||
|
data: T;
|
||||||
|
requestId: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiEnvelopeInterceptor<T>
|
||||||
|
implements NestInterceptor<T, ApiSuccessEnvelope<unknown>>
|
||||||
|
{
|
||||||
|
intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>,
|
||||||
|
): Observable<ApiSuccessEnvelope<unknown>> {
|
||||||
|
const httpContext = context.switchToHttp();
|
||||||
|
const request = httpContext.getRequest<RequestWithHeaders>();
|
||||||
|
const response = httpContext.getResponse<ResponseWithHeaders>();
|
||||||
|
const requestId = resolveRequestId(request.headers);
|
||||||
|
|
||||||
|
response.setHeader('x-request-id', requestId);
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((payload) => ({
|
||||||
|
success: true as const,
|
||||||
|
data: unwrapLegacyData(payload),
|
||||||
|
requestId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapLegacyData(payload: unknown): unknown {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(payload);
|
||||||
|
|
||||||
|
if (keys.length === 1 && keys[0] === 'data') {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一提取或生成请求 ID,供响应包裹、错误处理和后续审计链路复用。
|
||||||
|
*/
|
||||||
|
export type RequestHeaders = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
export type RequestWithHeaders = {
|
||||||
|
headers?: RequestHeaders;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResponseWithHeaders = {
|
||||||
|
setHeader: (name: string, value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveRequestId(headers?: RequestHeaders): string {
|
||||||
|
const rawRequestId = headers?.['x-request-id'];
|
||||||
|
const requestId = Array.isArray(rawRequestId) ? rawRequestId[0] : rawRequestId;
|
||||||
|
|
||||||
|
if (requestId?.trim()) {
|
||||||
|
return requestId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `req_${randomUUID()}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||||
|
import { ZodType, z } from 'zod';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Zod 做运行时入参校验,避免 DTO 只停留在 Swagger 文档层。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
||||||
|
constructor(private readonly schema: ZodType<T>) {}
|
||||||
|
|
||||||
|
transform(value: unknown): T {
|
||||||
|
const result = this.schema.safeParse(value);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'Request body validation failed',
|
||||||
|
400,
|
||||||
|
{
|
||||||
|
issues: result.error.issues.map((issue: z.core.$ZodIssue) => ({
|
||||||
|
path: issue.path.join('.'),
|
||||||
|
code: issue.code,
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const SENSITIVE_KEY_PATTERN =
|
||||||
|
/(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)/i;
|
||||||
|
|
||||||
|
export function redactSensitive<T>(value: T): T {
|
||||||
|
return redactValue(value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactValue(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => redactValue(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
||||||
|
key,
|
||||||
|
SENSITIVE_KEY_PATTERN.test(key) ? '[REDACTED]' : redactValue(entry),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && looksLikeSecret(value)) {
|
||||||
|
return '[REDACTED]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeSecret(value: string): boolean {
|
||||||
|
return value.length >= 24 && /^[A-Za-z0-9_./+=:-]+$/.test(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const optionalUrl = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.url()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('').transform(() => undefined));
|
||||||
|
|
||||||
|
const optionalString = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('').transform(() => undefined));
|
||||||
|
|
||||||
|
const booleanFromEnv = z.preprocess((value) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, z.boolean().default(false));
|
||||||
|
|
||||||
|
export const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(4300),
|
||||||
|
CORS_ORIGIN: optionalString,
|
||||||
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
DATABASE_URL: optionalString,
|
||||||
|
USE_DATABASE_READS: booleanFromEnv,
|
||||||
|
PRISMA_CONNECT_ON_BOOT: booleanFromEnv,
|
||||||
|
REDIS_URL: optionalString,
|
||||||
|
JENKINS_BASE_URL: optionalUrl,
|
||||||
|
JENKINS_USERNAME: optionalString,
|
||||||
|
JENKINS_API_TOKEN: optionalString,
|
||||||
|
GITEA_BASE_URL: optionalUrl,
|
||||||
|
GITEA_TOKEN: optionalString,
|
||||||
|
GITEA_WEBHOOK_SECRET: optionalString,
|
||||||
|
DEVOPS_PUBLIC_URL: optionalUrl,
|
||||||
|
NOTIFICATION_PROVIDER: optionalString,
|
||||||
|
NOTIFICATION_WEBHOOK_URL: optionalUrl,
|
||||||
|
FEISHU_WEBHOOK_URL: optionalUrl,
|
||||||
|
WECOM_WEBHOOK_URL: optionalUrl,
|
||||||
|
LLM_BASE_URL: optionalUrl,
|
||||||
|
LLM_API_KEY: optionalString,
|
||||||
|
LLM_MODEL: optionalString,
|
||||||
|
SECRET_ENCRYPTION_KEY: optionalString,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EnvConfig = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
export function validateEnv(config: Record<string, unknown>): EnvConfig {
|
||||||
|
const result = envSchema.safeParse(config);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const details = result.error.issues
|
||||||
|
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new Error(`Invalid environment configuration: ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { DeployEnvironment } from './deploy-run.types';
|
||||||
|
|
||||||
|
const optionalTrimmedString = (maxLength: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(maxLength)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('').transform(() => undefined));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建发布单的运行时校验规则;真实密钥和第三方参数不允许从前端传入。
|
||||||
|
*/
|
||||||
|
export const createDeployRunSchema = z.object({
|
||||||
|
projectKey: z.string().trim().min(1).max(80),
|
||||||
|
environment: z.enum(['test', 'production']),
|
||||||
|
ref: z.string().trim().min(1).max(200),
|
||||||
|
operator: z.string().trim().min(1).max(120),
|
||||||
|
remark: optionalTrimmedString(500),
|
||||||
|
idempotencyKey: optionalTrimmedString(260),
|
||||||
|
});
|
||||||
|
|
||||||
|
export class CreateDeployRunDto {
|
||||||
|
@ApiProperty({ example: 'access-manage' })
|
||||||
|
projectKey!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['test', 'production'], example: 'test' })
|
||||||
|
environment!: DeployEnvironment;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'develop' })
|
||||||
|
ref!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'operator@example.com' })
|
||||||
|
operator!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Manual test deploy before release.' })
|
||||||
|
remark?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'access-manage:test:develop:2026-06-11T10:00:00Z',
|
||||||
|
})
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
||||||
|
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
||||||
|
import { ProjectSummary } from '../projects/project.types';
|
||||||
|
import { DeployExecutionService } from './deploy-execution.service';
|
||||||
|
import { DeployRunRepository } from './deploy-run.repository';
|
||||||
|
import { CreateDeployRunInput, DeployRunSummary } from './deploy-run.types';
|
||||||
|
|
||||||
|
describe('DeployExecutionService', () => {
|
||||||
|
const project: ProjectSummary = {
|
||||||
|
id: 'project_001',
|
||||||
|
key: 'access-manage',
|
||||||
|
name: 'access-manage',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
displayName: '测试环境',
|
||||||
|
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const input: CreateDeployRunInput = {
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'test',
|
||||||
|
ref: 'develop',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRepository(): DeployRunRepository {
|
||||||
|
return new DeployRunRepository(
|
||||||
|
{
|
||||||
|
get: jest.fn(() => false),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(options: {
|
||||||
|
jenkinsHealth: ReturnType<JenkinsClient['healthSummary']>;
|
||||||
|
notificationStatus: 'sent' | 'skipped';
|
||||||
|
queueId?: string;
|
||||||
|
queueBuildNumber?: number;
|
||||||
|
buildResult?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE';
|
||||||
|
buildRunning?: boolean;
|
||||||
|
logText?: string;
|
||||||
|
}) {
|
||||||
|
const repository = createRepository();
|
||||||
|
const audit = {
|
||||||
|
record: jest.fn(),
|
||||||
|
} satisfies Pick<AuditService, 'record'>;
|
||||||
|
const notification = {
|
||||||
|
sendDeployRunEvent: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
status: options.notificationStatus,
|
||||||
|
channel: 'wecom' as const,
|
||||||
|
reason:
|
||||||
|
options.notificationStatus === 'skipped'
|
||||||
|
? 'Missing WECOM_WEBHOOK_URL'
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} satisfies Pick<DeployNotificationService, 'sendDeployRunEvent'>;
|
||||||
|
const jenkins = {
|
||||||
|
healthSummary: jest.fn(() => options.jenkinsHealth),
|
||||||
|
buildWithParameters: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
queueId: options.queueId ?? 'queue_001',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getQueueItem: jest.fn((queueId: string) =>
|
||||||
|
Promise.resolve({
|
||||||
|
queueId,
|
||||||
|
buildNumber: options.queueBuildNumber,
|
||||||
|
executableUrl: options.queueBuildNumber
|
||||||
|
? `https://jenkins.example/job/${options.queueBuildNumber}/`
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getBuildStatus: jest.fn((_jobPath: string, buildNumber: number) =>
|
||||||
|
Promise.resolve({
|
||||||
|
jobPath: 'my-project-dev/test-access-manage-develop',
|
||||||
|
buildNumber,
|
||||||
|
building: options.buildRunning ?? false,
|
||||||
|
result: options.buildResult,
|
||||||
|
url: `https://jenkins.example/job/${buildNumber}/`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getProgressiveText: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
text: options.logText ?? '',
|
||||||
|
nextStart: options.logText?.length ?? 0,
|
||||||
|
hasMore: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} satisfies Pick<
|
||||||
|
JenkinsClient,
|
||||||
|
| 'healthSummary'
|
||||||
|
| 'buildWithParameters'
|
||||||
|
| 'getQueueItem'
|
||||||
|
| 'getBuildStatus'
|
||||||
|
| 'getProgressiveText'
|
||||||
|
>;
|
||||||
|
const service = new DeployExecutionService(
|
||||||
|
repository,
|
||||||
|
audit as unknown as AuditService,
|
||||||
|
notification as unknown as DeployNotificationService,
|
||||||
|
jenkins as unknown as JenkinsClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audit,
|
||||||
|
jenkins,
|
||||||
|
notification,
|
||||||
|
repository,
|
||||||
|
service,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRun(
|
||||||
|
repository: DeployRunRepository,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): DeployRunSummary {
|
||||||
|
return repository.create({
|
||||||
|
...input,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(run: DeployRunSummary, bpmnNodeId: string) {
|
||||||
|
return run.steps.find((item) => item.bpmnNodeId === bpmnNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('completes a dry-run release when Jenkins is not configured', async () => {
|
||||||
|
const { audit, jenkins, repository, service } = createService({
|
||||||
|
jenkinsHealth: {
|
||||||
|
status: 'not_configured',
|
||||||
|
message: 'Missing JENKINS_API_TOKEN',
|
||||||
|
},
|
||||||
|
notificationStatus: 'skipped',
|
||||||
|
});
|
||||||
|
const run = createRun(repository, 'run_dry_001');
|
||||||
|
|
||||||
|
const result = await service.execute(run, project);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.jenkinsQueueId).toBeUndefined();
|
||||||
|
expect(step(result, 'gitea-check')?.status).toBe('success');
|
||||||
|
expect(step(result, 'jenkins-build')?.status).toBe('success');
|
||||||
|
expect(step(result, 'health-check')?.status).toBe('success');
|
||||||
|
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
|
||||||
|
expect(step(result, 'finish-release')?.status).toBe('success');
|
||||||
|
expect(jenkins.buildWithParameters).not.toHaveBeenCalled();
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'JENKINS_DRY_RUN_COMPLETED' }),
|
||||||
|
);
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers Jenkins and keeps the run queued until status polling completes', async () => {
|
||||||
|
const { audit, jenkins, repository, service } = createService({
|
||||||
|
jenkinsHealth: { status: 'ok' },
|
||||||
|
notificationStatus: 'sent',
|
||||||
|
queueId: 'jenkins_queue_42',
|
||||||
|
});
|
||||||
|
const run = createRun(repository, 'run_queue_001');
|
||||||
|
|
||||||
|
const result = await service.execute(run, project);
|
||||||
|
|
||||||
|
expect(result.status).toBe('queued');
|
||||||
|
expect(result.jenkinsQueueId).toBe('jenkins_queue_42');
|
||||||
|
expect(step(result, 'jenkins-build')?.status).toBe('queued');
|
||||||
|
expect(step(result, 'health-check')?.status).toBe('pending');
|
||||||
|
expect(step(result, 'notify-wecom')?.status).toBe('pending');
|
||||||
|
expect(jenkins.buildWithParameters).toHaveBeenCalledWith(
|
||||||
|
'my-project-dev/test-access-manage-develop',
|
||||||
|
expect.objectContaining({
|
||||||
|
DEPLOY_RUN_ID: 'run_queue_001',
|
||||||
|
DEPLOY_ENVIRONMENT: 'test',
|
||||||
|
GIT_REF: 'develop',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'JENKINS_BUILD_TRIGGERED',
|
||||||
|
after: expect.objectContaining({
|
||||||
|
queueId: 'jenkins_queue_42',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(audit.record).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs a queued Jenkins run to success', async () => {
|
||||||
|
const { audit, repository, service } = createService({
|
||||||
|
jenkinsHealth: { status: 'ok' },
|
||||||
|
notificationStatus: 'sent',
|
||||||
|
queueId: 'queue_success',
|
||||||
|
queueBuildNumber: 18,
|
||||||
|
buildResult: 'SUCCESS',
|
||||||
|
});
|
||||||
|
const run = await service.execute(createRun(repository, 'run_success_001'), project);
|
||||||
|
|
||||||
|
const result = await service.syncJenkinsRun(run, project);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.jenkinsBuildNumber).toBe(18);
|
||||||
|
expect(step(result, 'jenkins-build')?.status).toBe('success');
|
||||||
|
expect(step(result, 'health-check')?.status).toBe('success');
|
||||||
|
expect(step(result, 'notify-wecom')?.status).toBe('success');
|
||||||
|
expect(step(result, 'finish-release')?.status).toBe('success');
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'JENKINS_BUILD_STARTED' }),
|
||||||
|
);
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs a queued Jenkins run to failure with a log excerpt', async () => {
|
||||||
|
const { audit, repository, service } = createService({
|
||||||
|
jenkinsHealth: { status: 'ok' },
|
||||||
|
notificationStatus: 'skipped',
|
||||||
|
queueId: 'queue_failure',
|
||||||
|
queueBuildNumber: 19,
|
||||||
|
buildResult: 'FAILURE',
|
||||||
|
logText: ['install ok', 'build failed', 'token=secret-value'].join('\n'),
|
||||||
|
});
|
||||||
|
const run = await service.execute(createRun(repository, 'run_failure_001'), project);
|
||||||
|
|
||||||
|
const result = await service.syncJenkinsRun(run, project);
|
||||||
|
|
||||||
|
expect(result.status).toBe('failed');
|
||||||
|
expect(result.failureSummary).toBe('Jenkins build #19 FAILURE');
|
||||||
|
expect(step(result, 'jenkins-build')?.status).toBe('failed');
|
||||||
|
expect(step(result, 'jenkins-build')?.errorSummary).toBe(
|
||||||
|
'Jenkins build #19 FAILURE',
|
||||||
|
);
|
||||||
|
expect(step(result, 'jenkins-build')?.logExcerpt).toContain('build failed');
|
||||||
|
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'DEPLOY_FAILED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
|
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
||||||
|
import { JenkinsBuildStatus } from '../integrations/jenkins/jenkins.types';
|
||||||
|
import {
|
||||||
|
DeployNotificationEvent,
|
||||||
|
DeployNotificationService,
|
||||||
|
} from '../notifications/wecom/deploy-notification.service';
|
||||||
|
import {
|
||||||
|
ProjectEnvironmentSummary,
|
||||||
|
ProjectSummary,
|
||||||
|
} from '../projects/project.types';
|
||||||
|
import { DeployRunRepository } from './deploy-run.repository';
|
||||||
|
import { DeployRunStepStatus, DeployRunSummary } from './deploy-run.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeployExecutionService {
|
||||||
|
constructor(
|
||||||
|
private readonly deployRunRepository: DeployRunRepository,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
private readonly deployNotificationService: DeployNotificationService,
|
||||||
|
private readonly jenkinsClient: JenkinsClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
project: ProjectSummary,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
if (run.status !== 'pending') {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentRun = this.requireRun(
|
||||||
|
run.id,
|
||||||
|
this.deployRunRepository.updateMemoryRun(run.id, { status: 'running' }),
|
||||||
|
);
|
||||||
|
let currentStep: string | undefined;
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_EXECUTION_STARTED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: currentRun.id,
|
||||||
|
actorName: currentRun.operator,
|
||||||
|
after: {
|
||||||
|
projectKey: currentRun.projectKey,
|
||||||
|
environment: currentRun.environment,
|
||||||
|
ref: currentRun.ref,
|
||||||
|
status: currentRun.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentStep = 'gitea-check';
|
||||||
|
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
|
||||||
|
|
||||||
|
currentStep = 'jenkins-build';
|
||||||
|
const jenkinsResult = await this.advanceJenkinsBuild(currentRun, project);
|
||||||
|
currentRun = jenkinsResult.run;
|
||||||
|
|
||||||
|
if (!jenkinsResult.shouldContinue) {
|
||||||
|
await this.recordNotificationResult(currentRun, 'queued');
|
||||||
|
return currentRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStep = 'health-check';
|
||||||
|
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
|
||||||
|
|
||||||
|
currentStep = 'notify-wecom';
|
||||||
|
currentRun = await this.advanceNotification(currentRun, 'succeeded');
|
||||||
|
|
||||||
|
currentStep = 'finish-release';
|
||||||
|
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
|
||||||
|
currentRun = this.requireRun(
|
||||||
|
currentRun.id,
|
||||||
|
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||||
|
status: 'success',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_SUCCEEDED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: currentRun.id,
|
||||||
|
actorName: currentRun.operator,
|
||||||
|
after: {
|
||||||
|
status: currentRun.status,
|
||||||
|
jenkinsQueueId: currentRun.jenkinsQueueId,
|
||||||
|
jenkinsBuildNumber: currentRun.jenkinsBuildNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentRun;
|
||||||
|
} catch (error) {
|
||||||
|
const failureSummary = this.toFailureSummary(error);
|
||||||
|
|
||||||
|
if (currentStep) {
|
||||||
|
currentRun =
|
||||||
|
this.deployRunRepository.updateMemoryStep(
|
||||||
|
currentRun.id,
|
||||||
|
currentStep,
|
||||||
|
{
|
||||||
|
status: 'failed',
|
||||||
|
errorSummary: failureSummary,
|
||||||
|
},
|
||||||
|
) ?? currentRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRun =
|
||||||
|
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||||
|
status: 'failed',
|
||||||
|
failureSummary,
|
||||||
|
}) ?? currentRun;
|
||||||
|
|
||||||
|
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_FAILED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: currentRun.id,
|
||||||
|
actorName: currentRun.operator,
|
||||||
|
after: {
|
||||||
|
status: currentRun.status,
|
||||||
|
failedStep: currentStep,
|
||||||
|
failureSummary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncJenkinsRun(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
project: ProjectSummary,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
if (['success', 'failed', 'canceled'].includes(run.status)) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!run.jenkinsQueueId && !run.jenkinsBuildNumber) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = this.findEnvironment(project, run);
|
||||||
|
let currentRun = run;
|
||||||
|
let buildNumber = currentRun.jenkinsBuildNumber;
|
||||||
|
|
||||||
|
if (!buildNumber && currentRun.jenkinsQueueId) {
|
||||||
|
const queueItem = await this.jenkinsClient.getQueueItem(
|
||||||
|
currentRun.jenkinsQueueId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!queueItem.buildNumber) {
|
||||||
|
return this.requireRun(
|
||||||
|
currentRun.id,
|
||||||
|
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||||
|
status: 'queued',
|
||||||
|
jenkinsQueueId: queueItem.queueId,
|
||||||
|
jenkinsBuildUrl: queueItem.executableUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildNumber = queueItem.buildNumber;
|
||||||
|
currentRun = this.requireRun(
|
||||||
|
currentRun.id,
|
||||||
|
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||||
|
status: 'running',
|
||||||
|
jenkinsQueueId: queueItem.queueId,
|
||||||
|
jenkinsBuildNumber: queueItem.buildNumber,
|
||||||
|
jenkinsBuildUrl: queueItem.executableUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
currentRun = this.markStep(
|
||||||
|
currentRun.id,
|
||||||
|
'jenkins-build',
|
||||||
|
'running',
|
||||||
|
`Jenkins build #${queueItem.buildNumber} is running.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'JENKINS_BUILD_STARTED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: currentRun.id,
|
||||||
|
actorName: currentRun.operator,
|
||||||
|
after: {
|
||||||
|
queueId: queueItem.queueId,
|
||||||
|
buildNumber: queueItem.buildNumber,
|
||||||
|
buildUrl: queueItem.executableUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!buildNumber) {
|
||||||
|
return currentRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildStatus = await this.jenkinsClient.getBuildStatus(
|
||||||
|
environment.jenkinsJobPath,
|
||||||
|
buildNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
currentRun = this.requireRun(
|
||||||
|
currentRun.id,
|
||||||
|
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||||
|
status: buildStatus.building || !buildStatus.result ? 'running' : currentRun.status,
|
||||||
|
jenkinsBuildNumber: buildStatus.buildNumber,
|
||||||
|
jenkinsBuildUrl: buildStatus.url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (buildStatus.building || !buildStatus.result) {
|
||||||
|
return this.markStep(
|
||||||
|
currentRun.id,
|
||||||
|
'jenkins-build',
|
||||||
|
'running',
|
||||||
|
`Jenkins build #${buildStatus.buildNumber} is running.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildStatus.result === 'SUCCESS') {
|
||||||
|
return this.completeSuccessfulJenkinsRun(currentRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.failJenkinsRun(
|
||||||
|
currentRun,
|
||||||
|
buildStatus.result,
|
||||||
|
environment.jenkinsJobPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async advanceJenkinsBuild(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
project: ProjectSummary,
|
||||||
|
): Promise<{ run: DeployRunSummary; shouldContinue: boolean }> {
|
||||||
|
const environment = this.findEnvironment(project, run);
|
||||||
|
const health = this.jenkinsClient.healthSummary();
|
||||||
|
|
||||||
|
if (health.status === 'not_configured') {
|
||||||
|
const reason =
|
||||||
|
health.message ??
|
||||||
|
'Jenkins is not configured; simulated build completed.';
|
||||||
|
const currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'JENKINS_DRY_RUN_COMPLETED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
actorName: run.operator,
|
||||||
|
after: {
|
||||||
|
mode: 'dry-run',
|
||||||
|
reason,
|
||||||
|
jobPath: environment.jenkinsJobPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { run: currentRun, shouldContinue: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!environment.jenkinsJobPath) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
`Project ${project.key} ${run.environment} Jenkins job path is empty`,
|
||||||
|
400,
|
||||||
|
{ projectKey: project.key, environment: run.environment },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.markStep(run.id, 'jenkins-build', 'running');
|
||||||
|
const queueItem = await this.jenkinsClient.buildWithParameters(
|
||||||
|
environment.jenkinsJobPath,
|
||||||
|
this.buildParameters(run),
|
||||||
|
);
|
||||||
|
const nextStatus = queueItem.buildNumber ? 'running' : 'queued';
|
||||||
|
let currentRun = this.requireRun(
|
||||||
|
run.id,
|
||||||
|
this.deployRunRepository.updateMemoryRun(run.id, {
|
||||||
|
status: nextStatus,
|
||||||
|
jenkinsQueueId: queueItem.queueId,
|
||||||
|
jenkinsBuildNumber: queueItem.buildNumber,
|
||||||
|
jenkinsBuildUrl: queueItem.executableUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
currentRun = this.markStep(
|
||||||
|
run.id,
|
||||||
|
'jenkins-build',
|
||||||
|
nextStatus,
|
||||||
|
queueItem.buildNumber
|
||||||
|
? `Jenkins build #${queueItem.buildNumber} is running.`
|
||||||
|
: `Jenkins queue ${queueItem.queueId} accepted.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'JENKINS_BUILD_TRIGGERED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
actorName: run.operator,
|
||||||
|
after: {
|
||||||
|
jobPath: environment.jenkinsJobPath,
|
||||||
|
queueId: queueItem.queueId,
|
||||||
|
buildNumber: queueItem.buildNumber,
|
||||||
|
},
|
||||||
|
parameterDigest: this.buildParameters(run),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { run: currentRun, shouldContinue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async advanceNotification(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
event: DeployNotificationEvent,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
this.markStep(run.id, 'notify-wecom', 'running');
|
||||||
|
const notification =
|
||||||
|
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||||
|
const stepStatus =
|
||||||
|
notification.status === 'sent'
|
||||||
|
? 'success'
|
||||||
|
: notification.status === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: 'skipped';
|
||||||
|
const currentRun = this.completeStep(
|
||||||
|
run.id,
|
||||||
|
'notify-wecom',
|
||||||
|
stepStatus,
|
||||||
|
notification.status === 'sent' ? undefined : notification.reason,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
actorName: run.operator,
|
||||||
|
after: {
|
||||||
|
event,
|
||||||
|
channel: notification.channel,
|
||||||
|
reason: notification.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeSuccessfulJenkinsRun(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
let currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
|
||||||
|
currentRun = this.completeStep(currentRun.id, 'health-check', 'success');
|
||||||
|
currentRun = await this.advanceNotification(currentRun, 'succeeded');
|
||||||
|
currentRun = this.completeStep(currentRun.id, 'finish-release', 'success');
|
||||||
|
currentRun = this.requireRun(
|
||||||
|
currentRun.id,
|
||||||
|
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||||
|
status: 'success',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_SUCCEEDED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: currentRun.id,
|
||||||
|
actorName: currentRun.operator,
|
||||||
|
after: {
|
||||||
|
status: currentRun.status,
|
||||||
|
jenkinsQueueId: currentRun.jenkinsQueueId,
|
||||||
|
jenkinsBuildNumber: currentRun.jenkinsBuildNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async failJenkinsRun(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
result: Exclude<NonNullable<JenkinsBuildStatus['result']>, 'SUCCESS'>,
|
||||||
|
jobPath: string,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
|
||||||
|
const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`;
|
||||||
|
let currentRun =
|
||||||
|
this.deployRunRepository.updateMemoryStep(run.id, 'jenkins-build', {
|
||||||
|
status: 'failed',
|
||||||
|
errorSummary: failureSummary,
|
||||||
|
logExcerpt,
|
||||||
|
}) ?? run;
|
||||||
|
currentRun =
|
||||||
|
this.deployRunRepository.updateMemoryRun(currentRun.id, {
|
||||||
|
status: 'failed',
|
||||||
|
failureSummary,
|
||||||
|
}) ?? currentRun;
|
||||||
|
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_FAILED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: currentRun.id,
|
||||||
|
actorName: currentRun.operator,
|
||||||
|
after: {
|
||||||
|
status: currentRun.status,
|
||||||
|
failedStep: 'jenkins-build',
|
||||||
|
failureSummary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readFailureLogExcerpt(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
jobPath: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!run.jenkinsBuildNumber) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const log = await this.jenkinsClient.getProgressiveText(
|
||||||
|
jobPath,
|
||||||
|
run.jenkinsBuildNumber,
|
||||||
|
);
|
||||||
|
const logExcerpt = log.text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(-24)
|
||||||
|
.join('\n')
|
||||||
|
.slice(-4000);
|
||||||
|
|
||||||
|
return redactSensitive({ logExcerpt }).logExcerpt;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 buildParameters(run: DeployRunSummary): Record<string, string> {
|
||||||
|
return {
|
||||||
|
DEPLOY_RUN_ID: run.id,
|
||||||
|
PROJECT_KEY: run.projectKey,
|
||||||
|
DEPLOY_ENVIRONMENT: run.environment,
|
||||||
|
GIT_REF: run.ref,
|
||||||
|
OPERATOR: run.operator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeStep(
|
||||||
|
runId: string,
|
||||||
|
bpmnNodeId: string,
|
||||||
|
status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>,
|
||||||
|
errorSummary?: string,
|
||||||
|
): DeployRunSummary {
|
||||||
|
return this.requireRun(
|
||||||
|
runId,
|
||||||
|
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
|
||||||
|
status,
|
||||||
|
errorSummary,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private markStep(
|
||||||
|
runId: string,
|
||||||
|
bpmnNodeId: string,
|
||||||
|
status: Extract<DeployRunStepStatus, 'queued' | 'running'>,
|
||||||
|
message?: string,
|
||||||
|
): DeployRunSummary {
|
||||||
|
return this.requireRun(
|
||||||
|
runId,
|
||||||
|
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordNotificationResult(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
event: DeployNotificationEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
const notification =
|
||||||
|
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
actorName: run.operator,
|
||||||
|
after: {
|
||||||
|
event,
|
||||||
|
channel: notification.channel,
|
||||||
|
reason: notification.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toFailureSummary(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return redactSensitive({ message: error.message }).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown deploy execution error';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { seedDeployRuns } from './deploy-run.seed';
|
||||||
|
import {
|
||||||
|
CreateDeployRunInput,
|
||||||
|
DeployRunStepStatus,
|
||||||
|
DeployRunSummary,
|
||||||
|
DeployRunStatus,
|
||||||
|
} from './deploy-run.types';
|
||||||
|
|
||||||
|
type DeployRunMemoryPatch = {
|
||||||
|
status?: DeployRunStatus;
|
||||||
|
jenkinsQueueId?: string;
|
||||||
|
jenkinsBuildNumber?: number;
|
||||||
|
jenkinsBuildUrl?: string;
|
||||||
|
failureSummary?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeployRunStepMemoryPatch = {
|
||||||
|
status?: DeployRunStepStatus;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
message?: string;
|
||||||
|
errorSummary?: string;
|
||||||
|
logExcerpt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminalRunStatuses = new Set<DeployRunStatus>([
|
||||||
|
'success',
|
||||||
|
'failed',
|
||||||
|
'canceled',
|
||||||
|
]);
|
||||||
|
const terminalStepStatuses = new Set<DeployRunStepStatus>([
|
||||||
|
'success',
|
||||||
|
'failed',
|
||||||
|
'canceled',
|
||||||
|
'skipped',
|
||||||
|
]);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeployRunRepository {
|
||||||
|
private readonly memoryRuns = [...seedDeployRuns];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService<EnvConfig, true>,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findMany(): Promise<DeployRunSummary[]> {
|
||||||
|
if (!this.useDatabaseReads()) {
|
||||||
|
return [...this.memoryRuns].sort((left, right) =>
|
||||||
|
right.createdAt.localeCompare(left.createdAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runs = await this.prisma.deployRun.findMany({
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
environment: true,
|
||||||
|
steps: {
|
||||||
|
orderBy: {
|
||||||
|
order: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
return runs.map((run) => ({
|
||||||
|
id: run.id,
|
||||||
|
projectKey: run.project.key,
|
||||||
|
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||||
|
ref: run.ref,
|
||||||
|
status: run.status.toLowerCase() as DeployRunStatus,
|
||||||
|
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||||
|
operator: run.operator,
|
||||||
|
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||||
|
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||||
|
failureSummary: run.failureSummary ?? undefined,
|
||||||
|
startedAt: run.startedAt?.toISOString(),
|
||||||
|
finishedAt: run.finishedAt?.toISOString(),
|
||||||
|
createdAt: run.createdAt.toISOString(),
|
||||||
|
steps: run.steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
bpmnNodeId: step.bpmnNodeId,
|
||||||
|
name: step.name,
|
||||||
|
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||||
|
order: step.order,
|
||||||
|
startedAt: step.startedAt?.toISOString(),
|
||||||
|
finishedAt: step.finishedAt?.toISOString(),
|
||||||
|
errorSummary: step.errorSummary ?? undefined,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<DeployRunSummary | null> {
|
||||||
|
if (!this.useDatabaseReads()) {
|
||||||
|
return this.memoryRuns.find((run) => run.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = await this.prisma.deployRun.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
environment: true,
|
||||||
|
steps: {
|
||||||
|
orderBy: {
|
||||||
|
order: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
projectKey: run.project.key,
|
||||||
|
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||||
|
ref: run.ref,
|
||||||
|
status: run.status.toLowerCase() as DeployRunStatus,
|
||||||
|
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||||
|
operator: run.operator,
|
||||||
|
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||||
|
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||||
|
failureSummary: run.failureSummary ?? undefined,
|
||||||
|
startedAt: run.startedAt?.toISOString(),
|
||||||
|
finishedAt: run.finishedAt?.toISOString(),
|
||||||
|
createdAt: run.createdAt.toISOString(),
|
||||||
|
steps: run.steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
bpmnNodeId: step.bpmnNodeId,
|
||||||
|
name: step.name,
|
||||||
|
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||||
|
order: step.order,
|
||||||
|
startedAt: step.startedAt?.toISOString(),
|
||||||
|
finishedAt: step.finishedAt?.toISOString(),
|
||||||
|
errorSummary: step.errorSummary ?? undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: CreateDeployRunInput): DeployRunSummary {
|
||||||
|
const idempotencyKey =
|
||||||
|
input.idempotencyKey ??
|
||||||
|
[
|
||||||
|
input.projectKey,
|
||||||
|
input.environment,
|
||||||
|
input.ref,
|
||||||
|
input.operator,
|
||||||
|
new Date().toISOString(),
|
||||||
|
].join(':');
|
||||||
|
const existing = this.memoryRuns.find((run) => run.id === idempotencyKey);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const run: DeployRunSummary = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
projectKey: input.projectKey,
|
||||||
|
environment: input.environment,
|
||||||
|
ref: input.ref,
|
||||||
|
status: 'pending',
|
||||||
|
trigger: input.trigger ?? 'manual',
|
||||||
|
operator: input.operator,
|
||||||
|
createdAt: now,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: `${idempotencyKey}:start-release`,
|
||||||
|
bpmnNodeId: 'start-release',
|
||||||
|
name: '提交发布单',
|
||||||
|
status: 'success',
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${idempotencyKey}:gitea-check`,
|
||||||
|
bpmnNodeId: 'gitea-check',
|
||||||
|
name: '校验 Gitea ref',
|
||||||
|
status: 'pending',
|
||||||
|
order: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${idempotencyKey}:jenkins-build`,
|
||||||
|
bpmnNodeId: 'jenkins-build',
|
||||||
|
name: '触发 Jenkins 构建',
|
||||||
|
status: 'pending',
|
||||||
|
order: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${idempotencyKey}:health-check`,
|
||||||
|
bpmnNodeId: 'health-check',
|
||||||
|
name: '环境健康检查',
|
||||||
|
status: 'pending',
|
||||||
|
order: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${idempotencyKey}:notify-wecom`,
|
||||||
|
bpmnNodeId: 'notify-wecom',
|
||||||
|
name: '发送通知',
|
||||||
|
status: 'pending',
|
||||||
|
order: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${idempotencyKey}:finish-release`,
|
||||||
|
bpmnNodeId: 'finish-release',
|
||||||
|
name: '归档审计',
|
||||||
|
status: 'pending',
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.memoryRuns.unshift(run);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMemoryStatus(
|
||||||
|
id: string,
|
||||||
|
status: DeployRunStatus,
|
||||||
|
): DeployRunSummary | null {
|
||||||
|
return this.updateMemoryRun(id, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMemoryRun(
|
||||||
|
id: string,
|
||||||
|
patch: DeployRunMemoryPatch,
|
||||||
|
): DeployRunSummary | null {
|
||||||
|
const run = this.memoryRuns.find((item) => item.id === id);
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (patch.status) {
|
||||||
|
run.status = patch.status;
|
||||||
|
|
||||||
|
if (patch.status !== 'pending' && !run.startedAt) {
|
||||||
|
run.startedAt = patch.startedAt ?? now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminalRunStatuses.has(patch.status)) {
|
||||||
|
run.finishedAt = patch.finishedAt ?? now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jenkinsQueueId' in patch) {
|
||||||
|
run.jenkinsQueueId = patch.jenkinsQueueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jenkinsBuildNumber' in patch) {
|
||||||
|
run.jenkinsBuildNumber = patch.jenkinsBuildNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jenkinsBuildUrl' in patch) {
|
||||||
|
run.jenkinsBuildUrl = patch.jenkinsBuildUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('failureSummary' in patch) {
|
||||||
|
run.failureSummary = patch.failureSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('startedAt' in patch) {
|
||||||
|
run.startedAt = patch.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('finishedAt' in patch) {
|
||||||
|
run.finishedAt = patch.finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMemoryStep(
|
||||||
|
runId: string,
|
||||||
|
bpmnNodeId: string,
|
||||||
|
patch: DeployRunStepMemoryPatch,
|
||||||
|
): DeployRunSummary | null {
|
||||||
|
const run = this.memoryRuns.find((item) => item.id === runId);
|
||||||
|
const step = run?.steps.find((item) => item.bpmnNodeId === bpmnNodeId);
|
||||||
|
|
||||||
|
if (!run || !step) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (patch.status) {
|
||||||
|
step.status = patch.status;
|
||||||
|
|
||||||
|
if (patch.status !== 'pending' && !step.startedAt) {
|
||||||
|
step.startedAt = patch.startedAt ?? now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminalStepStatuses.has(patch.status)) {
|
||||||
|
step.finishedAt = patch.finishedAt ?? now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('startedAt' in patch) {
|
||||||
|
step.startedAt = patch.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('finishedAt' in patch) {
|
||||||
|
step.finishedAt = patch.finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('message' in patch) {
|
||||||
|
step.message = patch.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('errorSummary' in patch) {
|
||||||
|
step.errorSummary = patch.errorSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('logExcerpt' in patch) {
|
||||||
|
step.logExcerpt = patch.logExcerpt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private useDatabaseReads(): boolean {
|
||||||
|
return (
|
||||||
|
this.config.get('USE_DATABASE_READS', { infer: true }) &&
|
||||||
|
Boolean(this.config.get('DATABASE_URL', { infer: true }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { DeployRunSummary } from './deploy-run.types';
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
export const seedDeployRuns: DeployRunSummary[] = [
|
||||||
|
{
|
||||||
|
id: 'run_seed_access_manage_test_001',
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'test',
|
||||||
|
ref: 'develop',
|
||||||
|
status: 'pending',
|
||||||
|
trigger: 'manual',
|
||||||
|
operator: 'system-seed',
|
||||||
|
createdAt: now,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step_seed_access_manage_start',
|
||||||
|
bpmnNodeId: 'start-release',
|
||||||
|
name: '提交发布单',
|
||||||
|
status: 'success',
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_seed_access_manage_gitea',
|
||||||
|
bpmnNodeId: 'gitea-check',
|
||||||
|
name: '校验 Gitea ref',
|
||||||
|
status: 'pending',
|
||||||
|
order: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_seed_access_manage_jenkins',
|
||||||
|
bpmnNodeId: 'jenkins-build',
|
||||||
|
name: '触发 Jenkins 构建',
|
||||||
|
status: 'pending',
|
||||||
|
order: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_seed_access_manage_health',
|
||||||
|
bpmnNodeId: 'health-check',
|
||||||
|
name: '环境健康检查',
|
||||||
|
status: 'pending',
|
||||||
|
order: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_seed_access_manage_notify',
|
||||||
|
bpmnNodeId: 'notify-wecom',
|
||||||
|
name: '发送通知',
|
||||||
|
status: 'pending',
|
||||||
|
order: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_seed_access_manage_archive',
|
||||||
|
bpmnNodeId: 'finish-release',
|
||||||
|
name: '归档审计',
|
||||||
|
status: 'pending',
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export type DeployEnvironment = 'test' | 'production';
|
||||||
|
export type DeployRunStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'canceled';
|
||||||
|
export type DeployRunStepStatus = DeployRunStatus | 'skipped';
|
||||||
|
|
||||||
|
export type DeployRunStepSummary = {
|
||||||
|
id: string;
|
||||||
|
bpmnNodeId: string;
|
||||||
|
name: string;
|
||||||
|
status: DeployRunStepStatus;
|
||||||
|
order: number;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
message?: string;
|
||||||
|
errorSummary?: string;
|
||||||
|
logExcerpt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeployRunSummary = {
|
||||||
|
id: string;
|
||||||
|
projectKey: string;
|
||||||
|
environment: DeployEnvironment;
|
||||||
|
ref: string;
|
||||||
|
status: DeployRunStatus;
|
||||||
|
trigger: 'manual' | 'gitea_webhook' | 'retry';
|
||||||
|
operator: string;
|
||||||
|
jenkinsQueueId?: string;
|
||||||
|
jenkinsBuildNumber?: number;
|
||||||
|
jenkinsBuildUrl?: string;
|
||||||
|
failureSummary?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
steps: DeployRunStepSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateDeployRunInput = {
|
||||||
|
projectKey: string;
|
||||||
|
environment: DeployEnvironment;
|
||||||
|
ref: string;
|
||||||
|
operator: string;
|
||||||
|
remark?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
trigger?: DeployRunSummary['trigger'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeployRunJenkinsSyncError = {
|
||||||
|
id: string;
|
||||||
|
projectKey: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeployRunJenkinsSyncSummary = {
|
||||||
|
checkedAt: string;
|
||||||
|
totalCandidates: number;
|
||||||
|
syncedRuns: DeployRunSummary[];
|
||||||
|
failedRuns: DeployRunJenkinsSyncError[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBody,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
|
import {
|
||||||
|
CreateDeployRunDto,
|
||||||
|
createDeployRunSchema,
|
||||||
|
} from './create-deploy-run.dto';
|
||||||
|
import {
|
||||||
|
CreateDeployRunInput,
|
||||||
|
DeployRunJenkinsSyncSummary,
|
||||||
|
DeployRunSummary,
|
||||||
|
} from './deploy-run.types';
|
||||||
|
import { DeployRunsService } from './deploy-runs.service';
|
||||||
|
|
||||||
|
@ApiTags('deploy-runs')
|
||||||
|
@Controller('deploy-runs')
|
||||||
|
export class DeployRunsController {
|
||||||
|
constructor(private readonly deployRunsService: DeployRunsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ description: 'Latest deploy runs.' })
|
||||||
|
async listRuns(): Promise<DeployRunSummary[]> {
|
||||||
|
return this.deployRunsService.listRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOkResponse({ description: 'Deploy run detail.' })
|
||||||
|
async getRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.getRun(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiBody({ type: CreateDeployRunDto })
|
||||||
|
@ApiCreatedResponse({ description: 'Create a deploy run draft.' })
|
||||||
|
async createRun(
|
||||||
|
@Body(new ZodValidationPipe(createDeployRunSchema)) body: CreateDeployRunInput,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.createRun(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@ApiOkResponse({ description: 'Cancel a deploy run.' })
|
||||||
|
async cancelRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.cancelRun(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/retry')
|
||||||
|
@ApiCreatedResponse({ description: 'Retry a deploy run.' })
|
||||||
|
async retryRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.retryRun(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync-jenkins')
|
||||||
|
@ApiOkResponse({ description: 'Synchronize all queued/running Jenkins runs.' })
|
||||||
|
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
|
||||||
|
return this.deployRunsService.syncJenkinsRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/sync-jenkins')
|
||||||
|
@ApiOkResponse({ description: 'Synchronize Jenkins queue/build state.' })
|
||||||
|
async syncJenkinsRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.syncJenkinsRun(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
|
||||||
|
import { WeComModule } from '../notifications/wecom/wecom.module';
|
||||||
|
import { ProjectsModule } from '../projects/projects.module';
|
||||||
|
import { DeployExecutionService } from './deploy-execution.service';
|
||||||
|
import { DeployRunRepository } from './deploy-run.repository';
|
||||||
|
import { DeployRunsController } from './deploy-runs.controller';
|
||||||
|
import { DeployRunsService } from './deploy-runs.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
|
||||||
|
controllers: [DeployRunsController],
|
||||||
|
providers: [DeployExecutionService, DeployRunRepository, DeployRunsService],
|
||||||
|
exports: [DeployExecutionService, DeployRunRepository, DeployRunsService],
|
||||||
|
})
|
||||||
|
export class DeployRunsModule {}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
||||||
|
import { ProjectsService } from '../projects/projects.service';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 覆盖发布单第一阶段内存实现,后续接真实数据库时保持服务契约不变。
|
||||||
|
*/
|
||||||
|
describe('DeployRunsService', () => {
|
||||||
|
const baseRun: DeployRunSummary = {
|
||||||
|
id: 'run_001',
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'test',
|
||||||
|
ref: 'develop',
|
||||||
|
status: 'pending',
|
||||||
|
trigger: 'manual',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
createdAt: '2026-06-11T00:00:00.000Z',
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createService(options?: { runs?: DeployRunSummary[] }) {
|
||||||
|
const runs = options?.runs ?? [baseRun];
|
||||||
|
const repository = {
|
||||||
|
findMany: jest.fn(() => Promise.resolve(runs)),
|
||||||
|
findById: jest.fn((id: string) =>
|
||||||
|
Promise.resolve(runs.find((run) => run.id === id) ?? null),
|
||||||
|
),
|
||||||
|
create: jest.fn((input: CreateDeployRunInput) => ({
|
||||||
|
...baseRun,
|
||||||
|
id: input.idempotencyKey ?? 'generated_run',
|
||||||
|
projectKey: input.projectKey,
|
||||||
|
environment: input.environment,
|
||||||
|
ref: input.ref,
|
||||||
|
operator: input.operator,
|
||||||
|
trigger: input.trigger ?? 'manual',
|
||||||
|
})),
|
||||||
|
updateMemoryStatus: jest.fn((id: string, status: DeployRunStatus) =>
|
||||||
|
id === baseRun.id ? { ...baseRun, status } : null,
|
||||||
|
),
|
||||||
|
} satisfies Pick<
|
||||||
|
DeployRunRepository,
|
||||||
|
'findMany' | 'findById' | 'create' | 'updateMemoryStatus'
|
||||||
|
>;
|
||||||
|
const projects = {
|
||||||
|
getProject: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'project_001',
|
||||||
|
key: 'access-manage',
|
||||||
|
name: 'access-manage',
|
||||||
|
repositoryUrl:
|
||||||
|
'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active' as const,
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'test' as const,
|
||||||
|
displayName: '测试环境',
|
||||||
|
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} satisfies Pick<ProjectsService, 'getProject'>;
|
||||||
|
const audit = {
|
||||||
|
record: jest.fn(),
|
||||||
|
} satisfies Pick<AuditService, 'record'>;
|
||||||
|
const notification = {
|
||||||
|
sendDeployRunEvent: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
status: 'skipped' as const,
|
||||||
|
channel: 'wecom' as const,
|
||||||
|
reason: 'Missing WECOM_WEBHOOK_URL',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} satisfies Pick<DeployNotificationService, 'sendDeployRunEvent'>;
|
||||||
|
const execution = {
|
||||||
|
execute: jest.fn((run: DeployRunSummary) =>
|
||||||
|
Promise.resolve({ ...run, status: 'success' as const }),
|
||||||
|
),
|
||||||
|
syncJenkinsRun: jest.fn((run: DeployRunSummary) =>
|
||||||
|
Promise.resolve({ ...run, status: 'success' as const }),
|
||||||
|
),
|
||||||
|
} satisfies Pick<DeployExecutionService, 'execute' | 'syncJenkinsRun'>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
audit,
|
||||||
|
execution,
|
||||||
|
notification,
|
||||||
|
projects,
|
||||||
|
repository,
|
||||||
|
service: new DeployRunsService(
|
||||||
|
repository as unknown as DeployRunRepository,
|
||||||
|
projects as unknown as ProjectsService,
|
||||||
|
audit as unknown as AuditService,
|
||||||
|
notification as unknown as DeployNotificationService,
|
||||||
|
execution as unknown as DeployExecutionService,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a manual deploy run and records audit data', async () => {
|
||||||
|
const { audit, execution, repository, service } = createService();
|
||||||
|
|
||||||
|
const run = await service.createRun({
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'test',
|
||||||
|
ref: 'develop',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
idempotencyKey: 'idem_001',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.id).toBe('idem_001');
|
||||||
|
expect(repository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ projectKey: 'access-manage' }),
|
||||||
|
);
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'DEPLOY_REQUESTED',
|
||||||
|
resourceId: 'idem_001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(execution.execute).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'idem_001' }),
|
||||||
|
expect.objectContaining({ key: 'access-manage' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported environments before creating a run', async () => {
|
||||||
|
const { execution, repository, service } = createService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createRun({
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'staging',
|
||||||
|
ref: 'develop',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
} as unknown as CreateDeployRunInput),
|
||||||
|
).rejects.toBeInstanceOf(AppError);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
expect(execution.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels an existing deploy run', async () => {
|
||||||
|
const { audit, service } = createService();
|
||||||
|
|
||||||
|
const run = await service.cancelRun(baseRun.id);
|
||||||
|
|
||||||
|
expect(run.status).toBe('canceled');
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'DEPLOY_CANCELED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a deploy run with retry trigger', async () => {
|
||||||
|
const { repository, service } = createService();
|
||||||
|
|
||||||
|
const run = await service.retryRun(baseRun.id);
|
||||||
|
|
||||||
|
expect(run.trigger).toBe('retry');
|
||||||
|
expect(repository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
projectKey: baseRun.projectKey,
|
||||||
|
environment: baseRun.environment,
|
||||||
|
trigger: 'retry',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs all queued or running Jenkins runs with queue/build identifiers', async () => {
|
||||||
|
const queuedRun: DeployRunSummary = {
|
||||||
|
...baseRun,
|
||||||
|
id: 'run_queued',
|
||||||
|
status: 'queued',
|
||||||
|
jenkinsQueueId: 'queue_001',
|
||||||
|
};
|
||||||
|
const runningRun: DeployRunSummary = {
|
||||||
|
...baseRun,
|
||||||
|
id: 'run_running',
|
||||||
|
status: 'running',
|
||||||
|
jenkinsBuildNumber: 7,
|
||||||
|
};
|
||||||
|
const ignoredRun: DeployRunSummary = {
|
||||||
|
...baseRun,
|
||||||
|
id: 'run_running_without_jenkins',
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
const { audit, execution, service } = createService({
|
||||||
|
runs: [queuedRun, runningRun, ignoredRun],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.syncJenkinsRuns();
|
||||||
|
|
||||||
|
expect(result.totalCandidates).toBe(2);
|
||||||
|
expect(result.syncedRuns).toHaveLength(2);
|
||||||
|
expect(result.failedRuns).toHaveLength(0);
|
||||||
|
expect(execution.syncJenkinsRun).toHaveBeenCalledTimes(2);
|
||||||
|
expect(execution.syncJenkinsRun).toHaveBeenCalledWith(
|
||||||
|
queuedRun,
|
||||||
|
expect.objectContaining({ key: queuedRun.projectKey }),
|
||||||
|
);
|
||||||
|
expect(execution.syncJenkinsRun).toHaveBeenCalledWith(
|
||||||
|
runningRun,
|
||||||
|
expect.objectContaining({ key: runningRun.projectKey }),
|
||||||
|
);
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
|
||||||
|
after: expect.objectContaining({
|
||||||
|
totalCandidates: 2,
|
||||||
|
syncedCount: 2,
|
||||||
|
failedCount: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a redacted error when one Jenkins run fails during bulk sync', async () => {
|
||||||
|
const queuedRun: DeployRunSummary = {
|
||||||
|
...baseRun,
|
||||||
|
id: 'run_queued',
|
||||||
|
status: 'queued',
|
||||||
|
jenkinsQueueId: 'queue_001',
|
||||||
|
};
|
||||||
|
const { execution, service } = createService({ runs: [queuedRun] });
|
||||||
|
execution.syncJenkinsRun.mockRejectedValueOnce(
|
||||||
|
new Error('abcdefghijklmnopqrstuvwxyz123456'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.syncJenkinsRuns();
|
||||||
|
|
||||||
|
expect(result.totalCandidates).toBe(1);
|
||||||
|
expect(result.syncedRuns).toHaveLength(0);
|
||||||
|
expect(result.failedRuns).toEqual([
|
||||||
|
{
|
||||||
|
id: 'run_queued',
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
message: '[REDACTED]',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import {
|
||||||
|
DeployNotificationEvent,
|
||||||
|
DeployNotificationService,
|
||||||
|
} from '../notifications/wecom/deploy-notification.service';
|
||||||
|
import { ProjectsService } from '../projects/projects.service';
|
||||||
|
import { DeployExecutionService } from './deploy-execution.service';
|
||||||
|
import { DeployRunRepository } from './deploy-run.repository';
|
||||||
|
import {
|
||||||
|
CreateDeployRunInput,
|
||||||
|
DeployRunJenkinsSyncSummary,
|
||||||
|
DeployRunSummary,
|
||||||
|
} from './deploy-run.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeployRunsService {
|
||||||
|
constructor(
|
||||||
|
private readonly deployRunRepository: DeployRunRepository,
|
||||||
|
private readonly projectsService: ProjectsService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
private readonly deployNotificationService: DeployNotificationService,
|
||||||
|
private readonly deployExecutionService: DeployExecutionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listRuns(): Promise<DeployRunSummary[]> {
|
||||||
|
return this.deployRunRepository.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRun(id: string): Promise<DeployRunSummary> {
|
||||||
|
const run = await this.deployRunRepository.findById(id);
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
throw new AppError(
|
||||||
|
'RESOURCE_NOT_FOUND',
|
||||||
|
`Deploy run ${id} not found`,
|
||||||
|
404,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRun(input: CreateDeployRunInput): Promise<DeployRunSummary> {
|
||||||
|
if (!['test', 'production'].includes(input.environment)) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'environment must be test or production',
|
||||||
|
400,
|
||||||
|
{ environment: input.environment },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await this.projectsService.getProject(input.projectKey);
|
||||||
|
const run = this.deployRunRepository.create(input);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_REQUESTED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
actorName: input.operator,
|
||||||
|
after: {
|
||||||
|
projectKey: input.projectKey,
|
||||||
|
environment: input.environment,
|
||||||
|
ref: input.ref,
|
||||||
|
remark: input.remark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.deployExecutionService.execute(run, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelRun(id: string): Promise<DeployRunSummary> {
|
||||||
|
const run = this.deployRunRepository.updateMemoryStatus(id, 'canceled');
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
throw new AppError(
|
||||||
|
'RESOURCE_NOT_FOUND',
|
||||||
|
`Deploy run ${id} not found`,
|
||||||
|
404,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_CANCELED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
after: {
|
||||||
|
status: run.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.recordNotificationResult(run, 'canceled');
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryRun(id: string): Promise<DeployRunSummary> {
|
||||||
|
const source = await this.getRun(id);
|
||||||
|
return this.createRun({
|
||||||
|
projectKey: source.projectKey,
|
||||||
|
environment: source.environment,
|
||||||
|
ref: source.ref,
|
||||||
|
operator: source.operator,
|
||||||
|
remark: `Retry of ${source.id}`,
|
||||||
|
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
|
||||||
|
trigger: 'retry',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncJenkinsRun(id: string): Promise<DeployRunSummary> {
|
||||||
|
const run = await this.getRun(id);
|
||||||
|
const project = await this.projectsService.getProject(run.projectKey);
|
||||||
|
|
||||||
|
return this.deployExecutionService.syncJenkinsRun(run, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
|
||||||
|
const runs = await this.listRuns();
|
||||||
|
const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run));
|
||||||
|
const syncedRuns: DeployRunSummary[] = [];
|
||||||
|
const failedRuns: DeployRunJenkinsSyncSummary['failedRuns'] = [];
|
||||||
|
|
||||||
|
for (const run of candidates) {
|
||||||
|
try {
|
||||||
|
const project = await this.projectsService.getProject(run.projectKey);
|
||||||
|
const syncedRun = await this.deployExecutionService.syncJenkinsRun(
|
||||||
|
run,
|
||||||
|
project,
|
||||||
|
);
|
||||||
|
syncedRuns.push(syncedRun);
|
||||||
|
} catch (error) {
|
||||||
|
failedRuns.push({
|
||||||
|
id: run.id,
|
||||||
|
projectKey: run.projectKey,
|
||||||
|
message: this.toSafeSyncError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
after: {
|
||||||
|
totalCandidates: candidates.length,
|
||||||
|
syncedCount: syncedRuns.length,
|
||||||
|
failedCount: failedRuns.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
totalCandidates: candidates.length,
|
||||||
|
syncedRuns,
|
||||||
|
failedRuns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordNotificationResult(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
event: DeployNotificationEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
const notification =
|
||||||
|
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
after: {
|
||||||
|
event,
|
||||||
|
channel: notification.channel,
|
||||||
|
reason: notification.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isJenkinsSyncCandidate(run: DeployRunSummary): boolean {
|
||||||
|
return (
|
||||||
|
['queued', 'running'].includes(run.status) &&
|
||||||
|
Boolean(run.jenkinsQueueId || run.jenkinsBuildNumber)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSafeSyncError(error: unknown): string {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Jenkins sync failed';
|
||||||
|
|
||||||
|
return redactSensitive(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@ApiTags('health')
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ description: 'Runtime dependency health summary.' })
|
||||||
|
async getHealth(): Promise<ReturnType<HealthService['getHealth']>> {
|
||||||
|
return this.healthService.getHealth();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AgentModule } from '../agent/agent.module';
|
||||||
|
import { GiteaModule } from '../integrations/gitea/gitea.module';
|
||||||
|
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
|
||||||
|
import { WeComModule } from '../notifications/wecom/wecom.module';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AgentModule, GiteaModule, JenkinsModule, WeComModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [HealthService],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LlmClient } from '../agent/llm.client';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
||||||
|
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
||||||
|
import { WeComClient } from '../notifications/wecom/wecom.client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查是前端总览和后续部署前置校验的统一数据入口。
|
||||||
|
*/
|
||||||
|
describe('HealthService', () => {
|
||||||
|
it('returns ok when optional integrations are not configured but available services respond', async () => {
|
||||||
|
const config = {
|
||||||
|
get: jest.fn((key: keyof EnvConfig) => (key === 'REDIS_URL' ? undefined : undefined)),
|
||||||
|
} satisfies Pick<ConfigService<EnvConfig, true>, 'get'>;
|
||||||
|
const prisma = {
|
||||||
|
databaseHealth: jest.fn(() =>
|
||||||
|
Promise.resolve({ status: 'not_configured' as const }),
|
||||||
|
),
|
||||||
|
} satisfies Pick<PrismaService, 'databaseHealth'>;
|
||||||
|
const jenkins = {
|
||||||
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
|
} satisfies Pick<JenkinsClient, 'healthSummary'>;
|
||||||
|
const gitea = {
|
||||||
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
|
} satisfies Pick<GiteaClient, 'healthSummary'>;
|
||||||
|
const weCom = {
|
||||||
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
|
} satisfies Pick<WeComClient, 'healthSummary'>;
|
||||||
|
const llm = {
|
||||||
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
|
} satisfies Pick<LlmClient, 'healthSummary'>;
|
||||||
|
const service = new HealthService(
|
||||||
|
config as unknown as ConfigService<EnvConfig, true>,
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
jenkins as unknown as JenkinsClient,
|
||||||
|
gitea as unknown as GiteaClient,
|
||||||
|
weCom as unknown as WeComClient,
|
||||||
|
llm as unknown as LlmClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const health = await service.getHealth();
|
||||||
|
|
||||||
|
expect(health.status).toBe('ok');
|
||||||
|
expect(health.dependencies.database.status).toBe('not_configured');
|
||||||
|
expect(health.dependencies.jenkins.status).toBe('not_configured');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LlmClient } from '../agent/llm.client';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
||||||
|
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
||||||
|
import { WeComClient } from '../notifications/wecom/wecom.client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
type HealthStatus = 'ok' | 'not_configured' | 'unavailable';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService<EnvConfig, true>,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jenkins: JenkinsClient,
|
||||||
|
private readonly gitea: GiteaClient,
|
||||||
|
private readonly weCom: WeComClient,
|
||||||
|
private readonly llm: LlmClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getHealth(): Promise<{
|
||||||
|
status: HealthStatus;
|
||||||
|
checkedAt: string;
|
||||||
|
dependencies: Record<string, { status: HealthStatus; message?: string }>;
|
||||||
|
}> {
|
||||||
|
const dependencies = {
|
||||||
|
database: await this.prisma.databaseHealth(),
|
||||||
|
redis: this.config.get('REDIS_URL', { infer: true })
|
||||||
|
? { status: 'ok' as const }
|
||||||
|
: { status: 'not_configured' as const },
|
||||||
|
jenkins: this.jenkins.healthSummary(),
|
||||||
|
gitea: this.gitea.healthSummary(),
|
||||||
|
wecom: this.weCom.healthSummary(),
|
||||||
|
llm: this.llm.healthSummary(),
|
||||||
|
};
|
||||||
|
const hasUnavailable = Object.values(dependencies).some(
|
||||||
|
(dependency) => dependency.status === 'unavailable',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: hasUnavailable ? 'unavailable' : 'ok',
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
dependencies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IntegrationRequestError, IntegrationUnavailableError } from '../../common/errors/integration-error';
|
||||||
|
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||||
|
import { EnvConfig } from '../../config/env.schema';
|
||||||
|
import { GiteaBranch, GiteaCommitSummary, GiteaTag } from './gitea.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GiteaClient {
|
||||||
|
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
||||||
|
|
||||||
|
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
|
||||||
|
const missing = this.missingConfig();
|
||||||
|
return missing.length === 0
|
||||||
|
? { status: 'ok' }
|
||||||
|
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBranches(owner: string, repo: string): Promise<GiteaBranch[]> {
|
||||||
|
const payload = await this.request<Array<{ name: string; commit: { id: string } }>>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/branches`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return payload.map((branch) => ({
|
||||||
|
name: branch.name,
|
||||||
|
commitSha: branch.commit.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTags(owner: string, repo: string): Promise<GiteaTag[]> {
|
||||||
|
const payload = await this.request<Array<{ name: string; commit: { sha: string } }>>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/tags`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return payload.map((tag) => ({
|
||||||
|
name: tag.name,
|
||||||
|
commitSha: tag.commit.sha,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommit(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
ref: string,
|
||||||
|
): Promise<GiteaCommitSummary> {
|
||||||
|
const payload = await this.request<{
|
||||||
|
sha: string;
|
||||||
|
commit: {
|
||||||
|
message: string;
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>(`/api/v1/repos/${owner}/${repo}/git/commits/${encodeURIComponent(ref)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha: payload.sha,
|
||||||
|
message: payload.commit.message,
|
||||||
|
authorName: payload.commit.author?.name,
|
||||||
|
authoredAt: payload.commit.author?.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
this.assertConfigured();
|
||||||
|
const baseUrl = this.config.get('GITEA_BASE_URL', { infer: true }) ?? '';
|
||||||
|
const token = this.config.get('GITEA_TOKEN', { infer: true }) ?? '';
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
authorization: `token ${token}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new IntegrationRequestError('gitea', 'Gitea request failed', {
|
||||||
|
status: response.status,
|
||||||
|
path,
|
||||||
|
request: redactSensitive(init),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConfigured(): void {
|
||||||
|
const missing = this.missingConfig();
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new IntegrationUnavailableError('gitea', missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private missingConfig(): string[] {
|
||||||
|
const entries: Array<[string, string | undefined]> = [
|
||||||
|
['GITEA_BASE_URL', this.config.get('GITEA_BASE_URL', { infer: true })],
|
||||||
|
['GITEA_TOKEN', this.config.get('GITEA_TOKEN', { infer: true })],
|
||||||
|
];
|
||||||
|
|
||||||
|
return entries.filter(([, value]) => !value).map(([key]) => key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GiteaClient } from './gitea.client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [GiteaClient],
|
||||||
|
exports: [GiteaClient],
|
||||||
|
})
|
||||||
|
export class GiteaModule {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type GiteaBranch = {
|
||||||
|
name: string;
|
||||||
|
commitSha: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaTag = {
|
||||||
|
name: string;
|
||||||
|
commitSha: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaCommitSummary = {
|
||||||
|
sha: string;
|
||||||
|
message: string;
|
||||||
|
authorName?: string;
|
||||||
|
authoredAt?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IntegrationRequestError, IntegrationUnavailableError } from '../../common/errors/integration-error';
|
||||||
|
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||||
|
import { EnvConfig } from '../../config/env.schema';
|
||||||
|
import {
|
||||||
|
JenkinsBuildStatus,
|
||||||
|
JenkinsProgressiveText,
|
||||||
|
JenkinsQueueItem,
|
||||||
|
} from './jenkins.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JenkinsClient {
|
||||||
|
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
||||||
|
|
||||||
|
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
|
||||||
|
const missing = this.missingConfig();
|
||||||
|
return missing.length === 0
|
||||||
|
? { status: 'ok' }
|
||||||
|
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildWithParameters(
|
||||||
|
jobPath: string,
|
||||||
|
parameters: Record<string, string>,
|
||||||
|
): Promise<JenkinsQueueItem> {
|
||||||
|
this.assertConfigured();
|
||||||
|
const body = new URLSearchParams(parameters);
|
||||||
|
const response = await this.request(
|
||||||
|
`/job/${jobPath.split('/').map(encodeURIComponent).join('/job/')}/buildWithParameters`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const location = response.headers.get('location') ?? undefined;
|
||||||
|
return {
|
||||||
|
queueId: location?.split('/').filter(Boolean).pop() ?? 'unknown',
|
||||||
|
executableUrl: location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueueItem(queueId: string): Promise<JenkinsQueueItem> {
|
||||||
|
this.assertConfigured();
|
||||||
|
const response = await this.request(`/queue/item/${queueId}/api/json`);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
id?: number;
|
||||||
|
executable?: { number?: number; url?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
queueId: String(payload.id ?? queueId),
|
||||||
|
buildNumber: payload.executable?.number,
|
||||||
|
executableUrl: payload.executable?.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuildStatus(
|
||||||
|
jobPath: string,
|
||||||
|
buildNumber: number,
|
||||||
|
): Promise<JenkinsBuildStatus> {
|
||||||
|
this.assertConfigured();
|
||||||
|
const response = await this.request(
|
||||||
|
`/job/${jobPath.split('/').map(encodeURIComponent).join('/job/')}/${buildNumber}/api/json`,
|
||||||
|
);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
building?: boolean;
|
||||||
|
result?: JenkinsBuildStatus['result'];
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobPath,
|
||||||
|
buildNumber,
|
||||||
|
building: Boolean(payload.building),
|
||||||
|
result: payload.result,
|
||||||
|
url: payload.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProgressiveText(
|
||||||
|
jobPath: string,
|
||||||
|
buildNumber: number,
|
||||||
|
start = 0,
|
||||||
|
): Promise<JenkinsProgressiveText> {
|
||||||
|
this.assertConfigured();
|
||||||
|
const response = await this.request(
|
||||||
|
`/job/${jobPath.split('/').map(encodeURIComponent).join('/job/')}/${buildNumber}/logText/progressiveText?start=${start}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: await response.text(),
|
||||||
|
nextStart: Number(response.headers.get('x-text-size') ?? start),
|
||||||
|
hasMore: response.headers.get('x-more-data') === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(path: string, init: RequestInit = {}): 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 response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
authorization: `Basic ${Buffer.from(`${username}:${apiToken}`).toString('base64')}`,
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new IntegrationRequestError('jenkins', 'Jenkins request failed', {
|
||||||
|
status: response.status,
|
||||||
|
path,
|
||||||
|
request: redactSensitive(init),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConfigured(): void {
|
||||||
|
const missing = this.missingConfig();
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new IntegrationUnavailableError('jenkins', missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })],
|
||||||
|
];
|
||||||
|
|
||||||
|
return entries.filter(([, value]) => !value).map(([key]) => key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JenkinsClient } from './jenkins.client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [JenkinsClient],
|
||||||
|
exports: [JenkinsClient],
|
||||||
|
})
|
||||||
|
export class JenkinsModule {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type JenkinsQueueItem = {
|
||||||
|
queueId: string;
|
||||||
|
buildNumber?: number;
|
||||||
|
executableUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JenkinsBuildStatus = {
|
||||||
|
jobPath: string;
|
||||||
|
buildNumber: number;
|
||||||
|
building: boolean;
|
||||||
|
result?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE';
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JenkinsProgressiveText = {
|
||||||
|
text: string;
|
||||||
|
nextStart: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ApiEnvelopeInterceptor } from './common/http/api-envelope.interceptor';
|
||||||
|
import { AllExceptionsFilter } from './common/http/all-exceptions.filter';
|
||||||
|
import { EnvConfig } from './config/env.schema';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
bufferLogs: true,
|
||||||
|
rawBody: true,
|
||||||
|
});
|
||||||
|
const config = app.get(ConfigService<EnvConfig, true>);
|
||||||
|
const corsOrigin = config.get('CORS_ORIGIN', { infer: true });
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: corsOrigin ? corsOrigin.split(',').map((origin) => origin.trim()) : true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
app.useGlobalInterceptors(new ApiEnvelopeInterceptor());
|
||||||
|
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('DevOps Platform API')
|
||||||
|
.setDescription('Jenkins, Gitea, notification, BPMN deploy runs, and agent operation APIs.')
|
||||||
|
.setVersion('0.1.0')
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('docs', app, document);
|
||||||
|
|
||||||
|
await app.listen(config.get('PORT', { infer: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { WeComClient } from './wecom.client';
|
||||||
|
import { DeployNotificationService } from './deploy-notification.service';
|
||||||
|
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 覆盖企微通知的安全边界:缺少 webhook 时跳过,配置后才发送。
|
||||||
|
*/
|
||||||
|
describe('DeployNotificationService', () => {
|
||||||
|
const run: DeployRunSummary = {
|
||||||
|
id: 'run_001',
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'test',
|
||||||
|
ref: 'develop',
|
||||||
|
status: 'pending',
|
||||||
|
trigger: 'manual',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
createdAt: '2026-06-11T00:00:00.000Z',
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('skips notification when WeCom is not configured', async () => {
|
||||||
|
const weCom = {
|
||||||
|
healthSummary: jest.fn(() => ({
|
||||||
|
status: 'not_configured' as const,
|
||||||
|
message: 'Missing WECOM_WEBHOOK_URL',
|
||||||
|
})),
|
||||||
|
sendMarkdown: jest.fn(),
|
||||||
|
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||||
|
const service = new DeployNotificationService(
|
||||||
|
weCom as unknown as WeComClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.sendDeployRunEvent(run, 'created');
|
||||||
|
|
||||||
|
expect(result.status).toBe('skipped');
|
||||||
|
expect(weCom.sendMarkdown).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends markdown when WeCom is configured', async () => {
|
||||||
|
const weCom = {
|
||||||
|
healthSummary: jest.fn(() => ({ status: 'ok' as const })),
|
||||||
|
sendMarkdown: jest.fn(() => Promise.resolve({ accepted: true })),
|
||||||
|
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||||
|
const service = new DeployNotificationService(
|
||||||
|
weCom as unknown as WeComClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.sendDeployRunEvent(run, 'created');
|
||||||
|
|
||||||
|
expect(result.status).toBe('sent');
|
||||||
|
expect(weCom.sendMarkdown).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'DevOps 发布已创建',
|
||||||
|
markdown: expect.stringContaining('access-manage'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||||
|
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
||||||
|
import { WeComClient } from './wecom.client';
|
||||||
|
|
||||||
|
export type DeployNotificationEvent =
|
||||||
|
| 'created'
|
||||||
|
| 'queued'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'canceled'
|
||||||
|
| 'retried';
|
||||||
|
|
||||||
|
export type DeployNotificationResult = {
|
||||||
|
status: 'sent' | 'skipped' | 'failed';
|
||||||
|
channel: 'wecom';
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一封装发布单企微通知,保证 webhook 只存在于后端配置边界内。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DeployNotificationService {
|
||||||
|
constructor(private readonly weComClient: WeComClient) {}
|
||||||
|
|
||||||
|
async sendDeployRunEvent(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
event: DeployNotificationEvent,
|
||||||
|
): Promise<DeployNotificationResult> {
|
||||||
|
const health = this.weComClient.healthSummary();
|
||||||
|
|
||||||
|
if (health.status === 'not_configured') {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
channel: 'wecom',
|
||||||
|
reason: health.message ?? 'WeCom webhook is not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.weComClient.sendMarkdown({
|
||||||
|
title: this.titleFor(event),
|
||||||
|
markdown: this.markdownFor(run, event),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'sent', channel: 'wecom' };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
channel: 'wecom',
|
||||||
|
reason:
|
||||||
|
error instanceof Error
|
||||||
|
? redactSensitive({ message: error.message }).message
|
||||||
|
: 'Unknown WeCom notification error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private titleFor(event: DeployNotificationEvent): string {
|
||||||
|
if (event === 'canceled') {
|
||||||
|
return 'DevOps 发布已取消';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'queued') {
|
||||||
|
return 'DevOps 发布已进入 Jenkins 队列';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'succeeded') {
|
||||||
|
return 'DevOps 发布成功';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'failed') {
|
||||||
|
return 'DevOps 发布失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'retried') {
|
||||||
|
return 'DevOps 发布已重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'DevOps 发布已创建';
|
||||||
|
}
|
||||||
|
|
||||||
|
private markdownFor(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
event: DeployNotificationEvent,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
`> 事件:${this.titleFor(event)}`,
|
||||||
|
`> 项目:${run.projectKey}`,
|
||||||
|
`> 环境:${run.environment}`,
|
||||||
|
`> Ref:${run.ref}`,
|
||||||
|
`> 状态:${run.status}`,
|
||||||
|
`> 操作者:${run.operator}`,
|
||||||
|
`> Run ID:${run.id}`,
|
||||||
|
run.jenkinsQueueId ? `> Jenkins Queue:${run.jenkinsQueueId}` : undefined,
|
||||||
|
run.jenkinsBuildNumber ? `> Jenkins Build:#${run.jenkinsBuildNumber}` : undefined,
|
||||||
|
run.failureSummary ? `> 失败摘要:${run.failureSummary}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IntegrationRequestError, IntegrationUnavailableError } from '../../common/errors/integration-error';
|
||||||
|
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||||
|
import { EnvConfig } from '../../config/env.schema';
|
||||||
|
import { WeComMarkdownMessage, WeComSendResult } from './wecom.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WeComClient {
|
||||||
|
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
||||||
|
|
||||||
|
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
|
||||||
|
return this.config.get('WECOM_WEBHOOK_URL', { infer: true })
|
||||||
|
? { status: 'ok' }
|
||||||
|
: { status: 'not_configured', message: 'Missing WECOM_WEBHOOK_URL' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMarkdown(message: WeComMarkdownMessage): Promise<WeComSendResult> {
|
||||||
|
this.assertConfigured();
|
||||||
|
const webhookUrl = this.config.get('WECOM_WEBHOOK_URL', { infer: true }) ?? '';
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: {
|
||||||
|
content: `# ${message.title}\n\n${message.markdown}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new IntegrationRequestError('wecom', 'WeCom webhook request failed', {
|
||||||
|
status: response.status,
|
||||||
|
message: redactSensitive(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConfigured(): void {
|
||||||
|
if (!this.config.get('WECOM_WEBHOOK_URL', { infer: true })) {
|
||||||
|
throw new IntegrationUnavailableError('wecom', ['WECOM_WEBHOOK_URL']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DeployNotificationService } from './deploy-notification.service';
|
||||||
|
import { WeComClient } from './wecom.client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [WeComClient, DeployNotificationService],
|
||||||
|
exports: [WeComClient, DeployNotificationService],
|
||||||
|
})
|
||||||
|
export class WeComModule {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type WeComMarkdownMessage = {
|
||||||
|
title: string;
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeComSendResult = {
|
||||||
|
accepted: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
constructor(private readonly config: ConfigService<EnvConfig, true>) {
|
||||||
|
super({
|
||||||
|
log:
|
||||||
|
config.get('NODE_ENV', { infer: true }) === 'development'
|
||||||
|
? ['warn', 'error']
|
||||||
|
: ['error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
if (
|
||||||
|
this.config.get('DATABASE_URL', { infer: true }) &&
|
||||||
|
this.config.get('PRISMA_CONNECT_ON_BOOT', { infer: true })
|
||||||
|
) {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async databaseHealth(): Promise<{
|
||||||
|
status: 'not_configured' | 'ok' | 'unavailable';
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
if (!this.config.get('DATABASE_URL', { infer: true })) {
|
||||||
|
return { status: 'not_configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.$connect();
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'unavailable',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown database error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 前端 BPMN viewer 消费的后端流程定义契约。
|
||||||
|
*/
|
||||||
|
export type ProcessDefinitionSummary = {
|
||||||
|
key: 'release';
|
||||||
|
version: string;
|
||||||
|
xml: string;
|
||||||
|
nodeIds: string[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ProcessDefinitionSummary } from './process-definition.types';
|
||||||
|
import { ProcessDefinitionsService } from './process-definitions.service';
|
||||||
|
|
||||||
|
@ApiTags('process-definitions')
|
||||||
|
@Controller('process-definitions')
|
||||||
|
export class ProcessDefinitionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly processDefinitionsService: ProcessDefinitionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('release')
|
||||||
|
@ApiOkResponse({ description: 'BPMN process definition for deploy runs.' })
|
||||||
|
getReleaseProcess(): ProcessDefinitionSummary {
|
||||||
|
return this.processDefinitionsService.getReleaseProcess();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProcessDefinitionsController } from './process-definitions.controller';
|
||||||
|
import { ProcessDefinitionsService } from './process-definitions.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProcessDefinitionsController],
|
||||||
|
providers: [ProcessDefinitionsService],
|
||||||
|
exports: [ProcessDefinitionsService],
|
||||||
|
})
|
||||||
|
export class ProcessDefinitionsModule {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ProcessDefinitionsService } from './process-definitions.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保后端暴露的 BPMN 节点与 deploy run step 映射保持一致。
|
||||||
|
*/
|
||||||
|
describe('ProcessDefinitionsService', () => {
|
||||||
|
it('returns the release BPMN definition and canonical node ids', () => {
|
||||||
|
const service = new ProcessDefinitionsService();
|
||||||
|
const definition = service.getReleaseProcess();
|
||||||
|
|
||||||
|
expect(definition.key).toBe('release');
|
||||||
|
expect(definition.xml).toContain('bpmn:definitions');
|
||||||
|
expect(definition.nodeIds).toEqual([
|
||||||
|
'start-release',
|
||||||
|
'gitea-check',
|
||||||
|
'jenkins-build',
|
||||||
|
'health-check',
|
||||||
|
'notify-wecom',
|
||||||
|
'finish-release',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ProcessDefinitionSummary } from './process-definition.types';
|
||||||
|
import {
|
||||||
|
releaseProcessBpmnXml,
|
||||||
|
releaseProcessNodeIds,
|
||||||
|
} from './release-process.bpmn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理平台内置流程定义,后续负责从数据库/版本仓库加载 BPMN。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ProcessDefinitionsService {
|
||||||
|
getReleaseProcess(): ProcessDefinitionSummary {
|
||||||
|
return {
|
||||||
|
key: 'release',
|
||||||
|
version: 'release-v1',
|
||||||
|
xml: releaseProcessBpmnXml,
|
||||||
|
nodeIds: [...releaseProcessNodeIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 发布流程第一版 BPMN XML;后续可迁移到数据库并按环境/版本读取。
|
||||||
|
*/
|
||||||
|
export const releaseProcessBpmnXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_DevOpsRelease" targetNamespace="http://devops-platform.local/bpmn">
|
||||||
|
<bpmn:process id="Process_Release" name="DevOps release pipeline" isExecutable="false">
|
||||||
|
<bpmn:startEvent id="start-release" name="提交发布">
|
||||||
|
<bpmn:outgoing>Flow_Start_Gitea</bpmn:outgoing>
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:serviceTask id="gitea-check" name="Gitea ref 校验">
|
||||||
|
<bpmn:incoming>Flow_Start_Gitea</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_Gitea_Jenkins</bpmn:outgoing>
|
||||||
|
</bpmn:serviceTask>
|
||||||
|
<bpmn:serviceTask id="jenkins-build" name="Jenkins 构建">
|
||||||
|
<bpmn:incoming>Flow_Gitea_Jenkins</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_Jenkins_Health</bpmn:outgoing>
|
||||||
|
</bpmn:serviceTask>
|
||||||
|
<bpmn:serviceTask id="health-check" name="健康检查">
|
||||||
|
<bpmn:incoming>Flow_Jenkins_Health</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_Health_Notify</bpmn:outgoing>
|
||||||
|
</bpmn:serviceTask>
|
||||||
|
<bpmn:serviceTask id="notify-wecom" name="通知平台">
|
||||||
|
<bpmn:incoming>Flow_Health_Notify</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_Notify_End</bpmn:outgoing>
|
||||||
|
</bpmn:serviceTask>
|
||||||
|
<bpmn:endEvent id="finish-release" name="归档审计">
|
||||||
|
<bpmn:incoming>Flow_Notify_End</bpmn:incoming>
|
||||||
|
</bpmn:endEvent>
|
||||||
|
<bpmn:sequenceFlow id="Flow_Start_Gitea" sourceRef="start-release" targetRef="gitea-check" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_Gitea_Jenkins" sourceRef="gitea-check" targetRef="jenkins-build" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_Jenkins_Health" sourceRef="jenkins-build" targetRef="health-check" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_Health_Notify" sourceRef="health-check" targetRef="notify-wecom" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_Notify_End" sourceRef="notify-wecom" targetRef="finish-release" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmndi:BPMNDiagram id="BPMNDiagram_Release">
|
||||||
|
<bpmndi:BPMNPlane id="BPMNPlane_Release" bpmnElement="Process_Release">
|
||||||
|
<bpmndi:BPMNShape id="Shape_Start" bpmnElement="start-release">
|
||||||
|
<dc:Bounds x="120" y="145" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_Gitea" bpmnElement="gitea-check">
|
||||||
|
<dc:Bounds x="210" y="123" width="118" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_Jenkins" bpmnElement="jenkins-build">
|
||||||
|
<dc:Bounds x="380" y="123" width="118" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_Health" bpmnElement="health-check">
|
||||||
|
<dc:Bounds x="550" y="123" width="118" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_Notify" bpmnElement="notify-wecom">
|
||||||
|
<dc:Bounds x="720" y="123" width="118" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_End" bpmnElement="finish-release">
|
||||||
|
<dc:Bounds x="900" y="145" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Start_Gitea" bpmnElement="Flow_Start_Gitea">
|
||||||
|
<di:waypoint x="156" y="163" />
|
||||||
|
<di:waypoint x="210" y="163" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Gitea_Jenkins" bpmnElement="Flow_Gitea_Jenkins">
|
||||||
|
<di:waypoint x="328" y="163" />
|
||||||
|
<di:waypoint x="380" y="163" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Jenkins_Health" bpmnElement="Flow_Jenkins_Health">
|
||||||
|
<di:waypoint x="498" y="163" />
|
||||||
|
<di:waypoint x="550" y="163" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Health_Notify" bpmnElement="Flow_Health_Notify">
|
||||||
|
<di:waypoint x="668" y="163" />
|
||||||
|
<di:waypoint x="720" y="163" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Notify_End" bpmnElement="Flow_Notify_End">
|
||||||
|
<di:waypoint x="838" y="163" />
|
||||||
|
<di:waypoint x="900" y="163" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
</bpmn:definitions>`;
|
||||||
|
|
||||||
|
export const releaseProcessNodeIds = [
|
||||||
|
'start-release',
|
||||||
|
'gitea-check',
|
||||||
|
'jenkins-build',
|
||||||
|
'health-check',
|
||||||
|
'notify-wecom',
|
||||||
|
'finish-release',
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ProjectStatus } from '@prisma/client';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { seedProjects } from './project.seed';
|
||||||
|
import { ProjectSummary } from './project.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService<EnvConfig, true>,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findMany(): Promise<ProjectSummary[]> {
|
||||||
|
if (!this.useDatabaseReads()) {
|
||||||
|
return seedProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await this.prisma.project.findMany({
|
||||||
|
include: {
|
||||||
|
environments: {
|
||||||
|
include: {
|
||||||
|
deployJobs: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
key: project.key,
|
||||||
|
name: project.name,
|
||||||
|
repositoryUrl: project.repositoryUrl ?? '',
|
||||||
|
defaultBranch: project.defaultBranch,
|
||||||
|
status: project.status === ProjectStatus.ACTIVE ? 'active' : 'archived',
|
||||||
|
environments: project.environments.map((environment) => ({
|
||||||
|
name: environment.name === 'TEST' ? 'test' : 'production',
|
||||||
|
displayName: environment.displayName,
|
||||||
|
jenkinsJobPath: environment.deployJobs[0]?.jenkinsJobPath ?? '',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByKey(key: string): Promise<ProjectSummary | null> {
|
||||||
|
const projects = await this.findMany();
|
||||||
|
return projects.find((project) => project.key === key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private useDatabaseReads(): boolean {
|
||||||
|
return (
|
||||||
|
this.config.get('USE_DATABASE_READS', { infer: true }) &&
|
||||||
|
Boolean(this.config.get('DATABASE_URL', { infer: true }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { ProjectSummary } from './project.types';
|
||||||
|
|
||||||
|
export const seedProjects: ProjectSummary[] = [
|
||||||
|
{
|
||||||
|
id: 'project_access_manage',
|
||||||
|
key: 'access-manage',
|
||||||
|
name: 'access-manage',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
displayName: '测试环境',
|
||||||
|
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'my-project-prod/prod-access-manage-tag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project_role_admin',
|
||||||
|
key: 'role-admin',
|
||||||
|
name: 'role-admin',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/role-admin.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
displayName: '测试环境',
|
||||||
|
jenkinsJobPath: 'my-project-dev/test-role-admin-develop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'my-project-prod/prod-role-admin-tag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project_role_user',
|
||||||
|
key: 'role-user',
|
||||||
|
name: 'role-user',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/role-user.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
displayName: '测试环境',
|
||||||
|
jenkinsJobPath: 'my-project-dev/test-role-user-develop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'my-project-prod/prod-role-user-tag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project_my_resume',
|
||||||
|
key: 'my-resume',
|
||||||
|
name: 'my-resume',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/my-resume.git',
|
||||||
|
defaultBranch: 'master',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
displayName: '测试环境',
|
||||||
|
jenkinsJobPath: 'my-project-dev/test-my-resume-develop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'my-project-prod/prod-my-resume-tag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project_devops_platform_api',
|
||||||
|
key: 'devops-platform-api',
|
||||||
|
name: 'devops-platform-api',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/devops-platform/devops-platform-api.git',
|
||||||
|
defaultBranch: 'master',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'devops-platform/prod-devops-platform-api-master',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project_devops_platform_web',
|
||||||
|
key: 'devops-platform-web',
|
||||||
|
name: 'devops-platform-web',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/devops-platform/devops-platform-web.git',
|
||||||
|
defaultBranch: 'master',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type ProjectEnvironmentSummary = {
|
||||||
|
name: 'test' | 'production';
|
||||||
|
displayName: string;
|
||||||
|
jenkinsJobPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectSummary = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
repositoryUrl: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
status: 'active' | 'archived';
|
||||||
|
environments: ProjectEnvironmentSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectRefSummary = {
|
||||||
|
source: 'gitea' | 'fallback';
|
||||||
|
branches: Array<{
|
||||||
|
name: string;
|
||||||
|
commitSha?: string;
|
||||||
|
}>;
|
||||||
|
tags: Array<{
|
||||||
|
name: string;
|
||||||
|
commitSha?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ProjectRefSummary, ProjectSummary } from './project.types';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
|
@ApiTags('projects')
|
||||||
|
@Controller('projects')
|
||||||
|
export class ProjectsController {
|
||||||
|
constructor(private readonly projectsService: ProjectsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ description: 'Configured project list.' })
|
||||||
|
async listProjects(): Promise<ProjectSummary[]> {
|
||||||
|
return this.projectsService.listProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':key')
|
||||||
|
@ApiOkResponse({ description: 'Project detail by key.' })
|
||||||
|
async getProject(@Param('key') key: string): Promise<ProjectSummary> {
|
||||||
|
return this.projectsService.getProject(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':key/refs')
|
||||||
|
@ApiOkResponse({ description: 'Project branches and tags.' })
|
||||||
|
async listProjectRefs(@Param('key') key: string): Promise<ProjectRefSummary> {
|
||||||
|
return this.projectsService.listProjectRefs(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GiteaModule } from '../integrations/gitea/gitea.module';
|
||||||
|
import { ProjectRepository } from './project.repository';
|
||||||
|
import { ProjectsController } from './projects.controller';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [GiteaModule],
|
||||||
|
controllers: [ProjectsController],
|
||||||
|
providers: [ProjectRepository, ProjectsService],
|
||||||
|
exports: [ProjectRepository, ProjectsService],
|
||||||
|
})
|
||||||
|
export class ProjectsModule {}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
||||||
|
import { ProjectRepository } from './project.repository';
|
||||||
|
import { ProjectSummary } from './project.types';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
|
describe('ProjectsService', () => {
|
||||||
|
const project: ProjectSummary = {
|
||||||
|
id: 'project_access_manage',
|
||||||
|
key: 'access-manage',
|
||||||
|
name: 'access-manage',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active',
|
||||||
|
environments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createService(options?: {
|
||||||
|
giteaConfigured?: boolean;
|
||||||
|
branches?: Array<{ name: string; commitSha: string }>;
|
||||||
|
tags?: Array<{ name: string; commitSha: string }>;
|
||||||
|
}) {
|
||||||
|
const repository = {
|
||||||
|
findMany: jest.fn(() => Promise.resolve([project])),
|
||||||
|
findByKey: jest.fn((key: string) =>
|
||||||
|
Promise.resolve(key === project.key ? project : null),
|
||||||
|
),
|
||||||
|
} satisfies Pick<ProjectRepository, 'findMany' | 'findByKey'>;
|
||||||
|
const gitea = {
|
||||||
|
healthSummary: jest.fn(() =>
|
||||||
|
options?.giteaConfigured ? { status: 'ok' as const } : { status: 'not_configured' as const },
|
||||||
|
),
|
||||||
|
listBranches: jest.fn(() =>
|
||||||
|
Promise.resolve(options?.branches ?? [{ name: 'develop', commitSha: 'abc' }]),
|
||||||
|
),
|
||||||
|
listTags: jest.fn(() =>
|
||||||
|
Promise.resolve(options?.tags ?? [{ name: 'v1.0.0', commitSha: 'def' }]),
|
||||||
|
),
|
||||||
|
} satisfies Pick<GiteaClient, 'healthSummary' | 'listBranches' | 'listTags'>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
gitea,
|
||||||
|
repository,
|
||||||
|
service: new ProjectsService(
|
||||||
|
repository as unknown as ProjectRepository,
|
||||||
|
gitea as unknown as GiteaClient,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns fallback refs when Gitea is not configured', async () => {
|
||||||
|
const { gitea, service } = createService({ giteaConfigured: false });
|
||||||
|
|
||||||
|
const refs = await service.listProjectRefs(project.key);
|
||||||
|
|
||||||
|
expect(refs.source).toBe('fallback');
|
||||||
|
expect(refs.branches).toContainEqual({ name: 'develop' });
|
||||||
|
expect(refs.tags).toContainEqual({ name: 'v2026.06.05-envsplit.7' });
|
||||||
|
expect(gitea.listBranches).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Gitea refs when Gitea is configured', async () => {
|
||||||
|
const { gitea, service } = createService({
|
||||||
|
giteaConfigured: true,
|
||||||
|
branches: [{ name: 'feature/devops-platform', commitSha: 'abc123' }],
|
||||||
|
tags: [{ name: 'v2026.06.11.1', commitSha: 'def456' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const refs = await service.listProjectRefs(project.key);
|
||||||
|
|
||||||
|
expect(refs).toEqual({
|
||||||
|
source: 'gitea',
|
||||||
|
branches: [{ name: 'feature/devops-platform', commitSha: 'abc123' }],
|
||||||
|
tags: [{ name: 'v2026.06.11.1', commitSha: 'def456' }],
|
||||||
|
});
|
||||||
|
expect(gitea.listBranches).toHaveBeenCalledWith('my-project', 'access-manage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises a not found error for unknown projects', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expect(service.listProjectRefs('unknown')).rejects.toBeInstanceOf(AppError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
||||||
|
import { ProjectRepository } from './project.repository';
|
||||||
|
import { ProjectRefSummary, ProjectSummary } from './project.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectsService {
|
||||||
|
constructor(
|
||||||
|
private readonly projectRepository: ProjectRepository,
|
||||||
|
private readonly giteaClient: GiteaClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listProjects(): Promise<ProjectSummary[]> {
|
||||||
|
return this.projectRepository.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(key: string): Promise<ProjectSummary> {
|
||||||
|
const project = await this.projectRepository.findByKey(key);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new AppError('RESOURCE_NOT_FOUND', `Project ${key} not found`, 404, {
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listProjectRefs(key: string): Promise<ProjectRefSummary> {
|
||||||
|
const project = await this.getProject(key);
|
||||||
|
const repo = this.parseRepository(project);
|
||||||
|
|
||||||
|
if (repo && this.giteaClient.healthSummary().status === 'ok') {
|
||||||
|
try {
|
||||||
|
const [branches, tags] = await Promise.all([
|
||||||
|
this.giteaClient.listBranches(repo.owner, repo.name),
|
||||||
|
this.giteaClient.listTags(repo.owner, repo.name),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'gitea',
|
||||||
|
branches,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return this.fallbackRefs(project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fallbackRefs(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRepository(
|
||||||
|
project: ProjectSummary,
|
||||||
|
): { owner: string; name: string } | null {
|
||||||
|
try {
|
||||||
|
const pathname = new URL(project.repositoryUrl).pathname.replace(/\.git$/, '');
|
||||||
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
|
const name = parts.at(-1);
|
||||||
|
const owner = parts.at(-2);
|
||||||
|
|
||||||
|
return owner && name ? { owner, name } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackRefs(project: ProjectSummary): ProjectRefSummary {
|
||||||
|
const branches = Array.from(
|
||||||
|
new Set([project.defaultBranch, project.key === 'my-resume' ? 'master' : 'develop']),
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name) => ({ name }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'fallback',
|
||||||
|
branches,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: 'v2026.06.05-envsplit.7',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { IntegrationConfigStatusResponse } from './settings.types';
|
||||||
|
|
||||||
|
@ApiTags('settings')
|
||||||
|
@Controller('settings')
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
|
@Get('integration-config')
|
||||||
|
@ApiOkResponse({
|
||||||
|
description:
|
||||||
|
'Integration configuration status with secret values redacted server-side.',
|
||||||
|
})
|
||||||
|
getIntegrationConfig(): IntegrationConfigStatusResponse {
|
||||||
|
return this.settingsService.getIntegrationConfigStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
describe('SettingsService', () => {
|
||||||
|
it('returns configured when every required integration variable is real', () => {
|
||||||
|
const secretValues = [
|
||||||
|
'jenkins-real-token',
|
||||||
|
'gitea-real-token',
|
||||||
|
'gitea-real-webhook-secret',
|
||||||
|
'wecom-real-key',
|
||||||
|
'llm-real-api-key',
|
||||||
|
'0123456789abcdef0123456789abcdef',
|
||||||
|
];
|
||||||
|
const service = new SettingsService(
|
||||||
|
createConfig({
|
||||||
|
DATABASE_URL: 'mysql://devops_user:local-password@mysql.internal:3306/devops',
|
||||||
|
REDIS_URL: 'redis://redis.internal:6379/0',
|
||||||
|
JENKINS_BASE_URL: 'https://jenkins.internal',
|
||||||
|
JENKINS_USERNAME: 'devops-bot',
|
||||||
|
JENKINS_API_TOKEN: secretValues[0],
|
||||||
|
GITEA_BASE_URL: 'https://gitea.internal',
|
||||||
|
GITEA_TOKEN: secretValues[1],
|
||||||
|
GITEA_WEBHOOK_SECRET: secretValues[2],
|
||||||
|
NOTIFICATION_PROVIDER: 'wecom',
|
||||||
|
WECOM_WEBHOOK_URL: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${secretValues[3]}`,
|
||||||
|
LLM_BASE_URL: 'https://llm.internal/v1',
|
||||||
|
LLM_API_KEY: secretValues[4],
|
||||||
|
LLM_MODEL: 'gpt-enterprise',
|
||||||
|
SECRET_ENCRYPTION_KEY: secretValues[5],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = service.getIntegrationConfigStatus();
|
||||||
|
|
||||||
|
expect(Date.parse(result.checkedAt)).not.toBeNaN();
|
||||||
|
expect(result.integrations).toHaveLength(7);
|
||||||
|
expect(result.integrations.map((integration) => integration.status)).toEqual(
|
||||||
|
Array(7).fill('configured'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks partially configured integrations as partial with missing variable names only', () => {
|
||||||
|
const service = new SettingsService(
|
||||||
|
createConfig({
|
||||||
|
LLM_BASE_URL: 'https://llm.internal/v1',
|
||||||
|
LLM_MODEL: 'gpt-enterprise',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const llm = service
|
||||||
|
.getIntegrationConfigStatus()
|
||||||
|
.integrations.find((integration) => integration.key === 'llm');
|
||||||
|
|
||||||
|
expect(llm).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
configured: ['LLM_BASE_URL', 'LLM_MODEL'],
|
||||||
|
missing: ['LLM_API_KEY'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats env example placeholders as missing values', () => {
|
||||||
|
const service = new SettingsService(
|
||||||
|
createConfig({
|
||||||
|
JENKINS_BASE_URL: 'https://jenkins.example.internal',
|
||||||
|
JENKINS_USERNAME: 'devops-bot',
|
||||||
|
JENKINS_API_TOKEN: 'replace-with-secret-manager-value',
|
||||||
|
GITEA_BASE_URL: 'https://gitea.example.internal',
|
||||||
|
GITEA_TOKEN: 'replace-with-secret-manager-value',
|
||||||
|
GITEA_WEBHOOK_SECRET: 'replace-with-secret-manager-value',
|
||||||
|
NOTIFICATION_PROVIDER: 'replace-with-provider',
|
||||||
|
WECOM_WEBHOOK_URL:
|
||||||
|
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-secret-manager-value',
|
||||||
|
LLM_BASE_URL: 'https://llm.example.internal/v1',
|
||||||
|
LLM_API_KEY: 'replace-with-secret-manager-value',
|
||||||
|
LLM_MODEL: 'replace-with-model-name',
|
||||||
|
SECRET_ENCRYPTION_KEY: 'replace-with-32-byte-key',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = service.getIntegrationConfigStatus();
|
||||||
|
const byKey = Object.fromEntries(
|
||||||
|
result.integrations.map((integration) => [integration.key, integration]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(byKey.jenkins).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
configured: ['JENKINS_USERNAME'],
|
||||||
|
missing: ['JENKINS_BASE_URL', 'JENKINS_API_TOKEN'],
|
||||||
|
});
|
||||||
|
expect(byKey.gitea).toMatchObject({
|
||||||
|
status: 'missing',
|
||||||
|
missing: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
|
||||||
|
});
|
||||||
|
expect(byKey.notification).toMatchObject({
|
||||||
|
status: 'missing',
|
||||||
|
missing: ['NOTIFICATION_PROVIDER'],
|
||||||
|
});
|
||||||
|
expect(byKey.llm).toMatchObject({
|
||||||
|
status: 'missing',
|
||||||
|
missing: ['LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL'],
|
||||||
|
});
|
||||||
|
expect(byKey.secrets).toMatchObject({
|
||||||
|
status: 'missing',
|
||||||
|
missing: ['SECRET_ENCRYPTION_KEY'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not serialize secret, token, base URL, or webhook values', () => {
|
||||||
|
const hiddenValues = [
|
||||||
|
'https://jenkins.internal',
|
||||||
|
'jenkins-real-token',
|
||||||
|
'https://gitea.internal',
|
||||||
|
'gitea-real-token',
|
||||||
|
'gitea-real-webhook-secret',
|
||||||
|
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=wecom-real-key',
|
||||||
|
'https://open.feishu.cn/open-apis/bot/v2/hook/feishu-real-key',
|
||||||
|
'https://notification.internal/webhook/generic-real-key',
|
||||||
|
'https://llm.internal/v1',
|
||||||
|
'llm-real-api-key',
|
||||||
|
'mysql://devops_user:local-password@mysql.internal:3306/devops',
|
||||||
|
'redis://redis.internal:6379/0',
|
||||||
|
'0123456789abcdef0123456789abcdef',
|
||||||
|
];
|
||||||
|
const service = new SettingsService(
|
||||||
|
createConfig({
|
||||||
|
DATABASE_URL: hiddenValues[10],
|
||||||
|
REDIS_URL: hiddenValues[11],
|
||||||
|
JENKINS_BASE_URL: hiddenValues[0],
|
||||||
|
JENKINS_USERNAME: 'devops-bot',
|
||||||
|
JENKINS_API_TOKEN: hiddenValues[1],
|
||||||
|
GITEA_BASE_URL: hiddenValues[2],
|
||||||
|
GITEA_TOKEN: hiddenValues[3],
|
||||||
|
GITEA_WEBHOOK_SECRET: hiddenValues[4],
|
||||||
|
WECOM_WEBHOOK_URL: hiddenValues[5],
|
||||||
|
FEISHU_WEBHOOK_URL: hiddenValues[6],
|
||||||
|
NOTIFICATION_WEBHOOK_URL: hiddenValues[7],
|
||||||
|
NOTIFICATION_PROVIDER: 'wecom',
|
||||||
|
LLM_BASE_URL: hiddenValues[8],
|
||||||
|
LLM_API_KEY: hiddenValues[9],
|
||||||
|
LLM_MODEL: 'gpt-enterprise',
|
||||||
|
SECRET_ENCRYPTION_KEY: hiddenValues[12],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(service.getIntegrationConfigStatus());
|
||||||
|
|
||||||
|
hiddenValues.forEach((value) => {
|
||||||
|
expect(serialized).not.toContain(value);
|
||||||
|
});
|
||||||
|
expect(serialized).toContain('JENKINS_API_TOKEN');
|
||||||
|
expect(serialized).toContain('NOTIFICATION_PROVIDER');
|
||||||
|
expect(serialized).toContain('WECOM_WEBHOOK_URL');
|
||||||
|
expect(serialized).toContain('FEISHU_WEBHOOK_URL');
|
||||||
|
expect(serialized).toContain('SECRET_ENCRYPTION_KEY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createConfig(
|
||||||
|
values: Partial<Record<keyof EnvConfig, string | number | boolean | undefined>>,
|
||||||
|
): ConfigService<EnvConfig, true> {
|
||||||
|
return {
|
||||||
|
get: jest.fn((key: keyof EnvConfig) => values[key]),
|
||||||
|
} as unknown as ConfigService<EnvConfig, true>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import {
|
||||||
|
IntegrationConfigKey,
|
||||||
|
IntegrationConfigState,
|
||||||
|
IntegrationConfigStatus,
|
||||||
|
IntegrationConfigStatusResponse,
|
||||||
|
} from './settings.types';
|
||||||
|
|
||||||
|
type EnvStringKey = {
|
||||||
|
[Key in keyof EnvConfig]-?: Extract<EnvConfig[Key], string> extends never
|
||||||
|
? never
|
||||||
|
: Key;
|
||||||
|
}[keyof EnvConfig];
|
||||||
|
type EnvKey = keyof EnvConfig;
|
||||||
|
|
||||||
|
type IntegrationDefinition = {
|
||||||
|
key: IntegrationConfigKey;
|
||||||
|
name: string;
|
||||||
|
required: EnvStringKey[];
|
||||||
|
optional?: EnvKey[];
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
|
||||||
|
{
|
||||||
|
key: 'jenkins',
|
||||||
|
name: 'Jenkins',
|
||||||
|
required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gitea',
|
||||||
|
name: 'Gitea',
|
||||||
|
required: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
|
||||||
|
optional: ['GITEA_WEBHOOK_SECRET'],
|
||||||
|
note: 'GITEA_WEBHOOK_SECRET 用于开启 webhook 签名校验。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notification',
|
||||||
|
name: '通知平台',
|
||||||
|
required: ['NOTIFICATION_PROVIDER'],
|
||||||
|
optional: ['WECOM_WEBHOOK_URL', 'FEISHU_WEBHOOK_URL', 'NOTIFICATION_WEBHOOK_URL'],
|
||||||
|
note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'llm',
|
||||||
|
name: 'LLM Agent',
|
||||||
|
required: ['LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'database',
|
||||||
|
name: 'MySQL / Prisma',
|
||||||
|
required: ['DATABASE_URL'],
|
||||||
|
optional: ['USE_DATABASE_READS', 'PRISMA_CONNECT_ON_BOOT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'redis',
|
||||||
|
name: 'Redis / BullMQ',
|
||||||
|
required: ['REDIS_URL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'secrets',
|
||||||
|
name: '服务端密钥加密',
|
||||||
|
required: ['SECRET_ENCRYPTION_KEY'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLACEHOLDER_PATTERNS = [
|
||||||
|
/replace-with-/i,
|
||||||
|
/\.example\.internal(?:[/:]|$)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsService {
|
||||||
|
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
||||||
|
|
||||||
|
getIntegrationConfigStatus(): IntegrationConfigStatusResponse {
|
||||||
|
return {
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
integrations: INTEGRATION_DEFINITIONS.map((definition) =>
|
||||||
|
this.toIntegrationStatus(definition),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toIntegrationStatus(
|
||||||
|
definition: IntegrationDefinition,
|
||||||
|
): IntegrationConfigStatus {
|
||||||
|
const configured = definition.required.filter((key) =>
|
||||||
|
this.isConfiguredValue(this.getConfigValue(key)),
|
||||||
|
);
|
||||||
|
const missing = definition.required.filter(
|
||||||
|
(key) => !this.isConfiguredValue(this.getConfigValue(key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
name: definition.name,
|
||||||
|
status: this.resolveStatus(configured.length, missing.length),
|
||||||
|
required: [...definition.required],
|
||||||
|
configured,
|
||||||
|
missing,
|
||||||
|
...(definition.optional ? { optional: [...definition.optional] } : {}),
|
||||||
|
...(definition.note ? { note: definition.note } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfigValue(key: EnvStringKey): string | undefined {
|
||||||
|
const value = this.config.get(key, { infer: true });
|
||||||
|
|
||||||
|
return typeof value === 'string' ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isConfiguredValue(value: string | undefined): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveStatus(
|
||||||
|
configuredCount: number,
|
||||||
|
missingCount: number,
|
||||||
|
): IntegrationConfigState {
|
||||||
|
if (missingCount === 0) {
|
||||||
|
return 'configured';
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuredCount > 0 ? 'partial' : 'missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type IntegrationConfigKey =
|
||||||
|
| 'jenkins'
|
||||||
|
| 'gitea'
|
||||||
|
| 'notification'
|
||||||
|
| 'wecom'
|
||||||
|
| 'llm'
|
||||||
|
| 'database'
|
||||||
|
| 'redis'
|
||||||
|
| 'secrets';
|
||||||
|
|
||||||
|
export type IntegrationConfigState = 'configured' | 'partial' | 'missing';
|
||||||
|
|
||||||
|
export type IntegrationConfigStatus = {
|
||||||
|
key: IntegrationConfigKey;
|
||||||
|
name: string;
|
||||||
|
status: IntegrationConfigState;
|
||||||
|
required: string[];
|
||||||
|
configured: string[];
|
||||||
|
missing: string[];
|
||||||
|
optional?: string[];
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntegrationConfigStatusResponse = {
|
||||||
|
checkedAt: string;
|
||||||
|
integrations: IntegrationConfigStatus[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { verifyGiteaSignature } from './gitea-signature';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea webhook 签名必须基于原始 body,兼容两种常见签名头格式。
|
||||||
|
*/
|
||||||
|
describe('verifyGiteaSignature', () => {
|
||||||
|
const rawBody = Buffer.from(JSON.stringify({ ref: 'refs/heads/develop' }));
|
||||||
|
const secret = 'webhook-secret';
|
||||||
|
const hex = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||||
|
|
||||||
|
it('accepts unsigned webhooks when secret is not configured', () => {
|
||||||
|
expect(verifyGiteaSignature({ rawBody })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts X-Gitea-Signature hex value', () => {
|
||||||
|
expect(verifyGiteaSignature({ rawBody, secret, signature: hex })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts X-Hub-Signature-256 value', () => {
|
||||||
|
expect(
|
||||||
|
verifyGiteaSignature({
|
||||||
|
rawBody,
|
||||||
|
secret,
|
||||||
|
hubSignature256: `sha256=${hex}`,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mismatched signatures', () => {
|
||||||
|
expect(
|
||||||
|
verifyGiteaSignature({
|
||||||
|
rawBody,
|
||||||
|
secret,
|
||||||
|
signature: 'bad-signature',
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 Gitea webhook HMAC-SHA256 签名,兼容 X-Gitea-Signature 与 GitHub 风格头。
|
||||||
|
*/
|
||||||
|
export function verifyGiteaSignature(input: {
|
||||||
|
rawBody?: Buffer;
|
||||||
|
secret?: string;
|
||||||
|
signature?: string;
|
||||||
|
hubSignature256?: string;
|
||||||
|
}): boolean {
|
||||||
|
if (!input.secret) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.rawBody) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedHex = createHmac('sha256', input.secret)
|
||||||
|
.update(input.rawBody)
|
||||||
|
.digest('hex');
|
||||||
|
const candidates = [
|
||||||
|
normalizeSignature(input.signature),
|
||||||
|
normalizeSignature(input.hubSignature256),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return candidates.some((candidate) => safeEqualHex(expectedHex, candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSignature(signature?: string): string | undefined {
|
||||||
|
if (!signature) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = signature.trim();
|
||||||
|
return trimmed.startsWith('sha256=') ? trimmed.slice('sha256='.length) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqualHex(left: string, right?: string): boolean {
|
||||||
|
if (!right || !/^[a-f0-9]+$/i.test(right)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftBuffer = Buffer.from(left, 'hex');
|
||||||
|
const rightBuffer = Buffer.from(right, 'hex');
|
||||||
|
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Headers,
|
||||||
|
Post,
|
||||||
|
RawBody,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { GiteaWebhookHeaders, GiteaWebhookResult } from './gitea-webhook.types';
|
||||||
|
import { GiteaWebhookService } from './gitea-webhook.service';
|
||||||
|
|
||||||
|
@ApiTags('webhooks')
|
||||||
|
@Controller('webhooks/gitea')
|
||||||
|
export class GiteaWebhookController {
|
||||||
|
constructor(private readonly giteaWebhookService: GiteaWebhookService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiCreatedResponse({ description: 'Receive a Gitea webhook event.' })
|
||||||
|
async receiveWebhook(
|
||||||
|
@Headers('x-gitea-event') event: string | undefined,
|
||||||
|
@Headers('x-gitea-delivery') delivery: string | undefined,
|
||||||
|
@Headers('x-gitea-signature') signature: string | undefined,
|
||||||
|
@Headers('x-hub-signature-256') hubSignature256: string | undefined,
|
||||||
|
@Body() payload: unknown,
|
||||||
|
@RawBody() rawBody?: Buffer,
|
||||||
|
): Promise<GiteaWebhookResult> {
|
||||||
|
const headers: GiteaWebhookHeaders = {
|
||||||
|
event,
|
||||||
|
delivery,
|
||||||
|
signature,
|
||||||
|
hubSignature256,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.giteaWebhookService.handleWebhook({
|
||||||
|
headers,
|
||||||
|
payload,
|
||||||
|
rawBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DeployRunsModule } from '../../deploy-runs/deploy-runs.module';
|
||||||
|
import { ProjectsModule } from '../../projects/projects.module';
|
||||||
|
import { GiteaWebhookController } from './gitea-webhook.controller';
|
||||||
|
import { GiteaWebhookService } from './gitea-webhook.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ProjectsModule, DeployRunsModule],
|
||||||
|
controllers: [GiteaWebhookController],
|
||||||
|
providers: [GiteaWebhookService],
|
||||||
|
})
|
||||||
|
export class GiteaWebhookModule {}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuditService } from '../../audit/audit.service';
|
||||||
|
import { AppError } from '../../common/errors/app-error';
|
||||||
|
import { EnvConfig } from '../../config/env.schema';
|
||||||
|
import { DeployRunsService } from '../../deploy-runs/deploy-runs.service';
|
||||||
|
import { ProjectsService } from '../../projects/projects.service';
|
||||||
|
import { GiteaWebhookService } from './gitea-webhook.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea webhook 是测试环境自动部署入口,必须只接受受管仓库的分支 push。
|
||||||
|
*/
|
||||||
|
describe('GiteaWebhookService', () => {
|
||||||
|
const pushPayload = {
|
||||||
|
ref: 'refs/heads/develop',
|
||||||
|
after: 'abc123',
|
||||||
|
repository: {
|
||||||
|
name: 'access-manage',
|
||||||
|
full_name: 'my-project/access-manage',
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
login: 'developer',
|
||||||
|
},
|
||||||
|
head_commit: {
|
||||||
|
message: 'feat: smoke deploy',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createService(secret?: string) {
|
||||||
|
const config = {
|
||||||
|
get: jest.fn((key: keyof EnvConfig) =>
|
||||||
|
key === 'GITEA_WEBHOOK_SECRET' ? secret : undefined,
|
||||||
|
),
|
||||||
|
} satisfies Pick<ConfigService<EnvConfig, true>, 'get'>;
|
||||||
|
const projects = {
|
||||||
|
listProjects: jest.fn(() =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: 'project_access_manage',
|
||||||
|
key: 'access-manage',
|
||||||
|
name: 'access-manage',
|
||||||
|
repositoryUrl:
|
||||||
|
'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active' as const,
|
||||||
|
environments: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
} satisfies Pick<ProjectsService, 'listProjects'>;
|
||||||
|
const deployRuns = {
|
||||||
|
createRun: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'gitea:delivery-1:access-manage:develop:abc123',
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'test' as const,
|
||||||
|
ref: 'develop',
|
||||||
|
status: 'pending' as const,
|
||||||
|
trigger: 'gitea_webhook' as const,
|
||||||
|
operator: 'gitea:developer',
|
||||||
|
createdAt: '2026-06-11T00:00:00.000Z',
|
||||||
|
steps: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} satisfies Pick<DeployRunsService, 'createRun'>;
|
||||||
|
const audit = {
|
||||||
|
record: jest.fn(),
|
||||||
|
} satisfies Pick<AuditService, 'record'>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
audit,
|
||||||
|
deployRuns,
|
||||||
|
projects,
|
||||||
|
service: new GiteaWebhookService(
|
||||||
|
config as unknown as ConfigService<EnvConfig, true>,
|
||||||
|
projects as unknown as ProjectsService,
|
||||||
|
deployRuns as unknown as DeployRunsService,
|
||||||
|
audit as unknown as AuditService,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a test deploy run for a managed branch push', async () => {
|
||||||
|
const { audit, deployRuns, service } = createService();
|
||||||
|
|
||||||
|
const result = await service.handleWebhook({
|
||||||
|
headers: { event: 'push', delivery: 'delivery-1' },
|
||||||
|
payload: pushPayload,
|
||||||
|
rawBody: Buffer.from(JSON.stringify(pushPayload)),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.accepted).toBe(true);
|
||||||
|
expect(result.deployRunId).toBe('gitea:delivery-1:access-manage:develop:abc123');
|
||||||
|
expect(deployRuns.createRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'test',
|
||||||
|
ref: 'develop',
|
||||||
|
trigger: 'gitea_webhook',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'GITEA_WEBHOOK_ACCEPTED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores tag push events', async () => {
|
||||||
|
const { deployRuns, service } = createService();
|
||||||
|
|
||||||
|
const result = await service.handleWebhook({
|
||||||
|
headers: { event: 'push', delivery: 'delivery-2' },
|
||||||
|
payload: {
|
||||||
|
...pushPayload,
|
||||||
|
ref: 'refs/tags/v2026.06.11',
|
||||||
|
},
|
||||||
|
rawBody: Buffer.from('{}'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.accepted).toBe(false);
|
||||||
|
expect(result.reason).toContain('Only branch push');
|
||||||
|
expect(deployRuns.createRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid signatures when secret is configured', async () => {
|
||||||
|
const { service } = createService('webhook-secret');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.handleWebhook({
|
||||||
|
headers: {
|
||||||
|
event: 'push',
|
||||||
|
delivery: 'delivery-3',
|
||||||
|
signature: 'invalid',
|
||||||
|
},
|
||||||
|
payload: pushPayload,
|
||||||
|
rawBody: Buffer.from(JSON.stringify(pushPayload)),
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(AppError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuditService } from '../../audit/audit.service';
|
||||||
|
import { AppError } from '../../common/errors/app-error';
|
||||||
|
import { EnvConfig } from '../../config/env.schema';
|
||||||
|
import { DeployRunsService } from '../../deploy-runs/deploy-runs.service';
|
||||||
|
import { ProjectsService } from '../../projects/projects.service';
|
||||||
|
import {
|
||||||
|
GiteaPushPayload,
|
||||||
|
GiteaWebhookHeaders,
|
||||||
|
GiteaWebhookResult,
|
||||||
|
} from './gitea-webhook.types';
|
||||||
|
import { verifyGiteaSignature } from './gitea-signature';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Gitea push webhook 转换为测试环境发布单,生产发布仍保留人工入口。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GiteaWebhookService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService<EnvConfig, true>,
|
||||||
|
private readonly projectsService: ProjectsService,
|
||||||
|
private readonly deployRunsService: DeployRunsService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleWebhook(input: {
|
||||||
|
headers: GiteaWebhookHeaders;
|
||||||
|
payload: unknown;
|
||||||
|
rawBody?: Buffer;
|
||||||
|
}): Promise<GiteaWebhookResult> {
|
||||||
|
this.assertSignature(input);
|
||||||
|
|
||||||
|
if (input.headers.event !== 'push') {
|
||||||
|
return this.ignore(input.headers, 'Only push events can trigger test deploys');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.asPushPayload(input.payload);
|
||||||
|
|
||||||
|
if (!payload.ref.startsWith('refs/heads/')) {
|
||||||
|
return this.ignore(input.headers, 'Only branch push events trigger test deploys');
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = payload.ref.replace('refs/heads/', '');
|
||||||
|
const projects = await this.projectsService.listProjects();
|
||||||
|
const project = projects.find((item) => {
|
||||||
|
const repositoryName = item.repositoryUrl.replace(/\.git$/, '').split('/').pop();
|
||||||
|
const fullName = item.repositoryUrl.replace(/\.git$/, '').split('/').slice(-2).join('/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
item.key === payload.repository.name ||
|
||||||
|
repositoryName === payload.repository.name ||
|
||||||
|
fullName === payload.repository.full_name
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return this.ignore(input.headers, 'Repository is not managed by this platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedBranches = new Set([project.defaultBranch, 'develop', 'master']);
|
||||||
|
|
||||||
|
if (!allowedBranches.has(branch)) {
|
||||||
|
return this.ignore(
|
||||||
|
input.headers,
|
||||||
|
`Branch ${branch} is not configured for automatic test deploy`,
|
||||||
|
project.key,
|
||||||
|
branch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const operator =
|
||||||
|
payload.sender?.login ?? payload.sender?.username ?? 'gitea-webhook';
|
||||||
|
const run = await this.deployRunsService.createRun({
|
||||||
|
projectKey: project.key,
|
||||||
|
environment: 'test',
|
||||||
|
ref: branch,
|
||||||
|
operator: `gitea:${operator}`,
|
||||||
|
remark: payload.head_commit?.message ?? 'Gitea push webhook',
|
||||||
|
idempotencyKey: [
|
||||||
|
'gitea',
|
||||||
|
input.headers.delivery ?? 'no-delivery',
|
||||||
|
project.key,
|
||||||
|
branch,
|
||||||
|
payload.after,
|
||||||
|
].join(':'),
|
||||||
|
trigger: 'gitea_webhook',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'GITEA_WEBHOOK_ACCEPTED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
resourceId: run.id,
|
||||||
|
actorName: `gitea:${operator}`,
|
||||||
|
after: {
|
||||||
|
delivery: input.headers.delivery,
|
||||||
|
projectKey: project.key,
|
||||||
|
branch,
|
||||||
|
after: payload.after,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
event: input.headers.event,
|
||||||
|
delivery: input.headers.delivery,
|
||||||
|
projectKey: project.key,
|
||||||
|
branch,
|
||||||
|
deployRunId: run.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSignature(input: {
|
||||||
|
headers: GiteaWebhookHeaders;
|
||||||
|
rawBody?: Buffer;
|
||||||
|
}): void {
|
||||||
|
const secret = this.config.get('GITEA_WEBHOOK_SECRET', { infer: true });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!verifyGiteaSignature({
|
||||||
|
secret,
|
||||||
|
rawBody: input.rawBody,
|
||||||
|
signature: input.headers.signature,
|
||||||
|
hubSignature256: input.headers.hubSignature256,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', 'Invalid Gitea webhook signature', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private asPushPayload(payload: unknown): GiteaPushPayload {
|
||||||
|
if (!isRecord(payload) || !isRecord(payload.repository)) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', 'Invalid Gitea push payload', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = typeof payload.ref === 'string' ? payload.ref : '';
|
||||||
|
const after = typeof payload.after === 'string' ? payload.after : '';
|
||||||
|
|
||||||
|
if (!ref || !after) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', 'Gitea push payload missing ref or after', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as GiteaPushPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ignore(
|
||||||
|
headers: GiteaWebhookHeaders,
|
||||||
|
reason: string,
|
||||||
|
projectKey?: string,
|
||||||
|
branch?: string,
|
||||||
|
): GiteaWebhookResult {
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'GITEA_WEBHOOK_IGNORED',
|
||||||
|
resourceType: 'gitea_webhook',
|
||||||
|
resourceId: headers.delivery,
|
||||||
|
after: {
|
||||||
|
event: headers.event,
|
||||||
|
reason,
|
||||||
|
projectKey,
|
||||||
|
branch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accepted: false,
|
||||||
|
event: headers.event ?? 'unknown',
|
||||||
|
delivery: headers.delivery,
|
||||||
|
reason,
|
||||||
|
projectKey,
|
||||||
|
branch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Gitea push webhook 的最小可信字段集合,避免服务层依赖完整平台 payload。
|
||||||
|
*/
|
||||||
|
export type GiteaWebhookHeaders = {
|
||||||
|
event?: string;
|
||||||
|
delivery?: string;
|
||||||
|
signature?: string;
|
||||||
|
hubSignature256?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaPushPayload = {
|
||||||
|
ref: string;
|
||||||
|
after: string;
|
||||||
|
repository: {
|
||||||
|
name?: string;
|
||||||
|
full_name?: string;
|
||||||
|
clone_url?: string;
|
||||||
|
html_url?: string;
|
||||||
|
};
|
||||||
|
sender?: {
|
||||||
|
login?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
head_commit?: {
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaWebhookResult = {
|
||||||
|
accepted: boolean;
|
||||||
|
event: string;
|
||||||
|
delivery?: string;
|
||||||
|
reason?: string;
|
||||||
|
projectKey?: string;
|
||||||
|
branch?: string;
|
||||||
|
deployRunId?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"types": ["node", "jest"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "prisma/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user