From c20417ff93f75a088fe043e11799fc491c467b2f Mon Sep 17 00:00:00 2001 From: qinglong Date: Sat, 13 Jun 2026 20:32:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=9C=8D=E5=8A=A1=20?= =?UTF-8?q?=E2=80=94=20ZIP=E4=B8=8B=E8=BD=BD/=E5=A4=87=E4=BB=BD/=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A/=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/framework/base_config.yaml.example | 2 + config/permissions/pending_requests.json | 44 ++++ config/plugins/commands.yaml | 2 +- config/services/network_routes.yaml | 2 +- main.py | 61 +++++ services/update_service.py | 260 ++++++++++++++++++++++ services/web_panel/routes/status.py | 32 +++ 7 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 services/update_service.py diff --git a/config/framework/base_config.yaml.example b/config/framework/base_config.yaml.example index b3d3dfa..73f4664 100644 --- a/config/framework/base_config.yaml.example +++ b/config/framework/base_config.yaml.example @@ -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 diff --git a/config/permissions/pending_requests.json b/config/permissions/pending_requests.json index ad987f5..657a338 100644 --- a/config/permissions/pending_requests.json +++ b/config/permissions/pending_requests.json @@ -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 } } \ No newline at end of file diff --git a/config/plugins/commands.yaml b/config/plugins/commands.yaml index 8305f77..6e214de 100644 --- a/config/plugins/commands.yaml +++ b/config/plugins/commands.yaml @@ -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 diff --git a/config/services/network_routes.yaml b/config/services/network_routes.yaml index ca444e7..67f9a8a 100644 --- a/config/services/network_routes.yaml +++ b/config/services/network_routes.yaml @@ -1,5 +1,5 @@ http_port: 4200 -last_updated: 29386.797962851 +last_updated: 35916.617063641 plugin_routes: example_plugin: - methods: diff --git a/main.py b/main.py index 7e5da11..32a4244 100644 --- a/main.py +++ b/main.py @@ -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: diff --git a/services/update_service.py b/services/update_service.py new file mode 100644 index 0000000..3bbb2e6 --- /dev/null +++ b/services/update_service.py @@ -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() diff --git a/services/web_panel/routes/status.py b/services/web_panel/routes/status.py index 12bfc3d..11f4e3b 100644 --- a/services/web_panel/routes/status.py +++ b/services/web_panel/routes/status.py @@ -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)