fix(deploy): 修复组件版本解析和安装升级验收流程
This commit is contained in:
@@ -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 '✅ 拉取后的稳定镜像与目标发布一致'
|
||||
}
|
||||
@@ -1,41 +1,108 @@
|
||||
$script:ApplicationReleaseRoot = Split-Path $PSScriptRoot -Parent
|
||||
function Read-DeploymentReleaseRecord {
|
||||
param([string]$Path, [string]$Url='')
|
||||
if (-not $Url) { return (Get-Content $Path -Raw -Encoding UTF8 | ConvertFrom-Json) }
|
||||
try {
|
||||
$address = [Uri]$Url
|
||||
if ($address.Scheme -notin @('http','https') -or $address.UserInfo) { throw 'Invalid release URL' }
|
||||
return ((Invoke-WebRequest -UseBasicParsing -Uri $address -TimeoutSec 20 -Headers @{'Accept'='application/json';'Cache-Control'='no-cache'}).Content | ConvertFrom-Json)
|
||||
} catch { throw '无法读取正式发布源;保留当前服务,不使用历史清单宣称已更新' }
|
||||
}
|
||||
function Get-ApplicationVersionPlan {
|
||||
param([string]$Path)
|
||||
$content = Get-Content $Path -Raw -Encoding UTF8
|
||||
$selection = [string]$env:UPDATE_SERVICES
|
||||
$selected = @($selection.Trim() -split '\s+' | Where-Object { $_ })
|
||||
$selected = @(([string]$env:UPDATE_SERVICES).Trim() -split '\s+' | Where-Object { $_ })
|
||||
$requested = if ($env:UPDATE_RELEASE_VERSION) { $env:UPDATE_RELEASE_VERSION } else { $env:DEPLOY_RELEASE_VERSION }
|
||||
if ($requested -and $requested -notmatch '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?$') { throw '产品版本必须为完整版本号' }
|
||||
$relative = if ($requested) { "system-update/releases/$requested.json" } else { 'system-update/release-manifest.json' }
|
||||
$sourceMode = if ($null -ne $env:UPDATE_MANIFEST_SOURCE) { $env:UPDATE_MANIFEST_SOURCE } else { Get-EnvValue $content 'UPDATE_MANIFEST_SOURCE' }
|
||||
if (-not $sourceMode) { $sourceMode = 'remote' }
|
||||
$sourceMode = $sourceMode.Trim().ToLowerInvariant()
|
||||
if ($sourceMode -notin @('remote','local')) { throw 'UPDATE_MANIFEST_SOURCE 只支持 remote 或 local' }
|
||||
$releaseUrl = if ($null -ne $env:UPDATE_MANIFEST_URL) { $env:UPDATE_MANIFEST_URL } else { Get-EnvValue $content 'UPDATE_MANIFEST_URL' }
|
||||
if (-not $releaseUrl) { $releaseUrl = 'https://git.51easyai.com/wangbo/easyai/raw/branch/main/system-update/release-manifest.json' }
|
||||
try {
|
||||
$address = [Uri]$releaseUrl
|
||||
if ($address.Scheme -notin @('http','https') -or $address.UserInfo -or $releaseUrl -match '[\x00-\x1f\\]') { throw 'invalid URL' }
|
||||
} catch { throw '发布源必须是有效 HTTP(S) 地址,不能内嵌登录凭据或控制字符' }
|
||||
$manifestPath = Join-Path $script:ApplicationReleaseRoot $relative
|
||||
if ($sourceMode -eq 'remote' -and -not $requested) {
|
||||
$manifest = Read-DeploymentReleaseRecord -Url $releaseUrl
|
||||
} elseif (Test-Path $manifestPath) {
|
||||
$manifest = Read-DeploymentReleaseRecord -Path $manifestPath
|
||||
} elseif ($sourceMode -eq 'remote') {
|
||||
$manifest = Read-DeploymentReleaseRecord -Url ([Uri]::new([Uri]$releaseUrl,"releases/$requested.json").AbsoluteUri)
|
||||
} else { $manifest = Read-DeploymentReleaseRecord -Path $manifestPath }
|
||||
$version = [string]$manifest.productVersion
|
||||
if ($manifest.schemaVersion -ne 1 -or $version -notmatch '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?$' -or $manifest.platform -ne 'linux/amd64' -or (-not $requested -and $manifest.channel -ne 'stable') -or ($requested -and $version -ne $requested)) { throw '发布记录的版本、通道或平台不正确' }
|
||||
$support = Read-DeploymentReleaseRecord -Path (Join-Path $script:ApplicationReleaseRoot 'system-update/deployment-components.json')
|
||||
$records = @{}
|
||||
foreach ($item in $support.components) { $records[$item.key] = $item }
|
||||
$productPath = Join-Path $script:ApplicationReleaseRoot "system-update/releases/$version.json"
|
||||
if ((Test-Path $productPath) -or $sourceMode -eq 'remote') {
|
||||
$product = if (Test-Path $productPath) { Read-DeploymentReleaseRecord -Path $productPath } else {
|
||||
Read-DeploymentReleaseRecord -Url ([Uri]::new([Uri]$releaseUrl,"releases/$version.json").AbsoluteUri)
|
||||
}
|
||||
if ($product.productVersion -ne $version) { throw '配套产物记录与产品版本不一致' }
|
||||
foreach ($item in $product.components) { $records[$item.key] = $item }
|
||||
}
|
||||
foreach ($item in $manifest.components) { $records[$item.key] = $item }
|
||||
foreach ($item in $manifest.deploymentComponents) { $records[$item.key] = $item }
|
||||
$mapping = @(
|
||||
@('easyai-server','SERVER_VERSION','UPDATE_SERVER_VERSION','registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server'),
|
||||
@('easyai-web','WEB_VERSION','UPDATE_WEB_VERSION','registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai'),
|
||||
@('ws-gateway','WS_GATEWAY_VERSION','UPDATE_GATEWAY_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway'),
|
||||
@('model-conversion-worker','MODEL_CONVERSION_WORKER_VERSION','UPDATE_WORKER_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker'),
|
||||
@('easyai-updater','UPDATER_VERSION','UPDATE_UPDATER_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater'),
|
||||
@('easyai-asg','ASG_VERSION','UPDATE_ASG_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance'),
|
||||
@('agent-memory','AMS_VERSION','UPDATE_AMS_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory')
|
||||
@('easyai-server','server-main','SERVER_VERSION','UPDATE_SERVER_VERSION','comfy-ai/comfy-server'),
|
||||
@('easyai-web','web','WEB_VERSION','UPDATE_WEB_VERSION','comfy-ai/one-ai'),
|
||||
@('ws-gateway','ws-gateway','WS_GATEWAY_VERSION','UPDATE_GATEWAY_VERSION','easyaigc/wsgateway'),
|
||||
@('model-conversion-worker','model-conversion-worker','MODEL_CONVERSION_WORKER_VERSION','UPDATE_WORKER_VERSION','easyaigc/model-conversion-worker'),
|
||||
@('easyai-updater','updater','UPDATER_VERSION','UPDATE_UPDATER_VERSION','easyaigc/easyai-updater'),
|
||||
@('easyai-asg','service-governance','ASG_VERSION','UPDATE_ASG_VERSION','easyaigc/agent-governance'),
|
||||
@('agent-memory','memory-service','AMS_VERSION','UPDATE_AMS_VERSION','easyaigc/agent-memory')
|
||||
)
|
||||
$plan = @()
|
||||
foreach ($item in $mapping) {
|
||||
$service,$key,$option,$image = $item
|
||||
$value = [Environment]::GetEnvironmentVariable($option)
|
||||
$isSelected = $selected.Count -eq 0 -or $service -in $selected
|
||||
if ($value -and -not $isSelected) { throw "$option 要求在 UPDATE_SERVICES 中指定 $service" }
|
||||
if (-not $isSelected) { continue }
|
||||
if (-not $value) { $value = $env:UPDATE_RELEASE_VERSION }
|
||||
if (-not $value) { $value = Get-EnvValue $content $key }
|
||||
if (-not $value) { $value = Get-EnvValue $content 'VERSION' }
|
||||
$pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?$'
|
||||
if ($service -eq 'easyai-web') { $pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?(-no-ssr|-hmg-ai|-easyai|-chinacuc)?$' }
|
||||
if (-not $value -or $value -notmatch $pattern) { throw "$service 缺少固定版本;请指定 $option / UPDATE_RELEASE_VERSION,latest/dev 等浮动标签不能用于应用更新" }
|
||||
$plan += [PSCustomObject]@{ Service=$service; Key=$key; Version=$value; Image=$image }
|
||||
}
|
||||
if ($env:UPDATE_RELEASE_VERSION -and ($selected.Count -eq 0 -or ('easyai-server' -in $selected -and 'easyai-web' -in $selected))) {
|
||||
$oldDefault = Get-EnvValue $content 'VERSION'
|
||||
foreach ($item in $mapping) {
|
||||
$service,$key,$option,$image = $item
|
||||
if ($selected.Count -gt 0 -and $service -notin $selected -and -not (Get-EnvValue $content $key) -and $oldDefault) {
|
||||
$plan += [PSCustomObject]@{ Service=''; Key=$key; Version=$oldDefault; Image='' }
|
||||
$mode = Get-EnvValue $content 'SYSTEM_UPDATE_MODE'
|
||||
if (-not $mode) { $mode = $env:EASYAI_UPDATE_MODE_HINT }
|
||||
$legacy = $mode -eq 'legacy_watchtower'
|
||||
foreach ($entry in $mapping) {
|
||||
$service,$component,$key,$option,$repository = $entry
|
||||
$explicit = [Environment]::GetEnvironmentVariable($option)
|
||||
if ($selected.Count -gt 0 -and $service -notin $selected) {
|
||||
if ($explicit) { throw "$option 要求在 UPDATE_SERVICES 中指定 $service" }
|
||||
continue
|
||||
}
|
||||
$image = "registry.cn-shanghai.aliyuncs.com/$repository"
|
||||
$stableDigest = ""
|
||||
$variant = ""
|
||||
if ($explicit) {
|
||||
$pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?'
|
||||
if ($service -eq 'easyai-web') { $pattern += '(-no-ssr|-hmg-ai|-easyai|-chinacuc)?' }
|
||||
if ($service -eq 'easyai-updater') { $pattern = '^\d+\.\d+\.\d+(\.\d{8}(\.\d+)?)?' }
|
||||
if ($explicit -notmatch ($pattern + '(@sha256:[a-f0-9]{64})?$')) { throw "$option 必须是已发布的固定版本,不能使用浮动标签" }
|
||||
$target = $explicit
|
||||
} else {
|
||||
$item = $records[$component]
|
||||
$oldWeb = Get-EnvValue $content 'WEB_VERSION'
|
||||
if (-not $oldWeb) { $oldWeb = Get-EnvValue $content 'VERSION' }
|
||||
if ($service -eq 'easyai-web' -and $oldWeb -match '(-no-ssr|-hmg-ai|-easyai|-chinacuc)(@.*)?$') {
|
||||
$variant = $Matches[1]
|
||||
$variantKey = if ($variant -eq '-no-ssr') { 'web-no-ssr' } else { $variant.Substring(1) }
|
||||
$item = $records[$variantKey]
|
||||
if (-not $item) { throw "所选发布没有当前网页变体 $variant;请显式指定 UPDATE_WEB_VERSION" }
|
||||
}
|
||||
if (-not $item -or $item.image -ne $image -or $item.digest -notmatch '^sha256:[a-f0-9]{64}$') { throw "发布记录缺少有效配套产物 $component" }
|
||||
$tag = if ($item.tag) { [string]$item.tag } else { 'latest' }
|
||||
if ($tag -notmatch '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') { throw '配套产物标签不正确' }
|
||||
$target = if ($service -eq 'easyai-updater' -and $tag -eq 'latest') { $tag } else { "$tag@$($item.digest)" }
|
||||
if ($legacy -and -not $requested -and $component -in @('server-main','web','ws-gateway')) {
|
||||
$target = 'latest' + $variant
|
||||
$stableDigest = [string]$item.digest
|
||||
}
|
||||
}
|
||||
$plan += [PSCustomObject]@{ Service=''; Key='VERSION'; Version=$env:UPDATE_RELEASE_VERSION; Image='' }
|
||||
$plan += [PSCustomObject]@{ Service=$service; Key=$key; Version=$target; Image=$image }
|
||||
if ($component -in @('server-main','web','ws-gateway')) {
|
||||
$plan += [PSCustomObject]@{ Service=''; Key=$key.Replace('_VERSION','_STABLE_DIGEST'); Version=$stableDigest; Image='' }
|
||||
}
|
||||
}
|
||||
if ($selected.Count -eq 0 -or ('easyai-server' -in $selected -and 'easyai-web' -in $selected)) {
|
||||
$plan += [PSCustomObject]@{ Service=''; Key='VERSION'; Version=$version; Image='' }
|
||||
}
|
||||
return $plan
|
||||
}
|
||||
@@ -67,3 +134,14 @@ function Test-ApplicationVersionImages {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-DeploymentReleaseSource {
|
||||
param([string]$Path)
|
||||
$content = Get-Content $Path -Raw -Encoding UTF8
|
||||
if ($null -ne $env:UPDATE_MANIFEST_SOURCE) {
|
||||
$mode = if ($env:UPDATE_MANIFEST_SOURCE) { $env:UPDATE_MANIFEST_SOURCE.Trim().ToLowerInvariant() } else { 'remote' }
|
||||
$content = Set-EnvValue $content 'UPDATE_MANIFEST_SOURCE' $mode
|
||||
}
|
||||
if ($null -ne $env:UPDATE_MANIFEST_URL) { $content = Set-EnvValue $content 'UPDATE_MANIFEST_URL' $env:UPDATE_MANIFEST_URL }
|
||||
[System.IO.File]::WriteAllText($Path, $content, [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicitly adopt an old non-Git Linux package IN PLACE, preserving its data/config.
|
||||
|
||||
The source must be a clean Git checkout of the reviewed deployment repository.
|
||||
Unknown/custom configuration is preserved in an ignored Compose override.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('project', type=Path)
|
||||
parser.add_argument('source', type=Path)
|
||||
args = parser.parse_args()
|
||||
project, source = args.project.resolve(), args.source.resolve()
|
||||
|
||||
|
||||
def git(root, *arguments):
|
||||
return subprocess.check_output(['git', '-C', str(root), *arguments], text=True).strip()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
raise SystemExit('旧部署包迁移需要 PyYAML;请先安装 python3-yaml,尚未修改部署目录')
|
||||
if (project / '.git').exists() or project == source:
|
||||
raise SystemExit('此入口只用于没有 Git 的旧部署包')
|
||||
env = project / '.env'
|
||||
compose = project / 'docker-compose.yml'
|
||||
if not env.is_file() or not compose.is_file() or env.is_symlink() or compose.is_symlink():
|
||||
raise SystemExit('旧目录缺少 .env 或 docker-compose.yml,不能自动迁移')
|
||||
content = env.read_text()
|
||||
if any(line.startswith('COMPOSE_FILE=') and line.split('=', 1)[1].strip().strip('\"\'') for line in content.splitlines()):
|
||||
raise SystemExit('旧目录已有 COMPOSE_FILE 覆盖,请先核对差异后迁移;未修改配置')
|
||||
if git(source, 'status', '--porcelain', '--untracked-files=no'):
|
||||
raise SystemExit('迁移源含未提交改动,请使用已提交的部署仓库')
|
||||
revision = git(source, 'rev-parse', 'HEAD')
|
||||
repository = git(source, 'remote', 'get-url', 'origin')
|
||||
try:
|
||||
legacy = yaml.safe_load(compose.read_text())
|
||||
except (yaml.YAMLError, UnicodeError):
|
||||
raise SystemExit('旧 Compose 无法解析,尚未修改配置或容器')
|
||||
if not isinstance(legacy, dict) or not {'easyai-server', 'easyai-web'} <= set(legacy.get('services', {})):
|
||||
raise SystemExit('不能识别旧 Compose 服务,未修改部署目录')
|
||||
for service in legacy['services'].values():
|
||||
image = service.get('image', '')
|
||||
if image.startswith(('registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:',
|
||||
'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:',
|
||||
'registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:',
|
||||
'registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:',
|
||||
'registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:',
|
||||
'registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:',
|
||||
'registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:')):
|
||||
service.pop('image')
|
||||
legacy.pop('version', None)
|
||||
paths = git(source, 'ls-tree', '-r', '--name-only', 'HEAD').splitlines()
|
||||
for name in paths:
|
||||
target = project / name
|
||||
if target.is_symlink() or os.path.commonpath([str(target.resolve()), str(project)]) != str(project):
|
||||
raise SystemExit('目标路径包含符号链接或越界路径,未修改部署目录')
|
||||
if target.exists() and not target.is_file():
|
||||
raise SystemExit('目标文件与已有目录冲突,未修改部署目录')
|
||||
if name.startswith(('data/', 'sandbox-data/')) and target.is_file() and target.read_bytes() != (source / name).read_bytes():
|
||||
raise SystemExit('旧数据目录含有与仓库模板不同的文件,请先核对差异;未覆盖原数据')
|
||||
# Ordinary Compose overrides APPEND port bindings. Validate the effective
|
||||
# merge before adopting it; never silently open an extra default port.
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-package-check-') as temporary:
|
||||
staging = Path(temporary)
|
||||
for name in paths:
|
||||
target = staging / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source / name, target)
|
||||
for sample in source.glob('.env*.sample'):
|
||||
shutil.copyfile(sample, staging / sample.name[:-len('.sample')])
|
||||
for private in project.glob('.env*'):
|
||||
if private.is_file() and not private.name.endswith('.sample'):
|
||||
shutil.copyfile(private, staging / private.name)
|
||||
shutil.copyfile(compose, staging / 'old-package.yml')
|
||||
(staging / 'legacy-package.json').write_text(json.dumps(legacy))
|
||||
compose_command = ['docker', 'compose']
|
||||
if subprocess.run(compose_command + ['version'], capture_output=True).returncode:
|
||||
compose_command = ['docker-compose']
|
||||
|
||||
def configuration(files):
|
||||
command = compose_command + ['--project-name', 'easyai-package-check', '--project-directory', str(staging), '--env-file', str(staging / '.env')]
|
||||
for name in files:
|
||||
command += ['-f', str(staging / name)]
|
||||
result = subprocess.run(command + ['config', '--format', 'json'], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise SystemExit('旧配置无法与新版安全合并;请检查环境文件和 Compose 版本,原部署未修改')
|
||||
return json.loads(result.stdout)
|
||||
|
||||
original = configuration(['old-package.yml'])
|
||||
merged = configuration(['docker-compose.yml', 'legacy-package.json'])
|
||||
for service, settings in original['services'].items():
|
||||
old_ports = settings.get('ports', [])
|
||||
new_ports = merged['services'][service].get('ports', [])
|
||||
if old_ports and old_ports != new_ports:
|
||||
raise SystemExit(f'服务 {service} 的旧端口无法安全表示为覆盖配置;请先核对差异,原部署未修改')
|
||||
os.umask(0o077)
|
||||
backups = project / 'data/deployment-backups'
|
||||
backups.mkdir(parents=True, exist_ok=True)
|
||||
backup = Path(tempfile.mkdtemp(prefix='package-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=backups))
|
||||
records = []
|
||||
# Include all files the new repository will replace, plus private settings.
|
||||
for name in sorted(set(paths) | {p.name for p in project.glob('.env*') if p.is_file()}):
|
||||
original = project / name
|
||||
if original.is_file():
|
||||
saved = backup / 'files' / name
|
||||
saved.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(original, saved)
|
||||
saved.chmod(0o600)
|
||||
records.append({'file': name, 'sha256': hashlib.sha256(saved.read_bytes()).hexdigest()})
|
||||
(backup / 'backup.json').write_text(json.dumps({'kind': 'old-package-config-only', 'targetCommit': revision, 'files': records}, indent=2) + '\n')
|
||||
override = project / 'data/deployment-config/legacy-package.json'
|
||||
override.parent.mkdir(parents=True, exist_ok=True)
|
||||
override.write_text(json.dumps(legacy, ensure_ascii=False, indent=2) + '\n')
|
||||
# Create Git metadata without checking out over private settings or data.
|
||||
git(project, 'init', '-q')
|
||||
git(project, 'remote', 'add', 'origin', repository)
|
||||
git(project, 'fetch', '-q', '--depth=1', str(source), 'HEAD')
|
||||
git(project, 'symbolic-ref', 'HEAD', 'refs/heads/main')
|
||||
git(project, 'update-ref', 'refs/heads/main', revision)
|
||||
git(project, 'read-tree', 'HEAD')
|
||||
git(project, 'config', 'branch.main.remote', 'origin')
|
||||
git(project, 'config', 'branch.main.merge', 'refs/heads/main')
|
||||
for name in paths:
|
||||
destination = project / name
|
||||
if name.startswith(('data/', 'sandbox-data/')) and destination.exists():
|
||||
continue
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source / name, destination)
|
||||
with (project / '.git/info/exclude').open('a') as output:
|
||||
output.write('\n/data/deployment-config/\n/data/deployment-backups/\n')
|
||||
if content and not content.endswith('\n'):
|
||||
content += '\n'
|
||||
content += 'COMPOSE_FILE=docker-compose.yml:data/deployment-config/legacy-package.json\n'
|
||||
env.write_text(content)
|
||||
env.chmod(0o600)
|
||||
print('已在原目录接入部署仓库;保留原 .env、数据目录、数据卷与本地配置。')
|
||||
print('旧文件备份(不含数据库):' + str(backup))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preserve deployment configuration before migrations; this is not a database backup."""
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
root = Path.cwd()
|
||||
os.umask(0o077)
|
||||
parent = root / 'data/deployment-backups'
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
backup = Path(tempfile.mkdtemp(prefix='update-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=parent))
|
||||
files = list(root.glob('.env*')) + [root / name for name in ('docker-compose.yml', 'docker-compose.override.yml', 'compose.override.yaml')]
|
||||
records = []
|
||||
for source in files:
|
||||
if not source.is_file() or source.name.endswith('.sample'):
|
||||
continue
|
||||
target = backup / source.name
|
||||
shutil.copyfile(source, target)
|
||||
target.chmod(0o600)
|
||||
records.append({'file': source.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
(backup / 'backup.json').write_text(json.dumps({'kind': 'deployment-config-only', 'files': records}, indent=2) + '\n')
|
||||
print('已备份升级前配置(不含数据库):' + str(backup))
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Real anonymous image preflight for the complete README installation, no daemon mutations.
|
||||
set -euo pipefail
|
||||
unset UPDATE_SERVICES UPDATE_RELEASE_VERSION UPDATE_SERVER_VERSION UPDATE_WEB_VERSION UPDATE_GATEWAY_VERSION UPDATE_WORKER_VERSION UPDATE_UPDATER_VERSION UPDATE_ASG_VERSION UPDATE_AMS_VERSION
|
||||
unset UPDATE_MANIFEST_SOURCE UPDATE_MANIFEST_URL
|
||||
source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
manifest_source=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) export DEPLOY_RELEASE_VERSION="${2:?缺少版本}"; shift 2 ;;
|
||||
--manifest) manifest_source="$(cd "$(dirname "${2:?缺少发布记录}")" && pwd)/$(basename "$2")"; shift 2 ;;
|
||||
*) echo '用法:bash scripts/check-deployment-release.sh [--version 完整版本] [--manifest 发布记录]' >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$manifest_source" ] && ! [[ "${DEPLOY_RELEASE_VERSION:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?$ ]]; then
|
||||
echo '--manifest 必须同时指定完整 --version' >&2
|
||||
exit 1
|
||||
fi
|
||||
temporary="$(mktemp -d "${TMPDIR:-/tmp}/easyai-release-check.XXXXXX")"
|
||||
trap 'rm -r "$temporary"' EXIT
|
||||
cp "$source_root/docker-compose.yml" "$temporary/"
|
||||
cp -R "$source_root/scripts" "$source_root/system-update" "$temporary/"
|
||||
if [ -n "$manifest_source" ]; then cp "$manifest_source" "$temporary/system-update/releases/${DEPLOY_RELEASE_VERSION}.json"; fi
|
||||
for name in .env .env.tools .env.ASG .env.AMS; do cp "$source_root/$name.sample" "$temporary/$name"; done
|
||||
cd "$temporary"
|
||||
. ./scripts/init-security-env.sh
|
||||
. ./scripts/init-application-version-env.sh
|
||||
write_env_value .env UPDATE_MANIFEST_SOURCE local
|
||||
plan_application_versions .env
|
||||
apply_application_versions .env
|
||||
if docker compose version >/dev/null 2>&1; then compose=(docker compose); else compose=(docker-compose); fi
|
||||
configuration="$("${compose[@]}" config --format json)"
|
||||
printf '%s' "$configuration" | python3 ./scripts/verify-deployment-images.py
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refuse to adopt another Compose project or replace existing persistent storage."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
STATEFUL = {'mongo': {'/data/db'}, 'redis': {'/data'}, 'rabbitmq': {'/var/lib/rabbitmq'},
|
||||
'easyai-pgvector': {'/var/lib/postgresql', '/var/lib/postgresql/data'},
|
||||
'easyai-server': {'/app/upload'}}
|
||||
|
||||
|
||||
def canonical(path):
|
||||
path = path.replace('\\', '/')
|
||||
path = re.sub(r'^/(?:run/desktop/mnt/host|host_mnt)/([A-Za-z])/', r'\1:/', path)
|
||||
if re.match(r'^[A-Za-z]:/', path):
|
||||
return path.rstrip('/').casefold()
|
||||
if path.startswith('/host_mnt/'):
|
||||
path = path[len('/host_mnt'):]
|
||||
return str(Path(path).resolve()).rstrip('/')
|
||||
|
||||
|
||||
def validate(configuration, existing, selected=()):
|
||||
for service, settings in configuration['services'].items():
|
||||
if selected and service not in selected:
|
||||
continue
|
||||
container = existing.get(service)
|
||||
if not container:
|
||||
continue
|
||||
project = (container.get('Config', {}).get('Labels') or {}).get('com.docker.compose.project')
|
||||
if project != configuration['name']:
|
||||
raise ValueError(f'{service} 的现有容器不属于当前 Compose 项目;请核对原安装目录和项目名')
|
||||
actual = {mount['Destination']: mount for mount in container.get('Mounts', [])}
|
||||
for mount in settings.get('volumes', []):
|
||||
if mount['target'] not in STATEFUL.get(service, set()):
|
||||
continue
|
||||
old = actual.get(mount['target'], {})
|
||||
if mount['type'] == 'bind':
|
||||
expected = canonical(mount['source'])
|
||||
equal = old.get('Type') == 'bind' and canonical(old.get('Source', '')) == expected and Path(expected).is_dir()
|
||||
else:
|
||||
definition = configuration.get('volumes', {}).get(mount.get('source'), {})
|
||||
name = definition.get('name')
|
||||
equal = bool(name) and old.get('Type') == 'volume' and old.get('Name') == name
|
||||
if not equal:
|
||||
raise ValueError(f'{service} 的 {mount["target"]} 数据位置将改变;请先备份并按数据迁移步骤处理')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--sudo', action='store_true')
|
||||
parser.add_argument('--services', nargs='*', default=[])
|
||||
args = parser.parse_args()
|
||||
configuration = json.load(sys.stdin)
|
||||
docker = ['sudo', 'docker'] if args.sudo else ['docker']
|
||||
if subprocess.run(docker + ['info'], capture_output=True).returncode:
|
||||
raise ValueError('无法连接 Docker,未检查现有容器和数据位置')
|
||||
existing = {}
|
||||
for service, settings in configuration['services'].items():
|
||||
if args.services and service not in args.services:
|
||||
continue
|
||||
# Explicit global names can collide with another installation. Services
|
||||
# without container_name are already scoped by Compose's project name.
|
||||
if not settings.get('container_name'):
|
||||
continue
|
||||
result = subprocess.run(docker + ['container', 'inspect', settings['container_name']], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
if result.stderr and not re.search('No such (?:container|object)', result.stderr, re.I):
|
||||
raise ValueError(f'无法核对现有容器 {service},未继续部署')
|
||||
continue
|
||||
existing[service] = json.loads(result.stdout)[0]
|
||||
validate(configuration, existing, args.services)
|
||||
print('✅ 现有容器归属和持久化数据位置检查通过')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (ValueError, KeyError, TypeError, OSError) as error:
|
||||
print('❌ ' + str(error) + ';尚未拉取或重建服务', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -4,7 +4,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
expected_dir="$(cd "${project_dir}/data" && pwd -P)/redis"
|
||||
container="${REDIS_CONTAINER_NAME:-redis}"
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
@@ -16,6 +15,12 @@ if ! docker container inspect "$container" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "${project_dir}/data" ]; then
|
||||
echo '❌ 现有 Redis 的原数据目录不存在,停止部署;不会创建空目录替换旧数据' >&2
|
||||
exit 1
|
||||
fi
|
||||
expected_dir="$(cd "${project_dir}/data" && pwd -P)/redis"
|
||||
|
||||
mount="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Source}}{{end}}{{end}}' "$container")"
|
||||
if [ "$mount" != "bind|$expected_dir" ]; then
|
||||
echo "❌ 现有 Redis 的 /data 未挂载到 ${expected_dir},停止部署以保护 AOF/RDB 数据" >&2
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# Shared installation/update checks. Never print compose config or credentials.
|
||||
init_deployment_docker_config() {
|
||||
local file="$1" configured fallback
|
||||
configured="$(read_env_value "$file" DOCKER_CONFIG_FILE)"
|
||||
if [ -n "$configured" ] && [ -f "$configured" ]; then return 0; fi
|
||||
if [ -n "$configured" ] && [ "$configured" != /root/.docker/config.json ]; then
|
||||
echo '❌ DOCKER_CONFIG_FILE 不是可读取的配置文件;未启动服务' >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -f "${DOCKER_CONFIG:-$HOME/.docker}/config.json" ]; then
|
||||
write_env_value "$file" DOCKER_CONFIG_FILE "${DOCKER_CONFIG:-$HOME/.docker}/config.json"
|
||||
return 0
|
||||
fi
|
||||
# Anonymous installations have no ~/.docker/config.json. A missing short
|
||||
# bind source would otherwise be created by Docker as a DIRECTORY.
|
||||
fallback="$PWD/data/docker-auth/config.json"
|
||||
mkdir -p "$(dirname "$fallback")"
|
||||
chmod 700 "$(dirname "$fallback")"
|
||||
if [ ! -e "$fallback" ]; then (umask 077; printf '{}\n' > "$fallback"); fi
|
||||
[ -f "$fallback" ] || { echo '❌ 安装目录的镜像认证配置不是文件' >&2; return 1; }
|
||||
write_env_value "$file" DOCKER_CONFIG_FILE "$fallback"
|
||||
}
|
||||
|
||||
verify_deployment_images() {
|
||||
local compose="$1" configuration
|
||||
shift
|
||||
configuration="$(sudo $compose config --format json)" || return 1
|
||||
printf '%s' "$configuration" | python3 ./scripts/verify-deployment-images.py --sudo --services "$@"
|
||||
}
|
||||
|
||||
check_deployment_storage() {
|
||||
local compose="$1" configuration
|
||||
shift
|
||||
configuration="$(sudo $compose config --format json)" || return 1
|
||||
printf '%s' "$configuration" | python3 ./scripts/check-deployment-storage.py --sudo --services "$@"
|
||||
}
|
||||
|
||||
verify_pulled_release() {
|
||||
local compose="$1" configuration
|
||||
shift
|
||||
configuration="$(sudo $compose config --format json)" || return 1
|
||||
printf '%s' "$configuration" | python3 ./scripts/verify-pulled-release.py --sudo --services "$@"
|
||||
}
|
||||
|
||||
wait_for_deployment_services() {
|
||||
local compose="$1" timeout="$2" deadline service container_id state running health all_ready passes=0
|
||||
shift 2
|
||||
local services=("$@")
|
||||
[[ "$timeout" =~ ^[1-9][0-9]*$ ]] || { echo '❌ 健康检查等待秒数必须为正整数' >&2; return 1; }
|
||||
if [ "${#services[@]}" -eq 0 ]; then
|
||||
while IFS= read -r service; do [ -z "$service" ] || services+=("$service"); done < <(sudo $compose config --services)
|
||||
fi
|
||||
[ "${#services[@]}" -gt 0 ] || return 1
|
||||
deadline=$((SECONDS + timeout))
|
||||
echo "⏳ 等待 ${#services[@]} 个服务恢复可用(最长 ${timeout} 秒)..."
|
||||
while (( SECONDS < deadline )); do
|
||||
all_ready=true
|
||||
for service in "${services[@]}"; do
|
||||
container_id="$(sudo $compose ps -q "$service" 2>/dev/null || true)"
|
||||
if [ -z "$container_id" ]; then all_ready=false; continue; fi
|
||||
state="$(sudo docker inspect --format '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)"
|
||||
running="${state%%|*}"; health="${state#*|}"
|
||||
if [ "$running" != true ] || { [ "$health" != none ] && [ "$health" != healthy ]; }; then
|
||||
all_ready=false
|
||||
elif [ "$service" = watchtower ] && [ "$health" = none ]; then
|
||||
sudo docker exec "$container_id" /watchtower --health-check >/dev/null 2>&1 || all_ready=false
|
||||
fi
|
||||
done
|
||||
if [ "$all_ready" = true ]; then
|
||||
passes=$((passes + 1))
|
||||
if [ "$passes" -ge 2 ]; then echo '✅ 所选服务已恢复可用'; return 0; fi
|
||||
sleep 2
|
||||
else
|
||||
passes=0
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
echo '❌ 服务未在规定时间内恢复可用,部署未完成' >&2
|
||||
sudo $compose ps "${services[@]}" || true
|
||||
echo "请运行:$compose logs --tail=100 ${services[*]}" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -12,37 +12,23 @@ application_selected() {
|
||||
[ -z "${UPDATE_SERVICES:-}" ] || [[ " ${UPDATE_SERVICES} " == *" $1 "* ]]
|
||||
}
|
||||
|
||||
plan_application_versions() {
|
||||
local file="$1" service key option image value suffix base old_default
|
||||
APPLICATION_VERSION_WRITES=()
|
||||
while read -r service key option image; do
|
||||
value="${!option:-}"
|
||||
if [ -n "$value" ] && ! application_selected "$service"; then
|
||||
echo "❌ $option 要求在 UPDATE_SERVICES 中指定 $service;尚未修改环境或容器" >&2
|
||||
return 1
|
||||
fi
|
||||
application_selected "$service" || continue
|
||||
if [ -z "$value" ] && [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then value="$UPDATE_RELEASE_VERSION"; fi
|
||||
if [ -z "$value" ]; then value="$(read_env_value "$file" "$key")"; fi
|
||||
if [ -z "$value" ]; then value="$(read_env_value "$file" VERSION)"; fi
|
||||
suffix=''
|
||||
if [ "$service" = easyai-web ]; then suffix='(-no-ssr|-hmg-ai|-easyai|-chinacuc)?'; fi
|
||||
if ! [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?${suffix}$ ]]; then
|
||||
echo "❌ $service 缺少固定版本:请指定 $option 或 UPDATE_RELEASE_VERSION,或在 .env 保留有效 $key;latest/dev 等浮动标签不能用于应用更新" >&2
|
||||
return 1
|
||||
fi
|
||||
APPLICATION_VERSION_WRITES+=("$key=$value")
|
||||
done <<< "$APPLICATION_VERSION_MAP"
|
||||
if [ -n "${UPDATE_RELEASE_VERSION:-}" ] && application_selected easyai-server && application_selected easyai-web; then
|
||||
# VERSION 仍保留兼容入口;改变它前先冻结未选中服务的旧继承值。
|
||||
old_default="$(read_env_value "$file" VERSION)"
|
||||
while read -r service key option image; do
|
||||
application_selected "$service" && continue
|
||||
value="$(read_env_value "$file" "$key")"
|
||||
if [ -z "$value" ] && [ -n "$old_default" ]; then APPLICATION_VERSION_WRITES+=("$key=$old_default"); fi
|
||||
done <<< "$APPLICATION_VERSION_MAP"
|
||||
APPLICATION_VERSION_WRITES+=("VERSION=$UPDATE_RELEASE_VERSION")
|
||||
init_deployment_release_source() {
|
||||
local file="$1" mode
|
||||
if [ "${UPDATE_MANIFEST_SOURCE+x}" = x ]; then
|
||||
mode="$(printf '%s' "${UPDATE_MANIFEST_SOURCE:-remote}" | tr '[:upper:]' '[:lower:]')"
|
||||
write_env_value "$file" UPDATE_MANIFEST_SOURCE "$mode"
|
||||
fi
|
||||
if [ "${UPDATE_MANIFEST_URL+x}" = x ]; then write_env_value "$file" UPDATE_MANIFEST_URL "$UPDATE_MANIFEST_URL"; fi
|
||||
}
|
||||
|
||||
plan_application_versions() {
|
||||
local file="$1" plan entry resolver_root
|
||||
resolver_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APPLICATION_VERSION_WRITES=()
|
||||
plan="$(python3 "$resolver_root/scripts/resolve-deployment-release.py" --root "$resolver_root" --env-file "$file")" || return 1
|
||||
while IFS= read -r entry; do
|
||||
[ -z "$entry" ] || APPLICATION_VERSION_WRITES+=("$entry")
|
||||
done <<< "$plan"
|
||||
}
|
||||
|
||||
apply_application_versions() {
|
||||
|
||||
@@ -110,6 +110,10 @@ function main() {
|
||||
if (!VERSION.test(options.version ?? '') || options.version!==options['confirm-version']) throw new Error('用法: node scripts/promote-stable-release.mjs --version X.Y.Z.YYYYMMDD[.N] --confirm-version 同一版本 [--dry-run];仅在用户明确指定版本转稳定版后执行');
|
||||
const manifestPath=resolve(options.manifest ?? `${ROOT}/system-update/releases/${options.version}.json`);
|
||||
const manifest=JSON.parse(readFileSync(manifestPath,'utf8'));
|
||||
// The updater's three-component manifest is not the full first-install set.
|
||||
// Verify every default README image anonymously before changing stable aliases.
|
||||
promotionPlan(manifest,options.version,options['confirm-version']);
|
||||
execFileSync('bash',[`${ROOT}/scripts/check-deployment-release.sh`,'--version',options.version,'--manifest',manifestPath],{stdio:'inherit'});
|
||||
console.log(JSON.stringify(promoteStableRelease({manifest,version:options.version,confirmedVersion:options['confirm-version'],
|
||||
outputPath:resolve(options.output ?? `${ROOT}/system-update/release-manifest.json`),dryRun:options.dryRun}),null,2));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve a product release to its actual independently published components.
|
||||
|
||||
This only reads committed release records; it never changes stable aliases or
|
||||
chooses a newer candidate automatically. stdout contains version settings only.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
REGISTRY = 'registry.cn-shanghai.aliyuncs.com/'
|
||||
COMPONENTS = (
|
||||
('easyai-server', 'server-main', 'SERVER_VERSION', 'UPDATE_SERVER_VERSION', 'comfy-ai/comfy-server'),
|
||||
('easyai-web', 'web', 'WEB_VERSION', 'UPDATE_WEB_VERSION', 'comfy-ai/one-ai'),
|
||||
('ws-gateway', 'ws-gateway', 'WS_GATEWAY_VERSION', 'UPDATE_GATEWAY_VERSION', 'easyaigc/wsgateway'),
|
||||
('model-conversion-worker', 'model-conversion-worker', 'MODEL_CONVERSION_WORKER_VERSION', 'UPDATE_WORKER_VERSION', 'easyaigc/model-conversion-worker'),
|
||||
('easyai-updater', 'updater', 'UPDATER_VERSION', 'UPDATE_UPDATER_VERSION', 'easyaigc/easyai-updater'),
|
||||
('easyai-asg', 'service-governance', 'ASG_VERSION', 'UPDATE_ASG_VERSION', 'easyaigc/agent-governance'),
|
||||
('agent-memory', 'memory-service', 'AMS_VERSION', 'UPDATE_AMS_VERSION', 'easyaigc/agent-memory'),
|
||||
)
|
||||
PRODUCT_VERSION = r'\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?'
|
||||
SUFFIX = r'(?:-no-ssr|-hmg-ai|-easyai|-chinacuc)?'
|
||||
DIGEST = r'sha256:[a-f0-9]{64}'
|
||||
DEFAULT_RELEASE_URL = 'https://git.51easyai.com/wangbo/easyai/raw/branch/main/system-update/release-manifest.json'
|
||||
|
||||
|
||||
def env_values(path):
|
||||
result = {}
|
||||
for line in path.read_text(encoding='utf-8-sig').splitlines():
|
||||
match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)$', line)
|
||||
if match:
|
||||
result.setdefault(match[1], match[2].strip().strip('\"\''))
|
||||
return result
|
||||
|
||||
|
||||
def load(path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
except (OSError, ValueError) as error:
|
||||
raise ValueError(f'无法读取发布记录 {path.name};请完整更新部署仓库后重试') from error
|
||||
|
||||
|
||||
def remote_record(url):
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in ('http', 'https') or not parts.netloc or parts.username or parts.password:
|
||||
raise ValueError('发布源必须是 HTTP(S) 地址,不能在地址中嵌入登录凭据')
|
||||
try:
|
||||
with urlopen(Request(url, headers={'Accept': 'application/json', 'Cache-Control': 'no-cache'}), timeout=20) as response:
|
||||
content = response.read(1024 * 1024 + 1)
|
||||
if len(content) > 1024 * 1024:
|
||||
raise ValueError('oversized record')
|
||||
return json.loads(content)
|
||||
except Exception as error:
|
||||
raise ValueError('无法读取正式发布源;保留当前服务,不使用历史清单宣称已更新') from error
|
||||
|
||||
|
||||
def resolve(root, values, selection, options):
|
||||
selected = set(selection.split())
|
||||
requested = options.get('UPDATE_RELEASE_VERSION') or options.get('DEPLOY_RELEASE_VERSION')
|
||||
if requested and not re.fullmatch(PRODUCT_VERSION, requested):
|
||||
raise ValueError('产品版本必须为完整版本号,不能使用 latest/dev 或路径')
|
||||
mode = (options.get('UPDATE_MANIFEST_SOURCE', values.get('UPDATE_MANIFEST_SOURCE')) or 'remote').strip().lower()
|
||||
if mode not in ('local', 'remote'):
|
||||
raise ValueError('UPDATE_MANIFEST_SOURCE 只支持 remote 或 local')
|
||||
release_url = (options.get('UPDATE_MANIFEST_URL', values.get('UPDATE_MANIFEST_URL')) or DEFAULT_RELEASE_URL).strip()
|
||||
address = urlsplit(release_url)
|
||||
if (address.scheme not in ('http', 'https') or not address.netloc or address.username or address.password
|
||||
or any(ord(char) < 32 for char in release_url) or '\\' in release_url):
|
||||
raise ValueError('发布源必须是有效 HTTP(S) 地址,不能内嵌登录凭据或控制字符')
|
||||
path = root / 'system-update' / ('releases/' + requested + '.json' if requested else 'release-manifest.json')
|
||||
if mode == 'remote' and not requested:
|
||||
manifest = remote_record(release_url)
|
||||
elif path.exists():
|
||||
manifest = load(path)
|
||||
elif mode == 'remote':
|
||||
manifest = remote_record(urljoin(release_url, 'releases/' + requested + '.json'))
|
||||
else:
|
||||
manifest = load(path)
|
||||
version = manifest.get('productVersion', '')
|
||||
if (manifest.get('schemaVersion') != 1 or not re.fullmatch(PRODUCT_VERSION, version)
|
||||
or manifest.get('platform') != 'linux/amd64'
|
||||
or manifest.get('channel') not in (('versioned', 'stable') if requested else ('stable',))):
|
||||
raise ValueError('发布记录的版本、通道或平台不正确,未修改配置或容器')
|
||||
if requested and version != requested:
|
||||
raise ValueError('发布记录与指定产品版本不一致')
|
||||
# Independently maintained services use the reviewed baseline shipped with
|
||||
# this deployment tool. Product-specific records override it when needed.
|
||||
support = load(root / 'system-update/deployment-components.json')
|
||||
records = {item['key']: item for item in support['components']}
|
||||
# Older updater manifests intentionally contain only its three supported
|
||||
# components. Use the matching product record for Worker / web variants.
|
||||
product_path = root / 'system-update/releases' / (version + '.json')
|
||||
if product_path.exists() or mode == 'remote':
|
||||
product = (load(product_path) if product_path.exists() else remote_record(urljoin(release_url, 'releases/' + version + '.json')))
|
||||
if product.get('productVersion') != version:
|
||||
raise ValueError('配套产物记录与产品版本不一致')
|
||||
records.update({item['key']: item for item in product['components']})
|
||||
records.update({item['key']: item for item in manifest['components']})
|
||||
records.update({item['key']: item for item in manifest.get('deploymentComponents', [])})
|
||||
plan = []
|
||||
legacy = (values.get('SYSTEM_UPDATE_MODE') or options.get('EASYAI_UPDATE_MODE_HINT')) == 'legacy_watchtower'
|
||||
for service, component, key, option, repository in COMPONENTS:
|
||||
explicit = options.get(option, '')
|
||||
if selected and service not in selected:
|
||||
if explicit:
|
||||
raise ValueError(f'{option} 要求在 UPDATE_SERVICES 中指定 {service}')
|
||||
continue
|
||||
image = REGISTRY + repository
|
||||
stable_digest = ''
|
||||
if explicit:
|
||||
pattern = PRODUCT_VERSION + (SUFFIX if service == 'easyai-web' else '')
|
||||
if service == 'easyai-updater':
|
||||
pattern = r'\d+\.\d+\.\d+(?:\.\d{8}(?:\.\d+)?)?'
|
||||
if not re.fullmatch(pattern + '(?:@' + DIGEST + ')?', explicit):
|
||||
raise ValueError(f'{option} 必须是已发布的固定版本,不能使用浮动标签')
|
||||
target = explicit
|
||||
else:
|
||||
item = records.get(component)
|
||||
old_web = values.get('WEB_VERSION') or values.get('VERSION', '')
|
||||
variant = re.search(r'(-no-ssr|-hmg-ai|-easyai|-chinacuc)(?:@.*)?$', old_web)
|
||||
if service == 'easyai-web' and variant:
|
||||
variant_key = {'-no-ssr': 'web-no-ssr'}.get(variant[1], variant[1][1:])
|
||||
item = records.get(variant_key)
|
||||
if not item:
|
||||
raise ValueError(f'所选发布没有当前网页变体 {variant[1]};请显式指定 UPDATE_WEB_VERSION')
|
||||
if not item or item.get('image') != image or not re.fullmatch(DIGEST, item.get('digest', '')):
|
||||
raise ValueError(f'发布记录缺少有效配套产物 {component},未修改配置或容器')
|
||||
tag = item.get('tag') or 'latest'
|
||||
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}', tag):
|
||||
raise ValueError(f'配套产物标签不正确:{component}')
|
||||
# A stable alias is allowed only WITH its immutable recorded digest.
|
||||
# The updater is infrastructure with an existing independent
|
||||
# Watchtower/cron maintenance contract. Keep its official stable
|
||||
# alias so old installations can still receive executor updates.
|
||||
target = tag if service == 'easyai-updater' and tag == 'latest' else tag + '@' + item['digest']
|
||||
if legacy and not requested and component in ('server-main', 'web', 'ws-gateway'):
|
||||
# Legacy Watchtower must keep following the official stable
|
||||
# aliases. Pinning its containers to a digest strands updates.
|
||||
target = 'latest' + (variant[1] if service == 'easyai-web' and variant else '')
|
||||
stable_digest = item['digest']
|
||||
plan.append({'Service': service, 'Key': key, 'Version': target, 'Image': image})
|
||||
if component in ('server-main', 'web', 'ws-gateway'):
|
||||
plan.append({'Service': '', 'Key': key.replace('_VERSION', '_STABLE_DIGEST'), 'Version': stable_digest, 'Image': ''})
|
||||
# Compose companion defaults are independent; changing VERSION cannot
|
||||
# silently repoint an unselected gateway / worker anymore.
|
||||
if not selected or {'easyai-server', 'easyai-web'} <= selected:
|
||||
plan.append({'Service': '', 'Key': 'VERSION', 'Version': version, 'Image': ''})
|
||||
return plan
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parent.parent)
|
||||
parser.add_argument('--env-file', type=Path, required=True)
|
||||
parser.add_argument('--format', choices=('json', 'env'), default='env')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
plan = resolve(args.root, env_values(args.env_file), os.environ.get('UPDATE_SERVICES', ''), os.environ)
|
||||
if args.format == 'json':
|
||||
print(json.dumps(plan, ensure_ascii=False))
|
||||
else:
|
||||
for item in plan:
|
||||
print(item['Key'] + '=' + item['Version'])
|
||||
except (ValueError, KeyError, TypeError) as error:
|
||||
print('❌ ' + str(error), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -211,7 +211,7 @@ assert manifest["schemaVersion"] == 1
|
||||
assert manifest["channel"] == "stable"
|
||||
assert manifest["platform"] == "linux/amd64"
|
||||
assert manifest["managedUpdateAllowed"] is True
|
||||
assert manifest["rollbackSafe"] is True
|
||||
assert type(manifest["rollbackSafe"]) is bool # reviewed policy may forbid rollback
|
||||
assert manifest["minimumUpdaterVersion"] == "1.1.0"
|
||||
assert manifest["minimumDeploymentSchemaVersion"] == 2
|
||||
assert int(manifest["requiredFreeBytes"]) > 0
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-shell release behavior: stable defaults, independent versions, legacy compatibility."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
cases = [
|
||||
('new install', 'VERSION=latest\n', {}, True),
|
||||
('legacy latest', 'VERSION=latest\nMODEL_CONVERSION_WORKER_VERSION=latest\n', {}, True),
|
||||
('watchtower aliases', 'VERSION=latest\nSYSTEM_UPDATE_MODE=legacy_watchtower\n', {}, True),
|
||||
('watchtower variant', 'VERSION=latest-no-ssr\nSYSTEM_UPDATE_MODE=legacy_watchtower\n', {}, True),
|
||||
('legacy pinned', 'VERSION=3.29.0.20261014.2\n', {}, True),
|
||||
('recorded product', 'VERSION=latest\n', {'UPDATE_RELEASE_VERSION': '3.29.0.20261014.2'}, True),
|
||||
('selected server', 'VERSION=latest\n', {'UPDATE_SERVICES': 'easyai-server', 'UPDATE_SERVER_VERSION': '3.29.0.20261014.2'}, True),
|
||||
('web variant', 'VERSION=latest-no-ssr\n', {}, True),
|
||||
('updater independent semver', 'VERSION=latest\n', {'UPDATE_SERVICES': 'easyai-updater', 'UPDATE_UPDATER_VERSION': '1.1.0'}, True),
|
||||
('unknown product', 'VERSION=latest\n', {'UPDATE_RELEASE_VERSION': '9.9.9.20990101'}, False),
|
||||
('invalid product path', 'VERSION=latest\n', {'UPDATE_RELEASE_VERSION': '../latest'}, False),
|
||||
('invalid scope', 'VERSION=latest\n', {'UPDATE_SERVICES': 'easyai-server', 'UPDATE_WEB_VERSION': '3.29.0.20261014.2'}, False),
|
||||
('floating component rejected', 'VERSION=latest\n', {'UPDATE_WEB_VERSION': 'latest'}, False),
|
||||
]
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-release-resolution-') as temporary:
|
||||
path = Path(temporary) / '.env'
|
||||
for name, content, options, succeeds in cases:
|
||||
path.write_text(content + 'UPDATE_MANIFEST_SOURCE=local\nCONFIG_JWT_SECRET=private-fixture-must-not-be-printed\n')
|
||||
before = path.read_bytes()
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(('UPDATE_', 'DEPLOY_'))}
|
||||
env.update(options)
|
||||
bash = ['python3', str(root / 'scripts/resolve-deployment-release.py'), '--env-file', str(path), '--format', 'json']
|
||||
ps = ['pwsh', '-NoProfile', '-NonInteractive', '-Command',
|
||||
'. $args[0]; . $args[1]; @(Get-ApplicationVersionPlan -Path $args[2]) | ConvertTo-Json -Compress',
|
||||
str(root / 'scripts/Initialize-SecurityEnv.ps1'), str(root / 'scripts/Initialize-ApplicationVersions.ps1'), str(path)]
|
||||
# -Command positional arguments are parsed as PowerShell expressions;
|
||||
# use a saved script to preserve paths with spaces on both shells.
|
||||
script = Path(temporary) / 'plan.ps1'
|
||||
script.write_text('param($Security,$Versions,$Path)\n$ErrorActionPreference="Stop"\n. $Security\n. $Versions\n@(Get-ApplicationVersionPlan -Path $Path) | ConvertTo-Json -Compress\n')
|
||||
ps = ['pwsh', '-NoProfile', '-NonInteractive', '-File', str(script),
|
||||
str(root / 'scripts/Initialize-SecurityEnv.ps1'), str(root / 'scripts/Initialize-ApplicationVersions.ps1'), str(path)]
|
||||
plans = []
|
||||
for command in (bash, ps):
|
||||
result = subprocess.run(command, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||
assert (result.returncode == 0) == succeeds, (name, result.stdout, result.stderr)
|
||||
assert 'private-fixture-must-not-be-printed' not in result.stdout + result.stderr
|
||||
if succeeds:
|
||||
plan = json.loads(result.stdout)
|
||||
plan = plan if isinstance(plan, list) else [plan]
|
||||
plans.append({entry['Key']: entry['Version'] for entry in plan})
|
||||
assert path.read_bytes() == before, 'planning must not mutate private settings'
|
||||
if succeeds:
|
||||
assert plans[0] == plans[1], (name, plans)
|
||||
if name in ('new install', 'legacy latest', 'legacy pinned', 'recorded product'):
|
||||
assert plans[0]['WS_GATEWAY_VERSION'].startswith('3.25.0.20261009@sha256:')
|
||||
assert plans[0]['MODEL_CONVERSION_WORKER_VERSION'].startswith('3.25.0.20261009.2@sha256:')
|
||||
assert plans[0]['UPDATER_VERSION'] == 'latest', 'keep the existing updater maintenance channel'
|
||||
if name == 'selected server':
|
||||
assert set(plans[0]) == {'SERVER_VERSION', 'SERVER_STABLE_DIGEST'}
|
||||
if name.startswith('watchtower'):
|
||||
assert plans[0]['SERVER_VERSION'] == plans[0]['WS_GATEWAY_VERSION'] == 'latest'
|
||||
assert plans[0]['SERVER_STABLE_DIGEST'].startswith('sha256:')
|
||||
assert plans[0]['WEB_VERSION'] == ('latest-no-ssr' if name.endswith('variant') else 'latest')
|
||||
if name == 'web variant':
|
||||
assert '-no-ssr@sha256:' in plans[0]['WEB_VERSION']
|
||||
print(name + ': passed in Bash planner and PowerShell')
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Both command-line shells honor a live stable source even when Git's snapshot differs."""
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from threading import Thread
|
||||
|
||||
source = Path(__file__).resolve().parent.parent
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-release-source-') as temporary:
|
||||
root = Path(temporary)
|
||||
manifest = json.loads((source/'system-update/releases/3.29.0.20261014.2.json').read_text())
|
||||
manifest['channel'] = 'stable'
|
||||
manifest['components'] = [item for item in manifest['components'] if item['key'] in {'server-main','web','ws-gateway'}]
|
||||
(root/'release-manifest.json').write_text(json.dumps(manifest))
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def log_message(self, *args): pass
|
||||
server = ThreadingHTTPServer(('127.0.0.1',0),partial(Handler,directory=str(root)))
|
||||
thread = Thread(target=server.serve_forever,daemon=True)
|
||||
thread.start()
|
||||
script = root/'plan.ps1'
|
||||
script.write_text('param($Security,$Versions,$Path)\n$ErrorActionPreference="Stop"\n. $Security\n. $Versions\n@(Get-ApplicationVersionPlan -Path $Path) | ConvertTo-Json -Compress\n')
|
||||
env = {k:v for k,v in os.environ.items() if not k.startswith(('UPDATE_','DEPLOY_'))}
|
||||
try:
|
||||
for mode, filename, expected in [('remote','release-manifest.json','3.29.0.20261014.2'),('remote','missing.json',None),('local','missing.json','3.30.2.20261017')]:
|
||||
path = root/'.env'
|
||||
path.write_text(f'VERSION=latest\nUPDATE_MANIFEST_SOURCE={mode}\nUPDATE_MANIFEST_URL=http://127.0.0.1:{server.server_port}/{filename}?token=synthetic-secret-must-not-appear\n')
|
||||
before = path.read_bytes()
|
||||
commands = [['python3',str(source/'scripts/resolve-deployment-release.py'),'--env-file',str(path),'--format','json'],
|
||||
['pwsh','-NoProfile','-NonInteractive','-File',str(script),str(source/'scripts/Initialize-SecurityEnv.ps1'),str(source/'scripts/Initialize-ApplicationVersions.ps1'),str(path)]]
|
||||
for command in commands:
|
||||
result = subprocess.run(command,env=env,capture_output=True,text=True,timeout=90)
|
||||
assert 'synthetic-secret-must-not-appear' not in result.stdout+result.stderr
|
||||
assert (result.returncode==0)==bool(expected),(mode,filename,result.stdout,result.stderr)
|
||||
if expected:
|
||||
plan=json.loads(result.stdout)
|
||||
values={entry['Key']:entry['Version'] for entry in plan}
|
||||
assert values['VERSION']==expected
|
||||
assert values['SERVER_VERSION'].startswith(expected+'@sha256:')
|
||||
assert path.read_bytes()==before
|
||||
print(mode+'/'+filename+': both shells selected the authoritative source without stale fallback or credential logging')
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-shell safeguards for real Compose volume names and existing bind paths."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
source = Path(__file__).resolve().parent.parent
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-storage-check-') as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'upload').mkdir()
|
||||
(root / 'compose.yml').write_text('''name: fixture-project
|
||||
services:
|
||||
mongo:
|
||||
container_name: fixture-mongo
|
||||
image: mongo:7
|
||||
volumes: [database:/data/db]
|
||||
easyai-server:
|
||||
container_name: fixture-server
|
||||
image: alpine:3
|
||||
volumes: [./upload:/app/upload]
|
||||
volumes:
|
||||
database:
|
||||
name: existing-database
|
||||
''')
|
||||
configuration = subprocess.check_output(['docker','compose','config','--format','json'], cwd=root, text=True)
|
||||
(root / 'config.json').write_text(configuration)
|
||||
binaries = root / 'bin'
|
||||
binaries.mkdir()
|
||||
docker = binaries / 'docker'
|
||||
docker.write_text('''#!/usr/bin/env python3
|
||||
import json,os,sys
|
||||
from pathlib import Path
|
||||
args=sys.argv[1:]
|
||||
if args[:2]==['compose','config']: print(Path('config.json').read_text());sys.exit(0)
|
||||
if args[:1]==['info']:sys.exit(0)
|
||||
if args[:2]==['container','inspect']:
|
||||
project='another-project' if os.environ['STORAGE_CASE']=='wrong-project' else 'fixture-project'
|
||||
mount={'Type':'volume','Name':'old-database' if os.environ['STORAGE_CASE']=='changed-volume' else 'existing-database','Destination':'/data/db'}
|
||||
if args[-1]=='fixture-server':mount={'Type':'bind','Source':str(Path('upload').resolve()),'Destination':'/app/upload'}
|
||||
print(json.dumps([{'Config':{'Labels':{'com.docker.compose.project':project}},'Mounts':[mount]}]));sys.exit(0)
|
||||
sys.exit(1)
|
||||
''')
|
||||
docker.chmod(0o755)
|
||||
script = root / 'check.ps1'
|
||||
script.write_text('param($Runtime)\n$ErrorActionPreference="Stop"\n. $Runtime\n$selection = @($env:STORAGE_SELECTED -split " " | Where-Object { $_ })\nTest-DeploymentStorage -ComposeCommand @("docker","compose") -Services $selection\n')
|
||||
for case, selected, success in [('same-storage','',True),('wrong-project','',False),('changed-volume','',False),('changed-volume','easyai-server',True),('missing-bind','',False)]:
|
||||
if case == 'missing-bind': (root / 'upload').rmdir()
|
||||
env = {**os.environ, 'PATH':str(binaries)+os.pathsep+os.environ['PATH'], 'STORAGE_CASE':case, 'STORAGE_SELECTED':selected}
|
||||
commands = [(['python3',str(source/'scripts/check-deployment-storage.py'),'--services',*selected.split()], configuration),
|
||||
(['pwsh','-NoProfile','-NonInteractive','-File',str(script),str(source/'scripts/DeploymentRuntime.ps1')], None)]
|
||||
for command, stdin in commands:
|
||||
result = subprocess.run(command, input=stdin, cwd=root, env=env, capture_output=True, text=True, timeout=90)
|
||||
assert (result.returncode == 0) == success, (case, selected, result.stdout, result.stderr)
|
||||
print(case + '/' + (selected or 'all') + ': passed in Python/Bash and PowerShell')
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the actual installer with real Compose parsing and failing Docker transports."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
real_docker = shutil.which('docker')
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-install-failures-') as temporary:
|
||||
base = Path(temporary)
|
||||
for phase in ('storage', 'manifest', 'pull', 'health'):
|
||||
project = base / phase
|
||||
project.mkdir()
|
||||
for name in ('scripts', 'system-update'):
|
||||
shutil.copytree(root / name, project / name, ignore=shutil.ignore_patterns('__pycache__'))
|
||||
for name in ('start.sh', 'docker-compose.yml', 'easyai-proxy.conf.sample'):
|
||||
shutil.copy2(root / name, project / name)
|
||||
for sample in root.glob('.env*.sample'):
|
||||
shutil.copy2(sample, project / sample.name)
|
||||
sample = project / '.env.sample'
|
||||
sample.write_text(sample.read_text().replace('UPDATE_MANIFEST_SOURCE=remote', 'UPDATE_MANIFEST_SOURCE=local'))
|
||||
binaries = project / 'bin'
|
||||
binaries.mkdir()
|
||||
commands = project / 'commands.jsonl'
|
||||
docker = binaries / 'docker'
|
||||
docker.write_text('''#!/usr/bin/env python3
|
||||
import json,os,subprocess,sys
|
||||
args=sys.argv[1:]
|
||||
with open(os.environ['TEST_COMMANDS'],'a') as log: log.write(json.dumps(args)+'\\n')
|
||||
if args[:1]==['--config']: args=args[2:]
|
||||
if args[:2]==['compose','config'] or args[:2]==['compose','version']:
|
||||
sys.exit(subprocess.call([os.environ['REAL_DOCKER'],*args]))
|
||||
if args[:2]==['manifest','inspect']:
|
||||
if os.environ['TEST_PHASE']=='manifest': print('manifest unknown',file=sys.stderr);sys.exit(1)
|
||||
print(json.dumps({'Descriptor':{'platform':{'os':'linux','architecture':'amd64'}}}));sys.exit(0)
|
||||
if args[:2]==['compose','pull']: sys.exit(18 if os.environ['TEST_PHASE']=='pull' else 0)
|
||||
if args[:3]==['compose','ps','-q']: print('test-'+args[-1]);sys.exit(0)
|
||||
if args[:1]==['inspect']:
|
||||
if '--format' in args: print('true|unhealthy');sys.exit(0)
|
||||
sys.exit(1)
|
||||
if args[:2]==['container','inspect']:
|
||||
if os.environ['TEST_PHASE']=='storage' and args[-1]=='easyai-server':
|
||||
print(json.dumps([{'Config':{'Labels':{'com.docker.compose.project':'unrelated-project'}},'Mounts':[]}]))
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
''')
|
||||
docker.chmod(0o755)
|
||||
for name, code in [('sudo', '#!/bin/sh\nexec "$@"\n'), ('curl', '#!/bin/sh\nexit 0\n')]:
|
||||
path = binaries / name
|
||||
path.write_text(code)
|
||||
path.chmod(0o755)
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(('UPDATE_', 'DEPLOY_', 'COMPOSE_'))}
|
||||
env.update({'PATH': str(binaries) + os.pathsep + env['PATH'], 'REAL_DOCKER': real_docker,
|
||||
'TEST_COMMANDS': str(commands), 'TEST_PHASE': phase, 'CI': 'true',
|
||||
'DEPLOY_ACCESS': 'ip', 'DEPLOY_IP': '127.0.0.1', 'DEPLOY_NON_INTERACTIVE': '1',
|
||||
'DEPLOY_HEALTH_TIMEOUT_SECONDS': '1'})
|
||||
result = subprocess.run(['bash', 'start.sh'], cwd=project, env=env, capture_output=True, text=True, errors='replace', timeout=120)
|
||||
assert result.returncode != 0, (phase, result.stdout, result.stderr)
|
||||
assert '应用启动成功' not in result.stdout and '部署完成' not in result.stdout, result.stdout
|
||||
calls = [json.loads(line) for line in commands.read_text().splitlines()]
|
||||
if phase in ('storage', 'manifest'):
|
||||
assert not any(call[:2] in (['compose', 'pull'], ['compose', 'up']) for call in calls)
|
||||
if phase == 'pull':
|
||||
assert ['compose', 'pull'] in calls, (result.stdout, result.stderr, calls)
|
||||
assert not any(call[:2] == ['compose', 'up'] for call in calls)
|
||||
if phase == 'health':
|
||||
assert ['compose', 'up', '-d'] in calls
|
||||
assert '部署未完成' in result.stdout + result.stderr
|
||||
print(phase + ': installer failed without reporting success or continuing to the next stage')
|
||||
@@ -22,22 +22,28 @@ selected = ['easyai-server', 'easyai-web', 'model-conversion-worker']
|
||||
|
||||
|
||||
def run(args, cwd, env=None):
|
||||
return subprocess.run(args, cwd=cwd, env=env, text=True, encoding='utf-8', errors='backslashreplace', capture_output=True, timeout=35)
|
||||
return subprocess.run(args, cwd=cwd, env=env, text=True, encoding='utf-8', errors='backslashreplace', capture_output=True, timeout=90)
|
||||
|
||||
|
||||
def fixture(base):
|
||||
root = base / 'easyai'
|
||||
root.mkdir()
|
||||
for name in ['scripts', 'update.sh', 'update.ps1', '.env.sample', 'docker-compose.yml']:
|
||||
for name in ['scripts', 'system-update', 'update.sh', 'update.ps1', '.env.sample', 'docker-compose.yml']:
|
||||
path = source / name
|
||||
if path.is_dir():
|
||||
shutil.copytree(path, root / name)
|
||||
else:
|
||||
shutil.copy2(path, root / name)
|
||||
# Synthetic immutable product records keep these transport-only cases
|
||||
# independent from the current official release and its actual registry.
|
||||
for version in ['3.23.0.20261003', '3.25.0.20261009.2', '3.25.0.20261009.3']:
|
||||
components = [{'key': key, 'image': 'registry.cn-shanghai.aliyuncs.com/'+image, 'tag': version, 'digest': 'sha256:'+'a'*64}
|
||||
for key,image in [('server-main','comfy-ai/comfy-server'),('web','comfy-ai/one-ai'),('ws-gateway','easyaigc/wsgateway'),('model-conversion-worker','easyaigc/model-conversion-worker')]]
|
||||
(root / 'system-update/releases' / (version+'.json')).write_text(json.dumps({'schemaVersion':1,'channel':'versioned','productVersion':version,'platform':'linux/amd64','components':components}))
|
||||
(root / 'scripts/install-infrastructure-update-task.sh').write_text('#!/bin/sh\nexit 0\n')
|
||||
(root / 'scripts/Install-InfrastructureUpdateTask.ps1').write_text('# Scheduler transport is verified separately.\n')
|
||||
(root / 'data').mkdir()
|
||||
(root / '.env').write_text('VERSION=3.23.0.20261003\nMODEL_CONVERSION_WORKER_VERSION=3.23.0.20261003\nSYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n')
|
||||
(root / '.env').write_text('UPDATE_MANIFEST_SOURCE=local\nVERSION=3.23.0.20261003\nMODEL_CONVERSION_WORKER_VERSION=3.23.0.20261003\nSYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n')
|
||||
(root / '.gitignore').write_text('.env\n*.log\n')
|
||||
commands = base / 'commands.jsonl'
|
||||
commands.touch()
|
||||
@@ -48,7 +54,18 @@ def fixture(base):
|
||||
import json, os, sys
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ['UPDATE_TEST_COMMANDS'], 'a') as output: output.write(json.dumps(args)+'\\n')
|
||||
if args[:2] == ['container', 'inspect']: sys.exit(0 if os.environ.get('UPDATE_TEST_LEGACY_REDIS') == '1' else 1)
|
||||
if args[:1] == ['--config']: args = args[2:]
|
||||
values = dict(line.split('=',1) for line in open('.env').read().splitlines() if '=' in line and not line.startswith('#'))
|
||||
expected = {'comfy-ai/comfy-server':'SERVER_STABLE_DIGEST','comfy-ai/one-ai':'WEB_STABLE_DIGEST','easyaigc/wsgateway':'WS_GATEWAY_STABLE_DIGEST'}
|
||||
if args[:2] == ['manifest', 'inspect']:
|
||||
digest = next((values.get(key,'') for repo,key in expected.items() if '/'+repo+':' in args[-1]), '')
|
||||
if os.environ.get('UPDATE_TEST_STABLE_CHANGED') == 'before': digest='sha256:'+'b'*64
|
||||
print(json.dumps({'Descriptor':{'digest':digest,'platform':{'os':'linux','architecture':'amd64'}}})); sys.exit(0)
|
||||
if args[:2] == ['image','inspect']:
|
||||
ref=args[-1]; digest=next((values.get(key,'') for repo,key in expected.items() if '/'+repo+':' in ref), '')
|
||||
if os.environ.get('UPDATE_TEST_STABLE_CHANGED') == 'after': digest='sha256:'+'b'*64
|
||||
print(json.dumps([{'RepoDigests':[ref.rsplit(':',1)[0]+'@'+digest]}]));sys.exit(0)
|
||||
if args[:2] == ['container', 'inspect']: sys.exit(0 if args[-1] == 'redis' and os.environ.get('UPDATE_TEST_LEGACY_REDIS') == '1' else 1)
|
||||
if args[:1] == ['inspect']:
|
||||
if '--format' in args:
|
||||
if args[-1] == 'redis': print('volume|legacy-redis-data'); sys.exit(0)
|
||||
@@ -60,9 +77,14 @@ if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasya
|
||||
elif args[:4] == ['compose', 'config', '--format', 'json']:
|
||||
names = {'easyai-server':('SERVER_VERSION','comfy-ai/comfy-server'), 'easyai-web':('WEB_VERSION','comfy-ai/one-ai'), 'ws-gateway':('WS_GATEWAY_VERSION','easyaigc/wsgateway'), 'easyai-updater':('UPDATER_VERSION','easyaigc/easyai-updater'), 'model-conversion-worker':('MODEL_CONVERSION_WORKER_VERSION','easyaigc/model-conversion-worker')}
|
||||
values = dict(line.split('=',1) for line in open('.env').read().splitlines() if '=' in line and not line.startswith('#'))
|
||||
key,image = names[args[-1]]
|
||||
actual = 'registry.cn-shanghai.aliyuncs.com/'+image+':'+('latest' if os.environ.get('UPDATE_TEST_IMAGE_OVERRIDE') == '1' else values[key])
|
||||
print(json.dumps({'services': {args[-1]: {'image':actual}, 'dependency': {'image':'redis:7-alpine'}}}))
|
||||
wanted = [args[-1]] if args[-1] in names else list(names)
|
||||
services = {}
|
||||
for name in wanted:
|
||||
key,image = names[name]
|
||||
actual = 'registry.cn-shanghai.aliyuncs.com/'+image+':'+('local-override' if os.environ.get('UPDATE_TEST_IMAGE_OVERRIDE') == '1' else values.get(key, 'latest'))
|
||||
services[name] = {'image':actual}
|
||||
services['dependency'] = {'image':'redis:7-alpine'}
|
||||
print(json.dumps({'services': services,'x-easyai-release':{name:values.get(key,'') for name,key in [('easyai-server','SERVER_STABLE_DIGEST'),('easyai-web','WEB_STABLE_DIGEST'),('ws-gateway','WS_GATEWAY_STABLE_DIGEST')]}}))
|
||||
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
|
||||
sys.exit(0)
|
||||
''')
|
||||
@@ -71,7 +93,7 @@ sys.exit(0)
|
||||
p = binaries / name
|
||||
p.write_text(code)
|
||||
p.chmod(0o755)
|
||||
env = {**os.environ, 'PATH': str(binaries) + os.pathsep + os.environ['PATH'], 'CI': 'true', 'UPDATE_NO_WAIT': '1', 'UPDATE_MODE': 'image', 'UPDATE_SERVICES': ' '.join(selected), 'UPDATE_TEST_COMMANDS': str(commands), 'UPDATE_TEST_LEGACY_REDIS': '1'}
|
||||
env = {**{k:v for k,v in os.environ.items() if not k.startswith(('UPDATE_', 'DEPLOY_'))}, 'PATH': str(binaries) + os.pathsep + os.environ['PATH'], 'CI': 'true', 'UPDATE_NO_WAIT': '1', 'UPDATE_MODE': 'image', 'UPDATE_SERVICES': ' '.join(selected), 'UPDATE_TEST_COMMANDS': str(commands), 'UPDATE_TEST_LEGACY_REDIS': '1'}
|
||||
return root, commands, env
|
||||
|
||||
|
||||
@@ -105,6 +127,14 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
|
||||
result = run(command, root, env)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert_selected(root, log, result.stdout)
|
||||
for phase in ('before','after'):
|
||||
log.write_text('')
|
||||
result = run(command, root, {**env, 'UPDATE_TEST_STABLE_CHANGED':phase})
|
||||
assert result.returncode != 0, result.stdout + result.stderr
|
||||
assert not any(c[:2] == ['compose','up'] for c in calls(log))
|
||||
if phase == 'before': assert not any(c[:2] == ['compose','pull'] for c in calls(log))
|
||||
else: assert ['compose','pull',*selected] in calls(log)
|
||||
log.write_text('')
|
||||
original = (root / '.env').read_bytes()
|
||||
result = run(command, root, env)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -114,9 +144,10 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
|
||||
(root / '.env').write_text((root / '.env').read_text().replace('VERSION=3.23.0.20261003', 'VERSION=latest'))
|
||||
rejected_env = (root / '.env').read_bytes()
|
||||
result = run(command, root, env)
|
||||
assert result.returncode != 0 and '固定版本' in result.stdout + result.stderr
|
||||
assert rejected_env == (root / '.env').read_bytes()
|
||||
assert not any(c[:2] in [['compose','pull'],['compose','up']] for c in calls(log))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert_selected(root, log, result.stdout)
|
||||
migrated = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
|
||||
assert migrated['SERVER_VERSION'] == 'latest' and migrated['SERVER_STABLE_DIGEST'].startswith('sha256:')
|
||||
(root / '.env').write_bytes(original)
|
||||
log.write_text('')
|
||||
result = run(command, root, {**env, 'UPDATE_TEST_IMAGE_OVERRIDE':'1'})
|
||||
@@ -131,7 +162,8 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
|
||||
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': '3.23.0.20261003'})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
|
||||
assert values['VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION'] == '3.23.0.20261003'
|
||||
assert values['VERSION'] == '3.23.0.20261003'
|
||||
assert values['MODEL_CONVERSION_WORKER_VERSION'].startswith('3.23.0.20261003@sha256:')
|
||||
log.write_text('')
|
||||
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server missing-service'})
|
||||
assert result.returncode != 0
|
||||
@@ -146,7 +178,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
|
||||
result = run(command, root, {**env, 'UPDATE_SERVICES':'easyai-server', 'UPDATE_RELEASE_VERSION':'3.25.0.20261009.2'})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
after = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
|
||||
assert after['SERVER_VERSION'] == '3.25.0.20261009.2'
|
||||
assert after['SERVER_VERSION'].startswith('3.25.0.20261009.2@sha256:')
|
||||
assert after['VERSION'] == before['VERSION'] and after['WEB_VERSION'] == before['WEB_VERSION']
|
||||
assert after['MODEL_CONVERSION_WORKER_VERSION'] == before['MODEL_CONVERSION_WORKER_VERSION']
|
||||
assert ['compose','up','-d','--no-deps','easyai-server'] in calls(log)
|
||||
@@ -155,7 +187,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
core_values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
|
||||
assert core_values['VERSION'] == '3.25.0.20261009.3'
|
||||
assert core_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
|
||||
assert core_values['MODEL_CONVERSION_WORKER_VERSION'] == after['MODEL_CONVERSION_WORKER_VERSION']
|
||||
assert ['compose', 'pull', 'easyai-server', 'easyai-web'] in calls(log)
|
||||
assert ['compose', 'up', '-d', '--no-deps', 'easyai-server', 'easyai-web'] in calls(log)
|
||||
assert not any('model-conversion-worker' in call for call in calls(log) if call[:2] in [['compose', 'pull'], ['compose', 'up']])
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Adopt a real non-Git directory and resolve the resulting Compose with Docker CLI."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
source = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run(arguments, root, check=True):
|
||||
return subprocess.run(arguments, cwd=root, capture_output=True, text=True, check=check, timeout=40)
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-package-adoption-') as temporary:
|
||||
base = Path(temporary)
|
||||
template = base / 'tool'
|
||||
template.mkdir()
|
||||
for name in ['docker-compose.yml', '.gitignore']:
|
||||
shutil.copy2(source / name, template / name)
|
||||
for sample in source.glob('.env*.sample'):
|
||||
shutil.copy2(sample, template / sample.name)
|
||||
for command in [['git','init','-q'], ['git','config','user.name','Deployment fixture'], ['git','config','user.email','fixture@example.invalid'], ['git','add','.'], ['git','commit','-qm','test: 部署模板'], ['git','remote','add','origin','https://example.invalid/deployment.git']]:
|
||||
run(command, template)
|
||||
for kind in ('compatible', 'conflicting-port'):
|
||||
project = base / kind
|
||||
project.mkdir()
|
||||
for sample in source.glob('.env*.sample'):
|
||||
shutil.copy2(sample, project / sample.name.removesuffix('.sample'))
|
||||
private = (project / '.env').read_text() + '\nEXISTING_PRIVATE_SETTING=must-stay-private\n'
|
||||
(project / '.env').write_text(private)
|
||||
original = yaml.safe_load((source / 'docker-compose.yml').read_text())
|
||||
original['name'] = 'original-customer-project'
|
||||
original['services']['easyai-server']['environment'].append('EXISTING_OPTION=keep')
|
||||
if kind == 'conflicting-port':
|
||||
original['services']['easyai-web']['ports'] = ['127.0.0.1:39111:3010']
|
||||
(project / 'docker-compose.yml').write_text(yaml.safe_dump(original, sort_keys=False))
|
||||
marker = project / 'data/upload/existing-file.txt'
|
||||
marker.parent.mkdir(parents=True)
|
||||
marker.write_text('existing upload must remain')
|
||||
original_compose = (project / 'docker-compose.yml').read_bytes()
|
||||
result = run(['python3', str(source / 'scripts/adopt-deployment-directory.py'), str(project), str(template)], project, check=False)
|
||||
assert 'must-stay-private' not in result.stdout + result.stderr
|
||||
assert marker.read_text() == 'existing upload must remain'
|
||||
if kind == 'compatible':
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert (project / '.env').read_text().startswith(private)
|
||||
assert (project / '.git').is_dir()
|
||||
resolved = json.loads(run(['docker','compose','config','--format','json'], project).stdout)
|
||||
assert resolved['name'] == 'original-customer-project'
|
||||
assert resolved['services']['easyai-server']['environment']['EXISTING_OPTION'] == 'keep'
|
||||
assert len(resolved['services']['easyai-web']['ports']) == 1
|
||||
backups = list((project / 'data/deployment-backups').glob('package-*'))
|
||||
assert len(backups) == 1
|
||||
assert (backups[0] / 'files/.env').read_text() == private
|
||||
assert (backups[0] / 'files/docker-compose.yml').read_bytes() == original_compose
|
||||
assert os.stat(backups[0]).st_mode & 0o777 == 0o700
|
||||
assert not run(['git','status','--porcelain'], project).stdout
|
||||
else:
|
||||
assert result.returncode != 0 and '端口' in result.stdout + result.stderr
|
||||
assert not (project / '.git').exists()
|
||||
assert (project / '.env').read_text() == private
|
||||
assert (project / 'docker-compose.yml').read_bytes() == original_compose
|
||||
print(kind + ': data/config preserved and unsafe merge rejected before mutation')
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the installer's actual proxy verification with deterministic HTTP transports."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
source = Path(__file__).resolve().parent.parent
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-proxy-entry-') as temporary:
|
||||
root = Path(temporary)
|
||||
script = (source / 'start.sh').read_text().rsplit('main "$@"', 1)[0]
|
||||
(root / 'functions.sh').write_text(script)
|
||||
curl = root / 'curl'
|
||||
curl.write_text('''#!/usr/bin/env python3
|
||||
import json,os,sys
|
||||
with open(os.environ['CURL_CALLS'],'a') as out:out.write(json.dumps(sys.argv[1:])+'\\n')
|
||||
if os.environ['HTTP_CASE']=='home-error':sys.exit(22)
|
||||
if any('/api/health/update-ready' in arg for arg in sys.argv):
|
||||
print(json.dumps({'status':'ok','service':'wrong-upstream' if os.environ['HTTP_CASE']=='wrong-upstream' else 'easyai-server-main'}))
|
||||
''')
|
||||
curl.chmod(0o755)
|
||||
for name, mode, succeeds in [('home-error', 'ip', False), ('wrong-upstream', 'domain', False), ('healthy', 'ip', True), ('healthy', 'https', True)]:
|
||||
log = root / 'curl.jsonl'
|
||||
log.write_text('')
|
||||
env = {**os.environ, 'PATH': str(root) + os.pathsep + os.environ['PATH'], 'HTTP_CASE': name, 'CURL_CALLS': str(log)}
|
||||
run = '. ./functions.sh; DEPLOY_MODE="$1"; DEPLOY_IP=192.0.2.10; DEPLOY_DOMAIN=deployment.example; DEPLOY_HTTPS=false; if [ "$1" = https ]; then DEPLOY_MODE=domain; DEPLOY_HTTPS=true; fi; verify_proxy_entry'
|
||||
result = subprocess.run(['bash', '-c', run, 'fixture', mode], cwd=root, env=env, capture_output=True, text=True, timeout=15)
|
||||
assert (result.returncode == 0) == succeeds, (name, mode, result.stdout, result.stderr)
|
||||
calls = log.read_text()
|
||||
if mode == 'ip':
|
||||
assert 'Host: 192.0.2.10' in calls
|
||||
if mode == 'https':
|
||||
assert '--resolve' in calls and 'deployment.example:443:127.0.0.1' in calls
|
||||
assert '--insecure' not in calls and '"-k"' not in calls
|
||||
print(name + '/' + mode + ': passed')
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the resolved installation images, without pulling layers or using ACR credentials."""
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
STABLE_KEYS = {'easyai-server': 'SERVER_STABLE_DIGEST', 'easyai-web': 'WEB_STABLE_DIGEST', 'ws-gateway': 'WS_GATEWAY_STABLE_DIGEST'}
|
||||
|
||||
|
||||
def verify(image, docker, config, expected=None):
|
||||
# Official installation images must be available to a new anonymous user.
|
||||
# Never reuse a maintainer's credential helper as evidence of public access.
|
||||
official = image.startswith(('registry.cn-shanghai.aliyuncs.com/comfy-ai/',
|
||||
'registry.cn-shanghai.aliyuncs.com/easyaigc/'))
|
||||
command = docker + (['--config', config] if official else [])
|
||||
try:
|
||||
result = subprocess.run(command + ['manifest', 'inspect', '--verbose', image],
|
||||
capture_output=True, text=True, timeout=90)
|
||||
if result.returncode:
|
||||
message = result.stderr.lower()
|
||||
reason = ('标签或摘要不存在' if any(s in message for s in ('not found', 'manifest unknown', 'no such manifest'))
|
||||
else '未开放匿名拉取' if any(s in message for s in ('unauthorized', 'denied', 'authentication required'))
|
||||
else '仓库不可访问,请检查网络或仓库状态')
|
||||
raise ValueError(reason)
|
||||
manifests = json.loads(result.stdout)
|
||||
manifests = manifests if isinstance(manifests, list) else [manifests]
|
||||
platforms = [m.get('Descriptor', {}).get('platform', {}) for m in manifests]
|
||||
if not any(p.get('os') == 'linux' and p.get('architecture') == 'amd64' for p in platforms):
|
||||
raise ValueError('没有 linux/amd64 产物')
|
||||
if expected and not any(m.get('Descriptor', {}).get('digest') == expected
|
||||
and m.get('Descriptor', {}).get('platform', {}).get('architecture') == 'amd64'
|
||||
for m in manifests):
|
||||
raise ValueError('官方稳定别名与发布清单尚未同步;保留当前服务')
|
||||
return image, None
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return image, '镜像检查超时或 Docker 不可用'
|
||||
except (ValueError, KeyError, TypeError) as error:
|
||||
return image, str(error) if isinstance(error, ValueError) and not isinstance(error, json.JSONDecodeError) else '仓库返回了无效的镜像信息'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--sudo', action='store_true')
|
||||
parser.add_argument('--services', nargs='*', default=[])
|
||||
args = parser.parse_args()
|
||||
configuration = json.load(sys.stdin)
|
||||
images = sorted({value['image'] for service, value in configuration['services'].items()
|
||||
if not args.services or service in args.services})
|
||||
expected = {value['image']: configuration.get('x-easyai-release', {}).get(service)
|
||||
for service, value in configuration['services'].items() if service in STABLE_KEYS}
|
||||
if not images:
|
||||
parser.error('没有可检查的部署镜像')
|
||||
docker = ['sudo', 'docker'] if args.sudo else ['docker']
|
||||
with tempfile.TemporaryDirectory(prefix='easyai-anonymous-registry-') as config:
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
results = list(executor.map(lambda image: verify(image, docker, config, expected.get(image)), images))
|
||||
failures = [(image, error) for image, error in results if error]
|
||||
for image, error in failures:
|
||||
print(f'❌ {image}: {error}', file=sys.stderr)
|
||||
if failures:
|
||||
print('镜像检查失败,尚未拉取或重建服务;请核对发布记录和组件实际版本。', file=sys.stderr)
|
||||
return 1
|
||||
print(f'✅ 已核验 {len(images)} 个部署镜像的可访问性和 linux/amd64 平台')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check legacy stable aliases AFTER pull, before any container is recreated."""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--sudo', action='store_true')
|
||||
parser.add_argument('--services', nargs='*', default=[])
|
||||
args = parser.parse_args()
|
||||
configuration = json.load(sys.stdin)
|
||||
docker = ['sudo','docker'] if args.sudo else ['docker']
|
||||
keys = {'easyai-server':'SERVER_STABLE_DIGEST','easyai-web':'WEB_STABLE_DIGEST','ws-gateway':'WS_GATEWAY_STABLE_DIGEST'}
|
||||
for service, key in keys.items():
|
||||
if args.services and service not in args.services:
|
||||
continue
|
||||
settings = configuration['services'].get(service)
|
||||
if not settings:
|
||||
continue
|
||||
digest = configuration.get('x-easyai-release', {}).get(service)
|
||||
if not digest:
|
||||
continue
|
||||
reference = settings['image']
|
||||
repository = reference.rsplit(':', 1)[0]
|
||||
result = subprocess.run(docker + ['image','inspect',reference],capture_output=True,text=True)
|
||||
try:
|
||||
images = json.loads(result.stdout)
|
||||
matches = result.returncode == 0 and repository+'@'+digest in images[0].get('RepoDigests', [])
|
||||
except (ValueError, IndexError, KeyError):
|
||||
matches = False
|
||||
if not matches:
|
||||
print(f'❌ {service} 拉取后的稳定镜像与目标清单不一致;未重建服务,请重新检查正式发布',file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print('✅ 拉取后的稳定镜像与目标发布一致')
|
||||
Reference in New Issue
Block a user