242 lines
10 KiB
Bash
242 lines
10 KiB
Bash
#!/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
|
||
[Unit]
|
||
Description=PBL Agent OS (${APP_NAME}) - env ${ENV_NAME}
|
||
After=network.target mysql.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=${SUDO_USER:-pipeline}
|
||
WorkingDirectory=${APP_DIR}
|
||
Environment=PBLS_ENV=${ENV_NAME}
|
||
Environment=PBLS_DBNAME=${DB_NAME}
|
||
Environment=PYTHONPATH=${APP_DIR}/app:${MOD_DIR}
|
||
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/app/${APP_NAME}.py
|
||
Restart=always
|
||
RestartSec=5
|
||
StandardOutput=append:${LOG_DIR}/${APP_NAME}.out
|
||
StandardError=append:${LOG_DIR}/${APP_NAME}.err
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
UNIT
|
||
|
||
cat > "${UNIT_DIR}/start.sh" <<START
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
cd "${APP_DIR}"
|
||
export PBLS_ENV="${ENV_NAME}" PBLS_DBNAME="${DB_NAME}"
|
||
export PYTHONPATH="${APP_DIR}/app:${MOD_DIR}"
|
||
nohup "${VENV_DIR}/bin/python" "${APP_DIR}/app/${APP_NAME}.py" \\
|
||
>> "${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 - <<PY
|
||
import socket,sys
|
||
s=socket.socket(); s.settimeout(2)
|
||
sys.exit(0 if s.connect_ex(('127.0.0.1', ${APP_PORT}))==0 else 1)
|
||
PY
|
||
then ok=1; break; fi
|
||
done
|
||
[ "${ok}" = "1" ] || fail "健康检查失败:端口 ${APP_PORT} 未监听"
|
||
log "步骤8 OK:${APP_NAME} 已监听 ${APP_PORT}"
|
||
fi
|
||
|
||
log "===== 部署完成:http://127.0.0.1:${APP_PORT}/ (env=${ENV_NAME})====="
|