#!/usr/bin/env bash set -euo pipefail TARGET_ENV="" BASE_URL="" ASSUME_YES=0 SKIP_BUILD=0 SKIP_MIGRATE=0 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="$ROOT_DIR/.env" usage() { cat <<'EOF' 用法: bash scripts/install-ctms.sh dev|main|release [选项] 选项: --base-url 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。 --yes 跳过交互确认,适合自动化执行。 --skip-build 跳过 docker compose up -d --build。 --skip-migrate 跳过 alembic upgrade head。 -h, --help 显示帮助。 示例: 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 EOF } log() { printf '[CTMS] %s\n' "$*" } step() { printf '\n[CTMS] >>> %s\n' "$*" } fail() { printf '\n[CTMS] 安装中止: %s\n' "$*" >&2 exit 1 } run() { log "执行: $*" "$@" } 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 "目标环境必须是 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 ;; -h|--help) usage exit 0 ;; *) usage fail "未知参数: $1" ;; esac done } require_command() { local command_name="$1" local install_hint="$2" if ! command -v "$command_name" >/dev/null 2>&1; then printf '[CTMS] 缺少依赖: %s\n' "$command_name" >&2 printf '[CTMS] 安装建议: %s\n' "$install_hint" >&2 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 if [[ "$missing" -ne 0 ]]; then fail "系统依赖不完整" fi if ! docker compose version >/dev/null 2>&1; then fail "缺少 Docker Compose v2,请确认 docker compose version 可正常执行" fi log "依赖检查通过" } compose_project_name() { case "$TARGET_ENV" in dev) printf 'ctms_dev' ;; main) printf 'ctms_main' ;; release) printf 'ctms_release' ;; esac } app_env() { if [[ "$TARGET_ENV" == "dev" ]]; then printf 'development' else printf 'production' fi } 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 } 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" } normalize_private_key_to_stdout() { local escaped_key="$1" printf '%b' "$escaped_key" } private_key_is_valid() { local escaped_key="$1" [[ -n "$escaped_key" ]] || return 1 if ! normalize_private_key_to_stdout "$escaped_key" | openssl rsa -check -noout >/dev/null 2>&1; then return 1 fi return 0 } 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() { if [[ -z "$BASE_URL" ]]; then BASE_URL="$(default_base_url)" fi if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then read -r -p "请输入 release 健康检查域名,例如 https://ctms.example.com: " BASE_URL fi if [[ -z "$BASE_URL" ]]; then fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址" fi if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then fail "release 的 --base-url 必须使用 HTTPS" fi } confirm_install() { local project_name="$1" local runtime_env="$2" step "确认安装参数" log "目标环境: $TARGET_ENV" log "运行 ENV: $runtime_env" log "Compose 项目名: $project_name" log "健康检查地址: $BASE_URL" log "已有 .env: $([[ -f "$ENV_FILE" ]] && printf '会先备份再重写' || printf '不存在,将创建')" log "已有 pg_data: $([[ -d "$ROOT_DIR/pg_data" ]] && printf '保留' || printf '不存在')" log "构建镜像: $([[ "$SKIP_BUILD" -eq 1 ]] && printf '跳过' || printf '执行')" log "数据库迁移: $([[ "$SKIP_MIGRATE" -eq 1 ]] && printf '跳过' || printf '执行')" if [[ "$ASSUME_YES" -eq 1 ]]; then return fi local answer read -r -p "确认继续安装?输入 yes 继续: " answer [[ "$answer" == "yes" ]] || fail "用户取消" } backup_env_file() { if [[ -f "$ENV_FILE" ]]; then local backup_file backup_file="$ENV_FILE.bak.$(date +%Y%m%d%H%M%S)" cp "$ENV_FILE" "$backup_file" log "已备份现有 .env: $backup_file" fi } write_env_file() { local project_name="$1" local runtime_env="$2" local login_key_id="$3" local jwt_secret="$4" local rsa_private_key="$5" local temp_file temp_file="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")" if [[ -f "$ENV_FILE" ]]; then awk ' BEGIN { prefixes[1] = "COMPOSE_PROJECT_NAME=" prefixes[2] = "ENV=" prefixes[3] = "JWT_SECRET_KEY=" prefixes[4] = "LOGIN_RSA_KEY_ID=" prefixes[5] = "LOGIN_RSA_PRIVATE_KEY=" } { drop = 0 for (i = 1; i <= 5; i++) { if (index($0, prefixes[i]) == 1) { drop = 1 break } } if (!drop) print } ' "$ENV_FILE" > "$temp_file" fi { printf 'COMPOSE_PROJECT_NAME=%s\n' "$project_name" printf 'ENV=%s\n' "$runtime_env" 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" log "已写入 .env" } prepare_env_file() { step "准备 .env" local project_name="$1" local runtime_env="$2" local login_key_id="$3" local jwt_secret="" local rsa_private_key="" if [[ "$TARGET_ENV" == "dev" ]]; then jwt_secret="dev-secret" rsa_private_key="" else jwt_secret="$(read_env_value JWT_SECRET_KEY || true)" if [[ -z "$jwt_secret" || "$jwt_secret" == "dev-secret" ]]; then jwt_secret="$(generate_jwt_secret)" log "已生成新的 JWT_SECRET_KEY" else log "保留已有 JWT_SECRET_KEY" fi rsa_private_key="$(read_env_value LOGIN_RSA_PRIVATE_KEY || true)" if [[ -z "$rsa_private_key" ]]; then rsa_private_key="$(generate_escaped_private_key)" log "已生成新的 LOGIN_RSA_PRIVATE_KEY" elif private_key_is_valid "$rsa_private_key"; then log "保留已有有效 LOGIN_RSA_PRIVATE_KEY" else fail "现有 LOGIN_RSA_PRIVATE_KEY 不是有效 PEM 私钥。请修复 .env 后重试,脚本不会自动覆盖已有无效密钥。" fi fi backup_env_file write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key" } run_compose_config() { step "检查 Docker Compose 配置" run docker compose config >/dev/null log "Docker Compose 配置检查通过" } run_backend_init() { if [[ "$TARGET_ENV" == "dev" ]]; then return fi step "初始化生产数据库和管理员" run docker compose run --rm backend-init } run_build_and_start() { if [[ "$SKIP_BUILD" -eq 1 ]]; then step "启动服务(跳过镜像重新构建)" run docker compose up -d return fi step "构建镜像并启动服务" run docker compose up -d --build } run_migrations() { if [[ "$SKIP_MIGRATE" -eq 1 ]]; then step "跳过数据库迁移" return fi step "执行数据库迁移" run docker compose run --rm backend python -m alembic upgrade head } check_container_status() { step "检查容器状态" run docker compose ps local ps_output ps_output="$(docker compose ps --services --filter status=running 2>/dev/null || true)" printf '%s\n' "$ps_output" | grep -qx 'db' || fail "db 容器未运行" printf '%s\n' "$ps_output" | grep -qx 'backend' || fail "backend 容器未运行" printf '%s\n' "$ps_output" | grep -qx 'nginx' || fail "nginx 容器未运行" log "容器状态检查通过" } check_backend_environment() { step "检查后端环境变量" local expected_env="$1" local expected_key_id="$2" local expect_rsa="$3" local check_code check_code=$(cat <<'PY' from app.core.config import settings from app.core.login_crypto import _normalize_pem expected_env = "__EXPECTED_ENV__" expected_key_id = "__EXPECTED_KEY_ID__" expect_rsa = "__EXPECT_RSA__" == "1" print("ENV=" + settings.ENV) print("JWT_SECRET_IS_DEV=" + str(settings.JWT_SECRET_KEY == "dev-secret")) print("LOGIN_RSA_PRIVATE_KEY_SET=" + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))) print("LOGIN_RSA_KEY_ID=" + settings.LOGIN_RSA_KEY_ID) if settings.ENV != expected_env: raise SystemExit(f"ENV mismatch: {settings.ENV} != {expected_env}") if settings.LOGIN_RSA_KEY_ID != expected_key_id: raise SystemExit("LOGIN_RSA_KEY_ID mismatch") if expected_env == "production" and settings.JWT_SECRET_KEY == "dev-secret": raise SystemExit("production JWT_SECRET_KEY must not be dev-secret") if expect_rsa: if not settings.LOGIN_RSA_PRIVATE_KEY: raise SystemExit("LOGIN_RSA_PRIVATE_KEY is required") from cryptography.hazmat.primitives import serialization serialization.load_pem_private_key( _normalize_pem(settings.LOGIN_RSA_PRIVATE_KEY).encode("utf-8"), password=None, ) PY ) check_code="${check_code//__EXPECTED_ENV__/$expected_env}" check_code="${check_code//__EXPECTED_KEY_ID__/$expected_key_id}" check_code="${check_code//__EXPECT_RSA__/$expect_rsa}" run docker compose exec -T backend python -c "$check_code" log "后端环境变量检查通过" } check_http_endpoint() { local path="$1" local url="${BASE_URL%/}$path" log "检查接口: $url" if ! curl -fsS "$url" >/dev/null; then if [[ "$path" == "/api/v1/auth/login-key" ]]; then printf '[CTMS] 登录公钥接口失败。请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否是完整 PEM 单行转义文本。\n' >&2 fi fail "接口检查失败: $url" fi } show_progress_note() { log "当前阶段: $1" } run_health_checks() { local runtime_env="$1" local login_key_id="$2" local expect_rsa=0 if [[ "$runtime_env" == "production" ]]; then expect_rsa=1 fi 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" log "HTTP 接口检查通过" } main() { parse_args "$@" cd "$ROOT_DIR" local project_name local runtime_env local login_key_id project_name="$(compose_project_name)" runtime_env="$(app_env)" login_key_id="$(key_id)" resolve_base_url check_dependencies show_progress_note "准备写入环境配置" confirm_install "$project_name" "$runtime_env" prepare_env_file "$project_name" "$runtime_env" "$login_key_id" show_progress_note "校验并启动 Docker 流程" run_compose_config run_backend_init run_build_and_start run_migrations show_progress_note "执行健康检查" run_health_checks "$runtime_env" "$login_key_id" step "安装完成" log "访问地址: $BASE_URL" } main "$@"