function Backup-DeploymentConfig { param([string]$ProjectRoot) $backup = Join-Path $ProjectRoot ('data/deployment-backups/update-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') + '-' + [Guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Force $backup if ($env:OS -eq 'Windows_NT') { $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name & icacls.exe $backup /inheritance:r /grant:r "${identity}:(OI)(CI)F" *> $null if ($LASTEXITCODE -ne 0) { throw '无法保护配置备份目录的访问权限' } } else { & chmod 700 $backup } $files = @(Get-ChildItem -Path $ProjectRoot -Filter '.env*' -File | Where-Object { -not $_.Name.EndsWith('.sample') }) foreach ($name in @('docker-compose.yml','docker-compose.override.yml','compose.override.yaml')) { $file = Join-Path $ProjectRoot $name if (Test-Path $file -PathType Leaf) { $files += Get-Item $file } } foreach ($file in $files) { $target = Join-Path $backup $file.Name Copy-Item $file.FullName $target if ($env:OS -ne 'Windows_NT') { & chmod 600 $target } } Write-Host "已备份升级前配置(不含数据库):$backup" } function Initialize-DeploymentDockerConfig { param([string]$ProjectRoot) $path = Join-Path $ProjectRoot '.env' $content = Get-Content $path -Raw -Encoding UTF8 $configured = Get-EnvValue $content 'DOCKER_CONFIG_FILE' if ($configured -and (Test-Path $configured -PathType Leaf)) { return } if ($configured -and $configured -ne '/root/.docker/config.json') { throw 'DOCKER_CONFIG_FILE 不是可读取的配置文件' } $dockerConfig = if ($env:DOCKER_CONFIG) { Join-Path $env:DOCKER_CONFIG 'config.json' } else { Join-Path $HOME '.docker/config.json' } if (Test-Path $dockerConfig -PathType Leaf) { $content = Set-EnvValue $content 'DOCKER_CONFIG_FILE' $dockerConfig.Replace('\','/') [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)) return } $fallback = Join-Path $ProjectRoot 'data/docker-auth/config.json' $null = New-Item -ItemType Directory -Force (Split-Path $fallback -Parent) if (-not (Test-Path $fallback)) { [System.IO.File]::WriteAllText($fallback, "{}`n", [System.Text.UTF8Encoding]::new($false)) } if (-not (Test-Path $fallback -PathType Leaf)) { throw '安装目录的镜像认证配置不是文件' } $content = Set-EnvValue $content 'DOCKER_CONFIG_FILE' $fallback.Replace('\','/') [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)) } function Test-DeploymentStorage { param([string[]]$ComposeCommand, [string[]]$Services=@()) $executable = $ComposeCommand[0] $arguments = @($ComposeCommand | Select-Object -Skip 1) + @('config','--format','json') $configuration = & $executable @arguments if ($LASTEXITCODE -ne 0) { throw '部署配置校验失败,尚未拉取或重建服务' } $resolved = ($configuration -join "`n") | ConvertFrom-Json & docker info *> $null if ($LASTEXITCODE -ne 0) { throw '无法连接 Docker,未检查现有容器和数据位置' } $stateful = @{ 'mongo'=@('/data/db'); 'redis'=@('/data'); 'rabbitmq'=@('/var/lib/rabbitmq'); 'easyai-pgvector'=@('/var/lib/postgresql','/var/lib/postgresql/data'); 'easyai-server'=@('/app/upload') } function Get-StoragePath([string]$Value) { $Value = $Value.Replace('\','/') -replace '^/(run/desktop/mnt/host|host_mnt)/([A-Za-z])/', '$2:/' $Value = $Value -replace '^/host_mnt/', '/' return [IO.Path]::GetFullPath($Value).TrimEnd('\','/') } foreach ($property in $resolved.services.PSObject.Properties) { $service = $property.Name $settings = $property.Value if (($Services.Count -gt 0 -and $service -notin $Services) -or -not $settings.container_name) { continue } try { $raw = & docker container inspect $settings.container_name 2>&1; $inspectExit = $LASTEXITCODE } catch { if ($_.Exception.Message -match 'No such (container|object)') { continue } throw "无法核对现有容器 $service;尚未继续部署" } if ($inspectExit -ne 0) { if ($raw -and ($raw -join "`n") -notmatch 'No such (container|object)') { throw "无法核对现有容器 $service" } continue } $existing = @(($raw -join "`n") | ConvertFrom-Json)[0] if ($existing.Config.Labels.'com.docker.compose.project' -ne $resolved.name) { throw "$service 的现有容器不属于当前 Compose 项目;请核对原安装目录和项目名" } foreach ($mount in $settings.volumes) { if ($mount.target -notin $stateful[$service]) { continue } $old = @($existing.Mounts | Where-Object { $_.Destination -eq $mount.target } | Select-Object -First 1) $equal = $false if ($old.Count -gt 0 -and $mount.type -eq 'bind' -and $old[0].Type -eq 'bind') { $expected = Get-StoragePath $mount.source $actual = Get-StoragePath $old[0].Source $comparison = if ($env:OS -eq 'Windows_NT') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } $equal = [string]::Equals($expected,$actual,$comparison) -and (Test-Path $expected -PathType Container) } elseif ($old.Count -gt 0 -and $mount.type -eq 'volume') { $definition = $resolved.volumes.PSObject.Properties[$mount.source].Value $equal = $definition.name -and $old[0].Type -eq 'volume' -and $old[0].Name -eq $definition.name } if (-not $equal) { throw "$service 的 $($mount.target) 数据位置将改变;请先备份并按数据迁移步骤处理" } } } Write-Host '✅ 现有容器归属和持久化数据位置检查通过' } function Test-DeploymentImages { param([string[]]$ComposeCommand, [string[]]$Services=@()) $executable = $ComposeCommand[0] $arguments = @($ComposeCommand | Select-Object -Skip 1) + @('config','--format','json') $configuration = & $executable @arguments if ($LASTEXITCODE -ne 0) { throw '部署配置校验失败,尚未拉取或重建服务' } $resolved = ($configuration -join "`n") | ConvertFrom-Json $images = @($resolved.services.PSObject.Properties | Where-Object { $Services.Count -eq 0 -or $_.Name -in $Services } | ForEach-Object { $_.Value.image } | Sort-Object -Unique) if ($images.Count -eq 0) { throw '没有可检查的部署镜像' } $anonymousConfig = Join-Path ([IO.Path]::GetTempPath()) ('easyai-anonymous-' + [Guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory $anonymousConfig try { foreach ($image in $images) { $arguments = @() if ($image.StartsWith('registry.cn-shanghai.aliyuncs.com/comfy-ai/') -or $image.StartsWith('registry.cn-shanghai.aliyuncs.com/easyaigc/')) { $arguments += @('--config',$anonymousConfig) } $arguments += @('manifest','inspect','--verbose',$image) $result = & docker @arguments 2>&1 if ($LASTEXITCODE -ne 0) { throw "镜像不可拉取:$image;请核对组件版本、匿名访问权限或网络,尚未重建服务" } try { $descriptors = @(($result -join "`n") | ConvertFrom-Json) } catch { throw "镜像检查返回无效信息:$image" } $platforms = @($descriptors | Where-Object { $_.Descriptor.platform.os -eq 'linux' -and $_.Descriptor.platform.architecture -eq 'amd64' }) if ($platforms.Count -eq 0) { throw "镜像没有 linux/amd64 产物:$image" } foreach ($property in $resolved.services.PSObject.Properties) { if ($property.Value.image -ne $image) { continue } $expectedProperty = if ($resolved.'x-easyai-release') { $resolved.'x-easyai-release'.PSObject.Properties[$property.Name] } else { $null } if ($expectedProperty -and $expectedProperty.Value -and -not @($platforms | Where-Object { $_.Descriptor.digest -eq $expectedProperty.Value }).Count) { throw "官方稳定别名与发布清单尚未同步:$image;保留当前服务" } } } } finally { Remove-Item $anonymousConfig -Recurse -Force } Write-Host "✅ 已核验 $($images.Count) 个部署镜像的可访问性和平台" } function Wait-DeploymentServices { param([string[]]$ComposeCommand, [string[]]$Services=@(), [int]$TimeoutSeconds=600) if ($TimeoutSeconds -le 0) { throw '健康检查等待秒数必须为正整数' } $executable = $ComposeCommand[0] $prefix = @($ComposeCommand | Select-Object -Skip 1) if ($Services.Count -eq 0) { $Services = @(& $executable @prefix config --services) } if ($LASTEXITCODE -ne 0 -or $Services.Count -eq 0) { throw '无法确定需要验收的部署服务' } $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $passes = 0 Write-Host "⏳ 等待 $($Services.Count) 个服务恢复可用..." while ((Get-Date) -lt $deadline) { $allReady = $true foreach ($service in $Services) { $containerId = (& $executable @prefix ps -q $service 2>$null | Select-Object -First 1) if (-not $containerId) { $allReady = $false; continue } $state = (& docker inspect --format '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $containerId 2>$null) $parts = "$state".Trim().Split('|') if ($parts.Count -lt 2 -or $parts[0] -ne 'true' -or ($parts[1] -ne 'none' -and $parts[1] -ne 'healthy')) { $allReady = $false } elseif ($service -eq 'watchtower' -and $parts[1] -eq 'none') { & docker exec $containerId /watchtower --health-check *> $null if ($LASTEXITCODE -ne 0) { $allReady = $false } } } if ($allReady) { $passes++ if ($passes -ge 2) { Write-Host '✅ 所选服务已恢复可用'; return } Start-Sleep -Seconds 2 } else { $passes=0; Start-Sleep -Seconds 5 } } & $executable @prefix ps @Services throw '服务未在规定时间内恢复可用,部署未完成;请检查所选服务日志' } function Test-PulledReleaseImages { param([string[]]$ComposeCommand, [string[]]$Services=@()) $executable = $ComposeCommand[0] $arguments = @($ComposeCommand | Select-Object -Skip 1) + @('config','--format','json') $configuration = & $executable @arguments if ($LASTEXITCODE -ne 0) { throw '无法读取拉取后的镜像定义' } $resolved = ($configuration -join "`n") | ConvertFrom-Json foreach ($property in $resolved.'x-easyai-release'.PSObject.Properties) { if (-not $property.Value -or ($Services.Count -gt 0 -and $property.Name -notin $Services)) { continue } $reference = $resolved.services.PSObject.Properties[$property.Name].Value.image if (-not $reference) { continue } $repository = $reference -replace ':[^:]+$', '' $raw = & docker image inspect $reference if ($LASTEXITCODE -ne 0) { throw '无法检查已拉取的稳定镜像' } $image = @(($raw -join "`n") | ConvertFrom-Json)[0] if ("$repository@$($property.Value)" -notin $image.RepoDigests) { throw "$($property.Name) 拉取后的稳定镜像与目标清单不一致;未重建服务,请重新检查正式发布" } } Write-Host '✅ 拉取后的稳定镜像与目标发布一致' }