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

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