Files
ctms/scripts/install-ctms.sh
T
2026-05-11 14:51:07 +08:00

471 lines
12 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
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 <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888release 必填或交互输入。
--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 opensslmacOS: brew install openssl。" || missing=1
require_command curl "Linux: sudo apt-get install -y curlmacOS: 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 "$@"