fix(release): 环境固定版本并拆分人工稳定版提升
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
This commit is contained in:
@@ -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) 的有效镜像未使用环境中的固定版本;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}); }
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user