fix(deploy): support isolated temporary production release
This commit is contained in:
Vendored
+28
-4
@@ -1,15 +1,16 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent { label 'java-test' }
|
agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" }
|
||||||
options {
|
options {
|
||||||
timestamps()
|
timestamps()
|
||||||
disableConcurrentBuilds()
|
disableConcurrentBuilds()
|
||||||
}
|
}
|
||||||
stages {
|
stages {
|
||||||
stage('检查测试发布参数') {
|
stage('检查测试发布参数') {
|
||||||
|
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) {
|
if (env.JOB_BASE_NAME?.endsWith('-dev')) {
|
||||||
error('当前仅支持 test,请使用测试环境 Job')
|
error('此流水线仅支持 test 和本轮授权的 prod 灰度')
|
||||||
}
|
}
|
||||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
stage('构建并部署测试前端') {
|
stage('构建并部署测试前端') {
|
||||||
|
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||||
steps {
|
steps {
|
||||||
sh '''set -eu
|
sh '''set -eu
|
||||||
docker compose -f docker-compose.test.yml build --pull=false
|
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 {
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -26,13 +26,12 @@ test('test deployment requires an explicit shared network, Gateway and version',
|
|||||||
assert.match(compose, /external: true/)
|
assert.match(compose, /external: true/)
|
||||||
assert.match(compose, /127\.0\.0\.1:/)
|
assert.match(compose, /127\.0\.0\.1:/)
|
||||||
assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
|
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 compose -f docker-compose\.test\.yml config --quiet/)
|
||||||
assert.match(jenkins, /docker network inspect/)
|
assert.match(jenkins, /docker network inspect/)
|
||||||
assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/)
|
assert.match(jenkins, /scripts\/deploy-production\.sh/)
|
||||||
for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) {
|
assert.match(jenkins, /firstleg-production-ssh/)
|
||||||
assert.equal(existsSync(new URL(file, root)), false)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('application shell is revalidated and missing chunks remain 404', () => {
|
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(compose, /http:\/\/127\.0\.0\.1\/healthz/)
|
||||||
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
|
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/)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user