246 lines
14 KiB
Bash
246 lines
14 KiB
Bash
#!/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"
|
||
|
||
# ---- 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_* 模块 + 复用模块(可编辑模式)----
|
||
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})"
|