pbls/build.sh
agent.develop 8ac2694eee build(pbls): M9 收口——补齐 8c 模块 wwwroot 挂载步骤与应用侧可达性门禁 + 生成产物软链排除
- build.sh 新增「8c. 模块 wwwroot 挂载」:对所有含 wwwroot/ 的 pbl_* 模块执行
  ln -sfn "${mod_dir}/wwwroot" "${APP_DIR}/wwwroot/${mod_name}",并以应用侧路径
  find -L 实测页面壳数=12 作为门禁(不符即 die 终止部署),响应 QC 退回意见 #1/#2
  (生成步骤已接入但可达链路断在挂载环节)
- .gitignore 排除构建期生成的模块 wwwroot 软链(git 中软链非目录,模式不带结尾斜杠),
  并显式反排除应用仓自带的 wwwroot/pbl_overlay/(其 .js 为跟踪文件)
- 选择性 git add:仅 .gitignore 与 build.sh 两个文件,未使用 git add -A、未 add wwwroot/
- 自检:bash -n build.sh rc=0;gen_overlay_pages.py --check rc=0(打印生成+挂载两项均已接入)
2026-09-22 17:12:01 +08:00

225 lines
12 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(9300);本脚本不硬编码端口,从 env 读取
# 门禁:建表 36 张 + 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']")"
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)----
[ -f "${DDL_FILE}" ] || die "缺少 DDL ${DDL_FILE}"
if grep -Eiq 'BIGSERIAL|SERIAL|nextval|FOREIGN KEY|REFERENCES|ENUM\(|TIMESTAMP' "${DDL_FILE}"; then
grep -Ein 'BIGSERIAL|SERIAL|nextval|FOREIGN KEY|REFERENCES|ENUM\(|TIMESTAMP' "${DDL_FILE}" | head -20
die "DDL 含非 mariadb 方言关键字(应为零命中)"
fi
TABLE_COUNT="$(grep -c 'CREATE TABLE IF NOT EXISTS' "${DDL_FILE}")"
[ "${TABLE_COUNT}" = "36" ] || die "DDL 表数应为 36,实际 ${TABLE_COUNT}"
AUTOINC_COUNT="$(grep -c 'BIGINT NOT NULL AUTO_INCREMENT' "${DDL_FILE}")"
[ "${AUTOINC_COUNT}" = "36" ] || die "BIGINT AUTO_INCREMENT 主键应为 36 处,实际 ${AUTOINC_COUNT}"
log "硬门禁 6.4 通过: mariadb 方言,36 表 / 36 处 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
"${PY}" -m pip install -r "${APP_DIR}/requirements.txt"
# ---- 8. 本地安装 15 个 pbl_* 模块 + 复用模块(可编辑模式)----
for mod_dir in "${WS_DIR}"/modules/pbl_*; do
[ -d "${mod_dir}" ] || continue
if [ -f "${mod_dir}/pyproject.toml" ] || [ -f "${mod_dir}/setup.py" ]; then
log "安装模块 $(basename "${mod_dir}")"
"${PY}" -m pip install -e "${mod_dir}"
else
export PYTHONPATH="${mod_dir}:${PYTHONPATH:-}"
log "模块 $(basename "${mod_dir}") 无打包描述,加入 PYTHONPATH"
fi
done
# ---- 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}")"
[ "${ACTUAL_TABLES}" = "36" ] || die "建表数应为 36,实际 ${ACTUAL_TABLES}"
log "建表完成: pbl_* 共 ${ACTUAL_TABLES} 张"
# ---- 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
# ---- 13. 启动应用(唯一入口 + 唯一端口)----
log "启动 ${APP_NAME} 于端口 ${PORT}"
mkdir -p "${APP_DIR}/logs" "${APP_DIR}/run"
nohup "${PY}" "${ENTRY}" > "${APP_DIR}/logs/${APP_NAME}.out" 2>&1 &
echo $! > "${APP_DIR}/run/${APP_NAME}.pid"
sleep 5
# ---- 14. 健康检查 ----
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/public/health" || echo 000)"
log "健康检查 HTTP ${HTTP_CODE}"
[ "${HTTP_CODE}" = "200" ] || die "健康检查失败(HTTP ${HTTP_CODE}),查看 ${APP_DIR}/logs/${APP_NAME}.out"
log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH})"