fix(deploy): 修复组件版本解析和安装升级验收流程
Test deployment public URL (Linux) / test-linux (push) Waiting to run
Test start.ps1 (Windows) / test-windows (push) Waiting to run

This commit is contained in:
2026-10-18 00:18:00 +08:00
parent 895d92317e
commit b99eb849b4
30 changed files with 1579 additions and 270 deletions
+179
View File
@@ -0,0 +1,179 @@
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 '✅ 拉取后的稳定镜像与目标发布一致'
}