feat(chv): 阶段 2 客机运行时三分 daemon/execd/syncd + restore 留 seed.iso + tap 退避

- 客机三分(chv-guest/chv-daemon.py、chv-execd.py、chv-syncd.py,
  仅标准库;systemd Restart=always;build.sh 拷入 + 单元 + enable;
  buildinfo/check/generate lockfile 登记):
  daemon=心跳/资源上报/快照配合(prepare-snapshot 问 execd,
  有运行中任务或联系不上即拒绝);execd=shell/文件读写/codex
  托管(chv-cell 起 host.py,setsid 脱离,任务登记落盘,
  重启按 pid 认领;SO_PEERCRED 只认 uid 0/1000;codex env
  白名单);syncd=常驻循环只调 chv-sync pull/push,规则一条
  不改(先 pull --ff-only 再 push,分叉即停、不 --force、
  不 rebase;仓不存在记 needs-boot 不擅自 clone)。
  host.py 仍是唯一任务/codex 引擎,broker/browser/cell 分工
  不变,不另起第二套 agent。
- 阶段 1 遗留修复:restore_vm 失败路径 stop_vm(keep_disk=True),
  seed.iso 与任务盘都留,重试有输入;vm.boot/restore 的 tap
  触点遇 Resource busy 等 tap 空闲后重试一次(_wait_tap_idle
  看 ip link state,_ch_remote 报错带 stderr 尾巴以便识别);
  诊断不用 pgrep -x(15 字符上限)。
- 单测 cloud/tests/test_chv_opt_stage2.py 25 项;P2 干跑夹具
  与 enable 行断言随构建接线更新。
- 全量 cloud/tests 2001 全过(skipped=8)。
This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent f809014bef
commit 9d77ae55a7
10 files changed
+1932 -14

No files matched your search

+273
View File
@@ -0,0 +1,273 @@
#!/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())