diff --git a/.env.sample b/.env.sample index 1fdc3ce..678aa05 100644 --- a/.env.sample +++ b/.env.sample @@ -277,6 +277,15 @@ WATCHTOWER_HTTP_API_PERIODIC_POLLS=true APP_VERSION_CHECK_BASE_URL= EASYAI_DEPLOYMENT_SCHEMA_VERSION=2 +# ========== 15.2 3D 模型轻量转换 ========== +# update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。 +# 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。 +MODEL_CONVERSION_WORKER_VERSION=latest +# 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。 +MODEL_CONVERSION_WORKER_TOKEN= +# 付费通道默认关闭;自建失败不会自动扣费转入其他通道。 +MODEL_CONVERSION_HUNYUAN_ENABLED=false + # ========== 16. OSS 配置(可选,PDF 解析图片上传) ========== # 可填写 .env.tools 或在此覆盖 # OSS_ENDPOINT= diff --git a/README.md b/README.md index bedb51a..ddc274a 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能: - **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件) - 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件) - 每次默认执行 `scripts/update-env.d/` 中的版本化环境配置迁移,只补缺失/空值和必要列表项,不覆盖已有非空配置 +- 自动初始化 3D 模型转换内部令牌,拉取并启动独立 `model-conversion-worker`;通过数据库、队列连接和 Worker 健康检查后才显示更新成功。 - 仓库版本发生变化时自动切换到拉取后的新版更新脚本继续执行,确保本次新增的迁移立即生效 - 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL` - 兼容 `docker compose` 和 `docker-compose` 两种命令格式 @@ -241,6 +242,17 @@ UPDATE_MODE=image ./update.sh UPDATE_SKIP_REPO_UPDATE=true ./update.sh ``` +3D 转换服务首次上线或仅更新指定服务时,可直接使用同一个入口: + +```bash +UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \ +UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' ./update.sh +``` + +脚本拉取部署仓库后会切换到新版脚本,补齐缺失的转换配置;已有令牌、端口、数据卷和显式付费选择保持原值。指定 `UPDATE_SERVICES` 时只重建这些服务,不重建数据库等依赖;服务名称有误会在拉取镜像前终止。普通更新不指定该变量时保留原来的整组更新行为。转换容器只加入业务网络,不开放主机端口,不连接沙箱网络,不挂载 Docker socket,也不读取整个生产 `.env`。默认限额为 2 CPU、4 GiB、并发 1、单任务 300 秒、临时空间 2 GiB;原模型始终保留,轻量转换失败可使用原模型。 + +`MODEL_CONVERSION_WORKER_VERSION` 可以固定已发布的版本;默认 `latest`。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。 + - **查看帮助**:`./update.sh -h` 或 `./update.sh --help` ### 更新说明 diff --git a/docker-compose.yml b/docker-compose.yml index a28cfe4..430f3f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -144,6 +144,9 @@ services: - SYSTEM_UPDATE_SERVICE_URL=${SYSTEM_UPDATE_SERVICE_URL:-http://easyai-updater:3015} - SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN} - EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1} + # 独立转换 Worker 与 API 共用内部令牌,由安装/升级脚本自动生成。 + - MODEL_CONVERSION_WORKER_TOKEN=${MODEL_CONVERSION_WORKER_TOKEN} + - MODEL_CONVERSION_HUNYUAN_ENABLED=${MODEL_CONVERSION_HUNYUAN_ENABLED:-false} # 日志大小设置,避免日志文件过大 env_file: - .env @@ -158,6 +161,58 @@ services: options: max-size: "100m" max-file: "10" + model-conversion-worker: + container_name: easyai-model-conversion-worker + image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-latest} + labels: + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" + restart: unless-stopped + cpus: 2 + mem_limit: 4g + memswap_limit: 4g + pids_limit: 64 + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=2g,uid=1000,gid=1000,mode=0700 + stop_grace_period: 10s + networks: + - easyai + depends_on: + easyai-server: + condition: service_healthy + redis: + condition: service_healthy + mongo: + condition: service_started + # 只提供转换所需连接,不向解析器或 Worker 注入整个 .env。 + environment: + - MODEL_CONVERSION_MONGO_URI=mongodb://mongo:27017 + - MODEL_CONVERSION_MONGO_DB=${CONFIG_DB_MONGO_DBNAME:-aidraw} + - MODEL_CONVERSION_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME} + - MODEL_CONVERSION_MONGO_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD} + - MODEL_CONVERSION_MONGO_AUTH_SOURCE=admin + - MODEL_CONVERSION_REDIS_HOST=redis + - MODEL_CONVERSION_REDIS_PORT=6379 + - MODEL_CONVERSION_REDIS_USERNAME=${CONFIG_COMFYUI_QUENE_REDIS_USERNAME:-} + - MODEL_CONVERSION_REDIS_PASSWORD=${CONFIG_COMFYUI_QUENE_REDIS_PASSWORD:-} + - MODEL_CONVERSION_REDIS_DB=${CONFIG_COMFYUI_QUENE_REDIS_DB:-6} + - MODEL_CONVERSION_API_URL=http://easyai-server:3001 + - MODEL_CONVERSION_WORKER_TOKEN=${MODEL_CONVERSION_WORKER_TOKEN} + healthcheck: + test: ["CMD", "node", "src/healthcheck.mjs"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 20s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" ws-gateway: container_name: easyai-wsgateway # websocket网关 # 阿里云镜像地址 diff --git a/scripts/Initialize-SecurityEnv.ps1 b/scripts/Initialize-SecurityEnv.ps1 index 19d3e3e..c26fad0 100644 --- a/scripts/Initialize-SecurityEnv.ps1 +++ b/scripts/Initialize-SecurityEnv.ps1 @@ -60,7 +60,8 @@ function Initialize-SecurityEnv { @{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 }, @{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 }, @{ Key = "WATCHTOWER_API_KEY"; Legacy = ""; MinLength = 32 }, - @{ Key = "WATCHTOWER_HTTP_API_EVENTS_TOKEN"; Legacy = ""; MinLength = 32 } + @{ Key = "WATCHTOWER_HTTP_API_EVENTS_TOKEN"; Legacy = ""; MinLength = 32 }, + @{ Key = "MODEL_CONVERSION_WORKER_TOKEN"; Legacy = ""; MinLength = 32 } ) if ($Mode -eq "new") { $definitions += @( diff --git a/scripts/init-security-env.sh b/scripts/init-security-env.sh index 815b815..6014e2c 100644 --- a/scripts/init-security-env.sh +++ b/scripts/init-security-env.sh @@ -132,6 +132,7 @@ init_security_env() { ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN" ensure_security_secret "$file" "WATCHTOWER_API_KEY" ensure_security_secret "$file" "WATCHTOWER_HTTP_API_EVENTS_TOKEN" + ensure_security_secret "$file" "MODEL_CONVERSION_WORKER_TOKEN" ensure_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer" if [ "$mode" = "new" ]; then ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" diff --git a/scripts/test-model-conversion-update.py b/scripts/test-model-conversion-update.py new file mode 100644 index 0000000..8c40495 --- /dev/null +++ b/scripts/test-model-conversion-update.py @@ -0,0 +1,156 @@ +#!/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) +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'])]: + 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('') + 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 + result = run(['bash', 'update.sh'], root, {**env, 'UPDATE_MODE': 'full'}) + 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 + print('bash: real Git pull and self re-exec preserve targeted update passed') + +# Resolve the real deployment Compose separately from the entry-point transport fixtures. +resolved = run(['docker', 'compose', '--env-file', '.env.sample', 'config', '--format', 'json'], source) +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)})) diff --git a/scripts/test-security-env.sh b/scripts/test-security-env.sh index a249a85..001ab6d 100755 --- a/scripts/test-security-env.sh +++ b/scripts/test-security-env.sh @@ -66,6 +66,7 @@ init_security_env "$upgrade_env" upgrade > "$upgrade_log" assert_min_length "$upgrade_env" "CONFIG_JWT_SECRET" 32 assert_min_length "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET" 32 +assert_min_length "$upgrade_env" "MODEL_CONVERSION_WORKER_TOKEN" 32 assert_value "$upgrade_env" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" "" assert_value "$upgrade_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket" assert_single_key "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET" @@ -73,7 +74,8 @@ assert_single_key "$upgrade_env" "WS_AUTH_METHODS" upgrade_jwt="$(read_env_value "$upgrade_env" "CONFIG_JWT_SECRET")" upgrade_ws="$(read_env_value "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET")" -if grep -Fq "$upgrade_jwt" "$upgrade_log" || grep -Fq "$upgrade_ws" "$upgrade_log"; then +upgrade_worker="$(read_env_value "$upgrade_env" "MODEL_CONVERSION_WORKER_TOKEN")" +if grep -Fq "$upgrade_jwt" "$upgrade_log" || grep -Fq "$upgrade_ws" "$upgrade_log" || grep -Fq "$upgrade_worker" "$upgrade_log"; then fail "security initialization output must not reveal generated secrets" fi @@ -87,9 +89,11 @@ preserved_env="$TMP_DIR/preserved.env" write_env "$preserved_env" \ "CONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough" \ "WS_AUTH_WS_TICKET_SECRET=$preserved_secret" \ + "MODEL_CONVERSION_WORKER_TOKEN=$preserved_secret" \ "WS_AUTH_METHODS=bearer,ws_ticket" init_security_env "$preserved_env" upgrade > "$TMP_DIR/preserved.log" assert_value "$preserved_env" "WS_AUTH_WS_TICKET_SECRET" "$preserved_secret" +assert_value "$preserved_env" "MODEL_CONVERSION_WORKER_TOKEN" "$preserved_secret" assert_value "$preserved_env" "WS_AUTH_METHODS" "bearer,ws_ticket" new_env="$TMP_DIR/new.env" diff --git a/scripts/test-update-env-migrations.sh b/scripts/test-update-env-migrations.sh index c881d61..310e969 100755 --- a/scripts/test-update-env-migrations.sh +++ b/scripts/test-update-env-migrations.sh @@ -27,6 +27,8 @@ 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:1.22.3' "$LEGACY_ENV_FILE" +grep -qx 'MODEL_CONVERSION_WORKER_VERSION=latest' "$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 cmp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env" @@ -55,6 +57,8 @@ 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" ] +[ "$(read_value MODEL_CONVERSION_HUNYUAN_ENABLED)" = "false" ] cp "$ENV_FILE" "$TMP_DIR/first-run.env" bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$ENV_FILE" "$PROJECT_ROOT" >/dev/null diff --git a/scripts/update-env.d/030-model-conversion.env b/scripts/update-env.d/030-model-conversion.env new file mode 100644 index 0000000..bdf7461 --- /dev/null +++ b/scripts/update-env.d/030-model-conversion.env @@ -0,0 +1,3 @@ +# 独立模型转换;不改变现有在线更新模式与付费选择。 +MODEL_CONVERSION_WORKER_VERSION=latest +MODEL_CONVERSION_HUNYUAN_ENABLED=false diff --git a/update.ps1 b/update.ps1 index 05cd060..0b4cc96 100644 --- a/update.ps1 +++ b/update.ps1 @@ -56,6 +56,8 @@ if ($args -contains "-h" -or $args -contains "--help") { Write-Host " UPDATE_SKIP_REPO_UPDATE=true|false" Write-Host " true 等同 UPDATE_MODE=image" Write-Host " false 等同 UPDATE_MODE=full" + Write-Host " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'" + Write-Host " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。" Write-Host " UPDATE_NO_WAIT=1" Write-Host " 结束或失败时不等待按 Enter" Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数" @@ -274,7 +276,6 @@ if (-not (Ensure-DockerRunning)) { # 检测 docker compose . (Join-Path $scriptDir "scripts\Assert-RedisPersistence.ps1") -Assert-RedisPersistence -ProjectRoot $scriptDir $hasComposeV2 = $false try { $null = docker compose version 2>&1; $hasComposeV2 = $true } catch { } @@ -296,7 +297,13 @@ function Wait-CoreServices { } } - $services = @("easyai-server", "ws-gateway", "easyai-web") + $services = if ($updateServices.Count -gt 0) { + $updateServices + } elseif ($availableServices -contains "model-conversion-worker") { + @("easyai-server", "ws-gateway", "easyai-web", "model-conversion-worker") + } else { + @("easyai-server", "ws-gateway", "easyai-web") + } $deadline = (Get-Date).AddSeconds($timeoutSeconds) Write-Step "⏳ 正在等待核心服务恢复可用(最长 $timeoutSeconds 秒)..." @@ -329,9 +336,9 @@ function Wait-CoreServices { Write-Host "❌ 更新后核心服务未在 $timeoutSeconds 秒内恢复可用" -ForegroundColor Red if ($hasComposeV2) { - docker compose ps easyai-server ws-gateway easyai-web + docker compose ps @services } else { - docker-compose ps easyai-server ws-gateway easyai-web + docker-compose ps @services } Write-Err "请检查核心服务日志,确认失败原因后重试" } @@ -341,15 +348,29 @@ Write-Host "" Write-Step "🚀 重新启动 EasyAI..." Write-Step "📦 当前磁盘空间:" Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Root -eq ([System.IO.Path]::GetPathRoot($scriptDir)) } | Format-Table Name, Used, Free -AutoSize +$availableServices = if ($hasComposeV2) { @(& docker compose config --services) } else { @(& docker-compose config --services) } +if ($LASTEXITCODE -ne 0) { Write-Err "部署配置校验失败,尚未拉取或重建服务" } +$updateServices = @() +if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_SERVICES)) { + $updateServices = @($env:UPDATE_SERVICES.Trim() -split '\s+') + foreach ($service in $updateServices) { + if ($availableServices -notcontains $service) { Write-Err "更新服务不存在: $service;尚未拉取或重建任何服务" } + } +} +$upArguments = @("up", "-d") +if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices } +if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") { + Assert-RedisPersistence -ProjectRoot $scriptDir +} if ($hasComposeV2) { - docker compose pull + docker compose pull @updateServices if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" } - docker compose up -d + docker compose @upArguments if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" } } else { - docker-compose pull + docker-compose pull @updateServices if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" } - docker-compose up -d + docker-compose @upArguments if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" } } diff --git a/update.sh b/update.sh index ab0b24c..813b1bc 100755 --- a/update.sh +++ b/update.sh @@ -17,6 +17,8 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then echo " false 等同 UPDATE_MODE=full" echo " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数" echo " 等待核心服务恢复可用的最长时间,默认 300 秒" + echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'" + echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。" echo "" echo "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。" exit 0 @@ -228,6 +230,11 @@ wait_for_core_services() { local timeout_seconds="${UPDATE_HEALTH_TIMEOUT_SECONDS:-300}" local deadline=$((SECONDS + timeout_seconds)) local services=(easyai-server ws-gateway easyai-web) + if [ "${#UPDATE_SERVICE_LIST[@]}" -gt 0 ]; then + services=("${UPDATE_SERVICE_LIST[@]}") + elif printf '%s\n' "$AVAILABLE_SERVICES" | grep -qx 'model-conversion-worker'; then + services+=(model-conversion-worker) + fi local service container_id state running health all_ready if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then @@ -262,23 +269,42 @@ wait_for_core_services() { echo "❌ 更新后核心服务未在 ${timeout_seconds} 秒内恢复可用" echo " 当前状态:" - sudo $DOCKER_COMPOSE_CMD ps easyai-server ws-gateway easyai-web || true - echo " 请运行 '$DOCKER_COMPOSE_CMD logs --tail=100 easyai-server ws-gateway easyai-web' 查看原因" + sudo $DOCKER_COMPOSE_CMD ps "${services[@]}" || true + echo " 请运行 '$DOCKER_COMPOSE_CMD logs --tail=100 ${services[*]}' 查看原因" return 1 } -# 旧 Redis 若仍把 AOF/RDB 留在容器写层,直接 up 会丢失数据。 -sudo ./scripts/check-redis-persistence.sh +# 必须在拉取/重建前确认服务名称,防止拼写错误扩大更新范围。 +AVAILABLE_SERVICES="$(sudo $DOCKER_COMPOSE_CMD config --services)" +UPDATE_SERVICE_LIST=() +if [ -n "${UPDATE_SERVICES:-}" ]; then + read -r -a UPDATE_SERVICE_LIST <<< "$UPDATE_SERVICES" + for service in "${UPDATE_SERVICE_LIST[@]}"; do + if ! printf '%s\n' "$AVAILABLE_SERVICES" | grep -qx -- "$service"; then + echo "❌ 更新服务不存在: $service;尚未拉取或重建任何服务" >&2 + exit 1 + fi + done +fi + +# 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。 +if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then + sudo ./scripts/check-redis-persistence.sh +fi echo "🚀 重新启动EasyAI" echo "📦 当前磁盘空间:" df -h "$SCRIPT_DIR" | tail -n 1 -if ! sudo $DOCKER_COMPOSE_CMD pull; then +if ! sudo $DOCKER_COMPOSE_CMD pull "${UPDATE_SERVICE_LIST[@]}"; then echo "❌ 镜像拉取失败,EasyAI 未被标记为更新成功" echo " 请检查网络、镜像仓库权限和服务器磁盘空间后重试" exit 1 fi -if ! sudo $DOCKER_COMPOSE_CMD up -d; then +UP_ARGUMENTS=(-d) +if [ "${#UPDATE_SERVICE_LIST[@]}" -gt 0 ]; then + UP_ARGUMENTS+=(--no-deps "${UPDATE_SERVICE_LIST[@]}") +fi +if ! sudo $DOCKER_COMPOSE_CMD up "${UP_ARGUMENTS[@]}"; then echo "❌ 容器启动失败,EasyAI 未被标记为更新成功" exit 1 fi