#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ZSGLPT Update-Agent(宿主机运行) 职责: - 定期检查 Git 远端是否有新版本(写入 data/update/status.json) - 接收后台写入的 data/update/request.json 请求(check/update) - 执行 git reset --hard origin/ + docker compose build/up - 更新前备份数据库 data/app_data.db - 写入 data/update/result.json 与 data/update/jobs/.log 仅使用标准库,便于在宿主机直接运行。 """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import time import urllib.error import urllib.request import uuid from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Dict, Optional, Tuple def ts_str() -> str: return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def json_load(path: Path) -> Tuple[dict, Optional[str]]: try: with open(path, "r", encoding="utf-8") as f: return dict(json.load(f) or {}), None except FileNotFoundError: return {}, None except Exception as e: return {}, f"{type(e).__name__}: {e}" def json_dump_atomic(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(f"{path.suffix}.tmp.{os.getpid()}.{int(time.time() * 1000)}") with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) def sanitize_job_id(value: object) -> str: import re text = str(value or "").strip() if not text: return f"job_{uuid.uuid4().hex[:8]}" if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", text): return f"job_{uuid.uuid4().hex[:8]}" return text def _run(cmd: list[str], *, cwd: Path, log_fp, env: Optional[dict] = None, check: bool = True) -> subprocess.CompletedProcess: log_fp.write(f"[{ts_str()}] $ {' '.join(cmd)}\n") log_fp.flush() merged_env = os.environ.copy() if env: merged_env.update(env) return subprocess.run( cmd, cwd=str(cwd), env=merged_env, stdout=log_fp, stderr=log_fp, text=True, check=check, ) def _git_rev_parse(ref: str, *, cwd: Path) -> str: out = subprocess.check_output(["git", "rev-parse", ref], cwd=str(cwd), text=True).strip() return out def _git_is_dirty(*, cwd: Path) -> bool: out = subprocess.check_output(["git", "status", "--porcelain"], cwd=str(cwd), text=True) return bool(out.strip()) def _compose_cmd() -> list[str]: # 优先使用 docker compose(v2) try: subprocess.check_output(["docker", "compose", "version"], stderr=subprocess.STDOUT, text=True) return ["docker", "compose"] except Exception: return ["docker-compose"] def _http_healthcheck(url: str, *, timeout: float = 5.0) -> Tuple[bool, str]: try: req = urllib.request.Request(url, headers={"User-Agent": "zsglpt-update-agent/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: code = int(getattr(resp, "status", 200) or 200) if 200 <= code < 400: return True, f"HTTP {code}" return False, f"HTTP {code}" except urllib.error.HTTPError as e: return False, f"HTTPError {e.code}" except Exception as e: return False, f"{type(e).__name__}: {e}" @dataclass class Paths: repo_dir: Path data_dir: Path update_dir: Path status_path: Path request_path: Path result_path: Path jobs_dir: Path def build_paths(repo_dir: Path, data_dir: Optional[Path] = None) -> Paths: repo_dir = repo_dir.resolve() data_dir = (data_dir or (repo_dir / "data")).resolve() update_dir = data_dir / "update" return Paths( repo_dir=repo_dir, data_dir=data_dir, update_dir=update_dir, status_path=update_dir / "status.json", request_path=update_dir / "request.json", result_path=update_dir / "result.json", jobs_dir=update_dir / "jobs", ) def ensure_dirs(paths: Paths) -> None: paths.jobs_dir.mkdir(parents=True, exist_ok=True) def check_updates(*, paths: Paths, branch: str, log_fp=None) -> dict: env = {"GIT_TERMINAL_PROMPT": "0"} err = "" local = "" remote = "" dirty = False try: if log_fp: _run(["git", "fetch", "origin", branch], cwd=paths.repo_dir, log_fp=log_fp, env=env) else: subprocess.run(["git", "fetch", "origin", branch], cwd=str(paths.repo_dir), env={**os.environ, **env}, check=True) local = _git_rev_parse("HEAD", cwd=paths.repo_dir) remote = _git_rev_parse(f"origin/{branch}", cwd=paths.repo_dir) dirty = _git_is_dirty(cwd=paths.repo_dir) except Exception as e: err = f"{type(e).__name__}: {e}" update_available = bool(local and remote and local != remote) if not err else False return { "branch": branch, "checked_at": ts_str(), "local_commit": local, "remote_commit": remote, "update_available": update_available, "dirty": dirty, "error": err, } def backup_db(*, paths: Paths, log_fp, keep: int = 20) -> str: db_path = paths.data_dir / "app_data.db" backups_dir = paths.data_dir / "backups" backups_dir.mkdir(parents=True, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_path = backups_dir / f"app_data.db.{stamp}.bak" if db_path.exists(): log_fp.write(f"[{ts_str()}] backup db: {db_path} -> {backup_path}\n") log_fp.flush() shutil.copy2(db_path, backup_path) else: log_fp.write(f"[{ts_str()}] backup skipped: db not found: {db_path}\n") log_fp.flush() # 简单保留策略:按文件名排序,保留最近 keep 个 try: items = sorted([p for p in backups_dir.glob("app_data.db.*.bak") if p.is_file()], key=lambda p: p.name) if len(items) > keep: for p in items[: len(items) - keep]: try: p.unlink() except Exception: pass except Exception: pass return str(backup_path) def write_result(paths: Paths, data: dict) -> None: json_dump_atomic(paths.result_path, data) def consume_request(paths: Paths) -> Tuple[dict, Optional[str]]: data, err = json_load(paths.request_path) if err: # 避免解析失败导致死循环:将坏文件移走 try: bad_name = f"request.bad.{datetime.now().strftime('%Y%m%d_%H%M%S')}.{uuid.uuid4().hex[:6]}.json" bad_path = paths.update_dir / bad_name paths.request_path.rename(bad_path) except Exception: try: paths.request_path.unlink(missing_ok=True) # type: ignore[arg-type] except Exception: pass return {}, err if not data: return {}, None try: paths.request_path.unlink(missing_ok=True) # type: ignore[arg-type] except Exception: try: os.remove(paths.request_path) except Exception: pass return data, None def handle_update_job(*, paths: Paths, branch: str, health_url: str, job_id: str, requested_by: str) -> None: ensure_dirs(paths) log_path = paths.jobs_dir / f"{job_id}.log" with open(log_path, "a", encoding="utf-8") as log_fp: log_fp.write(f"[{ts_str()}] job start: {job_id}, branch={branch}, by={requested_by}\n") log_fp.flush() result: Dict[str, object] = { "job_id": job_id, "action": "update", "status": "running", "stage": "start", "message": "", "started_at": ts_str(), "finished_at": None, "duration_seconds": None, "requested_by": requested_by, "branch": branch, "from_commit": None, "to_commit": None, "backup_db": None, "health_url": health_url, "health_ok": None, "health_message": None, "error": "", } write_result(paths, result) start_ts = time.time() try: result["stage"] = "backup" result["message"] = "备份数据库" write_result(paths, result) result["backup_db"] = backup_db(paths=paths, log_fp=log_fp) result["stage"] = "git_fetch" result["message"] = "拉取远端代码" write_result(paths, result) _run(["git", "fetch", "origin", branch], cwd=paths.repo_dir, log_fp=log_fp, env={"GIT_TERMINAL_PROMPT": "0"}) from_commit = _git_rev_parse("HEAD", cwd=paths.repo_dir) result["from_commit"] = from_commit result["stage"] = "git_reset" result["message"] = f"切换到 origin/{branch}" write_result(paths, result) _run(["git", "reset", "--hard", f"origin/{branch}"], cwd=paths.repo_dir, log_fp=log_fp, env={"GIT_TERMINAL_PROMPT": "0"}) to_commit = _git_rev_parse("HEAD", cwd=paths.repo_dir) result["to_commit"] = to_commit compose = _compose_cmd() result["stage"] = "docker_build" result["message"] = "构建容器镜像" write_result(paths, result) _run([*compose, "build"], cwd=paths.repo_dir, log_fp=log_fp) result["stage"] = "docker_up" result["message"] = "重建并启动服务" write_result(paths, result) _run([*compose, "up", "-d", "--force-recreate"], cwd=paths.repo_dir, log_fp=log_fp) result["stage"] = "health_check" result["message"] = "健康检查" write_result(paths, result) ok = False health_msg = "" deadline = time.time() + 180 while time.time() < deadline: ok, health_msg = _http_healthcheck(health_url, timeout=5.0) if ok: break time.sleep(3) result["health_ok"] = ok result["health_message"] = health_msg if not ok: raise RuntimeError(f"healthcheck failed: {health_msg}") result["status"] = "success" result["stage"] = "done" result["message"] = "更新完成" except Exception as e: result["status"] = "failed" result["error"] = f"{type(e).__name__}: {e}" result["stage"] = result.get("stage") or "failed" result["message"] = "更新失败" log_fp.write(f"[{ts_str()}] ERROR: {result['error']}\n") log_fp.flush() finally: result["finished_at"] = ts_str() result["duration_seconds"] = int(time.time() - start_ts) write_result(paths, result) # 更新 status(成功/失败都尽量写一份最新状态) try: status = check_updates(paths=paths, branch=branch, log_fp=log_fp) json_dump_atomic(paths.status_path, status) except Exception: pass log_fp.write(f"[{ts_str()}] job end: {job_id}\n") log_fp.flush() def handle_check_job(*, paths: Paths, branch: str, job_id: str, requested_by: str) -> None: ensure_dirs(paths) log_path = paths.jobs_dir / f"{job_id}.log" with open(log_path, "a", encoding="utf-8") as log_fp: log_fp.write(f"[{ts_str()}] job start: {job_id}, action=check, branch={branch}, by={requested_by}\n") log_fp.flush() status = check_updates(paths=paths, branch=branch, log_fp=log_fp) json_dump_atomic(paths.status_path, status) log_fp.write(f"[{ts_str()}] job end: {job_id}\n") log_fp.flush() def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description="ZSGLPT Update-Agent (host)") parser.add_argument("--repo-dir", default=".", help="部署仓库目录(包含 docker-compose.yml)") parser.add_argument("--data-dir", default="", help="数据目录(默认 /data)") parser.add_argument("--branch", default="master", help="允许更新的分支名(默认 master)") parser.add_argument("--health-url", default="http://127.0.0.1:51232/", help="更新后健康检查URL") parser.add_argument("--check-interval-seconds", type=int, default=300, help="自动检查更新间隔(秒)") parser.add_argument("--poll-seconds", type=int, default=5, help="轮询 request.json 的间隔(秒)") args = parser.parse_args(argv) repo_dir = Path(args.repo_dir).resolve() if not (repo_dir / "docker-compose.yml").exists(): print(f"[fatal] docker-compose.yml not found in {repo_dir}", file=sys.stderr) return 2 if not (repo_dir / ".git").exists(): print(f"[fatal] .git not found in {repo_dir} (need git repo)", file=sys.stderr) return 2 data_dir = Path(args.data_dir).resolve() if args.data_dir else None paths = build_paths(repo_dir, data_dir=data_dir) ensure_dirs(paths) last_check_ts = 0.0 check_interval = max(30, int(args.check_interval_seconds)) poll_seconds = max(2, int(args.poll_seconds)) branch = str(args.branch or "master").strip() health_url = str(args.health_url or "").strip() # 启动时先写一次状态,便于后台立即看到 try: status = check_updates(paths=paths, branch=branch) json_dump_atomic(paths.status_path, status) last_check_ts = time.time() except Exception: pass while True: try: # 1) 优先处理 request req, err = consume_request(paths) if err: # request 文件损坏:写入 result 便于后台看到 write_result( paths, { "job_id": f"badreq_{uuid.uuid4().hex[:8]}", "action": "unknown", "status": "failed", "stage": "parse_request", "message": "request.json 解析失败", "error": err, "started_at": ts_str(), "finished_at": ts_str(), }, ) elif req: action = str(req.get("action") or "").strip().lower() job_id = sanitize_job_id(req.get("job_id")) requested_by = str(req.get("requested_by") or "") # 只允许固定分支,避免被注入/误操作 if action not in ("check", "update"): write_result( paths, { "job_id": job_id, "action": action, "status": "failed", "stage": "validate", "message": "不支持的 action", "error": f"unsupported action: {action}", "started_at": ts_str(), "finished_at": ts_str(), }, ) elif action == "check": handle_check_job(paths=paths, branch=branch, job_id=job_id, requested_by=requested_by) else: handle_update_job( paths=paths, branch=branch, health_url=health_url, job_id=job_id, requested_by=requested_by, ) last_check_ts = time.time() # 2) 周期性 check now = time.time() if now - last_check_ts >= check_interval: try: status = check_updates(paths=paths, branch=branch) json_dump_atomic(paths.status_path, status) except Exception: pass last_check_ts = now time.sleep(poll_seconds) except KeyboardInterrupt: return 0 except Exception: time.sleep(2) if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))