Release/test 合并上生产 #2
Vendored
+73
-4
@@ -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/admin-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_CLIENT_WEB_URL=https://app.buggoglobal.com/ \
|
||||
-t "$image" .
|
||||
docker push "$image"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('确认部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 admin.buggoglobal.com?", ok: '确认部署'
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
lock(resource: 'firstleg-production-admin-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 admin-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 admin-web-development || true
|
||||
else
|
||||
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: firstleg-prod-admin-web
|
||||
|
||||
services:
|
||||
admin-web:
|
||||
image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: firstleg-prod-admin-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "https://app.buggoglobal.com/"
|
||||
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
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "用法: $0 <服务名> <镜像仓库> <镜像标签> <Compose 文件>" >&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
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
|
||||
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
|
||||
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8')
|
||||
const productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8')
|
||||
const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
|
||||
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
|
||||
|
||||
@@ -47,13 +48,17 @@ describe('admin nginx deployment config', () => {
|
||||
expect(config).toContain('proxy_set_header Host $proxy_host;')
|
||||
})
|
||||
|
||||
it('runs each deployment on its target node without controller-local SSH files', () => {
|
||||
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'")
|
||||
it('keeps local environments on target nodes and uses credential-managed SSH for production', () => {
|
||||
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder'")
|
||||
expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'")
|
||||
expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d')
|
||||
expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh')
|
||||
expect(jenkinsfile).not.toContain('docker save')
|
||||
expect(jenkinsfile).not.toMatch(/\bssh\b/)
|
||||
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
|
||||
expect(jenkinsfile).toContain('git rev-parse origin/main')
|
||||
expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80')
|
||||
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway:9000"')
|
||||
expect(productionCompose).toContain('name: firstleg-prod-infra-network')
|
||||
})
|
||||
|
||||
it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => {
|
||||
|
||||
Reference in New Issue
Block a user