pipeline { agent any options { disableConcurrentBuilds() buildDiscarder(logRotator(numToKeepStr: '20')) skipDefaultCheckout(true) } parameters { string( name: 'ACR_REGISTRY', defaultValue: '', description: 'ACR 登录域名,不包含 https://,优先使用与 ACK 同 VPC 的内网域名' ) string(name: 'ACR_NAMESPACE', defaultValue: 'wonderq', description: 'ACR 命名空间') string(name: 'API_REPOSITORY', defaultValue: 'wonderq-api', description: 'API 镜像仓库名') string(name: 'ADMIN_REPOSITORY', defaultValue: 'wonderq-admin-ui', description: 'Admin UI 镜像仓库名') string(name: 'ACK_NAMESPACE', defaultValue: 'wonderq-prod', description: 'ACK Kubernetes 命名空间') string(name: 'API_DEPLOYMENT', defaultValue: 'wonderq-api', description: 'API Deployment 名称') string(name: 'API_CONTAINER', defaultValue: 'api', description: 'API Deployment 中的容器名称') string(name: 'ADMIN_DEPLOYMENT', defaultValue: 'wonderq-admin-ui', description: 'Admin UI Deployment 名称') string(name: 'ADMIN_CONTAINER', defaultValue: 'admin-ui', description: 'Admin UI Deployment 中的容器名称') string( name: 'ADMIN_API_BASE_URL', defaultValue: '', description: 'Admin UI 构建时的 API 地址;ALB 使用同域名 /api 分流时保持为空' ) string( name: 'ACR_CREDENTIALS_ID', defaultValue: 'acr-push-credential', description: 'Jenkins 中 ACR 用户名密码 Credential ID' ) string( name: 'ACK_KUBECONFIG_CREDENTIALS_ID', defaultValue: 'ack-prod-kubeconfig', description: 'Jenkins 中 ACK kubeconfig Secret File Credential ID' ) booleanParam(name: 'DEPLOY_TO_ACK', defaultValue: true, description: '推送镜像后是否更新 ACK Deployment') } environment { DOCKER_BUILDKIT = '1' } stages { stage('Checkout') { steps { checkout scm script { def commit = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() env.IMAGE_TAG = "${commit}-${env.BUILD_NUMBER}" env.API_IMAGE = "${params.ACR_REGISTRY}/${params.ACR_NAMESPACE}/${params.API_REPOSITORY}" env.ADMIN_IMAGE = "${params.ACR_REGISTRY}/${params.ACR_NAMESPACE}/${params.ADMIN_REPOSITORY}" } } } stage('Validate') { steps { sh ''' set -eu if [ -z "$ACR_REGISTRY" ]; then echo "ACR_REGISTRY 不能为空" >&2 exit 1 fi case "$ACR_REGISTRY" in *://*) echo "ACR_REGISTRY 只填写域名,不要包含协议" >&2 exit 1 ;; esac command -v docker >/dev/null docker version >/dev/null if [ "$DEPLOY_TO_ACK" = "true" ]; then command -v kubectl >/dev/null fi ''' } } stage('Build API') { steps { sh ''' set -eu docker build \ --tag "$API_IMAGE:$IMAGE_TAG" \ WonderQ-Admin ''' } } stage('Test API') { steps { sh ''' set -eu test_container='' cleanup_test_container() { if [ -n "$test_container" ]; then docker rm --force "$test_container" >/dev/null 2>&1 || true fi } trap cleanup_test_container EXIT test_container="$(docker create \ --entrypoint python \ "$API_IMAGE:$IMAGE_TAG" \ -m pytest)" docker cp WonderQ-Admin/tests/. "$test_container:/app/tests" docker start --attach "$test_container" ''' } } stage('Build Admin UI') { steps { sh ''' set -eu docker build \ --build-arg "VITE_API_BASE_URL=$ADMIN_API_BASE_URL" \ --tag "$ADMIN_IMAGE:$IMAGE_TAG" \ WonderQ-Admin-UI docker run --rm \ --entrypoint nginx \ "$ADMIN_IMAGE:$IMAGE_TAG" \ -t ''' } } stage('Push Images') { steps { withCredentials([ usernamePassword( credentialsId: params.ACR_CREDENTIALS_ID, usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD' ) ]) { sh ''' set -eu set +x docker_config="$(mktemp -d)" export DOCKER_CONFIG="$docker_config" cleanup_docker_config() { docker logout "$ACR_REGISTRY" >/dev/null 2>&1 || true rm -rf -- "$docker_config" } trap cleanup_docker_config EXIT printf '%s' "$ACR_PASSWORD" | docker login \ --username "$ACR_USERNAME" \ --password-stdin \ "$ACR_REGISTRY" docker push "$API_IMAGE:$IMAGE_TAG" docker push "$ADMIN_IMAGE:$IMAGE_TAG" ''' } } } stage('Deploy ACK') { when { expression { params.DEPLOY_TO_ACK } } steps { withCredentials([ file( credentialsId: params.ACK_KUBECONFIG_CREDENTIALS_ID, variable: 'ACK_KUBECONFIG' ) ]) { sh ''' set -eu kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ get deployment "$API_DEPLOYMENT" >/dev/null kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ get deployment "$ADMIN_DEPLOYMENT" >/dev/null kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ set image "deployment/$API_DEPLOYMENT" \ "$API_CONTAINER=$API_IMAGE:$IMAGE_TAG" if ! kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ rollout status "deployment/$API_DEPLOYMENT" --timeout=300s; then kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ rollout undo "deployment/$API_DEPLOYMENT" || true exit 1 fi kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ set image "deployment/$ADMIN_DEPLOYMENT" \ "$ADMIN_CONTAINER=$ADMIN_IMAGE:$IMAGE_TAG" if ! kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ rollout status "deployment/$ADMIN_DEPLOYMENT" --timeout=300s; then kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ rollout undo "deployment/$ADMIN_DEPLOYMENT" || true kubectl --kubeconfig "$ACK_KUBECONFIG" \ --namespace "$ACK_NAMESPACE" \ rollout undo "deployment/$API_DEPLOYMENT" || true exit 1 fi ''' } } } } post { success { echo "部署完成,镜像标签:${env.IMAGE_TAG}" } failure { echo '流水线失败,请查看对应阶段日志;数据库迁移不会随 Deployment 回滚自动撤销。' } } }