pbls/build.sh
2026-09-25 12:35:32 +08:00

420 lines
25 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# =====================================================================
# PBL Agent OS 验证原型(Phase 0/1)—— 一键部署脚本(test 环境)
# 端口唯一事实源:projects/pbls/env/test.json(9380);本脚本不硬编码端口,从 env 读取
# 门禁:建表 37 张 + 9 项种子全绿 + self_check PASS 74/74 + py_compile 全通过
# =====================================================================
set -euo pipefail
APP_NAME="pbls"
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WS_DIR="$(cd "${APP_DIR}/../.." && pwd)"
ENV_FILE="${WS_DIR}/projects/pbls/env/test.json"
DDL_FILE="${APP_DIR}/scripts/ddl/pbls_tables.sql"
VENV_DIR="${APP_DIR}/venv"
PY="${VENV_DIR}/bin/python"
log() { echo "[build.sh][$(date '+%F %T')] $*"; }
die() { echo "[build.sh][FATAL] $*" >&2; exit 1; }
# ---- 0. 前置检查:env 唯一事实源必须存在且无占位 ----
[ -f "${ENV_FILE}" ] || die "缺少环境事实源 ${ENV_FILE}"
if grep -Eq '待明确|TBD|占位|CHANGE_ME|xxx' "${ENV_FILE}"; then
die "${ENV_FILE} 含占位/待明确字段,禁止部署"
fi
read_env() { python3 -c "import json,sys;print(json.load(open('${ENV_FILE}'))$1)"; }
PORT="$(read_env "['port']")"
DB_HOST="$(read_env "['db']['host']")"
DB_PORT="$(read_env "['db']['port']")"
DB_USER="$(read_env "['db']['user']")"
DB_PASS="$(read_env "['db']['password']")"
DB_NAME="$(read_env "['db']['dbname']")"
DEPLOY_PATH="$(read_env "['deploy']['path']")"
# 外网口自检用(复测门禁 ① 同时 curl 127.0.0.1 与 192.168.16.3 两个地址);
# env 无 ssh.host 时回退 127.0.0.1,不让 read_env 抛 KeyError 中断部署。
SSH_HOST="$(python3 -c "import json;print(json.load(open('${ENV_FILE}')).get('ssh',{}).get('host') or '127.0.0.1')")"
log "env 事实源: port=${PORT} db=${DB_NAME}@${DB_HOST}:${DB_PORT} deploy=${DEPLOY_PATH}"
# ---- 1. 端口一致性核验(conf/config.json 必须与 env 同值)----
CONF_PORT="$(python3 -c "import json;print(json.load(open('${APP_DIR}/conf/config.json'))['port'])")"
[ "${CONF_PORT}" = "${PORT}" ] || die "端口不一致: conf=${CONF_PORT} env=${PORT}"
log "端口一致性核验通过: ${PORT}"
# ---- 2. 应用仓库不得另存 env/(端口唯一事实源在项目侧)----
[ ! -d "${APP_DIR}/env" ] || die "应用仓库存在 env/ 目录,违反端口唯一事实源规则"
log "应用仓库无 env/ 目录,核验通过"
# ---- 3. 入口硬门禁核验(6.1 / 6.2)----
ENTRY="${APP_DIR}/app/${APP_NAME}.py"
[ -f "${ENTRY}" ] || die "缺少入口 ${ENTRY}"
grep -q 'from ahserver.webapp import webapp' "${ENTRY}" || die "入口未使用 ahserver.webapp.webapp"
grep -q 'webapp(init)' "${ENTRY}" || die "入口未调用 webapp(init)"
if grep -Eq 'Flask|FastAPI|Django|http\.server|jsonify|@app\.route' "${ENTRY}"; then
die "入口含禁用框架关键字(Flask/FastAPI/Django/http.server/jsonify/@app.route)"
fi
log "硬门禁 6.1 通过: ahserver.webapp + webapp(init),无禁用框架"
# ServerEnv() 行号必须早于 load_rbac() 行号
ENV_LINE="$(grep -n 'ServerEnv()' "${ENTRY}" | head -1 | cut -d: -f1)"
RBAC_LINE="$(grep -n 'load_rbac()' "${ENTRY}" | head -1 | cut -d: -f1)"
[ -n "${ENV_LINE}" ] && [ -n "${RBAC_LINE}" ] || die "入口缺 ServerEnv() 或 load_rbac() 显式调用"
[ "${ENV_LINE}" -lt "${RBAC_LINE}" ] || die "ServerEnv() 必须早于 load_rbac()(env 行 ${ENV_LINE} >= rbac 行 ${RBAC_LINE})"
grep -q 'load_appbase()' "${ENTRY}" || die "入口缺 load_appbase() 显式调用"
grep -q 'load_pybricks()' "${ENTRY}" || die "入口缺 load_pybricks() 显式调用"
log "硬门禁 6.2 通过: ServerEnv()(L${ENV_LINE}) < load_rbac()(L${RBAC_LINE}),load_appbase/load_rbac/load_pybricks 均显式调用"
# ---- 4. DDL 方言核验(6.4:mariadb,BIGINT AUTO_INCREMENT,零 SERIAL/FK/ENUM/TIMESTAMP)----
# 【Q4 修复 / deploy_test 第 3 轮实测】旧版直接对 DDL **原文** grep,存在两类假阳性:
# (1) DDL 文末「契约自检」注释块为说明"零命中项",逐字写出了
# FOREIGN KEY / REFERENCES / ENUM( / SERIAL / BIGSERIAL / nextval / TIMESTAMP
# (实测原文命中 3 行:L6 头部说明、L220 COMMENT 串内的 references、L891 契约自检块),
# 旧 `grep -Eiq` 一律触发 die —— 注释/COMMENT 串被当成真实 DDL,门禁必然误杀;
# (2) `CREATE TABLE` / `AUTO_INCREMENT` 计数同理会把注释行与 COMMENT '...' 串内字样算进去,
# 表数不可信(且旧断言 36 与 DDL 实际 37 张不符,新增 pbl_compile_task 后未同步)。
# 修复:改为「先剥 SQL 注释 + 字符串字面量(含 COMMENT 串),再在纯 DDL 正文上扫描」,
# 由 scripts/ddl_gate_check.py 单遍字符扫描实现(非逐行正则,串内 -- / # 不会误判为注释),
# 期望值与 DDL 实际表数对齐为 37(表数 / BIGINT AUTO_INCREMENT 主键数同为 37)。
[ -f "${DDL_FILE}" ] || die "缺少 DDL ${DDL_FILE}"
DDL_GATE="${APP_DIR}/scripts/ddl_gate_check.py"
[ -f "${DDL_GATE}" ] || die "缺少 DDL 门禁扫描器 ${DDL_GATE}(6.4 依赖,禁止回退到原文 grep)"
# 4a. 扫描器自身可信度:先跑内置最小单测(剥注释/剥串/幂等/真实违规不漏报)
SELFTEST_RC=0
python3 "${DDL_GATE}" --selftest || SELFTEST_RC=$?
[ "${SELFTEST_RC}" = "0" ] || die "DDL 门禁扫描器自检未通过(--selftest rc=${SELFTEST_RC}),扫描结果不可信,终止部署"
# 4b. 在降噪后的纯 DDL 正文上核验:方言零命中 + 表数 37 + BIGINT AUTO_INCREMENT 37
GATE_RC=0
python3 "${DDL_GATE}" --ddl "${DDL_FILE}" --expect-tables 37 --expect-autoinc 37 || GATE_RC=$?
[ "${GATE_RC}" = "0" ] || die "硬门禁 6.4 未通过(ddl_gate_check.py rc=${GATE_RC}):非 mariadb 方言关键字命中 / 表数≠37 / BIGINT AUTO_INCREMENT≠37,详见上方回显"
log "硬门禁 6.4 通过: mariadb 方言(剥注释+COMMENT 串后零命中),37 表 / 37 处 BIGINT AUTO_INCREMENT"
# ---- 5. 依赖声明核验(6.5)----
for dep in ahserver sqlor apppublic appbase rbac PyMySQL; do
grep -qi "^${dep}" "${APP_DIR}/requirements.txt" || die "requirements.txt 缺依赖 ${dep}"
done
log "硬门禁 6.5 通过: ahserver/sqlor/apppublic/appbase/rbac/PyMySQL 均已声明"
# ---- 6. website.processors / indexes 核验(缺一会导致 / 返回 500)----
python3 - <<'PYEOF'
import json, sys
conf = json.load(open('conf/config.json'))
procs = dict((p[0], p[1]) for p in conf['website']['processors'])
for ext, name in (('.tmpl', 'tmpl'), ('.ui', 'bui'), ('.dspy', 'dspy')):
assert procs.get(ext) == name, 'processors 缺 %s->%s,实际 %r' % (ext, name, procs.get(ext))
assert 'index.ui' in conf['website']['indexes'], 'indexes 缺 index.ui'
print('[build.sh] website 核验通过: processors=%s indexes=%s' % (procs, conf['website']['indexes']))
PYEOF
# ---- 7. venv + 依赖安装 ----
if [ ! -x "${PY}" ]; then
log "创建 venv: ${VENV_DIR}"
python3 -m venv "${VENV_DIR}"
fi
"${PY}" -m pip install --upgrade pip wheel setuptools
# [deploy_test R5 修复 #1] 平台基础包(ahserver/sqlor/apppublic/appbase/rbac/bricks_for_python/accounting)
# 原走内网私有源 http://10.20.30.41:8081 —— 目标机 192.168.16.3 实测 connect timeout(5 次重试全失败),
# build.sh 在此处 BUILD_RC=1 终止(R4 部署失败根因,见 evidence/r4_build.log)。
# 改为:机构 wheelhouse 离线供给(--find-links,绝对路径,不依赖 cwd)+ 公网 pypi.org 取第三方包
# (目标机实测 https://pypi.org/simple/pymysql/ HTTP=200)。
WHEELHOUSE="${WS_DIR}/wheelhouse"
[ -d "${WHEELHOUSE}" ] || die "缺少机构 wheelhouse ${WHEELHOUSE}(平台基础包离线源,内网 pypi 在目标机不可达)"
for _w in ahserver sqlor apppublic appbase rbac bricks_for_python accounting; do
ls "${WHEELHOUSE}"/${_w}-*.whl >/dev/null 2>&1 || die "wheelhouse 缺 ${_w} 的 whl(${WHEELHOUSE})"
done
log "wheelhouse 就位: $(ls "${WHEELHOUSE}"/*.whl | wc -l) 个 whl @ ${WHEELHOUSE}"
"${PY}" -m pip install --find-links "${WHEELHOUSE}" -r "${APP_DIR}/requirements.txt"
# ---- 7b. 测试前置依赖(DISC.2)----
# 运行时依赖(requirements.txt)刻意不含 pytest;测试依赖单列 requirements-test.txt,
# 由本步装进同一个 venv,使
# source venv/bin/activate && python3 -m pytest tests/test_m5a_idempotency.py -v
# 在**不手工注入 PYTHONPATH** 的前提下可直接执行(此前靠 PYTHONPATH=<ws>/.pylibs
# 临时注入跑通,换机器/换角色即失效,test 角色会误判为环境不可用)。
# 只要运行时依赖的场景(如生产镜像)用 PBL_SKIP_TEST_DEPS=1 显式跳过。
REQ_TEST="${APP_DIR}/requirements-test.txt"
if [ "${PBL_SKIP_TEST_DEPS:-0}" = "1" ]; then
log "PBL_SKIP_TEST_DEPS=1:跳过测试前置依赖(venv 内将没有 pytest,测试角色需自行引导)"
elif [ ! -f "${REQ_TEST}" ]; then
die "缺测试依赖清单 ${REQ_TEST}(DISC.2 要求显式声明;确要跳过请设 PBL_SKIP_TEST_DEPS=1)"
else
log "安装测试前置依赖: ${REQ_TEST}"
"${PY}" -m pip install -r "${REQ_TEST}"
# 自证:装完必须真能 import + 跑 --version,否则本步等于没做(不让注释与事实矛盾)
"${PY}" -c 'import pytest' >/dev/null 2>&1 \
|| die "安装后 venv 内仍无法 import pytest(${PY})"
log "7b 自证通过: $("${PY}" -m pytest --version 2>&1 | head -1) @ ${PY}"
fi
# ---- 8. 本地安装 15 个 pbl_* 模块 + 复用模块(可编辑模式)----
# [deploy_test R5 修复 #2] 原循环只匹配 modules/pbl_*,但入口 app/pbls.py 还显式 import
# world / scene / entity / script_engine / scense_runtime / scense / scense_game
# 这 7 个复用域模块(pbls_spec.json referenced_modules)。漏装 → 入口 ImportError →
# 进程起不来(端口无 LISTEN、healthz=000)。此处按 spec.json referenced_modules 补装,
# 并做「入口 import 全部可解析」自证门禁,装不上就 die,不留到启动阶段才炸。
REUSE_MODULES="$(python3 -c "import json;print(' '.join(json.load(open('${WS_DIR}/projects/pbls/pbls_spec.json'))['referenced_modules']))")"
INSTALL_DIRS=""
for mod_dir in "${WS_DIR}"/modules/pbl_*; do
[ -d "${mod_dir}" ] || continue
INSTALL_DIRS="${INSTALL_DIRS} ${mod_dir}"
done
for rm in ${REUSE_MODULES}; do
# 平台基础包(apppublic/sqlor/ahserver/appbase/rbac/accounting)已由 wheelhouse 装好,跳过
case " ${rm} " in *" apppublic "*|*" sqlor "*|*" ahserver "*|*" appbase "*|*" rbac "*|*" accounting "*) continue;; esac
[ -d "${WS_DIR}/modules/${rm}" ] || die "spec.json referenced_modules 声明 ${rm},但 ${WS_DIR}/modules/${rm} 不存在"
INSTALL_DIRS="${INSTALL_DIRS} ${WS_DIR}/modules/${rm}"
done
for mod_dir in ${INSTALL_DIRS}; do
if [ -f "${mod_dir}/pyproject.toml" ] || [ -f "${mod_dir}/setup.py" ]; then
log "安装模块 $(basename "${mod_dir}")"
"${PY}" -m pip install --find-links "${WHEELHOUSE}" -e "${mod_dir}"
else
export PYTHONPATH="${mod_dir}:${PYTHONPATH:-}"
log "模块 $(basename "${mod_dir}") 无打包描述,加入 PYTHONPATH"
fi
done
# ---- 8a. 入口 import 可解析性自证(部署前拦截 ImportError,不留到启动阶段)----
log "入口 import 自证:$(basename "${ENTRY}") 全部 import 必须可解析"
IMPORT_RC=0
"${PY}" - <<'PYIMP' || IMPORT_RC=$?
import ast, importlib.util, sys
src = open('app/pbls.py', encoding='utf-8').read()
mods = set()
for node in ast.walk(ast.parse(src)):
if isinstance(node, ast.ImportFrom) and node.module:
mods.add(node.module.split('.')[0])
elif isinstance(node, ast.Import):
for a in node.names:
mods.add(a.name.split('.')[0])
mods -= set(sys.stdlib_module_names)
mods -= {'json', 'os', 'sys', 'ast'}
missing = sorted(m for m in mods if importlib.util.find_spec(m) is None)
print('[import-check] 需解析顶层模块 %d 个: %s' % (len(mods), ' '.join(sorted(mods))))
if missing:
print('[import-check][FAIL] 无法解析: %s' % ' '.join(missing))
sys.exit(1)
print('[import-check] PASS 全部可解析')
PYIMP
[ "${IMPORT_RC}" = "0" ] || die "入口 import 自证失败(rc=${IMPORT_RC}):上列模块在 venv 内不可解析,应用必然启动失败,终止部署"
# ---- 8b. M9 覆盖层页面壳生成(构建产物,不入库)+ 一致性门禁 ----
# 页面壳由 pbl_scense_ext 的生成器产出;产物目录 wwwroot/pbl_overlay/ 在模块仓
# .gitignore 中被排除,因此必须在构建期生成,否则新克隆/新部署环境该目录为空、
# /pbl_overlay/<key>.ui 导航全量 404。位置:模块安装之后、模块 wwwroot 软链(紧随其后的
# 「8c. 模块 wwwroot 挂载」段)之前——生成物落在模块仓,必须再经 8c 挂载到应用 wwwroot
# 才能在应用侧 URL /pbl_scense_ext/pbl_overlay/<key>.ui 上取到。
OVERLAY_GEN="${WS_DIR}/modules/pbl_scense_ext/scripts/gen_overlay_pages.py"
[ -f "${OVERLAY_GEN}" ] || die "缺少覆盖层页面壳生成器: ${OVERLAY_GEN}(modules/pbl_scense_ext 未就位)"
log "生成 PBL 覆盖层页面壳: ${OVERLAY_GEN}"
# 注意:不能用 `if ! cmd; then die "... rc=$?"` —— 进入分支时 $? 已被 if 取反为 0,
# 打印出的 rc 恒为 0 会误导排障;改为显式捕获真实返回码。
GEN_RC=0
"${PY}" "${OVERLAY_GEN}" || GEN_RC=$?
if [ "${GEN_RC}" -ne 0 ]; then
die "覆盖层页面壳生成失败(gen_overlay_pages.py rc=${GEN_RC}):wwwroot/pbl_overlay/ 将为空,/pbl_overlay/<key>.ui 全量 404,终止部署"
fi
log "覆盖层页面壳门禁校验(gen_overlay_pages.py --check)"
CHK_RC=0
"${PY}" "${OVERLAY_GEN}" --check || CHK_RC=$?
if [ "${CHK_RC}" -ne 0 ]; then
die "覆盖层页面壳门禁校验未通过(--check rc=${CHK_RC}):i18n zh/en key 不一致,或页面壳与 PAGES 注册表不一致,终止部署"
fi
log "覆盖层页面壳生成 + 门禁校验通过(12 个页面壳 / i18n key 一致)"
# ---- 8c. 模块 wwwroot 挂载(软链)+ 应用侧可达性门禁(QC 退回意见 #1/#2)----
# 上一版只做了 8b「生成」,生成物落在模块仓 modules/<m>/wwwroot/ 下,但本脚本从未把
# 模块 wwwroot 挂进应用 wwwroot —— 应用 website.root=wwwroot 只指向应用自身目录,
# 因此 build.sh 跑完后应用侧依然取不到 /pbl_scense_ext/pbl_overlay/<key>.ui(可达链路
# 断在挂载环节,全量 404 并未消除)。此处按 web-application-spec「挂载模块 9 步流程」
# 第 5 步(wwwroot 软链,非 cp,保持与模块仓单一副本同步)补齐,并立即以应用侧路径
# 实测 12 个页面壳数量作为门禁,rc≠0 终止部署。
LINKED_MODULES=""
for mod_dir in "${WS_DIR}"/modules/pbl_*; do
[ -d "${mod_dir}/wwwroot" ] || continue
mod_name="$(basename "${mod_dir}")"
www_link="${APP_DIR}/wwwroot/${mod_name}"
# 已存在但不是软链(应用仓自带同名真实目录)时不覆盖,直接终止让人工处置,
# 避免 ln -sfn 把软链建进既有目录里、或静默删掉应用仓跟踪文件。
if [ -e "${www_link}" ] && [ ! -L "${www_link}" ]; then
die "wwwroot 挂载冲突:${www_link} 已存在且不是软链(应用仓自带目录?),请人工处置后重试"
fi
ln -sfn "${mod_dir}/wwwroot" "${www_link}"
LINKED_MODULES="${LINKED_MODULES} ${mod_name}"
done
[ -n "${LINKED_MODULES}" ] || die "未发现任何含 wwwroot/ 的 pbl_* 模块,模块 wwwroot 挂载为空"
log "模块 wwwroot 软链挂载完成:${LINKED_MODULES}"
# 挂载后应用侧可达性门禁:覆盖层页面壳必须能通过应用 wwwroot 路径取到
APP_OVERLAY_DIR="${APP_DIR}/wwwroot/pbl_scense_ext/pbl_overlay"
APP_UI_COUNT=0
if [ -d "${APP_OVERLAY_DIR}" ]; then
APP_UI_COUNT="$(find -L "${APP_OVERLAY_DIR}" -maxdepth 1 -name '*.ui' | wc -l | tr -d '[:space:]')"
fi
if [ "${APP_UI_COUNT}" != "12" ]; then
die "应用侧覆盖层页面壳应为 12 个,实际 ${APP_UI_COUNT}(${APP_OVERLAY_DIR}):模块 wwwroot 未挂载成功或生成器未产出,/pbl_scense_ext/pbl_overlay/<key>.ui 将全量 404,终止部署"
fi
log "应用侧可达性核验通过: ${APP_OVERLAY_DIR}/*.ui = ${APP_UI_COUNT}"
# ---- 9. 语法验证:全部 .py 跑 py_compile(QC 退回意见 #16)----
log "py_compile 全量语法验证"
FAIL_SYNTAX=0
while IFS= read -r pyfile; do
if ! "${PY}" -m py_compile "${pyfile}"; then
echo "[build.sh][SYNTAX FAIL] ${pyfile}"
FAIL_SYNTAX=1
fi
done < <(find "${APP_DIR}/app" "${WS_DIR}/modules/pbl_common" "${WS_DIR}/modules/pbl_appcodes" -name '*.py' -not -path '*/venv/*' 2>/dev/null)
[ "${FAIL_SYNTAX}" = "0" ] || die "存在语法错误文件,终止部署"
log "py_compile 全部通过"
# ---- 10. 建表(mariadb,显式 TCP,禁 socket)----
log "执行 DDL: ${DDL_FILE}"
mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
--default-character-set=utf8mb4 "${DB_NAME}" < "${DDL_FILE}"
ACTUAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name LIKE 'pbl\\_%'" "${DB_NAME}")"
# Q4 修复:与 DDL 实际表数对齐(36 → 37,新增 pbl_compile_task)
[ "${ACTUAL_TABLES}" = "37" ] || die "建表数应为 37,实际 ${ACTUAL_TABLES}"
log "建表完成: pbl_* 共 ${ACTUAL_TABLES} 张"
# ---- 10b. 运行目录前置创建(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)----
# 原脚本到步骤 13 才 mkdir logs/run,而步骤 11 已经 `tee ${APP_DIR}/logs/self_check.log`。
# 全新克隆(仓库不含 logs/,.gitignore 也排除 *.log)时 tee 打不开文件 →
# set -euo pipefail 下整条管道非零 → die,部署在「建表已成功、自检未跑」处中断,
# 表现为门禁 ③ 表数 0/37(DDL 事务已提交但脚本没走到断言)与门禁 ②③ 全红。
mkdir -p "${APP_DIR}/logs" "${APP_DIR}/run"
log "运行目录就位: ${APP_DIR}/logs ${APP_DIR}/run"
# ---- 11. 枚举数量契约自检(QC 退回意见 #15:必须真实可运行输出)----
"${PY}" -m pbl_appcodes.self_check | tee "${APP_DIR}/logs/self_check.log"
grep -q 'SELF_CHECK pbl_appcodes: PASS 74/74' "${APP_DIR}/logs/self_check.log" \
|| die "pbl_appcodes 数量契约自检未通过"
# ---- 12. 9 项种子数据幂等注入(重复执行零差异)----
log "种子注入(第 1 轮)"
"${PY}" - <<'PYEOF'
import json
from pbl_appcodes.seed import seed_all
s1 = seed_all()
print('[seed round1]', json.dumps(s1, ensure_ascii=False, sort_keys=True))
s2 = seed_all()
print('[seed round2]', json.dumps(s2, ensure_ascii=False, sort_keys=True))
assert s2['groups_inserted'] == 0 and s2['groups_updated'] == 0, '重复执行组有差异'
assert s2['items_inserted'] == 0 and s2['items_updated'] == 0, '重复执行项有差异'
assert s2['idempotent'] is True, '幂等校验失败'
print('[seed] 幂等零差异校验通过: 8 组 74 项')
PYEOF
# ---- 12.5 RBAC 路径注册(Bug WIu0Rkn0KwB0XTjYZz0qk 修复:原脚本从不注册)----
# 中央聚合器 apps/pbls/scripts/load_path.py 把各模块 + 宿主自有端点(含本次新增的
# /healthz、/healthz.dspy、/api/public/health)逐条写入 permission/rolepermission。
# 不注册的后果:进程起来了、端口监听了,但 curl 一律 401/403 → 冒烟门禁 ① HTTP!=200。
# 先跑 --check 做通配符 + 聚合完整性门禁(rc!=0 即终止,禁止带缺口部署)。
LOAD_PATH="${APP_DIR}/scripts/load_path.py"
[ -f "${LOAD_PATH}" ] || die "缺少 RBAC 注册入口 ${LOAD_PATH}"
log "RBAC 一致性门禁: load_path.py --check"
CHK_RBAC=0
WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" --check || CHK_RBAC=$?
[ "${CHK_RBAC}" = "0" ] || die "RBAC 聚合门禁未通过(rc=${CHK_RBAC}):存在通配符或整模块聚合失败,终止部署"
log "RBAC 注册: load_path.py(逐条显式,无通配符)"
REG_RC=0
WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" || REG_RC=$?
if [ "${REG_RC}" != "0" ]; then
# set_role_perm.py 不在本机时 register() 返回 PENDING 清单且 rc=1;
# 这不是代码缺陷而是部署机缺工具,明确回显后终止(不静默放过 → 否则又是 403)。
die "RBAC 注册未成功(rc=${REG_RC}):set_role_perm.py 不可用或写库失败,端点将 401/403,终止部署"
fi
log "RBAC 注册完成"
# ---- 12.8 落地部署目录 DEPLOY_PATH(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)----
# 原脚本把 DEPLOY_PATH 读出来只用于最后一行日志 echo,从未创建/同步任何内容,
# 于是复测门禁 ④「ls -la /d/pbl/pbls」恒为 No such file or directory,
# 且 env/test.json 的 start_cmd(${DEPLOY_PATH}/venv/bin/python ${DEPLOY_PATH}/app/pbls.py)
# 指向的解释器与入口都不存在 → 门禁 ②③(进程数 0 / 端口未监听)不可能转绿。
# 处置:把应用树同步到 DEPLOY_PATH,venv 以软链复用(venv 内 shebang/符号链接是
# 绝对路径,复制换目录必坏,软链才安全),随后从 DEPLOY_PATH 启动。
if [ "${DEPLOY_PATH}" = "${APP_DIR}" ]; then
log "DEPLOY_PATH 与仓库目录同址(${DEPLOY_PATH}),跳过同步"
RUN_DIR="${APP_DIR}"
RUN_PY="${PY}"
RUN_ENTRY="${ENTRY}"
else
log "同步应用树到部署目录: ${DEPLOY_PATH}"
mkdir -p "${DEPLOY_PATH}"
# -a 保留权限/时间并**保留软链本身**(8c 建的模块 wwwroot 软链是绝对路径,换机仍可解析);
# 排除 venv/logs/run/.git:venv 不可搬移,logs/run 由目标侧自建,.git 不属于运行态。
rsync -a --delete \
--exclude 'venv/' --exclude '.venv/' --exclude 'logs/' --exclude 'run/' \
--exclude '.git/' --exclude '__pycache__/' --exclude '*.pyc' \
"${APP_DIR}/" "${DEPLOY_PATH}/"
mkdir -p "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run"
# venv 软链复用(目标侧 start_cmd 要求 ${DEPLOY_PATH}/venv/bin/python 可用)
if [ ! -e "${DEPLOY_PATH}/venv" ]; then
ln -sfn "${VENV_DIR}" "${DEPLOY_PATH}/venv"
fi
[ -x "${DEPLOY_PATH}/venv/bin/python" ] || die "部署目录 venv 不可用: ${DEPLOY_PATH}/venv/bin/python"
[ -f "${DEPLOY_PATH}/app/${APP_NAME}.py" ] || die "部署目录缺入口: ${DEPLOY_PATH}/app/${APP_NAME}.py"
[ -f "${DEPLOY_PATH}/conf/config.json" ] || die "部署目录缺配置: ${DEPLOY_PATH}/conf/config.json"
log "部署目录就位: ${DEPLOY_PATH}(app/conf/wwwroot/scripts + venv 软链 + logs/run)"
RUN_DIR="${DEPLOY_PATH}"
RUN_PY="${DEPLOY_PATH}/venv/bin/python"
RUN_ENTRY="${DEPLOY_PATH}/app/${APP_NAME}.py"
fi
# ---- 13. 启动应用(唯一入口 + 唯一端口,从部署目录启动)----
# 先停旧进程(幂等重跑不撞端口):按 pid 文件 + 入口路径双口径清理。
if [ -f "${RUN_DIR}/run/${APP_NAME}.pid" ]; then
OLD_PID="$(cat "${RUN_DIR}/run/${APP_NAME}.pid" 2>/dev/null || true)"
if [ -n "${OLD_PID:-}" ] && kill -0 "${OLD_PID}" 2>/dev/null; then
log "停止旧进程 pid=${OLD_PID}"
kill "${OLD_PID}" 2>/dev/null || true
sleep 2
fi
fi
pkill -f "${RUN_ENTRY}" 2>/dev/null || true
sleep 1
log "启动 ${APP_NAME} 于端口 ${PORT}(cwd=${RUN_DIR})"
# cwd 必须是 RUN_DIR:conf/config.json 的 website.root="wwwroot" 是相对路径,
# 从别处启动会解析不到 wwwroot → 所有 .ui/.dspy 404(含 /healthz)。
( cd "${RUN_DIR}" && nohup "${RUN_PY}" "${RUN_ENTRY}" \
> "${RUN_DIR}/logs/${APP_NAME}.out" 2>&1 & echo $! > "${RUN_DIR}/run/${APP_NAME}.pid" )
sleep 6
# 进程 + 端口双自证(对应复测门禁 ②):起不来就把日志尾部打出来再 die,
# 避免「HTTP=000 但没人知道为什么」的四轮同构复测。
PROC_N="$(ps -ef | grep -F "${APP_NAME}.py" | grep -v grep | wc -l | tr -d '[:space:]')"
log "进程数=${PROC_N}"
if [ "${PROC_N}" = "0" ]; then
echo "----- ${RUN_DIR}/logs/${APP_NAME}.out (tail 60) -----"
tail -60 "${RUN_DIR}/logs/${APP_NAME}.out" 2>/dev/null || true
echo "----------------------------------------------------"
die "应用进程未存活(ps 计数 0),门禁 ② 不成立,详见上方日志尾部"
fi
# ---- 14. 健康检查(两条口径都验:/healthz = 复测门禁 ①,/api/public/health = 原自检口径)----
check_http() {
curl -s -m 8 -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || echo 000
}
HZ_CODE="$(check_http "http://127.0.0.1:${PORT}/healthz")"
AH_CODE="$(check_http "http://127.0.0.1:${PORT}/api/public/health")"
IP_CODE="$(check_http "http://${SSH_HOST:-127.0.0.1}:${PORT}/healthz")"
log "健康检查: /healthz=${HZ_CODE} /api/public/health=${AH_CODE} 外网口 /healthz=${IP_CODE}"
if [ "${HZ_CODE}" != "200" ] || [ "${AH_CODE}" != "200" ]; then
echo "----- ${RUN_DIR}/logs/${APP_NAME}.out (tail 60) -----"
tail -60 "${RUN_DIR}/logs/${APP_NAME}.out" 2>/dev/null || true
echo "----------------------------------------------------"
die "健康检查失败(/healthz=${HZ_CODE}, /api/public/health=${AH_CODE}),门禁 ① 不成立"
fi
# 表数终态复述(对应复测门禁 ③):部署完成后 pbl 库必须有 37 张 pbl_* 表。
FINAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name LIKE 'pbl\\_%'" "${DB_NAME}")"
log "终态表数: ${DB_NAME}.pbl_* = ${FINAL_TABLES}/37"
[ "${FINAL_TABLES}" = "37" ] || die "终态表数 ${FINAL_TABLES} != 37,门禁 ③ 不成立"
log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH},/healthz=200,表数 ${FINAL_TABLES}/37)"