From 0d3a27f4babc4b13403b835c902da5c02a0c79b3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 10:27:28 +0800 Subject: [PATCH 01/10] build(docker): layer Spring Boot service images --- deploy/docker/Dockerfile | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index ecd1131f5..62542d939 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,4 +1,12 @@ ARG BASE_IMAGE=docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine +FROM ${BASE_IMAGE} AS extractor + +WORKDIR /workspace + +ARG JAR_FILE +COPY ${JAR_FILE} app.jar +RUN java -Djarmode=layertools -jar app.jar extract + FROM ${BASE_IMAGE} RUN addgroup -S app \ @@ -9,8 +17,11 @@ WORKDIR /app # 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。 RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs -ARG JAR_FILE -COPY --chown=app:app ${JAR_FILE} /app/app.jar +# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。 +COPY --from=extractor --chown=app:app /workspace/dependencies/ ./ +COPY --from=extractor --chown=app:app /workspace/spring-boot-loader/ ./ +COPY --from=extractor --chown=app:app /workspace/snapshot-dependencies/ ./ +COPY --from=extractor --chown=app:app /workspace/application/ ./ USER app @@ -22,4 +33,4 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD busybox wget -q -O /dev/null "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1 -ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] From a7ec6ed285e0a99cf794e63733884582b6bb19c0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 10:38:40 +0800 Subject: [PATCH 02/10] ci: publish development and test images to registry --- Jenkinsfile | 19 +++++++------------ Jenkinsfile.service | 32 +++++++++++++------------------- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 941569a2f..0bb9dbe34 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -79,19 +79,14 @@ pipeline { env.RESOLVED_ENVIRONMENT = resolvedEnvironment echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" - String requestedRepository - if (['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { - requestedRepository = "baiyue-firstleg-${env.RESOLVED_ENVIRONMENT}" - echo "${env.RESOLVED_ENVIRONMENT} 环境使用本地镜像,由构建节点直接传输到部署目标" - } else { - requestedRepository = params.IMAGE_REPOSITORY?.trim() - if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { - error('预发布/生产环境必须填写真实镜像仓库地址') - } - if (requestedRepository.contains('://') || !requestedRepository.contains('/')) { - error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://') - } + String requestedRepository = params.IMAGE_REPOSITORY?.trim() + if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { + error('必须填写真实镜像仓库地址') } + if (requestedRepository.contains('://') || !requestedRepository.contains('/')) { + error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://') + } + echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}" env.RESOLVED_IMAGE_REPOSITORY = requestedRepository String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim() diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 8216948b2..715dea812 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -63,9 +63,7 @@ pipeline { } String sourceCommit = params.SOURCE_COMMIT?.trim() String imageTag = params.IMAGE_TAG?.trim() - String imageRepository = ['development', 'test'].contains(params.ENVIRONMENT) - ? "baiyue-firstleg-${params.ENVIRONMENT}" - : params.IMAGE_REPOSITORY?.trim() + String imageRepository = params.IMAGE_REPOSITORY?.trim() if (!sourceCommit) { sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() @@ -86,8 +84,8 @@ pipeline { if (!imageRepository || imageRepository.contains('://')) { error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀') } - if (!['development', 'test'].contains(params.ENVIRONMENT) && !imageRepository.contains('/')) { - error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') + if (!imageRepository.contains('/')) { + error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') } env.RESOLVED_SERVICE = service @@ -143,22 +141,18 @@ pipeline { sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'" sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'" - if (['development', 'test'].contains(params.ENVIRONMENT)) { - echo "${params.ENVIRONMENT} 镜像已保存在构建节点 ${env.NODE_NAME}: ${image}" - } else { - echo "推送共享镜像: ${image}" - withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { - try { - sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' - sh "docker push ${image}" - } finally { - sh "docker logout ${registryHost} || true" - } - } - if (params.DEPLOY) { - stash name: 'deployment-files', includes: 'deploy/**', useDefaultExcludes: true + echo "推送共享镜像: ${image}" + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + try { + sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' + sh "docker push ${image}" + } finally { + sh "docker logout ${registryHost} || true" } } + if (params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT)) { + stash name: 'deployment-files', includes: 'deploy/**', useDefaultExcludes: true + } } } } From 0f296e016eee1e32ca00917ce2446e69b5386901 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 10:50:33 +0800 Subject: [PATCH 03/10] ci: use dedicated registry credentials --- Jenkinsfile.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 715dea812..9104eb579 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -40,7 +40,7 @@ pipeline { } environment { - REGISTRY_CREDENTIALS = 'gitea-test-token' + REGISTRY_CREDENTIALS = 'gitea-registry-token' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml' MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8' From fd1c582d4550d0428ff580ec7abb62dfa75670ad Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 11:01:06 +0800 Subject: [PATCH 04/10] ci: serialize registry-based environment deployments --- Jenkinsfile.service | 9 ++++++- deploy/scripts/deploy-built-service.sh | 26 ++++++++++++++++--- ...est-environment-pipeline-contract.test.mjs | 8 ++++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 9104eb579..f2727d827 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -163,6 +163,8 @@ pipeline { } steps { script { + String deployLock = "firstleg-${params.ENVIRONMENT}-deploy" + lock(resource: deployLock, inversePrecedence: true) { boolean development = params.ENVIRONMENT == 'development' boolean testTargetIsLocal = ['master', 'built-in'].contains(env.NODE_NAME) || ['master', 'built-in'].any { label -> env.NODE_LABELS?.tokenize()?.contains(label) } @@ -189,10 +191,15 @@ pipeline { "TARGET_SSH_KNOWN_HOSTS=${remoteKnownHosts}", "TARGET_DEPLOY_DIR=${remoteDir}" ]) { - withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { + withCredentials([ + file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, + usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') + ]) { sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" } } + } } } } diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index e6eb3fa1e..f8520ae03 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -13,6 +13,7 @@ SERVICE="$4" ENV_FILE="$5" COMPOSE_FILE="deploy/docker-compose.yml" IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG" +REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" PROJECT_NAME="firstleg-$DEPLOY_ENV" case "$DEPLOY_ENV" in @@ -74,9 +75,17 @@ if [ "$SERVICE" = "file" ]; then fi fi -docker image inspect "$IMAGE" >/dev/null +: "${REGISTRY_USER:?REGISTRY_USER 未设置}" +: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" + +local_registry_logout() { + docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true +} deploy_local() { + printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" + trap local_registry_logout EXIT HUP INT TERM + docker pull "$IMAGE" export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG docker compose \ --project-name "$PROJECT_NAME" \ @@ -88,6 +97,8 @@ deploy_local() { --env-file "$ENV_FILE" \ -f "$COMPOSE_FILE" \ ps "$SERVICE" + local_registry_logout + trap - EXIT HUP INT TERM } if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then @@ -146,8 +157,14 @@ mv -f "$env_tmp" "$remote_dir/.env" mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" REMOTE_PREPARE -echo "传输镜像到 $SSH_TARGET: $IMAGE" -docker save "$IMAGE" | gzip -1 | run_ssh 'gzip -dc | docker load' +remote_registry_logout() { + run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true +} + +echo "目标服务器从共享仓库拉取增量镜像: $IMAGE" +printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" +trap remote_registry_logout EXIT HUP INT TERM +run_ssh docker pull "$IMAGE" run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" <<'REMOTE_DEPLOY' set -eu @@ -171,3 +188,6 @@ docker compose \ -f docker-compose.yml \ ps "$service" REMOTE_DEPLOY + +remote_registry_logout +trap - EXIT HUP INT TERM diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index e76beaf5b..c7a79e70c 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -30,12 +30,16 @@ test('production deployments cannot skip tests', () => { assert.match(pipeline, /error\('生产环境禁止跳过测试'\)/) }) -test('Java builds use the shared builder pool and deploy on the target environment node', () => { +test('Java builds use the shared builder pool and serialize incremental target deployments', () => { assert.match(pipeline, /agent \{ label 'java-builder' \}/) assert.match(pipeline, /\['development', 'test'\]\.contains\(params\.ENVIRONMENT\)/) assert.match(pipeline, /\['master', 'built-in'\]\.contains\(env\.NODE_NAME\)/) + assert.match(pipeline, /String deployLock = "firstleg-\$\{params\.ENVIRONMENT\}-deploy"/) + assert.match(pipeline, /lock\(resource: deployLock, inversePrecedence: true\)/) assert.match(pipeline, /deploy-built-service\.sh/) - assert.match(directDeployScript, /docker save "\$IMAGE" \| gzip -1 \| run_ssh/) + assert.match(directDeployScript, /docker pull "\$IMAGE"/) + assert.match(directDeployScript, /run_ssh docker pull "\$IMAGE"/) + assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) assert.match(directDeployScript, /--pull never/) }) From 668668b505b5b94e2db1b2a90a80216a1148ce30 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 11:45:14 +0800 Subject: [PATCH 05/10] fix(ci): schedule coordinator on builder agents --- Jenkinsfile | 2 +- scripts/controller-pipeline-contract.test.mjs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 0bb9dbe34..a49c4f462 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -36,7 +36,7 @@ def environmentChoicesForJob(String jobBaseName) { pipeline { // 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。 - agent any + agent { label 'java-builder' } options { disableConcurrentBuilds() diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index bcaee0d4f..48a20a75c 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -5,6 +5,11 @@ import test from 'node:test' const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +test('coordinator runs only on the shared Java builder pool', () => { + assert.match(pipeline, /agent \{ label 'java-builder' \}/) + assert.doesNotMatch(pipeline, /agent any/) +}) + test('AUTO detects database migrations without expanding them to all services', () => { assert.match(pipeline, /def databaseMigrationPaths = \['deploy\/sql\/'\]/) assert.match(pipeline, /!databaseMigration && commonPaths\.any/) From e92cfe18ad294cb2ecf990bccb1b3d79e8735e93 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 12:20:26 +0800 Subject: [PATCH 06/10] ci: use internal Java base image --- Jenkinsfile | 2 +- Jenkinsfile.service | 2 +- deploy/docker/Dockerfile | 2 +- scripts/test-environment-pipeline-contract.test.mjs | 13 +++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index a49c4f462..5544ff9e0 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -63,7 +63,7 @@ pipeline { booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth') } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index f2727d827..084d87d2e 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -36,7 +36,7 @@ pipeline { booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') } environment { diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 62542d939..4e7c1d79a 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine +ARG BASE_IMAGE=172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine FROM ${BASE_IMAGE} AS extractor WORKDIR /workspace diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index c7a79e70c..45abe70d5 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -10,6 +10,8 @@ const directDeployScript = (await readFile(new URL('../deploy/scripts/deploy-bui .replaceAll('\r\n', '\n') const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') test('test deployments compile packages without rerunning the full unit test suite', () => { assert.match(pipeline, /params\.ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) @@ -48,3 +50,14 @@ test('shared builders use a reachable public Maven mirror instead of the retired assert.match(ciMavenSettings, /https:\/\/maven\.aliyun\.com\/repository\/public/) assert.doesNotMatch(ciMavenSettings, /:18081/) }) + +test('Java service images use the internal registry base image by default', () => { + const internalBaseImage = '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine' + + assert.match(coordinatorPipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) + assert.match(pipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) + assert.match(serviceDockerfile, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) + assert.doesNotMatch(coordinatorPipeline, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) + assert.doesNotMatch(pipeline, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) + assert.doesNotMatch(serviceDockerfile, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) +}) From d3e16bf29f1319e5f6b9c1d2422bfe5299c022cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 17:43:20 +0800 Subject: [PATCH 07/10] fix(ci): deploy on target environment agents --- Jenkinsfile.service | 77 ++++++++----------- ...est-environment-pipeline-contract.test.mjs | 4 +- 2 files changed, 35 insertions(+), 46 deletions(-) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 084d87d2e..28f98316e 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -43,9 +43,6 @@ pipeline { REGISTRY_CREDENTIALS = 'gitea-registry-token' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml' MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' - DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8' - DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy' - DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/application' } stages { @@ -150,55 +147,45 @@ pipeline { sh "docker logout ${registryHost} || true" } } - if (params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT)) { + if (params.DEPLOY) { stash name: 'deployment-files', includes: 'deploy/**', useDefaultExcludes: true } } } } + } + post { + always { + cleanWs(deleteDirs: true, notFailBuild: true) + } + } + } - stage('直接部署开发或测试环境') { - when { - expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) } - } - steps { - script { - String deployLock = "firstleg-${params.ENVIRONMENT}-deploy" - lock(resource: deployLock, inversePrecedence: true) { - boolean development = params.ENVIRONMENT == 'development' - boolean testTargetIsLocal = ['master', 'built-in'].contains(env.NODE_NAME) || - ['master', 'built-in'].any { label -> env.NODE_LABELS?.tokenize()?.contains(label) } - boolean targetIsLocal = development - ? env.NODE_LABELS?.tokenize()?.contains('java-dev') - : testTargetIsLocal - echo "构建节点 ${env.NODE_NAME} 部署到 ${params.ENVIRONMENT},目标是否为本机: ${targetIsLocal}" - String credentialId = "firstleg-${params.ENVIRONMENT}-env" - String remoteTarget = development ? 'deploy@172.31.198.8' : 'deploy@172.31.198.7' - String remoteKey = development - ? '/var/jenkins_home/.ssh/dev_deploy' - : '/home/deploy/.ssh/test_deploy' - String remoteKnownHosts = development - ? '/var/jenkins_home/.ssh/known_hosts' - : '/home/deploy/.ssh/known_hosts' - String remoteDir = development - ? '/opt/firstleg/application' - : '/opt/firstleg/test-application' - - withEnv([ - "TARGET_IS_LOCAL=${targetIsLocal}", - "TARGET_SSH_TARGET=${remoteTarget}", - "TARGET_SSH_KEY=${remoteKey}", - "TARGET_SSH_KNOWN_HOSTS=${remoteKnownHosts}", - "TARGET_DEPLOY_DIR=${remoteDir}" + stage('部署开发或测试环境') { + when { + beforeAgent true + expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) } + } + agent { + label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'}" + } + steps { + deleteDir() + unstash 'deployment-files' + script { + String deployLock = "firstleg-${params.ENVIRONMENT}-deploy" + String credentialId = "firstleg-${params.ENVIRONMENT}-env" + lock(resource: deployLock, inversePrecedence: true) { + echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" + withEnv([ + 'TARGET_IS_LOCAL=true' + ]) { + withCredentials([ + file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, + usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') ]) { - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" - } - } + sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" } } } diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 45abe70d5..1389f920c 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -35,10 +35,12 @@ test('production deployments cannot skip tests', () => { test('Java builds use the shared builder pool and serialize incremental target deployments', () => { assert.match(pipeline, /agent \{ label 'java-builder' \}/) assert.match(pipeline, /\['development', 'test'\]\.contains\(params\.ENVIRONMENT\)/) - assert.match(pipeline, /\['master', 'built-in'\]\.contains\(env\.NODE_NAME\)/) + assert.match(pipeline, /params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(pipeline, /'TARGET_IS_LOCAL=true'/) assert.match(pipeline, /String deployLock = "firstleg-\$\{params\.ENVIRONMENT\}-deploy"/) assert.match(pipeline, /lock\(resource: deployLock, inversePrecedence: true\)/) assert.match(pipeline, /deploy-built-service\.sh/) + assert.doesNotMatch(pipeline, /dev_deploy|test_deploy|TARGET_SSH_KEY/) assert.match(directDeployScript, /docker pull "\$IMAGE"/) assert.match(directDeployScript, /run_ssh docker pull "\$IMAGE"/) assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) From 52b673b8789f8a8d1047558942b4172dcd152995 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 17:46:11 +0800 Subject: [PATCH 08/10] perf(ci): release coordinator builder executor --- Jenkinsfile | 12 ++++++++---- scripts/controller-pipeline-contract.test.mjs | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 5544ff9e0..389450309 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -36,7 +36,7 @@ def environmentChoicesForJob(String jobBaseName) { pipeline { // 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。 - agent { label 'java-builder' } + agent none options { disableConcurrentBuilds() @@ -69,6 +69,7 @@ pipeline { stages { stage('确定构建范围') { + agent { label 'java-builder' } steps { script { // 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。 @@ -151,6 +152,11 @@ pipeline { } } } + post { + always { + cleanWs(deleteDirs: true, notFailBuild: true) + } + } } stage('生产环境部署确认(仅 production 执行)') { @@ -170,6 +176,7 @@ pipeline { expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE } } } + agent { label 'java-test' } steps { sh 'sh deploy/scripts/initialize-test-databases.sh' } @@ -225,7 +232,4 @@ pipeline { } } - post { - always { cleanWs(deleteDirs: true, notFailBuild: true) } - } } diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 48a20a75c..7e0758505 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -5,9 +5,12 @@ import test from 'node:test' const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') -test('coordinator runs only on the shared Java builder pool', () => { +test('coordinator releases its builder while waiting for downstream jobs', () => { + assert.match(pipeline, /pipeline \{[\s\S]*?agent none/) + assert.match(pipeline, /stage\('确定构建范围'\) \{[\s\S]*?agent \{ label 'java-builder' \}/) assert.match(pipeline, /agent \{ label 'java-builder' \}/) assert.doesNotMatch(pipeline, /agent any/) + assert.doesNotMatch(pipeline, /post \{\s*always \{ cleanWs[\s\S]*?\}\s*\}\s*\}\s*$/) }) test('AUTO detects database migrations without expanding them to all services', () => { From abab08e879d6f8b3ec05d1d19554f0c74edf3225 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 17:53:18 +0800 Subject: [PATCH 09/10] perf(ci): reuse Git workspaces across builds --- Jenkinsfile | 5 ----- Jenkinsfile.service | 5 ----- scripts/controller-pipeline-contract.test.mjs | 2 +- scripts/test-environment-pipeline-contract.test.mjs | 6 ++++++ 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 389450309..34c3f882e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -152,11 +152,6 @@ pipeline { } } } - post { - always { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } } stage('生产环境部署确认(仅 production 执行)') { diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 28f98316e..7f8c620de 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -191,11 +191,6 @@ pipeline { } } } - post { - always { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } } stage('部署单个服务') { diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 7e0758505..5bf92b4ed 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -10,7 +10,7 @@ test('coordinator releases its builder while waiting for downstream jobs', () => assert.match(pipeline, /stage\('确定构建范围'\) \{[\s\S]*?agent \{ label 'java-builder' \}/) assert.match(pipeline, /agent \{ label 'java-builder' \}/) assert.doesNotMatch(pipeline, /agent any/) - assert.doesNotMatch(pipeline, /post \{\s*always \{ cleanWs[\s\S]*?\}\s*\}\s*\}\s*$/) + assert.doesNotMatch(pipeline, /cleanWs\(/) }) test('AUTO detects database migrations without expanding them to all services', () => { diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 1389f920c..164a2db37 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -63,3 +63,9 @@ test('Java service images use the internal registry base image by default', () = assert.doesNotMatch(pipeline, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) assert.doesNotMatch(serviceDockerfile, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) }) + +test('source workspaces are preserved for incremental Git fetches', () => { + assert.match(pipeline, /checkout scm/) + assert.equal((pipeline.match(/cleanWs\(/g) ?? []).length, 2) + assert.doesNotMatch(coordinatorPipeline, /cleanWs\(/) +}) From 657f3553d85b86cbe206ae8e4d9faf7e8277a04a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 22 Aug 2026 17:55:50 +0800 Subject: [PATCH 10/10] fix(ci): retain service source workspaces --- Jenkinsfile.service | 10 +++++----- scripts/test-environment-pipeline-contract.test.mjs | 11 +++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 7f8c620de..92815a1c9 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -154,11 +154,6 @@ pipeline { } } } - post { - always { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } } stage('部署开发或测试环境') { @@ -191,6 +186,11 @@ pipeline { } } } + post { + always { + cleanWs(deleteDirs: true, notFailBuild: true) + } + } } stage('部署单个服务') { diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 164a2db37..f59c35877 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -65,7 +65,18 @@ test('Java service images use the internal registry base image by default', () = }) test('source workspaces are preserved for incremental Git fetches', () => { + const buildStage = pipeline.slice( + pipeline.indexOf("stage('构建并发布单服务')"), + pipeline.indexOf("stage('部署开发或测试环境')") + ) + const directDeployStage = pipeline.slice( + pipeline.indexOf("stage('部署开发或测试环境')"), + pipeline.indexOf("stage('部署单个服务')") + ) + assert.match(pipeline, /checkout scm/) + assert.doesNotMatch(buildStage, /cleanWs\(/) + assert.match(directDeployStage, /cleanWs\(/) assert.equal((pipeline.match(/cleanWs\(/g) ?? []).length, 2) assert.doesNotMatch(coordinatorPipeline, /cleanWs\(/) })