flnxi-Wu/scripts/deploy/local-build-and-upload.ps1
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

1252 lines
57 KiB
PowerShell
Raw 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()]
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
}