fix(deploy): publish unified services to temporary gray environment
This commit is contained in:
Vendored
+5
-1
@@ -111,6 +111,8 @@ def fixedEnvironmentForJob(String jobBaseName) {
|
||||
switch (jobBaseName) {
|
||||
case 'backend-java':
|
||||
return 'test'
|
||||
case 'backend-java-prod':
|
||||
return 'production'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -120,6 +122,8 @@ def fixedDownstreamJobPrefixForJob(String jobBaseName) {
|
||||
switch (jobBaseName) {
|
||||
case 'backend-java':
|
||||
return 'baiyue-firstleg-'
|
||||
case 'backend-java-prod':
|
||||
return 'baiyue-firstleg-prod-'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -174,7 +178,7 @@ pipeline {
|
||||
}
|
||||
if (params.ENVIRONMENT != resolvedEnvironment ||
|
||||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
|
||||
error('当前流水线仅允许 TEST 与 baiyue-firstleg- 下游前缀')
|
||||
error('部署环境和下游前缀必须与当前 Job 固定映射一致')
|
||||
}
|
||||
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
|
||||
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
|
||||
|
||||
+38
-2
@@ -20,6 +20,7 @@ def serviceJars = [
|
||||
]
|
||||
|
||||
def fixedEnvironmentForServiceJob(String jobBaseName) {
|
||||
if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) return 'production'
|
||||
if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) {
|
||||
return 'test'
|
||||
}
|
||||
@@ -89,7 +90,7 @@ pipeline {
|
||||
error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
|
||||
}
|
||||
if (params.ENVIRONMENT != resolvedEnvironment) {
|
||||
error('当前单服务流水线仅允许 TEST')
|
||||
error('部署环境必须与当前单服务 Job 固定映射一致')
|
||||
}
|
||||
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
|
||||
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
|
||||
@@ -120,6 +121,13 @@ pipeline {
|
||||
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
|
||||
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
|
||||
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
|
||||
echo "灰度发布提交必须已经合并到 main" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
'''
|
||||
|
||||
|
||||
@@ -188,7 +196,7 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('部署 TEST') {
|
||||
when { expression { params.DEPLOY } }
|
||||
when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'test' } }
|
||||
agent none
|
||||
steps {
|
||||
script {
|
||||
@@ -209,6 +217,34 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署临时灰度') {
|
||||
when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } }
|
||||
agent none
|
||||
steps {
|
||||
script {
|
||||
lock(resource: "firstleg-production-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
|
||||
node('java-builder') {
|
||||
deleteDir()
|
||||
unstash 'deployment-files'
|
||||
withCredentials([
|
||||
file(credentialsId: 'firstleg-production-env', variable: 'DEPLOY_ENV_FILE'),
|
||||
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_IS_LOCAL=false', 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST,
|
||||
"ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) {
|
||||
sh 'sh deploy/scripts/deploy-built-service.sh production "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
post {
|
||||
|
||||
+21
-20
@@ -1,4 +1,4 @@
|
||||
name: firstleg-test
|
||||
name: firstleg-${DEPLOY_ENV:-test}
|
||||
|
||||
x-common: &common
|
||||
restart: "on-failure:3"
|
||||
@@ -8,17 +8,17 @@ x-common: &common
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment: &common-environment
|
||||
DEPLOY_ENV: test
|
||||
DEPLOY_ENV: ${DEPLOY_ENV:-test}
|
||||
BAIYUE_RELEASE_ID: ${RELEASE_ID:-local}
|
||||
BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true}
|
||||
SERVER_SHUTDOWN: graceful
|
||||
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s}
|
||||
SPRING_PROFILES_ACTIVE: test
|
||||
SPRING_PROFILES_ACTIVE: ${DEPLOY_ENV:-test}
|
||||
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
|
||||
NACOS_USERNAME: ${NACOS_USERNAME}
|
||||
NACOS_PASSWORD: ${NACOS_PASSWORD}
|
||||
NACOS_GROUP: ${NACOS_GROUP:-BAIYUE}
|
||||
NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual TEST namespace ID required}
|
||||
NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual environment namespace ID required}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
@@ -32,7 +32,7 @@ x-common: &common
|
||||
REDIS_TOKEN_KEY_PREFIX: ${REDIS_TOKEN_KEY_PREFIX:-baiyue:auth}
|
||||
SPRING_SQL_INIT_MODE: never
|
||||
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER}
|
||||
MQ_TOPIC_PREFIX: test-
|
||||
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-}
|
||||
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
|
||||
BAIYUE_AUDIT_ENDPOINT: http://audit-service:9700/internal/audit/events
|
||||
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}
|
||||
@@ -50,11 +50,12 @@ services:
|
||||
gateway:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
|
||||
container_name: firstleg-test-gateway
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9000
|
||||
SPRING_PROFILES_ACTIVE: ${DEPLOY_ENV:-test},cn
|
||||
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
||||
@@ -62,7 +63,7 @@ services:
|
||||
auth:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
|
||||
container_name: firstleg-test-auth
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-auth
|
||||
expose: ["9100"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -76,7 +77,7 @@ services:
|
||||
iam:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
|
||||
container_name: firstleg-test-iam
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-iam
|
||||
expose: ["9201"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -90,7 +91,7 @@ services:
|
||||
firstleg:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
|
||||
container_name: firstleg-test-business
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-business
|
||||
expose: ["9202"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -133,7 +134,7 @@ services:
|
||||
finance:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
|
||||
container_name: firstleg-test-finance
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-finance
|
||||
expose: ["9203"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -156,7 +157,7 @@ services:
|
||||
order:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
|
||||
container_name: firstleg-test-order
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-order
|
||||
expose: ["9204"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -168,7 +169,7 @@ services:
|
||||
warehouse:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
|
||||
container_name: firstleg-test-warehouse
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
|
||||
expose: ["9205"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -180,7 +181,7 @@ services:
|
||||
file:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG}
|
||||
container_name: firstleg-test-file
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-file
|
||||
expose: ["9206"]
|
||||
volumes:
|
||||
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
|
||||
@@ -202,7 +203,7 @@ services:
|
||||
print:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
|
||||
container_name: firstleg-test-print
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-print
|
||||
expose: ["9207"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -212,7 +213,7 @@ services:
|
||||
integration-service:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
|
||||
container_name: firstleg-test-integration
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-integration
|
||||
expose: ["9210"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -261,7 +262,7 @@ services:
|
||||
audit-service:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
|
||||
container_name: firstleg-test-audit
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-audit
|
||||
expose: ["9700"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -276,7 +277,7 @@ services:
|
||||
lastleg-service:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||
container_name: firstleg-test-lastleg
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||
expose: ["9303"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -303,7 +304,7 @@ services:
|
||||
warehouse-overseas-service:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
||||
container_name: firstleg-test-warehouse-overseas
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
||||
expose: ["9305"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -316,7 +317,7 @@ services:
|
||||
|
||||
networks:
|
||||
firstleg-network:
|
||||
name: firstleg-test-network
|
||||
name: firstleg-${DEPLOY_ENV:-test}-network
|
||||
infra-network:
|
||||
external: true
|
||||
name: ${INFRA_NETWORK:-firstleg-test-infra-network}
|
||||
name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network}
|
||||
|
||||
@@ -29,18 +29,23 @@ for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do
|
||||
done
|
||||
|
||||
|
||||
[ "$DEPLOY_ENV" = "test" ] || { echo "Only test is supported" >&2; exit 2; }
|
||||
case "$DEPLOY_ENV" in test|production) ;; *) echo "Only test and production are supported" >&2; exit 2 ;; esac
|
||||
case "$SERVICE" in
|
||||
gateway|auth|iam|firstleg|finance|order|warehouse|file|print|integration-service|lastleg-service|warehouse-overseas-service|audit-service) ;;
|
||||
*) echo "Unknown or retired service: $SERVICE" >&2; exit 2 ;;
|
||||
esac
|
||||
[ "${TARGET_IS_LOCAL:-false}" = "true" ] || { echo "TEST deploy must run on the target host" >&2; exit 2; }
|
||||
case "$IMAGE_REPOSITORY" in ''|*[!a-zA-Z0-9._:/-]*) echo "Invalid image repository" >&2; exit 2 ;; esac
|
||||
case "$IMAGE_TAG" in ''|*[!a-zA-Z0-9_.-]*) echo "Invalid image tag" >&2; exit 2 ;; esac
|
||||
case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esac
|
||||
if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
|
||||
echo "TEST deploy must run on the target host" >&2; exit 2
|
||||
fi
|
||||
[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; }
|
||||
env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; }
|
||||
for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do
|
||||
[ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; }
|
||||
done
|
||||
[ "$(env_file_value MQ_TOPIC_PREFIX)" = "test-" ] || { echo "MQ_TOPIC_PREFIX must be test-" >&2; exit 2; }
|
||||
[ "$(env_file_value MQ_TOPIC_PREFIX)" = "$DEPLOY_ENV-" ] || { echo "MQ_TOPIC_PREFIX must be $DEPLOY_ENV-" >&2; exit 2; }
|
||||
case "$SERVICE" in
|
||||
gateway|print|integration-service) db_prefix='' ;;
|
||||
lastleg-service) db_prefix=LASTLEG ;;
|
||||
@@ -138,6 +143,7 @@ wait_for_local_health() {
|
||||
}
|
||||
|
||||
deploy_local() {
|
||||
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
|
||||
deploy_docker_config=$(mktemp -d)
|
||||
DOCKER_CONFIG="$deploy_docker_config"
|
||||
export DOCKER_CONFIG
|
||||
@@ -223,4 +229,37 @@ deploy_local() {
|
||||
return 1
|
||||
}
|
||||
|
||||
deploy_local
|
||||
deploy_remote() {
|
||||
: "${TARGET_SSH_TARGET:?TARGET_SSH_TARGET required}"
|
||||
: "${TARGET_SSH_KEY:?TARGET_SSH_KEY required}"
|
||||
: "${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS required}"
|
||||
[ -f "$TARGET_SSH_KEY" ] && [ -f "$TARGET_SSH_KNOWN_HOSTS" ] || exit 2
|
||||
run_ssh() {
|
||||
ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$TARGET_SSH_TARGET" "$@"
|
||||
}
|
||||
run_scp() {
|
||||
scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@"
|
||||
}
|
||||
remote_dir=$(run_ssh 'umask 077; mktemp -d /tmp/firstleg-production-release.XXXXXXXXXX')
|
||||
case "$remote_dir" in /tmp/firstleg-production-release.*) ;; *) echo "Invalid remote temporary directory" >&2; exit 2 ;; esac
|
||||
case "$remote_dir" in *[!a-zA-Z0-9/._-]*) exit 2 ;; esac
|
||||
cleanup_remote() { run_ssh "rm -rf -- '$remote_dir'" >/dev/null 2>&1; }
|
||||
trap cleanup_remote EXIT HUP INT TERM
|
||||
run_ssh "mkdir -p '$remote_dir/deploy/scripts'"
|
||||
run_scp "$ENV_FILE" "$TARGET_SSH_TARGET:$remote_dir/deployment.env"
|
||||
run_scp "$COMPOSE_FILE" "$TARGET_SSH_TARGET:$remote_dir/deploy/docker-compose.yml"
|
||||
run_scp deploy/scripts/deploy-built-service.sh "$TARGET_SSH_TARGET:$remote_dir/deploy/scripts/deploy-built-service.sh"
|
||||
# Registry credentials only travel over stdin, never in process arguments or files.
|
||||
{ printf '%s\n' "$REGISTRY_USER"; printf '%s' "$REGISTRY_PASSWORD"; } | run_ssh \
|
||||
"cd '$remote_dir' && IFS= read -r REGISTRY_USER && REGISTRY_PASSWORD=\$(cat) && export REGISTRY_USER REGISTRY_PASSWORD TARGET_IS_LOCAL=true ALLOW_SAME_CONTRACT_ROLLBACK='${ALLOW_SAME_CONTRACT_ROLLBACK:-false}' DEPLOY_HEALTH_TIMEOUT_SECONDS='$HEALTH_TIMEOUT_SECONDS' DEPLOY_HEALTH_POLL_SECONDS='$HEALTH_POLL_SECONDS' && sh deploy/scripts/deploy-built-service.sh production '$IMAGE_REPOSITORY' '$IMAGE_TAG' '$SERVICE' deployment.env"
|
||||
cleanup_remote
|
||||
trap - EXIT HUP INT TERM
|
||||
}
|
||||
|
||||
if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then
|
||||
deploy_local
|
||||
else
|
||||
deploy_remote
|
||||
fi
|
||||
|
||||
@@ -12,7 +12,7 @@ test('基础 Compose 显式配置审计服务地址,避免静默退化为日
|
||||
/BAIYUE_AUDIT_ENDPOINT: http:\/\/audit-service:9700\/internal\/audit\/events/)
|
||||
assert.doesNotMatch(compose, /^\s+AUDIT_SERVICE_URL:/m)
|
||||
assert.match(compose, /^ audit-service:\s*$/m)
|
||||
assert.match(compose, /container_name: firstleg-test-audit/)
|
||||
assert.match(compose, /container_name: firstleg-\$\{DEPLOY_ENV:-test\}-audit/)
|
||||
assert.match(compose, /networks: \[firstleg-network, infra-network\]/)
|
||||
})
|
||||
|
||||
|
||||
@@ -52,44 +52,33 @@ test('coordinator cannot initialize test databases', () => {
|
||||
assert.doesNotMatch(pipeline, /测试数据库初始化/)
|
||||
})
|
||||
|
||||
test('coordinator defaults event bridge deployments to both regions', () => {
|
||||
assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS'\]/)
|
||||
assert.match(pipeline, /\['BOTH', 'SELECT', 'CN', 'OVERSEAS'\]\.contains\(bridgeRegion\)/)
|
||||
assert.match(pipeline, /兼容旧 Jenkins 参数 SELECT:本次按 BOTH/)
|
||||
assert.doesNotMatch(pipeline, /事件桥 BOTH 仅支持开发或测试环境/)
|
||||
assert.match(pipeline, /String downstreamImageTag = deployBothBridges[\s\S]*?\? "\$\{env\.IMAGE_TAG\}-both"/)
|
||||
assert.match(pipeline, /String downstreamBridgeRegion = deployBothBridges[\s\S]*?\? 'CN'/)
|
||||
assert.match(pipeline, /service == 'event-bridge-service' \? params\.EVENT_BRIDGE_REGION : 'CN'/)
|
||||
assert.match(pipeline, /string\(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion\)/)
|
||||
test('coordinator dispatches exactly the current TEST service map without retired regional units', async () => {
|
||||
const serviceMap = await readFile(new URL('../deploy/service-map.yml', import.meta.url), 'utf8')
|
||||
const expected = [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map(match => match[1]).sort()
|
||||
const declaration = pipeline.match(/def serviceModules = \[([\s\S]*?)\]/)?.[1] || ''
|
||||
const actual = [...declaration.matchAll(/'([^']+)'/g)].map(match => match[1]).sort()
|
||||
assert.equal(expected.length, 13)
|
||||
assert.deepEqual(actual, expected)
|
||||
assert.doesNotMatch(pipeline, /EVENT_BRIDGE_REGION|gateway-overseas|event-bridge-service/)
|
||||
})
|
||||
|
||||
test('production deploy uses DEPLOY as the single explicit release switch', () => {
|
||||
test('TEST and gray jobs use fixed independent environment mappings and explicit DEPLOY', () => {
|
||||
assert.match(pipeline, /booleanParam\(name: 'DEPLOY'/)
|
||||
assert.match(pipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/)
|
||||
assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/)
|
||||
assert.match(pipeline, /case 'backend-java':[\s\S]*?return 'test'/)
|
||||
assert.match(pipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/)
|
||||
assert.match(pipeline, /case 'backend-java-prod':[\s\S]*?return 'baiyue-firstleg-prod-'/)
|
||||
assert.doesNotMatch(pipeline, /return 'development'|backend-java-dev/)
|
||||
assert.match(pipeline, /booleanParam\(name: 'DEPLOY', value: params\.DEPLOY\)/)
|
||||
assert.doesNotMatch(pipeline, /input message:/)
|
||||
assert.doesNotMatch(pipeline, /\u786e\u8ba4\u90e8\u7f72/)
|
||||
})
|
||||
|
||||
test('production deploy reports one aggregated result to WeCom', () => {
|
||||
const sourceCommitIndex = pipeline.indexOf("env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD'")
|
||||
const productionGuardIndex = pipeline.indexOf("if (env.RESOLVED_ENVIRONMENT == 'production')")
|
||||
|
||||
assert.match(pipeline, /credentialsId: 'wecom-prod-webhook'/)
|
||||
assert.match(pipeline, /node\('java-builder'\) \{[\s\S]*?WECOM_WEBHOOK/)
|
||||
assert.match(pipeline, /env\.JOB_BASE_NAME == 'backend-java-prod' && params\.DEPLOY/)
|
||||
assert.ok(sourceCommitIndex >= 0, 'source commit must be recorded for notifications')
|
||||
assert.ok(sourceCommitIndex < productionGuardIndex,
|
||||
'source commit must be recorded before production validation can fail')
|
||||
test('TEST coordinator records downstream identity and fails the aggregate on any unsuccessful service', () => {
|
||||
assert.match(pipeline, /env\.SOURCE_COMMIT = sh\(script: 'git rev-parse HEAD'/)
|
||||
assert.match(pipeline, /def downstreamBuildUrls = \[:\]/)
|
||||
assert.match(pipeline, /downstreamBuildUrls\[service\] = downstreamBuild\.absoluteUrl/)
|
||||
assert.match(pipeline, /downstreamBuildNumbers\[service\] = downstreamBuild\.number/)
|
||||
assert.match(pipeline, /unstable \{[\s\S]*?'\u26a0\ufe0f \u751f\u4ea7\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/)
|
||||
assert.match(pipeline, /\u7ed3\u679c\uff1a\u6210\u529f \$\{successCount\} \/ \u5931\u8d25 \$\{failedServices\.size\(\)\}/)
|
||||
assert.match(pipeline, /\u5931\u8d25\u670d\u52a1\uff1a\$\{failedLinks/)
|
||||
assert.match(pipeline, /failure \{[\s\S]*?'\u274c \u751f\u4ea7\u53d1\u5e03\u5931\u8d25'/)
|
||||
assert.match(pipeline, /aborted \{[\s\S]*?'\u23f9 \u751f\u4ea7\u90e8\u7f72\u5df2\u53d6\u6d88'/)
|
||||
assert.match(pipeline, /> \u8017\u65f6\uff1a\$\{duration\}/)
|
||||
assert.match(pipeline, /\u4f01\u4e1a\u5fae\u4fe1\u901a\u77e5\u5931\u8d25\uff0c\u4e0d\u5f71\u54cd\u539f\u6784\u5efa\u7ed3\u679c/)
|
||||
assert.doesNotMatch(servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/)
|
||||
assert.match(pipeline, /downstreamResults\.findAll \{ service, result -> result != 'SUCCESS' \}/)
|
||||
assert.match(pipeline, /if \(failed\) \{[\s\S]*?unstable\(/)
|
||||
assert.doesNotMatch(pipeline + servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/)
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ case "$command" in
|
||||
esac
|
||||
`
|
||||
|
||||
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false }) {
|
||||
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-'))
|
||||
const mockBin = join(temporaryDirectory, 'bin')
|
||||
const dockerPath = join(mockBin, 'docker')
|
||||
@@ -82,13 +82,32 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting
|
||||
await mkdir(mockBin)
|
||||
await writeFile(dockerPath, mockDocker, 'utf8')
|
||||
await chmod(dockerPath, 0o755)
|
||||
await writeFile(envFile, ['NACOS_SERVER_ADDR=nacos:8848','NACOS_NAMESPACE=test-id','NACOS_USERNAME=fixture','NACOS_PASSWORD=fixture','REDIS_HOST=redis','REDIS_PORT=6379','REDIS_DATABASE=0','REDIS_TOKEN_KEY_PREFIX=baiyue:auth','INTERNAL_SERVICE_TOKEN=fixture','JWT_RSA_JWK_SET=fixture','JWT_CURRENT_KID=fixture','ROCKETMQ_NAME_SERVER=namesrv:9876','MQ_TOPIC_PREFIX=test-','AUDIT_DB_URL=jdbc:mysql://mysql/audit','AUDIT_DB_USERNAME=fixture','AUDIT_DB_PASSWORD=fixture',''].join('\n'), 'utf8')
|
||||
if (remote) {
|
||||
await writeFile(join(mockBin, 'ssh'), `#!/usr/bin/env sh
|
||||
set -eu
|
||||
for argument in "$@"; do command="$argument"; done
|
||||
printf '%s\\n' "$command" >> "$MOCK_SSH_LOG"
|
||||
exec sh -c "$command"
|
||||
`)
|
||||
await writeFile(join(mockBin, 'scp'), `#!/usr/bin/env sh
|
||||
set -eu
|
||||
while [ "$#" -gt 2 ]; do shift; done
|
||||
cp "$1" "\${2#*:}"
|
||||
`)
|
||||
await chmod(join(mockBin, 'ssh'), 0o755)
|
||||
await chmod(join(mockBin, 'scp'), 0o755)
|
||||
await writeFile(join(temporaryDirectory, 'key'), 'fixture')
|
||||
await writeFile(join(temporaryDirectory, 'known-hosts'), 'fixture')
|
||||
}
|
||||
|
||||
await writeFile(envFile, ['NACOS_SERVER_ADDR=nacos:8848','NACOS_NAMESPACE=test-id','NACOS_USERNAME=fixture','NACOS_PASSWORD=fixture','REDIS_HOST=redis','REDIS_PORT=6379','REDIS_DATABASE=0','REDIS_TOKEN_KEY_PREFIX=baiyue:auth','INTERNAL_SERVICE_TOKEN=fixture','JWT_RSA_JWK_SET=fixture','JWT_CURRENT_KID=fixture','ROCKETMQ_NAME_SERVER=namesrv:9876',`MQ_TOPIC_PREFIX=${topicPrefix}`,'AUDIT_DB_URL=jdbc:mysql://mysql/audit','AUDIT_DB_USERNAME=fixture','AUDIT_DB_PASSWORD=fixture',''].join('\n'), 'utf8')
|
||||
await writeFile(logFile, '', 'utf8')
|
||||
await writeFile(currentImageFile, 'registry.local/group/firstleg/audit-service:oldgood\n', 'utf8')
|
||||
await writeFile(healthStateFile, 'healthy\n', 'utf8')
|
||||
await writeFile(runtimeStateFile, 'running\n', 'utf8')
|
||||
|
||||
const result = spawnSync(shell, [
|
||||
'-c', 'PATH="$MOCK_BIN:$PATH"; export PATH; exec sh "$@"', 'fixture',
|
||||
deployScript.replaceAll('\\', '/'),
|
||||
environment,
|
||||
'registry.local/group/firstleg',
|
||||
@@ -101,7 +120,12 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${mockBin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
TARGET_IS_LOCAL: 'true',
|
||||
MOCK_BIN: mockBin.replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`),
|
||||
TARGET_IS_LOCAL: remote ? 'false' : 'true',
|
||||
TARGET_SSH_TARGET: 'fixture@fixture',
|
||||
TARGET_SSH_KEY: join(temporaryDirectory, 'key').replaceAll('\\', '/'),
|
||||
TARGET_SSH_KNOWN_HOSTS: join(temporaryDirectory, 'known-hosts').replaceAll('\\', '/'),
|
||||
MOCK_SSH_LOG: join(temporaryDirectory, 'ssh.log').replaceAll('\\', '/'),
|
||||
ALLOW_SAME_CONTRACT_ROLLBACK: String(allowRollback),
|
||||
REGISTRY_USER: 'ci-user',
|
||||
REGISTRY_PASSWORD: 'ci-password',
|
||||
@@ -120,8 +144,9 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting
|
||||
|
||||
const dockerLog = await readFile(logFile, 'utf8')
|
||||
const currentImage = (await readFile(currentImageFile, 'utf8')).trim()
|
||||
const sshLog = remote ? await readFile(join(temporaryDirectory, 'ssh.log'), 'utf8').catch(() => '') : ''
|
||||
await rm(temporaryDirectory, { recursive: true, force: true })
|
||||
return { ...result, dockerLog, currentImage }
|
||||
return { ...result, dockerLog, currentImage, sshLog }
|
||||
}
|
||||
|
||||
test('successful deployment keeps the new healthy image', async (context) => {
|
||||
@@ -182,8 +207,8 @@ test('architecture switch failure does not roll back one old service', async ()
|
||||
assert.doesNotMatch(result.dockerLog,/pull .*:oldgood/)
|
||||
assert.match(result.stderr,/Automatic rollback disabled/)
|
||||
})
|
||||
test('non TEST environments and retired services fail before Docker', async () => {
|
||||
for(const options of [{environment:'production'},{environment:'development'},{service:'event-bridge-service'},{service:'gateway-overseas'}]){
|
||||
test('unsupported environments and retired services fail before Docker', async () => {
|
||||
for(const options of [{environment:'development'},{service:'event-bridge-service'},{service:'gateway-overseas'}]){
|
||||
const result=await runDeployment({imageTag:'newgood',...options})
|
||||
assert.equal(result.status,2)
|
||||
assert.equal(result.dockerLog,'')
|
||||
@@ -200,3 +225,36 @@ test('SQL initializer rejects absent authorization before any Docker call',()=>{
|
||||
assert.equal(result.status,2)
|
||||
assert.match(result.stderr,/Explicit TEST SQL authorization required/)
|
||||
})
|
||||
|
||||
|
||||
test('gray deploy preserves health checks and uses an isolated Compose project', async () => {
|
||||
const result = await runDeployment({ imageTag: 'graygood', environment: 'production', allowRollback: false })
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.match(result.dockerLog, /--project-name firstleg-production/)
|
||||
assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/)
|
||||
})
|
||||
|
||||
test('gray deployment rejects TEST message scope before any Docker operation', async () => {
|
||||
const result = await runDeployment({ imageTag: 'graygood', environment: 'production', topicPrefix: 'test-' })
|
||||
assert.equal(result.status, 2)
|
||||
assert.equal(result.dockerLog, '')
|
||||
assert.match(result.stderr, /MQ_TOPIC_PREFIX must be production-/)
|
||||
})
|
||||
|
||||
|
||||
test('SSH gray delivery executes the same health gate and cleans its unique temporary bundle', async () => {
|
||||
const result = await runDeployment({ imageTag: 'remotegray', environment: 'production', allowRollback: false, remote: true })
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:remotegray')
|
||||
assert.match(result.dockerLog, /--project-name firstleg-production/)
|
||||
assert.match(result.sshLog, /rm -rf -- '\/tmp\/firstleg-production-release\.[A-Za-z0-9]+'/)
|
||||
assert.doesNotMatch(result.sshLog, /ci-password/)
|
||||
})
|
||||
|
||||
test('SSH gray health failure remains failed and still removes the uploaded credential bundle', async () => {
|
||||
const result = await runDeployment({ imageTag: 'remotebad', failingTag: 'remotebad', environment: 'production', allowRollback: false, remote: true })
|
||||
assert.equal(result.status, 1, result.stderr)
|
||||
assert.match(result.stderr, /Automatic rollback disabled/)
|
||||
assert.match(result.sshLog, /rm -rf -- '\/tmp\/firstleg-production-release\.[A-Za-z0-9]+'/)
|
||||
assert.doesNotMatch(result.sshLog, /ci-password/)
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ test('TEST has one complete Compose, namespace, session and no retired deploymen
|
||||
const compose=read('deploy/docker-compose.yml')
|
||||
for(const service of ['gateway','auth','iam','firstleg','finance','order','warehouse','file','print','integration-service','lastleg-service','warehouse-overseas-service','audit-service']) assert.match(compose,new RegExp('^ '+service+':','m'))
|
||||
assert.doesNotMatch(compose,/event-bridge|gateway-overseas|OVERSEAS_REDIS|OVERSEAS_NACOS|OVERSEAS_JWT|JWT_SECRET|test-cn|test-overseas/)
|
||||
assert.match(compose,/SPRING_PROFILES_ACTIVE: test/)
|
||||
assert.match(compose,/NACOS_NAMESPACE: \$\{NACOS_NAMESPACE:\?actual TEST namespace ID required\}/)
|
||||
assert.match(compose,/SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\}/)
|
||||
assert.match(compose,/NACOS_NAMESPACE: \$\{NACOS_NAMESPACE:\?actual environment namespace ID required\}/)
|
||||
assert.match(compose,/REDIS_DATABASE: \$\{REDIS_DATABASE:-0\}/)
|
||||
assert.match(compose,/SPRING_DATA_REDIS_USERNAME: \$\{REDIS_USERNAME:-default\}/)
|
||||
assert.match(compose,/JWT_JWKS_URI: http:\/\/auth:9100\/api\/auth\/\.well-known\/jwks.json/)
|
||||
@@ -37,10 +37,10 @@ test('every retained application imports TEST configuration with explicit namesp
|
||||
assert.match(common,/session-validation-uri: \$\{AUTH_SESSION_VALIDATION_URL:http:\/\/auth:9100\/internal\/auth\/accounts\/sessions\/validate\}/)
|
||||
assert.match(read('deploy/scripts/deploy-built-service.sh'),/Missing HEALTHCHECK; deployment failed/)
|
||||
})
|
||||
test('Jenkins TEST publication and Nacos import have no dual-region or production execution',()=>{
|
||||
test('Jenkins retains the unified topology across TEST and gray; Nacos import stays explicit',()=>{
|
||||
for(const p of ['Jenkinsfile','Jenkinsfile.service']){
|
||||
const s=read(p)
|
||||
assert.doesNotMatch(s,/EVENT_BRIDGE|gateway-overseas|event-bridge-service|return 'production'|return 'development'|firstleg-production-env|java-dev/)
|
||||
assert.doesNotMatch(s,/EVENT_BRIDGE|gateway-overseas|event-bridge-service|return 'development'|java-dev/)
|
||||
assert.doesNotMatch(s,/maven.test.skip/)
|
||||
}
|
||||
assert.match(read('Jenkinsfile.service'),/node\('java-test'\)/)
|
||||
|
||||
@@ -28,7 +28,8 @@ test('test compilation is preserved even when execution is explicitly skipped',
|
||||
|
||||
test('coordinator and service jobs enforce their own deployment environments', () => {
|
||||
assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/)
|
||||
assert.doesNotMatch(coordinatorPipeline,/return 'production'|return 'development'/)
|
||||
assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/)
|
||||
assert.doesNotMatch(coordinatorPipeline,/return 'development'/)
|
||||
assert.match(pipeline,/startsWith\('baiyue-firstleg-'\)/)
|
||||
assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-dev-'\)/)
|
||||
assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-prod-'\)/)
|
||||
@@ -63,7 +64,7 @@ test('AUTO maps shared modules to their real consumers instead of rebuilding eve
|
||||
})
|
||||
|
||||
test('TEST builds respect explicit test execution controls', () => {
|
||||
assert.doesNotMatch(coordinatorPipeline,/backend-java-prod|return 'production'/)
|
||||
assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/)
|
||||
assert.match(coordinatorPipeline,/!params.SKIP_TESTS/)
|
||||
assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/)
|
||||
})
|
||||
@@ -74,7 +75,7 @@ test('Java builds use the shared builder pool and serialize incremental target d
|
||||
assert.match(pipeline,/lock\(resource: "firstleg-test-\$\{env.RESOLVED_SERVICE\}-deploy"/)
|
||||
assert.match(pipeline,/'TARGET_IS_LOCAL=true'/)
|
||||
assert.match(directDeployScript,/up -d --no-deps --pull never/)
|
||||
assert.doesNotMatch(directDeployScript,/--remove-orphans|run_ssh|docker save/)
|
||||
assert.doesNotMatch(directDeployScript,/--remove-orphans|docker save/)
|
||||
})
|
||||
|
||||
test('downstream skips tests only by explicit flag and keeps test compilation',()=>{
|
||||
@@ -137,7 +138,7 @@ test('TEST deployments use explicit local env and Compose files', () => {
|
||||
assert.match(directDeployScript,/TEST deploy must run on the target host/)
|
||||
assert.match(directDeployScript,/--env-file "\$ENV_FILE"/)
|
||||
assert.match(directDeployScript,/-f "\$COMPOSE_FILE"/)
|
||||
assert.doesNotMatch(directDeployScript,/scp|run_ssh/)
|
||||
assert.match(directDeployScript,/if \[ "\$DEPLOY_ENV" = "test" \] && \[ "\$\{TARGET_IS_LOCAL:-false\}" != "true" \]/)
|
||||
})
|
||||
|
||||
test('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => {
|
||||
@@ -178,3 +179,14 @@ test('retired bridge jobs cannot be dispatched', () => {
|
||||
assert.doesNotMatch(pipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/)
|
||||
assert.doesNotMatch(coordinatorPipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/)
|
||||
})
|
||||
|
||||
|
||||
test('gray publication reuses verified SSH credentials and the unified health executor', () => {
|
||||
for (const credential of ['firstleg-production-env', 'gitea-production-pull-token', 'firstleg-production-ssh', 'firstleg-production-host', 'firstleg-production-known-hosts']) assert.ok(pipeline.includes(credential))
|
||||
assert.match(pipeline, /git merge-base --is-ancestor "\$RESOLVED_SOURCE_COMMIT" origin\/main/)
|
||||
assert.match(directDeployScript, /StrictHostKeyChecking=yes/)
|
||||
assert.match(directDeployScript, /trap cleanup_remote EXIT HUP INT TERM/)
|
||||
assert.match(serviceCompose, /name: firstleg-\$\{DEPLOY_ENV:-test\}-network/)
|
||||
assert.match(serviceCompose, /SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\},cn/)
|
||||
assert.doesNotMatch(directDeployScript, /deploy-blue-green|worker|StrictHostKeyChecking=no/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user