diff --git a/admin/deploy/README.md b/admin/deploy/README.md new file mode 100644 index 0000000..9265b65 --- /dev/null +++ b/admin/deploy/README.md @@ -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 调用、采集、采购、下单或付款。 diff --git a/admin/deploy/deploy-admin-remote.sh b/admin/deploy/deploy-admin-remote.sh new file mode 100644 index 0000000..eb69b69 --- /dev/null +++ b/admin/deploy/deploy-admin-remote.sh @@ -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" diff --git a/admin/deploy/deploy-admin.ps1 b/admin/deploy/deploy-admin.ps1 new file mode 100644 index 0000000..f23ed96 --- /dev/null +++ b/admin/deploy/deploy-admin.ps1 @@ -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 +} diff --git a/admin/deploy_script_test.go b/admin/deploy_script_test.go new file mode 100644 index 0000000..ad99c88 --- /dev/null +++ b/admin/deploy_script_test.go @@ -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) + } + } +} diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index b18e528..7bf80d6 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -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. 任务完成定义 单元任务同时满足下面几条才算完成: