301 lines
11 KiB
Bash
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"
|