Files
codex-app-server/chv-guest/chv-syncd.py
T
host 8013431f63 fix(chv): 阶段 2 syncd 写路径改到 chv 所有的 /run/chv/syncd/ 子目录
/run/chv 是 0755 root:root,chv 身份的 syncd 写不进状态文件
与 socket,_atomic_write 抛错致进程反复退出、systemctl 长期
activating(2026-11-07 真机实测)。socket 与状态文件改到
tmpfiles 预建的 /run/chv/syncd/(0755 chv:chv,与 chv-browser
的 shots 同款);daemon 的 sync 摘要读同一路径;驱动的
syncd socket 路径与 ls 检查同步更新;另修驱动 SSH poller
线程残留(daemon=True + wake 失败也 set 事件)。
2026-11-08 09:45:00 +00:00

274 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/syncd.sock"
STATUS_PATH = "/run/chv/syncd/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())