Files

260 lines
10 KiB
PowerShell
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.
[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
}