From ec0c06ff88e6eda987c42b0dff2ddbd2d8ed0921 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 11:02:47 +0800 Subject: [PATCH] fix(deploy): publish unified services to temporary gray environment --- Jenkinsfile | 6 +- Jenkinsfile.service | 40 ++++++++++- deploy/docker-compose.yml | 41 +++++------ deploy/scripts/deploy-built-service.sh | 47 +++++++++++-- scripts/audit-deployment-contract.test.mjs | 2 +- scripts/controller-pipeline-contract.test.mjs | 49 +++++-------- .../deploy-built-service-rollback.test.mjs | 70 +++++++++++++++++-- scripts/overseas-deployment-contract.test.mjs | 8 +-- ...est-environment-pipeline-contract.test.mjs | 20 ++++-- 9 files changed, 211 insertions(+), 72 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 3659d5dfb..36bbea19e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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}" diff --git a/Jenkinsfile.service b/Jenkinsfile.service index a11c90f78..310979127 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -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 { diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 472f1e6a3..56f4febb3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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} diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 3846f5b8d..995da8f58 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -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 diff --git a/scripts/audit-deployment-contract.test.mjs b/scripts/audit-deployment-contract.test.mjs index 03b984051..3424924e8 100644 --- a/scripts/audit-deployment-contract.test.mjs +++ b/scripts/audit-deployment-contract.test.mjs @@ -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\]/) }) diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 7680e9048..56f806fe3 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -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/) }) diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index cb77b3b15..f4f82baae 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -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/) +}) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index e3a9c9c24..e4290d567 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -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'\)/) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index a23e788fb..c37dcd5b7 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -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/) +})