From 2ab46a6ac32aea1c0ab45f7febe6d89e8fe1e412 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 4 Oct 2026 07:11:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AD=E6=8E=A7=20Bridge=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=BF=9C=E7=A8=8B=E5=BF=AB=E9=80=9F=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=9A/api/hub/version=20=E4=B8=8E=20/api/hub/update?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/hub/hub_bridge_register.py | 21 +++++ lib/instance/instance_update_lib.py | 124 ++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 lib/instance/instance_update_lib.py diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index d2a7f12..2cf2fcc 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -388,6 +388,27 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None: finally: conn.close() + @app.route("/api/hub/version", methods=["GET"]) + def hub_version(): + """中控探测客户端当前 git 版本/在线.""" + if not hub_bridge_token_ok(request.headers): + return jsonify({"ok": False, "msg": "未授权"}), 401 + from lib.instance.instance_update_lib import read_git_version + + ver = read_git_version() + ver["online"] = True + return jsonify(ver) + + @app.route("/api/hub/update", methods=["POST"]) + def hub_update(): + """中控远程快速更新:git pull + pm2 restart(异步).""" + if not hub_bridge_token_ok(request.headers): + return jsonify({"ok": False, "msg": "未授权"}), 401 + from lib.instance.instance_update_lib import schedule_pull_and_restart + + out = schedule_pull_and_restart(delay_seconds=1.5) + return jsonify(out), (200 if out.get("ok") else 400) + @app.route("/api/hub/backup/list", methods=["GET"]) def hub_backup_list(): if not hub_bridge_token_ok(request.headers): diff --git a/lib/instance/instance_update_lib.py b/lib/instance/instance_update_lib.py new file mode 100644 index 0000000..041a8dd --- /dev/null +++ b/lib/instance/instance_update_lib.py @@ -0,0 +1,124 @@ +"""客户端远程快速更新:git pull + pm2 restart(供中控 Bridge 触发).""" +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any + +from lib.paths import REPO_ROOT + +_update_lock = threading.Lock() +_update_pending_until = 0.0 + + +def _repo_root() -> Path: + env_root = (os.getenv("REPO") or "").strip() + if env_root: + return Path(env_root) + return Path(REPO_ROOT) + + +def _run_git(args: list[str], *, cwd: Path) -> tuple[bool, str]: + try: + proc = subprocess.run( + ["git", *args], + cwd=str(cwd), + capture_output=True, + text=True, + timeout=15, + ) + except FileNotFoundError: + return False, "未找到 git" + except subprocess.TimeoutExpired: + return False, "git 超时" + except Exception as e: + return False, str(e) or "git 失败" + out = (proc.stdout or "").strip() or (proc.stderr or "").strip() + return proc.returncode == 0, out + + +def read_git_version() -> dict[str, Any]: + """读取当前部署目录 git 版本信息.""" + root = _repo_root() + out: dict[str, Any] = { + "ok": True, + "repo": str(root), + "sha": "", + "short_sha": "", + "branch": "", + "dirty": False, + "msg": "", + } + if not (root / ".git").exists() and not (root / ".git").is_file(): + # worktree 时 .git 可能是文件 + out["ok"] = False + out["msg"] = "非 git 仓库" + return out + ok, sha = _run_git(["rev-parse", "HEAD"], cwd=root) + if not ok or not sha: + out["ok"] = False + out["msg"] = sha or "无法读取 HEAD" + return out + out["sha"] = sha + out["short_sha"] = sha[:7] + ok_b, branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) + if ok_b and branch: + out["branch"] = branch + ok_d, dirty = _run_git(["status", "--porcelain"], cwd=root) + if ok_d: + out["dirty"] = bool(dirty.strip()) + return out + + +def schedule_pull_and_restart(*, delay_seconds: float = 1.5) -> dict[str, Any]: + """延迟执行 deploy/pull_and_restart.sh,便于 Bridge 响应先返回.""" + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持远程更新", "deferred": False} + root = _repo_root() + script = root / "deploy" / "pull_and_restart.sh" + if not script.is_file(): + return {"ok": False, "msg": f"缺少更新脚本: {script}", "deferred": False} + + global _update_pending_until + now = time.time() + with _update_lock: + if now < _update_pending_until: + return {"ok": False, "msg": "已有更新任务进行中,请稍后再试", "deferred": False} + _update_pending_until = now + 120.0 + + version = read_git_version() + try: + delay = max(0.5, float(delay_seconds)) + cmd = ( + f"sleep {delay} && " + f"cd {shlex.quote(str(root))} && " + f"exec bash {shlex.quote(str(script))}" + ) + subprocess.Popen( + ["bash", "-c", cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + cwd=str(root), + ) + return { + "ok": True, + "msg": "更新已受理,即将 git pull 并重启", + "deferred": True, + "before_sha": version.get("sha") or "", + "before_short_sha": version.get("short_sha") or "", + "branch": version.get("branch") or "", + } + except FileNotFoundError: + with _update_lock: + _update_pending_until = 0.0 + return {"ok": False, "msg": "未找到 bash", "deferred": False} + except Exception as e: + with _update_lock: + _update_pending_until = 0.0 + return {"ok": False, "msg": str(e) or "触发更新失败", "deferred": False}