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

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