fix(deploy): support isolated temporary production release

This commit is contained in:
2026-10-07 11:09:42 +08:00
parent b3f78207b8
commit b7fcc14e59
5 changed files with 131 additions and 9 deletions
Vendored
+28 -4
View File
@@ -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'
}
}
}
}
}
+22
View File
@@ -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
+58
View File
@@ -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
+9
View File
@@ -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"
+14 -5
View File
@@ -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/)
})