From b7fcc14e59fe2f063c2235ff72d697788666b8a0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 11:09:42 +0800 Subject: [PATCH] fix(deploy): support isolated temporary production release --- Jenkinsfile | 32 ++++++++++++++-- docker-compose.production.yml | 22 +++++++++++ scripts/deploy-production.sh | 58 +++++++++++++++++++++++++++++ scripts/pull-production-image.sh | 9 +++++ src/deployment/nginxConfig.test.mjs | 19 +++++++--- 5 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 docker-compose.production.yml create mode 100644 scripts/deploy-production.sh create mode 100644 scripts/pull-production-image.sh diff --git a/Jenkinsfile b/Jenkinsfile index 877891f..19da01a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,15 +1,16 @@ pipeline { - agent { label 'java-test' } + agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" } options { timestamps() disableConcurrentBuilds() } stages { stage('检查测试发布参数') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { script { - if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) { - error('当前仅支持 test,请使用测试环境 Job') + if (env.JOB_BASE_NAME?.endsWith('-dev')) { + error('此流水线仅支持 test 和本轮授权的 prod 灰度') } env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } @@ -23,6 +24,7 @@ pipeline { } } stage('构建并部署测试前端') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu docker compose -f docker-compose.test.yml build --pull=false @@ -31,8 +33,30 @@ pipeline { ''' } } + stage('发布临时生产灰度') { + when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } } + steps { + lock(resource: 'firstleg-production-client-web-deploy') { + withCredentials([ + usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'), + 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') + ]) { + sh 'sh scripts/deploy-production.sh' + } + } + } + } } post { - failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' } + failure { + script { + if (!env.JOB_BASE_NAME?.endsWith('-prod')) { + sh '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..e7f339d --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,22 @@ +name: firstleg-prod-client-web +services: + client-web: + image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_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:-29012}:80" + environment: + API_UPSTREAM: "http://gateway:9000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + networks: + - backend +networks: + backend: + external: true + name: firstleg-production-network diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..ebcb234 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu +set +x +: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}" +: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}" +: "${TARGET_SSH_KEY:?}" +: "${TARGET_SSH_USER:?}" +: "${TARGET_SSH_HOST:?}" +: "${TARGET_SSH_KNOWN_HOSTS:?}" +: "${BUILD_REGISTRY_USER:?}" +: "${BUILD_REGISTRY_PASSWORD:?}" +: "${REGISTRY_USER:?}" +: "${REGISTRY_PASSWORD:?}" +case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac +# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters. +printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$' +printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$' +git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +IMAGE_TAG=$(git rev-parse --short=12 HEAD) +registry=${PRODUCTION_IMAGE_REPOSITORY%%/*} +image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG" +TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST" +remote_dir=/data/firstleg/deploy/gray-client-web +run_ssh() { + ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@" +} +run_scp() { + scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@" +} +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" +docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + --build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" . +docker push "$image" +run_ssh install -d -m 750 "$remote_dir" +run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml" +# The remote env file contains only image coordinates and public web origin, never credentials. +printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \ + "$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'" +# Registry credentials exist only in an isolated temporary Docker config, removed after pull. +printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$' +run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh" +printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image" +run_ssh sh -s -- "$remote_dir" <<'REMOTE' +set -eu +cd "$1" +docker network inspect firstleg-production-network >/dev/null +docker compose --env-file release.env -f compose.yml config --quiet +docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120 +docker compose --env-file release.env -f compose.yml exec -T client-web wget -qO- http://127.0.0.1/healthz +REMOTE diff --git a/scripts/pull-production-image.sh b/scripts/pull-production-image.sh new file mode 100644 index 0000000..91562c3 --- /dev/null +++ b/scripts/pull-production-image.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +set +x +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +docker login -u "$1" --password-stdin "$2" +docker pull "$3" diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 67bee6b..83548bf 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -26,13 +26,12 @@ test('test deployment requires an explicit shared network, Gateway and version', assert.match(compose, /external: true/) assert.match(compose, /127\.0\.0\.1:/) assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/) - assert.match(jenkins, /label 'java-test'/) + assert.match(jenkins, /'java-builder' : 'java-test'/) assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/) assert.match(jenkins, /docker network inspect/) - assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/) - for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) { - assert.equal(existsSync(new URL(file, root)), false) - } + assert.match(jenkins, /scripts\/deploy-production\.sh/) + assert.match(jenkins, /firstleg-production-ssh/) + }) test('application shell is revalidated and missing chunks remain 404', () => { @@ -52,3 +51,13 @@ test('container build excludes local environment values and waits for Nginx heal assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/) assert.match(jenkins, /up -d --wait --wait-timeout 120/) }) + +test('gray production is isolated from TEST and routes to the single Gateway', () => { + const production = readFileSync(new URL('docker-compose.production.yml', root), 'utf8') + assert.match(production, /name: firstleg-production-network/) + assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(production, /127\.0\.0\.1:/) + assert.match(production, /healthcheck:/) + assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/) + assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/) +})