Files
cmautobuy/admin/deploy/deploy-admin-remote.sh
T

301 lines
11 KiB
Bash

#!/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
mkdir -p "$release_root" "$backup_dir" "$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"