Files
ctms/scripts/install.sh
T

518 lines
20 KiB
Bash
Executable File
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
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 <<EOF
${C_BOLD}用法:${C_RESET}
bash scripts/install.sh ${C_CYAN}<环境>${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 <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 "初始化数据库与管理员账号"
if [[ "$SKIP_BUILD" -eq 1 ]]; then
ctms_run_quiet "运行 backend-init 容器" -- compose_cmd run --rm backend-init
else
# backend-init 是 profile 服务,不会被后续 `compose up --build` 自动构建。
# 更新部署时必须先基于当前代码重建,否则会复用旧镜像并缺失新 Alembic revision。
CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建并运行 backend-init 容器" -- compose_cmd run --rm --build backend-init
fi
}
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 "$@"