fix(deploy): publish unified services to temporary gray environment

This commit is contained in:
2026-10-07 11:02:47 +08:00
parent 6346e7bd95
commit ec0c06ff88
9 changed files with 211 additions and 72 deletions
+1 -1
View File
@@ -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\]/)
})
+19 -30
View File
@@ -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/)
})
+64 -6
View File
@@ -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/)
})