forked from wangbo/easyai
feat(deploy): 支持更新脚本自动部署模型转换服务
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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`
|
||||
|
||||
### 更新说明
|
||||
|
||||
@@ -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网关
|
||||
# 阿里云镜像地址
|
||||
|
||||
@@ -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 += @(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}))
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# 独立模型转换;不改变现有在线更新模式与付费选择。
|
||||
MODEL_CONVERSION_WORKER_VERSION=latest
|
||||
MODEL_CONVERSION_HUNYUAN_ENABLED=false
|
||||
+29
-8
@@ -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 未被标记为更新成功" }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 会丢失数据。
|
||||
# 必须在拉取/重建前确认服务名称,防止拼写错误扩大更新范围。
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user