#!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF 全栈部署自举校验(Backlog C1) # make up / make up-no-pull 的强制前置(SKIP_PREFLIGHT=1 逃生),也可独立运行。 # 目标:把"空库 PG 却 healthy、引擎/业务全线连不上"的静默失败拦在 # compose up 之前,缺项直接给出修复指引(migration/install.sh、INSTALL.md)。 # # 校验分三层: # 硬前置(失败立即退出) :docker CLI / daemon 可达 / compose v2 插件 / 编排文件存在 # 聚合检查(跑完统一汇总):.env 必填键(从 compose 的 ${VAR:?} 动态提取)、 # 客户坐标(CUSTOMER_DB_HOST/PORT 必填 + 实连探测 + 与 localdb profile 配套)、 # postgres_pgdata 卷、PG 容器可起且接受连接、三库表数 ≥ 基准、 # 种子数据(jnpf_init 有 admin)、共享配置文件就位(补丁 #8)、业务角色齐 # 警告(不拦截):Docker 可用内存 <12GB(L024)、宿主 hosts 缺 cx-infra、 # # 两种库形态,检查项按 CUSTOMER_DB_HOST 自动分流(2026-08-07): # 栈内 PG(=cx-postgres,交付/正式):全套跑,含卷/容器/三库/种子/角色 # 远端客户库(=CVM 上的客户实例,开发机/测试环境):3-5 与角色检查跳过——那边的 # 卷、schema、种子、角色由该 PG 实例自身的运维负责(docs/db-connection-pool-guide.md), # 本机既起不了那个容器也 docker exec 不进去;本机侧只验坐标可达与共享配置文件 # # 用法: # ./docker/preflight.sh # 仓库根执行(离线交付包内同路径,包根执行) # # 参数(环境变量,默认=现场标准值;改名可做隔离测试,不动真库): # CONTAINER PG 容器名 默认 cx-postgres # VOLUME_NAME PG external 数据卷名 默认 postgres_pgdata # ENV_FILE 凭据文件 默认 .env # COMPOSE_FILE_PATH 编排文件 默认 docker-compose.yml # DB_INIT / DB_FLOW / DB_XXLJOB 三库名 # PG_WAIT_SECS 等 PG 接受连接的上限秒数 默认 180(含大卷 crash recovery 窗口) # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail trap 'exit 130' INT trap 'exit 143' TERM CONTAINER="${CONTAINER:-cx-postgres}" VOLUME_NAME="${VOLUME_NAME:-postgres_pgdata}" ENV_FILE="${ENV_FILE:-.env}" COMPOSE_FILE_PATH="${COMPOSE_FILE_PATH:-docker-compose.yml}" DB_INIT="${DB_INIT:-jnpf_init}" DB_FLOW="${DB_FLOW:-jnpf_flow}" DB_XXLJOB="${DB_XXLJOB:-jnpf_xxljob}" PG_WAIT_SECS="${PG_WAIT_SECS:-180}" # 三库表数基准(下限,只允许 ≥,业务扩表不误报;install.sh 用精确匹配核对,本处刻意保守) # 注:73→74 只为 20260724 新增 audit_events +1;与 install.sh 现行「官方293+自建84=377」的 # 精确基准存在既有 10 张漂移(早于本次改动,未在本次范围内一并追平,避免误改无关基准) BASE_INIT=367 # 官方 293 + 自建 74 BASE_FLOW=70 BASE_XXLJOB=11 log() { printf '\033[1;34m[preflight]\033[0m %s\n' "$*"; } warnp(){ printf '\033[1;33m[preflight][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[preflight][err]\033[0m %s\n' "$*" >&2; exit 1; } # 修复指引的路径按运行位置自适应:离线交付包根有 migration/,仓库根在 docker/postgres/ 下 if [ -f migration/install.sh ]; then MIG_DIR="migration" else MIG_DIR="docker/postgres/migration" fi # ── 聚合失败机制:单项失败不中断,跑完统一汇总(前置失败的项标记跳过) ────── FAILS=() WARNS=() ok() { printf ' \033[1;32m✓\033[0m %s\n' "$1"; } bad() { printf ' \033[1;31m✗\033[0m %s\n' "$1"; FAILS+=("$1"$'\n'" 修复:$2"); } warn2(){ printf ' \033[1;33m!\033[0m %s\n' "$1"; WARNS+=("$1 —— $2"); } # $2 可选:跳过原因后缀,默认「前置失败」(外部库模式等主动跳过场景传自己的原因) skip() { printf ' \033[1;90m-\033[0m %s(%s,跳过)\n' "$1" "${2:-前置失败}"; } psql_exec() { docker exec "$CONTAINER" psql -U postgres "$@"; } get_env() { { grep -E "^$1=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2-; } || true; } CDH=""; CDP="" # ── 0. 硬前置:docker / compose ────────────────────────────────────────────── log "===== 0/6 docker 环境 =====" command -v docker >/dev/null 2>&1 \ || die "未找到 docker 命令——先安装 Docker Engine(含 compose v2 插件)" docker info >/dev/null 2>&1 \ || die "docker daemon 不可达——确认 Docker/OrbStack 已启动,且当前用户有权限(docker info 报错内容为准)" docker compose version >/dev/null 2>&1 \ || die "docker compose v2 插件不可用——需要 Docker Compose V2(docker compose version 验证)" ok "docker CLI / daemon / compose v2 就绪" # 编排文件存在性提前到此处硬前置检查(原放在下方"1. .env 必填键",但下面若干 # 检查更早就用 awk 读取该文件——set -e 下 awk 遇文件不存在会以 awk 自身退出码裸退,绕过所有 # die() 提示;提到最前面确保任何读取 COMPOSE_FILE_PATH 的分支之前已保证文件存在) [ -f "$COMPOSE_FILE_PATH" ] \ || die "缺编排文件 ${COMPOSE_FILE_PATH}——请在仓库根/离线交付包根目录执行" # 内存红线(L024:全栈 16 容器 <12GB 会 OOM 连锁:PG crash 循环 + gRPC 超时风暴) # 四舍五入到 GiB:VM 名义 12GB 会被内核保留吃掉零头(12288MiB 实报 ~12.2GiB→整除变 11 误报) MEM_TOTAL="$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0)" MEM_GIB=$(( (MEM_TOTAL + 512*1024*1024) / 1024 / 1024 / 1024 )) if [ "$MEM_TOTAL" -gt 0 ] && [ "$MEM_GIB" -lt 12 ]; then warn2 "Docker 可用内存仅 ${MEM_GIB}GB(<12GB 红线)" "全栈需 ≥12GB:Linux 加物理内存/减容器;OrbStack 用 orb config set memory_mib 12288(L024)" else ok "Docker 可用内存 ${MEM_GIB}GB(≥12GB)" fi # cx-infra 宿主解析(ApiDomain 双消费方约定;ApiDomain 已改现场地址的纯容器部署可忽略本项) # Git Bash 下 /etc/hosts 是 Git 自带文件,Windows 真 hosts 在 System32/drivers/etc/hosts HOSTS_FILE=/etc/hosts case "$(uname -s)" in MINGW*|MSYS*) HOSTS_FILE="$(cygpath -u "$SYSTEMROOT")/System32/drivers/etc/hosts" ;; esac if grep -q 'cx-infra' "$HOSTS_FILE" 2>/dev/null; then ok "宿主 hosts 已含 cx-infra" else warn2 "宿主 hosts 缺 cx-infra" "补一行「127.0.0.1 cx-infra」(本机浏览器调试用;ApiDomain 已改为现场地址的纯容器部署可忽略)" fi # (RocketMQ 已于 2026-08-10 移除,原 mq-store 卷属主检查一并删除,见补丁 #10) # ── 1. .env 必填键(从 compose 文件的 \${VAR:?} 动态提取,改 compose 自动跟进) ── log "===== 1/6 .env 必填键 =====" ENV_OK=1 # 编排文件存在性已在 0/6 硬前置检查(早于本节对该文件的 awk/grep 读取) if [ ! -f "$ENV_FILE" ]; then ENV_OK=0 bad "缺 ${ENV_FILE}(compose 凭据来源)" "cp .env.example .env 并按注释改值(交付环境必须全部改密,见 docs/release-checklist.md §0.7)" else # 先滤注释行再提取(compose 头注释里有 ${VAR:?} 字样的说明文字,不滤会误提取) REQUIRED_KEYS="$(grep -vE '^[[:space:]]*#' "$COMPOSE_FILE_PATH" | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*:\?' | sed -E 's/^\$\{//; s/:\?$//' | sort -u)" MISSING=() for key in $REQUIRED_KEYS; do # .env 里有非空值,或已在环境变量里(compose 两种来源都认) if grep -qE "^${key}=..*" "$ENV_FILE" || [ -n "${!key:-}" ]; then :; else MISSING+=("$key"); fi done if [ "${#MISSING[@]}" -eq 0 ]; then ok "必填键齐:$(echo "$REQUIRED_KEYS" | tr '\n' ' ')" else ENV_OK=0 bad "${ENV_FILE} 缺必填键:${MISSING[*]}" "对照 .env.example 补齐(每个键的生成方法见文件内注释)" fi fi # ── 2. 客户坐标(docs/adr-multi-customer-db-isolation.md §3)──────────────── # 缺配会静默回退到默认 cx-postgres:5432——在开发机上就是连到本机沙盒而不是客户库, # 数据写错地方且无任何提示。这两个变量同时决定业务库 jnpf_init 与引擎库 jnpf_flow # (compose 的 FLOW_DB_URL 由它们推导),故两库不可能半切。 log "===== 2/6 客户坐标 =====" if [ "$ENV_OK" = 0 ]; then skip "客户坐标" else # `|| true` 不可省:set -euo pipefail 下 grep 无匹配返回 1,会经 pipefail 传给命令替换, # 令赋值失败并被 set -e 直接终止脚本——表现为打完节标题就静默退出(缺配恰恰是要报的情况) CDH="$(grep -E '^CUSTOMER_DB_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" CDP="$(grep -E '^CUSTOMER_DB_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" if [ -z "$CDH" ] || [ -z "$CDP" ]; then bad "${ENV_FILE} 缺 CUSTOMER_DB_HOST / CUSTOMER_DB_PORT" \ "从 .env.example 抄这两行:同栈 PG 填 cx-postgres / 5432;客户库填 YOUR_DB_HOST / 154xx(客户1=15432、客户2=15433)" elif [ -n "$(printf '%s' "$CDP" | tr -d '0-9')" ]; then bad "CUSTOMER_DB_PORT 必须是整数,当前='${CDP}'" "改 ${ENV_FILE}" else echo " 生效坐标:${CDH}:${CDP}" # 栈内 PG 收在 localdb profile 内(默认不启动),开关必须与坐标配套,否则两种错配: # 坐标指栈内却没开 profile → PG 根本不起,全栈无库可连(硬失败) # 坐标指远端却开了 profile → 起一个没人连的 PG,还要求 external 卷存在(警告) PROFILES="$(printf '%s' "${COMPOSE_PROFILES:-$(get_env COMPOSE_PROFILES)}" | tr ' ' ',')" case ",${PROFILES}," in *,localdb,*) HAS_LOCALDB=1 ;; *) HAS_LOCALDB=0 ;; esac if [ "$CDH" = "cx-postgres" ] && [ "$HAS_LOCALDB" = 0 ]; then bad "CUSTOMER_DB_HOST=cx-postgres 但未启用 localdb profile(栈内 PG 不会启动,业务全线无库可连)" \ "在 ${ENV_FILE} 加一行 COMPOSE_PROFILES=localdb(见 .env.example §1)" elif [ "$CDH" != "cx-postgres" ] && [ "$HAS_LOCALDB" = 1 ]; then warn2 "库在远端 ${CDH}:${CDP},却启用了 localdb profile" \ "注释掉 ${ENV_FILE} 的 COMPOSE_PROFILES=localdb——否则会起一个没人连的 PG,且缺 external 卷 ${VOLUME_NAME} 时整栈起不来" fi if [ "$CDH" = "cx-postgres" ]; then # compose 内部服务名,宿主解析不了;起栈后由 PG healthcheck 与下面 3/6 保证 ok "客户坐标 = 同栈 PG(${CDH}:${CDP}),跳过宿主实连探测" elif command -v nc >/dev/null 2>&1 && nc -z -w 5 "$CDH" "$CDP" >/dev/null 2>&1; then ok "客户坐标 ${CDH}:${CDP} 可达" elif ! command -v nc >/dev/null 2>&1 && \ (exec 3<>"/dev/tcp/${CDH}/${CDP}") >/dev/null 2>&1; then # Git Bash 无 nc,退回 bash /dev/tcp(无超时控制,不可达时可能等到 TCP SYN 超时) ok "客户坐标 ${CDH}:${CDP} 可达(/dev/tcp 探测)" else bad "客户坐标 ${CDH}:${CDP} 连不上" \ "确认端口填对(客户1=15432 / 客户2=15433)、云安全组已放行来源 IP、本机网络出站不受限" fi fi # Compose 生效的 Redis 坐标。JNPF_DOCKER_REDIS_* 允许宿主 JVM 用 127.0.0.1、 # 容器改走 cx-infra(host-gateway),避免容器把 127.0.0.1 误当成宿主机。 RDH_HOST="$(grep -E '^JNPF_REDIS_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDP_HOST="$(grep -E '^JNPF_REDIS_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDH="$(grep -E '^JNPF_DOCKER_REDIS_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDP="$(grep -E '^JNPF_DOCKER_REDIS_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDH="${RDH:-${RDH_HOST:-cx-redis}}"; RDP="${RDP:-${RDP_HOST:-6379}}" echo " Compose Redis 坐标:${RDH}:${RDP}" if [ "$RDH" = "cx-redis" ]; then ok "Redis 坐标 = 同栈 redis(${RDH}:${RDP}),跳过宿主实连探测" else # 这两个名称只在容器内指向 host-gateway;预检运行在宿主,等价探测回环地址。 REDIS_PROBE_HOST="$RDH" case "$RDH" in cx-infra|host.docker.internal) REDIS_PROBE_HOST=127.0.0.1 ;; esac if command -v nc >/dev/null 2>&1 && nc -z -w 5 "$REDIS_PROBE_HOST" "$RDP" >/dev/null 2>&1; then ok "Redis 坐标 ${RDH}:${RDP} 可达" elif ! command -v nc >/dev/null 2>&1 && \ (exec 3<>"/dev/tcp/${REDIS_PROBE_HOST}/${RDP}") >/dev/null 2>&1; then ok "Redis 坐标 ${RDH}:${RDP} 可达(/dev/tcp 探测)" else bad "Redis 坐标 ${RDH}:${RDP} 连不上" \ "宿主自装 Redis 请设 JNPF_DOCKER_REDIS_HOST=cx-infra,并确认 Redis 正监听对应端口;远端实例确认云安全组已放行来源 IP" fi fi fi # ── 外部库模式判定 ─────────────────────────────────────────────────────────── # 坐标指 CVM/远程实例时,本机 cx-postgres 不是数据面(测试环境已按 testenv.yml 用 # profiles 把它请出栈):3/6-6/6 的本地卷/容器/schema/种子检查全部跳过——否则 4/6 # 会把已停用的本地 PG 又拉起来。远程库连通性 2/6 已实连探测;schema/密码由 CVM 侧 # 管理 + 服务启动连库行为兜底。 EXTERNAL_DB=0 if [ "$ENV_OK" = 1 ] && [ -n "${CDH:-}" ] && [ "$CDH" != "cx-postgres" ]; then EXTERNAL_DB=1 fi # ── 3. PG 数据卷 ───────────────────────────────────────────────────────────── log "===== 3/6 数据卷 ${VOLUME_NAME} =====" VOL_OK=1 if [ "$EXTERNAL_DB" = 1 ]; then VOL_OK=0 skip "数据卷检查(本地 PG 非数据面)" "外部库模式 ${CDH}:${CDP}" elif docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then ok "external 卷 ${VOLUME_NAME} 存在" else VOL_OK=0 bad "external 卷 ${VOLUME_NAME} 不存在(compose 声明 external,缺卷直接起不来)" \ "docker volume create ${VOLUME_NAME},再灌数据:( cd ${MIG_DIR} && ./install.sh && ./create-app-roles.sh )(INSTALL.md §2 ⑤)" fi # ── 4. PG 容器可起且接受连接 ───────────────────────────────────────────────── log "===== 4/6 PG 容器 ${CONTAINER} =====" PG_OK=0 if [ "$EXTERNAL_DB" = 1 ]; then skip "PG 容器检查(不拉起本地 ${CONTAINER})" "外部库模式" elif docker exec "$CONTAINER" true >/dev/null 2>&1; then PG_OK=1 elif [ "$VOL_OK" = 1 ] && [ "$ENV_OK" = 1 ]; then log "容器未在运行,尝试拉起:docker compose up -d cx-postgres" if docker compose up -d cx-postgres >/dev/null 2>&1; then PG_OK=1 else bad "PG 容器拉起失败(docker compose up -d cx-postgres)" \ "镜像缺失则先 make images(仓库)/ make load(离线包);再看 docker compose logs cx-postgres" fi else skip "PG 容器拉起(.env 或数据卷未就绪)" fi if [ "$PG_OK" = 1 ]; then waited=0 until docker exec "$CONTAINER" pg_isready -q -U postgres >/dev/null 2>&1; do if [ "$waited" -ge "$PG_WAIT_SECS" ]; then PG_OK=0 bad "PG ${PG_WAIT_SECS}s 内未接受连接(大卷 crash recovery 可能更久)" \ "docker compose logs cx-postgres 看恢复进度/报错;确认内存充足(L024)后可调大 PG_WAIT_SECS 重跑" break fi [ "$waited" = 0 ] && log "等待 PG 接受连接(上限 ${PG_WAIT_SECS}s,冷启动/crash recovery 属正常)…" sleep 3; waited=$((waited+3)) done [ "$PG_OK" = 1 ] && ok "PG 接受连接(pg_isready)" fi # ── 5. 三库 schema:库存在 + 表数 ≥ 基准 ──────────────────────────────────── log "===== 5/6 三库 schema =====" DBS_OK=1 if [ "$EXTERNAL_DB" = 1 ]; then DBS_OK=0 skip "三库 schema 校验" "外部库模式" elif [ "$PG_OK" = 1 ]; then check_db() { local db="$1" base="$2" n if ! psql_exec -tAc "SELECT 1 FROM pg_database WHERE datname='$db'" 2>/dev/null | grep -q 1; then DBS_OK=0 bad "库 ${db} 不存在" "全新环境跑 ( cd ${MIG_DIR} && ./install.sh )(建四库+schema;INSTALL.md §2 ⑤)" return fi n="$(psql_exec -d "$db" -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'" 2>/dev/null || echo 0)" if [ "${n:-0}" -ge "$base" ]; then ok "库 ${db}:${n} 张表(基准 ≥${base})" else DBS_OK=0 bad "库 ${db} 表数 ${n} < 基准 ${base}(schema 不完整/灌库中断)" \ "重跑 ( cd ${MIG_DIR} && ./install.sh )——目标库非空会被守卫拦下,按提示 FORCE_RESEED=1(会清该库数据,先确认归属)" fi } check_db "$DB_INIT" "$BASE_INIT" check_db "$DB_FLOW" "$BASE_FLOW" check_db "$DB_XXLJOB" "$BASE_XXLJOB" else DBS_OK=0 skip "三库 schema 校验" fi # ── 6. 种子数据 + 共享配置文件 + 业务角色 ─────────────────────────────────── log "===== 6/6 种子数据、共享配置文件与业务角色 =====" # 共享配置文件就位(补丁 #8:替代原配置中心种子检查)——与库无关,外部库模式也要查 for f in datasource resources system-config frame-config logger mq discovery; do if [ -f "config/shared/$f.yaml" ]; then ok "config/shared/${f}.yaml 就位" else bad "缺 config/shared/${f}.yaml(业务服务取不到该项配置,起来也全挂)" \ "确认仓库内 config/shared/ 目录完整(补丁 #8 后配置随仓库分发,不再经配置中心下发)" fi done if [ "$PG_OK" = 1 ] && [ "$DBS_OK" = 1 ]; then n_admin="$(psql_exec -d "$DB_INIT" -tAc "SELECT count(*) FROM base_user WHERE f_account='admin'" 2>/dev/null || echo 0)" if [ "${n_admin:-0}" -ge 1 ]; then ok "种子用户 admin 在(${DB_INIT}.base_user)" else bad "${DB_INIT}.base_user 无 admin(种子数据缺失,登录/冒烟必失败)" \ "全新环境:install.sh 的官方 init 含种子;割接环境:按 docs/archive/server-pg-cutover-checklist.md 跑 migrate-data.sh" fi # 业务角色(角色名以 .env 为准,缺省=交付标准值;密码是否匹配 preflight 不验——见 L030, # socket/127.0.0.1 是 trust 假验证,真实验证由服务启动后的连库行为兜底) APP_ROLE="$(get_env JNPF_APP_DB_USER)"; APP_ROLE="${APP_ROLE:-jnpf_app}" FLOW_ROLE="$(get_env JNPF_FLOW_DB_USER)"; FLOW_ROLE="${FLOW_ROLE:-jnpf_flow_app}" for role in "$APP_ROLE" "$FLOW_ROLE"; do if psql_exec -tAc "SELECT 1 FROM pg_roles WHERE rolname='$role'" 2>/dev/null | grep -q 1; then ok "业务角色 ${role} 在" else bad "业务角色 ${role} 不存在(对应服务连库必报 password authentication failed)" \ "先定稿 ${ENV_FILE} 再跑 ( cd ${MIG_DIR} && ./create-app-roles.sh )(幂等,按 .env 重置密码;§0.11 铁律顺序)" fi done else [ "$EXTERNAL_DB" = 1 ] && skip "种子数据与业务角色校验(由 CVM 侧管理)" "外部库模式" \ || skip "种子数据与业务角色校验" fi # ── 汇总 ───────────────────────────────────────────────────────────────────── echo if [ "${#WARNS[@]}" -gt 0 ]; then warnp "警告 ${#WARNS[@]} 项(不拦截):" for w in "${WARNS[@]}"; do printf ' · %s\n' "$w"; done fi if [ "${#FAILS[@]}" -eq 0 ]; then log "preflight 通过 ✓(可继续 docker compose up)" else printf '\033[1;31m[preflight][err]\033[0m 未通过 %s 项:\n' "${#FAILS[@]}" >&2 i=1 for f in "${FAILS[@]}"; do printf ' %2d. %s\n' "$i" "$f" >&2; i=$((i+1)); done printf '\033[1;31m[preflight][err]\033[0m 修复后重跑 make preflight;确要跳过:SKIP_PREFLIGHT=1 make up\n' >&2 exit 1 fi