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