diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md index 7ebb6f6b..6dfbcfce 100644 --- a/docs/guides/branch-environment-installation.md +++ b/docs/guides/branch-environment-installation.md @@ -1,10 +1,13 @@ # CTMS 安装步骤向导 -按目标分支选择对应章节执行: +推荐使用安装脚本完成 Docker 部署、环境变量、健康检查和数据迁移。手工步骤保留为排障附录。 -- [dev 分支安装](#dev-分支安装) -- [main 分支安装](#main-分支安装) -- [release 分支安装](#release-分支安装) +- [安装前准备](#安装前准备) +- [推荐脚本安装](#推荐脚本安装) +- [手工安装/排障:dev 分支](#手工安装排障dev-分支) +- [手工安装/排障:main 分支](#手工安装排障main-分支) +- [手工安装/排障:release 分支](#手工安装排障release-分支) +- [常见安装问题](#常见安装问题) ## 安装前准备 @@ -89,7 +92,61 @@ grep -n "^\\.env\\.\\*$" .gitignore 预期能看到 `.env` 和 `.env.*` 已被忽略。 -## dev 分支安装 +## 推荐脚本安装 + +安装脚本入口: + +```bash +bash scripts/install-ctms.sh dev|main|release [选项] +``` + +常用示例: + +```bash +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 +``` + +可选参数: + +```text +--base-url 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。 +--yes 跳过交互确认,适合自动化执行。 +--skip-build 跳过 docker compose up -d --build。 +--skip-migrate 跳过 alembic upgrade head。 +``` + +脚本会执行: + +```text +1. 检查 docker、docker compose、openssl、curl。 +2. 创建或更新 .env;已有 .env 会先备份为 .env.bak.<时间戳>。 +3. main/release 缺少 JWT 或 RSA 私钥时自动生成,已有有效密钥保持不变。 +4. main/release 先执行 backend-init。 +5. 执行 docker compose up -d --build。 +6. 执行 docker compose run --rm backend python -m alembic upgrade head。 +7. 检查容器状态、后端环境、/health、/api/v1/auth/login-key。 +``` + +脚本不会执行: + +```text +git checkout +git pull +docker compose down -v +删除 pg_data +删除数据库 volume +自动安装系统依赖 +``` + +如果只想查看参数说明: + +```bash +bash scripts/install-ctms.sh --help +``` + +## 手工安装/排障:dev 分支 ### 1. 切换分支 @@ -184,7 +241,7 @@ npm run build cd .. ``` -## main 分支安装 +## 手工安装/排障:main 分支 ### 1. 切换分支 @@ -213,6 +270,12 @@ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login_rsa_main awk '{printf "%s\\n", $0}' login_rsa_main.pem ``` +也可以直接生成完整的 `.env` 配置行,复制输出结果替换 `.env` 中的 `LOGIN_RSA_PRIVATE_KEY`: + +```bash +printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$(awk '{printf "%s\\n", $0}' login_rsa_main.pem)" +``` + ### 3. 写入环境配置 在仓库根目录创建或覆盖 `.env`,将占位符替换为上一步生成的值: @@ -339,7 +402,7 @@ npm run build cd .. ``` -## release 分支安装 +## 手工安装/排障:release 分支 ### 1. 切换分支 @@ -384,6 +447,12 @@ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login_rsa_rele awk '{printf "%s\\n", $0}' login_rsa_release.pem ``` +也可以直接生成完整的 `.env` 配置行,复制输出结果替换 `.env` 中的 `LOGIN_RSA_PRIVATE_KEY`: + +```bash +printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$(awk '{printf "%s\\n", $0}' login_rsa_release.pem)" +``` + 将 `login_rsa_release.pem` 保存到服务器安全目录或密钥管理系统。 ### 4. 写入环境配置 diff --git a/scripts/install-ctms.sh b/scripts/install-ctms.sh new file mode 100755 index 00000000..ba88ccde --- /dev/null +++ b/scripts/install-ctms.sh @@ -0,0 +1,470 @@ +#!/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 "$@"