#!/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=35) def fixture(base): root = base / 'easyai' root.mkdir() for name in ['scripts', '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) (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 / '.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[:2] == ['container', 'inspect']: sys.exit(0 if 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\\nmodel-conversion-worker\\nmongo\\nredis') 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 = {**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'} 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) 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() 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'] == values['MODEL_CONVERSION_WORKER_VERSION'] == '3.23.0.20261003' 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 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:${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)}))