622 lines
22 KiB
Bash
Executable File
622 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# ─────────────────────────────────────────────
|
||
# CTMS 一键安装脚本
|
||
# ─────────────────────────────────────────────
|
||
|
||
TARGET_ENV=""
|
||
BASE_URL=""
|
||
ASSUME_YES=0
|
||
SKIP_BUILD=0
|
||
SKIP_MIGRATE=0
|
||
VERBOSE=0
|
||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
ENV_FILE="$ROOT_DIR/.env"
|
||
LOG_DIR="$ROOT_DIR/.install-logs"
|
||
|
||
# ── 颜色 & 样式 ──────────────────────────────
|
||
_has_color() { [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null)" -ge 8 ]]; }
|
||
|
||
if _has_color; then
|
||
C_RESET="$(tput sgr0)"
|
||
C_BOLD="$(tput bold)"
|
||
C_DIM="$(tput dim 2>/dev/null || printf '')"
|
||
C_BLUE="$(tput setaf 4)"
|
||
C_CYAN="$(tput setaf 6)"
|
||
C_GREEN="$(tput setaf 2)"
|
||
C_YELLOW="$(tput setaf 3)"
|
||
C_RED="$(tput setaf 1)"
|
||
C_WHITE="$(tput setaf 7)"
|
||
else
|
||
C_RESET="" C_BOLD="" C_DIM="" C_BLUE="" C_CYAN=""
|
||
C_GREEN="" C_YELLOW="" C_RED="" C_WHITE=""
|
||
fi
|
||
|
||
# ── 输出函数 ─────────────────────────────────
|
||
|
||
_banner() {
|
||
printf '\n'
|
||
printf '%s╔══════════════════════════════════════════════╗%s\n' "${C_BLUE}${C_BOLD}" "${C_RESET}"
|
||
printf '%s║%s ██████╗████████╗███╗ ███╗███████╗ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
|
||
printf '%s║%s ██╔════╝╚══██╔══╝████╗ ████║██╔════╝ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
|
||
printf '%s║%s ██║ ██║ ██╔████╔██║███████╗ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
|
||
printf '%s║%s ██║ ██║ ██║╚██╔╝██║╚════██║ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
|
||
printf '%s║%s ╚██████╗ ██║ ██║ ╚═╝ ██║███████║ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
|
||
printf '%s║%s ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
|
||
printf '%s║%s 临床试验管理系统 安装程序 %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_DIM}${C_WHITE}" "${C_BLUE}" "${C_RESET}"
|
||
printf '%s╚══════════════════════════════════════════════╝%s\n' "${C_BLUE}${C_BOLD}" "${C_RESET}"
|
||
printf '\n'
|
||
}
|
||
|
||
log() { printf ' %s·%s %s\n' "${C_DIM}" "${C_RESET}" "$*"; }
|
||
ok() { printf ' %s✔%s %s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" "$*"; }
|
||
warn() { printf ' %s⚠%s %s\n' "${C_YELLOW}${C_BOLD}" "${C_RESET}" "$*" >&2; }
|
||
|
||
step() {
|
||
local idx="${_STEP_IDX:-0}"
|
||
_STEP_IDX=$(( idx + 1 ))
|
||
printf '\n%s┌─ 步骤 %s · %s%s\n' \
|
||
"${C_BLUE}${C_BOLD}" "$_STEP_IDX" "$*" "${C_RESET}"
|
||
}
|
||
|
||
fail() {
|
||
printf '\n %s✖ 安装中止%s\n' "${C_RED}${C_BOLD}" "${C_RESET}" >&2
|
||
printf ' %s%s%s\n\n' "${C_RED}" "$*" "${C_RESET}" >&2
|
||
exit 1
|
||
}
|
||
|
||
run() {
|
||
printf ' %s$%s %s%s%s\n' "${C_DIM}" "${C_RESET}" "${C_WHITE}" "$*" "${C_RESET}"
|
||
"$@"
|
||
}
|
||
|
||
compose_cmd() {
|
||
if [[ "$TARGET_ENV" == "dev" ]]; then
|
||
docker compose -f "$ROOT_DIR/docker-compose.dev.yaml" "$@"
|
||
else
|
||
docker compose "$@"
|
||
fi
|
||
}
|
||
|
||
# 运行长命令,默认折叠输出。失败或 --verbose 时回放完整日志。
|
||
# 用法: run_quiet <提示语> -- <命令...>
|
||
run_quiet() {
|
||
local label="$1"; shift
|
||
[[ "${1:-}" == "--" ]] && shift
|
||
|
||
if [[ "$VERBOSE" -eq 1 ]]; then
|
||
printf ' %s$%s %s%s%s\n' "${C_DIM}" "${C_RESET}" "${C_WHITE}" "$*" "${C_RESET}"
|
||
"$@"
|
||
return $?
|
||
fi
|
||
|
||
mkdir -p "$LOG_DIR"
|
||
local log_file
|
||
log_file="$(mktemp "$LOG_DIR/cmd_XXXXXXXX")"
|
||
local start_ts elapsed exit_code
|
||
start_ts="$(date +%s)"
|
||
|
||
set +e
|
||
if [[ -t 1 ]]; then
|
||
"$@" >"$log_file" 2>&1 &
|
||
local cmd_pid=$!
|
||
_spinner_for "$cmd_pid" "$label" "$start_ts" &
|
||
local spin_pid=$!
|
||
wait "$cmd_pid"
|
||
exit_code=$?
|
||
kill "$spin_pid" 2>/dev/null || true
|
||
wait "$spin_pid" 2>/dev/null || true
|
||
printf '\r\033[K'
|
||
else
|
||
printf ' · %s …\n' "$label"
|
||
"$@" >"$log_file" 2>&1
|
||
exit_code=$?
|
||
fi
|
||
set -e
|
||
|
||
elapsed=$(( $(date +%s) - start_ts ))
|
||
if [[ "$exit_code" -eq 0 ]]; then
|
||
printf ' %s✔%s %s %s(%ss)%s\n' \
|
||
"${C_GREEN}${C_BOLD}" "${C_RESET}" "$label" "${C_DIM}" "$elapsed" "${C_RESET}"
|
||
return 0
|
||
fi
|
||
|
||
printf '\n %s✖%s %s 失败(耗时 %ss),完整输出:\n\n' \
|
||
"${C_RED}${C_BOLD}" "${C_RESET}" "$label" "$elapsed" >&2
|
||
sed 's/^/ /' "$log_file" >&2
|
||
printf '\n' >&2
|
||
fail "命令执行失败,详细日志保留于:${log_file#$ROOT_DIR/}(追加 --verbose 可看实时输出)"
|
||
}
|
||
|
||
_spinner_for() {
|
||
local cmd_pid="$1" label="$2" start_ts="$3"
|
||
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||
local i=0
|
||
while kill -0 "$cmd_pid" 2>/dev/null; do
|
||
local elapsed=$(( $(date +%s) - start_ts ))
|
||
printf '\r\033[K %s%s%s %s %s(%ss)%s' \
|
||
"${C_CYAN}${C_BOLD}" "${frames[i]}" "${C_RESET}" \
|
||
"$label" "${C_DIM}" "$elapsed" "${C_RESET}"
|
||
i=$(( (i + 1) % ${#frames[@]} ))
|
||
sleep 0.1
|
||
done
|
||
}
|
||
|
||
# ── 帮助 ─────────────────────────────────────
|
||
|
||
usage() {
|
||
cat <<EOF
|
||
${C_BOLD}用法:${C_RESET}
|
||
bash scripts/install-ctms.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-ctms.sh dev
|
||
bash scripts/install-ctms.sh main --base-url http://127.0.0.1:8888
|
||
bash scripts/install-ctms.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
|
||
}
|
||
|
||
# ── 依赖检查 ─────────────────────────────────
|
||
|
||
_require_command() {
|
||
local cmd="$1"
|
||
local hint="$2"
|
||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||
warn "缺少依赖: ${C_BOLD}${cmd}${C_RESET}"
|
||
warn "安装方式: $hint"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
check_dependencies() {
|
||
step "检查系统依赖"
|
||
local missing=0
|
||
_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
|
||
_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
|
||
_require_command curl "Linux: sudo apt-get install -y curl | macOS: brew install curl" || missing=1
|
||
[[ "$missing" -eq 0 ]] || fail "系统依赖不完整,请按上方提示安装后重试"
|
||
|
||
if ! docker compose version >/dev/null 2>&1; then
|
||
fail "未检测到 Docker Compose v2,请确认 docker compose version 可正常执行"
|
||
fi
|
||
ok "系统依赖检查通过"
|
||
}
|
||
|
||
# ── 环境参数计算 ──────────────────────────────
|
||
|
||
compose_project_name() {
|
||
case "$TARGET_ENV" in
|
||
dev) printf 'ctms_dev' ;;
|
||
main) printf 'ctms_main' ;;
|
||
release) printf 'ctms_release' ;;
|
||
esac
|
||
}
|
||
|
||
app_env() {
|
||
[[ "$TARGET_ENV" == "dev" ]] && printf 'development' || printf 'production'
|
||
}
|
||
|
||
key_id() {
|
||
local today
|
||
today="$(date +%Y%m%d)"
|
||
case "$TARGET_ENV" in
|
||
dev) printf 'default' ;;
|
||
main) printf 'main-%s' "$today" ;;
|
||
release) printf 'release-%s' "$today" ;;
|
||
esac
|
||
}
|
||
|
||
default_base_url() {
|
||
case "$TARGET_ENV" in
|
||
dev|main) printf 'http://127.0.0.1:8888' ;;
|
||
release) printf '' ;;
|
||
esac
|
||
}
|
||
|
||
# ── .env 读写 ─────────────────────────────────
|
||
|
||
read_env_value() {
|
||
local key="$1"
|
||
[[ -f "$ENV_FILE" ]] || return 0
|
||
awk -v key="$key" '
|
||
BEGIN { prefix = key "=" }
|
||
index($0, prefix) == 1 {
|
||
value = substr($0, length(prefix) + 1)
|
||
if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) {
|
||
value = substr(value, 2, length(value) - 2)
|
||
}
|
||
print value
|
||
exit
|
||
}
|
||
' "$ENV_FILE"
|
||
}
|
||
|
||
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)"
|
||
|
||
[[ -n "$env_project_name" ]] || fail ".env 缺少 COMPOSE_PROJECT_NAME,请补齐后重试"
|
||
[[ -n "$env_runtime_env" ]] || fail ".env 缺少 ENV,请补齐后重试"
|
||
[[ -n "$env_login_key_id" ]] || fail ".env 缺少 LOGIN_RSA_KEY_ID,请补齐后重试"
|
||
|
||
if [[ "$env_project_name" != "$desired_project_name" ]]; then
|
||
fail ".env 的 COMPOSE_PROJECT_NAME=$env_project_name 与目标环境 $TARGET_ENV 期望值 $desired_project_name 不一致"
|
||
fi
|
||
if [[ "$env_runtime_env" != "$desired_runtime_env" ]]; then
|
||
fail ".env 的 ENV=$env_runtime_env 与目标环境 $TARGET_ENV 期望值 $desired_runtime_env 不一致"
|
||
fi
|
||
|
||
EFFECTIVE_PROJECT_NAME="$env_project_name"
|
||
EFFECTIVE_RUNTIME_ENV="$env_runtime_env"
|
||
EFFECTIVE_LOGIN_KEY_ID="$env_login_key_id"
|
||
}
|
||
|
||
generate_escaped_private_key() {
|
||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null \
|
||
| awk '{printf "%s\\n", $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
|
||
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
|
||
env_status="$([[ -f "$ENV_FILE" ]] && printf '已存在' || printf '新建')"
|
||
build_status="$([[ "$SKIP_BUILD" -eq 1 ]] && printf '跳过' || printf '执行')"
|
||
migrate_status="$([[ "$SKIP_MIGRATE" -eq 1 ]] && printf '跳过' || printf '执行')"
|
||
|
||
printf '\n'
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" "目标环境" "${C_RESET}" "$TARGET_ENV"
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" "运行模式" "${C_RESET}" "$runtime_env"
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" "Compose 项目" "${C_RESET}" "$project_name"
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" "健康检查地址" "${C_RESET}" "$BASE_URL"
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" ".env 文件" "${C_RESET}" "$env_status"
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" "pg_data 目录" "${C_RESET}" "$([[ -d "$ROOT_DIR/pg_data" ]] && printf '已存在' || printf '新建')"
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" "镜像构建" "${C_RESET}" "$build_status"
|
||
printf ' %s%-18s%s %s\n' "${C_BOLD}" "数据库迁移" "${C_RESET}" "$migrate_status"
|
||
printf '\n'
|
||
|
||
if [[ "$ASSUME_YES" -eq 1 ]]; then
|
||
log "已启用 --yes,跳过确认"
|
||
return
|
||
fi
|
||
|
||
printf ' %s▸%s 确认以上参数并继续安装?输入 %syes%s 继续: ' \
|
||
"${C_YELLOW}${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
|
||
local answer
|
||
read -r answer
|
||
[[ "$answer" == "yes" ]] || fail "用户取消安装"
|
||
}
|
||
|
||
# ── .env 写入 ─────────────────────────────────
|
||
|
||
write_env_file() {
|
||
local project_name="$1" runtime_env="$2" login_key_id="$3"
|
||
local jwt_secret="$4" rsa_private_key="$5"
|
||
local temp_file
|
||
temp_file="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")"
|
||
|
||
{
|
||
printf 'COMPOSE_PROJECT_NAME=%s\n' "$project_name"
|
||
printf 'ENV=%s\n' "$runtime_env"
|
||
printf 'VITE_RUNTIME_ENV=%s\n' "$runtime_env"
|
||
printf 'VITE_ALLOW_INSECURE_DEV_LOGIN=%s\n' "$([[ "$runtime_env" == "development" ]] && printf 'true' || printf 'false')"
|
||
printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret"
|
||
printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id"
|
||
printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$rsa_private_key"
|
||
} >> "$temp_file"
|
||
|
||
mv "$temp_file" "$ENV_FILE"
|
||
ok ".env 写入完成"
|
||
}
|
||
|
||
upsert_env_value() {
|
||
local key="$1" value="$2"
|
||
local temp_file
|
||
temp_file="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")"
|
||
if [[ -f "$ENV_FILE" && -n "$(read_env_value "$key" || true)" ]]; then
|
||
awk -v key="$key" -v value="$value" '
|
||
BEGIN { prefix = key "=" }
|
||
index($0, prefix) == 1 { print key "=" value; next }
|
||
{ print }
|
||
' "$ENV_FILE" > "$temp_file"
|
||
else
|
||
[[ -f "$ENV_FILE" ]] && cat "$ENV_FILE" > "$temp_file"
|
||
printf '%s=%s\n' "$key" "$value" >> "$temp_file"
|
||
fi
|
||
mv "$temp_file" "$ENV_FILE"
|
||
}
|
||
|
||
sync_frontend_build_env() {
|
||
local runtime_env="$1"
|
||
local allow_insecure_dev_login="false"
|
||
[[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true"
|
||
|
||
upsert_env_value VITE_RUNTIME_ENV "$runtime_env"
|
||
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=""
|
||
|
||
if [[ -f "$ENV_FILE" ]]; then
|
||
ok "检测到现有 .env,保留原文件并跳过写入"
|
||
return
|
||
fi
|
||
|
||
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
|
||
|
||
write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key"
|
||
}
|
||
|
||
# ── Docker 流程 ───────────────────────────────
|
||
|
||
run_compose_config() {
|
||
step "校验 Docker Compose 配置"
|
||
run_quiet "Docker Compose 配置语法校验" -- compose_cmd config
|
||
}
|
||
|
||
run_backend_init() {
|
||
[[ "$TARGET_ENV" != "dev" ]] || return 0
|
||
step "初始化数据库与管理员账号"
|
||
run_quiet "运行 backend-init 容器" -- compose_cmd run --rm backend-init
|
||
}
|
||
|
||
run_build_and_start() {
|
||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||
step "启动服务(跳过镜像构建)"
|
||
run_quiet "启动容器" -- compose_cmd up -d
|
||
else
|
||
step "构建镜像并启动服务"
|
||
run_quiet "构建镜像并启动容器(首次较慢)" -- compose_cmd up -d --build
|
||
fi
|
||
}
|
||
|
||
run_migrations() {
|
||
if [[ "$SKIP_MIGRATE" -eq 1 ]]; then
|
||
log "已跳过数据库迁移(--skip-migrate)"
|
||
return
|
||
fi
|
||
step "执行数据库迁移"
|
||
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)
|
||
for svc in "${services[@]}"; do
|
||
if printf '%s\n' "$running" | grep -qx "$svc"; then
|
||
printf ' %s✔%s %-8s 运行中\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" "$svc"
|
||
else
|
||
printf ' %s✖%s %-8s %s未运行%s\n' "${C_RED}${C_BOLD}" "${C_RESET}" "$svc" "${C_RED}" "${C_RESET}"
|
||
fail "$svc 容器未正常运行,请执行 'bash scripts/install-ctms.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
|
||
)
|
||
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 url="${BASE_URL%/}$path"
|
||
log "探测接口: $url"
|
||
local attempt=1 max_attempts=10 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"
|
||
}
|
||
|
||
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_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
|
||
step "探测 HTTP 接口可用性"
|
||
check_http_endpoint "/health"
|
||
check_http_endpoint "/api/v1/auth/login-key"
|
||
ok "HTTP 接口全部就绪"
|
||
}
|
||
|
||
# ── 完成提示 ─────────────────────────────────
|
||
|
||
show_success() {
|
||
printf '\n'
|
||
printf '%s┌──────────────────────────────────────────────┐%s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}"
|
||
printf '%s│%s ✔ CTMS 安装成功 %s│%s\n' "${C_GREEN}${C_BOLD}" "${C_GREEN}" "${C_GREEN}${C_BOLD}" "${C_RESET}"
|
||
printf '%s└──────────────────────────────────────────────┘%s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}"
|
||
printf '\n'
|
||
printf ' %s访问地址%s %s%s%s\n' "${C_BOLD}" "${C_RESET}" "${C_CYAN}" "$BASE_URL" "${C_RESET}"
|
||
printf ' %s环境模式%s %s\n' "${C_BOLD}" "${C_RESET}" "$TARGET_ENV"
|
||
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"
|
||
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 "$@"
|