#!/usr/bin/env bash set -euo pipefail # ───────────────────────────────────────────── # CTMS 一键安装脚本 # 复用 scripts/common.sh 的共享库(颜色、UI、compose、依赖、.env)。 # ───────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/common.sh source "$SCRIPT_DIR/common.sh" TARGET_ENV="" BASE_URL="" ASSUME_YES=0 SKIP_BUILD=0 SKIP_MIGRATE=0 VERBOSE=0 ROOT_DIR="$CTMS_ROOT_DIR" ENV_FILE="$CTMS_ENV_FILE" # ── Banner ─────────────────────────────────── _banner() { local b="${CC_PRIMARY}${C_BOLD}" # 边框主色 local a="${CC_INFO}" # 字形填充色 local r="${C_RESET}" printf '\n' printf '%s╔══════════════════════════════════════════════╗%s\n' "$b" "$r" printf '%s║%s ██████╗████████╗███╗ ███╗███████╗ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ██╔════╝╚══██╔══╝████╗ ████║██╔════╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ██║ ██║ ██╔████╔██║███████╗ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ██║ ██║ ██║╚██╔╝██║╚════██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ╚██████╗ ██║ ██║ ╚═╝ ██║███████║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s 临床试验管理系统 · 安装部署程序 %s║%s\n' "$b" "${CC_MUTED}" "${CC_PRIMARY}" "$r" printf '%s╚══════════════════════════════════════════════╝%s\n' "$b" "$r" printf '\n' } # ── 简短别名(统一走 common 的 ctms_*) ──────── log() { ctms_info "$@"; } ok() { ctms_ok "$@"; } warn() { ctms_warn "$@"; } fail() { ctms_fail "$@"; } step() { ctms_step "$@"; } # 当前环境的 compose 调用 compose_cmd() { ctms_compose "$TARGET_ENV" "$@" } # ── 帮助 ───────────────────────────────────── usage() { cat <${C_RESET} [选项] ${C_BOLD}环境:${C_RESET} ${C_CYAN}dev${C_RESET} 本地开发环境(使用默认密钥,不生成 RSA 密钥对) 前端通过 Vite dev server 热加载,入口仍为 http://127.0.0.1:8888 ${C_CYAN}main${C_RESET} 内网测试 / 预发布环境 ${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对) ${C_BOLD}选项:${C_RESET} ${C_WHITE}--base-url ${C_RESET} 健康检查使用的访问地址 dev/main 默认 http://127.0.0.1:8888 release 必须通过此参数或交互输入提供 ${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行 ${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d) ${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head) ${C_WHITE}--verbose${C_RESET} 展开所有命令的实时输出(默认折叠为 spinner) ${C_WHITE}-h, --help${C_RESET} 显示此帮助信息 ${C_BOLD}示例:${C_RESET} bash scripts/install.sh dev bash scripts/install.sh main --base-url http://127.0.0.1:8888 bash scripts/install.sh release --base-url https://ctms.example.com --yes EOF } # ── 参数解析 ───────────────────────────────── parse_args() { if [[ $# -eq 0 ]]; then usage exit 1 fi TARGET_ENV="$1" shift case "$TARGET_ENV" in dev|main|release) ;; -h|--help) usage; exit 0 ;; *) usage fail "未知环境 \"$TARGET_ENV\",有效值为 dev、main 或 release" ;; esac while [[ $# -gt 0 ]]; do case "$1" in --base-url) [[ $# -ge 2 ]] || fail "--base-url 需要提供一个 URL 参数" BASE_URL="$2" shift 2 ;; --yes) ASSUME_YES=1; shift ;; --skip-build) SKIP_BUILD=1; shift ;; --skip-migrate) SKIP_MIGRATE=1; shift ;; --verbose) VERBOSE=1; shift ;; -h|--help) usage; exit 0 ;; *) usage fail "未知参数: $1" ;; esac done } # ── 依赖检查 ───────────────────────────────── check_dependencies() { step "检查系统依赖" ctms_require_dependencies "$TARGET_ENV" ok "系统依赖检查通过" } # ── 环境参数计算 ────────────────────────────── compose_project_name() { ctms_project_name "$TARGET_ENV"; } app_env() { ctms_app_env "$TARGET_ENV"; } key_id() { ctms_key_id "$TARGET_ENV"; } default_base_url() { ctms_default_base_url "$TARGET_ENV"; } # ── .env 读写(复用 common) ────────────────── read_env_value() { ctms_read_env_value "$@"; } resolve_effective_config() { local desired_project_name="$1" local desired_runtime_env="$2" local desired_login_key_id="$3" EFFECTIVE_PROJECT_NAME="$desired_project_name" EFFECTIVE_RUNTIME_ENV="$desired_runtime_env" EFFECTIVE_LOGIN_KEY_ID="$desired_login_key_id" [[ -f "$ENV_FILE" ]] || return 0 local env_project_name env_runtime_env env_login_key_id env_project_name="$(read_env_value COMPOSE_PROJECT_NAME || true)" env_runtime_env="$(read_env_value ENV || true)" env_login_key_id="$(read_env_value LOGIN_RSA_KEY_ID || true)" # 身份类“冲突”才致命(防止覆盖后孤立原环境的容器);“缺失”留给 prepare_env_file 补全。 if [[ -n "$env_project_name" && "$env_project_name" != "$desired_project_name" ]]; then fail ".env 的 COMPOSE_PROJECT_NAME=$env_project_name 与目标环境 $TARGET_ENV 期望值 $desired_project_name 不一致;如需切换环境请先卸载旧环境或手动清理 .env" fi if [[ -n "$env_runtime_env" && "$env_runtime_env" != "$desired_runtime_env" ]]; then fail ".env 的 ENV=$env_runtime_env 与目标环境 $TARGET_ENV 期望值 $desired_runtime_env 不一致;如需切换环境请先卸载旧环境或手动清理 .env" fi # 仅采用 .env 中实际存在的值,缺失项保留目标默认(由 prepare_env_file 写回)。 [[ -n "$env_project_name" ]] && EFFECTIVE_PROJECT_NAME="$env_project_name" [[ -n "$env_runtime_env" ]] && EFFECTIVE_RUNTIME_ENV="$env_runtime_env" [[ -n "$env_login_key_id" ]] && EFFECTIVE_LOGIN_KEY_ID="$env_login_key_id" } # 生成单行转义 PEM:行间用 \n 连接,末尾不追加多余 \n generate_escaped_private_key() { openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null \ | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }' } generate_jwt_secret() { openssl rand -hex 32 } # ── 地址解析 ───────────────────────────────── resolve_base_url() { [[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)" if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then printf ' %s▸%s 请输入 release 健康检查域名(例如 https://ctms.example.com): ' \ "${C_YELLOW}${C_BOLD}" "${C_RESET}" read -r BASE_URL || true fi [[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址" if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then fail "release 环境的访问地址必须使用 HTTPS" fi } # ── 安装确认 ───────────────────────────────── confirm_install() { local project_name="$1" local runtime_env="$2" step "确认安装参数" local env_status build_status migrate_status pg_status env_status="$([[ -f "$ENV_FILE" ]] && printf '已存在' || printf '新建')" build_status="$([[ "$SKIP_BUILD" -eq 1 ]] && printf '跳过' || printf '执行')" migrate_status="$([[ "$SKIP_MIGRATE" -eq 1 ]] && printf '跳过' || printf '执行')" pg_status="$([[ -d "$ROOT_DIR/pg_data" ]] && printf '已存在' || printf '新建')" local w=66 local row row() { local label="$1" value="$2" vcolor="${3:-$CC_TEXT}" local padded padded="$(ctms_pad_right "$label" 14)" ctms_box_line "${CC_PRIMARY}${C_BOLD}" "$w" \ "${padded}${value}" \ "${CC_MUTED}${padded}${C_RESET}${vcolor}${value}${C_RESET}" } printf '\n' ctms_box_top "${CC_PRIMARY}${C_BOLD}" "安装参数预览" "$w" row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}" row "运行模式" "$runtime_env" row "Compose 项目" "$project_name" row "健康检查地址" "$BASE_URL" "${CC_INFO}" row ".env 文件" "$env_status" row "pg_data 目录" "$pg_status" row "镜像构建" "$build_status" row "数据库迁移" "$migrate_status" ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w" if [[ "$ASSUME_YES" -eq 1 ]]; then log "已启用 --yes,跳过确认" return fi printf '\n %s%s%s 确认以上参数并继续安装?输入 %syes%s 继续: ' \ "${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_BOLD}" "${C_RESET}" local answer read -r answer || answer="" [[ "$answer" == "yes" ]] || fail "用户取消安装" } # ── .env 准备 ───────────────────────────────── sync_frontend_build_env() { local runtime_env="$1" local allow_insecure_dev_login="false" [[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true" ctms_upsert_env_value VITE_RUNTIME_ENV "$runtime_env" ctms_upsert_env_value VITE_ALLOW_INSECURE_DEV_LOGIN "$allow_insecure_dev_login" ok "前端构建环境变量已同步" } prepare_env_file() { step "准备环境配置文件" local project_name="$1" runtime_env="$2" login_key_id="$3" local jwt_secret="" rsa_private_key="" # 首次安装:无 .env,写全量。 if [[ ! -f "$ENV_FILE" ]]; then if [[ "$TARGET_ENV" == "dev" ]]; then jwt_secret="dev-secret" rsa_private_key="" else jwt_secret="$(generate_jwt_secret)" ok "已生成新的 JWT_SECRET_KEY" log "正在生成 RSA 2048 密钥对,请稍候…" rsa_private_key="$(generate_escaped_private_key)" ok "已生成新的 LOGIN_RSA_PRIVATE_KEY" fi ctms_write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key" ok ".env 写入完成" return fi # 已有 .env:逐项探测,缺则补全,已有值一律不动(身份类冲突已在 resolve_effective_config 拦截)。 local repaired=0 # 身份类:缺失补目标值 if ! ctms_env_has_key COMPOSE_PROJECT_NAME; then ctms_upsert_env_value COMPOSE_PROJECT_NAME "$project_name"; repaired=1 warn ".env 缺少 COMPOSE_PROJECT_NAME,已补为 $project_name" fi if ! ctms_env_has_key ENV; then ctms_upsert_env_value ENV "$runtime_env"; repaired=1 warn ".env 缺少 ENV,已补为 $runtime_env" fi if ! ctms_env_has_key LOGIN_RSA_KEY_ID; then ctms_upsert_env_value LOGIN_RSA_KEY_ID "$login_key_id"; repaired=1 warn ".env 缺少 LOGIN_RSA_KEY_ID,已补为 $login_key_id" fi # JWT 密钥:缺失或空才补(保留已有值,避免使现有会话失效) if [[ -z "$(read_env_value JWT_SECRET_KEY || true)" ]]; then if [[ "$TARGET_ENV" == "dev" ]]; then jwt_secret="dev-secret" else jwt_secret="$(generate_jwt_secret)" fi ctms_upsert_env_value JWT_SECRET_KEY "$jwt_secret"; repaired=1 warn ".env 缺少 JWT_SECRET_KEY,已补全" fi # RSA 私钥:非 dev 缺失或空才生成;dev 仅确保键存在(允许空值) if [[ -z "$(read_env_value LOGIN_RSA_PRIVATE_KEY || true)" ]]; then if [[ "$TARGET_ENV" != "dev" ]]; then log "正在生成 RSA 2048 密钥对,请稍候…" rsa_private_key="$(generate_escaped_private_key)" ctms_upsert_env_value LOGIN_RSA_PRIVATE_KEY "$rsa_private_key"; repaired=1 warn ".env 缺少 LOGIN_RSA_PRIVATE_KEY,已生成新密钥" elif ! ctms_env_has_key LOGIN_RSA_PRIVATE_KEY; then ctms_upsert_env_value LOGIN_RSA_PRIVATE_KEY ""; repaired=1 fi fi if [[ "$repaired" -eq 1 ]]; then ok ".env 已补全缺失项" else ok "检测到完整 .env,沿用现有配置" fi } # ── Docker 流程 ─────────────────────────────── run_compose_config() { step "校验 Docker Compose 配置" ctms_run_quiet "Docker Compose 配置语法校验" -- compose_cmd config } run_backend_init() { step "初始化数据库与管理员账号" ctms_run_quiet "运行 backend-init 容器" -- compose_cmd run --rm backend-init } run_build_and_start() { if [[ "$SKIP_BUILD" -eq 1 ]]; then step "启动服务(跳过镜像构建)" if [[ "$TARGET_ENV" == "dev" ]]; then ctms_run_quiet "启动开发容器并刷新后端/Nginx/Vite" -- compose_cmd up -d --force-recreate backend nginx frontend-dev else ctms_run_quiet "启动容器" -- compose_cmd up -d fi else step "构建镜像并启动服务" if [[ "$TARGET_ENV" == "dev" ]]; then CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend nginx frontend-dev else CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建镜像并启动容器(首次较慢)" -- compose_cmd up -d --build fi fi } run_migrations() { if [[ "$SKIP_MIGRATE" -eq 1 ]]; then log "已跳过数据库迁移(--skip-migrate)" return fi step "执行数据库迁移" ctms_run_quiet "Alembic upgrade head" -- compose_cmd run --rm backend python -m alembic upgrade head } # ── 健康检查 ───────────────────────────────── check_container_status() { step "检查容器运行状态" local running running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)" local services=(db backend nginx) [[ "$TARGET_ENV" != "dev" ]] || services+=(frontend-dev) local svc for svc in "${services[@]}"; do if printf '%s\n' "$running" | grep -qx "$svc"; then printf ' %s%s%s %-12s %s运行中%s\n' "${CC_SUCCESS}${C_BOLD}" "$ICON_RUNNING" "${C_RESET}" "$svc" "${CC_SUCCESS}" "${C_RESET}" else printf ' %s%s%s %-12s %s未运行%s\n' "${CC_DANGER}${C_BOLD}" "$ICON_STOPPED" "${C_RESET}" "$svc" "${CC_DANGER}" "${C_RESET}" fail "$svc 容器未正常运行,请执行 'bash scripts/install.sh $TARGET_ENV --skip-build --skip-migrate --verbose' 或对应 compose ps 命令排查" fi done } check_backend_environment() { step "校验后端运行时环境变量" local expected_env="$1" expected_key_id="$2" expect_rsa="$3" local check_code check_code=$(cat <<'PY' import os, sys from app.core.config import settings from app.core.login_crypto import _normalize_pem expected_env = os.environ["EXPECTED_ENV"] expected_key_id = os.environ["EXPECTED_KEY_ID"] expect_rsa = os.environ["EXPECT_RSA"] == "1" if settings.ENV != expected_env: sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}") if settings.LOGIN_RSA_KEY_ID != expected_key_id: sys.exit("LOGIN_RSA_KEY_ID 不匹配") if expected_env == "production" and settings.JWT_SECRET_KEY == "dev-secret": sys.exit("生产环境不允许使用默认 JWT_SECRET_KEY") if expect_rsa: if not settings.LOGIN_RSA_PRIVATE_KEY: sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置") from cryptography.hazmat.primitives import serialization serialization.load_pem_private_key( _normalize_pem(settings.LOGIN_RSA_PRIVATE_KEY).encode("utf-8"), password=None, ) PY ) ctms_run_quiet "执行后端配置自检" -- compose_cmd exec -T \ -e EXPECTED_ENV="$expected_env" \ -e EXPECTED_KEY_ID="$expected_key_id" \ -e EXPECT_RSA="$expect_rsa" \ backend python -c "$check_code" } check_http_endpoint() { local path="$1" local max_attempts="${2:-10}" local url="${BASE_URL%/}$path" log "探测接口: $url" local attempt=1 delay=2 warmup_delay=3 log "等待服务预热 ${warmup_delay}s …" sleep "$warmup_delay" while [[ "$attempt" -le "$max_attempts" ]]; do if curl -fsS "$url" >/dev/null 2>&1; then ok "接口就绪: $path" return 0 fi if [[ "$attempt" -lt "$max_attempts" ]]; then log "接口尚未就绪,${delay}s 后重试(${attempt}/${max_attempts})…" sleep "$delay" fi attempt=$(( attempt + 1 )) done if [[ "$path" == "/api/v1/auth/login-key" ]]; then warn "登录公钥接口无响应,请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否为完整的单行转义 PEM 文本" fi fail "接口探测失败: $url" } check_dev_nginx_mode() { [[ "$TARGET_ENV" == "dev" ]] || return 0 step "校验开发 Nginx 代理配置" if compose_cmd exec -T nginx sh -c 'grep -q "frontend_dev" /etc/nginx/nginx.conf && grep -q "proxy_pass http://frontend_dev" /etc/nginx/nginx.conf' >/dev/null 2>&1; then ok "Nginx 已代理到 Vite dev server" return fi fail "当前 Nginx 未加载开发配置,请重试:bash scripts/install.sh dev --skip-migrate --verbose" } run_health_checks() { local runtime_env="$1" login_key_id="$2" local expect_rsa=0 [[ "$runtime_env" == "production" ]] && expect_rsa=1 check_container_status check_dev_nginx_mode check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa" step "探测 HTTP 接口可用性" check_http_endpoint "/health" check_http_endpoint "/api/v1/auth/login-key" if [[ "$TARGET_ENV" == "dev" ]]; then check_http_endpoint "/" 60 fi ok "HTTP 接口全部就绪" } # ── 完成提示 ───────────────────────────────── show_success() { local w=66 local pa pe pa="$(ctms_pad_right "访问地址" 14)" pe="$(ctms_pad_right "环境模式" 14)" printf '\n' ctms_box_top "${CC_SUCCESS}${C_BOLD}" "$ICON_OK CTMS 安装成功" "$w" ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \ "${pa}${BASE_URL}" \ "${CC_MUTED}${pa}${C_RESET}${CC_INFO}${BASE_URL}${C_RESET}" ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \ "${pe}${TARGET_ENV}" \ "${CC_MUTED}${pe}${C_RESET}${CC_TEXT}${TARGET_ENV}${C_RESET}" ctms_box_bottom "${CC_SUCCESS}${C_BOLD}" "$w" printf '\n' } # ── 入口 ───────────────────────────────────── main() { _banner parse_args "$@" cd "$ROOT_DIR" local project_name runtime_env login_key_id project_name="$(compose_project_name)" runtime_env="$(app_env)" login_key_id="$(key_id)" resolve_base_url resolve_effective_config "$project_name" "$runtime_env" "$login_key_id" check_dependencies confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV" prepare_env_file "$project_name" "$runtime_env" "$login_key_id" sync_frontend_build_env "$runtime_env" # 健康检查地址持久化到 .env(单一事实来源);独立 upsert,绕开 prepare_env_file 对已存在 .env 的跳过。 ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL" run_compose_config run_backend_init run_build_and_start run_migrations resolve_effective_config "$project_name" "$runtime_env" "$login_key_id" run_health_checks "$EFFECTIVE_RUNTIME_ENV" "$EFFECTIVE_LOGIN_KEY_ID" show_success } main "$@"