From dd1e6af8b350e8f5e3c4c3ca78265ecb538f16a2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 17 Sep 2026 11:49:01 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E5=A2=9E=E5=8A=A0=20Client=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E9=83=A8=E7=BD=B2=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 77 ++++++++++++++++++++++-- docker-compose.production.yml | 19 ++++++ scripts/deploy-production.sh | 93 +++++++++++++++++++++++++++++ src/deployment/nginxConfig.test.mjs | 12 +++- 4 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 docker-compose.production.yml create mode 100644 scripts/deploy-production.sh diff --git a/Jenkinsfile b/Jenkinsfile index 25b1c5c..3b003a9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ pipeline { agent { - label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}" + label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}" } environment { @@ -10,6 +10,7 @@ pipeline { REGISTRY_CREDENTIALS = 'gitea-registry-token' NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine' NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine' + PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/client-web' } options { @@ -22,6 +23,17 @@ pipeline { steps { script { env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false' + env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false' + if (env.IS_PRODUCTION == 'true') { + sh ''' + set -eu + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { + echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2 + exit 1 + } + ''' + } env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } sh 'git log -1 --oneline' @@ -29,7 +41,7 @@ pipeline { } stage('构建并部署测试前端') { - when { expression { env.IS_DEVELOPMENT != 'true' } } + when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } steps { withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { sh ''' @@ -50,6 +62,61 @@ pipeline { } } + stage('构建并发布生产镜像') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + sh ''' + set -eu + export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" + rm -rf "$DOCKER_CONFIG" + mkdir -p "$DOCKER_CONFIG" + cleanup_registry() { + docker logout git.buggoglobal.com >/dev/null 2>&1 || true + rm -rf "$DOCKER_CONFIG" + } + trap cleanup_registry EXIT HUP INT TERM + printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.buggoglobal.com + image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG" + docker build \ + --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ + --build-arg VITE_ADMIN_WEB_ORIGIN=https://admin.buggoglobal.com \ + -t "$image" . + docker push "$image" + ''' + } + } + } + + stage('确认部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 app.buggoglobal.com?", ok: '确认部署' + } + } + + stage('部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + lock(resource: 'firstleg-production-client-web-deploy', inversePrecedence: true) { + withCredentials([ + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + withEnv([ + 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend', + 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST + ]) { + sh "sh scripts/deploy-production.sh client-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml" + } + } + } + } + } + stage('构建并部署开发前端') { when { expression { env.IS_DEVELOPMENT == 'true' } } steps { @@ -82,7 +149,7 @@ pipeline { } stage('部署检查') { - when { expression { env.IS_DEVELOPMENT != 'true' } } + when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } steps { sh ''' set -eu @@ -96,7 +163,9 @@ pipeline { post { failure { sh ''' - if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then + if [ "${IS_PRODUCTION:-false}" = "true" ]; then + echo "生产部署失败,请查看上方远程部署日志" + elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then docker logs --tail=100 client-web-development || true else docker compose -f docker-compose.test.yml logs --tail=100 || true diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..167b779 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,19 @@ +name: firstleg-prod-client-web + +services: + client-web: + image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required} + container_name: firstleg-prod-client-web + restart: unless-stopped + ports: + - "127.0.0.1:${HOST_PORT:-9002}:80" + environment: + API_UPSTREAM: "http://gateway:9000" + OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300" + networks: + - infra-network + +networks: + infra-network: + external: true + name: firstleg-prod-infra-network diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..821a711 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2 + exit 2 +fi + +SERVICE="$1" +IMAGE_REPOSITORY="$2" +IMAGE_TAG="$3" +COMPOSE_FILE="$4" +IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG" +REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" +SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" +SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" +SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" +REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" +: "${REGISTRY_USER:?REGISTRY_USER 未设置}" +: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" + +run_ssh() { + ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" +REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml" +run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp" +run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE' +set -eu +compose_tmp="$1" +compose_target="$2" +chmod 644 "$compose_tmp" +mv -f "$compose_tmp" "$compose_target" +REMOTE_PREPARE + +printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" +trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM +run_ssh docker pull "$IMAGE" + +run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +service="$2" +image_repository="$3" +image_tag="$4" +compose_file="docker-compose.$service.yml" +project_name="firstleg-prod-$service" +cd "$remote_dir" +export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag" + +compose() { + docker compose --project-name "$project_name" -f "$compose_file" "$@" +} + +container_id="$(compose ps --all --quiet "$service" | head -n 1)" +previous_image="" +if [ -n "$container_id" ]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")" +fi + +if compose up -d --no-deps --pull never "$service" && \ + compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then + echo "生产前端部署成功: $image_repository:$image_tag" + compose ps "$service" + exit 0 +fi + +echo "生产前端健康检查失败,准备回滚" >&2 +compose logs --tail=100 "$service" >&2 || true +case "$previous_image" in + "$image_repository:"*) ;; + '') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;; + *) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;; +esac +previous_tag="${previous_image#"$image_repository:"}" +if [ "$previous_tag" = "$image_tag" ]; then + echo "旧镜像与新镜像一致,无法回滚" >&2 + exit 1 +fi +docker pull "$previous_image" +export IMAGE_TAG="$previous_tag" +compose up -d --no-deps --pull never "$service" +compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true +echo "已回滚至: $previous_image" >&2 +exit 1 +REMOTE_DEPLOY diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index f7632ce..691c0f4 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test' const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8') const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8') const developmentCompose = readFileSync(new URL('../../docker-compose.development.yml', import.meta.url), 'utf8') +const productionCompose = readFileSync(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8') const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8') const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8') @@ -38,11 +39,16 @@ test('connects the development client proxy to both development gateways', () => assert.doesNotMatch(developmentCompose, /test-api|prod(?:uction)?/i) }) -test('runs each deployment on its target node without controller-local SSH files', () => { - assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-dev'\) \? 'java-dev' : 'java-test'/) +test('keeps local environments on target nodes and uses credential-managed SSH for production', () => { + assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-prod'\) \? 'java-builder'/) assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/) assert.match(jenkinsfile, /docker compose -p "\$DEVELOPMENT_COMPOSE_PROJECT" -f "\$DEVELOPMENT_COMPOSE_FILE" up -d/) - assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save|\bssh\b/) + assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save/) + assert.match(jenkinsfile, /credentialsId: 'firstleg-production-ssh'/) + assert.match(jenkinsfile, /git rev-parse origin\/main/) + assert.match(productionCompose, /127\.0\.0\.1:\$\{HOST_PORT:-9002\}:80/) + assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(productionCompose, /name: firstleg-prod-infra-network/) }) test('uses authenticated internal base images in CI', () => {