backend scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:31:27 +08:00
commit 84fc2c0677
159 changed files with 10542 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
# 五阶段流水线,见 09-build-deploy.md。核心原则:
# **Build once, promote across environments with versioned artifacts and gated approvals**
# ——同一个镜像走 UAT 和 Prod,环境差异只在 ConfigMap/Secret/profile 上。
#
# Dev 环境**不在**这条流水线里:内网 k3s,手动跑 scripts/deploy-dev.sh(见 09 文档「环境层级」)。
stages:
- validate
- package
- release
- deploy-uat
- deploy-prod
variables:
# Gradle 缓存进 CI cache,避免每个 job 重新下载全部依赖
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # MR 触发 CI Validation(不部署)
- if: '$CI_COMMIT_BRANCH == "main"' # main 分支合入触发 CI Validation
- if: '$CI_COMMIT_TAG' # 打 protected tag 触发"选择版本发布"流程
.gradle-cache: &gradle-cache
key: gradle-deps
paths:
- .gradle/caches
- .gradle/wrapper
# ---------------------------------------------------------------------------
# 阶段二:CI Validation(质量门禁)
# ---------------------------------------------------------------------------
# 待接入 ktlint/detekt/jacoco 后启用。
# 本次骨架没有引入这三个插件,job 放开就是必红——所以先注释保留占位,
# 而不是留一个 allow_failure: true 的假绿灯(那种门禁一旦长期黄着,就等于没有)。
# 启用步骤:
# 1. 根 build.gradle 的 subprojects 里加
# org.jlleitschuh.gradle.ktlint、io.gitlab.arturbosch.detekt、jacoco 三个插件;
# 2. jacoco 配 jacocoTestReport + jacocoTestCoverageVerification(阈值先定低再逐步抬);
# 3. 取消下面这个 job 和 unit-integration-test 里 coverage_report 那几行的注释。
#
# lint:
# stage: validate
# script:
# - ./gradlew ktlintCheck detekt --no-daemon
# cache: *gradle-cache
unit-integration-test:
stage: validate
# Testcontainers 需要能起 Docker(见 10-testing.md)。
# Runner 上没有 Docker 的话,这里退化成 ./gradlew test -PexcludeIntegration
# 但那样 @DataJpaTest / WireMock 这批测试就不跑了,等于放掉了最能发现问题的一层。
services:
- docker:dind
script:
- ./gradlew test --no-daemon
artifacts:
when: always # 失败时更需要看到测试报告
reports:
junit: '**/build/test-results/test/TEST-*.xml'
# 待接入 jacoco 后启用:
# coverage_report:
# coverage_format: jacoco
# path: build/reports/jacoco/aggregate/jacocoTestReport.xml
cache: *gradle-cache
build-package:
stage: validate
script:
- ./gradlew :bootstrap:bootJar --no-daemon
artifacts:
paths:
- bootstrap/build/libs/*.jar # 传给 package 阶段的 Dockerfile 直接消费
expire_in: 1 week
cache: *gradle-cache
# 待接入依赖漏洞扫描后启用。
# 需要先在根 build.gradle 加 org.owasp.dependencycheck 插件,
# 并去 https://nvd.nist.gov/developers/request-an-api-key 免费申请 NVD_API_KEY 存成 masked variable
# ——不配 API Key 会卡在 "Updating the NVD CVE data" 几十分钟甚至直接超时失败。
#
# security-scan:
# stage: validate
# script:
# - ./gradlew dependencyCheckAnalyze -Dnvd.api.key=$NVD_API_KEY --no-daemon
# cache:
# key: nvd-db # 缓存漏洞库,避免每次流水线重新拉全量数据
# paths:
# - build/dependency-check-data
# ---------------------------------------------------------------------------
# 阶段三:Artifact & Release Controls(制品与发布控制)
# ---------------------------------------------------------------------------
docker-build-push:
stage: package
needs: [build-package] # 直接消费 validate 阶段的 jar artifact,镜像里不再重新编译
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # 推送到 Azure Container RegistryACR
image-scan:
stage: package
needs: [docker-build-push]
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
script:
# dependencyCheck 只覆盖我们自己声明的依赖,管不到基础镜像里的 OS 包(glibc、openssl 这类),
# 而那恰恰是镜像 CVE 的大头,所以镜像层面要单独扫。
# --ignore-unfixed 是刻意的:上游还没发补丁的 CVE 报出来也无法处理,
# 让它阻断流水线只会训练团队去无脑加白名单,最后所有告警一起失效。
- >-
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# SBOM:记录这个镜像里到底装了什么。将来爆出新 CVE 时,能直接查"我们哪些线上版本受影响",
# 而不是挨个把历史镜像拉下来重新扫一遍。
- syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -o cyclonedx-json > sbom.json
artifacts:
paths: [sbom.json]
cut-release:
stage: release
rules:
- if: '$CI_COMMIT_TAG'
script:
# 把已经验证过的 commit-sha 镜像"打标"成不可变的发布版本,而不是重新构建。
# 后续 UAT/Prod 部署的都是这同一个镜像摘要,保证"UAT 验证过的和 Prod 部署的字节级一致"。
- >-
az acr import --name $ACR_NAME
--source $ACR_NAME.azurecr.io/conti-backend:$CI_COMMIT_SHORT_SHA
--image conti-backend:$CI_COMMIT_TAG
# ---------------------------------------------------------------------------
# 阶段四:CD to Azure
#
# AKS 是 Private ClusterAPI Server 只能通过 Private Endpoint 访问),
# GitLab 的公网共享 Runner 连不上——deploy-* 系列 job 必须跑在 VNet 内的 self-hosted Runner 上。
# tags: [azure-vnet-runner] 就是这个约束的落地:公网 Runner 没有这个 tag,天然不会被误调度。
# ---------------------------------------------------------------------------
deploy-uat:
stage: deploy-uat
tags:
- azure-vnet-runner
environment:
name: uat
rules:
- if: '$CI_COMMIT_TAG'
when: manual # Promotion Gate:需要人工点击"Promote to UAT"
script:
- az login --identity # Runner 用 Managed Identity 登录,不存长期有效的 SP 密码
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
# Key Vault / Config Retrieval:现取值渲染成 K8s Secret
# 密钥只在这个 job 执行期间短暂存在,不打印到日志、不落盘到镜像
- JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --query value -o tsv)
- DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password --query value -o tsv)
- F6_API_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name f6-api-key --query value -o tsv)
- >-
kubectl create secret generic conti-backend-secret -n retailapp-uat
--from-literal=SECURITY_JWT_SECRET="$JWT_KEY"
--from-literal=DB_PASSWORD="$DB_PASSWORD"
--from-literal=F6_API_KEY="$F6_API_KEY"
--dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f k8s/rbac.yaml
- kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat
- kubectl apply -f k8s/pdb.yaml
# 部署的是 tag 不是 commit-sha:用的是 cut-release 生成的那个不可变发布版本,
# 这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-uat
- kubectl rollout status deployment/conti-backend -n retailapp-uat --timeout=180s
- curl -sf https://uat.internal.example.com/actuator/health || exit 1
deploy-prod:
stage: deploy-prod
tags:
- azure-vnet-runner
environment:
name: production
rules:
- if: '$CI_COMMIT_TAG'
when: manual # Promotion Gate:需要更高权限的人工审批
script:
- az login --identity
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
- JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --query value -o tsv)
- DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password-prod --query value -o tsv)
- F6_API_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name f6-api-key-prod --query value -o tsv)
- >-
kubectl create secret generic conti-backend-secret -n retailapp-prod
--from-literal=SECURITY_JWT_SECRET="$JWT_KEY"
--from-literal=DB_PASSWORD="$DB_PASSWORD"
--from-literal=F6_API_KEY="$F6_API_KEY"
--dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f k8s/rbac.yaml
- kubectl apply -f k8s/configmap-prod.yaml -n retailapp-prod
- kubectl apply -f k8s/pdb.yaml
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-prod
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
- curl -sf https://api.example.com/actuator/health || exit 1
rollback-prod:
stage: deploy-prod
tags:
- azure-vnet-runner
environment:
name: production
when: manual # 手动触发,回滚到"上一个已批准的镜像 tag"
script:
- az login --identity
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
# ROLLBACK_TAG 不是自动推导出来的,而是触发这个 job 时由操作人手工填入的变量。
# 取值来源:GitLab Environment "production" 的部署历史里,当前版本之前的那个 tag。
# 刻意不做成自动取"上一个"——回滚目标必须是人明确确认过的版本,
# 不能出现"上一个版本本身就是有问题的、结果自动回滚到它"这种情况。
- '[ -n "$ROLLBACK_TAG" ] || { echo "必须指定 ROLLBACK_TAG"; exit 1; }'
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$ROLLBACK_TAG -n retailapp-prod
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
# 提醒:镜像能秒回滚,**数据库不能**。Flyway 社区版没有 undo
# 破坏性变更必须走 expand-contract 两次发布,否则回滚镜像救不了已经改过的库
# (见 09-build-deploy.md「数据库迁移与回滚的协同」)。