feat: 增加 Admin 安全生产部署脚本 (#309)
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# Admin 生产部署脚本
|
||||
|
||||
本目录的脚本把已经多次人工验证的 Admin 发布步骤固定下来:从指定 Git 提交隔离构建,完成测试和 Linux 交叉编译,再在服务器备份数据库、预检、原子切换并检查健康状态。
|
||||
|
||||
## 第一次使用
|
||||
|
||||
从仓库根目录先查看将执行的目标,不会连接或修改服务器:
|
||||
|
||||
```powershell
|
||||
.\admin\deploy\deploy-admin.ps1 -WhatIf
|
||||
```
|
||||
|
||||
确认后部署当前已经提交的 `HEAD`:
|
||||
|
||||
```powershell
|
||||
.\admin\deploy\deploy-admin.ps1
|
||||
```
|
||||
|
||||
脚本默认使用当前服务器 `root@185.216.248.75:21638`、域名 `https://buy.833729.com`,优先使用本机已有的 `%USERPROFILE%\.ssh\vps_ai_deploy`;也可以明确指定只允许当前账号读取的 SSH 私钥:
|
||||
|
||||
```powershell
|
||||
.\admin\deploy\deploy-admin.ps1 -IdentityFile C:\secure\cmautobuy_deploy
|
||||
```
|
||||
|
||||
脚本使用 `BatchMode=yes`,不会接收或保存 SSH 密码。没有可用密钥或 ssh-agent 时会在上传前失败。
|
||||
|
||||
## 发布有数据库迁移的版本
|
||||
|
||||
脚本会比较生产 `schema_migrations` 和固定提交声明的目标版本。目标版本更高时默认停止。核对迁移和回退兼容性后,必须显式允许:
|
||||
|
||||
```powershell
|
||||
.\admin\deploy\deploy-admin.ps1 -AllowSchemaMigration
|
||||
```
|
||||
|
||||
这个开关只允许新二进制在数据库备份后、独立端口预检时执行现有迁移,不会跳过迁移自检,也不会自动恢复数据库备份。
|
||||
|
||||
## 常用参数
|
||||
|
||||
| 参数 | 默认值 | 作用 |
|
||||
|---|---|---|
|
||||
| `-Commit` | `HEAD` | 发布指定提交、分支或标签解析出的固定提交 |
|
||||
| `-ServerAddress` | `185.216.248.75` | SSH 服务器地址 |
|
||||
| `-SSHPort` | `21638` | SSH 端口 |
|
||||
| `-SSHUser` | `root` | 执行 systemd、备份和原子切换的服务器账号 |
|
||||
| `-IdentityFile` | 自动查找现有部署密钥 | 显式指定 SSH 私钥路径 |
|
||||
| `-PublicBaseUrl` | `https://buy.833729.com` | 发布后的公网 HTTPS 健康检查地址 |
|
||||
| `-AllowSchemaMigration` | 关闭 | 明确允许目标代码升级生产 schema |
|
||||
| `-WhatIf` | 关闭 | 只显示目标,不构建、上传、备份或重启 |
|
||||
|
||||
工作区可以有未提交文件,但脚本只使用 `git archive <固定提交>` 的内容。需要发布本地新代码时必须先提交;脚本会警告未提交文件未被打包。
|
||||
|
||||
## 固定安全步骤
|
||||
|
||||
1. 使用 Go 1.23.0 运行全量 test、build、vet。
|
||||
2. 构建 `linux/amd64`、`CGO_ENABLED=0` 的单文件二进制并计算 SHA-256。
|
||||
3. 服务器核对上传 SHA、当前 schema、systemd 状态和四类后台活动任务。
|
||||
4. 使用服务器本机 MySQL 管理配置创建单事务 gzip 备份,检查压缩完整性和建表语句。
|
||||
5. 安装到 `/opt/cmautobuy/releases/<提交短号>/`,只链接稳定 `config.yaml` 和 `data/`,不复制凭据与业务数据。
|
||||
6. 在 `127.0.0.1:18083` 使用生产环境预检;迁移也只会在完成备份后从这里触发。
|
||||
7. 再次确认后台空闲,原子切换 `/opt/cmautobuy/cmautobuy-admin` 并重启 systemd。
|
||||
8. 检查稳定链接、systemd、Nginx、本机 18080、公网根路径和登录页。
|
||||
|
||||
切换后任何检查失败,远端执行器会恢复上一稳定二进制并重启。数据库迁移不会自动反向恢复,因为恢复备份会覆盖迁移后产生的业务数据;有迁移的版本必须在执行前确认旧二进制是否兼容新 schema。
|
||||
|
||||
脚本不会删除旧 release 和历史备份,也不会触发顺运宝同步、目录导入、AI 调用、采集、采购、下单或付款。
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,259 @@
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "High")]
|
||||
param(
|
||||
[ValidatePattern("^[A-Za-z0-9.-]+$")]
|
||||
[string]$ServerAddress = "185.216.248.75",
|
||||
|
||||
[ValidateRange(1, 65535)]
|
||||
[int]$SSHPort = 21638,
|
||||
|
||||
[ValidatePattern("^[A-Za-z0-9_.-]+$")]
|
||||
[string]$SSHUser = "root",
|
||||
|
||||
[string]$IdentityFile = "",
|
||||
|
||||
[string]$Commit = "HEAD",
|
||||
|
||||
[ValidatePattern("^https://[A-Za-z0-9.-]+(?::[0-9]{1,5})?$")]
|
||||
[string]$PublicBaseUrl = "https://buy.833729.com",
|
||||
|
||||
[switch]$AllowSchemaMigration
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Assert-CommandExists {
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "缺少命令 $Name,请先安装并加入 PATH。"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CheckedNative {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments,
|
||||
[string]$WorkingDirectory = "",
|
||||
[string]$FailureMessage = "命令执行失败"
|
||||
)
|
||||
|
||||
if ($WorkingDirectory) {
|
||||
Push-Location -LiteralPath $WorkingDirectory
|
||||
}
|
||||
try {
|
||||
& $FilePath @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$FailureMessage(退出码 $LASTEXITCODE)"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($WorkingDirectory) {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CheckedNativeOutput {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments,
|
||||
[string]$WorkingDirectory = "",
|
||||
[string]$FailureMessage = "命令执行失败"
|
||||
)
|
||||
|
||||
if ($WorkingDirectory) {
|
||||
Push-Location -LiteralPath $WorkingDirectory
|
||||
}
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$FailureMessage(退出码 $LASTEXITCODE):$($output | Out-String)"
|
||||
}
|
||||
return (($output | Out-String).Trim())
|
||||
}
|
||||
finally {
|
||||
if ($WorkingDirectory) {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-SafeBuildDirectory {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return
|
||||
}
|
||||
$resolvedPath = [System.IO.Path]::GetFullPath($Path)
|
||||
$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
|
||||
$leafName = Split-Path -Leaf $resolvedPath
|
||||
if (-not $resolvedPath.StartsWith($temporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not $leafName.StartsWith("cmautobuy-deploy-", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "拒绝清理不受信任的临时目录:$resolvedPath"
|
||||
}
|
||||
Remove-Item -LiteralPath $resolvedPath -Recurse -Force
|
||||
}
|
||||
|
||||
foreach ($commandName in @("git", "go", "tar", "ssh", "scp")) {
|
||||
Assert-CommandExists -Name $commandName
|
||||
}
|
||||
|
||||
$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\.."))
|
||||
$actualRoot = Get-CheckedNativeOutput -FilePath "git" -Arguments @("rev-parse", "--show-toplevel") `
|
||||
-WorkingDirectory $repositoryRoot -FailureMessage "当前目录不是 Git 仓库"
|
||||
if ([System.IO.Path]::GetFullPath($actualRoot) -ne $repositoryRoot) {
|
||||
throw "脚本必须位于 cmautobuy 仓库的 admin/deploy 目录。"
|
||||
}
|
||||
|
||||
$fullCommit = Get-CheckedNativeOutput -FilePath "git" -Arguments @("rev-parse", "$Commit`^{commit}") `
|
||||
-WorkingDirectory $repositoryRoot -FailureMessage "无法解析提交 $Commit"
|
||||
if ($fullCommit -notmatch "^[0-9a-f]{40}$") {
|
||||
throw "Git 返回了无效提交哈希:$fullCommit"
|
||||
}
|
||||
$releaseID = $fullCommit.Substring(0, 7)
|
||||
|
||||
$worktreeState = Get-CheckedNativeOutput -FilePath "git" -Arguments @("status", "--porcelain") `
|
||||
-WorkingDirectory $repositoryRoot -FailureMessage "无法检查工作区状态"
|
||||
if ($worktreeState) {
|
||||
Write-Warning "工作区存在未提交文件;本次只从固定提交 $releaseID 构建,这些文件不会进入发布物。"
|
||||
}
|
||||
|
||||
if ($IdentityFile) {
|
||||
if (-not (Test-Path -LiteralPath $IdentityFile -PathType Leaf)) {
|
||||
throw "SSH 私钥不存在:$IdentityFile"
|
||||
}
|
||||
$IdentityFile = (Resolve-Path -LiteralPath $IdentityFile).Path
|
||||
}
|
||||
else {
|
||||
$defaultIdentity = Join-Path $env:USERPROFILE ".ssh\vps_ai_deploy"
|
||||
if (Test-Path -LiteralPath $defaultIdentity -PathType Leaf) {
|
||||
$IdentityFile = (Resolve-Path -LiteralPath $defaultIdentity).Path
|
||||
}
|
||||
}
|
||||
|
||||
$targetDescription = "$SSHUser@$ServerAddress`:$SSHPort"
|
||||
$migrationText = if ($AllowSchemaMigration) { "允许备份后执行 schema 升级" } else { "禁止 schema 升级" }
|
||||
if (-not $PSCmdlet.ShouldProcess(
|
||||
$targetDescription,
|
||||
"从固定提交 $releaseID 构建并部署 Admin($migrationText)"
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
$temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("cmautobuy-deploy-" + [guid]::NewGuid().ToString("N"))
|
||||
$remoteNonce = [guid]::NewGuid().ToString("N")
|
||||
$remoteBinary = "/tmp/cmautobuy-deploy-$remoteNonce.bin"
|
||||
$remoteRunner = "/tmp/cmautobuy-deploy-$remoteNonce.sh"
|
||||
$remoteCleanupNeeded = $false
|
||||
|
||||
$sshArguments = @(
|
||||
"-p", $SSHPort.ToString(),
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "ConnectTimeout=15"
|
||||
)
|
||||
$scpArguments = @(
|
||||
"-P", $SSHPort.ToString(),
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "ConnectTimeout=15"
|
||||
)
|
||||
if ($IdentityFile) {
|
||||
$sshArguments += @("-i", $IdentityFile)
|
||||
$scpArguments += @("-i", $IdentityFile)
|
||||
}
|
||||
$remoteTarget = "$SSHUser@$ServerAddress"
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null
|
||||
$sourceArchive = Join-Path $temporaryDirectory "source.tar"
|
||||
$sourceDirectory = Join-Path $temporaryDirectory "source"
|
||||
$outputDirectory = Join-Path $temporaryDirectory "output"
|
||||
New-Item -ItemType Directory -Path $sourceDirectory, $outputDirectory | Out-Null
|
||||
|
||||
Write-Host "[1/7] 从固定提交 $fullCommit 导出隔离源码..."
|
||||
Invoke-CheckedNative -FilePath "git" -Arguments @(
|
||||
"archive", "--format=tar", "--output=$sourceArchive", $fullCommit
|
||||
) -WorkingDirectory $repositoryRoot -FailureMessage "导出固定提交失败"
|
||||
Invoke-CheckedNative -FilePath "tar" -Arguments @("-xf", $sourceArchive, "-C", $sourceDirectory) `
|
||||
-FailureMessage "解压固定提交失败"
|
||||
|
||||
$mysqlSource = Join-Path $sourceDirectory "admin\repository\mysql_db.go"
|
||||
$mysqlText = [System.IO.File]::ReadAllText($mysqlSource)
|
||||
$schemaMatch = [regex]::Match($mysqlText, "const\s+mysqlSchemaVersion\s*=\s*([0-9]+)")
|
||||
if (-not $schemaMatch.Success) {
|
||||
throw "无法从固定提交读取目标 MySQL schema 版本。"
|
||||
}
|
||||
$targetSchema = [int]$schemaMatch.Groups[1].Value
|
||||
|
||||
Write-Host "[2/7] 使用 Go 1.23.0 执行全量 test、build、vet..."
|
||||
$savedEnvironment = @{}
|
||||
foreach ($name in @("GOTOOLCHAIN", "GOOS", "GOARCH", "CGO_ENABLED")) {
|
||||
$savedEnvironment[$name] = [System.Environment]::GetEnvironmentVariable($name, "Process")
|
||||
}
|
||||
try {
|
||||
$env:GOTOOLCHAIN = "go1.23.0"
|
||||
$env:GOOS = ""
|
||||
$env:GOARCH = ""
|
||||
$env:CGO_ENABLED = ""
|
||||
Invoke-CheckedNative -FilePath "go" -Arguments @("version") -WorkingDirectory (Join-Path $sourceDirectory "admin") `
|
||||
-FailureMessage "Go 1.23.0 不可用"
|
||||
Invoke-CheckedNative -FilePath "go" -Arguments @("test", "./...", "-count=1") `
|
||||
-WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Admin 全量测试失败"
|
||||
Invoke-CheckedNative -FilePath "go" -Arguments @("build", "./...") `
|
||||
-WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Admin 全量编译失败"
|
||||
Invoke-CheckedNative -FilePath "go" -Arguments @("vet", "./...") `
|
||||
-WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Admin go vet 失败"
|
||||
|
||||
Write-Host "[3/7] 构建 Linux amd64 单文件发布物..."
|
||||
$env:GOOS = "linux"
|
||||
$env:GOARCH = "amd64"
|
||||
$env:CGO_ENABLED = "0"
|
||||
$localBinary = Join-Path $outputDirectory "cmautobuy-admin"
|
||||
Invoke-CheckedNative -FilePath "go" -Arguments @(
|
||||
"build", "-trimpath", "-ldflags=-s -w", "-o", $localBinary, "."
|
||||
) -WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Linux amd64 发布物构建失败"
|
||||
}
|
||||
finally {
|
||||
foreach ($name in $savedEnvironment.Keys) {
|
||||
[System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process")
|
||||
}
|
||||
}
|
||||
|
||||
$localHash = (Get-FileHash -LiteralPath $localBinary -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$localSize = (Get-Item -LiteralPath $localBinary).Length
|
||||
Write-Host "发布物:commit=$releaseID schema=v$targetSchema size=$localSize sha256=$localHash"
|
||||
|
||||
$remoteScriptSource = Join-Path $PSScriptRoot "deploy-admin-remote.sh"
|
||||
$remoteScriptUpload = Join-Path $temporaryDirectory "deploy-admin-remote.sh"
|
||||
$remoteScriptText = [System.IO.File]::ReadAllText($remoteScriptSource).Replace("`r`n", "`n").Replace("`r", "`n")
|
||||
[System.IO.File]::WriteAllText($remoteScriptUpload, $remoteScriptText, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
Write-Host "[4/7] 上传发布物和远端执行器..."
|
||||
$remoteCleanupNeeded = $true
|
||||
Invoke-CheckedNative -FilePath "scp" -Arguments ($scpArguments + @(
|
||||
$localBinary, "${remoteTarget}:$remoteBinary"
|
||||
)) -FailureMessage "上传 Admin 发布物失败;请检查 SSH 密钥或 ssh-agent"
|
||||
Invoke-CheckedNative -FilePath "scp" -Arguments ($scpArguments + @(
|
||||
$remoteScriptUpload, "${remoteTarget}:$remoteRunner"
|
||||
)) -FailureMessage "上传远端部署执行器失败"
|
||||
|
||||
Write-Host "[5/7] 由服务器执行备份、独立端口预检和原子切换..."
|
||||
$allowMigrationValue = if ($AllowSchemaMigration) { "1" } else { "0" }
|
||||
$remoteCommand = "chmod 0700 $remoteRunner && bash $remoteRunner $releaseID $localHash $targetSchema $allowMigrationValue $PublicBaseUrl $remoteBinary"
|
||||
Invoke-CheckedNative -FilePath "ssh" -Arguments ($sshArguments + @($remoteTarget, $remoteCommand)) `
|
||||
-FailureMessage "服务器部署失败;远端执行器已在必要时尝试恢复上一版本"
|
||||
|
||||
Write-Host "[6/7] 服务器部署和健康检查已通过。"
|
||||
Write-Host "[7/7] 完成:$PublicBaseUrl 已切换到提交 $releaseID。"
|
||||
}
|
||||
finally {
|
||||
if ($remoteCleanupNeeded) {
|
||||
& ssh @sshArguments $remoteTarget "rm -f -- $remoteBinary $remoteRunner" 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "未能清理远端临时上传文件;路径为 $remoteBinary 和 $remoteRunner。"
|
||||
}
|
||||
}
|
||||
Remove-SafeBuildDirectory -Path $temporaryDirectory
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readDeployScript(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestDeployAdminPowerShell_固定提交隔离构建且支持WhatIf(t *testing.T) {
|
||||
source := readDeployScript(t, "deploy/deploy-admin.ps1")
|
||||
for _, want := range []string{
|
||||
"SupportsShouldProcess = $true",
|
||||
"\"archive\", \"--format=tar\"",
|
||||
"$env:GOTOOLCHAIN = \"go1.23.0\"",
|
||||
"$env:GOOS = \"linux\"",
|
||||
"$env:GOARCH = \"amd64\"",
|
||||
"$env:CGO_ENABLED = \"0\"",
|
||||
"BatchMode=yes",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"AllowSchemaMigration",
|
||||
} {
|
||||
if !strings.Contains(source, want) {
|
||||
t.Errorf("本地部署脚本缺少关键安全步骤 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployAdminRemote_备份预检原子切换和失败回退(t *testing.T) {
|
||||
source := readDeployScript(t, "deploy/deploy-admin-remote.sh")
|
||||
for _, want := range []string{
|
||||
"check_active_jobs",
|
||||
"mysqldump --defaults-extra-file=",
|
||||
"gzip -t",
|
||||
"preflight_port=\"18083\"",
|
||||
"current_schema < target_schema",
|
||||
"allow_migration",
|
||||
"mv -Tf \"$temporary_link\" \"$stable_binary\"",
|
||||
"restore_previous_release",
|
||||
"数据库迁移不会自动回滚",
|
||||
"nginx -t",
|
||||
} {
|
||||
if !strings.Contains(source, want) {
|
||||
t.Errorf("远端部署脚本缺少关键安全步骤 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployScripts_不降低传输安全或写死凭据(t *testing.T) {
|
||||
source := readDeployScript(t, "deploy/deploy-admin.ps1") + "\n" +
|
||||
readDeployScript(t, "deploy/deploy-admin-remote.sh")
|
||||
for _, forbidden := range []string{
|
||||
"StrictHostKeyChecking=no",
|
||||
"--no-check-certificate",
|
||||
"curl -k",
|
||||
"CMAUTOBUY_DB_PASSWORD=",
|
||||
"Authorization:",
|
||||
} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Errorf("部署脚本包含禁止内容 %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,21 @@ CMAutoBuyAdmin/
|
||||
|
||||
比 Client 简单——Go 没有 Python 那种依赖收集问题,不需要 `app/` 目录。
|
||||
|
||||
### 9.1 生产部署脚本
|
||||
|
||||
生产发布统一使用 `admin/deploy/deploy-admin.ps1`,详细参数和恢复边界见
|
||||
`admin/deploy/README.md`。脚本只从已提交的固定 Git 提交导出隔离源码,工作区未提交
|
||||
文件不进入发布物;固定使用 Go 1.23.0 完成全量验证后再构建 Linux amd64 二进制。
|
||||
|
||||
切换前必须满足:上传 SHA-256 一致、四类后台任务为空、MySQL 逻辑备份可解压且包含建表
|
||||
语句、新 release 在独立端口通过生产配置和 schema 自检。目标代码声明的 schema 高于
|
||||
生产时默认停止,只有部署人员核对迁移与应用回退兼容性后显式传入
|
||||
`-AllowSchemaMigration` 才能继续。
|
||||
|
||||
稳定二进制通过符号链接原子切换;systemd、Nginx、本机和公网健康检查任一失败时恢复上一
|
||||
二进制并重启。数据库迁移和备份恢复不自动反向执行,避免覆盖发布后业务数据。旧 release、
|
||||
历史备份、稳定 `config.yaml`、环境文件、AI 密钥和 `data/` 都不得由脚本清理或替换。
|
||||
|
||||
## 10. 任务完成定义
|
||||
|
||||
单元任务同时满足下面几条才算完成:
|
||||
|
||||
Reference in New Issue
Block a user