From 4ff55ba3e08c7fe0c6e04832fa95ebb680c76b8c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 17:52:47 +0800 Subject: [PATCH] fix(ci): bound Maven resources and serialize shared builder jobs --- Jenkinsfile | 14 +++-- Jenkinsfile.service | 7 ++- deploy/scripts/run-ci-maven.sh | 37 ++++++++++++ pom.xml | 24 ++++++++ scripts/README.md | 2 + scripts/ci-maven-resource-contract.test.mjs | 67 +++++++++++++++++++++ 6 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 deploy/scripts/run-ci-maven.sh create mode 100644 scripts/ci-maven-resource-contract.test.mjs diff --git a/Jenkinsfile b/Jenkinsfile index 047ec59ad..3659d5dfb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -327,11 +327,15 @@ pipeline { .findAll { it } .join(',') echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" - sh """ - test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" - find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete - mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test - """ + 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 + sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + """ + } + } echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" } } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 1b34f46dc..a11c90f78 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -55,7 +55,6 @@ pipeline { environment { REGISTRY_CREDENTIALS = 'gitea-registry-token' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml' - MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' } stages { @@ -141,7 +140,11 @@ pipeline { if (params.SKIP_TESTS && coordinatorPinnedCommit) { echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" } - sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" + lock(resource: 'firstleg-test-maven') { + timeout(time: 50, unit: 'MINUTES') { + sh "sh deploy/scripts/run-ci-maven.sh --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" + } + } } } post { diff --git a/deploy/scripts/run-ci-maven.sh b/deploy/scripts/run-ci-maven.sh new file mode 100644 index 000000000..c300267e2 --- /dev/null +++ b/deploy/scripts/run-ci-maven.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Linux Jenkins entry; no service deployment, data cleanup or dependency installation. +set -eu + +for tool in flock timeout mvn; do + command -v "$tool" >/dev/null 2>&1 || { + echo "Required CI tool missing: $tool" >&2 + exit 69 + } +done +for argument in "$@"; do + case "$argument" in + -T*|--threads*) + echo 'Parallel Maven reactors are disabled on the shared TEST builder.' >&2 + exit 64 + ;; + esac +done + +# All jobs run as the same builder account. Keep the inode: flock releases on close. +# A controller-side abort must not permit another build over a surviving local build. +umask 077 +lock_dir="${HOME:?HOME is required}/.cache/chongchong-ci" +mkdir -p "$lock_dir" +exec 9>"$lock_dir/maven.lock" +if ! flock -n 9; then + echo 'Another local Maven build still holds the CI lock; inspect it before retrying.' >&2 + exit 75 +fi + +# Separate budgets: this process is Maven; ci-bounded configures its test JVM. +MAVEN_OPTS='-Xms128m -Xmx768m -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=128m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' +export MAVEN_OPTS + +# Local watchdog still runs if the Jenkins controller/agent connection is lost. +# Do not use --foreground: timeout must signal the forked process group too. +exec timeout --signal=TERM --kill-after=30s 45m mvn -Pci-bounded "$@" diff --git a/pom.xml b/pom.xml index 6755a57e4..d2431172e 100644 --- a/pom.xml +++ b/pom.xml @@ -171,6 +171,30 @@ + + ci-bounded + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -Xms128m -Xmx1024m -XX:MaxMetaspaceSize=384m -XX:MaxDirectMemorySize=128m -XX:+ExitOnOutOfMemoryError + 1 + true + ping + kill + 1200 + 30 + + + + + local diff --git a/scripts/README.md b/scripts/README.md index a8859e8dd..bad458f43 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`deploy/scripts/run-ci-maven.sh` 是 Linux Jenkins 的受限 Maven 构建入口,不部署、不访问业务数据库、不安装依赖。总控及单服务 Job 共用 `firstleg-test-maven` 锁;同一构建账号另用 `$HOME/.cache/chongchong-ci/maven.lock` 文件锁,已有本机持锁任务时失败退出,不删除锁文件或杀其他进程。入口禁止 `-T/--threads`,固定 Maven 堆768MiB、元空间256MiB、直接内存128MiB,启用根POM `ci-bounded`:单个测试JVM、堆1GiB、元空间384MiB、直接内存128MiB,保留JaCoCo迟绑定;OOM快速退出,父进程丢失检测、停止时kill、单测试JVM20分钟上限。独立于Jenkins连接的本机GNU timeout在45分钟后TERM、30秒后KILL进程组,流水线另设50分钟超时;缺少flock/timeout/mvn直接失败。构建输出及Surefire报告沿现有Jenkins归档;失败先确认原进程退出再重跑,不自动清理缓存。该预算不等于总RSS硬限额,代理本身和JVM原生内存仍需保留余量;节点执行器应设为1、保持systemd control-group清理,并在真实Linux节点验证峰值。`ci-maven-resource-contract.test.mjs` 检查双入口接线、参数拒绝/失败传播和子JVM配置,Shell替身检查不代替Linux实际锁与信号验收。 + `client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 `client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-/report.json` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表浏览器通过。可追加 `--database-profile=<同目录test-database.env>`:用现有 mysql CLI 的只读一致性事务,在导入前及每次拒绝后读取两客户 Order 的订单、货物、参与方、内部备注、状态历史、幂等和领域 Outbox 七表完整记录摘要(含逻辑删除),严格比较数量与完整摘要;不同、缺表、截断或查询失败即停止。DB 凭据只进入子进程环境,报告不含原始行;文件、审计及认证正常留存不属于这七表不变断言,不声称全环境零写入。未传此参数只验 HTTP。对应 Node/Python 测试检查零网络计划、环境边界、精准拒绝、模板其他部分不变及数据库快照完整性。 diff --git a/scripts/ci-maven-resource-contract.test.mjs b/scripts/ci-maven-resource-contract.test.mjs new file mode 100644 index 000000000..321f7321f --- /dev/null +++ b/scripts/ci-maven-resource-contract.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const wrapper = resolve(root, 'deploy/scripts/run-ci-maven.sh') +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' +const shellAvailable = process.platform !== 'win32' || existsSync(bash) + +test('both Jenkins Maven paths share one cross-job lock and bounded host entry', () => { + for (const file of ['Jenkinsfile', 'Jenkinsfile.service']) { + const source = readFileSync(resolve(root, file), 'utf8') + assert.match(source, /lock\(resource: 'firstleg-test-maven'\)\s*\{\s*timeout\(time: 50, unit: 'MINUTES'\)/) + assert.match(source, /sh deploy\/scripts\/run-ci-maven\.sh --settings/) + assert.doesNotMatch(source, /(?:^|\n)\s*mvn\s|sh "mvn /) + } + const source = readFileSync(wrapper, 'utf8') + assert.match(source, /exec 9>/) + assert.match(source, /flock -n 9/) + assert.match(source, /exec timeout --signal=TERM --kill-after=30s 45m mvn -Pci-bounded/) + assert.doesNotMatch(source, /exec timeout[^\n]*--foreground/) +}) + +test('test JVM gets a separate heap, JaCoCo late binding, and parent-loss cleanup', () => { + const pom = readFileSync(resolve(root, 'pom.xml'), 'utf8') + const profile = pom.match(/\s*ci-bounded<\/id>([\s\S]*?)<\/profile>/)?.[1] + assert.ok(profile) + assert.match(profile, /@\{argLine\}[^<]*-Xmx1024m[^<]*-XX:\+ExitOnOutOfMemoryError<\/argLine>/) + for (const [name, value] of Object.entries({ forkCount: '1', enableProcessChecker: 'ping', shutdown: 'kill', + forkedProcessTimeoutInSeconds: '1200', forkedProcessExitTimeoutInSeconds: '30' })) + assert.ok(profile.includes(`<${name}>${value}`)) +}) + +test('shell rejects parallel reactors and held host lock before invoking Maven; propagates failure', { skip: !shellAvailable }, () => { + const directory = mkdtempSync(join(tmpdir(), 'ci-maven-command-test-')) + try { + // Stand-ins exercise command wiring, not Linux flock or process-group signalling. + writeFileSync(join(directory, 'flock'), '#!/bin/sh\nexit "${TEST_LOCK_STATUS:-0}"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'mvn'), '#!/bin/sh\nexit 99\n', { mode: 0o755 }) + writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" "$MAVEN_OPTS" "$@"\nexit "${TEST_CHILD_STATUS:-0}"\n', { mode: 0o755 }) + const run = (args, extra = {}) => spawnSync(bash, ['-c', + 'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"', + 'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'), ...args], + { encoding: 'utf8', timeout: 10000, env: { ...process.env, ...extra } }) + for (const args of [['-T', '3', 'test'], ['-T3', 'test'], ['--threads=3', 'test']]) { + const result = run(args) + assert.equal(result.status, 64, result.stderr) + assert.equal(result.stdout, '') + } + const held = run(['test'], { TEST_LOCK_STATUS: '1' }) + assert.equal(held.status, 75, held.stderr) + assert.equal(held.stdout, '') + const normal = run(['-pl', 'file', '-am', 'test'], { TEST_CHILD_STATUS: '23', MAVEN_OPTS: '-Xmx8g' }) + assert.equal(normal.status, 23, normal.stderr) + assert.match(normal.stdout, /-Xmx768m/) + assert.doesNotMatch(normal.stdout, /-Xmx8g/) + assert.match(normal.stdout, /--signal=TERM\r?\n--kill-after=30s\r?\n45m\r?\nmvn\r?\n-Pci-bounded\r?\n-pl\r?\nfile/) + } finally { + // Only this newly-created fixture directory; no project copies or shared caches removed. + assert.equal(resolve(directory).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/')), true) + rmSync(directory, { recursive: true }) + } +})