834 lines
57 KiB
Bash
Executable File
834 lines
57 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# =====================================================================
|
||
# PBL Agent OS 验证原型(Phase 0/1)—— 一键部署脚本(test 环境)
|
||
# 端口唯一事实源:projects/pbls/env/test.json(9380);本脚本不硬编码端口,从 env 读取
|
||
# 门禁:手写 DDL 32 张 + 模块 models 链 6 张(10a 步)+ 9 项种子全绿
|
||
# + self_check PASS 74/74 + py_compile 全通过
|
||
# =====================================================================
|
||
set -euo pipefail
|
||
|
||
APP_NAME="pbls"
|
||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
# ---- 0a. 机构工作空间根(WS_DIR)解析:可覆盖 + 逐级探测 + 事实源验证 ----
|
||
# [Bug BWY68rZR_Uh2A41JlGT7D / elS7VxwO7ufbPiPl-ucGA 修复]
|
||
# 原实现 WS_DIR="${APP_DIR}/../.." 只在机构工作空间 apps/pbls 下成立:
|
||
# APP_DIR=/d/pipeline/workspaces/0/sdlc_general/apps/pbls → WS_DIR=工作空间根 ✅
|
||
# 部署到 env.deploy.path=/d/pbl/pbls 后:
|
||
# APP_DIR=/d/pbl/pbls → WS_DIR=/d ❌(ENV_FILE=/d/projects/... 不存在 → 第 0 步即 die)
|
||
# 后果链:build.sh 从未跑通 → venv 未建 → DDL 未落库(pbl 库表数 0/37)→ 进程未起
|
||
# → 9380 无 LISTEN → healthz=000 → 42 条功能用例持续 blocked。
|
||
# 现改为三段式:① 环境变量显式覆盖(PBL_WS_DIR / WS_DIR)
|
||
# ② 从 APP_DIR 逐级向上探测含 projects/pbls/env/test.json 的目录
|
||
# ③ 兜底机构工作空间绝对路径(部署机与工作区同机,实测可读)
|
||
_ws_probe() {
|
||
local _c _d
|
||
for _c in "${PBL_WS_DIR:-}" "${WS_DIR_OVERRIDE:-}" "${WS_DIR:-}"; do
|
||
[ -n "${_c}" ] || continue
|
||
if [ -f "${_c}/projects/pbls/env/test.json" ]; then (cd "${_c}" && pwd); return 0; fi
|
||
done
|
||
_d="${APP_DIR}"
|
||
while [ "${_d}" != "/" ] && [ -n "${_d}" ]; do
|
||
if [ -f "${_d}/projects/pbls/env/test.json" ]; then (cd "${_d}" && pwd); return 0; fi
|
||
_d="$(dirname "${_d}")"
|
||
done
|
||
for _c in /d/pipeline/workspaces/0/sdlc_general "${HOME}/sdlc_general"; do
|
||
if [ -f "${_c}/projects/pbls/env/test.json" ]; then (cd "${_c}" && pwd); return 0; fi
|
||
done
|
||
return 1
|
||
}
|
||
RESOLVED_WS_DIR="$(_ws_probe || true)"
|
||
[ -n "${RESOLVED_WS_DIR}" ] || {
|
||
echo "[build.sh][FATAL] 无法定位机构工作空间根(需含 projects/pbls/env/test.json)。" >&2
|
||
echo "[build.sh][FATAL] APP_DIR=${APP_DIR};请显式导出 PBL_WS_DIR=<工作空间根> 后重跑。" >&2
|
||
exit 1
|
||
}
|
||
WS_DIR="${RESOLVED_WS_DIR}"
|
||
ENV_FILE="${WS_DIR}/projects/pbls/env/test.json"
|
||
DDL_FILE="${APP_DIR}/scripts/ddl/pbls_tables.sql"
|
||
# [Bug 4SxqQAulkH9eAzEys_h-e 根因闭环] 基座模块(appbase/rbac)models 链 DDL ——
|
||
# 单一真源 = ${BASE_PKGS_ROOT}/{appbase,rbac}/models/*.json,由 gen_base_ddl.py 生成,
|
||
# 禁止手工编辑 base_tables.sql。第 10a-base 步执行。
|
||
BASE_GEN="${APP_DIR}/scripts/gen_base_ddl.py"
|
||
BASE_DDL_FILE="${APP_DIR}/scripts/ddl/base_tables.sql"
|
||
BASE_ALIAS_MAP="${APP_DIR}/scripts/ddl/base_table_alias_map.json"
|
||
# 基座包根:显式取 WS_DIR/pkgs(build.sh 已三段式解析 WS_DIR),gen_base_ddl.py
|
||
# 内部再做存在性校验并打印解析出的绝对路径(web-application-spec 铁律 5)。
|
||
BASE_PKGS_ROOT="${PBL_BASE_PKGS_ROOT:-${WS_DIR}/pkgs}"
|
||
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 实际表数对齐(表数 / BIGINT AUTO_INCREMENT 主键数同值);
|
||
# [2026-09-28 方案 A] governance/template 6 表迁出后为 32。
|
||
[ -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 正文上核验:方言零命中 + 表数 32 + BIGINT AUTO_INCREMENT 32
|
||
# [2026-09-28 方案 A] pbl_governance 4 表 + pbl_template 2 表迁出至模块 models/*.json
|
||
# 单一真源(id=str(32) 应用层 gen_pk,M5a-DISC.1 裁决(a)),手写 DDL 37→32。
|
||
# 模块 6 表由第 10a 步 gen/apply 链建,不进本门禁的 autoinc 口径。
|
||
GATE_RC=0
|
||
python3 "${DDL_GATE}" --ddl "${DDL_FILE}" --expect-tables 32 --expect-autoinc 32 || GATE_RC=$?
|
||
[ "${GATE_RC}" = "0" ] || die "硬门禁 6.4 未通过(ddl_gate_check.py rc=${GATE_RC}):非 mariadb 方言关键字命中 / 表数≠32 / BIGINT AUTO_INCREMENT≠32,详见上方回显"
|
||
log "硬门禁 6.4 通过: mariadb 方言(剥注释+COMMENT 串后零命中),32 表 / 32 处 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.6 依赖声明「可满足性」静态门禁 ----
|
||
# [Bug BWY68rZR_Uh2A41JlGT7D 修复] 门禁 6.5 只 grep「声明存在」,不校验「声明可满足」,
|
||
# 于是三类必然让 `pip install -r requirements.txt` 失败(BUILD_RC=1)的缺陷被放行到部署阶段:
|
||
# 1) 版本下界凭空抬高、公共源无对应发行物(ahserver>=1.4.0 而实装/最高仅 1.2.0)
|
||
# 2) 幻影包 pybricks(平台前端能力实由 bricks_for_python 提供;公共源同名包是 LEGO 机器人库)
|
||
# 3) 不可编译包 mysqlclient(目标机无 mysql_config,pbl 无 sudo 装不了 mariadb-devel)
|
||
# 6.6 把可满足性前移为静态门禁,并按任务要求「检查所有模块的同类问题」一并扫描
|
||
# 全部 modules/*/pyproject.toml(幻影包 / 标准库误当第三方 / 与 wheelhouse 版本不一致)。
|
||
REQ_SAT_GATE="${APP_DIR}/scripts/check_requirements_satisfiable.py"
|
||
[ -f "${REQ_SAT_GATE}" ] || die "缺少依赖可满足性门禁脚本 ${REQ_SAT_GATE}"
|
||
EXTRA_REQ_ARGS=""
|
||
[ -f "${APP_DIR}/requirements-test.txt" ] && EXTRA_REQ_ARGS="--extra-requirements ${APP_DIR}/requirements-test.txt"
|
||
python3 "${REQ_SAT_GATE}" \
|
||
--requirements "${APP_DIR}/requirements.txt" \
|
||
${EXTRA_REQ_ARGS} \
|
||
--wheelhouse "${WS_DIR}/wheelhouse" \
|
||
--modules-glob "${WS_DIR}/modules/*/pyproject.toml" || die "硬门禁 6.6 未通过:依赖声明不可满足(幻影包/版本与 wheelhouse 不一致/失效内网源/相对 find-links),详见上方逐条回显"
|
||
log "硬门禁 6.6 通过: 依赖声明可满足(基座版本与 wheelhouse 逐字一致,零幻影包,全模块 pyproject 同类缺陷零命中)"
|
||
|
||
# ---- 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://DEAD-INTERNAL-PYPI-REMOVED: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="${PBL_WHEELHOUSE:-${WS_DIR}/wheelhouse}"
|
||
[ -d "${WHEELHOUSE}" ] || die "缺少机构 wheelhouse ${WHEELHOUSE}(平台基础包离线源,内网 pypi 在目标机不可达);部署机路径不同时请导出 PBL_WHEELHOUSE=<wheelhouse 绝对路径>"
|
||
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']))")"
|
||
# [deploy_test R8 修复] 安装顺序改为拓扑序:pbl_common 必须最先装。
|
||
# 原实现用 glob `modules/pbl_*` 按字母序安装,pbl_agent_runtime 排在 pbl_common 之前,
|
||
# 而 pbl_agent_runtime 的 pyproject 依赖 pbl_common(本地模块,任何索引都没有)→
|
||
# pip 报 "ERROR: No matching distribution found for pbl_common",BUILD_RC=1
|
||
# (实测 /d/pbl/pbls_ws/build_r8.log L170-176)。故先装 pbl_common,再装其余 pbl_*。
|
||
INSTALL_DIRS=""
|
||
if [ -d "${WS_DIR}/modules/pbl_common" ]; then
|
||
INSTALL_DIRS="${WS_DIR}/modules/pbl_common"
|
||
fi
|
||
for mod_dir in "${WS_DIR}"/modules/pbl_*; do
|
||
[ -d "${mod_dir}" ] || continue
|
||
[ "$(basename "${mod_dir}")" = "pbl_common" ] && 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}"
|
||
|
||
# ---- 8d. 平台基座包 wwwroot 挂载(软链)+ 可达性门禁(owner 2026-09-30 手动修复)----
|
||
# 根因(Bug KMzNPfKwI7Byk2SzLR / Vt3D8nvlALCIbdye / CJijeS7Mz6UekBB_YwNuR / LSycNWkqDbOlAeqSSna5z):
|
||
# wheelhouse 基座 wheel 只含 .py(rbac wheel 14 文件、wwwroot=0),venv editable 安装使 Python
|
||
# 代码可达(healthz/dspy 200),但 rbac 登录页与 bricks dist(header.tmpl/bricks.js)等基座包
|
||
# wwwroot 静态资源从未挂进应用 wwwroot/ → index.ui 渲染 /bricks/header.tmpl 时 fpath=None →
|
||
# processor None → be_call AttributeError → 根路径与 /rbac/user/login.ui 全 500。
|
||
# 宿主 9090 正确参照即 wwwroot/rbac→pkgs/rbac/wwwroot、wwwroot/bricks→pkgs/bricks/dist 两条软链。
|
||
# 定位方式(2026-09-30 pbl 用户实测):PKGS_DIR 经 editable 源运行时定位(→ scense/pkgs,
|
||
# pbl 可读 rbac/wwwroot/user/login.ui);bricks dist 全机唯一在
|
||
# /d/pipeline/pipeline-app/pkgs/bricks/dist(scense/pkgs 下无 bricks),pbl 实测可读 header.tmpl。
|
||
# 实测结果:挂载后 /rbac/user/login.ui=200、/=200、/healthz=200(平台约定登录路径是
|
||
# /rbac/user/login.ui,非 /main/rbac/ 前缀——宿主实测 /main/ 前缀本身也 500)。
|
||
BASE_PKGS_DIR="$("${PY}" -c 'import rbac,os;print(os.path.dirname(os.path.dirname(rbac.__file__)))' 2>/dev/null)"
|
||
BRICKS_DIST="/d/pipeline/pipeline-app/pkgs/bricks/dist"
|
||
[ -n "${BASE_PKGS_DIR}" ] && [ -d "${BASE_PKGS_DIR}/rbac/wwwroot" ] || die "基座 rbac wwwroot 定位失败: BASE_PKGS_DIR=${BASE_PKGS_DIR:-<empty>}(editable 源不可达?)"
|
||
[ -f "${BRICKS_DIST}/header.tmpl" ] || die "bricks dist 不可达: ${BRICKS_DIST}/header.tmpl 不存在(若部署用户无权限,参照 wheelhouse 先例 chmod o+rX + 父链 o+x)"
|
||
if [ -e "${APP_DIR}/wwwroot/rbac" ] && [ ! -L "${APP_DIR}/wwwroot/rbac" ]; then
|
||
die "wwwroot/rbac 已存在且不是软链(应用仓自带目录?),请人工处置后重试"
|
||
fi
|
||
if [ -e "${APP_DIR}/wwwroot/bricks" ] && [ ! -L "${APP_DIR}/wwwroot/bricks" ]; then
|
||
die "wwwroot/bricks 已存在且不是软链(应用仓自带目录?),请人工处置后重试"
|
||
fi
|
||
ln -sfn "${BASE_PKGS_DIR}/rbac/wwwroot" "${APP_DIR}/wwwroot/rbac"
|
||
ln -sfn "${BRICKS_DIST}" "${APP_DIR}/wwwroot/bricks"
|
||
log "基座包 wwwroot 挂载完成: rbac→${BASE_PKGS_DIR}/rbac/wwwroot bricks→${BRICKS_DIST}"
|
||
|
||
# 挂载后应用侧可达性门禁:覆盖层页面壳必须能通过应用 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}"
|
||
# [2026-09-28 方案 A] 计数口径从 LIKE 'pbl_%' 改为 IN 点名 32 张手写表——
|
||
# LIKE 会被其它模块自建表(rtx pbl_entity_state 等)/迁移备份表干扰而虚高判死。
|
||
HAND_TABLES="'pbl_blueprint','pbl_blueprint_version','pbl_blueprint_lock','pbl_subobject','pbl_subobject_attr','pbl_subobject_rel','pbl_subobject_asset','pbl_subobject_ref','pbl_subobject_review','pbl_blueprint_tag','pbl_blueprint_publish','pbl_validation_ruleset','pbl_validation_run','pbl_validation_finding','pbl_compile_job','pbl_compile_artifact','pbl_game_definition','pbl_compile_log','pbl_agent_session','pbl_agent_message','pbl_agent_tool_call','pbl_agent_arbitration','pbl_artifact','pbl_evidence','pbl_evidence_idem','pbl_evidence_binding','pbl_rubric','pbl_rubric_criterion','pbl_assessment_report','pbl_domain_binding','pbl_analytics_snapshot','pbl_compile_task'"
|
||
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 IN (${HAND_TABLES})" "${DB_NAME}")"
|
||
# Q4 修复:与 DDL 实际表数对齐;[2026-09-28 方案 A] governance/template 6 表迁出
|
||
# 至模块 models 链(第 10a 步建),本文件手写 DDL 37→32。
|
||
[ "${ACTUAL_TABLES}" = "32" ] || die "建表数应为 32(手写 DDL 段),实际 ${ACTUAL_TABLES}"
|
||
log "手写 DDL 建表完成: pbl_* 共 ${ACTUAL_TABLES} 张(模块 models 链 6 张见 10a 步)"
|
||
|
||
# ---- 10a. 模块 models 链建表(owner 裁决 2026-09-28 方案 A)----
|
||
# pbl_governance 4 表 + pbl_template 2 表:models/*.json 四段式 → json2ddl 生成
|
||
# → apply_ddl.py 执行(存在性门禁,绝不 DROP)。先 --check 校验 sql/ 产物与
|
||
# models 指纹一致(改表忘重跑 gen 即 die),再逐模块 apply + verify。
|
||
for MOD in pbl_governance pbl_template; do
|
||
MOD_DIR="${WS_DIR}/modules/${MOD}"
|
||
[ -f "${MOD_DIR}/scripts/gen_ddl_from_models.py" ] || die "${MOD} 缺 gen_ddl_from_models.py(models 链不完整)"
|
||
log "[10a] ${MOD}: gen --check(models↔sql 指纹幂等门禁)"
|
||
"${PY}" "${MOD_DIR}/scripts/gen_ddl_from_models.py" --check \
|
||
|| die "${MOD}: sql/ 产物与 models/*.json 指纹不一致——改表结构后必须重跑 gen_ddl_from_models.py"
|
||
log "[10a] ${MOD}: apply_ddl(存在性门禁建表 + information_schema 列核验)"
|
||
"${PY}" "${MOD_DIR}/scripts/apply_ddl.py" --verify \
|
||
|| die "${MOD}: apply_ddl 失败(详见上方真实错误链)"
|
||
done
|
||
# 模块链 6 表终态断言(含补建 pbl_privacy_consent)
|
||
MOD_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 IN ('pbl_tenant','pbl_role_binding','pbl_audit_log','pbl_privacy_consent','pbl_template','pbl_template_item')" "${DB_NAME}")"
|
||
[ "${MOD_TABLES}" = "6" ] || die "模块 models 链建表应为 6 张,实际 ${MOD_TABLES}"
|
||
log "[10a] 模块 models 链建表完成: 6/6(governance 4 含 privacy_consent + template 2)"
|
||
|
||
# ---- 10a-base. 基座模块(appbase / rbac)models 链建表 ----
|
||
# [Bug 4SxqQAulkH9eAzEys_h-e 根因闭环 / QC 未过项 #1]
|
||
# 症状链:POST /main/rbac/user/up_login.dspy → 500 "Server got itself in trouble"
|
||
# ← ERROR 1146 (42S02) Table 'pbl.users' doesn't exist
|
||
# ← build.sh 建表段只覆盖 scripts/ddl/pbls_tables.sql(32 张 pbl_* 手写表)
|
||
# 与 modules/pbl_*/models(6 张治理/模板域表,第 10a 步),
|
||
# 基座 appbase(appcodes/appcodes_kv/params/svgicon)与
|
||
# rbac(users/role/permission/rolepermission/userrole/organization/orgtypes/audit_log)
|
||
# 的 models 链从未被建表。
|
||
# 本段职责:① 从 models 单一真源重新生成 base_tables.sql + alias map(脚本自带
|
||
# 「表数<8 或 users 缺失即 die」门禁);② 用 env 派生的连接参数执行建表;
|
||
# ③ information_schema 逐张点名自校验(防计数虚高);④ 全部 IF NOT EXISTS,幂等。
|
||
# 口径边界:本段只新增 12 张基座表,不触碰第 10 步 32 张手写表门禁、
|
||
# 第 10a 步 6 张模块链表门禁,也不改动后续 38 关键表终态门禁的口径。
|
||
[ -f "${BASE_GEN}" ] || die "缺少基座 DDL 生成器 ${BASE_GEN}"
|
||
|
||
# gen_base_ddl.py 只用标准库;优先用 venv 解释器(与 10a 一致),venv 不在位时回退 python3
|
||
BASE_GEN_PY="${PY}"
|
||
[ -x "${BASE_GEN_PY}" ] || BASE_GEN_PY="python3"
|
||
|
||
# 把 WS_DIR 交给生成器(其候选链第 2 项 = ${WS_DIR}/pkgs);BASE_PKGS_ROOT 仅在
|
||
# 真实存在时导出,否则留给生成器自带的三段式探测(会逐条打印候选与存在性,不静默)。
|
||
export WS_DIR
|
||
if [ -d "${BASE_PKGS_ROOT}" ]; then
|
||
export BASE_PKGS_ROOT
|
||
log "[10a-base] BASE_PKGS_ROOT=${BASE_PKGS_ROOT} exists=yes(显式导出)"
|
||
else
|
||
log "[10a-base][WARN] BASE_PKGS_ROOT=${BASE_PKGS_ROOT} exists=no —— 交由 gen_base_ddl.py 三段式探测基座包根(其候选含 /d/pipeline/pipeline-app/pkgs),探测过程逐条回显"
|
||
fi
|
||
log "[10a-base] 生成基座 DDL: ${BASE_GEN_PY} ${BASE_GEN}"
|
||
log "[10a-base] 输出: ${BASE_DDL_FILE} / ${BASE_ALIAS_MAP}"
|
||
"${BASE_GEN_PY}" "${BASE_GEN}" \
|
||
--out "${BASE_DDL_FILE}" \
|
||
--alias-map "${BASE_ALIAS_MAP}" \
|
||
|| die "[10a-base] gen_base_ddl.py 失败(基座 models 读取不完整 / users 缺失 / BASE_PKGS_ROOT 不可达,详见上方 [gen_base_ddl] 真实错误链)"
|
||
|
||
# 生成产物存在性 + 生成器 SELF_CHECK 口径复核(不依赖 stderr 抓取,直接数 SQL 文件)
|
||
[ -f "${BASE_DDL_FILE}" ] || die "[10a-base] 生成器未产出 ${BASE_DDL_FILE}"
|
||
[ -f "${BASE_ALIAS_MAP}" ] || die "[10a-base] 生成器未产出 ${BASE_ALIAS_MAP}(声明表名→真源表名对照,test/deploy 探针据此改用真名消除 1146 误报)"
|
||
BASE_SQL_TABLES="$(grep -c 'CREATE TABLE IF NOT EXISTS' "${BASE_DDL_FILE}" || true)"
|
||
[ "${BASE_SQL_TABLES}" -ge 8 ] || die "[10a-base] base_tables.sql 仅含 ${BASE_SQL_TABLES} 张表(<8),基座 models 读取不完整"
|
||
grep -q 'CREATE TABLE IF NOT EXISTS `users`' "${BASE_DDL_FILE}" \
|
||
|| die "[10a-base] base_tables.sql 不含 users 表 —— up_login.dspy 500 根因未消除"
|
||
log "[10a-base] 生成校验通过: base_tables.sql 含 ${BASE_SQL_TABLES} 张表,users=present"
|
||
|
||
log "[10a-base] 执行基座 DDL: ${BASE_DDL_FILE} → ${DB_NAME}@${DB_HOST}:${DB_PORT}"
|
||
mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
|
||
--default-character-set=utf8mb4 "${DB_NAME}" < "${BASE_DDL_FILE}" \
|
||
|| die "[10a-base] 基座 DDL 执行失败(连接参数取自 ${ENV_FILE},请核对 db.host/port/user/password 可达性)"
|
||
|
||
# 自校验:逐张点名(IN 列表),不用 LIKE —— LIKE 会被同前缀表/迁移备份表干扰而虚高判死。
|
||
# 期望 12 张:appbase 4(appcodes/appcodes_kv/params/svgicon)
|
||
# + rbac 8(audit_log/organization/orgtypes/permission/role/rolepermission/userrole/users)
|
||
BASE_TABLES="'appcodes','appcodes_kv','params','svgicon','audit_log','organization','orgtypes','permission','role','rolepermission','userrole','users'"
|
||
BASE_ACTUAL="$(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 IN (${BASE_TABLES})" "${DB_NAME}")"
|
||
log "[10a-base] 基座表点名核验: ${BASE_ACTUAL}/12(appbase 4 + rbac 8)"
|
||
[ "${BASE_ACTUAL}" = "12" ] || {
|
||
MISSING="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
|
||
-N -B -e "SELECT t.n FROM (SELECT 'appcodes' n UNION ALL SELECT 'appcodes_kv' UNION ALL SELECT 'params' UNION ALL SELECT 'svgicon' UNION ALL SELECT 'audit_log' UNION ALL SELECT 'organization' UNION ALL SELECT 'orgtypes' UNION ALL SELECT 'permission' UNION ALL SELECT 'role' UNION ALL SELECT 'rolepermission' UNION ALL SELECT 'userrole' UNION ALL SELECT 'users') t LEFT JOIN information_schema.tables i ON i.table_schema='${DB_NAME}' AND i.table_name=t.n WHERE i.table_name IS NULL" "${DB_NAME}" | tr '\n' ' ')"
|
||
die "[10a-base] 基座表在场数应为 12,实际 ${BASE_ACTUAL};缺失: ${MISSING:-(无法枚举)}"
|
||
}
|
||
|
||
# users 表单独点名(登录链路命门,QC 验收项要求显式回显 users=present)
|
||
USERS_PRESENT="$(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='users'" "${DB_NAME}")"
|
||
[ "${USERS_PRESENT}" = "1" ] || die "[10a-base] users 表不存在 —— up_login.dspy 仍会 ERROR 1146 → 500"
|
||
# up_login.dspy 锁定检查/成功登录写的三列必须在位(来源 pkgs/rbac/migration/*.sql,
|
||
# models/users.json 未含,由 gen_base_ddl.py 补齐;此处对已存在的旧表做终态核验)
|
||
USERS_LOGIN_COLS="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
|
||
-N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='${DB_NAME}' AND table_name='users' AND column_name IN ('username','login_fail_count','last_login_fail','last_login')" "${DB_NAME}")"
|
||
[ "${USERS_LOGIN_COLS}" = "4" ] || die "[10a-base] users 表登录链列不全(username/login_fail_count/last_login_fail/last_login 应在场 4,实际 ${USERS_LOGIN_COLS})"
|
||
log "[10a-base] SELF_CHECK base_tables: PASS tables=${BASE_ACTUAL}/12 users=present login_cols=${USERS_LOGIN_COLS}/4"
|
||
|
||
# ---- 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"
|
||
# [deploy_test R11 修复] 原门禁断言字面量 'PASS 74/74' 与 pbl_appcodes.self_check 真实输出格式不符:
|
||
# 实测输出为 'SELF_CHECK pbl_appcodes: PASS 18/18'(18 = 自检项数),74 项枚举契约由其中
|
||
# '[PASS] meta: _meta.groups=8 / items=74' 与 '[PASS] groups: 每组项数符合契约(8/5/8/6/6/10/16/15 = 74)'
|
||
# 两行承载(r11 build 日志 1411-1430 行实测)。故门禁改为「PASS 汇总行 + 74 项契约双证据」联合断言,
|
||
# 语义不放宽(仍要求自检全绿且枚举数=74),仅修正过期字面量。
|
||
grep -qE 'SELF_CHECK pbl_appcodes: PASS [0-9]+/[0-9]+' "${APP_DIR}/logs/self_check.log" \
|
||
|| die "pbl_appcodes 数量契约自检未通过(无 PASS 汇总行)"
|
||
grep -q 'items=74' "${APP_DIR}/logs/self_check.log" \
|
||
|| die "pbl_appcodes 数量契约自检未通过(_meta items != 74)"
|
||
grep -q '= 74)' "${APP_DIR}/logs/self_check.log" \
|
||
|| die "pbl_appcodes 数量契约自检未通过(每组项数合计 != 74)"
|
||
# 注:set -euo pipefail 下 `grep && die` 在 grep 无命中(rc=1)时会误触发脚本退出,故用 if 结构
|
||
if grep -q 'FAIL' "${APP_DIR}/logs/self_check.log"; then
|
||
die "pbl_appcodes 数量契约自检存在 FAIL 项"
|
||
fi
|
||
log "枚举数量契约自检通过: 74 项 / 自检全绿(无 FAIL)"
|
||
|
||
# ---- 12. 9 项种子数据幂等注入(重复执行零差异)----
|
||
log "种子注入(第 1 轮)"
|
||
# [2026-09-29 修复] 种子段是**独立 python 进程**(heredoc),必须自己做 DB bootstrap:
|
||
# ServerEnv.get_module_dbname(库名唯一事实源=conf/config.json 的 module_dbname 段)
|
||
# + DBPools(cfg.databases)。原脚本从不做这一步,于是 get_db() 解析不到库名 →
|
||
# get_module_dbname fail-closed 抛 'NoneType' object is not callable,
|
||
# _table_exists 退化成误报「共享编码表 appcodes 不存在(appbase 未初始化)」,
|
||
# 最终 DbError 中断部署。此前该缺陷被 seed.py 的 `SHOW TABLES LIKE %s` 旧式传参
|
||
# (sqlor v2 只认 ${name}$ 具名 dict)以更早的 PBL_E_PARAM/AttributeError 形式
|
||
# 掩盖,两处必须一起修才能真正走通。
|
||
# bootstrap 序列与 modules/pbl_*/scripts/apply_ddl.py 及 app/pbls.py init() 同口径
|
||
# (本修复已在测试机实测:种子注入两轮幂等零差异通过)。
|
||
export SEED_APP_DIR="${APP_DIR}"
|
||
export SEED_WS_DIR="${WS_DIR}"
|
||
"${PY}" - <<'PYEOF'
|
||
import json, asyncio, os, sys
|
||
|
||
# ---- DB bootstrap(独立进程必做,见上方注释)----
|
||
_APP_DIR = os.environ.get('SEED_APP_DIR') or os.getcwd()
|
||
_WS_DIR = os.environ.get('SEED_WS_DIR') or os.path.normpath(
|
||
os.path.join(_APP_DIR, '..', '..'))
|
||
os.chdir(_APP_DIR) # conf/config.json 相对路径口径
|
||
for _p in (os.path.join(_WS_DIR, 'modules', 'pbl_common'),
|
||
os.path.join(_WS_DIR, 'modules', 'pbl_appcodes'),
|
||
os.path.join(_WS_DIR, 'pkgs'),
|
||
'/d/pipeline/binpkg_overlay'):
|
||
if os.path.isdir(_p) and _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
|
||
from ahserver.globalEnv import getConfig, DBPools
|
||
from ahserver.serverenv import ServerEnv
|
||
|
||
with open(os.path.join(_APP_DIR, 'conf', 'config.json'), encoding='utf-8') as _fh:
|
||
_MODULE_DBNAME = json.load(_fh).get('module_dbname') or {}
|
||
|
||
|
||
def _gmd(_m):
|
||
_n = _MODULE_DBNAME.get(_m) or _MODULE_DBNAME.get('default')
|
||
if not _n:
|
||
raise RuntimeError('conf/config.json 缺 module_dbname 映射: %r' % _m)
|
||
return _n
|
||
|
||
|
||
_cfg = getConfig()
|
||
_env = ServerEnv() # MUST be before DBPools(同 pbls.py init)
|
||
_env.get_module_dbname = _gmd
|
||
DBPools(_cfg.databases)
|
||
print('[seed] DB bootstrap 完成: 目标库=%s(module_dbname 映射,非硬编码)'
|
||
% _gmd('pbl_appcodes'))
|
||
|
||
from pbl_appcodes.seed import seed_all
|
||
# [deploy_test R12 定点补丁] seed_all 是 async def,原脚本同步调用 → TypeError:
|
||
# Object of type coroutine is not JSON serializable + RuntimeWarning: never awaited。
|
||
# [2026-09-29 修复] 两轮必须跑在**同一个事件循环**里,不能各自 asyncio.run():
|
||
# sqlor 连接池的 Future 绑定首次建池的 loop,第二次 asyncio.run() 新建 loop 后
|
||
# 复用同一连接池 → "got Future attached to a different loop"(build_run7 实测,
|
||
# 第 1 轮 inserted=82 成功、第 2 轮崩)。幂等对比口径不变:同 loop 内顺序两轮,
|
||
# 第 2 轮仍须零插入零更新。
|
||
async def _seed_two_rounds():
|
||
r1 = await seed_all()
|
||
print('[seed round1]', json.dumps(r1, ensure_ascii=False, sort_keys=True))
|
||
r2 = await seed_all()
|
||
print('[seed round2]', json.dumps(r2, ensure_ascii=False, sort_keys=True))
|
||
return r1, r2
|
||
|
||
|
||
s1, s2 = asyncio.run(_seed_two_rounds())
|
||
# [deploy_test R12 定点补丁 #2] 原断言用 groups_inserted/items_inserted/idempotent 三个键,
|
||
# 但 seed_all 真实返回契约(seed.py:494-499)是 ok/groups/items/inserted/updated/skipped/per_group,
|
||
# 无上述键 → 同步调用修好后必然 KeyError。改按真实返回键断言,幂等口径不变(第二轮零插入零更新)。
|
||
assert s1['ok'] is True and s1['groups'] == 8 and s1['items'] == 74, '第 1 轮种子契约不满足(8 组 74 项)'
|
||
assert s2['ok'] is True and s2['inserted'] == 0 and s2['updated'] == 0, '重复执行有差异(幂等失败)'
|
||
assert s2['groups'] == 8 and s2['items'] == 74, '第 2 轮种子契约不满足'
|
||
print('[seed] 幂等零差异校验通过: 8 组 74 项')
|
||
PYEOF
|
||
|
||
# ---- 12.1 pbl_template 离线兜底模板种子(幂等)----
|
||
log "pbl_template 种子注入(tpl_offline_default)"
|
||
"${PY}" - <<'PYEOF_SEED_TEMPLATE'
|
||
import json, asyncio, os, sys
|
||
_WS = os.environ.get('SEED_WS_DIR', '/d/pipeline/workspaces/0/sdlc_general')
|
||
for _p in (os.path.join(_WS, 'modules', 'pbl_common'), os.path.join(_WS, 'modules', 'pbl_template'), os.path.join(_WS, 'pkgs'), '/d/pipeline/binpkg_overlay'):
|
||
if os.path.isdir(_p) and _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
os.chdir(os.environ.get('SEED_APP_DIR', os.getcwd()))
|
||
from pbl_template.seed import seed_all_sync, seed_templates
|
||
# 先跑 async 版(在 seed 段同 loop 内,确保连接池一致)
|
||
async def _seed():
|
||
r1 = await seed_templates()
|
||
print('[seed.tpl round1]', json.dumps(r1, ensure_ascii=False, sort_keys=True))
|
||
r2 = await seed_templates()
|
||
print('[seed.tpl round2]', json.dumps(r2, ensure_ascii=False, sort_keys=True))
|
||
return r1, r2
|
||
s1, s2 = asyncio.run(_seed())
|
||
assert s1['ok'] is True, 'pbl_template 第 1 轮种子失败: %s' % s1.get('errors', [])
|
||
assert s2['ok'] is True and s2['templates_inserted'] == 0 and s2['items_inserted'] == 0, \
|
||
'pbl_template 重复执行有差异(幂等失败)'
|
||
print('[seed.tpl] 幂等零差异校验通过: tpl=%d items=%d' % (s1['templates_inserted'], s1['items_inserted']))
|
||
PYEOF_SEED_TEMPLATE
|
||
|
||
# ---- 12.2 pbl_assessment 默认量规种子(幂等)----
|
||
log "pbl_assessment 种子注入(rubric_default_5level)"
|
||
"${PY}" - <<'PYEOF_SEED_ASSESS'
|
||
import json, asyncio, os, sys
|
||
_WS = os.environ.get('SEED_WS_DIR', '/d/pipeline/workspaces/0/sdlc_general')
|
||
for _p in (os.path.join(_WS, 'modules', 'pbl_common'), os.path.join(_WS, 'modules', 'pbl_assessment'), os.path.join(_WS, 'pkgs'), '/d/pipeline/binpkg_overlay'):
|
||
if os.path.isdir(_p) and _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
os.chdir(os.environ.get('SEED_APP_DIR', os.getcwd()))
|
||
from pbl_assessment.seed import seed_rubrics, seed_all_sync
|
||
async def _seed():
|
||
r1 = await seed_rubrics()
|
||
print('[seed.rubric round1]', json.dumps(r1, ensure_ascii=False, sort_keys=True))
|
||
r2 = await seed_rubrics()
|
||
print('[seed.rubric round2]', json.dumps(r2, ensure_ascii=False, sort_keys=True))
|
||
return r1, r2
|
||
s1, s2 = asyncio.run(_seed())
|
||
assert s1['ok'] is True, 'pbl_assessment 第 1 轮种子失败: %s' % s1.get('errors', [])
|
||
assert s2['ok'] is True and s2['rubrics_inserted'] == 0 and s2['criteria_inserted'] == 0, \
|
||
'pbl_assessment 重复执行有差异(幂等失败)'
|
||
print('[seed.rubric] 幂等零差异校验通过: rubric=%d criteria=%d' % (s1['rubrics_inserted'], s1['criteria_inserted']))
|
||
PYEOF_SEED_ASSESS
|
||
|
||
# ---- 12.3 pbl_validation 内置 14 维规则种子(幂等)----
|
||
log "pbl_validation 种子注入(vrs_14dim_v1)"
|
||
"${PY}" - <<'PYEOF_SEED_VALIDATE'
|
||
import json, os, sys
|
||
_WS = os.environ.get('SEED_WS_DIR', '/d/pipeline/workspaces/0/sdlc_general')
|
||
for _p in (os.path.join(_WS, 'modules', 'pbl_common'), os.path.join(_WS, 'modules', 'pbl_validation'), os.path.join(_WS, 'pkgs'), '/d/pipeline/binpkg_overlay'):
|
||
if os.path.isdir(_p) and _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
os.chdir(os.environ.get('SEED_APP_DIR', os.getcwd()))
|
||
from pbl_validation.init import seed_builtin_rules
|
||
# seed_builtin_rules 是同步 def,直接调用
|
||
s1 = seed_builtin_rules(dbname=None, tenant_id='')
|
||
print('[seed.validate round1] seeded=%d' % s1)
|
||
s2 = seed_builtin_rules(dbname=None, tenant_id='')
|
||
print('[seed.validate round2] seeded=%d' % s2)
|
||
assert s2 == 0, 'pbl_validation 重复执行有差异(幂等失败,第 2 轮 seeded=%d != 0)' % s2
|
||
print('[seed.validate] 幂等零差异校验通过: rules=%d (vrs_14dim_v1)' % s1)
|
||
PYEOF_SEED_VALIDATE
|
||
|
||
|
||
|
||
# ---- 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}"
|
||
|
||
# ---- 12.45 RBAC 写入工具定位(set_role_perm.py)----
|
||
# [2026-09-29 修复] load_path.py 的 find_set_perm() 候选根是 ~/repos/sage、
|
||
# ~/test/sage、/d/ymq/repos/sage、/opt/sage 四个**外部 sage 根**加 PATH;本机
|
||
# (pipeline-app 部署形态)一个都不命中(/d/ymq/repos/sage 存在但对 pipeline
|
||
# 账号 Permission denied,os.path.exists 返回 False 故不会误选),于是
|
||
# register() 把 308 条路径全部记为 PENDING 并返回 rc=1 → 12.5 die。
|
||
# 本机真实副本是 /d/pipeline/pipeline-app/set_role_perm.py(全机唯一 find 命中,
|
||
# conf/config.json 完备)。该工具实测:
|
||
# · 可写库:`Registered: logined(logined) -> /pipeline-pbls/healthz_probe.ui`,rc=0
|
||
# (退出时 aiomysql 的 "Event loop is closed" 是 GC 噪音,不影响写入)
|
||
# · 先查后写天然幂等:连跑 3 次 permission / rolepermission 各仍只 1 行
|
||
# · 单次约 0.65s → 308 条约 200s,在本脚本 timeout 预算内
|
||
# 修法用 load_path.py **原生支持的 RBAC_SET_PERM 覆盖**,不改 load_path.py
|
||
# (该文件属上游 agent.develop,其候选口径面向 sage 根,改它会波及其他宿主)。
|
||
if [ -n "${RBAC_SET_PERM:-}" ] && [ -f "${RBAC_SET_PERM}" ]; then
|
||
log "RBAC 写入工具: ${RBAC_SET_PERM}(外部 RBAC_SET_PERM 指定)"
|
||
else
|
||
RBAC_SET_PERM=""
|
||
for _cand in /d/pipeline/pipeline-app/set_role_perm.py \
|
||
"${PIPE_APP_DIR:-/nonexistent}/set_role_perm.py" \
|
||
"$(command -v set_role_perm.py || true)"; do
|
||
if [ -n "${_cand}" ] && [ -f "${_cand}" ]; then RBAC_SET_PERM="${_cand}"; break; fi
|
||
done
|
||
if [ -z "${RBAC_SET_PERM}" ]; then
|
||
die "未找到 RBAC 写入工具 set_role_perm.py:load_path.py 候选根(~/repos/sage、~/test/sage、/d/ymq/repos/sage、/opt/sage)本机均不存在或不可读。处置:导出 RBAC_SET_PERM=<绝对路径> 后重跑(本机参考位置 /d/pipeline/pipeline-app/set_role_perm.py)"
|
||
fi
|
||
log "RBAC 写入工具: ${RBAC_SET_PERM}(本机候选探测)"
|
||
fi
|
||
export RBAC_SET_PERM
|
||
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 不属于运行态。
|
||
# [Bug elS7VxwO7ufbPiPl-ucGA 修复] rsync 缺失兜底:部署机(pbl 用户,无 sudo)实测
|
||
# 可能未装 rsync,原实现直接调用 → command not found → set -e 下 die,
|
||
# 部署目录 /d/pbl/pbls 永远建不出来(复测门禁 ④ 恒红)。
|
||
# 无 rsync 时退化为 tar 管道复制(POSIX 必备,保留权限与软链本身),并显式清理运行态目录。
|
||
# [Bug r13-start2 修复·续] exclude 模式必须无尾斜杠:'venv/' 只匹配目录,venv 是软链时
|
||
# 不命中 → --delete 会把部署侧 venv 软链删掉(实测发生)。并排除 venv.stale-* 保留目录。
|
||
if command -v rsync >/dev/null 2>&1; then
|
||
rsync -a --delete \
|
||
--exclude 'venv' --exclude '.venv' --exclude 'venv.stale-*' --exclude '.venv.stale-*' \
|
||
--exclude 'logs' --exclude 'run' \
|
||
--exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \
|
||
"${APP_DIR}/" "${DEPLOY_PATH}/"
|
||
else
|
||
log "rsync 不可用,退化 tar 管道同步(保留权限/软链)"
|
||
( cd "${APP_DIR}" && tar cf - \
|
||
--exclude='./venv' --exclude='./.venv' --exclude='./logs' --exclude='./run' \
|
||
--exclude='./.git' --exclude='__pycache__' --exclude='*.pyc' . ) \
|
||
| ( cd "${DEPLOY_PATH}" && tar xf - )
|
||
# tar 路径无 --delete 语义:手工清理运行态残留,避免旧文件污染
|
||
rm -rf "${DEPLOY_PATH}/.git" "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run" 2>/dev/null || true
|
||
fi
|
||
mkdir -p "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run"
|
||
# venv 软链复用(目标侧 start_cmd 要求 ${DEPLOY_PATH}/venv/bin/python 可用)
|
||
# [Bug r13-start2 修复] 原条件 [ ! -e venv ] 对部署目录里**真实存在的陈旧 venv 目录**
|
||
# 恒为假 → 软链永远建不出来 → 启动实际用旧 venv 的旧 rbac(无 event_dispatcher 判空守卫),
|
||
# load_rbac 即 AttributeError: NoneType.bind,进程数=0/healthz=000。
|
||
# 处置:venv 是目录(非软链)或软链目标不符时,同目录 rename 保留为 venv.stale-<ts>
|
||
# 再建软链——不跨文件系统 mv(会退化 copy+delete 失败),不 rm(保留证据可回退)。
|
||
if [ -L "${DEPLOY_PATH}/venv" ] && [ "$(readlink -f "${DEPLOY_PATH}/venv")" = "$(readlink -f "${VENV_DIR}")" ]; then
|
||
log "部署目录 venv 软链已指向 ${VENV_DIR},跳过"
|
||
else
|
||
if [ -e "${DEPLOY_PATH}/venv" ] || [ -L "${DEPLOY_PATH}/venv" ]; then
|
||
STALE="${DEPLOY_PATH}/venv.stale-$(date +%Y%m%d%H%M%S)"
|
||
mv "${DEPLOY_PATH}/venv" "${STALE}" || die "陈旧 venv 无法移开: ${DEPLOY_PATH}/venv -> ${STALE}"
|
||
log "陈旧 venv 已保留为 ${STALE}(属主 $(stat -c %U:%G "${STALE}" 2>/dev/null || echo unknown))"
|
||
fi
|
||
ln -sfn "${VENV_DIR}" "${DEPLOY_PATH}/venv"
|
||
log "venv 软链建立: ${DEPLOY_PATH}/venv -> ${VENV_DIR}"
|
||
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 库必须**逐张在场** 38 张关键表
|
||
# (手写 DDL 32 + 模块 models 链 6:governance 4 表含补建 pbl_privacy_consent + template 2 表)。
|
||
# [2026-09-28 方案 A] 断言从「LIKE 计数」升级为「关键表逐张点名」——裸计数会被
|
||
# 其它模块启动期自建表/迁移备份表干扰(计数虚高同样判死),点名在场才是确定性门禁。
|
||
MOD6_TABLES="'pbl_tenant','pbl_role_binding','pbl_audit_log','pbl_privacy_consent','pbl_template','pbl_template_item'"
|
||
REQUIRED_TABLES="${MOD6_TABLES},${HAND_TABLES}"
|
||
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 IN (${REQUIRED_TABLES})" "${DB_NAME}")"
|
||
TOTAL_PBL="$(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 "终态表数: 关键表 ${FINAL_TABLES}/38 在场(pbl_* 总计 ${TOTAL_PBL} 张,含模块自建/扩展表)"
|
||
[ "${FINAL_TABLES}" = "38" ] || {
|
||
MISSING="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
|
||
-N -B -e "SELECT GROUP_CONCAT(t.n ORDER BY t.n) FROM (SELECT 'pbl_tenant' AS n UNION ALL SELECT 'pbl_role_binding' AS n UNION ALL SELECT 'pbl_audit_log' AS n UNION ALL SELECT 'pbl_privacy_consent' AS n UNION ALL SELECT 'pbl_template' AS n UNION ALL SELECT 'pbl_template_item' AS n UNION ALL SELECT 'pbl_blueprint' AS n UNION ALL SELECT 'pbl_blueprint_version' AS n UNION ALL SELECT 'pbl_blueprint_lock' AS n UNION ALL SELECT 'pbl_subobject' AS n UNION ALL SELECT 'pbl_subobject_attr' AS n UNION ALL SELECT 'pbl_subobject_rel' AS n UNION ALL SELECT 'pbl_subobject_asset' AS n UNION ALL SELECT 'pbl_subobject_ref' AS n UNION ALL SELECT 'pbl_subobject_review' AS n UNION ALL SELECT 'pbl_blueprint_tag' AS n UNION ALL SELECT 'pbl_blueprint_publish' AS n UNION ALL SELECT 'pbl_validation_ruleset' AS n UNION ALL SELECT 'pbl_validation_run' AS n UNION ALL SELECT 'pbl_validation_finding' AS n UNION ALL SELECT 'pbl_compile_job' AS n UNION ALL SELECT 'pbl_compile_artifact' AS n UNION ALL SELECT 'pbl_game_definition' AS n UNION ALL SELECT 'pbl_compile_log' AS n UNION ALL SELECT 'pbl_agent_session' AS n UNION ALL SELECT 'pbl_agent_message' AS n UNION ALL SELECT 'pbl_agent_tool_call' AS n UNION ALL SELECT 'pbl_agent_arbitration' AS n UNION ALL SELECT 'pbl_artifact' AS n UNION ALL SELECT 'pbl_evidence' AS n UNION ALL SELECT 'pbl_evidence_idem' AS n UNION ALL SELECT 'pbl_evidence_binding' AS n UNION ALL SELECT 'pbl_rubric' AS n UNION ALL SELECT 'pbl_rubric_criterion' AS n UNION ALL SELECT 'pbl_assessment_report' AS n UNION ALL SELECT 'pbl_domain_binding' AS n UNION ALL SELECT 'pbl_analytics_snapshot' AS n UNION ALL SELECT 'pbl_compile_task' AS n) t WHERE NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name=t.n)" "${DB_NAME}" 2>/dev/null)"
|
||
die "关键表在场 ${FINAL_TABLES} != 38(32 手写 + 6 模块 models 链),门禁 ③ 不成立——缺表: ${MISSING:-未知(mysql查询失败)}"
|
||
}
|
||
|
||
log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH},/healthz=200,关键表 ${FINAL_TABLES}/38)"
|