Files
baiyue-firstleg/Jenkinsfile

445 lines
24 KiB
Groovy
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service'
]
def serviceCheckboxes = [
'gateway': 'SERVICE_GATEWAY',
'auth': 'SERVICE_AUTH',
'iam': 'SERVICE_IAM',
'firstleg': 'SERVICE_FIRSTLEG',
'finance': 'SERVICE_FINANCE',
'order': 'SERVICE_ORDER',
'warehouse': 'SERVICE_WAREHOUSE',
'file': 'SERVICE_FILE',
'print': 'SERVICE_PRINT',
'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'audit-service': 'SERVICE_AUDIT'
]
def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
'deploy/docker-compose.yml', 'deploy/service-map.yml', 'config/nacos/test/', 'deploy/scripts/', 'Jenkinsfile'
]
def commonPathServices = [
'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'audit-service'
],
'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'lastleg-service', 'warehouse-overseas-service', 'audit-service'
],
'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'audit-service'
],
'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service'
],
'common-event-contract/': [
'firstleg', 'finance', 'lastleg-service'
],
'deploy/docker-compose.audit.yml': ['audit-service'],
]
def databaseMigrationPaths = ['deploy/sql/']
// 暂停数据库结构只读门禁;保留实现,完成 Credentials 配置后改为 true。
def databaseSchemaContractEnabled = false
def downstreamResults = [:]
def downstreamBuildUrls = [:]
def downstreamBuildNumbers = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
if (!description?.startsWith('AUTO ')) return null
int markerIndex = description.indexOf(' | ')
if (markerIndex < 0) return null
String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String state = description.substring(markerIndex + ' | '.length()).trim()
List<String> failedServices = []
if (state.startsWith('RESULTS')) {
String summary = state.substring('RESULTS'.length()).trim()
int metadataIndex = summary.indexOf(' | ')
if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim()
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=')
if (separator > 0) {
String service = item.substring(0, separator).trim()
String result = item.substring(separator + 1).trim()
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
}
}
} else if (state.startsWith('PENDING')) {
// 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。
// 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交,
// 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。
String selected = state.substring('PENDING'.length()).trim()
int suffixIndex = selected.indexOf(' | ')
if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim()
failedServices.addAll(selected.split(',').collect { it.trim() }
.findAll { allowedServices.contains(it) })
} else {
return null
}
return [commit: commit, failedServices: failedServices.unique()]
}
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
def candidate = previousBuild
int inspected = 0
while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices)
// 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
if (state != null) return state
candidate = candidate.previousBuild
inspected++
}
return null
}
// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。
def previousSuccessfulTestCommit(def previousBuild, List<String> requiredServices) {
def candidate = previousBuild
int inspected = 0
while (candidate != null && inspected < 30) {
String description = candidate.description ?: ''
String marker = ' | TESTS=PASSED '
int markerIndex = description.lastIndexOf(marker)
if (candidate.result == 'SUCCESS' && markerIndex >= 0) {
List metadata = description.substring(markerIndex + marker.length()).trim().split(' ')
if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ &&
requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0]
}
candidate = candidate.previousBuild
inspected++
}
return ''
}
def environmentChoicesForJob(String jobBaseName) {
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
}
def fixedEnvironmentForJob(String jobBaseName) {
switch (jobBaseName) {
case 'backend-java':
return 'test'
case 'backend-java-prod':
return 'production'
default:
return null
}
}
def fixedDownstreamJobPrefixForJob(String jobBaseName) {
switch (jobBaseName) {
case 'backend-java':
return 'baiyue-firstleg-'
case 'backend-java-prod':
return 'baiyue-firstleg-prod-'
default:
return null
}
}
def downstreamJobPrefixChoicesForJob(String jobBaseName) {
return [fixedDownstreamJobPrefixForJob(jobBaseName) ?: 'baiyue-firstleg-']
}
pipeline {
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
agent none
options {
disableConcurrentBuilds()
timestamps()
buildDiscarder(logRotator(numToKeepStr: '30'))
}
parameters {
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '客户与权限服务(iam)')
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)')
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归')
// 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
}
stages {
stage('确定构建范围') {
agent { label 'java-builder' }
steps {
script {
String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME)
String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME)
if (!resolvedEnvironment || !resolvedJobPrefix) {
error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
}
if (params.ENVIRONMENT != resolvedEnvironment ||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
error('部署环境和下游前缀必须与当前 Job 固定映射一致')
}
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
// 先记录当前提交,使后续生产校验失败时通知仍能定位版本。
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository) {
error('必须填写真实镜像仓库地址')
}
if (requestedRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
}
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
}
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
env.DATABASE_CHANGE_DETECTED = 'false'
List selected = []
if (params.BUILD_MODE == 'ALL') {
selected = serviceModules
} else if (params.BUILD_MODE == 'SELECT') {
selected = serviceModules.findAll { service ->
params[serviceCheckboxes[service]] == true
}
if (!selected) {
error('SELECT 模式至少需要勾选一个服务')
}
} else {
def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules)
String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: []
if (!base) {
// 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
// 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
}
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules
echo 'AUTO 未找到可用基线,安全回退为全量构建'
} else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it }
boolean databaseChanged = changed.any { path ->
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
List serviceRelevantChanges = changed.findAll { path ->
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
boolean fullRebuild = serviceRelevantChanges.any { path ->
fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
}
if (fullRebuild) {
selected = serviceModules
} else {
selected = serviceModules.findAll { module ->
serviceRelevantChanges.any { it == module || it.startsWith("${module}/") }
}
commonPathServices.each { prefix, impactedServices ->
if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) {
selected.addAll(impactedServices)
}
}
}
selected.addAll(retryServices)
echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" +
"补跑失败服务: ${retryServices.join(',') ?: '无'}"
}
}
List invalid = selected.findAll { !serviceModules.contains(it) }
if (invalid) {
error("未知服务: ${invalid.join(', ')};允许值: ${serviceModules.join(', ')}")
}
selected = selected.unique()
env.SELECTED_SERVICES = selected.join(',')
env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected)
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) {
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS"
: (env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务')
} else {
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
}
if (env.DATABASE_CHANGE_DETECTED == 'true') {
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
}
}
}
}
stage('Gateway 路由静态门禁') {
when {
expression { env.NO_SERVICE_CHANGE != 'true' }
}
agent { label 'java-builder' }
steps {
sh 'node --test scripts/gateway-route-contract.test.mjs'
}
}
stage('数据库结构只读门禁') {
when {
expression { databaseSchemaContractEnabled }
}
agent { label 'java-builder' }
steps {
script {
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-schema-readonly"
withCredentials([file(credentialsId: credentialId, variable: 'SCHEMA_CHECK_ENV_FILE')]) {
sh '''
set -eu
set -a
. "$SCHEMA_CHECK_ENV_FILE"
set +a
test "${SCHEMA_CHECK_READ_ONLY:-}" = "true"
node scripts/database-schema-contract.mjs --region BOTH
'''
}
}
}
}
stage('统一 Maven 测试门禁') {
when {
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
}
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
steps {
script {
String selectedModules = env.SELECTED_SERVICES.split(',')
.collect { it.trim() }
.findAll { it }
.join(',')
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
String testPlan = "${pwd(tmp: true)}/ci-test-plan.json"
lock(resource: 'firstleg-test-maven') {
timeout(time: 50, unit: 'MINUTES') {
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}'
node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}'
test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\"
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments
node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}'
"""
}
}
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
env.TEST_GATE_PASSED = 'true'
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
stage('并行调度独立服务 Job') {
when { expression { env.NO_SERVICE_CHANGE != 'true' } }
steps {
script {
Map branches = [:]
env.SELECTED_SERVICES.split(',').each { selectedService ->
String service = selectedService
branches[service] = {
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
echo "触发单服务 Job: ${jobName}"
def downstreamBuild = build(
job: jobName,
wait: true,
propagate: false,
parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
// 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
booleanParam(name: 'SKIP_TESTS', value: true),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE)
]
)
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
downstreamBuildUrls[service] = downstreamBuild.absoluteUrl ?: ''
downstreamBuildNumbers[service] = downstreamBuild.number?.toString() ?: ''
echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})"
}
}
parallel branches
}
}
}
stage('汇总服务结果') {
when { expression { env.NO_SERVICE_CHANGE != 'true' } }
steps {
script {
List failed = downstreamResults.findAll { service, result -> result != 'SUCCESS' }
.collect { service, result -> "${service}=${result}" }
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
echo "单服务执行汇总: ${summary}"
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
: "${env.IMAGE_TAG} | ${summary}"
if (env.TEST_GATE_PASSED == 'true') {
currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}"
}
if (failed) {
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
}
}
}
}
}
}