#!/usr/bin/env bash
|
# ─────────────────────────────────────────────────────────────────────────────
|
# 构建机专用:组装整栈离线交付包(Backlog B / M3,方案见 tasks/backlog-b-offline-package-plan.md)
|
#
|
# 产物 dist/jnpf-offline-<日期>-<gitsha>/:
|
# 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 行尾(`<hash>␠␠./路径`,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)"
|