#!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # 构建机专用:组装整栈离线交付包(Backlog B / M3,方案见 tasks/backlog-b-offline-package-plan.md) # # 产物 dist/jnpf-offline-<日期>-/: # images/cx-images.tar.gz 9 唯一镜像(linux/amd64,单 tar 共享层去重) # images/cx-migration-tools.tar.gz 迁移辅助镜像 mysql:8.0 + pgloader(割接后可删) # images/manifest.txt 镜像 digest/ID/架构/大小 + git commit # docker-compose.yml / .env.example / Makefile(现场版) / INSTALL.md # (docker/rocketmq/ 已随 RocketMQ 于 2026-08-10 移除,见补丁 #10;补丁 #8 起 # 注册/配置中心整体摘除,其挂载配置目录已不再打包,config/shared/ 随镜像烧入即含全部配置) # migration/ MySQL→PG 迁移工具包(白名单复制,绝不含 dump/) # docs/ release-checklist / compose-guide / cutover-checklist # SHA256SUMS 全包完整性清单(现场 make verify 校验) # # 用法:make images-offline(推荐,自动校验 jar 与 flow-engine context) # 环境变量: # ALLOW_DIRTY=1 工作区脏时仍打包(验证期用;正式交付必须干净提交) # SKIP_MIGRATION_TOOLS=1 跳过 mysql/pgloader 辅助镜像(包更小,现场迁移须自备) # # 安全机制:本脚本会把本地业务镜像 tag 临时覆盖为 amd64,结束(含失败)时 trap 自动 # 重建原生镜像恢复——否则下次 make up 会以 11 个 Rosetta JVM 运行(L024 事故面)。 # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$REPO_ROOT" log() { printf '\033[1;34m[offline]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[offline][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[offline][err]\033[0m %s\n' "$*" >&2; exit 1; } # 交付构建的受控源(均为 SWR amd64 单架构;与 flow-engine 既有基座同源) SWR_JRE="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/bellsoft/liberica-openjre-rocky:21" SWR_REDIS="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/redis:8.4.0-alpine" # OnlyOffice DS(2026-08-19 由 Hub 长名切到 SWR 短名,同 cx-redis 机制) SWR_ONLYOFFICE="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/onlyoffice/documentserver:9.4.0.1" # 前端 nginx 基底(2026-07-17 imagetools inspect 实测:单 manifest amd64,L032 验证过) SWR_NGINX="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:1.27-alpine" MYSQL_IMAGE="mysql:8.0" PGLOADER_IMAGE="dimitri/pgloader:latest" # amd64 构建环境:DOCKER_DEFAULT_PLATFORM 覆盖未指定 platform 的业务服务。 # COMPOSE_PROFILES=localdb 是打包期必须的:cx-postgres 在该 profile 内(默认不启动),不带它 # build 会跳过 PG 镜像、`config --images` 也数不到它,交付包直接缺库镜像。交付现场的 # .env 同样要设这个键(PG 就是那边的唯一库),见 .env.example §1。 compose_amd64() { env DOCKER_DEFAULT_PLATFORM=linux/amd64 COMPOSE_PROFILES=localdb \ docker compose "$@" } # ── 0. 前置校验 ────────────────────────────────────────────────────────────── command -v docker >/dev/null || die "未找到 docker" DIRTY="" if [ -n "$(git status --porcelain)" ]; then if [ "${ALLOW_DIRTY:-0}" = "1" ]; then DIRTY="-dirty"; warn "工作区有未提交改动(ALLOW_DIRTY=1 放行)——正式交付必须在干净提交上打包" else die "工作区有未提交改动——交付产物必须可追溯到唯一 commit。先提交,或验证期用 ALLOW_DIRTY=1" fi fi # 5 个默认后端服务的 jar 逐一校验(平台聚合 + 独立 DMS) BIZ_JARS=( jnpf-gateway/target jnpf-platform/jnpf-platform-server/target jnpf-biz-common/jnpf-biz-common-server/target jnpf-lims/jnpf-lims-server/target jnpf-dms/jnpf-dms-server/target ) for d in "${BIZ_JARS[@]}"; do ls "$d"/*.jar >/dev/null 2>&1 || die "缺业务 jar:${d}/*.jar —— 先 make jars" done ls docker/flow-engine/context/*.jar >/dev/null 2>&1 || die "缺 flow-engine context jar —— 先 make prepare" [ -f docker/frontend/dist/index.html ] || die "缺前端产物 docker/frontend/dist/ —— 先 make web-dist(新鲜度看 dist/.build-info)" # migration sql 组装校验(缺则尝试自动组装,官方脚本目录不在则明确报错) if [ ! -f docker/postgres/migration/sql/jnpf_dbnull_init.sql ]; then log "migration/sql/ 未组装,尝试自动组装 …" ./docker/postgres/migration/assemble-package.sh || die "sql/ 组装失败(官方脚本目录缺失时传 DB_REPO=... 重试)" fi GIT_SHA="$(git rev-parse --short HEAD)" PKG_NAME="cx-offline-$(date +%Y%m%d)-${GIT_SHA}${DIRTY}" PKG="dist/$PKG_NAME" rm -rf "$PKG" mkdir -p "$PKG/images" # ── 恢复机制:无论成败,退出时把本地镜像恢复为原生架构 ──────────────────────── NEED_RESTORE_BUILD=0 NEED_RESTORE_MYSQL=0 restore() { local rc=$? trap - EXIT if [ "$NEED_RESTORE_BUILD" = 1 ]; then log "恢复本地原生业务镜像(arm64 层缓存命中,通常秒级)…" if env COMPOSE_PROFILES=localdb docker compose build >/dev/null; then log "本地镜像已恢复:cx/gateway=$(docker image inspect --format '{{.Architecture}}' "cx/gateway:${CX_VERSION:-1.0.0}" 2>/dev/null || echo '?')" else warn "本地镜像恢复失败!手动执行 make images —— 否则 make up 将以 amd64 镜像运行(L024 风险)" fi fi if [ "$NEED_RESTORE_MYSQL" = 1 ]; then docker pull --platform linux/arm64 "$MYSQL_IMAGE" >/dev/null 2>&1 \ || warn "mysql:8.0 arm64 恢复失败(仅影响本地 mysql 客户端容器走模拟层,可稍后手动 pull)" fi exit "$rc" } trap restore EXIT # 未捕获的 INT/TERM 不会触发 EXIT trap(bash 语义),显式转成 exit 保证恢复必然执行 trap 'exit 130' INT trap 'exit 143' TERM # ── 1. amd64 构建(业务基座注入 SWR;postgres/flow-engine 已 platform 固定=缓存命中) ── log "===== amd64 构建(5 后端业务镜像 + 自建镜像;基座 ${SWR_JRE##*/})=====" NEED_RESTORE_BUILD=1 compose_amd64 build --build-arg BASE_IMAGE="$SWR_JRE" --build-arg NGINX_BASE_IMAGE="$SWR_NGINX" # ── 2. amd64 基础设施拉取 + retag 成 compose 用的短名 ──────────────────────── # 不能再用 `compose pull --ignore-buildable`:compose 里这两个服务写的是短名 # (cx-redis;补丁 #8 摘除注册/配置中心、补丁 #10 摘除 RocketMQ 前还有更多基础设施短名), # 裸 pull 会去 docker.io 找不存在的仓库。这里显式拉 SWR amd64 长名再 retag, # 等价于 amd64 版的 `make pull-infra`。 log "===== amd64 基础设施镜像拉取(SWR)+ retag 短名 =====" docker pull --platform linux/amd64 "$SWR_REDIS" docker tag "$SWR_REDIS" cx-redis:8.4.0-alpine # OnlyOffice DS ~2GB,是这一步耗时的大头(首次拉取;后续走本地缓存) docker pull --platform linux/amd64 "$SWR_ONLYOFFICE" docker tag "$SWR_ONLYOFFICE" cx-onlyoffice:9.4.0.1 # ── 3. 镜像清单 + 逐一架构校验 ──────────────────────────────────────────────── # 下限 8 的来历(2026-08-20 实测 `docker compose config --images | sort -u`,默认 profile): # · cx-onlyoffice 在默认 profile,必须由上面的 SWR pull+retag 供货。 # · 平台聚合后,默认清单为 cx/gateway、cx/platform、cx/lims、cx/biz-common、cx/dms、 # cx/flow-engine、cx/web、cx-onlyoffice。DMS 独立服务使清单从 7 个增加到 8 个。 # 维护提示:这个数字**每次增删 compose 服务都要跟着改**,它的价值是挡住"漏了某个镜像", # 所以宁可精确、不要留富余。 IMAGES=() while IFS= read -r img; do IMAGES+=("$img"); done < <(compose_amd64 config --images | sort -u) [ "${#IMAGES[@]}" -ge 8 ] || die "compose 解析出的镜像数异常:${#IMAGES[@]}(期望 ≥8)" log "===== 架构校验(todo 要求:打包前逐一 docker inspect)=====" for img in "${IMAGES[@]}"; do arch="$(docker image inspect --format '{{.Architecture}}' "$img" 2>/dev/null)" \ || die "镜像不存在:${img}" [ "$arch" = "amd64" ] || die "架构不符:${img} 是 ${arch}(期望 amd64)" printf ' %-78s amd64 ✓\n' "$img" done # ── 4. 导出运行镜像(单 tar 合并 save:共享基座层只存一份) ─────────────────── log "===== docker save ${#IMAGES[@]} 个镜像 → images/cx-images.tar.gz(数 GB,耐心)=====" docker save "${IMAGES[@]}" | gzip > "$PKG/images/cx-images.tar.gz" # ── 5. 迁移辅助镜像(migrate-data.sh 的隐藏 Docker Hub 运行时依赖,收编离线化) ─ TOOL_IMAGES=() if [ "${SKIP_MIGRATION_TOOLS:-0}" != "1" ]; then log "===== 迁移辅助镜像(amd64)=====" NEED_RESTORE_MYSQL=1 docker pull --platform linux/amd64 "$MYSQL_IMAGE" docker pull --platform linux/amd64 "$PGLOADER_IMAGE" for img in "$MYSQL_IMAGE" "$PGLOADER_IMAGE"; do arch="$(docker image inspect --format '{{.Architecture}}' "$img")" [ "$arch" = "amd64" ] || die "架构不符:${img} 是 ${arch}" done TOOL_IMAGES=("$MYSQL_IMAGE" "$PGLOADER_IMAGE") docker save "${TOOL_IMAGES[@]}" | gzip > "$PKG/images/cx-migration-tools.tar.gz" else warn "SKIP_MIGRATION_TOOLS=1:不打迁移辅助镜像,现场停机窗口需自备 mysql:8.0 与 pgloader" fi # ── 6. manifest ────────────────────────────────────────────────────────────── { echo "JNPF 整栈离线交付包 镜像清单" echo "包名 : $PKG_NAME" echo "git commit : $(git rev-parse HEAD)${DIRTY}" echo "打包时间 : $(date '+%F %T %z')" echo "目标平台 : linux/amd64" echo printf '%-78s %-7s %-8s %s\n' "IMAGE" "ARCH" "SIZE" "IMAGE_ID | REPO_DIGEST" for img in "${IMAGES[@]}" ${TOOL_IMAGES[@]+"${TOOL_IMAGES[@]}"}; do docker image inspect --format \ "$(printf '%-78s' "$img") {{.Architecture}} {{.Size}}B {{.Id}} | {{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}(本地构建,无 RepoDigest){{end}}" \ "$img" done } > "$PKG/images/manifest.txt" # ── 7. 运行时文件(相对路径与仓库一致,compose 原样可用) ───────────────────── log "===== 拷贝运行时文件与文档 =====" cp docker-compose.yml .env.example "$PKG/" # 部署自举校验(Backlog C1):包内路径与仓库一致,包 Makefile 的 up 强制前置 cp docker/preflight.sh "$PKG/docker/" # 前端 nginx.conf:compose 以相对路径挂载(现场免构建改反代/开 TLS),包内缺它 up 直接失败 mkdir -p "$PKG/docker/frontend" cp docker/frontend/nginx.conf "$PKG/docker/frontend/" # DLQ/spool 重放预检脚本:guide §19「DLQ 重放闭环」逐条引用,INSTALL.md §1 的 python3 前置 # 即为它——此前遗漏未打包,现场按手册操作会因文件缺失而失败(Codex review) mkdir -p "$PKG/tasks" cp tasks/audit-event-validate.sh "$PKG/tasks/" # migration 工具包:白名单复制(绝不 cp -R 整目录——dump/ 含现场业务数据,codex review) mkdir -p "$PKG/migration/sql" for f in install.sh migrate-data.sh backup.sh create-app-roles.sh create_app_roles.sql \ schema-drift-check.sh \ README-迁移操作手册.md; do cp "docker/postgres/migration/$f" "$PKG/migration/" done cp docker/postgres/migration/sql/* "$PKG/migration/sql/" mkdir -p "$PKG/docs" cp docs/release-checklist.md docs/docker-compose-guide.md docs/archive/server-pg-cutover-checklist.md "$PKG/docs/" cp "$SCRIPT_DIR/INSTALL.md" "$PKG/INSTALL.md" cp "$SCRIPT_DIR/site.Makefile" "$PKG/Makefile" # ── 8. 完整性清单 ──────────────────────────────────────────────────────────── log "===== 生成 SHA256SUMS =====" SHACMD="sha256sum"; command -v sha256sum >/dev/null || SHACMD="shasum -a 256" ( cd "$PKG" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 $SHACMD > SHA256SUMS ) # ── 8b. 组包后关键交付物存在性断言(Codex review)────────────────────────────── [ -f "$PKG/tasks/audit-event-validate.sh" ] \ || die "组包异常:DLQ/spool 重放预检脚本未进入交付包(tasks/audit-event-validate.sh 缺失,guide §19/INSTALL.md §1 引用会失效)" # 精确锚定 SHA256SUMS 行尾(`␠␠./路径`,sha256sum/shasum 文本模式统一格式),不用子串匹配 # (grep -qF 会被更长路径的偶然子串误判为"已覆盖",如 .../not-tasks/audit-event-validate.sh 或 # tasks/audit-event-validate.sh.bak 均含该子串但并非同一交付文件) grep -qE ' \./tasks/audit-event-validate\.sh$' "$PKG/SHA256SUMS" \ || die "组包异常:SHA256SUMS 未覆盖 tasks/audit-event-validate.sh" # ── 9. 汇总 ────────────────────────────────────────────────────────────────── log "===== 打包完成 =====" du -sh "$PKG" | awk '{print " 总大小: "$1}' du -sh "$PKG"/images/*.tar.gz | sed 's/^/ /' echo log "交付包目录:$PKG" log "单文件传输(镜像已 gzip,外层不再压缩):" log " tar cf dist/${PKG_NAME}.tar -C dist ${PKG_NAME}" log "现场步骤见包内 INSTALL.md(make verify → load → 配 .env → up → wait → smoke)"