306 lines
12 KiB
Bash
306 lines
12 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
|
||
|
||
# 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"
|