From ed5cfe08b860cd86c5ea29ea4705a5e20188a459 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 10:19:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0jenkins=E6=89=93=E5=8C=85?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 236 +++++++++++++++++++++++++++++++++ WonderQ-Admin-UI/.dockerignore | 10 ++ WonderQ-Admin-UI/Dockerfile | 29 ++++ WonderQ-Admin-UI/nginx.conf | 33 +++++ 4 files changed, 308 insertions(+) create mode 100644 Jenkinsfile create mode 100644 WonderQ-Admin-UI/.dockerignore create mode 100644 WonderQ-Admin-UI/Dockerfile create mode 100644 WonderQ-Admin-UI/nginx.conf diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..cb54035 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,236 @@ +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 回滚自动撤销。' + } + } +} diff --git a/WonderQ-Admin-UI/.dockerignore b/WonderQ-Admin-UI/.dockerignore new file mode 100644 index 0000000..6f895df --- /dev/null +++ b/WonderQ-Admin-UI/.dockerignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.git/ +.idea/ +.env +.env.* +coverage/ +.vite/ +*.log + diff --git a/WonderQ-Admin-UI/Dockerfile b/WonderQ-Admin-UI/Dockerfile new file mode 100644 index 0000000..4278a6d --- /dev/null +++ b/WonderQ-Admin-UI/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS build + +WORKDIR /app + +COPY package.json yarn.lock ./ + +# The project keeps Windows native packages for local Yarn 1 installs. The lock +# file also contains the matching Linux packages used by this container build. +RUN yarn install --frozen-lockfile --non-interactive --ignore-platform + +COPY . . + +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL="${VITE_API_BASE_URL}" + +RUN yarn build + +FROM nginx:1.28-alpine AS runtime + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 + diff --git a/WonderQ-Admin-UI/nginx.conf b/WonderQ-Admin-UI/nginx.conf new file mode 100644 index 0000000..9c21d95 --- /dev/null +++ b/WonderQ-Admin-UI/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + root /usr/share/nginx/html; + index index.html; + charset utf-8; + server_tokens off; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_min_length 1024; + gzip_types text/css application/javascript application/json image/svg+xml; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /index.html { + expires -1; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +}