#!/usr/bin/env bash # ============================================================================= # pbls 应用一键部署脚本(PBL Agent OS Phase 0/1) # 覆盖 web-application-spec 的 8 步集成清单: # 1) 拉取代码(应用 + 23 个模块仓) # 2) 建虚拟环境 + pip install -r requirements.txt # 3) 建库建表(mariadb 方言 DDL,幂等) # 4) wwwroot 软链(前端覆盖层 / bricks 静态资源) # 5) 枚举种子注入(pbl_appcodes seed,幂等,8 组 74 项) # 6) i18n 语言包编译 # 7) 服务配置(systemd unit + 端口 9300 三方一致性校验) # 8) 启动 + 健康检查 # 用法: bash build.sh [test|prod] (默认 test) # ============================================================================= set -euo pipefail ENV_NAME="${1:-test}" APP_NAME="pbls" APP_PORT="9300" WS_ROOT="${WS_ROOT:-/d/pipeline/workspaces/0/sdlc_general}" APP_DIR="${WS_ROOT}/apps/${APP_NAME}" MOD_DIR="${WS_ROOT}/modules" PROJ_DIR="${WS_ROOT}/projects/${APP_NAME}" VENV_DIR="${APP_DIR}/.venv" WWWROOT="${APP_DIR}/wwwroot" LOG_DIR="${APP_DIR}/logs" GIT_BASE="${GIT_BASE:-https://git.example.internal/sdlc}" GIT_BRANCH="${GIT_BRANCH:-main}" DB_HOST="${DB_HOST:-127.0.0.1}" DB_PORT="${DB_PORT:-3306}" DB_USER="${DB_USER:-pbls}" DB_PASS="${DB_PASS:-pbls@2026}" DB_NAME="${DB_NAME:-pbls}" # 23 个模块(load_order,与 app/pbls.py、pbls_spec.json 一致) MODULES=( appbase rbac pybricks pbl_common world scene entity scense scense_runtime script_engine kdb world_snapshot pbl_appcodes pbl_blueprint pbl_validation pbl_compiler pbl_agent_runtime pbl_evidence pbl_assessment pbl_kdb_ext pbl_domain_ext pbl_scense_ext pbl_runtime_ext pbl_template pbl_analytics pbl_governance ) log() { echo "[build.sh][$(date '+%F %T')] $*"; } fail() { echo "[build.sh][FATAL] $*" >&2; exit 1; } log "===== 开始部署 ${APP_NAME} (env=${ENV_NAME}, port=${APP_PORT}) =====" # ----------------------------------------------------------------------------- # 步骤 0:环境与项目 env 文件校验(端口三方一致性硬门禁) # ----------------------------------------------------------------------------- ENV_FILE="${PROJ_DIR}/env/${ENV_NAME}.json" [ -f "${ENV_FILE}" ] || fail "缺少项目环境文件:${ENV_FILE}" command -v python3 >/dev/null 2>&1 || fail "未安装 python3" command -v git >/dev/null 2>&1 || fail "未安装 git" # 三方端口一致性:conf/config.json == projects/pbls/env/{env}.json == 入口约定 9300 CONF_PORT=$(python3 -c "import json;print(json.load(open('${APP_DIR}/conf/config.json'))['website']['port'])") ENV_PORT=$(python3 -c "import json;print(json.load(open('${ENV_FILE}'))['website']['port'])") [ "${CONF_PORT}" = "${APP_PORT}" ] || fail "conf/config.json 端口 ${CONF_PORT} != ${APP_PORT}" [ "${ENV_PORT}" = "${APP_PORT}" ] || fail "env/${ENV_NAME}.json 端口 ${ENV_PORT} != ${APP_PORT}" log "步骤0 OK:端口三方一致 = ${APP_PORT}" # ----------------------------------------------------------------------------- # 步骤 1:拉取代码(应用仓 + 模块仓;已存在则 fetch/reset 到目标分支) # ----------------------------------------------------------------------------- mkdir -p "${MOD_DIR}" "${LOG_DIR}" clone_or_update() { local url="$1" dest="$2" if [ -d "${dest}/.git" ]; then log " 更新已存在仓库:${dest}" git -C "${dest}" fetch --all --prune --quiet || true git -C "${dest}" checkout "${GIT_BRANCH}" --quiet 2>/dev/null || true git -C "${dest}" reset --hard "origin/${GIT_BRANCH}" --quiet 2>/dev/null || true else log " 克隆:${url} -> ${dest}" git clone --depth 1 -b "${GIT_BRANCH}" "${url}" "${dest}" || \ log " [WARN] 克隆失败(离线/无权限),使用工作空间已有代码:${dest}" fi } clone_or_update "${GIT_BASE}/${APP_NAME}_app.git" "${APP_DIR}" for m in "${MODULES[@]}"; do [ -d "${MOD_DIR}/${m}" ] && continue clone_or_update "${GIT_BASE}/${m}.git" "${MOD_DIR}/${m}" done log "步骤1 OK:代码就绪(应用 + ${#MODULES[@]} 个模块)" # ----------------------------------------------------------------------------- # 步骤 2:虚拟环境 + 依赖安装 # ----------------------------------------------------------------------------- if [ ! -x "${VENV_DIR}/bin/python" ]; then python3 -m venv "${VENV_DIR}" || fail "创建 venv 失败" fi # shellcheck disable=SC1091 source "${VENV_DIR}/bin/activate" python -m pip install --upgrade pip setuptools wheel --quiet || true pip install -r "${APP_DIR}/requirements.txt" --quiet || fail "pip install 失败" # 模块以源码路径方式挂载(PYTHONPATH 注入,避免逐个打包发布) export PYTHONPATH="${APP_DIR}/app:${MOD_DIR}:${PYTHONPATH:-}" log "步骤2 OK:依赖安装完成,PYTHONPATH=${PYTHONPATH}" # ----------------------------------------------------------------------------- # 步骤 3:建库建表(mariadb 方言,DDL 幂等:CREATE TABLE IF NOT EXISTS) # ----------------------------------------------------------------------------- if command -v mysql >/dev/null 2>&1; then mysql -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \ -e "CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" \ || fail "建库失败" for ddl in "${APP_DIR}"/scripts/ddl/*.sql; do [ -f "${ddl}" ] || continue log " 执行 DDL:${ddl}" mysql -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" "${DB_NAME}" < "${ddl}" \ || fail "DDL 执行失败:${ddl}" done else log " [WARN] 未检测到 mysql 客户端,改用应用内建表(模块 tables.py 自动 CREATE IF NOT EXISTS)" fi log "步骤3 OK:36 表 DDL 就绪(BIGINT AUTO_INCREMENT 主键 / varchar(32) 编码 / 无 FK / 无 ENUM / 无 TIMESTAMP)" # ----------------------------------------------------------------------------- # 步骤 4:wwwroot 软链(前端覆盖层 + bricks 静态资源) # ----------------------------------------------------------------------------- mkdir -p "${WWWROOT}" # bricks / sage 前端静态资源(存在才链,缺失不阻断) for src in "${MOD_DIR}/pybricks/wwwroot" "${MOD_DIR}/appbase/wwwroot" "${MOD_DIR}/rbac/wwwroot"; do [ -d "${src}" ] || continue base=$(basename "$(dirname "${src}")") ln -sfn "${src}" "${WWWROOT}/${base}" && log " 软链:${WWWROOT}/${base} -> ${src}" done # PBL 前端覆盖层(pbl_scense_ext / pbl_overlay) for ov in pbl_scense_ext pbl_domain_ext; do [ -d "${MOD_DIR}/${ov}/wwwroot" ] && ln -sfn "${MOD_DIR}/${ov}/wwwroot" "${WWWROOT}/${ov}" done [ -d "${APP_DIR}/wwwroot/pbl_overlay" ] && log " 覆盖层:${WWWROOT}/pbl_overlay(应用自带)" log "步骤4 OK:wwwroot 软链完成" # ----------------------------------------------------------------------------- # 步骤 5:枚举种子注入(pbl_appcodes,幂等;8 组 74 项) # ----------------------------------------------------------------------------- python - <<'PY' || fail "枚举种子注入失败" import os, sys sys.path.insert(0, os.environ.get('MOD_DIR', 'modules')) from pbl_appcodes.seed import seed_all res = seed_all(dbname=os.environ.get('DB_NAME', 'pbls'), idempotent=True) print('[seed] groups=%s items=%s inserted=%s skipped=%s' % ( res.get('groups'), res.get('items'), res.get('inserted'), res.get('skipped'))) assert res.get('groups') == 8 and res.get('items') == 74, '枚举组/项数量不符(应 8 组 74 项)' PY log "步骤5 OK:枚举种子幂等注入完成" # ----------------------------------------------------------------------------- # 步骤 6:i18n 语言包编译 # ----------------------------------------------------------------------------- if [ -d "${APP_DIR}/i18n" ]; then python - <<'PY' || log " [WARN] i18n 编译异常,继续" import json, os, glob root = 'i18n' out = {} for f in glob.glob(os.path.join(root, '**', '*.json'), recursive=True): lang = os.path.basename(os.path.dirname(f)) out.setdefault(lang, {}).update(json.load(open(f, encoding='utf-8'))) os.makedirs('conf', exist_ok=True) json.dump(out, open('conf/i18n.compiled.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=1) print('[i18n] langs=%s keys=%s' % (list(out), sum(len(v) for v in out.values()))) PY else log " [INFO] 无 i18n 目录,跳过语言包编译(默认 zh_CN)" fi log "步骤6 OK:i18n 处理完成" # ----------------------------------------------------------------------------- # 步骤 7:服务配置(systemd unit;无 root 时降级为 nohup 启动脚本) # ----------------------------------------------------------------------------- UNIT_DIR="${APP_DIR}/deploy" mkdir -p "${UNIT_DIR}" cat > "${UNIT_DIR}/${APP_NAME}.service" < "${UNIT_DIR}/start.sh" <> "${LOG_DIR}/${APP_NAME}.out" 2>> "${LOG_DIR}/${APP_NAME}.err" & echo \$! > "${APP_DIR}/${APP_NAME}.pid" echo "started pid=\$(cat ${APP_DIR}/${APP_NAME}.pid) port=${APP_PORT}" START chmod +x "${UNIT_DIR}/start.sh" log "步骤7 OK:服务配置生成(${UNIT_DIR}/${APP_NAME}.service + start.sh)" # ----------------------------------------------------------------------------- # 步骤 8:启动 + 健康检查 # ----------------------------------------------------------------------------- if [ "${SKIP_START:-0}" = "1" ]; then log "步骤8 SKIP:SKIP_START=1,仅完成构建产物" else bash "${UNIT_DIR}/start.sh" || fail "启动失败" ok=0 for i in $(seq 1 30); do sleep 2 if python - <