26dd3bb387
主要变更: 1. 动态更新功能 - 新增 Python 更新服务 (docker/update-service.py) - 添加系统更新前端页面 (frontend/src/pages/SystemUpdate.tsx) - 配置 Nginx 代理更新服务 API - 更新 Docker 启动脚本支持多进程管理 - 修复权限验证接口 (AuthController.verify) 2. Release 创建脚本 - 新增 create-release.sh 脚本支持快速创建 GitHub Release - 支持自动拼接 -beta 后缀(pre-release) - 支持无交互模式(--yes 参数) - 添加详细的使用文档 3. GitHub Actions 增强 - 添加更新包构建和上传流程 - 支持 Pre-release 检测和过滤 4. 文档完善 - 添加动态更新技术方案文档 - 添加 Docker 版本号确定流程文档 - 添加 Release 脚本使用说明
574 lines
17 KiB
Python
574 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
PolyHermes 动态更新服务
|
||
负责检查更新、下载更新包、执行更新和回滚
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import logging
|
||
import subprocess
|
||
import time
|
||
import shutil
|
||
import tarfile
|
||
import requests
|
||
from pathlib import Path
|
||
from threading import Thread
|
||
from flask import Flask, jsonify, request
|
||
from datetime import datetime
|
||
|
||
# ==================== 配置 ====================
|
||
app = Flask(__name__)
|
||
|
||
# 日志配置
|
||
LOG_FILE = Path('/var/log/polyhermes/update-service.log')
|
||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||
handlers=[
|
||
logging.FileHandler(LOG_FILE),
|
||
logging.StreamHandler()
|
||
]
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 路径配置
|
||
APP_DIR = Path('/app')
|
||
VERSION_FILE = APP_DIR / 'version.json'
|
||
UPDATES_DIR = APP_DIR / 'updates'
|
||
BACKUPS_DIR = APP_DIR / 'backups'
|
||
BACKEND_JAR = APP_DIR / 'app.jar'
|
||
FRONTEND_DIR = Path('/usr/share/nginx/html')
|
||
|
||
# 创建必要目录
|
||
UPDATES_DIR.mkdir(parents=True, exist_ok=True)
|
||
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# GitHub 配置
|
||
GITHUB_REPO = os.getenv('GITHUB_REPO', 'WrBug/PolyHermes')
|
||
ALLOW_PRERELEASE = os.getenv('ALLOW_PRERELEASE', 'false').lower() == 'true'
|
||
BACKEND_URL = 'http://localhost:8000'
|
||
|
||
# 更新状态
|
||
update_status = {
|
||
'updating': False,
|
||
'progress': 0,
|
||
'message': '就绪',
|
||
'error': None
|
||
}
|
||
|
||
# ==================== 工具函数 ====================
|
||
|
||
def get_current_version():
|
||
"""获取当前版本"""
|
||
try:
|
||
if VERSION_FILE.exists():
|
||
with open(VERSION_FILE) as f:
|
||
data = json.load(f)
|
||
return data.get('version', 'unknown')
|
||
return 'unknown'
|
||
except Exception as e:
|
||
logger.error(f"读取版本失败: {e}")
|
||
return 'unknown'
|
||
|
||
|
||
def fetch_latest_release():
|
||
"""获取最新 Release"""
|
||
try:
|
||
if ALLOW_PRERELEASE:
|
||
# 测试模式:获取所有 Release(包括 pre-release)
|
||
url = f'https://api.github.com/repos/{GITHUB_REPO}/releases'
|
||
response = requests.get(url, headers={'Accept': 'application/vnd.github.v3+json'}, timeout=10)
|
||
releases = response.json()
|
||
|
||
if releases and len(releases) > 0:
|
||
latest = releases[0]
|
||
logger.info(f"检测到版本: {latest['tag_name']} (pre-release: {latest.get('prerelease', False)})")
|
||
return {
|
||
'tag': latest['tag_name'],
|
||
'name': latest['name'],
|
||
'body': latest['body'],
|
||
'published_at': latest['published_at'],
|
||
'assets': latest['assets'],
|
||
'prerelease': latest.get('prerelease', False)
|
||
}
|
||
else:
|
||
# 生产模式:只获取正式版本
|
||
url = f'https://api.github.com/repos/{GITHUB_REPO}/releases/latest'
|
||
response = requests.get(url, headers={'Accept': 'application/vnd.github.v3+json'}, timeout=10)
|
||
|
||
if response.status_code == 200:
|
||
data = response.json()
|
||
return {
|
||
'tag': data['tag_name'],
|
||
'name': data['name'],
|
||
'body': data['body'],
|
||
'published_at': data['published_at'],
|
||
'assets': data['assets'],
|
||
'prerelease': False
|
||
}
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取 Release 失败: {e}")
|
||
return None
|
||
|
||
|
||
def compare_versions(v1, v2):
|
||
"""
|
||
比较两个版本号(语义化版本)
|
||
返回: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||
"""
|
||
def normalize(v):
|
||
parts = v.replace('v', '').split('-')[0].split('.')
|
||
return [int(x) for x in parts]
|
||
|
||
try:
|
||
parts1 = normalize(v1)
|
||
parts2 = normalize(v2)
|
||
|
||
for i in range(max(len(parts1), len(parts2))):
|
||
p1 = parts1[i] if i < len(parts1) else 0
|
||
p2 = parts2[i] if i < len(parts2) else 0
|
||
if p1 > p2:
|
||
return 1
|
||
elif p1 < p2:
|
||
return -1
|
||
return 0
|
||
except:
|
||
return 0
|
||
|
||
|
||
def check_admin_permission(req):
|
||
"""检查管理员权限"""
|
||
auth_header = req.headers.get('Authorization')
|
||
if not auth_header:
|
||
return False
|
||
|
||
try:
|
||
response = requests.get(
|
||
f'{BACKEND_URL}/api/auth/verify',
|
||
headers={'Authorization': auth_header},
|
||
timeout=3
|
||
)
|
||
return response.status_code == 200
|
||
except Exception as e:
|
||
logger.error(f"权限验证失败: {e}")
|
||
return False
|
||
|
||
|
||
def download_file(url, dest_path):
|
||
"""下载文件"""
|
||
logger.info(f"开始下载: {url}")
|
||
response = requests.get(url, stream=True, timeout=300)
|
||
response.raise_for_status()
|
||
|
||
total_size = int(response.headers.get('content-length', 0))
|
||
downloaded = 0
|
||
|
||
with open(dest_path, 'wb') as f:
|
||
for chunk in response.iter_content(chunk_size=8192):
|
||
if chunk:
|
||
f.write(chunk)
|
||
downloaded += len(chunk)
|
||
if total_size > 0:
|
||
progress = int((downloaded / total_size) * 30) # 下载占30%
|
||
update_status['progress'] = progress
|
||
|
||
logger.info(f"下载完成: {dest_path}")
|
||
return dest_path
|
||
|
||
|
||
def backup_current_version():
|
||
"""备份当前版本"""
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
backup_dir = BACKUPS_DIR / timestamp
|
||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
logger.info(f"创建备份: {backup_dir}")
|
||
|
||
# 备份后端 JAR
|
||
if BACKEND_JAR.exists():
|
||
shutil.copy2(BACKEND_JAR, backup_dir / 'app.jar')
|
||
|
||
# 备份前端(打包)
|
||
if FRONTEND_DIR.exists():
|
||
frontend_backup = backup_dir / 'frontend.tar.gz'
|
||
with tarfile.open(frontend_backup, 'w:gz') as tar:
|
||
tar.add(FRONTEND_DIR, arcname='.')
|
||
|
||
# 备份版本信息
|
||
if VERSION_FILE.exists():
|
||
shutil.copy2(VERSION_FILE, backup_dir / 'version.json')
|
||
|
||
logger.info(f"备份完成: {backup_dir}")
|
||
return backup_dir
|
||
|
||
|
||
def restore_backup(backup_dir):
|
||
"""恢复备份"""
|
||
logger.info(f"开始恢复备份: {backup_dir}")
|
||
|
||
# 恢复后端 JAR
|
||
backup_jar = backup_dir / 'app.jar'
|
||
if backup_jar.exists():
|
||
shutil.copy2(backup_jar, BACKEND_JAR)
|
||
|
||
# 恢复前端
|
||
frontend_backup = backup_dir / 'frontend.tar.gz'
|
||
if frontend_backup.exists():
|
||
# 清空前端目录
|
||
if FRONTEND_DIR.exists():
|
||
shutil.rmtree(FRONTEND_DIR)
|
||
FRONTEND_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 解压备份
|
||
with tarfile.open(frontend_backup, 'r:gz') as tar:
|
||
tar.extractall(FRONTEND_DIR)
|
||
|
||
# 恢复版本信息
|
||
backup_version = backup_dir / 'version.json'
|
||
if backup_version.exists():
|
||
shutil.copy2(backup_version, VERSION_FILE)
|
||
|
||
logger.info("备份恢复完成")
|
||
|
||
|
||
def perform_update(target_version):
|
||
"""执行更新流程"""
|
||
global update_status
|
||
|
||
try:
|
||
update_status['updating'] = True
|
||
update_status['progress'] = 0
|
||
update_status['message'] = '开始更新...'
|
||
update_status['error'] = None
|
||
|
||
# 1. 获取最新 Release
|
||
update_status['message'] = '获取 Release 信息...'
|
||
release = fetch_latest_release()
|
||
if not release:
|
||
raise Exception("无法获取 Release 信息")
|
||
|
||
tag = release['tag']
|
||
assets = release['assets']
|
||
|
||
# 查找更新包
|
||
update_asset = None
|
||
for asset in assets:
|
||
if asset['name'].endswith('-update.tar.gz'):
|
||
update_asset = asset
|
||
break
|
||
|
||
if not update_asset:
|
||
raise Exception(f"未找到更新包: {tag}")
|
||
|
||
update_status['progress'] = 10
|
||
|
||
# 2. 下载更新包
|
||
update_status['message'] = f'下载更新包 {tag}...'
|
||
download_url = update_asset['browser_download_url']
|
||
download_path = UPDATES_DIR / update_asset['name']
|
||
download_file(download_url, download_path)
|
||
|
||
update_status['progress'] = 40
|
||
|
||
# 3. 备份当前版本
|
||
update_status['message'] = '备份当前版本...'
|
||
backup_dir = backup_current_version()
|
||
|
||
update_status['progress'] = 50
|
||
|
||
# 4. 解压更新包
|
||
update_status['message'] = '解压更新包...'
|
||
extract_dir = UPDATES_DIR / 'current'
|
||
if extract_dir.exists():
|
||
shutil.rmtree(extract_dir)
|
||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
with tarfile.open(download_path, 'r:gz') as tar:
|
||
tar.extractall(extract_dir)
|
||
|
||
update_status['progress'] = 60
|
||
|
||
# 5. 停止后端进程
|
||
update_status['message'] = '停止后端服务...'
|
||
logger.info("停止后端进程...")
|
||
subprocess.run(['pkill', '-f', 'java -jar'], check=False)
|
||
time.sleep(2)
|
||
|
||
update_status['progress'] = 65
|
||
|
||
# 6. 替换文件
|
||
update_status['message'] = '更新文件...'
|
||
|
||
# 替换后端 JAR
|
||
new_jar = extract_dir / 'backend' / 'polyhermes.jar'
|
||
if new_jar.exists():
|
||
shutil.copy2(new_jar, BACKEND_JAR)
|
||
logger.info("后端 JAR 已更新")
|
||
|
||
# 替换前端文件
|
||
new_frontend = extract_dir / 'frontend'
|
||
if new_frontend.exists():
|
||
if FRONTEND_DIR.exists():
|
||
shutil.rmtree(FRONTEND_DIR)
|
||
shutil.copytree(new_frontend, FRONTEND_DIR)
|
||
logger.info("前端文件已更新")
|
||
|
||
# 更新版本信息
|
||
new_version = extract_dir / 'version.json'
|
||
if new_version.exists():
|
||
shutil.copy2(new_version, VERSION_FILE)
|
||
logger.info("版本信息已更新")
|
||
|
||
update_status['progress'] = 75
|
||
|
||
# 7. 重启后端服务
|
||
update_status['message'] = '重启后端服务...'
|
||
logger.info("重启后端服务...")
|
||
subprocess.Popen([
|
||
'java', '-jar', str(BACKEND_JAR),
|
||
'--spring.profiles.active=prod'
|
||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
||
update_status['progress'] = 80
|
||
|
||
# 8. 重载 Nginx
|
||
update_status['message'] = '重载 Nginx...'
|
||
subprocess.run(['nginx', '-s', 'reload'], check=True)
|
||
|
||
update_status['progress'] = 85
|
||
|
||
# 9. 健康检查
|
||
update_status['message'] = '健康检查...'
|
||
logger.info("等待后端服务启动...")
|
||
|
||
healthy = False
|
||
for i in range(30):
|
||
try:
|
||
response = requests.get(f'{BACKEND_URL}/api/system/health', timeout=2)
|
||
if response.status_code == 200:
|
||
healthy = True
|
||
break
|
||
except:
|
||
pass
|
||
time.sleep(1)
|
||
|
||
if not healthy:
|
||
logger.error("健康检查失败,开始回滚...")
|
||
update_status['message'] = '健康检查失败,回滚中...'
|
||
restore_backup(backup_dir)
|
||
|
||
# 重启后端
|
||
subprocess.run(['pkill', '-f', 'java -jar'], check=False)
|
||
time.sleep(1)
|
||
subprocess.Popen([
|
||
'java', '-jar', str(BACKEND_JAR),
|
||
'--spring.profiles.active=prod'
|
||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
||
subprocess.run(['nginx', '-s', 'reload'], check=True)
|
||
|
||
raise Exception("健康检查失败,已回滚到旧版本")
|
||
|
||
update_status['progress'] = 100
|
||
update_status['message'] = f'更新成功:{tag}'
|
||
logger.info(f"更新成功:{tag}")
|
||
|
||
# 清理临时文件
|
||
if download_path.exists():
|
||
download_path.unlink()
|
||
if extract_dir.exists():
|
||
shutil.rmtree(extract_dir)
|
||
|
||
except Exception as e:
|
||
logger.error(f"更新失败: {e}")
|
||
update_status['error'] = str(e)
|
||
update_status['message'] = f'更新失败: {str(e)}'
|
||
finally:
|
||
update_status['updating'] = False
|
||
|
||
|
||
# ==================== API 路由 ====================
|
||
|
||
@app.route('/health', methods=['GET'])
|
||
def health():
|
||
"""健康检查"""
|
||
return jsonify({'code': 0, 'data': 'ok', 'message': 'success'})
|
||
|
||
|
||
@app.route('/version', methods=['GET'])
|
||
def version():
|
||
"""获取当前版本"""
|
||
try:
|
||
if VERSION_FILE.exists():
|
||
with open(VERSION_FILE) as f:
|
||
data = json.load(f)
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': {
|
||
'version': data.get('version', 'unknown'),
|
||
'tag': data.get('tag', 'unknown'),
|
||
'buildTime': data.get('buildTime', '')
|
||
},
|
||
'message': 'success'
|
||
})
|
||
else:
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': {
|
||
'version': 'unknown',
|
||
'tag': 'unknown',
|
||
'buildTime': ''
|
||
},
|
||
'message': 'success'
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"获取版本失败: {e}")
|
||
return jsonify({
|
||
'code': 500,
|
||
'data': None,
|
||
'message': str(e)
|
||
}), 500
|
||
|
||
|
||
@app.route('/check', methods=['GET'])
|
||
def check():
|
||
"""检查更新"""
|
||
try:
|
||
current_version = get_current_version()
|
||
release = fetch_latest_release()
|
||
|
||
if not release:
|
||
return jsonify({
|
||
'code': 500,
|
||
'data': None,
|
||
'message': '无法获取 Release 信息'
|
||
}), 500
|
||
|
||
latest_tag = release['tag']
|
||
latest_version = latest_tag.lstrip('v')
|
||
|
||
has_update = compare_versions(latest_version, current_version) > 0
|
||
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': {
|
||
'hasUpdate': has_update,
|
||
'currentVersion': current_version,
|
||
'latestVersion': latest_version,
|
||
'latestTag': latest_tag,
|
||
'releaseNotes': release.get('body', ''),
|
||
'publishedAt': release.get('published_at', ''),
|
||
'prerelease': release.get('prerelease', False)
|
||
},
|
||
'message': 'success'
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"检查更新失败: {e}")
|
||
return jsonify({
|
||
'code': 500,
|
||
'data': None,
|
||
'message': str(e)
|
||
}), 500
|
||
|
||
|
||
@app.route('/update', methods=['POST'])
|
||
def update():
|
||
"""执行更新(需要管理员权限)"""
|
||
|
||
# 权限检查
|
||
if not check_admin_permission(request):
|
||
return jsonify({
|
||
'code': 403,
|
||
'data': None,
|
||
'message': '需要管理员权限'
|
||
}), 403
|
||
|
||
if update_status['updating']:
|
||
return jsonify({
|
||
'code': 409,
|
||
'data': None,
|
||
'message': '正在更新中,请稍后'
|
||
}), 409
|
||
|
||
# 异步执行更新
|
||
thread = Thread(target=perform_update, args=('latest',))
|
||
thread.start()
|
||
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': '更新已启动',
|
||
'message': 'success'
|
||
})
|
||
|
||
|
||
@app.route('/status', methods=['GET'])
|
||
def status():
|
||
"""获取更新状态"""
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': {
|
||
'updating': update_status['updating'],
|
||
'progress': update_status['progress'],
|
||
'message': update_status['message'],
|
||
'error': update_status['error']
|
||
},
|
||
'message': 'success'
|
||
})
|
||
|
||
|
||
@app.route('/logs', methods=['GET'])
|
||
def logs():
|
||
"""获取更新日志(需要管理员权限)"""
|
||
|
||
# 权限检查
|
||
if not check_admin_permission(request):
|
||
return jsonify({
|
||
'code': 403,
|
||
'data': None,
|
||
'message': '需要管理员权限'
|
||
}), 403
|
||
|
||
try:
|
||
if LOG_FILE.exists():
|
||
with open(LOG_FILE) as f:
|
||
lines = f.readlines()
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': ''.join(lines[-1000:]), # 最后1000行
|
||
'message': 'success'
|
||
})
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': '',
|
||
'message': 'success'
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"获取日志失败: {e}")
|
||
return jsonify({
|
||
'code': 500,
|
||
'data': None,
|
||
'message': str(e)
|
||
}), 500
|
||
|
||
|
||
# ==================== 主程序 ====================
|
||
|
||
if __name__ == '__main__':
|
||
logger.info("=" * 50)
|
||
logger.info("PolyHermes 更新服务启动")
|
||
logger.info(f"GitHub 仓库: {GITHUB_REPO}")
|
||
logger.info(f"允许 Pre-release: {ALLOW_PRERELEASE}")
|
||
logger.info(f"当前版本: {get_current_version()}")
|
||
logger.info("=" * 50)
|
||
|
||
# 启动 Flask 服务
|
||
app.run(host='0.0.0.0', port=9090, debug=False)
|