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:
1 parent
f809014bef
commit
9d77ae55a7
10 files changed
+1932
-14
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user