1024 lines
37 KiB
PowerShell
1024 lines
37 KiB
PowerShell
# ================================================================
|
||
# FLNXI素材库 - 部署脚本 v1.2
|
||
# 功能:本地构建 -> 推送到Registry -> 远程部署
|
||
# 特性:自动加载基础镜像、智能重试、镜像清理、趣味提示
|
||
# ================================================================
|
||
|
||
param(
|
||
[string]$Server = "45.249.208.118",
|
||
[string]$Username = "root",
|
||
[SecureString]$Password = $null,
|
||
[string]$KeyPath = "",
|
||
[string]$ImageName = "flnxi-material",
|
||
[string]$Tag = "local-deploy",
|
||
[string]$RemotePath = "/root/deploy",
|
||
[string]$EnvFile = "",
|
||
[switch]$SkipCheck,
|
||
[switch]$SkipBuild,
|
||
[switch]$SkipUpload,
|
||
[switch]$Debug,
|
||
[switch]$Help,
|
||
[switch]$CleanImages,
|
||
[int]$KeepImageCount = 3
|
||
)
|
||
|
||
# 脚本配置
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = "Continue"
|
||
try {
|
||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
|
||
}
|
||
} catch {
|
||
# 控制台编码设置失败时忽略
|
||
}
|
||
|
||
$script:StageTracker = @{
|
||
Total = 0
|
||
Current = 0
|
||
}
|
||
|
||
$script:FunTips = @(
|
||
"记得喝口咖啡,让部署的快乐多续一会",
|
||
"今日小目标:让 CI/CD 成为最懂你的搭档",
|
||
"猫咪提示:部署途中别忘了伸个懒腰",
|
||
"数据正在轨道中穿梭,耐心是宇航员的必备素质",
|
||
"小彩蛋:部署顺利指数 +1%,好运常伴你",
|
||
"背景 BGM 同步播放中,部署节奏稳稳的",
|
||
"如果日志有点长,记得深呼吸,冷静才是王牌技能",
|
||
"镜像像快递一样打包上路,收件人:Production",
|
||
"每一个 build 都是一次漂亮的起飞",
|
||
"代码正在变身,从开发模式切换到生产模式",
|
||
"FLNXI素材库正在打包,准备惊艳登场",
|
||
"部署速度:光速模式已启动",
|
||
"部署马戏团正在表演,请保持期待",
|
||
"夜深了?没关系,部署脚本陪你到天亮",
|
||
"部署完成后,记得给自己一个小奖励"
|
||
)
|
||
|
||
function Initialize-StageTracker {
|
||
param(
|
||
[bool]$DoCheck,
|
||
[bool]$DoBuild,
|
||
[bool]$DoPush,
|
||
[bool]$DoRemote
|
||
)
|
||
$script:StageTracker.Total = @($DoCheck, $DoBuild, $DoPush, $DoRemote) | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count
|
||
if (-not $script:StageTracker.Total -or $script:StageTracker.Total -le 0) {
|
||
$script:StageTracker.Total = 1
|
||
}
|
||
$script:StageTracker.Current = 0
|
||
}
|
||
|
||
function Enter-Stage {
|
||
param(
|
||
[string]$Title,
|
||
[string]$Subtitle = "",
|
||
[string]$Emoji = "[*]"
|
||
)
|
||
$script:StageTracker.Current++
|
||
$current = $script:StageTracker.Current
|
||
$total = $script:StageTracker.Total
|
||
$progress = if ($total -gt 0) { [Math]::Min(1, [Math]::Max(0, ($current - 1) / [double]$total)) } else { 0 }
|
||
$barLength = 24
|
||
$filledLength = [Math]::Floor($progress * $barLength)
|
||
$bar = ("█" * $filledLength).PadRight($barLength, "░")
|
||
Write-ColorOutput ""
|
||
$stageMessage = $Emoji + " " + $Title + " (" + [string]$current + "/" + [string]$total + ")"
|
||
Write-ColorOutput $stageMessage "Magenta"
|
||
$progressPercent = [Math]::Round($progress * 100)
|
||
$progressMessage = "[" + $bar + "] " + [string]$progressPercent + "%"
|
||
Write-ColorOutput $progressMessage "Magenta"
|
||
if ($Subtitle) {
|
||
Write-Info $Subtitle
|
||
}
|
||
}
|
||
|
||
function Get-FunTip {
|
||
if (-not $script:FunTips -or $script:FunTips.Count -eq 0) {
|
||
return $null
|
||
}
|
||
return Get-Random -InputObject $script:FunTips
|
||
}
|
||
|
||
# 内部函数:庆祝阶段完成(使用自定义动词以保持代码可读性)
|
||
function Celebrate-Stage {
|
||
param([string]$Title)
|
||
$tip = Get-FunTip
|
||
$completeMessage = $Title + "完成!"
|
||
Write-ColorOutput $completeMessage "Green"
|
||
if ($tip) {
|
||
$tipMessage = " " + $tip
|
||
Write-ColorOutput $tipMessage "Gray"
|
||
}
|
||
}
|
||
|
||
function Test-RemoteHealth {
|
||
param(
|
||
[string]$Server,
|
||
[int]$Port = 4320,
|
||
[int]$MaxWaitSeconds = 45
|
||
)
|
||
$healthUrl = "http://{0}:{1}/api/health" -f $Server, $Port
|
||
Write-Info "开始健康检查: $healthUrl"
|
||
$elapsed = 0
|
||
$interval = 3
|
||
$ok = $false
|
||
while ($elapsed -lt $MaxWaitSeconds -and -not $ok) {
|
||
try {
|
||
$resp = Invoke-WebRequest -Uri $healthUrl -Method GET -TimeoutSec 5 -ErrorAction Stop
|
||
if ($resp.StatusCode -eq 200) {
|
||
$ok = $true
|
||
break
|
||
} else {
|
||
Write-Warning ("健康检查返回状态码 {0},稍后重试..." -f $resp.StatusCode)
|
||
}
|
||
} catch {
|
||
Write-Info ("应用尚未完全就绪({0}s),继续等待..." -f $elapsed)
|
||
}
|
||
Start-Sleep -Seconds $interval
|
||
$elapsed += $interval
|
||
}
|
||
|
||
if ($ok) {
|
||
Write-Success ("健康检查通过,应用已就绪 ({0}s)" -f $elapsed)
|
||
$tip = Get-FunTip
|
||
if ($tip) {
|
||
$healthTipMessage = "健康彩蛋: " + $tip
|
||
Write-ColorOutput $healthTipMessage "Gray"
|
||
}
|
||
return $true
|
||
} else {
|
||
Write-Warning ("在 {0}s 内未能通过健康检查,请稍后手动访问 {1} 确认" -f $MaxWaitSeconds, $healthUrl)
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# 颜色输出函数
|
||
function Write-ColorOutput {
|
||
param(
|
||
[string]$Message,
|
||
[ConsoleColor]$ForegroundColor = "White"
|
||
)
|
||
Write-Host $Message -ForegroundColor $ForegroundColor
|
||
}
|
||
|
||
function Write-Success {
|
||
param([string]$Message)
|
||
Write-ColorOutput "[OK] $Message" "Green"
|
||
}
|
||
|
||
function Write-Warning {
|
||
param([string]$Message)
|
||
Write-ColorOutput "[WARN] $Message" "Yellow"
|
||
}
|
||
|
||
function Write-ErrorColored {
|
||
param([string]$Message)
|
||
Write-ColorOutput "[ERROR] $Message" "Red"
|
||
}
|
||
|
||
function Write-Info {
|
||
param([string]$Message)
|
||
Write-ColorOutput "[INFO] $Message" "Cyan"
|
||
}
|
||
|
||
# 环境变量文件处理
|
||
function Resolve-EnvFilePath {
|
||
param([string]$Path)
|
||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||
return $null
|
||
}
|
||
try {
|
||
return (Resolve-Path $Path -ErrorAction Stop).Path
|
||
} catch {
|
||
return $null
|
||
}
|
||
}
|
||
|
||
# 内部函数:准备构建环境
|
||
function Prepare-BuildEnvironment {
|
||
param(
|
||
[string]$ProjectRoot,
|
||
[string]$EnvFileCandidate,
|
||
[string]$Server
|
||
)
|
||
|
||
$state = @{
|
||
TargetPath = Join-Path $ProjectRoot ".env.production"
|
||
BackupPath = $null
|
||
SourcePath = $null
|
||
CreatedTemp = $false
|
||
}
|
||
|
||
$targetPath = $state.TargetPath
|
||
$existingTarget = Test-Path $targetPath
|
||
|
||
if ($existingTarget -and [string]::IsNullOrWhiteSpace($EnvFileCandidate)) {
|
||
Write-Info "检测到现有 .env.production,直接使用"
|
||
return $state
|
||
}
|
||
|
||
$candidatePaths = @()
|
||
$resolvedCandidate = Resolve-EnvFilePath $EnvFileCandidate
|
||
if ($resolvedCandidate) {
|
||
$candidatePaths += $resolvedCandidate
|
||
}
|
||
|
||
$candidatePaths += @(
|
||
(Join-Path $ProjectRoot ".env.production.local"),
|
||
(Join-Path $ProjectRoot "config-bianliang.env"),
|
||
(Join-Path $ProjectRoot ".env.local")
|
||
)
|
||
|
||
foreach ($candidate in $candidatePaths) {
|
||
if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path $candidate)) {
|
||
$state.SourcePath = (Resolve-Path $candidate).Path
|
||
Write-Info "使用环境变量文件: $($state.SourcePath)"
|
||
break
|
||
}
|
||
}
|
||
|
||
if ($state.SourcePath) {
|
||
if ($existingTarget) {
|
||
$state.BackupPath = "$targetPath.deploy-backup"
|
||
Copy-Item -Path $targetPath -Destination $state.BackupPath -Force
|
||
}
|
||
if ($state.SourcePath -ne $targetPath) {
|
||
Copy-Item -Path $state.SourcePath -Destination $targetPath -Force
|
||
$state.CreatedTemp = $true
|
||
}
|
||
return $state
|
||
}
|
||
|
||
Write-Warning "未找到可用的环境变量文件,将生成临时占位配置。建议通过 -EnvFile 指定真实配置以避免构建期访问远程服务失败。"
|
||
|
||
$placeholder = @"
|
||
# 生产环境配置 (临时生成)
|
||
NODE_ENV=production
|
||
NEXT_TELEMETRY_DISABLED=1
|
||
|
||
# 数据库配置 (占位符)
|
||
DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder
|
||
|
||
# 应用URL配置
|
||
NEXTAUTH_URL=http://$Server:4320
|
||
APP_PUBLIC_URL=http://$Server:4320
|
||
|
||
# 安全配置
|
||
NEXTAUTH_SECRET=$(New-Guid)
|
||
|
||
# 存储配置
|
||
S3_ENDPOINT=https://s3.1080p.hk
|
||
S3_BUCKET=jsxiangche
|
||
S3_ACCESS_KEY_ID=AEvT1p0wSNjow3Asu9NV
|
||
S3_SECRET_ACCESS_KEY=u8nzFl1S6dNoT5PQaQhkHz8Du6iX0hVnPswtvHhs
|
||
S3_REGION=us-east-1
|
||
S3_PUBLIC_DOMAIN=https://s3.1080p.hk/jsxiangche
|
||
S3_ENABLE_PATH_STYLE=1
|
||
S3_FILE_PATH=由壹点AI/
|
||
|
||
# 本地上传目录
|
||
UPLOAD_DIR=./uploads
|
||
"@
|
||
|
||
$placeholder | Out-File -FilePath $targetPath -Encoding UTF8 -Force
|
||
$state.CreatedTemp = -not $existingTarget
|
||
return $state
|
||
}
|
||
|
||
function Restore-BuildEnvironment {
|
||
param($State)
|
||
if (-not $State) {
|
||
return
|
||
}
|
||
|
||
$targetPath = $State.TargetPath
|
||
if ($State.BackupPath -and (Test-Path $State.BackupPath)) {
|
||
Move-Item -Path $State.BackupPath -Destination $targetPath -Force
|
||
return
|
||
}
|
||
|
||
if ($State.CreatedTemp -and (Test-Path $targetPath)) {
|
||
Remove-Item -Path $targetPath -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
# 文件大小格式化
|
||
function Format-FileSize {
|
||
param([long]$Size)
|
||
if ($Size -ge 1GB) {
|
||
return "{0:N2} GB" -f ($Size / 1GB)
|
||
} elseif ($Size -ge 1MB) {
|
||
return "{0:N2} MB" -f ($Size / 1MB)
|
||
} elseif ($Size -ge 1KB) {
|
||
return "{0:N2} KB" -f ($Size / 1KB)
|
||
} else {
|
||
return "$Size B"
|
||
}
|
||
}
|
||
|
||
# 检查命令是否存在
|
||
function Test-Command {
|
||
param([string]$Command)
|
||
try {
|
||
$null = Get-Command $Command -ErrorAction Stop
|
||
return $true
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# 查找SSH密钥文件
|
||
function Find-SSHKeys {
|
||
$possibleKeys = @(
|
||
# 优先使用项目专用的生产环境密钥(已验证可用)
|
||
"$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"
|
||
)
|
||
|
||
$foundKeys = @()
|
||
foreach ($key in $possibleKeys) {
|
||
if (Test-Path $key) {
|
||
$foundKeys += $key
|
||
}
|
||
}
|
||
|
||
return $foundKeys
|
||
}
|
||
|
||
|
||
# 生成唯一临时文件名
|
||
function Get-TempFileName {
|
||
param([string]$Prefix = "deploy")
|
||
$timestamp = Get-Date -Format "yyyyMMddHHmmss"
|
||
$processId = $PID
|
||
$random = Get-Random -Minimum 1000 -Maximum 9999
|
||
return "${Prefix}_${timestamp}_${processId}_${random}.txt"
|
||
}
|
||
|
||
# 测试SSH连接(带重试机制)
|
||
function Test-SSHConnection {
|
||
param(
|
||
[string]$Server,
|
||
[string]$Username,
|
||
[SecureString]$Password = $null,
|
||
[string]$KeyPath = "",
|
||
[int]$MaxRetries = 3,
|
||
[int]$RetryDelay = 2
|
||
)
|
||
|
||
$target = "${Username}@${Server}"
|
||
$attempt = 0
|
||
$lastError = $null
|
||
|
||
while ($attempt -lt $MaxRetries) {
|
||
$attempt++
|
||
if ($attempt -gt 1) {
|
||
Write-Info "SSH连接重试 ($attempt/$MaxRetries)..."
|
||
Start-Sleep -Seconds $RetryDelay
|
||
}
|
||
|
||
# 生成唯一临时文件名
|
||
$stdoutFile = Get-TempFileName -Prefix "ssh_stdout"
|
||
$stderrFile = Get-TempFileName -Prefix "ssh_stderr"
|
||
|
||
try {
|
||
# 使用 OpenSSH(增加超时时间到20秒)
|
||
$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3")
|
||
|
||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||
$sshArgs += @("-i", $KeyPath)
|
||
if ($attempt -eq 1) {
|
||
Write-Info "使用SSH密钥: $KeyPath"
|
||
}
|
||
} elseif ($Password) {
|
||
# 注意:SecureString 密码认证需要额外处理,当前主要使用 SSH 密钥
|
||
$sshArgs += @("-o", "PasswordAuthentication=yes")
|
||
if ($attempt -eq 1) {
|
||
Write-Warning "密码认证支持有限,建议使用 SSH 密钥"
|
||
}
|
||
} else {
|
||
if ($attempt -eq 1) {
|
||
Write-Warning "未提供密钥,使用交互式认证"
|
||
}
|
||
$sshArgs += @("-o", "StrictHostKeyChecking=no")
|
||
}
|
||
|
||
$process = Start-Process -FilePath "ssh" -ArgumentList ($sshArgs + @($target, "echo 'SSH连接测试成功'")) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
|
||
|
||
if ($process.ExitCode -eq 0) {
|
||
Write-Success "SSH连接测试成功"
|
||
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
|
||
return $true
|
||
} else {
|
||
$errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue
|
||
$lastError = $errorOutput
|
||
Write-Warning "SSH连接失败 (尝试 $attempt/$MaxRetries): $errorOutput"
|
||
}
|
||
} catch {
|
||
$lastError = $_.Exception.Message
|
||
Write-Warning "SSH测试异常 (尝试 $attempt/$MaxRetries): $_"
|
||
} finally {
|
||
# 清理临时文件
|
||
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
Write-ErrorColored "SSH连接失败,已重试 $MaxRetries 次。最后错误: $lastError"
|
||
return $false
|
||
}
|
||
|
||
|
||
# 内部函数:清理本地 Docker 镜像
|
||
function Clean-LocalDockerImages {
|
||
param(
|
||
[string]$ImageName,
|
||
[string]$KeepTag = "local-deploy",
|
||
[int]$KeepCount = 3
|
||
)
|
||
|
||
Write-Info "清理本地 Docker 镜像..."
|
||
|
||
try {
|
||
# 清理悬空镜像
|
||
Write-Info " 清理悬空镜像..."
|
||
docker image prune -f 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success " 悬空镜像清理完成"
|
||
}
|
||
|
||
# 清理指定镜像的旧版本(保留最新的几个)
|
||
Write-Info " 清理 $ImageName 的旧版本镜像(保留最新 $KeepCount 个)..."
|
||
$images = docker images --format "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}" | Where-Object { $_ -match $ImageName }
|
||
|
||
if ($images) {
|
||
$sortedImages = $images | Sort-Object { [DateTime]::ParseExact(($_ -split "`t")[3], "yyyy-MM-dd HH:mm:ss zzz", $null) } -Descending
|
||
$toRemove = $sortedImages | Select-Object -Skip $KeepCount
|
||
|
||
foreach ($img in $toRemove) {
|
||
$imgId = ($img -split "`t")[0]
|
||
$imgRepo = ($img -split "`t")[1]
|
||
$imgTag = ($img -split "`t")[2]
|
||
Write-Info " 删除: $imgRepo`:$imgTag ($imgId)"
|
||
docker rmi -f $imgId 2>&1 | Out-Null
|
||
}
|
||
|
||
if ($toRemove) {
|
||
Write-Success " 已清理 $($toRemove.Count) 个旧镜像"
|
||
} else {
|
||
Write-Info " 没有需要清理的旧镜像"
|
||
}
|
||
}
|
||
|
||
# 清理所有未使用的镜像(可选,谨慎使用)
|
||
# docker image prune -a -f
|
||
|
||
return $true
|
||
} catch {
|
||
Write-Warning "清理本地镜像时出错: $_"
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# 内部函数:清理服务器上的 Docker 镜像
|
||
function Clean-RemoteDockerImages {
|
||
param(
|
||
[string]$Server,
|
||
[string]$Username,
|
||
[string]$KeyPath,
|
||
[string]$ImageName,
|
||
[int]$KeepCount = 3
|
||
)
|
||
|
||
Write-Info "清理服务器上的 Docker 镜像..."
|
||
|
||
$cleanCmd = @"
|
||
# 清理悬空镜像
|
||
docker image prune -f
|
||
|
||
# 清理指定镜像的旧版本(保留最新的几个)
|
||
docker images --format '{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}' | grep '$ImageName' | sort -k4 -r | tail -n +$($KeepCount + 1) | awk '{print \$1}' | xargs -r docker rmi -f 2>/dev/null || true
|
||
|
||
# 显示清理后的镜像列表
|
||
echo '--- 清理后的镜像列表 ---
|
||
docker images | grep '$ImageName' | head -10
|
||
"@
|
||
|
||
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30")
|
||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||
$sshArgs += @("-i", $KeyPath)
|
||
}
|
||
|
||
try {
|
||
$sshArgs += @("${Username}@${Server}", $cleanCmd)
|
||
$process = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait
|
||
if ($process.ExitCode -eq 0) {
|
||
Write-Success "服务器镜像清理完成"
|
||
return $true
|
||
} else {
|
||
Write-Warning "服务器镜像清理可能未完全成功(退出码: $($process.ExitCode))"
|
||
return $false
|
||
}
|
||
} catch {
|
||
Write-Warning "清理服务器镜像时出错: $_"
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# 内部函数:清理 Docker Registry 中的旧镜像
|
||
function Clean-RegistryImages {
|
||
param(
|
||
[string]$RegistryUrl,
|
||
[string]$ImageName,
|
||
[int]$KeepCount = 5
|
||
)
|
||
|
||
Write-Info "清理 Docker Registry 中的旧镜像..."
|
||
|
||
try {
|
||
# 获取镜像的所有标签
|
||
$tagsUrl = "$RegistryUrl/v2/$ImageName/tags/list"
|
||
$tagsResponse = Invoke-RestMethod -Uri $tagsUrl -Method Get -ErrorAction SilentlyContinue
|
||
|
||
if ($tagsResponse -and $tagsResponse.tags) {
|
||
$tags = $tagsResponse.tags | Where-Object { $_ -ne "latest" -and $_ -ne "local-deploy" } | Sort-Object -Descending
|
||
$toRemove = $tags | Select-Object -Skip $KeepCount
|
||
|
||
if ($toRemove) {
|
||
Write-Info " 将删除 $($toRemove.Count) 个旧标签: $($toRemove -join ', ')"
|
||
|
||
foreach ($tag in $toRemove) {
|
||
try {
|
||
# 获取 manifest digest
|
||
$manifestUrl = "$RegistryUrl/v2/$ImageName/manifests/$tag"
|
||
$headers = @{
|
||
"Accept" = "application/vnd.docker.distribution.manifest.v2+json"
|
||
}
|
||
$manifestResponse = Invoke-WebRequest -Uri $manifestUrl -Method Head -Headers $headers -ErrorAction SilentlyContinue
|
||
|
||
if ($manifestResponse -and $manifestResponse.Headers["Docker-Content-Digest"]) {
|
||
$digest = $manifestResponse.Headers["Docker-Content-Digest"]
|
||
$deleteUrl = "$RegistryUrl/v2/$ImageName/manifests/$digest"
|
||
|
||
Invoke-RestMethod -Uri $deleteUrl -Method Delete -ErrorAction SilentlyContinue | Out-Null
|
||
Write-Info " 已删除: $ImageName`:$tag"
|
||
}
|
||
} catch {
|
||
Write-Warning " 删除 $ImageName`:$tag 失败: $_"
|
||
}
|
||
}
|
||
|
||
Write-Success "Registry 镜像清理完成"
|
||
} else {
|
||
Write-Info " 没有需要清理的旧镜像标签"
|
||
}
|
||
} else {
|
||
Write-Info " 未找到镜像 $ImageName 或无法访问 Registry"
|
||
}
|
||
|
||
return $true
|
||
} catch {
|
||
Write-Warning "清理 Registry 镜像时出错: $_"
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# 内部函数:检查并准备基础镜像(优先级:本地已有 > Registry > 本地文件 > 网络下载)
|
||
function Load-BaseImages {
|
||
param(
|
||
[string]$ProjectRoot,
|
||
[string]$RegistryUrl = ""
|
||
)
|
||
|
||
# 基础镜像列表
|
||
$baseImages = @(
|
||
"node:18-alpine"
|
||
)
|
||
|
||
$loadedCount = 0
|
||
$pulledCount = 0
|
||
$skippedCount = 0
|
||
|
||
Write-Info "检查基础镜像..."
|
||
foreach ($baseImage in $baseImages) {
|
||
# 1. 优先检查本地是否已有该镜像(最快,直接使用)
|
||
$localCheck = docker images --format "{{.Repository}}:{{.Tag}}" | Select-String -Pattern "^${baseImage}$"
|
||
if ($localCheck) {
|
||
Write-Info " [✓] 本地已存在,直接使用: $baseImage"
|
||
$skippedCount++
|
||
continue
|
||
}
|
||
|
||
# 2. 如果本地没有,尝试从Registry拉取(避免从Docker Hub下载)
|
||
if ($RegistryUrl) {
|
||
$imageName = $baseImage -replace ":", "-"
|
||
$registryBaseImage = "${RegistryUrl}/youyidian/base-${imageName}:latest"
|
||
|
||
Write-Info " 尝试从Registry拉取: $registryBaseImage"
|
||
$pullResult = docker pull $registryBaseImage 2>&1
|
||
if ($LASTEXITCODE -eq 0) {
|
||
# 标记为原始名称
|
||
docker tag $registryBaseImage $baseImage 2>&1 | Out-Null
|
||
Write-Info " [OK] 已从Registry拉取并标记: $baseImage"
|
||
$pulledCount++
|
||
continue
|
||
} else {
|
||
Write-Info " [跳过] Registry中不存在"
|
||
}
|
||
}
|
||
|
||
# 3. 如果Registry也没有,尝试从本地文件加载
|
||
$imagesDir = Join-Path $ProjectRoot "docker-images"
|
||
if (Test-Path $imagesDir) {
|
||
$foundInFiles = $false
|
||
Get-ChildItem $imagesDir -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||
if ($foundInFiles) { return }
|
||
$imageDir = $_.FullName
|
||
Get-ChildItem $imageDir -Filter "*.tar" -ErrorAction SilentlyContinue | ForEach-Object {
|
||
if ($foundInFiles) { return }
|
||
$tarFile = $_.FullName
|
||
Write-Info " 尝试从本地文件加载: $($_.Name)"
|
||
$result = docker load -i $tarFile 2>&1
|
||
if ($LASTEXITCODE -eq 0) {
|
||
$loadedImage = $result | Select-String -Pattern "Loaded image: (.+)" | ForEach-Object { $_.Matches[0].Groups[1].Value }
|
||
if ($loadedImage -and $loadedImage -match $baseImage) {
|
||
Write-Info " [OK] 已从本地文件加载: $loadedImage"
|
||
$loadedCount++
|
||
$foundInFiles = $true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if ($foundInFiles) {
|
||
continue
|
||
}
|
||
}
|
||
|
||
# 4. 如果以上都没有,构建时会从网络下载(Docker会自动处理)
|
||
Write-Info " [提示] 未找到基础镜像,构建时将尝试从网络下载: $baseImage"
|
||
}
|
||
|
||
if ($skippedCount -gt 0) {
|
||
Write-Success "本地已有 $skippedCount 个基础镜像,直接使用"
|
||
}
|
||
if ($pulledCount -gt 0) {
|
||
Write-Success "从Registry拉取 $pulledCount 个基础镜像"
|
||
}
|
||
if ($loadedCount -gt 0) {
|
||
Write-Success "从本地文件加载 $loadedCount 个基础镜像"
|
||
}
|
||
}
|
||
|
||
# 主函数
|
||
function Main {
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
Write-ColorOutput " 新建简化部署脚本 v1.1" "Magenta"
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
|
||
# 加载基础镜像(在构建前)
|
||
if (-not $SkipBuild) {
|
||
$projectRoot = if ($PSScriptRoot) { Split-Path $PSScriptRoot -Parent } else { Get-Location }
|
||
$registryUrl = "${Server}:5000"
|
||
Load-BaseImages -ProjectRoot $projectRoot -RegistryUrl $registryUrl
|
||
Write-ColorOutput ""
|
||
}
|
||
|
||
# 显示配置
|
||
Write-Info "部署配置:"
|
||
Write-Info " 服务器: $Server"
|
||
Write-Info " 用户名: $Username"
|
||
Write-Info " 镜像: $ImageName`:$Tag"
|
||
Write-Info " 跳过检查: $SkipCheck"
|
||
Write-Info " 跳过构建: $SkipBuild"
|
||
Write-Info " 跳过上传: $SkipUpload"
|
||
Write-ColorOutput ""
|
||
|
||
# 调试模式
|
||
if ($Debug) {
|
||
Write-Info "[DEBUG] 调试模式已启用:将输出额外的诊断信息"
|
||
}
|
||
|
||
$willCheck = -not $SkipCheck
|
||
$willBuild = -not $SkipBuild
|
||
$willPush = -not $SkipUpload
|
||
$willRemote = $willPush
|
||
Initialize-StageTracker -DoCheck:$willCheck -DoBuild:$willBuild -DoPush:$willPush -DoRemote:$willRemote
|
||
|
||
# 检查必要工具
|
||
Write-Info "检查必要工具..."
|
||
$requiredTools = @("docker")
|
||
$missingTools = @()
|
||
|
||
foreach ($tool in $requiredTools) {
|
||
if (Test-Command $tool) {
|
||
Write-Success "$tool 已安装"
|
||
# 检查 Docker 是否可用
|
||
if ($tool -eq "docker") {
|
||
$dockerCheck = docker ps 2>&1
|
||
if ($LASTEXITCODE -ne 0 -and $dockerCheck -match "cannot find the file specified|Is the docker daemon running") {
|
||
Write-Warning "Docker 命令存在但 Docker Desktop 可能未运行"
|
||
Write-Info "正在等待 Docker Desktop 启动(最多等待 30 秒)..."
|
||
$maxWait = 30
|
||
$waited = 0
|
||
$dockerReady = $false
|
||
while ($waited -lt $maxWait) {
|
||
Start-Sleep -Seconds 2
|
||
$waited += 2
|
||
docker ps 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
$dockerReady = $true
|
||
Write-Success "Docker 已就绪"
|
||
break
|
||
}
|
||
}
|
||
if (-not $dockerReady) {
|
||
Write-ErrorColored "Docker Desktop 未运行,请手动启动 Docker Desktop 后重试"
|
||
Write-Info "提示: 在 Windows 上,请启动 'Docker Desktop' 应用程序"
|
||
exit 1
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
Write-ErrorColored "$tool 未安装"
|
||
$missingTools += $tool
|
||
}
|
||
}
|
||
|
||
if ($missingTools.Count -gt 0) {
|
||
Write-ErrorColored "缺少必要工具: $($missingTools -join ', ')"
|
||
exit 1
|
||
}
|
||
|
||
# 项目根目录
|
||
$projectRoot = Get-Location
|
||
Write-Info "项目目录: $projectRoot"
|
||
|
||
# 1. 检查阶段
|
||
if (-not $SkipCheck) {
|
||
Enter-Stage "检查阶段" "先给代码做个快速体检"
|
||
Write-ColorOutput "===> 检查阶段" "Cyan"
|
||
|
||
Write-Info "运行 npm run check..."
|
||
try {
|
||
$process = Start-Process -FilePath "npm" -ArgumentList @("run", "check") -NoNewWindow -PassThru -Wait
|
||
if ($process.ExitCode -ne 0) {
|
||
Write-Warning "npm run check 有警告,但继续执行"
|
||
} else {
|
||
Write-Success "代码检查通过"
|
||
}
|
||
} catch {
|
||
Write-Warning "检查阶段异常: $_"
|
||
}
|
||
Celebrate-Stage "检查阶段"
|
||
}
|
||
|
||
# 2. 构建阶段
|
||
$fullImage = "${ImageName}:${Tag}"
|
||
|
||
if (-not $SkipBuild) {
|
||
Enter-Stage "构建阶段" "正在把代码揉成可部署的镜像"
|
||
Write-ColorOutput "===> 构建阶段" "Cyan"
|
||
$envState = Prepare-BuildEnvironment -ProjectRoot $projectRoot -EnvFileCandidate $EnvFile -Server $Server
|
||
try {
|
||
Write-Info "开始Docker构建: $fullImage"
|
||
|
||
# 构建参数
|
||
$buildArgs = @(
|
||
"build",
|
||
"-t", $fullImage,
|
||
"-f", "Dockerfile",
|
||
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",
|
||
"--build-arg", "NEXTAUTH_URL=http://$Server:4320",
|
||
"--build-arg", "APP_PUBLIC_URL=http://$Server:4320",
|
||
"."
|
||
)
|
||
|
||
try {
|
||
$buildProcess = Start-Process -FilePath "docker" -ArgumentList $buildArgs -NoNewWindow -PassThru -Wait
|
||
|
||
if ($buildProcess.ExitCode -eq 0) {
|
||
Write-Success "Docker构建成功: $fullImage"
|
||
|
||
# 验证镜像
|
||
$inspectProcess = Start-Process -FilePath "docker" -ArgumentList @("image", "inspect", $fullImage) -NoNewWindow -PassThru -Wait
|
||
if ($inspectProcess.ExitCode -eq 0) {
|
||
Write-Success "镜像验证成功"
|
||
} else {
|
||
Write-ErrorColored "镜像验证失败"
|
||
exit 1
|
||
}
|
||
} else {
|
||
Write-ErrorColored "Docker构建失败"
|
||
exit 1
|
||
}
|
||
} catch {
|
||
Write-ErrorColored "构建过程异常: $_"
|
||
exit 1
|
||
}
|
||
} finally {
|
||
Restore-BuildEnvironment -State $envState
|
||
}
|
||
Celebrate-Stage "构建阶段"
|
||
} else {
|
||
Write-Warning "跳过构建,使用现有镜像: $fullImage"
|
||
}
|
||
|
||
# 3. 推送镜像到 Docker Registry
|
||
if (-not $SkipUpload) {
|
||
Enter-Stage "镜像推送阶段" "把镜像打包寄到私有仓库"
|
||
Write-ColorOutput "===> 推送镜像到 Docker Registry" "Cyan"
|
||
|
||
$registryUrl = "${Server}:5000"
|
||
# 推送到 youyidian/flnxi-material 命名空间
|
||
$registryImage = "${registryUrl}/youyidian/${ImageName}:${Tag}"
|
||
|
||
Write-Info "Registry 地址: $registryUrl"
|
||
Write-Info "目标镜像: $registryImage"
|
||
|
||
# 标记镜像
|
||
Write-Info "标记镜像..."
|
||
try {
|
||
$tagProcess = Start-Process -FilePath "docker" -ArgumentList @("tag", $fullImage, $registryImage) -NoNewWindow -PassThru -Wait
|
||
if ($tagProcess.ExitCode -eq 0) {
|
||
Write-Success "镜像标记成功"
|
||
} else {
|
||
Write-ErrorColored "镜像标记失败"
|
||
exit 1
|
||
}
|
||
} catch {
|
||
Write-ErrorColored "标记过程异常: $_"
|
||
exit 1
|
||
}
|
||
|
||
# 推送镜像(带重试机制)
|
||
Write-Info "正在推送镜像到 Registry..."
|
||
$pushMaxRetries = 3
|
||
$pushRetryDelay = 5
|
||
$pushAttempt = 0
|
||
$pushSuccess = $false
|
||
|
||
while ($pushAttempt -lt $pushMaxRetries -and -not $pushSuccess) {
|
||
$pushAttempt++
|
||
if ($pushAttempt -gt 1) {
|
||
Write-Info "镜像推送重试 ($pushAttempt/$pushMaxRetries)..."
|
||
Start-Sleep -Seconds $pushRetryDelay
|
||
}
|
||
|
||
try {
|
||
$pushProcess = Start-Process -FilePath "docker" -ArgumentList @("push", $registryImage) -NoNewWindow -PassThru -Wait
|
||
|
||
if ($pushProcess.ExitCode -eq 0) {
|
||
Write-Success "镜像推送成功: $registryImage"
|
||
$pushSuccess = $true
|
||
} else {
|
||
if ($pushAttempt -lt $pushMaxRetries) {
|
||
Write-Warning "镜像推送失败 (尝试 $pushAttempt/$pushMaxRetries),将重试..."
|
||
} else {
|
||
Write-ErrorColored "镜像推送失败,已重试 $pushMaxRetries 次"
|
||
Write-Info "提示: 请确保 Docker 已配置允许不安全的 Registry ($registryUrl)"
|
||
Write-Info " 在 Windows 上,编辑 Docker Desktop 设置 -> Docker Engine,添加:"
|
||
Write-Info " { \"insecure-registries\": [\"$registryUrl\"] }"
|
||
exit 1
|
||
}
|
||
}
|
||
} catch {
|
||
if ($pushAttempt -lt $pushMaxRetries) {
|
||
Write-Warning "推送过程异常 (尝试 $pushAttempt/$pushMaxRetries): $_,将重试..."
|
||
} else {
|
||
Write-ErrorColored "推送过程异常,已重试 $pushMaxRetries 次: $_"
|
||
exit 1
|
||
}
|
||
}
|
||
}
|
||
Celebrate-Stage "镜像推送阶段"
|
||
|
||
# 测试SSH连接(用于后续部署命令)
|
||
Enter-Stage "远程部署阶段" "正在连线服务器,准备热乎乎上线"
|
||
Write-Info "测试SSH连接..."
|
||
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
|
||
$sshKeys = Find-SSHKeys
|
||
if ($null -ne $sshKeys -and $sshKeys.Length -gt 0) {
|
||
$KeyPath = $sshKeys[0]
|
||
}
|
||
}
|
||
|
||
$sshTest = Test-SSHConnection -Server $Server -Username $Username -Password $Password -KeyPath $KeyPath
|
||
if (-not $sshTest) {
|
||
Write-Warning "SSH连接测试失败,将跳过自动部署"
|
||
Write-Info "请手动在服务器上执行部署命令"
|
||
} else {
|
||
# 服务器端只需要拉取已构建好的镜像并部署,不需要基础镜像
|
||
# 因为镜像已经在本地构建完成,所有层(包括基础镜像层)都已包含在镜像中
|
||
# 在服务器上拉取镜像并部署(带重试机制)
|
||
Write-Info "在服务器上拉取镜像并部署..."
|
||
# 先停止并删除旧容器,然后拉取新镜像,重新启动(使用 --no-deps --pull never 避免拉取依赖服务和重复拉取)
|
||
$deployCmd = "cd $RemotePath && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app"
|
||
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3")
|
||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||
$sshArgs += @("-i", $KeyPath)
|
||
Write-Info "使用 OpenSSH 执行远程部署命令"
|
||
} elseif ($Password) {
|
||
Write-Warning "未提供 SSH 密钥,无法在非交互模式下使用密码。请手动执行部署命令。"
|
||
$sshArgs = $null
|
||
} else {
|
||
Write-Warning "缺少 SSH 凭据,请手动执行部署命令。"
|
||
$sshArgs = $null
|
||
}
|
||
|
||
if ($sshArgs) {
|
||
$deployMaxRetries = 2
|
||
$deployRetryDelay = 5
|
||
$deployAttempt = 0
|
||
$deploySuccess = $false
|
||
|
||
while ($deployAttempt -lt $deployMaxRetries -and -not $deploySuccess) {
|
||
$deployAttempt++
|
||
if ($deployAttempt -gt 1) {
|
||
Write-Info "远程部署重试 ($deployAttempt/$deployMaxRetries)..."
|
||
Start-Sleep -Seconds $deployRetryDelay
|
||
}
|
||
|
||
$currentSshArgs = $sshArgs + @("${Username}@${Server}", $deployCmd)
|
||
try {
|
||
$sshProcess = Start-Process -FilePath "ssh" -ArgumentList $currentSshArgs -NoNewWindow -PassThru -Wait
|
||
if ($sshProcess.ExitCode -eq 0) {
|
||
Write-Success "部署成功"
|
||
$deploySuccess = $true
|
||
Celebrate-Stage "远程部署阶段"
|
||
} else {
|
||
if ($deployAttempt -lt $deployMaxRetries) {
|
||
Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),将重试..."
|
||
} else {
|
||
Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),已重试 $deployMaxRetries 次,请手动执行: $deployCmd"
|
||
}
|
||
}
|
||
} catch {
|
||
if ($deployAttempt -lt $deployMaxRetries) {
|
||
Write-Warning "部署过程异常 (尝试 $deployAttempt/$deployMaxRetries): $_,将重试..."
|
||
} else {
|
||
Write-ErrorColored "部署过程异常,已重试 $deployMaxRetries 次: $_"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# 清理镜像
|
||
if ($CleanImages) {
|
||
Write-ColorOutput "" "White"
|
||
Enter-Stage "清理阶段" "清理旧版本镜像,释放磁盘空间"
|
||
Write-ColorOutput "===> 清理阶段" "Cyan"
|
||
|
||
# 清理本地镜像
|
||
Clean-LocalDockerImages -ImageName $ImageName -KeepTag $Tag -KeepCount $KeepImageCount
|
||
|
||
# 清理服务器镜像
|
||
if (-not $SkipUpload -and $sshTest) {
|
||
Clean-RemoteDockerImages -Server $Server -Username $Username -KeyPath $KeyPath -ImageName $ImageName -KeepCount $KeepImageCount
|
||
}
|
||
|
||
Celebrate-Stage "清理阶段"
|
||
}
|
||
|
||
# 健康检查
|
||
if (-not $SkipUpload -and $deploySuccess) {
|
||
Write-ColorOutput "" "White"
|
||
Enter-Stage "健康检查" "检查应用是否正常运行"
|
||
Write-ColorOutput "===> 健康检查" "Cyan"
|
||
|
||
Test-RemoteHealth -Server $Server -Port 4320 -MaxWaitSeconds 45
|
||
Celebrate-Stage "健康检查"
|
||
}
|
||
|
||
# 完成
|
||
Write-ColorOutput "" "White"
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
Write-ColorOutput " 部署脚本执行完成" "Magenta"
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
|
||
if ($deploySuccess) {
|
||
Write-Success "应用已成功部署到服务器: $Server"
|
||
Write-Info "访问地址: http://$Server:4320"
|
||
} else {
|
||
Write-Warning "部署过程中遇到问题,可能需要手动完成部署"
|
||
}
|
||
|
||
$tip = Get-FunTip
|
||
if ($tip) {
|
||
Write-ColorOutput "" "White"
|
||
Write-ColorOutput "💡 $tip" "Yellow"
|
||
}
|
||
}
|
||
|
||
# 执行主函数
|
||
Main
|
||
|
||
|