fix(release): 环境固定版本并拆分人工稳定版提升

This commit is contained in:
2026-10-14 08:35:04 +08:00
parent 5fa19eff08
commit 8c1eadc5ea
13 changed files with 482 additions and 46 deletions
@@ -0,0 +1,62 @@
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 { $_ })
$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')
)
$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='' }
}
}
$plan += [PSCustomObject]@{ Service=''; Key='VERSION'; Version=$env:UPDATE_RELEASE_VERSION; Image='' }
}
return $plan
}
function Set-ApplicationVersions {
param([string]$Path, [object[]]$Plan)
$content = Get-Content $Path -Raw -Encoding UTF8
foreach ($item in $Plan) { $content = Set-EnvValue $content $item.Key $item.Version }
[System.IO.File]::WriteAllText($Path, $content, [System.Text.UTF8Encoding]::new($false))
foreach ($item in $Plan) { Write-Host "📌 已持久化应用版本: $($item.Key)=$($item.Version)" }
}
function Test-ApplicationVersionImages {
param([object[]]$Plan, [string[]]$ComposeCommand)
foreach ($item in $Plan) {
if (-not $item.Service) { continue }
$args = @($ComposeCommand | Select-Object -Skip 1) + @('config','--images',$item.Service)
$executable = $ComposeCommand[0]
$actual = & $executable @args
if ($LASTEXITCODE -ne 0 -or "$actual".Trim() -ne "$($item.Image):$($item.Version)") {
throw "$($item.Service) 的有效镜像未使用环境中的固定版本;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务"
}
}
}
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
# 仅规划/持久化已选应用的固定版本;由 update.sh 调用,不编辑 Compose。
APPLICATION_VERSION_MAP='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'
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")
fi
}
apply_application_versions() {
local file="$1" entry temporary
temporary="$(mktemp "${file}.versions.XXXXXX")"
cp "$file" "$temporary"
chmod 600 "$temporary"
for entry in "${APPLICATION_VERSION_WRITES[@]}"; do
write_env_value "$temporary" "${entry%%=*}" "${entry#*=}"
done
mv "$temporary" "$file"
for entry in "${APPLICATION_VERSION_WRITES[@]}"; do echo "📌 已持久化应用版本: $entry"; done
}
verify_application_version_images() {
local file="$1" compose="$2" service key option image version actual
while read -r service key option image; do
application_selected "$service" || continue
version="$(read_env_value "$file" "$key")"
actual="$(sudo $compose config --images "$service")" || return 1
if [ "$actual" != "$image:$version" ]; then
echo "❌ $service 的有效镜像未使用 .env 中的固定版本 $key=$version;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务" >&2
return 1
fi
done <<< "$APPLICATION_VERSION_MAP"
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const VERSION = /^\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?$/;
const TARGETS = {
'server-main': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server','latest',''],
web: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest',''],
'web-no-ssr': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-no-ssr','-no-ssr'],
'ws-gateway': ['registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway','latest',''],
'model-conversion-worker': ['registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker','latest',''],
updater: ['registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater','latest',''],
'service-governance': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance','latest',''],
'memory-service': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory','latest',''],
};
const CORE = ['server-main','web','ws-gateway'];
const docker = args => execFileSync('docker', args, {encoding:'utf8', stdio:['ignore','pipe','pipe']});
const inspectArgs = ref => ['buildx','imagetools','inspect',ref,'--format','{{json .Manifest}}'];
export function promotionPlan(manifest, version, confirmedVersion) {
if (!VERSION.test(version ?? '') || version !== confirmedVersion) throw new Error('必须人工指定完整 --version 并用相同 --confirm-version 确认,不能自动选择最新版');
if (manifest.schemaVersion !== 1 || manifest.channel !== 'versioned' || manifest.productVersion !== version || manifest.platform !== 'linux/amd64') throw new Error('候选版本记录与指定版本/平台不一致');
if (typeof manifest.releaseId !== 'string' || !manifest.releaseId.trim() || manifest.releaseId.length > 200 || !Array.isArray(manifest.components)) throw new Error('候选版本记录缺少有效 releaseId/components');
if (!Number.isFinite(Date.parse(manifest.publishedAt)) || ['managedUpdateAllowed','scheduledUpdateAllowed','rollbackSafe'].some(key=>typeof manifest[key]!=='boolean')) throw new Error('候选版本记录缺少有效发布时间或更新兼容性设置');
const seen = new Set();
const images = manifest.components.map(component => {
const target = TARGETS[component.key];
if (!target || seen.has(component.key) || component.image !== target[0] || !/^sha256:[a-f0-9]{64}$/.test(component.digest ?? '')) throw new Error(`无效或重复组件: ${component.key}`);
seen.add(component.key);
const tag = component.tag ?? `${version}${target[2]}`;
const base = target[2] && tag.endsWith(target[2]) ? tag.slice(0,-target[2].length) : tag;
if (!VERSION.test(base) || (target[2] && !tag.endsWith(target[2]))) throw new Error(`组件 ${component.key} 必须引用固定版本标签`);
return {key:component.key, image:component.image, digest:component.digest, source:`${component.image}:${tag}`, stable:`${component.image}:${target[1]}`};
});
for (const key of [...CORE,'web-no-ssr']) if (!seen.has(key)) throw new Error(`候选记录缺少必要组件: ${key}`);
return images;
}
export function promoteStableRelease({manifest, version, confirmedVersion, outputPath, dryRun=false, run=docker}) {
const plan = promotionPlan(manifest, version, confirmedVersion);
// 所有固定标签先核验完成,再发生任何稳定标签写入。
for (const image of plan) {
const descriptor = JSON.parse(run(inspectArgs(image.source)));
let platforms = descriptor.manifests?.map(item=>item.platform) ?? [descriptor.platform];
if (!platforms.some(Boolean)) {
const verbose = JSON.parse(run(['manifest','inspect','--verbose',image.source]));
platforms = (Array.isArray(verbose) ? verbose : [verbose]).map(item=>item.Descriptor?.platform);
}
if (descriptor.digest !== image.digest || !platforms.some(platform=>platform?.os==='linux' && platform?.architecture==='amd64')) throw new Error(`固定产物核验失败: ${image.key};稳定标签未修改`);
}
if (dryRun) return {status:'DRY_RUN',version,images:plan};
if (!outputPath) throw new Error('缺少稳定清单输出路径');
const previous = new Map();
for (const image of plan) {
try { previous.set(image.stable, JSON.parse(run(inspectArgs(image.stable))).digest); }
catch (error) {
if (!/manifest unknown|not found|no such manifest/i.test(String(error.stderr ?? error.message))) throw error;
previous.set(image.stable,null);
}
}
const changed = [];
try {
for (const image of plan) {
// 先登记尝试;写入成功但回执丢失时也必须尝试恢复原稳定指针。
changed.push(image);
run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${image.digest}`]);
const actual = JSON.parse(run(inspectArgs(image.stable)));
if (actual.digest !== image.digest) throw new Error(`稳定标签读回不一致: ${image.key}`);
}
const promoted = {...manifest, channel:'stable', publishedAt:new Date().toISOString(),
promotedFromPublishedAt:manifest.publishedAt, components:manifest.components.filter(item=>CORE.includes(item.key))};
const temporary = `${outputPath}.tmp`;
writeFileSync(temporary, JSON.stringify(promoted,null,2)+'\n');
renameSync(temporary,outputPath);
return {status:'PROMOTED',version,images:plan,manifest:outputPath};
} catch (error) {
const recovery = [];
for (const image of changed.reverse()) {
const oldDigest = previous.get(image.stable);
if (!oldDigest) { recovery.push(`${image.key}: 原稳定标签不存在,保留本次标签并记录未完成`); continue; }
try {
run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${oldDigest}`]);
if (JSON.parse(run(inspectArgs(image.stable))).digest !== oldDigest) throw new Error('恢复读回不一致');
recovery.push(`${image.key}: 已恢复原稳定标签`);
} catch { recovery.push(`${image.key}: 恢复失败,需按原 digest 继续恢复`); }
}
throw new Error(`稳定提升未完成,未提交新的稳定清单:${error.message};${recovery.join(';')}`);
}
}
function main() {
const options = {};
const args = process.argv.slice(2);
for (let index=0; index<args.length; index++) {
const key=args[index];
if (key==='--dry-run') options.dryRun=true;
else if (['--version','--confirm-version','--manifest','--output'].includes(key)) {
const value=args[++index];
if (!value || value.startsWith('--')) throw new Error(`${key} 缺少参数`);
options[key.slice(2)]=value;
} else throw new Error(`未知选项: ${key}`);
}
// 在读取候选文件、调用 Docker 前先拒绝未确认或浮动版本。
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'));
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));
}
if (process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href) {
try { main(); } catch (error) { console.error(error.message); process.exitCode=1; }
}
+98
View File
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promotionPlan, promoteStableRelease } from './promote-stable-release.mjs';
const version='3.29.0.20261014.2';
const manifest={schemaVersion:1,channel:'versioned',productVersion:version,platform:'linux/amd64',releaseId:'release-id',managedUpdateAllowed:true,scheduledUpdateAllowed:true,rollbackSafe:false,publishedAt:'2026-10-14T00:00:00Z',
components:[
['server-main','comfy-ai/comfy-server',''],['web','comfy-ai/one-ai',''],
['web-no-ssr','comfy-ai/one-ai','-no-ssr'],['ws-gateway','easyaigc/wsgateway',''],
['model-conversion-worker','easyaigc/model-conversion-worker',''],
].map(([key,image,suffix],index)=>({key,image:'registry.cn-shanghai.aliyuncs.com/'+image,tag:version+suffix,digest:'sha256:'+String(index+1).repeat(64),buildId:'source-sha'}))};
function fixture() {
const plan=promotionPlan(manifest,version,version);
const tags=new Map(plan.flatMap(image=>[[image.source,image.digest],[image.stable,'sha256:'+'9'.repeat(64)]]));
const calls=[];
const run=args=>{
calls.push(args);
if (args[0]==='manifest') return JSON.stringify({Descriptor:{platform:{os:'linux',architecture:'amd64'}}});
if (args[2]==='inspect') {
const ref=args[3];
// 真实单平台 imagetools 输出没有 platform,需读取 verbose manifest。
return JSON.stringify({digest:tags.get(ref),...(ref.includes('comfy-server') ? {} : {manifests:[{platform:{os:'linux',architecture:'amd64'}}]})});
}
assert.equal(args[2],'create');
assert.ok(args.includes('--prefer-index=false'));
const ref=args[args.indexOf('--tag')+1];
const digest=args.at(-1).split('@')[1];
tags.set(ref,digest);
return '';
};
return {plan,tags,calls,run};
}
test('未明确确认同一个版本及浮动版本都不调用Docker',()=>{
let called=false;
for (const [candidate,confirm] of [[version,undefined],[version,'3.29.0.20261014.3'],['latest','latest']]) {
assert.throws(()=>promoteStableRelease({manifest,version:candidate,confirmedVersion:confirm,run:()=>{called=true;}}),/人工指定/);
}
assert.equal(called,false);
});
test('候选必须包括SSR/noSSR,并拒绝重复、错误镜像和浮动源标签',()=>{
for (const components of [manifest.components.filter(item=>item.key!=='web-no-ssr'),
[...manifest.components,manifest.components[0]],
manifest.components.map((item,index)=>index?item:{...item,image:'wrong/image'}),
manifest.components.map((item,index)=>index?item:{...item,tag:'latest'})]) {
assert.throws(()=>promotionPlan({...manifest,components},version,version));
}
});
test('全部固定产物先核验;任一digest不匹配时稳定标签和清单保持',()=>{
const f=fixture();
f.tags.set(f.plan.at(-1).source,'sha256:'+'8'.repeat(64));
assert.throws(()=>promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:'unused',run:f.run}),/核验失败/);
assert.ok(f.calls.every(args=>args[2]!=='create'));
assert.ok(f.plan.every(image=>f.tags.get(image.stable)==='sha256:'+'9'.repeat(64)));
});
test('dry-run核验固定版本且不写任何稳定标签或文件',()=>{
const f=fixture();
assert.equal(promoteStableRelease({manifest,version,confirmedVersion:version,dryRun:true,run:f.run}).status,'DRY_RUN');
assert.ok(f.calls.every(args=>args[2]!=='create'));
});
test('人工提升只按已核验digest补稳定标签,两种前端分别保留;不构建业务或改源版本',()=>{
const dir=mkdtempSync(join(tmpdir(),'easyai-promote-'));
try {
const path=join(dir,'stable.json');
const f=fixture();
const result=promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:path,run:f.run});
assert.equal(result.status,'PROMOTED');
assert.ok(f.plan.every(image=>f.tags.get(image.stable)===image.digest));
assert.ok(f.plan.every(image=>f.tags.get(image.source)===image.digest));
assert.equal(JSON.parse(readFileSync(path)).channel,'stable');
assert.deepEqual(JSON.parse(readFileSync(path)).components.map(item=>item.key),['server-main','web','ws-gateway']);
assert.ok(f.calls.every(args=>args[0]!=='build' && args[0]!=='push'));
} finally { rmSync(dir,{recursive:true,force:true}); }
});
test('稳定别名写入后回执失败时恢复原指针,稳定清单不提前写入',()=>{
const dir=mkdtempSync(join(tmpdir(),'easyai-promote-fail-'));
try {
const path=join(dir,'stable.json'); writeFileSync(path,'old stable manifest');
const f=fixture(); let once=true;
const run=args=>{
const output=f.run(args);
if (args[2]==='create' && args.includes(f.plan[2].stable) && once) { once=false; throw new Error('write succeeded but response lost'); }
return output;
};
assert.throws(()=>promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:path,run}),/未完成.*已恢复/);
assert.ok(f.plan.every(image=>f.tags.get(image.stable)==='sha256:'+'9'.repeat(64)));
assert.equal(readFileSync(path,'utf8'),'old stable manifest');
} finally { rmSync(dir,{recursive:true,force:true}); }
});
+32 -5
View File
@@ -37,7 +37,7 @@ def fixture(base):
(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('SYSTEM_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('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 / '.gitignore').write_text('.env\n*.log\n')
commands = base / 'commands.jsonl'
commands.touch()
@@ -57,6 +57,11 @@ if args[:1] == ['inspect']:
sys.exit(0)
sys.exit(1)
if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasyai-web\\nws-gateway\\neasyai-updater\\nmodel-conversion-worker\\nmongo\\nredis')
elif args[:3] == ['compose', 'config', '--images']:
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]]
print('registry.cn-shanghai.aliyuncs.com/'+image+':'+('latest' if os.environ.get('UPDATE_TEST_IMAGE_OVERRIDE') == '1' else values[key]))
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
sys.exit(0)
''')
@@ -105,6 +110,19 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert original == (root / '.env').read_bytes(), 'repeat changes deployment settings'
log.write_text('')
original = (root / '.env').read_bytes()
(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))
(root / '.env').write_bytes(original)
log.write_text('')
result = run(command, root, {**env, 'UPDATE_TEST_IMAGE_OVERRIDE':'1'})
assert result.returncode != 0 and '有效镜像' in result.stdout + result.stderr
assert not any(c[:2] in [['compose','pull'],['compose','up']] for c in calls(log))
log.write_text('')
original = (root / '.env').read_bytes()
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': 'latest;unsafe'})
assert result.returncode != 0
assert original == (root / '.env').read_bytes()
@@ -121,7 +139,16 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
result = run(command, root, {**env, 'UPDATE_TEST_UNHEALTHY': '1', 'UPDATE_HEALTH_TIMEOUT_SECONDS': '1'})
assert result.returncode != 0
assert '应用更新成功' not in result.stdout
if name == 'bash':
if name in ('bash','powershell'):
log.write_text('')
before = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
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['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)
log.write_text('')
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server easyai-web', 'UPDATE_RELEASE_VERSION': '3.25.0.20261009.3'})
assert result.returncode == 0, result.stdout + result.stderr
@@ -132,7 +159,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
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']])
values = core_values
print('bash: core-only release preserves non-target Worker pin passed')
print(f'{name}: core-only release preserves non-target Worker pin passed')
companion_env = {**env, 'UPDATE_SERVICES': 'ws-gateway easyai-updater',
'UPDATE_GATEWAY_VERSION': '3.25.0.20261009',
'UPDATE_UPDATER_VERSION': '3.25.0.20261009'}
@@ -154,7 +181,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert new_values['VERSION'] == values['VERSION']
assert new_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
assert new_values['CONFIG_JWT_SECRET'] == values['CONFIG_JWT_SECRET']
print('bash: independent companion pins preserve non-target versions and reject invalid scope passed')
print(f'{name}: independent companion pins preserve non-target versions and reject invalid scope passed')
print(f'{name}: selected services, stable secret, invalid service and unhealthy Worker passed')
case = base / 'self-update'
@@ -178,7 +205,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
for args in [['git', 'add', 'update.sh'], ['git', 'commit', '-qm', 'test: 验证更新脚本自更新'], ['git', 'push', '-q', 'origin', 'HEAD']]:
assert run(args, seed).returncode == 0
compose_path = root / 'docker-compose.yml'
compose_path.write_text(compose_path.read_text().replace('comfy-ai/one-ai:${VERSION}', 'comfy-ai/one-ai@sha256:' + 'a' * 64))
compose_path.write_text(compose_path.read_text().replace('comfy-ai/one-ai:${WEB_VERSION:-${VERSION}}', 'comfy-ai/one-ai@sha256:' + 'a' * 64))
result = run(['bash', 'update.sh'], root, {**env, 'UPDATE_MODE': 'full', 'UPDATE_ADOPT_LEGACY_CONFIG': '1', 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'})
assert result.returncode == 0, result.stdout + result.stderr
assert '切换到新版更新脚本' in result.stdout
+2 -2
View File
@@ -27,7 +27,7 @@ grep -qx 'WATCHTOWER_CLEANUP=' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_HTTP_API_PERIODIC_POLLS=false' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_UPDATE_TARGET=easyai-server easyai-wsgateway easyai-web' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_WORKER_VERSION=latest' "$LEGACY_ENV_FILE"
! grep -q '^MODEL_CONVERSION_WORKER_VERSION=' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_HUNYUAN_ENABLED=false' "$LEGACY_ENV_FILE"
cp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env"
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$LEGACY_ENV_FILE" "$PROJECT_ROOT" >/dev/null
@@ -57,7 +57,7 @@ read_value() {
[ "$(read_value UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ]
[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ]
[ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ]
[ "$(read_value MODEL_CONVERSION_WORKER_VERSION)" = "latest" ]
[ -z "$(read_value MODEL_CONVERSION_WORKER_VERSION)" ]
[ "$(read_value MODEL_CONVERSION_HUNYUAN_ENABLED)" = "false" ]
cp "$ENV_FILE" "$TMP_DIR/first-run.env"
@@ -1,3 +1,3 @@
# 独立模型转换;不改变现有在线更新模式与付费选择。
MODEL_CONVERSION_WORKER_VERSION=latest
MODEL_CONVERSION_HUNYUAN_ENABLED=false