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

1024 lines
37 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.

# ================================================================
# 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