feat(deploy): 支持更新脚本自动部署模型转换服务
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s

This commit is contained in:
2026-10-03 02:17:47 +08:00
parent 4e4c4c9f23
commit be3ba8838a
11 changed files with 308 additions and 16 deletions
+9
View File
@@ -277,6 +277,15 @@ WATCHTOWER_HTTP_API_PERIODIC_POLLS=true
APP_VERSION_CHECK_BASE_URL= APP_VERSION_CHECK_BASE_URL=
EASYAI_DEPLOYMENT_SCHEMA_VERSION=2 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 解析图片上传) ========== # ========== 16. OSS 配置(可选,PDF 解析图片上传) ==========
# 可填写 .env.tools 或在此覆盖 # 可填写 .env.tools 或在此覆盖
# OSS_ENDPOINT= # OSS_ENDPOINT=
+12
View File
@@ -175,6 +175,7 @@ update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件) - **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件) - 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
- 每次默认执行 `scripts/update-env.d/` 中的版本化环境配置迁移,只补缺失/空值和必要列表项,不覆盖已有非空配置 - 每次默认执行 `scripts/update-env.d/` 中的版本化环境配置迁移,只补缺失/空值和必要列表项,不覆盖已有非空配置
- 自动初始化 3D 模型转换内部令牌,拉取并启动独立 `model-conversion-worker`;通过数据库、队列连接和 Worker 健康检查后才显示更新成功。
- 仓库版本发生变化时自动切换到拉取后的新版更新脚本继续执行,确保本次新增的迁移立即生效 - 仓库版本发生变化时自动切换到拉取后的新版更新脚本继续执行,确保本次新增的迁移立即生效
- 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL` - 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL`
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式 - 兼容 `docker compose` 和 `docker-compose` 两种命令格式
@@ -241,6 +242,17 @@ UPDATE_MODE=image ./update.sh
UPDATE_SKIP_REPO_UPDATE=true ./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` - **查看帮助**:`./update.sh -h` 或 `./update.sh --help`
### 更新说明 ### 更新说明
+55
View File
@@ -144,6 +144,9 @@ services:
- SYSTEM_UPDATE_SERVICE_URL=${SYSTEM_UPDATE_SERVICE_URL:-http://easyai-updater:3015} - SYSTEM_UPDATE_SERVICE_URL=${SYSTEM_UPDATE_SERVICE_URL:-http://easyai-updater:3015}
- SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN} - SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN}
- EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1} - 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_file:
- .env - .env
@@ -158,6 +161,58 @@ services:
options: options:
max-size: "100m" max-size: "100m"
max-file: "10" 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: ws-gateway:
container_name: easyai-wsgateway # websocket网关 container_name: easyai-wsgateway # websocket网关
# 阿里云镜像地址 # 阿里云镜像地址
+2 -1
View File
@@ -60,7 +60,8 @@ function Initialize-SecurityEnv {
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 }, @{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 },
@{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 }, @{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 },
@{ Key = "WATCHTOWER_API_KEY"; 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") { if ($Mode -eq "new") {
$definitions += @( $definitions += @(
+1
View File
@@ -132,6 +132,7 @@ init_security_env() {
ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN" ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN"
ensure_security_secret "$file" "WATCHTOWER_API_KEY" ensure_security_secret "$file" "WATCHTOWER_API_KEY"
ensure_security_secret "$file" "WATCHTOWER_HTTP_API_EVENTS_TOKEN" 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" ensure_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer"
if [ "$mode" = "new" ]; then if [ "$mode" = "new" ]; then
ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"
+156
View File
@@ -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)}))
+5 -1
View File
@@ -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" "CONFIG_JWT_SECRET" 32
assert_min_length "$upgrade_env" "WS_AUTH_WS_TICKET_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" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" ""
assert_value "$upgrade_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket" assert_value "$upgrade_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket"
assert_single_key "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET" 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_jwt="$(read_env_value "$upgrade_env" "CONFIG_JWT_SECRET")"
upgrade_ws="$(read_env_value "$upgrade_env" "WS_AUTH_WS_TICKET_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" fail "security initialization output must not reveal generated secrets"
fi fi
@@ -87,9 +89,11 @@ preserved_env="$TMP_DIR/preserved.env"
write_env "$preserved_env" \ write_env "$preserved_env" \
"CONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough" \ "CONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough" \
"WS_AUTH_WS_TICKET_SECRET=$preserved_secret" \ "WS_AUTH_WS_TICKET_SECRET=$preserved_secret" \
"MODEL_CONVERSION_WORKER_TOKEN=$preserved_secret" \
"WS_AUTH_METHODS=bearer,ws_ticket" "WS_AUTH_METHODS=bearer,ws_ticket"
init_security_env "$preserved_env" upgrade > "$TMP_DIR/preserved.log" 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" "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" assert_value "$preserved_env" "WS_AUTH_METHODS" "bearer,ws_ticket"
new_env="$TMP_DIR/new.env" new_env="$TMP_DIR/new.env"
+4
View File
@@ -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_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_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 '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" 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 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" 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 UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ]
[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ] [ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ]
[ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ] [ "$(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" cp "$ENV_FILE" "$TMP_DIR/first-run.env"
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$ENV_FILE" "$PROJECT_ROOT" >/dev/null bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$ENV_FILE" "$PROJECT_ROOT" >/dev/null
@@ -0,0 +1,3 @@
# 独立模型转换;不改变现有在线更新模式与付费选择。
MODEL_CONVERSION_WORKER_VERSION=latest
MODEL_CONVERSION_HUNYUAN_ENABLED=false
+29 -8
View File
@@ -56,6 +56,8 @@ if ($args -contains "-h" -or $args -contains "--help") {
Write-Host " UPDATE_SKIP_REPO_UPDATE=true|false" Write-Host " UPDATE_SKIP_REPO_UPDATE=true|false"
Write-Host " true 等同 UPDATE_MODE=image" Write-Host " true 等同 UPDATE_MODE=image"
Write-Host " false 等同 UPDATE_MODE=full" 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 " UPDATE_NO_WAIT=1"
Write-Host " 结束或失败时不等待按 Enter" Write-Host " 结束或失败时不等待按 Enter"
Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数" Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数"
@@ -274,7 +276,6 @@ if (-not (Ensure-DockerRunning)) {
# 检测 docker compose # 检测 docker compose
. (Join-Path $scriptDir "scripts\Assert-RedisPersistence.ps1") . (Join-Path $scriptDir "scripts\Assert-RedisPersistence.ps1")
Assert-RedisPersistence -ProjectRoot $scriptDir
$hasComposeV2 = $false $hasComposeV2 = $false
try { $null = docker compose version 2>&1; $hasComposeV2 = $true } catch { } 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) $deadline = (Get-Date).AddSeconds($timeoutSeconds)
Write-Step "⏳ 正在等待核心服务恢复可用(最长 $timeoutSeconds 秒)..." Write-Step "⏳ 正在等待核心服务恢复可用(最长 $timeoutSeconds 秒)..."
@@ -329,9 +336,9 @@ function Wait-CoreServices {
Write-Host "❌ 更新后核心服务未在 $timeoutSeconds 秒内恢复可用" -ForegroundColor Red Write-Host "❌ 更新后核心服务未在 $timeoutSeconds 秒内恢复可用" -ForegroundColor Red
if ($hasComposeV2) { if ($hasComposeV2) {
docker compose ps easyai-server ws-gateway easyai-web docker compose ps @services
} else { } else {
docker-compose ps easyai-server ws-gateway easyai-web docker-compose ps @services
} }
Write-Err "请检查核心服务日志,确认失败原因后重试" Write-Err "请检查核心服务日志,确认失败原因后重试"
} }
@@ -341,15 +348,29 @@ Write-Host ""
Write-Step "🚀 重新启动 EasyAI..." Write-Step "🚀 重新启动 EasyAI..."
Write-Step "📦 当前磁盘空间:" Write-Step "📦 当前磁盘空间:"
Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Root -eq ([System.IO.Path]::GetPathRoot($scriptDir)) } | Format-Table Name, Used, Free -AutoSize 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) { if ($hasComposeV2) {
docker compose pull docker compose pull @updateServices
if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" } if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" }
docker compose up -d docker compose @upArguments
if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" } if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" }
} else { } else {
docker-compose pull docker-compose pull @updateServices
if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" } if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" }
docker-compose up -d docker-compose @upArguments
if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" } if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" }
} }
+32 -6
View File
@@ -17,6 +17,8 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
echo " false 等同 UPDATE_MODE=full" echo " false 等同 UPDATE_MODE=full"
echo " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数" echo " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数"
echo " 等待核心服务恢复可用的最长时间,默认 300 秒" echo " 等待核心服务恢复可用的最长时间,默认 300 秒"
echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。"
echo "" echo ""
echo "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。" echo "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。"
exit 0 exit 0
@@ -228,6 +230,11 @@ wait_for_core_services() {
local timeout_seconds="${UPDATE_HEALTH_TIMEOUT_SECONDS:-300}" local timeout_seconds="${UPDATE_HEALTH_TIMEOUT_SECONDS:-300}"
local deadline=$((SECONDS + timeout_seconds)) local deadline=$((SECONDS + timeout_seconds))
local services=(easyai-server ws-gateway easyai-web) 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 local service container_id state running health all_ready
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
@@ -262,23 +269,42 @@ wait_for_core_services() {
echo "❌ 更新后核心服务未在 ${timeout_seconds} 秒内恢复可用" echo "❌ 更新后核心服务未在 ${timeout_seconds} 秒内恢复可用"
echo " 当前状态:" echo " 当前状态:"
sudo $DOCKER_COMPOSE_CMD ps easyai-server ws-gateway easyai-web || true sudo $DOCKER_COMPOSE_CMD ps "${services[@]}" || true
echo " 请运行 '$DOCKER_COMPOSE_CMD logs --tail=100 easyai-server ws-gateway easyai-web' 查看原因" echo " 请运行 '$DOCKER_COMPOSE_CMD logs --tail=100 ${services[*]}' 查看原因"
return 1 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 "🚀 重新启动EasyAI"
echo "📦 当前磁盘空间:" echo "📦 当前磁盘空间:"
df -h "$SCRIPT_DIR" | tail -n 1 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 "❌ 镜像拉取失败,EasyAI 未被标记为更新成功"
echo " 请检查网络、镜像仓库权限和服务器磁盘空间后重试" echo " 请检查网络、镜像仓库权限和服务器磁盘空间后重试"
exit 1 exit 1
fi 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 未被标记为更新成功" echo "❌ 容器启动失败,EasyAI 未被标记为更新成功"
exit 1 exit 1
fi fi