From 110d52fd5b13fe62d176d24f24073201a03e5c09 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 12:52:05 +0800 Subject: [PATCH] fix(ci): bound Docker image build resources --- Dockerfile | 7 +-- Jenkinsfile | 8 ++- README.md | 2 + scripts/build-image.sh | 18 +++++++ scripts/build-resource-contract.test.mjs | 67 ++++++++++++++++++++++++ scripts/deploy-production.sh | 2 +- 6 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 scripts/build-image.sh create mode 100644 scripts/build-resource-contract.test.mjs diff --git a/Dockerfile b/Dockerfile index 8c6e73e1..9219df38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build WORKDIR /app +# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。 +ENV NODE_OPTIONS=--max-old-space-size=2048 + # Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。 ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com @@ -21,9 +24,7 @@ RUN pnpm config set registry https://registry.npmmirror.com && \ COPY . . ARG VITE_CLIENT_WEB_URL ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL} -# Docker 构建时同机还会运行多项服务,限制 Vitest 并发以避免测试超时。 -# pnpm 会直接把脚本名后的参数传给 Vitest;额外的 `--` 会让 Vitest -# 把 maxWorkers 当作位置参数,导致并发限制不生效。 +# 类型检查通过后才生成静态产物。 RUN pnpm build # 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。 diff --git a/Jenkinsfile b/Jenkinsfile index 0004b5cb..bb1bb658 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,6 +3,7 @@ pipeline { options { timestamps() disableConcurrentBuilds() + timeout(time: 20, unit: 'MINUTES') } stages { stage('检查测试发布参数') { @@ -27,8 +28,11 @@ pipeline { when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu - docker compose -f docker-compose.test.yml build --pull=false - docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120 + sh scripts/build-image.sh --pull=false \ + --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + -t "admin-web-test:$IMAGE_TAG" . + docker compose -f docker-compose.test.yml up -d --no-build --pull never --wait --wait-timeout 120 docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz ''' } diff --git a/README.md b/README.md index ced2f53f..7bbb33e7 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ VITE_CLIENT_WEB_URL=http://localhost:5174 容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。 +Jenkins 的测试及灰度镜像构建均通过 `scripts/build-image.sh`,Compose 只部署已构建镜像。构建预算及节点要求见[部署指南](../../docs/测试环境Jenkins部署指南.md)。离线回归为 `node --test scripts/build-resource-contract.test.mjs`,本机仍经统一 `local-check.ps1` 入口运行。 + 认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。 diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100644 index 00000000..a6174568 --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# 构建前至少保留 1GiB 给业务服务,避免在宿主机内存不足时继续启动构建。 +available_kib=$(awk '$1 == "MemAvailable:" { print $2 }' /proc/meminfo) +case "$available_kib" in + ''|*[!0-9]*) echo 'Cannot read host available memory; build refused.' >&2; exit 69 ;; +esac +if [ "$available_kib" -lt 4194304 ]; then + echo 'Docker build requires at least 4GiB available host memory; retry after resources recover.' >&2 + exit 75 +fi + +# 当前节点使用 Docker 26 的经典构建器。显式固定后端,确保资源参数实际生效。 +# 限制覆盖每个 RUN 容器,失败时也清理中间容器,超时由宿主机独立终止。 +exec env DOCKER_BUILDKIT=0 timeout --signal=TERM --kill-after=30s 15m \ + docker build --memory=3g --memory-swap=3g \ + --cpu-period=100000 --cpu-quota=200000 --force-rm "$@" diff --git a/scripts/build-resource-contract.test.mjs b/scripts/build-resource-contract.test.mjs new file mode 100644 index 00000000..12ec275d --- /dev/null +++ b/scripts/build-resource-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const wrapper = join(root, 'scripts/build-image.sh') +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' +const shellAvailable = process.platform !== 'win32' || existsSync(bash) + +// 使用替身验证参数、内存预检和退出码,不启动 Docker 或访问部署环境。 +function runBuild(availableMemory, childStatus = '0') { + const directory = mkdtempSync(join(tmpdir(), 'frontend-build-resource-')) + try { + writeFileSync(join(directory, 'awk'), '#!/bin/sh\nprintf "%s\\n" "$TEST_AVAILABLE_MEMORY"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" watchdog "$@"\nshift 3\nexec "$@"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'docker'), '#!/bin/sh\nprintf "%s\\n" "builder=$DOCKER_BUILDKIT" "$@"\nexit "$TEST_CHILD_STATUS"\n', { mode: 0o755 }) + return spawnSync(bash, ['-c', + 'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"', + 'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'), + '--build-arg', 'VALUE=with space', '-t', 'web:test', '.'], { + encoding: 'utf8', timeout: 10000, + env: { ...process.env, DOCKER_BUILDKIT: '1', TEST_AVAILABLE_MEMORY: availableMemory, TEST_CHILD_STATUS: childStatus } + }) + } finally { + assert.equal(dirname(resolve(directory)), resolve(tmpdir())) + rmSync(directory, { recursive: true }) + } +} + +test('TEST and gray builds share the resource gate and deployment cannot rebuild implicitly', () => { + const pipeline = readFileSync(join(root, 'Jenkinsfile'), 'utf8') + const production = readFileSync(join(root, 'scripts/deploy-production.sh'), 'utf8') + const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8') + for (const source of [pipeline, production]) { + assert.match(source, /sh scripts\/build-image\.sh/) + assert.doesNotMatch(source, /docker (?:compose[^\n]* build|build\s)/) + } + assert.match(pipeline, /timeout\(time: 20, unit: 'MINUTES'\)/) + assert.match(pipeline, /up -d --no-build --pull never --wait/) + assert.ok(pipeline.includes(`-t "${basename(root)}-test:$IMAGE_TAG"`)) + assert.match(dockerfile, /^ENV NODE_OPTIONS=--max-old-space-size=2048$/m) +}) + +test('build forwards quoted arguments, enforces container budgets and propagates failure', { skip: !shellAvailable }, () => { + const result = runBuild('4194304', '23') + assert.equal(result.status, 23, result.stderr) + assert.match(result.stdout, /watchdog\r?\n--signal=TERM\r?\n--kill-after=30s\r?\n15m\r?\ndocker\r?\nbuild/) + assert.match(result.stdout, /builder=0\r?\nbuild/) + for (const argument of ['--memory=3g', '--memory-swap=3g', '--cpu-period=100000', '--cpu-quota=200000', '--force-rm']) { + assert.ok(result.stdout.split(/\r?\n/).includes(argument), argument) + } + assert.match(result.stdout, /--build-arg\r?\nVALUE=with space\r?\n-t\r?\nweb:test\r?\n\./) + assert.equal(runBuild('8388608').status, 0) + assert.equal(runBuild('8388608', '124').status, 124) +}) + +test('low or unreadable host memory refuses to start Docker', { skip: !shellAvailable }, () => { + for (const [memory, status] of [['4194303', 75], ['', 69], ['unknown', 69]]) { + const result = runBuild(memory) + assert.equal(result.status, status, result.stderr) + assert.equal(result.stdout, '') + } +}) diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index c1169e7e..496b0fbe 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -35,7 +35,7 @@ DOCKER_CONFIG=$(mktemp -d) cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } trap cleanup EXIT HUP INT TERM printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" -docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ +sh scripts/build-image.sh --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ --build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" . docker push "$image"