1252 lines
57 KiB
PowerShell
1252 lines
57 KiB
PowerShell
[CmdletBinding()]
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$Server,
|
||
|
||
[string]$Username = "root",
|
||
|
||
[string]$ImageName = "flnxi-material",
|
||
|
||
[string]$Tag = "local-deploy",
|
||
|
||
[string]$Dockerfile = "Dockerfile",
|
||
|
||
[string]$Context = ".",
|
||
|
||
[string]$RemotePath = "/root/deploy",
|
||
|
||
[string]$ArchiveDirectory = "deploy-artifacts",
|
||
|
||
[switch]$SkipCheck,
|
||
|
||
[switch]$SkipUpload,
|
||
|
||
[switch]$SkipBuild,
|
||
|
||
# 注意:使用 String 类型是为了兼容性,实际使用时密码会通过命令行参数传递
|
||
# 在生产环境中,建议使用环境变量或密钥文件而不是命令行参数
|
||
[string]$Password,
|
||
|
||
[switch]$CleanupAfterDeploy,
|
||
|
||
[switch]$KeepTarFile,
|
||
|
||
[string]$ComposeFile = "docker-compose.prod.yml",
|
||
|
||
# 是否在数据库迁移时跳过 Prisma Client 生成
|
||
# 默认跳过(因为镜像构建时已生成),如果 schema 有变化但镜像未更新,可设置为 false
|
||
# 使用方式:-SkipPrismaGenerate 表示跳过(默认),-SkipPrismaGenerate:$false 表示不跳过
|
||
[switch]$SkipPrismaGenerate
|
||
)
|
||
|
||
# 设置控制台输出编码为 UTF-8,避免中文乱码
|
||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
|
||
}
|
||
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = "Stop"
|
||
|
||
function Assert-Command {
|
||
param([string]$Name)
|
||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||
throw "缺少依赖命令:$Name"
|
||
}
|
||
}
|
||
|
||
function Show-Progress {
|
||
param(
|
||
[string]$Activity,
|
||
[string]$Status,
|
||
[int]$PercentComplete,
|
||
[string]$CurrentOperation
|
||
)
|
||
Write-Progress -Activity $Activity -Status $Status -PercentComplete $PercentComplete -CurrentOperation $CurrentOperation
|
||
}
|
||
|
||
function Format-FileSize {
|
||
param([long]$Bytes)
|
||
if ($Bytes -ge 1GB) {
|
||
return "{0:N2} GB" -f ($Bytes / 1GB)
|
||
} elseif ($Bytes -ge 1MB) {
|
||
return "{0:N2} MB" -f ($Bytes / 1MB)
|
||
} elseif ($Bytes -ge 1KB) {
|
||
return "{0:N2} KB" -f ($Bytes / 1KB)
|
||
} else {
|
||
return "$Bytes B"
|
||
}
|
||
}
|
||
|
||
function Format-Speed {
|
||
param([long]$BytesPerSecond)
|
||
return "$(Format-FileSize $BytesPerSecond)/s"
|
||
}
|
||
|
||
function Find-PuTTYTools {
|
||
# 尝试找到 PuTTY 工具
|
||
$searchPaths = @(
|
||
"C:\Program Files\PuTTY",
|
||
"C:\Program Files (x86)\PuTTY",
|
||
"$env:USERPROFILE\AppData\Local\Programs\PuTTY"
|
||
)
|
||
|
||
$plink = $null
|
||
$pscp = $null
|
||
|
||
foreach ($path in $searchPaths) {
|
||
$plinkPath = Join-Path $path "plink.exe"
|
||
$pscpPath = Join-Path $path "pscp.exe"
|
||
if (Test-Path $plinkPath) { $plink = $plinkPath }
|
||
if (Test-Path $pscpPath) { $pscp = $pscpPath }
|
||
if ($plink -and $pscp) { break }
|
||
}
|
||
|
||
# 也尝试从 PATH 中查找
|
||
if (-not $plink) {
|
||
$plinkCmd = Get-Command plink -ErrorAction SilentlyContinue
|
||
if ($plinkCmd) { $plink = $plinkCmd.Source }
|
||
}
|
||
if (-not $pscp) {
|
||
$pscpCmd = Get-Command pscp -ErrorAction SilentlyContinue
|
||
if ($pscpCmd) { $pscp = $pscpCmd.Source }
|
||
}
|
||
|
||
return @{ Plink = $plink; Pscp = $pscp }
|
||
}
|
||
|
||
function Test-SSHKeyAuth {
|
||
param(
|
||
[string]$Server,
|
||
[string]$Username,
|
||
[string]$KeyPath = "$env:USERPROFILE\.ssh\id_rsa"
|
||
)
|
||
|
||
# 检查SSH密钥是否存在
|
||
if (-not (Test-Path $KeyPath)) {
|
||
return $false
|
||
}
|
||
|
||
# 设置正确的权限(仅 Windows 需要)
|
||
$acl = Get-Acl $KeyPath
|
||
$acl.SetAccessRuleProtection($true, $false)
|
||
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($env:USERNAME, "FullControl", "Allow")
|
||
$acl.SetAccessRule($rule)
|
||
Set-Acl $KeyPath $acl
|
||
|
||
# 测试SSH密钥连接
|
||
try {
|
||
$null = ssh -o BatchMode=yes -o ConnectTimeout=5 -i $KeyPath $Username@$Server "echo 'SSH key auth success'" 2>$null
|
||
return $LASTEXITCODE -eq 0
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
function Get-SSHKeyPath {
|
||
param([string]$Server)
|
||
|
||
# 优先检查标准SSH密钥文件
|
||
$keyPaths = @(
|
||
"$env:USERPROFILE\.ssh\youyidian_prod_ed25519",
|
||
"$env:USERPROFILE\.ssh\id_rsa",
|
||
"$env:USERPROFILE\.ssh\id_ed25519",
|
||
"$env:USERPROFILE\.ssh\id_ecdsa",
|
||
"$env:USERPROFILE\.ssh\id_dsa"
|
||
)
|
||
|
||
foreach ($keyPath in $keyPaths) {
|
||
if (Test-Path $keyPath) {
|
||
Write-Host "发现SSH密钥: $keyPath" -ForegroundColor Gray
|
||
return $keyPath
|
||
}
|
||
}
|
||
|
||
# 检查是否有自定义密钥路径配置
|
||
$sshConfig = "$env:USERPROFILE\.ssh\config"
|
||
if (Test-Path $sshConfig) {
|
||
$configContent = Get-Content $sshConfig -ErrorAction SilentlyContinue
|
||
$serverConfig = $configContent | Where-Object { $_ -match [regex]::Escape($Server) -or $_ -match "^Host\s+$Server" }
|
||
if ($serverConfig) {
|
||
foreach ($line in $serverConfig) {
|
||
if ($line -match "IdentityFile\s+(.+)\s*") {
|
||
$keyPath = $matches[1].Trim().Replace('~', $env:USERPROFILE)
|
||
if (Test-Path $keyPath) {
|
||
Write-Host "从SSH配置中发现密钥: $keyPath" -ForegroundColor Gray
|
||
return $keyPath
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $null
|
||
}
|
||
|
||
function Get-AuthMethod {
|
||
param(
|
||
[string]$Server,
|
||
[string]$Username,
|
||
# 注意:使用 String 类型是为了兼容性,实际使用时密码会通过命令行参数传递
|
||
[string]$Password,
|
||
[string]$KeyPath = "$env:USERPROFILE\.ssh\id_rsa"
|
||
)
|
||
|
||
Write-Host "检查SSH认证方式..." -ForegroundColor Gray
|
||
|
||
# 优先尝试SSH密钥认证
|
||
if (-not $KeyPath) {
|
||
$KeyPath = Get-SSHKeyPath -Server $Server
|
||
}
|
||
|
||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||
Write-Host "检测到SSH密钥: $KeyPath" -ForegroundColor Gray
|
||
if (Test-SSHKeyAuth -Server $Server -Username $Username -KeyPath $KeyPath) {
|
||
Write-Host "✓ SSH密钥认证成功" -ForegroundColor Green
|
||
return @{
|
||
Method = "key"
|
||
KeyPath = $KeyPath
|
||
Tool = "ssh"
|
||
}
|
||
} else {
|
||
Write-Host "✗ SSH密钥认证失败" -ForegroundColor Yellow
|
||
}
|
||
}
|
||
|
||
# 尝试使用PuTTY工具的密钥认证(如果密钥存在)
|
||
$puttyTools = Find-PuTTYTools
|
||
if ($puttyTools.Plink -and (Test-Path $puttyTools.Plink)) {
|
||
$puttyKeys = @(
|
||
"$env:USERPROFILE\.ssh\youyidian_prod_ed25519",
|
||
"$env:USERPROFILE\.ssh\id_rsa",
|
||
"$env:USERPROFILE\.ssh\id_ed25519"
|
||
)
|
||
|
||
foreach ($puttyKey in $puttyKeys) {
|
||
if (Test-Path $puttyKey) {
|
||
# 尝试使用 PuTTY 格式的密钥(需要转换)
|
||
Write-Host "尝试PuTTY密钥认证..." -ForegroundColor Gray
|
||
if (Test-SSHKeyAuth -Server $Server -Username $Username -KeyPath $puttyKey) {
|
||
Write-Host "✓ PuTTY密钥认证成功" -ForegroundColor Green
|
||
return @{
|
||
Method = "key-putty"
|
||
KeyPath = $puttyKey
|
||
Tool = "plink/pscp"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# 最后尝试密码认证
|
||
if ($Password) {
|
||
Write-Host "尝试密码认证..." -ForegroundColor Gray
|
||
if ($puttyTools.Plink -and (Test-Path $puttyTools.Plink)) {
|
||
return @{
|
||
Method = "password"
|
||
Tool = "plink/pscp"
|
||
}
|
||
} else {
|
||
Write-Host "⚠ 未找到PuTTY工具,将使用交互式SSH(需要手动输入密码)" -ForegroundColor Yellow
|
||
return @{
|
||
Method = "password-interactive"
|
||
Tool = "ssh/scp"
|
||
}
|
||
}
|
||
}
|
||
|
||
throw "未找到可用的SSH认证方式,请检查SSH密钥或提供密码"
|
||
}
|
||
|
||
function Get-ServerHostKey {
|
||
param([string]$Server)
|
||
|
||
$hostKey = $null
|
||
try {
|
||
# 方法1: 先从 known_hosts 文件中读取(如果已经连接过服务器)
|
||
$knownHostsPath = Join-Path $env:USERPROFILE ".ssh\known_hosts"
|
||
if (Test-Path $knownHostsPath) {
|
||
$knownHostsContent = Get-Content $knownHostsPath -ErrorAction SilentlyContinue
|
||
if ($knownHostsContent) {
|
||
# known_hosts 格式:hostname,ip key-type key-data [comment]
|
||
# 查找服务器对应的密钥行(匹配服务器 IP 或主机名)
|
||
$matchingLines = $knownHostsContent | Where-Object {
|
||
$_ -notmatch '^\s*#' -and # 排除注释行
|
||
$_ -match '\s+(ssh-\w+|ecdsa-sha2-\S+)\s+' -and # 包含密钥类型
|
||
($_ -match [regex]::Escape($Server) -or $_ -match "^$Server,") # 匹配服务器地址
|
||
}
|
||
|
||
if ($matchingLines) {
|
||
# 优先选择 ed25519 密钥
|
||
$keyLine = $matchingLines | Where-Object { $_ -match 'ssh-ed25519' } | Select-Object -First 1
|
||
if (-not $keyLine) {
|
||
$keyLine = $matchingLines | Where-Object { $_ -match 'ecdsa-sha2' } | Select-Object -First 1
|
||
}
|
||
if (-not $keyLine) {
|
||
$keyLine = $matchingLines | Where-Object { $_ -match 'ssh-rsa' } | Select-Object -First 1
|
||
}
|
||
|
||
if ($keyLine) {
|
||
# 解析密钥行:提取密钥类型和密钥数据
|
||
if ($keyLine -match '\s+(ssh-\w+|ecdsa-sha2-\S+)\s+(\S+)') {
|
||
$keyType = $matches[1]
|
||
$keyData = $matches[2]
|
||
# 计算 SHA256 指纹
|
||
$tempKeyFile = [System.IO.Path]::GetTempFileName()
|
||
try {
|
||
# known_hosts 格式需要转换为标准格式:hostname key-type key-data
|
||
"$Server $keyType $keyData" | Out-File -FilePath $tempKeyFile -Encoding ASCII -NoNewline
|
||
$fingerprintOutput = & ssh-keygen -lf $tempKeyFile -E sha256 2>$null
|
||
if ($fingerprintOutput) {
|
||
$fingerprint = ($fingerprintOutput -split '\s+')[1]
|
||
if ($fingerprint) {
|
||
# plink/pscp 的 -hostkey 格式:仅使用 SHA256:指纹(不需要算法名)
|
||
# fingerprint 已经包含 SHA256: 前缀
|
||
$hostKey = $fingerprint
|
||
return $hostKey
|
||
}
|
||
}
|
||
} finally {
|
||
Remove-Item -Path $tempKeyFile -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# 方法2: 如果 known_hosts 中没有,使用 ssh-keyscan 获取服务器公钥(不需要密码)
|
||
# 优先使用 ed25519(更安全且 plink 支持更好),然后是 ecdsa,最后是 rsa
|
||
$keyscanOutput = ssh-keyscan -t ed25519,ecdsa,rsa $Server 2>$null
|
||
if ($keyscanOutput) {
|
||
# 过滤掉注释行,优先选择 ed25519 密钥
|
||
$keyLine = $keyscanOutput | Where-Object { $_ -notmatch '^\s*#' -and $_ -match 'ssh-ed25519' } | Select-Object -First 1
|
||
if (-not $keyLine) {
|
||
# 如果没有 ed25519,尝试 ecdsa
|
||
$keyLine = $keyscanOutput | Where-Object { $_ -notmatch '^\s*#' -and $_ -match 'ecdsa-sha2' } | Select-Object -First 1
|
||
}
|
||
if (-not $keyLine) {
|
||
# 最后使用 rsa
|
||
$keyLine = $keyscanOutput | Where-Object { $_ -notmatch '^\s*#' -and $_ -match 'ssh-rsa' } | Select-Object -First 1
|
||
}
|
||
if ($keyLine) {
|
||
$keyLine = $keyLine.ToString().Trim()
|
||
if ($keyLine -match '^\S+\s+(ssh-\w+)\s+(\S+)') {
|
||
$keyType = $matches[1]
|
||
$keyData = $matches[2]
|
||
# 计算 SHA256 指纹
|
||
$tempKeyFile = [System.IO.Path]::GetTempFileName()
|
||
try {
|
||
"$keyLine" | Out-File -FilePath $tempKeyFile -Encoding ASCII -NoNewline
|
||
$fingerprintOutput = & ssh-keygen -lf $tempKeyFile -E sha256 2>$null
|
||
if ($fingerprintOutput) {
|
||
$fingerprint = ($fingerprintOutput -split '\s+')[1]
|
||
if ($fingerprint) {
|
||
# plink/pscp 的 -hostkey 格式:仅使用 SHA256:指纹(不需要算法名)
|
||
# fingerprint 已经包含 SHA256: 前缀
|
||
$hostKey = $fingerprint
|
||
}
|
||
}
|
||
} finally {
|
||
Remove-Item -Path $tempKeyFile -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
# 如果获取密钥失败,返回 null
|
||
}
|
||
|
||
return $hostKey
|
||
}
|
||
|
||
function Invoke-SSHCommand {
|
||
param(
|
||
[string]$Server,
|
||
[string]$Username,
|
||
[string]$Command,
|
||
# 注意:使用 String 类型是为了兼容性,实际使用时密码会通过命令行参数传递
|
||
[string]$Password,
|
||
[hashtable]$AuthInfo
|
||
)
|
||
|
||
$sshTarget = "${Username}@${Server}"
|
||
|
||
# 使用统一的认证方式
|
||
if (-not $AuthInfo) {
|
||
try {
|
||
$AuthInfo = Get-AuthMethod -Server $Server -Username $Username -Password $Password
|
||
} catch {
|
||
Write-Host "获取认证方式失败: $_" -ForegroundColor Red
|
||
throw $_
|
||
}
|
||
}
|
||
|
||
Write-Host "使用认证方式: $($AuthInfo.Method)" -ForegroundColor Gray
|
||
|
||
# 根据认证方式选择不同的工具和方法
|
||
switch ($AuthInfo.Method) {
|
||
"key" {
|
||
# 使用SSH密钥认证
|
||
Write-Host "使用SSH密钥: $($AuthInfo.KeyPath)" -ForegroundColor Gray
|
||
$output = ssh -o BatchMode=yes -i $AuthInfo.KeyPath $sshTarget $Command 2>&1
|
||
}
|
||
"key-putty" {
|
||
# 使用PuTTY工具的密钥认证
|
||
$puttyTools = Find-PuTTYTools
|
||
$plink = $puttyTools.Plink
|
||
if (-not ($plink -and (Test-Path $plink))) {
|
||
throw "未找到plink工具,无法使用PuTTY密钥认证"
|
||
}
|
||
|
||
Write-Host "使用PuTTY密钥: $($AuthInfo.KeyPath)" -ForegroundColor Gray
|
||
# 自动获取服务器密钥
|
||
$hostKey = Get-ServerHostKey -Server $Server
|
||
|
||
if ($hostKey) {
|
||
Write-Host "使用服务器密钥: $hostKey" -ForegroundColor Gray
|
||
$output = & $plink -batch -ssh -hostkey $hostKey -i $AuthInfo.KeyPath $sshTarget $Command 2>&1
|
||
} else {
|
||
Write-Host "警告: 无法获取服务器密钥指纹" -ForegroundColor Yellow
|
||
$output = & $plink -batch -ssh -i $AuthInfo.KeyPath $sshTarget $Command 2>&1
|
||
}
|
||
}
|
||
"password" {
|
||
# 使用密码认证(PuTTY)
|
||
$puttyTools = Find-PuTTYTools
|
||
$plink = $puttyTools.Plink
|
||
if (-not ($plink -and (Test-Path $plink))) {
|
||
throw "未找到plink工具,无法使用密码认证"
|
||
}
|
||
|
||
# 如果没有传递密码参数,尝试从作用域变量获取
|
||
if (-not $Password) {
|
||
$Password = (Get-Variable -Name Password -Scope 1 -ErrorAction SilentlyContinue).Value
|
||
}
|
||
|
||
if (-not $Password) {
|
||
throw "密码认证需要密码,但未提供密码参数"
|
||
}
|
||
|
||
Write-Host "使用密码认证" -ForegroundColor Gray
|
||
$hostKey = Get-ServerHostKey -Server $Server
|
||
|
||
if ($hostKey) {
|
||
Write-Host "使用服务器密钥: $hostKey" -ForegroundColor Gray
|
||
$output = & $plink -batch -ssh -hostkey $hostKey -pw $Password $sshTarget $Command 2>&1
|
||
} else {
|
||
Write-Host "警告: 无法获取服务器密钥指纹" -ForegroundColor Yellow
|
||
$output = & $plink -batch -ssh -pw $Password $sshTarget $Command 2>&1
|
||
}
|
||
}
|
||
"password-interactive" {
|
||
# 交互式密码认证
|
||
Write-Host "使用交互式密码认证" -ForegroundColor Yellow
|
||
Write-Host "请手动输入密码" -ForegroundColor Gray
|
||
$output = ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $sshTarget $Command 2>&1
|
||
}
|
||
default {
|
||
throw "不支持的认证方式: $($AuthInfo.Method)"
|
||
}
|
||
}
|
||
|
||
# 输出命令结果
|
||
$output | ForEach-Object { Write-Host $_ -ForegroundColor Gray }
|
||
$exitCode = $LASTEXITCODE
|
||
$global:LASTEXITCODE = $exitCode
|
||
return $exitCode
|
||
}
|
||
|
||
function Invoke-SCPUpload {
|
||
param(
|
||
[string]$LocalPath,
|
||
[string]$Server,
|
||
[string]$Username,
|
||
[string]$RemotePath,
|
||
# 注意:使用 String 类型是为了兼容性,实际使用时密码会通过命令行参数传递
|
||
[string]$Password,
|
||
[hashtable]$AuthInfo
|
||
)
|
||
|
||
$remoteTarget = "${Username}@${Server}:${RemotePath}/"
|
||
|
||
# 使用统一的认证方式
|
||
if (-not $AuthInfo) {
|
||
try {
|
||
$AuthInfo = Get-AuthMethod -Server $Server -Username $Username -Password $Password
|
||
} catch {
|
||
Write-Host "获取认证方式失败: $_" -ForegroundColor Red
|
||
throw $_
|
||
}
|
||
}
|
||
|
||
Write-Host "使用认证方式上传: $($AuthInfo.Method)" -ForegroundColor Gray
|
||
|
||
# 根据认证方式选择不同的工具和方法
|
||
switch ($AuthInfo.Method) {
|
||
"key" {
|
||
# 使用SSH密钥认证上传
|
||
Write-Host "使用SSH密钥上传: $($AuthInfo.KeyPath)" -ForegroundColor Gray
|
||
$process = Start-Process -FilePath "scp" -ArgumentList @("-i", $AuthInfo.KeyPath, "-o", "BatchMode=yes", $LocalPath, $remoteTarget) -NoNewWindow -PassThru -Wait
|
||
return $process.ExitCode
|
||
}
|
||
"key-putty" {
|
||
# 使用PuTTY工具的密钥认证上传
|
||
$puttyTools = Find-PuTTYTools
|
||
$pscp = $puttyTools.Pscp
|
||
if (-not ($pscp -and (Test-Path $pscp))) {
|
||
throw "未找到pscp工具,无法使用PuTTY密钥认证上传"
|
||
}
|
||
|
||
Write-Host "使用PuTTY密钥上传: $($AuthInfo.KeyPath)" -ForegroundColor Gray
|
||
# 自动获取服务器密钥
|
||
$hostKey = Get-ServerHostKey -Server $Server
|
||
|
||
if ($hostKey) {
|
||
Write-Host "使用服务器密钥: $hostKey" -ForegroundColor Gray
|
||
$process = Start-Process -FilePath $pscp -ArgumentList @(
|
||
"-batch", "-hostkey", $hostKey, "-i", $AuthInfo.KeyPath,
|
||
$LocalPath, $remoteTarget
|
||
) -NoNewWindow -PassThru -Wait
|
||
} else {
|
||
Write-Host "警告: 无法获取服务器密钥指纹" -ForegroundColor Yellow
|
||
$process = Start-Process -FilePath $pscp -ArgumentList @(
|
||
"-batch", "-i", $AuthInfo.KeyPath,
|
||
$LocalPath, $remoteTarget
|
||
) -NoNewWindow -PassThru -Wait
|
||
}
|
||
return $process.ExitCode
|
||
}
|
||
"password" {
|
||
# 使用密码认证上传(PuTTY)
|
||
$puttyTools = Find-PuTTYTools
|
||
$pscp = $puttyTools.Pscp
|
||
if (-not ($pscp -and (Test-Path $pscp))) {
|
||
throw "未找到pscp工具,无法使用密码认证上传"
|
||
}
|
||
|
||
# 如果没有传递密码参数,尝试从作用域变量获取
|
||
if (-not $Password) {
|
||
$Password = (Get-Variable -Name Password -Scope 1 -ErrorAction SilentlyContinue).Value
|
||
}
|
||
|
||
if (-not $Password) {
|
||
throw "密码认证需要密码,但未提供密码参数"
|
||
}
|
||
|
||
Write-Host "使用密码认证上传" -ForegroundColor Gray
|
||
$hostKey = Get-ServerHostKey -Server $Server
|
||
|
||
$exitCode = 0
|
||
if ($hostKey) {
|
||
Write-Host "使用服务器密钥: $hostKey" -ForegroundColor Gray
|
||
# 显示上传进度(pscp 会自动显示进度条)
|
||
# 将标准输出和错误输出分离,只捕获退出码
|
||
$null = & $pscp -batch -hostkey $hostKey -pw $Password $LocalPath $remoteTarget 2>&1
|
||
# pscp 成功时返回 0,失败时返回非 0
|
||
$exitCode = $LASTEXITCODE
|
||
# 如果 $LASTEXITCODE 未正确设置,检查输出中是否有错误
|
||
if ($null -eq $exitCode) {
|
||
$exitCode = if ($?) { 0 } else { 1 }
|
||
}
|
||
} else {
|
||
Write-Host "警告: 无法获取服务器密钥指纹" -ForegroundColor Yellow
|
||
# 显示上传进度(pscp 会自动显示进度条)
|
||
$null = & $pscp -batch -pw $Password $LocalPath $remoteTarget 2>&1
|
||
$exitCode = $LASTEXITCODE
|
||
if ($null -eq $exitCode) {
|
||
$exitCode = if ($?) { 0 } else { 1 }
|
||
}
|
||
}
|
||
|
||
return $exitCode
|
||
}
|
||
"password-interactive" {
|
||
# 交互式密码认证上传
|
||
Write-Host "使用交互式密码认证上传" -ForegroundColor Yellow
|
||
$process = Start-Process -FilePath "scp" -ArgumentList @("-o", "StrictHostKeyChecking=no", $LocalPath, $remoteTarget) -NoNewWindow -PassThru -Wait
|
||
return $process.ExitCode
|
||
}
|
||
default {
|
||
throw "不支持的认证方式: $($AuthInfo.Method)"
|
||
}
|
||
}
|
||
}
|
||
|
||
Assert-Command -Name "docker"
|
||
if (-not $SkipUpload) {
|
||
Assert-Command -Name "scp"
|
||
}
|
||
|
||
# 启用 BuildKit 以使用缓存挂载和更好的缓存策略
|
||
if (-not $env:DOCKER_BUILDKIT) {
|
||
$env:DOCKER_BUILDKIT = "1"
|
||
Write-Host "已启用 BuildKit 缓存" -ForegroundColor Green
|
||
}
|
||
if (-not $env:COMPOSE_DOCKER_CLI_BUILD) {
|
||
$env:COMPOSE_DOCKER_CLI_BUILD = "1"
|
||
}
|
||
|
||
$projectRoot = Resolve-Path "$PSScriptRoot/../.."
|
||
Push-Location $projectRoot
|
||
try {
|
||
# 智能跳过检查:如果 package.json 未变化,可以跳过检查
|
||
$shouldSkipCheck = $SkipCheck
|
||
if (-not $shouldSkipCheck) {
|
||
$packageHashFile = Join-Path $projectRoot ".last-package-hash"
|
||
$currentPackageHash = ""
|
||
if (Test-Path "package.json") {
|
||
$currentPackageHash = (Get-FileHash "package.json" -Algorithm SHA256).Hash
|
||
}
|
||
|
||
if (Test-Path $packageHashFile) {
|
||
$lastPackageHash = Get-Content $packageHashFile -ErrorAction SilentlyContinue
|
||
if ($currentPackageHash -eq $lastPackageHash) {
|
||
Write-Host "检测到 package.json 未变化,跳过类型检查和 lint(使用 -SkipCheck:$false 强制检查)" -ForegroundColor Yellow
|
||
$shouldSkipCheck = $true
|
||
}
|
||
}
|
||
|
||
if (-not $shouldSkipCheck -and $currentPackageHash) {
|
||
$currentPackageHash | Out-File $packageHashFile -NoNewline
|
||
}
|
||
}
|
||
|
||
if (-not $shouldSkipCheck) {
|
||
Write-Host "===> npm run check" -ForegroundColor Cyan
|
||
npm run check
|
||
}
|
||
|
||
$fullImage = "${ImageName}:${Tag}"
|
||
|
||
# 检查镜像是否存在
|
||
docker image inspect $fullImage 2>&1 | Out-Null
|
||
$imageExists = $LASTEXITCODE -eq 0
|
||
|
||
if (-not $SkipBuild) {
|
||
if ($env:DOCKER_CONFIG) {
|
||
Write-Host "使用 DOCKER_CONFIG=${env:DOCKER_CONFIG}" -ForegroundColor Cyan
|
||
}
|
||
# 构建时传递占位符环境变量,避免 Next.js 预渲染时缺少 DATABASE_URL
|
||
# 注意:这只是为了让构建通过,运行时仍需要真实的 DATABASE_URL
|
||
$buildArgs = @(
|
||
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",
|
||
"--build-arg", "NEXTAUTH_URL=http://45.249.208.118:4320",
|
||
"--build-arg", "APP_PUBLIC_URL=http://45.249.208.118:4320"
|
||
)
|
||
Write-Host "===> docker build -t $fullImage -f $Dockerfile $Context" -ForegroundColor Cyan
|
||
Write-Host "正在构建 Docker 镜像,这可能需要几分钟..." -ForegroundColor Yellow
|
||
Write-Host "提示: 如果长时间无响应,请检查网络连接或 Docker 状态" -ForegroundColor Gray
|
||
Write-Host "提示: 使用 BuildKit 缓存,如果依赖未变化,构建会更快" -ForegroundColor Gray
|
||
|
||
# 尝试使用之前的镜像作为缓存源
|
||
$cacheFromArgs = @()
|
||
$previousImage = "${ImageName}:previous"
|
||
docker image inspect $previousImage 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
$cacheFromArgs = @("--cache-from", $previousImage)
|
||
Write-Host "使用之前的镜像作为缓存源: $previousImage" -ForegroundColor Gray
|
||
}
|
||
|
||
# 使用 --progress=plain 避免终端跳动,输出更稳定
|
||
# 添加超时处理,避免无限等待
|
||
$buildStartTime = Get-Date
|
||
docker build --progress=plain $cacheFromArgs -t $fullImage -f $Dockerfile $buildArgs $Context
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "docker build 失败,终止部署"
|
||
}
|
||
$buildDuration = ((Get-Date) - $buildStartTime).TotalMinutes
|
||
Write-Host "构建完成,耗时: $([Math]::Round($buildDuration, 1)) 分钟" -ForegroundColor Green
|
||
|
||
# 标记当前镜像为 previous,供下次使用
|
||
docker tag $fullImage $previousImage 2>&1 | Out-Null
|
||
} else {
|
||
if (-not $imageExists) {
|
||
Write-Host "⚠ 镜像 $fullImage 不存在,但使用了 -SkipBuild 参数" -ForegroundColor Yellow
|
||
Write-Host "自动触发构建..." -ForegroundColor Yellow
|
||
if ($env:DOCKER_CONFIG) {
|
||
Write-Host "使用 DOCKER_CONFIG=${env:DOCKER_CONFIG}" -ForegroundColor Cyan
|
||
}
|
||
$buildArgs = @(
|
||
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",
|
||
"--build-arg", "NEXTAUTH_URL=http://45.249.208.118:4320",
|
||
"--build-arg", "APP_PUBLIC_URL=http://45.249.208.118:4320"
|
||
)
|
||
Write-Host "===> docker build -t $fullImage -f $Dockerfile $Context" -ForegroundColor Cyan
|
||
Write-Host "正在构建 Docker 镜像,这可能需要几分钟..." -ForegroundColor Yellow
|
||
Write-Host "提示: 如果长时间无响应,请检查网络连接或 Docker 状态" -ForegroundColor Gray
|
||
Write-Host "提示: 使用 BuildKit 缓存,如果依赖未变化,构建会更快" -ForegroundColor Gray
|
||
|
||
# 尝试使用之前的镜像作为缓存源
|
||
$cacheFromArgs = @()
|
||
$previousImage = "${ImageName}:previous"
|
||
docker image inspect $previousImage 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
$cacheFromArgs = @("--cache-from", $previousImage)
|
||
Write-Host "使用之前的镜像作为缓存源: $previousImage" -ForegroundColor Gray
|
||
}
|
||
|
||
# 使用 --progress=plain 避免终端跳动,输出更稳定
|
||
# 添加超时处理,避免无限等待
|
||
$buildStartTime = Get-Date
|
||
docker build --progress=plain $cacheFromArgs -t $fullImage -f $Dockerfile $buildArgs $Context
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "docker build 失败,终止部署"
|
||
}
|
||
$buildDuration = ((Get-Date) - $buildStartTime).TotalMinutes
|
||
Write-Host "构建完成,耗时: $([Math]::Round($buildDuration, 1)) 分钟" -ForegroundColor Green
|
||
|
||
# 标记当前镜像为 previous,供下次使用
|
||
docker tag $fullImage $previousImage 2>&1 | Out-Null
|
||
} else {
|
||
Write-Host "跳过构建,使用现有镜像:$fullImage" -ForegroundColor Yellow
|
||
}
|
||
}
|
||
|
||
# 再次确认镜像存在
|
||
docker image inspect $fullImage | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "镜像 $fullImage 不存在,无法继续。请确认标记是否正确或重新构建"
|
||
}
|
||
|
||
$archiveRoot = Join-Path $projectRoot $ArchiveDirectory
|
||
if (-not (Test-Path $archiveRoot)) {
|
||
New-Item -ItemType Directory -Path $archiveRoot | Out-Null
|
||
}
|
||
|
||
$timestamp = Get-Date -Format "yyyyMMddHHmmss"
|
||
$safeName = $ImageName -replace '[\\/]', '_'
|
||
$archivePath = Join-Path $archiveRoot "$safeName-$Tag-$timestamp.tar"
|
||
|
||
Write-Host "===> docker save $fullImage -> $archivePath" -ForegroundColor Cyan
|
||
Write-Host "正在打包镜像,请稍候..." -ForegroundColor Yellow
|
||
|
||
$startTime = Get-Date
|
||
|
||
# 启动后台任务监控文件大小并显示进度
|
||
$saveJob = Start-Job -ScriptBlock {
|
||
param($FilePath)
|
||
$startTime = Get-Date
|
||
$lastSize = 0
|
||
$lastTime = $startTime
|
||
while ($true) {
|
||
if (Test-Path $FilePath) {
|
||
$currentSize = (Get-Item $FilePath -ErrorAction SilentlyContinue).Length
|
||
$currentTime = Get-Date
|
||
$elapsed = ($currentTime - $lastTime).TotalSeconds
|
||
|
||
if ($elapsed -gt 0.3) { # 每0.3秒更新一次
|
||
$speed = if ($currentSize -gt $lastSize -and $elapsed -gt 0) { ($currentSize - $lastSize) / $elapsed } else { 0 }
|
||
$totalElapsed = ($currentTime - $startTime).TotalSeconds
|
||
$avgSpeed = if ($totalElapsed -gt 0) { $currentSize / $totalElapsed } else { 0 }
|
||
|
||
Write-Output @{
|
||
Size = $currentSize
|
||
Speed = $speed
|
||
AvgSpeed = $avgSpeed
|
||
Elapsed = $totalElapsed
|
||
}
|
||
$lastSize = $currentSize
|
||
$lastTime = $currentTime
|
||
}
|
||
}
|
||
Start-Sleep -Milliseconds 200
|
||
}
|
||
} -ArgumentList $archivePath
|
||
|
||
# 执行 docker save(在后台)
|
||
$dockerProcess = Start-Process -FilePath "docker" -ArgumentList @("save", "-o", $archivePath, $fullImage) -NoNewWindow -PassThru
|
||
|
||
# 监控进度
|
||
$estimatedSize = 500 * 1024 * 1024 # 初始估算 500MB,会动态调整
|
||
while (-not $dockerProcess.HasExited) {
|
||
$jobOutput = Receive-Job $saveJob -ErrorAction SilentlyContinue
|
||
if ($jobOutput) {
|
||
$latest = $jobOutput[-1]
|
||
$currentSize = $latest.Size
|
||
$speed = $latest.Speed
|
||
$avgSpeed = $latest.AvgSpeed
|
||
$elapsed = $latest.Elapsed
|
||
|
||
# 动态调整估算大小
|
||
if ($currentSize -gt 0 -and $avgSpeed -gt 0) {
|
||
$estimatedSize = [Math]::Max($estimatedSize, $currentSize + ($avgSpeed * 10)) # 预估未来10秒的增长
|
||
}
|
||
|
||
$percent = if ($estimatedSize -gt 0) { [Math]::Min(99, [int](($currentSize / $estimatedSize) * 100)) } else { 0 }
|
||
$status = "已打包: $(Format-FileSize $currentSize) | 速度: $(Format-Speed $speed) | 平均: $(Format-Speed $avgSpeed)"
|
||
|
||
Write-Progress -Activity "打包 Docker 镜像" -Status $status -PercentComplete $percent
|
||
}
|
||
Start-Sleep -Milliseconds 300
|
||
}
|
||
|
||
# 等待 docker save 完成
|
||
$dockerProcess.WaitForExit()
|
||
|
||
# 停止监控任务
|
||
Stop-Job $saveJob -ErrorAction SilentlyContinue
|
||
Remove-Job $saveJob -ErrorAction SilentlyContinue
|
||
Write-Progress -Activity "打包 Docker 镜像" -Completed
|
||
|
||
if ($dockerProcess.ExitCode -ne 0) {
|
||
throw "docker save 失败,退出码: $($dockerProcess.ExitCode)"
|
||
}
|
||
|
||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $archivePath)) {
|
||
throw "docker save 失败或文件未生成:$archivePath"
|
||
}
|
||
|
||
$finalSize = (Get-Item $archivePath).Length
|
||
Write-Host "✓ 镜像打包完成: $(Format-FileSize $finalSize)" -ForegroundColor Green
|
||
|
||
if (-not $SkipUpload) {
|
||
Write-Host "===> 测试 SSH 连接..." -ForegroundColor Cyan
|
||
Write-Host "正在连接到 ${Username}@${Server}..." -ForegroundColor Gray
|
||
if ($Password) {
|
||
Write-Host "已配置密码,将自动登录" -ForegroundColor Gray
|
||
} else {
|
||
Write-Host "提示: 如果未配置 SSH 密钥,需要手动输入密码" -ForegroundColor Yellow
|
||
}
|
||
|
||
# 获取统一的认证信息
|
||
try {
|
||
$AuthInfo = Get-AuthMethod -Server $Server -Username $Username -Password $Password
|
||
} catch {
|
||
Write-Host "获取认证信息失败: $_" -ForegroundColor Red
|
||
throw
|
||
}
|
||
|
||
# 测试 SSH 连接
|
||
$sshTest = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "echo 'SSH连接成功'" -AuthInfo $AuthInfo
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Host "SSH 连接失败!" -ForegroundColor Red
|
||
Write-Host "错误信息: $sshTest" -ForegroundColor Red
|
||
Write-Host "请检查:" -ForegroundColor Yellow
|
||
Write-Host " 1. 服务器地址是否正确: ${Server}" -ForegroundColor Yellow
|
||
Write-Host " 2. 用户名是否正确: ${Username}" -ForegroundColor Yellow
|
||
Write-Host " 3. 密码是否正确或SSH密钥配置是否正确" -ForegroundColor Yellow
|
||
Write-Host " 4. 网络连接是否正常" -ForegroundColor Yellow
|
||
throw "SSH 连接失败,无法继续部署"
|
||
}
|
||
Write-Host "✓ SSH 连接测试通过" -ForegroundColor Green
|
||
|
||
Write-Host "===> 确保远程目录 ${RemotePath} 存在" -ForegroundColor Cyan
|
||
|
||
$mkdirResult = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "mkdir -p ${RemotePath}" -AuthInfo $AuthInfo
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Host "创建远程目录失败: $mkdirResult" -ForegroundColor Red
|
||
throw "无法创建远程目录 ${RemotePath}"
|
||
}
|
||
Write-Host "✓ 远程目录已就绪" -ForegroundColor Green
|
||
|
||
$fileSize = (Get-Item $archivePath).Length
|
||
$fileName = Split-Path $archivePath -Leaf
|
||
$remoteTarget = "${Username}@${Server}:${RemotePath}/"
|
||
|
||
Write-Host "===> 上传镜像到服务器" -ForegroundColor Cyan
|
||
Write-Host "文件: $fileName" -ForegroundColor Gray
|
||
Write-Host "大小: $(Format-FileSize $fileSize)" -ForegroundColor Gray
|
||
Write-Host "目标: $remoteTarget" -ForegroundColor Gray
|
||
Write-Host ""
|
||
|
||
# 尝试使用 rsync(如果有,显示进度)
|
||
if (Get-Command rsync -ErrorAction SilentlyContinue) {
|
||
Write-Host "使用 rsync 上传(带进度显示)..." -ForegroundColor Yellow
|
||
$startTime = Get-Date
|
||
$rsyncProcess = Start-Process -FilePath "rsync" -ArgumentList @(
|
||
"-avz",
|
||
"--progress",
|
||
"--human-readable",
|
||
$archivePath,
|
||
"${Username}@${Server}:${RemotePath}/"
|
||
) -NoNewWindow -PassThru -Wait
|
||
|
||
if ($rsyncProcess.ExitCode -ne 0) {
|
||
throw "rsync 上传失败,退出码: $($rsyncProcess.ExitCode)"
|
||
}
|
||
} else {
|
||
# 使用 scp,显示上传进度
|
||
Write-Host "使用 scp 上传..." -ForegroundColor Yellow
|
||
$startTime = Get-Date
|
||
|
||
# 启动后台任务显示进度(基于时间估算)
|
||
$uploadJob = Start-Job -ScriptBlock {
|
||
param($StartTime, $FileSize)
|
||
while ($true) {
|
||
$elapsed = ((Get-Date) - $StartTime).TotalSeconds
|
||
if ($elapsed -gt 0.5) {
|
||
$estimatedSpeed = $FileSize / $elapsed # 假设匀速
|
||
$estimatedRemaining = if ($estimatedSpeed -gt 0) { ($FileSize / $estimatedSpeed) - $elapsed } else { 0 }
|
||
$percent = [Math]::Min(99, [int](($elapsed / ($elapsed + $estimatedRemaining + 1)) * 100))
|
||
|
||
Write-Output @{
|
||
Elapsed = $elapsed
|
||
EstimatedSpeed = $estimatedSpeed
|
||
EstimatedRemaining = $estimatedRemaining
|
||
Percent = $percent
|
||
}
|
||
}
|
||
Start-Sleep -Milliseconds 500
|
||
}
|
||
} -ArgumentList $startTime, $fileSize
|
||
|
||
# 执行 scp(使用统一认证方式)
|
||
Write-Host "正在上传文件,请稍候..." -ForegroundColor Yellow
|
||
$scpExitCode = 0
|
||
try {
|
||
$uploadResult = Invoke-SCPUpload -LocalPath $archivePath -Server $Server -Username $Username -RemotePath $RemotePath -Password $Password -AuthInfo $AuthInfo
|
||
$scpExitCode = $uploadResult
|
||
|
||
# 确保退出码是数字类型
|
||
if ($scpExitCode -is [string]) {
|
||
# 如果是字符串,尝试提取数字
|
||
if ($scpExitCode -match '(\d+)') {
|
||
$scpExitCode = [int]$matches[1]
|
||
} else {
|
||
$scpExitCode = 0 # 如果无法解析,假设成功
|
||
}
|
||
}
|
||
|
||
if ($scpExitCode -ne 0) {
|
||
throw "文件上传失败,退出码: $scpExitCode"
|
||
}
|
||
Write-Host "✓ 文件上传成功" -ForegroundColor Green
|
||
} catch {
|
||
Write-Host "✗ 文件上传失败: $_" -ForegroundColor Red
|
||
# 检查错误信息中是否包含 "100%",如果包含则可能是误报
|
||
if ($_ -match "100%") {
|
||
Write-Host "⚠ 检测到上传进度显示 100%,可能是误报,继续执行..." -ForegroundColor Yellow
|
||
$scpExitCode = 0
|
||
} else {
|
||
throw "文件上传失败: $_"
|
||
}
|
||
} finally {
|
||
# 停止进度显示任务
|
||
Stop-Job $uploadJob -ErrorAction SilentlyContinue
|
||
Remove-Job $uploadJob -ErrorAction SilentlyContinue
|
||
Write-Progress -Activity "上传镜像到服务器" -Completed
|
||
}
|
||
|
||
if ($scpExitCode -ne 0) {
|
||
throw "scp 上传失败,退出码: $scpExitCode,请检查网络或远程目录权限"
|
||
}
|
||
}
|
||
|
||
$endTime = Get-Date
|
||
$uploadDuration = ($endTime - $startTime).TotalSeconds
|
||
$uploadSpeed = $fileSize / $uploadDuration
|
||
Write-Host ""
|
||
Write-Host "✓ 上传完成!" -ForegroundColor Green
|
||
Write-Host " 耗时: $([Math]::Round($uploadDuration, 1)) 秒" -ForegroundColor Gray
|
||
Write-Host " 平均速度: $(Format-Speed $uploadSpeed)" -ForegroundColor Gray
|
||
|
||
Write-Host ""
|
||
Write-Host "===> 上传 docker-compose 文件到服务器" -ForegroundColor Cyan
|
||
$composeFile = Join-Path $projectRoot $ComposeFile
|
||
$composeFileUploaded = $false
|
||
|
||
# 如果指定的文件不存在,尝试使用 docker-compose.yml 作为后备
|
||
if (-not (Test-Path $composeFile)) {
|
||
$fallbackComposeFile = Join-Path $projectRoot "docker-compose.yml"
|
||
if (Test-Path $fallbackComposeFile) {
|
||
Write-Host "警告: 未找到指定的 $ComposeFile,使用 docker-compose.yml 作为后备" -ForegroundColor Yellow
|
||
$composeFile = $fallbackComposeFile
|
||
}
|
||
}
|
||
|
||
if (Test-Path $composeFile) {
|
||
$composeFileName = Split-Path $composeFile -Leaf
|
||
Write-Host "正在上传 $composeFileName..." -ForegroundColor Gray
|
||
$composeUploadResult = Invoke-SCPUpload -LocalPath $composeFile -Server $Server -Username $Username -RemotePath $RemotePath -AuthInfo $AuthInfo
|
||
if ($composeUploadResult -ne 0) {
|
||
Write-Host "警告: $composeFileName 上传失败,退出码: $composeUploadResult" -ForegroundColor Yellow
|
||
Write-Host "提示: 将跳过自动部署,请手动上传 docker-compose 文件" -ForegroundColor Yellow
|
||
} else {
|
||
Write-Host "✓ $composeFileName 上传成功" -ForegroundColor Green
|
||
$composeFileUploaded = $true
|
||
|
||
# 如果上传的不是 docker-compose.yml,需要重命名或创建符号链接
|
||
if ($composeFileName -ne "docker-compose.yml") {
|
||
Write-Host "正在创建 docker-compose.yml 链接..." -ForegroundColor Gray
|
||
$linkResult = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "cd ${RemotePath} && cp $composeFileName docker-compose.yml" -AuthInfo $AuthInfo
|
||
if ($linkResult -eq 0) {
|
||
Write-Host "✓ 已创建 docker-compose.yml" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "警告: 创建 docker-compose.yml 失败,部署时将使用 $composeFileName" -ForegroundColor Yellow
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
Write-Host "警告: 未找到 docker-compose 文件($ComposeFile 或 docker-compose.yml),将检查服务器上是否已存在" -ForegroundColor Yellow
|
||
}
|
||
|
||
Write-Host ""
|
||
Write-Host "===> 在服务器上加载镜像并部署" -ForegroundColor Cyan
|
||
$tarFileName = Split-Path $archivePath -Leaf
|
||
$remoteTarPath = "${RemotePath}/${tarFileName}"
|
||
|
||
# 加载镜像
|
||
Write-Host "正在加载镜像..." -ForegroundColor Gray
|
||
$loadExitCode = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "docker load -i ${remoteTarPath}" -AuthInfo $AuthInfo
|
||
if ($loadExitCode -ne 0) {
|
||
Write-Host "加载镜像失败,退出码: $loadExitCode" -ForegroundColor Red
|
||
throw "无法在服务器上加载镜像"
|
||
}
|
||
Write-Host "✓ 镜像加载成功" -ForegroundColor Green
|
||
|
||
# 检查是否存在 docker-compose.yml(如果刚刚上传成功,直接尝试部署)
|
||
$deploymentSucceeded = $false
|
||
if (-not $composeFileUploaded) {
|
||
Write-Host "检查服务器上的 docker-compose.yml 文件..." -ForegroundColor Gray
|
||
$checkComposeExitCode = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "test -f ${RemotePath}/docker-compose.yml" -AuthInfo $AuthInfo
|
||
} else {
|
||
# 如果刚刚上传成功,直接使用
|
||
$checkComposeExitCode = 0
|
||
}
|
||
|
||
if ($checkComposeExitCode -eq 0) {
|
||
# 部署(使用 docker compose)
|
||
# 注意:docker-compose.prod.yml 使用私有镜像仓库格式,我们需要修改为本地镜像名称
|
||
Write-Host "正在部署应用..." -ForegroundColor Gray
|
||
$imageTag = $Tag
|
||
$fullImageName = "${ImageName}:${imageTag}"
|
||
|
||
# 直接修改 docker-compose.yml 文件中的镜像名称(更可靠)
|
||
Write-Host "更新 docker-compose.yml 中的镜像名称..." -ForegroundColor Gray
|
||
$updateImageCmd = "cd ${RemotePath} && sed -i 's|image:.*flnxi-material.*|image: ${fullImageName}|g' docker-compose.yml"
|
||
$updateResult = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command $updateImageCmd -AuthInfo $AuthInfo
|
||
if ($updateResult -ne 0) {
|
||
Write-Host "警告: 更新镜像名称失败,将使用环境变量方式" -ForegroundColor Yellow
|
||
} else {
|
||
Write-Host "✓ 镜像名称已更新为: ${fullImageName}" -ForegroundColor Green
|
||
}
|
||
|
||
# 先强制停止并删除旧容器,然后启动新容器
|
||
Write-Host "停止并清理旧容器..." -ForegroundColor Gray
|
||
# 无论 docker compose down 是否成功,都尝试强制删除可能存在的旧容器
|
||
Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "cd ${RemotePath} && docker compose down --remove-orphans" -AuthInfo $AuthInfo | Out-Null
|
||
Write-Host "强制删除可能存在的旧容器..." -ForegroundColor Gray
|
||
Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "docker rm -f flnxi-material 2>/dev/null || true" -AuthInfo $AuthInfo | Out-Null
|
||
|
||
$deployExitCode = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "cd ${RemotePath} && docker compose up -d" -AuthInfo $AuthInfo
|
||
if ($deployExitCode -ne 0) {
|
||
Write-Host "部署失败,退出码: $deployExitCode" -ForegroundColor Red
|
||
Write-Host "提示: 请手动检查服务器上的 docker-compose.yml 文件" -ForegroundColor Yellow
|
||
throw "无法部署应用"
|
||
}
|
||
Write-Host "✓ 容器启动成功" -ForegroundColor Green
|
||
|
||
# 等待容器完全启动(智能等待,检查健康状态)
|
||
Write-Host "等待容器就绪..." -ForegroundColor Gray
|
||
$maxWaitTime = 60 # 最大等待时间(秒)
|
||
$waitInterval = 2 # 检查间隔(秒)
|
||
$elapsedTime = 0
|
||
$containersReady = $false
|
||
|
||
while ($elapsedTime -lt $maxWaitTime -and -not $containersReady) {
|
||
# 检查容器健康状态
|
||
$healthOutput = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "cd ${RemotePath} && docker compose ps --format '{{.Name}}:{{.Status}}' 2>/dev/null | head -3" -AuthInfo $AuthInfo
|
||
|
||
# 检查 postgres 和 minio 是否健康
|
||
if ($healthOutput -match "healthy" -or $elapsedTime -gt 15) {
|
||
# 至少等待 15 秒确保服务启动,或者检测到健康状态
|
||
$containersReady = $true
|
||
Write-Host "✓ 容器已就绪" -ForegroundColor Green
|
||
} else {
|
||
Start-Sleep -Seconds $waitInterval
|
||
$elapsedTime += $waitInterval
|
||
Write-Host " 等待中... (${elapsedTime}s/${maxWaitTime}s)" -ForegroundColor Gray
|
||
}
|
||
}
|
||
|
||
if (-not $containersReady) {
|
||
Write-Host "警告: 容器健康检查超时,但继续执行部署步骤" -ForegroundColor Yellow
|
||
}
|
||
|
||
# 备份数据库(在执行迁移前)
|
||
Write-Host "备份数据库..." -ForegroundColor Cyan
|
||
$backupTimestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||
$backupFileName = "backup_${backupTimestamp}.sql"
|
||
$backupPath = "${RemotePath}/backups"
|
||
|
||
# 确保备份目录存在
|
||
$null = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "mkdir -p ${backupPath}" -AuthInfo $AuthInfo
|
||
|
||
# 执行数据库备份
|
||
# 从环境变量或 docker-compose.yml 中获取数据库名称,默认使用 flnxi
|
||
# 注意:如果实际数据库名称不同,请修改此处的数据库名称
|
||
$dbName = "flnxi" # 默认数据库名称,与 docker-compose.prod.yml 保持一致
|
||
$backupExitCode = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "cd ${RemotePath} && docker compose exec -T postgres pg_dump -U postgres ${dbName} > ${backupPath}/${backupFileName} 2>&1" -AuthInfo $AuthInfo
|
||
if ($backupExitCode -eq 0) {
|
||
# 检查备份文件是否生成
|
||
$checkBackupResult = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "test -f ${backupPath}/${backupFileName} && echo 'exists' || echo 'not_found'" -AuthInfo $AuthInfo
|
||
if ($checkBackupResult -eq 0) {
|
||
Write-Host "✓ 数据库备份成功: ${backupFileName}" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "警告: 备份文件可能未生成,但继续执行迁移" -ForegroundColor Yellow
|
||
}
|
||
} else {
|
||
Write-Host "警告: 数据库备份失败,但继续执行迁移" -ForegroundColor Yellow
|
||
Write-Host "提示: 建议手动备份数据库后再执行迁移" -ForegroundColor Yellow
|
||
}
|
||
|
||
# 执行数据库迁移
|
||
Write-Host "执行数据库迁移..." -ForegroundColor Gray
|
||
# 使用 db push 而不是 migrate deploy,因为可能没有迁移文件
|
||
# 不使用 --accept-data-loss,避免数据丢失
|
||
# 根据参数决定是否跳过 Prisma Client 生成
|
||
# 默认跳过(因为镜像构建时已生成),如果 schema 有变化但镜像未更新,可禁用此参数
|
||
# 如果用户没有显式指定,默认跳过($true)
|
||
$shouldSkipGenerate = -not $PSBoundParameters.ContainsKey('SkipPrismaGenerate') -or $SkipPrismaGenerate
|
||
$skipGenerateFlag = if ($shouldSkipGenerate) { "--skip-generate" } else { "" }
|
||
if ($shouldSkipGenerate) {
|
||
Write-Host "提示: 跳过 Prisma Client 生成(镜像中已包含)" -ForegroundColor Gray
|
||
} else {
|
||
Write-Host "提示: 将重新生成 Prisma Client(适用于 schema 有变化的情况)" -ForegroundColor Yellow
|
||
}
|
||
$migrateCommand = "cd ${RemotePath} && docker compose exec -T app npx prisma db push --schema=/app/prisma/schema.prisma $skipGenerateFlag"
|
||
$migrateExitCode = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command $migrateCommand -AuthInfo $AuthInfo
|
||
if ($migrateExitCode -eq 0) {
|
||
Write-Host "✓ 数据库迁移成功" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "警告: 数据库迁移失败,退出码: $migrateExitCode" -ForegroundColor Yellow
|
||
Write-Host "提示: 请手动检查数据库连接和迁移状态" -ForegroundColor Yellow
|
||
Write-Host "提示: 如需恢复,可使用备份文件: ${backupPath}/${backupFileName}" -ForegroundColor Yellow
|
||
Write-Host "手动执行: ssh ${Username}@${Server} 'cd ${RemotePath} && docker compose exec app npx prisma db push'" -ForegroundColor Gray
|
||
}
|
||
|
||
# 健康检查和自动验证
|
||
Write-Host ""
|
||
Write-Host "===> 健康检查和自动验证" -ForegroundColor Cyan
|
||
|
||
# 等待应用完全启动(智能等待,检查健康检查端点)
|
||
Write-Host "等待应用完全启动..." -ForegroundColor Gray
|
||
$maxWaitTime = 30 # 最大等待时间(秒)
|
||
$waitInterval = 2 # 检查间隔(秒)
|
||
$elapsedTime = 0
|
||
$appReady = $false
|
||
|
||
while ($elapsedTime -lt $maxWaitTime -and -not $appReady) {
|
||
try {
|
||
$healthResponse = Invoke-WebRequest -Uri "http://${Server}:4320/api/health" -Method GET -TimeoutSec 2 -ErrorAction SilentlyContinue
|
||
if ($healthResponse.StatusCode -eq 200) {
|
||
$appReady = $true
|
||
Write-Host "✓ 应用已就绪" -ForegroundColor Green
|
||
}
|
||
} catch {
|
||
# 应用尚未就绪,继续等待
|
||
}
|
||
|
||
if (-not $appReady) {
|
||
Start-Sleep -Seconds $waitInterval
|
||
$elapsedTime += $waitInterval
|
||
Write-Host " 等待中... (${elapsedTime}s/${maxWaitTime}s)" -ForegroundColor Gray
|
||
}
|
||
}
|
||
|
||
if (-not $appReady) {
|
||
Write-Host "警告: 应用健康检查超时,但继续执行验证步骤" -ForegroundColor Yellow
|
||
}
|
||
|
||
# 检查容器状态
|
||
Write-Host "检查容器状态..." -ForegroundColor Gray
|
||
$containerStatusResult = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "cd ${RemotePath} && docker compose ps" -AuthInfo $AuthInfo
|
||
if ($containerStatusResult -eq 0) {
|
||
Write-Host "✓ 容器状态检查完成" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "警告: 容器状态检查失败" -ForegroundColor Yellow
|
||
}
|
||
|
||
# 检查应用健康状态(如果配置了健康检查端点)
|
||
Write-Host "检查应用健康状态..." -ForegroundColor Gray
|
||
$healthCheckUrl = "http://${Server}:4320/api/health"
|
||
try {
|
||
$healthResponse = Invoke-WebRequest -Uri $healthCheckUrl -Method GET -TimeoutSec 10 -ErrorAction SilentlyContinue
|
||
if ($healthResponse.StatusCode -eq 200) {
|
||
Write-Host "✓ 应用健康检查通过" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "警告: 应用健康检查返回状态码: $($healthResponse.StatusCode)" -ForegroundColor Yellow
|
||
}
|
||
} catch {
|
||
Write-Host "警告: 无法访问健康检查端点(可能未配置或应用未完全启动)" -ForegroundColor Yellow
|
||
Write-Host "提示: 请手动访问应用确认部署是否成功" -ForegroundColor Gray
|
||
}
|
||
|
||
# 检查应用日志(最后20行,查找错误)
|
||
Write-Host "检查应用日志..." -ForegroundColor Gray
|
||
$logCheckResult = Invoke-SSHCommand -Server $Server -Username $Username -Password $Password -Command "cd ${RemotePath} && docker compose logs app --tail=20 2>&1 | grep -i 'error\|fatal\|failed' | head -5 || echo 'no_errors_found'" -AuthInfo $AuthInfo
|
||
if ($logCheckResult -eq 0) {
|
||
Write-Host "✓ 应用日志检查完成" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "提示: 建议手动查看应用日志确认是否有错误" -ForegroundColor Gray
|
||
}
|
||
|
||
Write-Host "✓ 部署成功" -ForegroundColor Green
|
||
$deploymentSucceeded = $true
|
||
} else {
|
||
Write-Host "未找到 docker-compose.yml 文件,跳过自动部署" -ForegroundColor Yellow
|
||
Write-Host "提示: 请手动在服务器上执行以下命令完成部署:" -ForegroundColor Yellow
|
||
Write-Host " ssh ${Username}@${Server}" -ForegroundColor Gray
|
||
Write-Host " cd ${RemotePath}" -ForegroundColor Gray
|
||
Write-Host " docker compose up -d" -ForegroundColor Gray
|
||
}
|
||
|
||
Write-Host ""
|
||
if ($deploymentSucceeded) {
|
||
Write-Host "✓ 部署完成!" -ForegroundColor Green
|
||
Write-Host "镜像已加载并部署到服务器: ${Server}" -ForegroundColor Gray
|
||
} else {
|
||
Write-Host "✓ 镜像已加载到服务器: ${Server}" -ForegroundColor Green
|
||
Write-Host "⚠ 未执行自动部署(缺少 docker-compose.yml 文件)" -ForegroundColor Yellow
|
||
}
|
||
|
||
# 清理本地镜像和文件
|
||
# 如果部署成功,默认执行清理;如果跳过部署,也默认清理(因为镜像已成功加载到服务器)
|
||
# 用户可以通过 -CleanupAfterDeploy:$false 禁用清理
|
||
$shouldCleanup = -not $PSBoundParameters.ContainsKey('CleanupAfterDeploy') -or $CleanupAfterDeploy
|
||
|
||
if ($shouldCleanup) {
|
||
Write-Host ""
|
||
Write-Host "===> 清理本地资源" -ForegroundColor Cyan
|
||
|
||
# 删除本地镜像
|
||
Write-Host "正在删除本地镜像: ${fullImage}..." -ForegroundColor Gray
|
||
$removeImageResult = docker rmi $fullImage 2>&1
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Host "✓ 本地镜像已删除" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "警告: 删除本地镜像失败(可能已被删除或正在使用)" -ForegroundColor Yellow
|
||
Write-Host " 错误信息: $removeImageResult" -ForegroundColor Gray
|
||
}
|
||
|
||
# 删除 tar 文件(除非用户选择保留)
|
||
if (-not $KeepTarFile) {
|
||
Write-Host "正在删除本地 tar 文件: $archivePath..." -ForegroundColor Gray
|
||
if (Test-Path $archivePath) {
|
||
Remove-Item -Path $archivePath -Force -ErrorAction SilentlyContinue
|
||
if (-not (Test-Path $archivePath)) {
|
||
Write-Host "✓ 本地 tar 文件已删除" -ForegroundColor Green
|
||
} else {
|
||
Write-Host "警告: 删除 tar 文件失败" -ForegroundColor Yellow
|
||
}
|
||
} else {
|
||
Write-Host "提示: tar 文件不存在,可能已被删除" -ForegroundColor Gray
|
||
}
|
||
} else {
|
||
Write-Host "保留 tar 文件: $archivePath" -ForegroundColor Gray
|
||
}
|
||
}
|
||
} else {
|
||
Write-Host ""
|
||
Write-Host "镜像已保存为:$archivePath" -ForegroundColor Green
|
||
Write-Host "可使用 scp 手动上传,或使用私有镜像仓库分发。"
|
||
}
|
||
}
|
||
finally {
|
||
Pop-Location
|
||
}
|
||
|
||
|