#!/usr/bin/env bash set -Eeuo pipefail umask 077 if [[ $# -ne 6 ]]; then echo "用法: deploy-admin-remote.sh RELEASE_ID SHA256 TARGET_SCHEMA ALLOW_MIGRATION PUBLIC_BASE_URL UPLOADED_BINARY" >&2 exit 2 fi release_id="$1" expected_sha="$2" target_schema="$3" allow_migration="$4" public_base_url="$5" uploaded_binary="$6" [[ "$release_id" =~ ^[0-9a-f]{7}$ ]] || { echo "release ID 无效" >&2; exit 2; } [[ "$expected_sha" =~ ^[0-9a-f]{64}$ ]] || { echo "SHA-256 无效" >&2; exit 2; } [[ "$target_schema" =~ ^[0-9]+$ ]] || { echo "目标 schema 无效" >&2; exit 2; } [[ "$allow_migration" == "0" || "$allow_migration" == "1" ]] || { echo "迁移授权值无效" >&2; exit 2; } [[ "$public_base_url" =~ ^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]] || { echo "公网 URL 必须是 HTTPS origin" >&2; exit 2; } [[ "$uploaded_binary" =~ ^/tmp/cmautobuy-deploy-[0-9a-f]{32}\.bin$ ]] || { echo "上传路径无效" >&2; exit 2; } [[ ${EUID} -eq 0 ]] || { echo "远端部署必须由 root 执行" >&2; exit 2; } base_dir="/opt/cmautobuy" release_root="$base_dir/releases" release_dir="$release_root/$release_id" release_binary="$release_dir/cmautobuy-admin" stable_binary="$base_dir/cmautobuy-admin" stable_config="$base_dir/config.yaml" stable_data="$base_dir/data" backup_dir="$base_dir/backups" environment_file="/etc/cmautobuy/admin.env" mysql_defaults="/root/.mysql84-root.cnf" database_name="autobuy" service_name="cmautobuy-admin" preflight_port="18083" preflight_pid="" preflight_log="" backup_tmp="" temporary_link="" previous_target="" switched=0 deployment_complete=0 require_command() { command -v "$1" >/dev/null 2>&1 || { echo "服务器缺少命令: $1" >&2; exit 1; } } mysql_scalar() { mysql --defaults-extra-file="$mysql_defaults" --batch --skip-column-names "$database_name" -e "$1" } http_code() { curl --silent --show-error --output /dev/null --write-out '%{http_code}' --max-time 12 "$1" } stop_preflight() { if [[ -z "$preflight_pid" ]]; then return fi if kill -0 "$preflight_pid" 2>/dev/null; then kill "$preflight_pid" 2>/dev/null || true for _ in {1..20}; do kill -0 "$preflight_pid" 2>/dev/null || break sleep 0.25 done if kill -0 "$preflight_pid" 2>/dev/null; then kill -9 "$preflight_pid" 2>/dev/null || true fi fi wait "$preflight_pid" 2>/dev/null || true preflight_pid="" } restore_previous_release() { if [[ -z "$previous_target" || ! -x "$previous_target" ]]; then echo "无法自动回退:上一二进制路径不存在,请人工处理。" >&2 return 1 fi local rollback_link="$base_dir/.cmautobuy-admin.rollback.$$" ln -s "$previous_target" "$rollback_link" mv -Tf "$rollback_link" "$stable_binary" systemctl restart "$service_name" || true for _ in {1..60}; do if systemctl is-active --quiet "$service_name"; then local code code="$(http_code "http://127.0.0.1:18080/" 2>/dev/null || true)" if [[ "$code" == "303" ]]; then echo "已恢复上一版本:$previous_target" >&2 return 0 fi fi sleep 1 done echo "上一版本链接已恢复,但服务健康检查未通过,请立即人工处理。" >&2 return 1 } on_exit() { local result=$? set +e stop_preflight [[ -n "$preflight_log" ]] && rm -f -- "$preflight_log" [[ -n "$backup_tmp" ]] && rm -f -- "$backup_tmp" [[ -n "$temporary_link" ]] && rm -f -- "$temporary_link" rm -f -- "$uploaded_binary" if [[ $result -ne 0 && $switched -eq 1 && $deployment_complete -eq 0 ]]; then echo "部署失败,正在恢复上一稳定二进制。数据库迁移不会自动回滚。" >&2 restore_previous_release || true fi exit "$result" } trap on_exit EXIT trap 'exit 130' INT TERM for command_name in bash sha256sum mysql mysqldump gzip awk curl systemctl ss runuser install readlink nginx; do require_command "$command_name" done [[ -f "$uploaded_binary" ]] || { echo "找不到上传的 Admin 二进制" >&2; exit 1; } [[ -f "$mysql_defaults" ]] || { echo "缺少服务器本机 MySQL 配置 $mysql_defaults" >&2; exit 1; } [[ -f "$environment_file" ]] || { echo "缺少生产环境文件 $environment_file" >&2; exit 1; } [[ -f "$stable_config" ]] || { echo "缺少稳定配置 $stable_config" >&2; exit 1; } [[ -d "$stable_data" ]] || { echo "缺少稳定数据目录 $stable_data" >&2; exit 1; } systemctl is-enabled --quiet "$service_name" || { echo "systemd 服务未启用" >&2; exit 1; } systemctl is-active --quiet "$service_name" || { echo "生产 Admin 当前不是 active,停止部署" >&2; exit 1; } actual_sha="$(sha256sum "$uploaded_binary" | awk '{print $1}')" [[ "$actual_sha" == "$expected_sha" ]] || { echo "上传文件 SHA-256 不一致" >&2; exit 1; } current_schema="$(mysql_scalar 'SELECT COALESCE(MAX(version),0) FROM schema_migrations')" [[ "$current_schema" =~ ^[0-9]+$ ]] || { echo "无法读取生产 schema 版本" >&2; exit 1; } if (( current_schema > target_schema )); then echo "目标代码 schema v$target_schema 低于生产 v$current_schema,禁止发布。" >&2 exit 1 fi if (( current_schema < target_schema )) && [[ "$allow_migration" != "1" ]]; then echo "目标需要 schema v$current_schema -> v$target_schema;请核对迁移后使用 -AllowSchemaMigration 重新执行。" >&2 exit 1 fi check_active_jobs() { local active_count active_count="$(mysql_scalar "SELECT (SELECT COUNT(*) FROM syb_sync_runs WHERE status='running') + (SELECT COUNT(*) FROM catalog_import_runs WHERE status='processing') + (SELECT COUNT(*) FROM ai_match_batches WHERE status IN ('queued','running')) + (SELECT COUNT(*) FROM syb_inner_code_records WHERE status IN ('queued','applying'))")" [[ "$active_count" =~ ^[0-9]+$ ]] || { echo "无法读取后台活动任务数量" >&2; return 1; } if (( active_count != 0 )); then echo "仍有 $active_count 条后台活动记录,禁止重启 Admin。" >&2 return 1 fi echo "后台活动检查通过:0" } check_active_jobs # umask 077 会让普通 mkdir 新建的 release 目录变成 0700 root:root, # 后续 runuser 切换到 cmautobuy 后即使二进制本身可执行,也无法遍历目录。 # releases 只开放给服务组读取/遍历;数据库备份目录继续保持 root 私有。 mkdir -p "$backup_dir" install -d -o root -g cmautobuy -m 0750 "$release_root" install -d -o root -g cmautobuy -m 0750 "$release_dir" if [[ -e "$release_binary" ]]; then installed_sha="$(sha256sum "$release_binary" | awk '{print $1}')" [[ "$installed_sha" == "$expected_sha" ]] || { echo "release 目录已有不同二进制,拒绝覆盖" >&2; exit 1; } else install -o cmautobuy -g cmautobuy -m 0750 "$uploaded_binary" "$release_binary" fi chown cmautobuy:cmautobuy "$release_binary" chmod 0750 "$release_binary" ensure_release_link() { local link_path="$1" local expected_target="$2" if [[ -L "$link_path" ]]; then [[ "$(readlink -f "$link_path")" == "$(readlink -f "$expected_target")" ]] || { echo "release 中已有指向其他位置的链接:$link_path" >&2 return 1 } elif [[ -e "$link_path" ]]; then echo "release 中存在非链接路径:$link_path" >&2 return 1 else ln -s "$expected_target" "$link_path" fi } ensure_release_link "$release_dir/config.yaml" "$stable_config" ensure_release_link "$release_dir/data" "$stable_data" previous_target="$(readlink -f "$stable_binary")" [[ -x "$previous_target" ]] || { echo "当前稳定二进制无效:$previous_target" >&2; exit 1; } if [[ "$previous_target" == "$release_binary" && "$current_schema" == "$target_schema" ]]; then [[ "$(http_code 'http://127.0.0.1:18080/' 2>/dev/null || true)" == "303" ]] || { echo "目标版本已启用,但本机健康检查失败" >&2 exit 1 } echo "目标 release 已经在运行,无需重复备份和重启。" deployment_complete=1 exit 0 fi timestamp="$(date -u +%Y%m%dT%H%M%SZ)" backup_name="autobuy-before-${release_id}-${timestamp}-notablespaces.sql.gz" backup_path="$backup_dir/$backup_name" backup_tmp="$backup_dir/.${backup_name}.tmp" echo "创建部署前 MySQL 逻辑备份..." mysqldump --defaults-extra-file="$mysql_defaults" --no-tablespaces --single-transaction --routines --triggers "$database_name" \ | gzip -c > "$backup_tmp" chmod 0600 "$backup_tmp" gzip -t "$backup_tmp" create_table_count="$(gzip -cd "$backup_tmp" | awk '/^CREATE TABLE/{count++} END{print count+0}')" (( create_table_count > 0 )) || { echo "备份中没有建表语句,停止部署" >&2; exit 1; } mv "$backup_tmp" "$backup_path" backup_tmp="" backup_sha="$(sha256sum "$backup_path" | awk '{print $1}')" echo "备份完成:$backup_path tables=$create_table_count sha256=$backup_sha" if ss -ltn | awk 'NR>1 {print $4}' | grep -Eq "(^|:)${preflight_port}$"; then echo "预检端口 $preflight_port 已被占用" >&2 exit 1 fi preflight_log="/tmp/cmautobuy-preflight-${release_id}-$$.log" echo "在 127.0.0.1:$preflight_port 启动新 release 预检..." set +u set -a # shellcheck disable=SC1090 . "$environment_file" set +a set -u ( cd "$release_dir" exec runuser -u cmautobuy -- ./cmautobuy-admin -addr "127.0.0.1:$preflight_port" ) >"$preflight_log" 2>&1 & preflight_pid=$! preflight_ready=0 for _ in {1..90}; do root_code="$(http_code "http://127.0.0.1:$preflight_port/" 2>/dev/null || true)" if [[ "$root_code" == "303" ]]; then preflight_ready=1 break fi kill -0 "$preflight_pid" 2>/dev/null || break sleep 1 done if [[ $preflight_ready -ne 1 ]]; then echo "独立端口预检启动失败,最后日志如下:" >&2 tail -n 80 "$preflight_log" >&2 || true exit 1 fi login_code="$(http_code "http://127.0.0.1:$preflight_port/login" 2>/dev/null || true)" [[ "$login_code" == "200" ]] || { echo "预检登录页状态异常:$login_code" >&2; exit 1; } stop_preflight rm -f -- "$preflight_log" preflight_log="" schema_after_preflight="$(mysql_scalar 'SELECT COALESCE(MAX(version),0) FROM schema_migrations')" [[ "$schema_after_preflight" == "$target_schema" ]] || { echo "预检后 schema 为 v$schema_after_preflight,目标为 v$target_schema" >&2 exit 1 } check_active_jobs temporary_link="$base_dir/.cmautobuy-admin.${release_id}.$$" ln -s "$release_binary" "$temporary_link" mv -Tf "$temporary_link" "$stable_binary" temporary_link="" switched=1 systemctl restart "$service_name" live_ready=0 for _ in {1..60}; do if systemctl is-active --quiet "$service_name"; then live_root_code="$(http_code 'http://127.0.0.1:18080/' 2>/dev/null || true)" if [[ "$live_root_code" == "303" ]]; then live_ready=1 break fi fi sleep 1 done if [[ $live_ready -ne 1 ]]; then echo "新版本 systemd 或本机 18080 健康检查失败" >&2 journalctl -u "$service_name" -n 80 --no-pager >&2 || true exit 1 fi systemctl is-enabled --quiet "$service_name" [[ "$(readlink -f "$stable_binary")" == "$release_binary" ]] || { echo "稳定链接未指向目标 release" >&2; exit 1; } nginx -t [[ "$(http_code "$public_base_url/" 2>/dev/null || true)" == "303" ]] || { echo "公网根路径健康检查失败" >&2; exit 1; } [[ "$(http_code "$public_base_url/login" 2>/dev/null || true)" == "200" ]] || { echo "公网登录页健康检查失败" >&2; exit 1; } [[ "$(mysql_scalar 'SELECT COALESCE(MAX(version),0) FROM schema_migrations')" == "$target_schema" ]] || { echo "发布后 schema 版本异常" >&2 exit 1 } check_active_jobs deployment_complete=1 echo "部署成功:release=$release_id schema=v$target_schema previous=$previous_target"