刘光辉
15 小时以前 34981c30a78e8bbd7791131059a9210f9928b62c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
#!/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)"