#!/usr/bin/env python3 """chv-syncd:客机运行时 sync 常驻(阶段 2 三分之一)。 职责:常驻循环调用 /usr/local/bin/chv-sync 做 Gitea 双向同步, 状态落盘 /run/chv/sync-status.json,socket /run/chv/syncd.sock 接受 ping/status/sync-now。 同步规则一条不改(与 chv-sync.sh 同款,复用它不重写 git 逻辑): - Gitea https://gitea.tose.top 是权威远端;启动 clone 选定仓 最新 HEAD(仍由 cloud-init runcmd 的 chv-sync boot 做; syncd 只在仓已 clone 的前提下循环,仓不存在记 needs-boot 不擅自 clone——boot 须 root 写 credential,syncd 以 chv 身份跑,无权也不该碰)。 - 每轮先 pull --ff-only 再 push;pull 分叉即停住、跳过 push, 状态记 error、绝不 --force、不自动 rebase、不覆盖任何一方。 - 工作树脏时 pull 拒绝,同样记 error 等下一轮,不丢改动。 - push 只推已提交的提交;未提交改动不参与同步。 - token 只走 /home/chv/.git-credentials(0600),syncd 自身不读 guest-env、不打印、不记入状态文件与日志。 以 chv 身份运行。默认每 300 秒一轮(CHV_SYNC_INTERVAL, 下限 60 秒);sync-now 立即触发一轮(轮内重复触发跳过)。 """ import json import os import signal import socket import subprocess import sys import threading import time SYNC_SOCK = "/run/chv/syncd.sock" STATUS_PATH = "/run/chv/sync-status.json" WORKSPACE = "/home/chv/workspace" SYNC_BIN = "/usr/local/bin/chv-sync" DEFAULT_INTERVAL = 300.0 MIN_INTERVAL = 60.0 MAX_REQUEST_BYTES = 65536 def _atomic_write(path, data: bytes): tmp = "%s.tmp.%d" % (path, os.getpid()) with open(tmp, "wb") as fh: fh.write(data) fh.flush() os.fsync(fh.fileno()) os.rename(tmp, path) def _read_json(path): try: with open(path) as fh: return json.load(fh) except (OSError, ValueError): return None def resolve_repo(workspace=WORKSPACE): """WORKSPACE 下唯一的 git 仓库;多个/零个返回 (None, reason)。""" try: names = sorted(os.listdir(workspace)) except OSError as exc: return None, "workspace unreadable: %s" % exc found = [os.path.join(workspace, name) for name in names if os.path.isdir(os.path.join(workspace, name, ".git"))] if len(found) == 1: return found[0], None if not found: return None, "needs-boot: no cloned repo in workspace" return None, "ambiguous: %d repos, explicit repo required" % len(found) def _run_sync(action, repo): """跑 chv-sync pull/push;返回 (ok, tail)。tail 只取末行 (git 报错不含 token:remote URL 是干净 https,credential 走 store 文件),截断 500 字符。""" try: proc = subprocess.run( [SYNC_BIN, action, repo], capture_output=True, text=True, errors="replace", timeout=300) except OSError as exc: return False, "sync binary failed: %s" % exc except subprocess.TimeoutExpired: return False, "sync %s timeout after 300s" % action tail = ((proc.stdout or "") + (proc.stderr or "")).strip() tail = tail.splitlines()[-1] if tail else "" return proc.returncode == 0, tail[:500] def _git_head(repo): try: proc = subprocess.run( ["git", "-C", repo, "rev-parse", "HEAD"], capture_output=True, text=True, errors="replace", timeout=30) except (OSError, subprocess.TimeoutExpired): return None if proc.returncode != 0: return None return proc.stdout.strip() class Syncd: def __init__(self, interval=DEFAULT_INTERVAL, status_path=STATUS_PATH, workspace=WORKSPACE): self.interval = max(interval, MIN_INTERVAL) self.status_path = status_path self.workspace = workspace self._stop = threading.Event() self._cycle_lock = threading.Lock() self._cycle_running = False self._status = _read_json(status_path) or { "service": "chv-syncd", "last": "never", "at": None, "head": None, "error": None, "consecutive_errors": 0, "cycles": 0} def _write_status(self): _atomic_write(self.status_path, (json.dumps(self._status) + "\n").encode()) def run_cycle(self): """一轮同步:先 pull 再 push;任一步失败即停住记 error。""" if not self._cycle_lock.acquire(blocking=False): return {"ok": False, "error": "cycle already running"} try: self._cycle_running = True return self._cycle() finally: self._cycle_running = False self._cycle_lock.release() def _cycle(self): repo, reason = resolve_repo(self.workspace) now = time.time() self._status["cycles"] = self._status.get("cycles", 0) + 1 if repo is None: self._status.update( {"last": "error", "at": now, "error": reason, "consecutive_errors": self._status.get("consecutive_errors", 0) + 1}) self._write_status() return {"ok": False, "error": reason} head_before = _git_head(repo) ok, tail = _run_sync("pull", repo) if not ok: self._status.update( {"last": "error", "at": now, "repo": repo, "head": head_before, "error": "pull failed: %s" % tail, "consecutive_errors": self._status.get("consecutive_errors", 0) + 1}) self._write_status() return {"ok": False, "error": "pull failed: %s" % tail} ok, tail = _run_sync("push", repo) head_after = _git_head(repo) if not ok: self._status.update( {"last": "error", "at": now, "repo": repo, "head": head_after, "error": "push failed: %s" % tail, "consecutive_errors": self._status.get("consecutive_errors", 0) + 1}) self._write_status() return {"ok": False, "error": "push failed: %s" % tail} self._status.update( {"last": "ok", "at": now, "repo": repo, "head": head_after, "head_before": head_before, "error": None, "consecutive_errors": 0}) self._write_status() return {"ok": True, "repo": repo, "head": head_after} def handle(self, req): if not isinstance(req, dict): return {"ok": False, "error": "request must be an object"} op = req.get("op") if op == "ping": return {"ok": True, "service": "chv-syncd", "pid": os.getpid()} if op == "status": return {"ok": True, "status": self._status} if op == "sync-now": return self.run_cycle() return {"ok": False, "error": "unknown op: %s" % (op,)} def _serve(self): if os.path.exists(SYNC_SOCK): os.remove(SYNC_SOCK) srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) srv.bind(SYNC_SOCK) os.chmod(SYNC_SOCK, 0o600) srv.listen(8) srv.settimeout(0.5) try: while not self._stop.is_set(): try: conn, _ = srv.accept() except socket.timeout: continue except OSError: break with conn: try: conn.settimeout(10.0) buf = b"" while len(buf) < MAX_REQUEST_BYTES: chunk = conn.recv(4096) if not chunk: break buf += chunk if buf.endswith(b"\n"): break req = json.loads(buf.decode()) resp = self.handle(req) except (OSError, ValueError) as exc: resp = {"ok": False, "error": str(exc)} try: conn.sendall((json.dumps(resp) + "\n").encode()) except OSError: pass finally: try: srv.close() except OSError: pass try: os.remove(SYNC_SOCK) except OSError: pass def run(self): thread = threading.Thread(target=self._serve, daemon=True) thread.start() print("chv-syncd pid=%d interval=%.0fs status=%s" % (os.getpid(), self.interval, self.status_path), flush=True) try: while not self._stop.is_set(): self.run_cycle() self._stop.wait(self.interval) finally: self._stop.set() thread.join(timeout=5) def stop(self): self._stop.set() def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) interval = float(os.environ.get("CHV_SYNC_INTERVAL") or DEFAULT_INTERVAL) i = 0 while i < len(argv): if argv[i] == "--interval" and i + 1 < len(argv): interval = float(argv[i + 1]) i += 2 else: print("用法:chv-syncd [--interval 秒]", file=sys.stderr) return 2 syncd = Syncd(interval=interval) def _on_signal(signum, _frame): syncd.stop() signal.signal(signal.SIGTERM, _on_signal) signal.signal(signal.SIGINT, _on_signal) syncd.run() return 0 if __name__ == "__main__": sys.exit(main())