fix(ci): bound Maven resources and serialize shared builder jobs

This commit is contained in:
2026-10-03 17:52:47 +08:00
parent 78a4d4a089
commit 4ff55ba3e0
6 changed files with 144 additions and 7 deletions
Vendored
+9 -5
View File
@@ -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};下游单服务任务不再重复执行测试"
}
}
+5 -2
View File
@@ -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 {
+37
View File
@@ -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 "$@"
+24
View File
@@ -171,6 +171,30 @@
</plugins>
</build>
<profiles>
<profile>
<id>ci-bounded</id>
<properties>
<!-- JaCoCo fills this property at runtime; retain late evaluation below. -->
<argLine></argLine>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -Xms128m -Xmx1024m -XX:MaxMetaspaceSize=384m -XX:MaxDirectMemorySize=128m -XX:+ExitOnOutOfMemoryError</argLine>
<forkCount>1</forkCount>
<reuseForks>true</reuseForks>
<enableProcessChecker>ping</enableProcessChecker>
<shutdown>kill</shutdown>
<forkedProcessTimeoutInSeconds>1200</forkedProcessTimeoutInSeconds>
<forkedProcessExitTimeoutInSeconds>30</forkedProcessExitTimeoutInSeconds>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>local</id>
<properties>
+2
View File
@@ -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-<run-id>/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。
`client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=<bundled Python>`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-<run-id>/report.json` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表浏览器通过。可追加 `--database-profile=<同目录test-database.env>`:用现有 mysql CLI 的只读一致性事务,在导入前及每次拒绝后读取两客户 Order 的订单、货物、参与方、内部备注、状态历史、幂等和领域 Outbox 七表完整记录摘要(含逻辑删除),严格比较数量与完整摘要;不同、缺表、截断或查询失败即停止。DB 凭据只进入子进程环境,报告不含原始行;文件、审计及认证正常留存不属于这七表不变断言,不声称全环境零写入。未传此参数只验 HTTP。对应 Node/Python 测试检查零网络计划、环境边界、精准拒绝、模板其他部分不变及数据库快照完整性。
@@ -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(/<profile>\s*<id>ci-bounded<\/id>([\s\S]*?)<\/profile>/)?.[1]
assert.ok(profile)
assert.match(profile, /<argLine>@\{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}</${name}>`))
})
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 })
}
})