feat: 更新服务 — ZIP下载/备份/回滚/重启
UpdateService:
- 分支感知: 从 base_config 读取 branch, 拼接 archive/{branch}.zip
- 定时检查: 每6h 拉取 version.json 比对版本
- 更新前自动打包备份 (zip, 保留最新5个)
- 保护用户配置: base_config.yaml / plugins/*/config.yaml / data/
- 子进程 pip install 依赖
API:
- GET /api/updates — 更新状态
- POST /api/updates/check — 手动检查
- POST /api/updates/apply — 执行更新
回滚:
- python main.py --rollback — 恢复到最新备份
- 无备份时提示
重启:
- .restart_flag 信号文件 → 优雅退出 → systemd 拉起
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ framework:
|
||||
debug: true
|
||||
name: SenSu
|
||||
version: v0.7.0
|
||||
branch: dev
|
||||
repo_url: https://git.yeij.top/AskaEth/SenSu
|
||||
logging:
|
||||
debug_level_file: true
|
||||
level: DEBUG
|
||||
|
||||
@@ -482,5 +482,49 @@
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29386.802027486
|
||||
},
|
||||
"1daf5e1a": {
|
||||
"plugin_name": "example_plugin",
|
||||
"permissions": [
|
||||
"plugin.example.read",
|
||||
"plugin.example.write",
|
||||
"plugin.example.execute",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29765.65822229
|
||||
},
|
||||
"b2d1dd2e": {
|
||||
"plugin_name": "sentinel",
|
||||
"permissions": [
|
||||
"plugin.sentinel.read",
|
||||
"plugin.sentinel.write",
|
||||
"plugin.network.access",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29765.666844425
|
||||
},
|
||||
"cbc42132": {
|
||||
"plugin_name": "example_plugin",
|
||||
"permissions": [
|
||||
"plugin.example.read",
|
||||
"plugin.example.write",
|
||||
"plugin.example.execute",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 35916.606768589
|
||||
},
|
||||
"2bd9a945": {
|
||||
"plugin_name": "sentinel",
|
||||
"permissions": [
|
||||
"plugin.sentinel.read",
|
||||
"plugin.sentinel.write",
|
||||
"plugin.network.access",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 35916.627558381
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ commands:
|
||||
permissions:
|
||||
- framework.command.test
|
||||
source: internal
|
||||
last_updated: 29386.77279457
|
||||
last_updated: 35916.559043798
|
||||
plugin_commands:
|
||||
example_plugin:
|
||||
echo: *id001
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
http_port: 4200
|
||||
last_updated: 29386.797962851
|
||||
last_updated: 35916.617063641
|
||||
plugin_routes:
|
||||
example_plugin:
|
||||
- methods:
|
||||
|
||||
@@ -12,6 +12,8 @@ from services.pyenv_manager import PyEnvManager
|
||||
from services.proxy_service import ProxyService
|
||||
from services.web_panel.utils.system_info import SystemInfoCollector
|
||||
from services.sensu_db import SenSuDB
|
||||
from services.update_service import UpdateService
|
||||
from services.update_service import UpdateService
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@@ -223,6 +225,13 @@ class SenSuFramework:
|
||||
await proxy_service.start()
|
||||
self.service_manager.register_service("proxy", proxy_service)
|
||||
logger.info("✅ 项目引擎+反向代理就绪")
|
||||
|
||||
logger.info("> 初始化 更新检测 中...")
|
||||
update_service = UpdateService(self.service_manager)
|
||||
await update_service.start()
|
||||
self.service_manager.register_service("update", update_service)
|
||||
logger.info("✅ 更新检测就绪")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"引擎/代理初始化跳过: {e}")
|
||||
|
||||
@@ -355,7 +364,14 @@ class SenSuFramework:
|
||||
# 特殊终端,添加命令行输入处理
|
||||
tui = self.service_manager.get_service("tui")
|
||||
if self.headless:
|
||||
import os as _os
|
||||
restart_flag = _os.path.join(_os.path.dirname(__file__), ".restart_flag")
|
||||
while self.is_running:
|
||||
if _os.path.exists(restart_flag):
|
||||
logger.info("🔄 检测到重启标记, 执行优雅重启...")
|
||||
_os.remove(restart_flag)
|
||||
await self._restart_framework()
|
||||
break
|
||||
try: await asyncio.sleep(1)
|
||||
except asyncio.CancelledError: break
|
||||
elif not hasattr(tui, 'tui_app'):
|
||||
@@ -377,6 +393,13 @@ class SenSuFramework:
|
||||
except Exception as e:
|
||||
logger.error(f"运行框架主循环时出错: {str(e)}", exc_info=True)
|
||||
await self._safe_shutdown()
|
||||
|
||||
async def _restart_framework(self):
|
||||
"""优雅重启: 关闭所有服务后退出 (由 systemd/supervisor 自动拉起)"""
|
||||
logger.info("🔄 执行优雅重启...")
|
||||
await self.shutdown()
|
||||
import sys as _sys
|
||||
_sys.exit(0)
|
||||
|
||||
async def _run_cli_mode(self):
|
||||
"""运行命令行模式"""
|
||||
@@ -429,6 +452,38 @@ class SenSuFramework:
|
||||
logger.error(f"关闭框架时出错: {str(e)}", exc_info=True)
|
||||
await self._safe_shutdown()
|
||||
|
||||
def rollback():
|
||||
"""回滚到最新备份"""
|
||||
import zipfile, shutil, os, glob
|
||||
backup_dir = os.path.join(os.path.dirname(__file__), "data", "backups")
|
||||
if not os.path.isdir(backup_dir):
|
||||
print("📭 没有备份目录")
|
||||
return
|
||||
backups = sorted(glob.glob(os.path.join(backup_dir, "SenSu_backup_*.zip")), key=os.path.getmtime, reverse=True)
|
||||
if not backups:
|
||||
print("📭 没有可用的备份包")
|
||||
return
|
||||
latest = backups[0]
|
||||
name = os.path.basename(latest)
|
||||
print(f"📦 回滚到: {name}")
|
||||
try:
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with zipfile.ZipFile(latest, "r") as zf:
|
||||
zf.extractall(tmp)
|
||||
project_root = os.path.dirname(__file__)
|
||||
for item in os.listdir(tmp):
|
||||
src = os.path.join(tmp, item)
|
||||
dst = os.path.join(project_root, item)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
print("✅ 回滚完成, 请重启 SenSu")
|
||||
except Exception as e:
|
||||
print(f"❌ 回滚失败: {e}")
|
||||
|
||||
|
||||
async def main(headless=False):
|
||||
"""主函数"""
|
||||
framework = SenSuFramework(headless=headless)
|
||||
@@ -466,7 +521,13 @@ if __name__ == "__main__":
|
||||
# 运行主程序
|
||||
parser = argparse.ArgumentParser(description="SenSu")
|
||||
parser.add_argument("--headless", action="store_true")
|
||||
parser.add_argument("--rollback", action="store_true", help="回滚到最新备份")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.rollback:
|
||||
rollback()
|
||||
sys.exit(0)
|
||||
|
||||
asyncio.run(main(headless=args.headless))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""版本更新服务 — 下载 release ZIP + 解压 + 保护配置 + 热重启"""
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL = 3600 * 6 # 6 小时
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
_RESTART_FLAG = _PROJECT_ROOT / ".restart_flag"
|
||||
|
||||
# 更新时跳过的路径 (用户配置/数据)
|
||||
_PRESERVE_PATHS = {
|
||||
"config/framework/base_config.yaml",
|
||||
"config/permissions/",
|
||||
"data/",
|
||||
"plugins/*/config.yaml",
|
||||
"logs/",
|
||||
".restart_flag",
|
||||
}
|
||||
|
||||
|
||||
def _parse_ver(v: str) -> tuple:
|
||||
v = v.strip().lstrip("vV")
|
||||
try:
|
||||
return tuple(int(p) for p in v.split(".")[:3])
|
||||
except Exception:
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
_BACKUP_SKIP = {"data/", "logs/", "__pycache__/", ".git/", "node_modules/", ".venv/", "venv/",
|
||||
"*.pyc", "*.pyo", ".restart_flag"}
|
||||
|
||||
|
||||
def _backup_current(dest_path: Path):
|
||||
"""将当前项目打包为 ZIP (跳过数据/日志/缓存)"""
|
||||
from fnmatch import fnmatch
|
||||
with zipfile.ZipFile(str(dest_path), "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(str(_PROJECT_ROOT)):
|
||||
# 跳过不需要备份的目录
|
||||
dirs[:] = [d for d in dirs if not any(
|
||||
fnmatch(d + "/", p.rstrip("/") + "/") or fnmatch(d, p.rstrip("/"))
|
||||
for p in _BACKUP_SKIP if "/" in p or p.endswith("/")
|
||||
) and d not in {p.rstrip("/") for p in _BACKUP_SKIP if not any(c in p for c in "*?[")}]
|
||||
for f in files:
|
||||
rel = os.path.relpath(os.path.join(root, f), str(_PROJECT_ROOT))
|
||||
if any(fnmatch(f, p) or fnmatch(rel, p) for p in _BACKUP_SKIP):
|
||||
continue
|
||||
zf.write(os.path.join(root, f), rel)
|
||||
|
||||
|
||||
def _should_preserve(rel_path: str) -> bool:
|
||||
"""检查路径是否应跳过 (保护用户数据)"""
|
||||
from fnmatch import fnmatch
|
||||
for pattern in _PRESERVE_PATHS:
|
||||
if fnmatch(rel_path, pattern) or rel_path.startswith(pattern.rstrip("/") + "/"):
|
||||
return True
|
||||
# 匹配目录前缀
|
||||
if rel_path == pattern.rstrip("/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class UpdateService:
|
||||
def __init__(self, service_manager):
|
||||
self.sm = service_manager
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self.status: dict = {"state": "idle", "progress": 0, "error": "",
|
||||
"last_check": 0, "update_available": False}
|
||||
# 从配置读取分支和仓库地址
|
||||
try:
|
||||
init = self.sm.get_service("init")
|
||||
cfg = init.get_config("base").get("framework", {})
|
||||
except Exception:
|
||||
cfg = {}
|
||||
self.branch = cfg.get("branch", "main")
|
||||
self.repo_url = cfg.get("repo_url", "https://git.yeij.top/AskaEth/SenSu").rstrip("/")
|
||||
self.repo_name = self.repo_url.rstrip("/").split("/")[-1]
|
||||
# ZIP 下载地址: Gitea 格式
|
||||
self.download_url = f"{self.repo_url}/archive/{self.branch}.zip"
|
||||
# 版本检查 API
|
||||
self.version_url = f"{self.repo_url}/raw/branch/{self.branch}/version.json"
|
||||
|
||||
async def start(self):
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info(f"🔄 更新服务已启动 — 分支: {self.branch}, 仓库: {self.repo_url}")
|
||||
|
||||
async def _loop(self):
|
||||
await asyncio.sleep(30) # 等框架就绪
|
||||
while self._running:
|
||||
await self._check_version()
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
async def _check_version(self):
|
||||
"""检查是否有新版本 (非阻塞)"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
data = await loop.run_in_executor(None, self._sync_fetch_json, self.version_url)
|
||||
self.status["last_check"] = time.time()
|
||||
if not data:
|
||||
return
|
||||
remote_fw = data.get("framework", {})
|
||||
remote_ver = remote_fw.get("version", "")
|
||||
if not remote_ver:
|
||||
return
|
||||
current = self._current_version()
|
||||
if _parse_ver(remote_ver) > _parse_ver(current):
|
||||
self.status["update_available"] = True
|
||||
self.status["latest_version"] = remote_ver
|
||||
self.status["current_version"] = current
|
||||
self.status["download_url"] = self.download_url
|
||||
logger.info(f"🔄 新版本可用: {remote_ver} (当前: {current})")
|
||||
except Exception as e:
|
||||
logger.debug(f"版本检查失败: {e}")
|
||||
|
||||
def _sync_fetch_json(self, url: str) -> Optional[dict]:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "SenSu-Update/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _current_version(self) -> str:
|
||||
try:
|
||||
init = self.sm.get_service("init")
|
||||
return init.get_config("base").get("framework", {}).get("version", "0.0.0").lstrip("vV")
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
# ── 执行更新 ──
|
||||
|
||||
async def apply_update(self) -> dict:
|
||||
"""下载并应用更新 (在线程池中执行磁盘操作)"""
|
||||
if self.status["state"] == "downloading":
|
||||
return {"ok": False, "error": "更新已在进行中"}
|
||||
|
||||
self.status = {"state": "downloading", "progress": 0, "error": "",
|
||||
"last_check": self.status.get("last_check", 0),
|
||||
"update_available": self.status.get("update_available", False)}
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, self._sync_apply)
|
||||
if result.get("ok"):
|
||||
self.status = {"state": "done", "progress": 100, "error": "",
|
||||
"last_check": time.time(), "update_available": False}
|
||||
# 写重启标记
|
||||
_RESTART_FLAG.touch()
|
||||
logger.info("✅ 更新完成, 框架将在下次主循环检测后重启")
|
||||
else:
|
||||
self.status["state"] = "error"
|
||||
self.status["error"] = result.get("error", "未知错误")
|
||||
return result
|
||||
except Exception as e:
|
||||
self.status["state"] = "error"
|
||||
self.status["error"] = str(e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def _sync_apply(self) -> dict:
|
||||
"""同步执行更新 (在线程池中)"""
|
||||
tmp_zip = None
|
||||
tmp_dir = None
|
||||
try:
|
||||
url = self.download_url
|
||||
logger.info(f"📥 下载更新包: {url}")
|
||||
|
||||
# 0. 备份当前版本
|
||||
self.status["progress"] = 2
|
||||
backup_name = f"SenSu_backup_{self._current_version()}_{time.strftime('%Y%m%d_%H%M%S')}.zip"
|
||||
backup_dir = _PROJECT_ROOT / "data" / "backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / backup_name
|
||||
_backup_current(backup_path)
|
||||
logger.info(f"📦 已备份到: {backup_path}")
|
||||
# 清理旧备份 (保留最新 5 个)
|
||||
backups = sorted(backup_dir.glob("SenSu_backup_*.zip"), key=os.path.getmtime, reverse=True)
|
||||
for old in backups[5:]:
|
||||
old.unlink()
|
||||
|
||||
# 1. 下载 ZIP
|
||||
self.status["progress"] = 10
|
||||
tmp_zip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "SenSu-Update/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
shutil.copyfileobj(resp, tmp_zip)
|
||||
tmp_zip.close()
|
||||
self.status["progress"] = 40
|
||||
|
||||
# 2. 解压到临时目录
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
with zipfile.ZipFile(tmp_zip.name, "r") as zf:
|
||||
zf.extractall(tmp_dir)
|
||||
self.status["progress"] = 60
|
||||
|
||||
# 3. Gitea zip 内层目录: 通常是 repo_name-branch/
|
||||
extracted_root = tmp_dir
|
||||
contents = os.listdir(tmp_dir)
|
||||
if len(contents) == 1:
|
||||
inner = os.path.join(tmp_dir, contents[0])
|
||||
if os.path.isdir(inner):
|
||||
extracted_root = inner
|
||||
|
||||
# 4. 覆盖文件 (跳过用户配置)
|
||||
for root, dirs, files in os.walk(extracted_root):
|
||||
rel = os.path.relpath(root, extracted_root)
|
||||
if rel == ".":
|
||||
rel = ""
|
||||
for f in files:
|
||||
fp = os.path.join(rel, f) if rel else f
|
||||
if _should_preserve(fp):
|
||||
continue
|
||||
src = os.path.join(root, f)
|
||||
dst = os.path.join(str(_PROJECT_ROOT), fp)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
self.status["progress"] = 80
|
||||
|
||||
# 5. 安装依赖 (子进程)
|
||||
req_file = str(_PROJECT_ROOT / "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
r = subprocess.run(
|
||||
["pip", "install", "-r", req_file, "--break-system-packages"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
cwd=str(_PROJECT_ROOT),
|
||||
)
|
||||
if r.returncode != 0:
|
||||
logger.warning(f"依赖安装警告: {r.stderr[-300:]}")
|
||||
|
||||
self.status["progress"] = 100
|
||||
return {"ok": True, "msg": "更新完成, 框架将重启"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新失败: {e}")
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
if tmp_zip and os.path.exists(tmp_zip.name):
|
||||
os.unlink(tmp_zip.name)
|
||||
if tmp_dir and os.path.exists(tmp_dir):
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
async def check_now(self):
|
||||
await self._check_version()
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
@@ -40,6 +40,9 @@ def setup_routes(app, prefix=''):
|
||||
app.router.add_get(f'{prefix}/api/framework', panel_auth(get_framework))
|
||||
app.router.add_get(f'{prefix}/api/system', panel_auth(get_system))
|
||||
app.router.add_get(f'{prefix}/api/system/ws', _ws_auth_wrapper(sys_ws_handler))
|
||||
app.router.add_get(f'{prefix}/api/updates', panel_auth(get_updates))
|
||||
app.router.add_post(f'{prefix}/api/updates/check', panel_auth(check_now))
|
||||
app.router.add_post(f'{prefix}/api/updates/apply', panel_auth(apply_update))
|
||||
logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)")
|
||||
|
||||
def _read_version():
|
||||
@@ -128,3 +131,32 @@ async def sys_ws_handler(req):
|
||||
pass
|
||||
_sys_ws_clients.discard(ws)
|
||||
return ws
|
||||
|
||||
|
||||
async def get_updates(req):
|
||||
"""获取更新状态"""
|
||||
sm = req.app.get("service_manager")
|
||||
us = sm.get_service("update") if sm else None
|
||||
if not us:
|
||||
return web.json_response({"error": "更新服务未就绪"}, status=503)
|
||||
return web.json_response(us.status)
|
||||
|
||||
|
||||
async def check_now(req):
|
||||
"""手动触发更新检查"""
|
||||
sm = req.app.get("service_manager")
|
||||
us = sm.get_service("update") if sm else None
|
||||
if not us:
|
||||
return web.json_response({"error": "更新服务未就绪"}, status=503)
|
||||
await us.check_now()
|
||||
return web.json_response(us.status)
|
||||
|
||||
|
||||
async def apply_update(req):
|
||||
"""执行更新"""
|
||||
sm = req.app.get("service_manager")
|
||||
us = sm.get_service("update") if sm else None
|
||||
if not us:
|
||||
return web.json_response({"error": "更新服务未就绪"}, status=503)
|
||||
result = await us.apply_update()
|
||||
return web.json_response(result)
|
||||
|
||||
Reference in New Issue
Block a user