feat: 增加 Admin 安全生产部署脚本 (#309)

This commit is contained in:
chengma
2026-08-26 15:51:23 +08:00
parent ce780dd8db
commit c6f9a80095
5 changed files with 710 additions and 0 deletions
+65
View File
@@ -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 调用、采集、采购、下单或付款。
+300
View File
@@ -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"
+259
View File
@@ -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
}
+71
View File
@@ -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)
}
}
}
+15
View File
@@ -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. 任务完成定义
单元任务同时满足下面几条才算完成: