Files
easyai/scripts/test-model-conversion-update.py
T

277 lines
18 KiB
Python

#!/usr/bin/env python3
"""Run the real Bash/PowerShell update entry points with isolated command transport.
Docker transport is mocked here; actual container/queue conversion is a separate acceptance.
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument('--bash-only', action='store_true', help='Validate Linux updates only; the default still requires Bash and PowerShell.')
options = parser.parse_args()
source = options.source.resolve()
started = time.monotonic()
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=90)
def fixture(base):
root = base / 'easyai'
root.mkdir()
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('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()
binaries = base / 'bin'
binaries.mkdir()
docker = binaries / 'docker'
docker.write_text('''#!/usr/bin/env python3
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[: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)
unhealthy = os.environ.get('UPDATE_TEST_UNHEALTHY') == '1' and args[-1].endswith('model-conversion-worker')
print('true|unhealthy' if unhealthy else 'true|healthy')
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[: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('#'))
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)
''')
docker.chmod(0o755)
for name, code in [('sudo', '#!/bin/sh\nexec "$@"\n'), ('systemctl', '#!/bin/sh\nexit 0\n')]:
p = binaries / name
p.write_text(code)
p.chmod(0o755)
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
def calls(path):
return [json.loads(line) for line in path.read_text().splitlines()]
def assert_selected(root, command_file, output):
recorded = calls(command_file)
assert ['compose', 'pull', *selected] in recorded, recorded
assert ['compose', 'up', '-d', '--no-deps', *selected] in recorded, recorded
assert ['compose', 'pull'] not in recorded
assert ['compose', 'up', '-d'] not in recorded
assert ['compose', 'ps', '-q', 'model-conversion-worker'] in recorded
values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
token = values['MODEL_CONVERSION_WORKER_TOKEN']
assert len(token) >= 32
assert token not in output
assert values['MODEL_CONVERSION_HUNYUAN_ENABLED'] == 'false'
assert values['CONFIG_JWT_SECRET'] == 'existing-jwt-secret-that-is-long-enough'
with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporary:
base = Path(temporary)
for name, command in [('bash', ['bash', 'update.sh']), ('powershell', ['pwsh', '-NoProfile', '-File', 'update.ps1'])]:
if options.bash_only and name != 'bash':
continue
case = base / name
case.mkdir()
root, log, env = fixture(case)
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
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, 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'})
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()
assert not any(c[:2] in [['compose', 'pull'], ['compose', 'up']] for c in calls(log))
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'] == '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
assert not any(c[:2] in [['compose', 'pull'], ['compose', 'up']] for c in calls(log))
log.write_text('')
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 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'].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)
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
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'] == 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']])
values = core_values
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'}
original = (root / '.env').read_bytes()
for overrides in ({'UPDATE_GATEWAY_VERSION': 'latest;unsafe'},
{'UPDATE_SERVICES': 'ws-gateway'}):
log.write_text('')
result = run(command, root, {**companion_env, **overrides})
assert result.returncode != 0
assert original == (root / '.env').read_bytes()
assert not any(c[:2] in [['compose', 'pull'], ['compose', 'up']] for c in calls(log))
log.write_text('')
result = run(command, root, companion_env)
assert result.returncode == 0, result.stdout + result.stderr
assert ['compose', 'pull', 'ws-gateway', 'easyai-updater'] in calls(log)
assert ['compose', 'up', '-d', '--no-deps', 'ws-gateway', 'easyai-updater'] in calls(log)
new_values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert new_values['WS_GATEWAY_VERSION'] == new_values['UPDATER_VERSION'] == '3.25.0.20261009'
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(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'
case.mkdir()
root, log, env = fixture(case)
for args in [['git', 'init', '-q'], ['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid'], ['git', 'add', '.'], ['git', 'commit', '-qm', 'test: 初始更新脚本']]:
result = run(args, root)
assert result.returncode == 0, result.stderr
remote = case / 'remote.git'
seed = case / 'seed'
assert run(['git', 'clone', '-q', '--bare', str(root), str(remote)], case).returncode == 0
assert run(['git', 'remote', 'add', 'origin', str(remote)], root).returncode == 0
branch = run(['git', 'branch', '--show-current'], root).stdout.strip()
assert run(['git', 'branch', '--set-upstream-to', f'origin/{branch}'], root).returncode != 0
assert run(['git', 'fetch', '-q', 'origin'], root).returncode == 0
assert run(['git', 'branch', '--set-upstream-to', f'origin/{branch}'], root).returncode == 0
assert run(['git', 'clone', '-q', str(remote), str(seed)], case).returncode == 0
for args in [['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid']]:
assert run(args, seed).returncode == 0
with (seed / 'update.sh').open('a') as stream: stream.write('\n# Updated deployment fixture\n')
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:${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
assert_selected(root, log, result.stdout)
assert run(['git', 'rev-parse', 'HEAD'], root).stdout == run(['git', 'rev-parse', 'HEAD'], seed).stdout
assert compose_path.read_text() == (seed / 'docker-compose.yml').read_text()
backups = list((root / 'data/deployment-backups').glob('legacy-update-*'))
assert len(backups) == 1
assert json.loads((backups[0] / 'migration.json').read_text())['restoredRepositoryImages'] == ['easyai-web']
print('bash: real Git pull, reviewed image restore and self re-exec preserve targeted update passed')
# Resolve the real deployment Compose separately from the entry-point transport fixtures.
with tempfile.TemporaryDirectory(prefix='easyai-compose-resolution-') as directory:
compose_root = Path(directory)
shutil.copy2(source / 'docker-compose.yml', compose_root / 'docker-compose.yml')
for sample in source.glob('.env*.sample'):
shutil.copy2(sample, compose_root / sample.name.removesuffix('.sample'))
resolved = run(['docker', 'compose', '--env-file', '.env', 'config', '--format', 'json'], compose_root)
assert resolved.returncode == 0, resolved.stderr
config = json.loads(resolved.stdout)
worker = config['services']['model-conversion-worker']
api = config['services']['easyai-server']
assert not worker.get('ports')
assert not worker.get('env_file')
assert worker['read_only'] is True
assert set(worker['networks']) == {'easyai'}
assert float(worker['cpus']) == 2
assert int(worker['mem_limit']) == 4 * 1024**3
assert worker['pids_limit'] == 64
assert worker['environment']['MODEL_CONVERSION_WORKER_TOKEN'] == api['environment']['MODEL_CONVERSION_WORKER_TOKEN']
assert worker['healthcheck']['test'] == ['CMD', 'node', 'src/healthcheck.mjs']
assert api['environment']['MODEL_CONVERSION_HUNYUAN_ENABLED'] == 'false'
print('real Compose: limits, network isolation, shared token and health check passed')
print(json.dumps({'passed': True, 'durationSeconds': round(time.monotonic() - started, 3)}))