# 五阶段流水线,见 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 Registry(ACR) 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 Cluster(API 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「数据库迁移与回滚的协同」)。