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,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chv-daemon:客机运行时 daemon(阶段 2 三分之一)。
|
||||
|
||||
职责(只做这三件,不碰任务执行与同步):
|
||||
1. 生命周期/心跳:每 10 秒写 /run/chv/heartbeat.json(atomic),
|
||||
含 pid、启动时间、seq、uptime、loadavg、内存、workspace
|
||||
余量、execd 任务数、sync 最近状态摘要。seq 跨重启连续
|
||||
(启动时读旧文件续号),宿主侧据此判断“心跳丢了还是
|
||||
daemon 重启了”。
|
||||
2. 资源上报:同上,心跳文件即上报载体(宿主经 SSH 读文件,
|
||||
不另开通道;控制通道仍是 SSH,见 executor.py 定稿)。
|
||||
3. 快照配合:Unix socket /run/chv/daemon.sock,op
|
||||
prepare-snapshot——问 execd 是否有运行中任务;有则
|
||||
拒绝({ok:false, reason:task-running}),无则写
|
||||
/run/chv/snapshot-ready.json 标记并返回 ok;execd
|
||||
联系不上时同样拒绝(fail-closed,不写标记)。
|
||||
宿主侧快照流程暂未调它(阶段 2 不改 executor 快照流),
|
||||
接口先行、供宿主侧后续接线与本阶段 live 演练。
|
||||
|
||||
与既有分工的映射(不要第二套 agent):
|
||||
- host.py 仍是唯一的任务/ codex 执行引擎(CLI,经 SSH 或
|
||||
execd 的 codex op 调);daemon 从不跑 host.py。
|
||||
- chv-broker/chv-browser 仍是单元内外 IPC 与浏览器子代理,
|
||||
daemon 不碰它们的 socket。
|
||||
- sync 仍是 chv-sync.sh + chv-syncd 常驻循环,daemon 只读
|
||||
sync-status.json 作摘要,不调 git。
|
||||
|
||||
只用标准库。以 root 运行(读 /proc 与各服务状态文件无权限
|
||||
问题;socket 0600 root:root,只接受本机 root 客户)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
HEARTBEAT_PATH = "/run/chv/heartbeat.json"
|
||||
DAEMON_SOCK = "/run/chv/daemon.sock"
|
||||
SNAPSHOT_READY_PATH = "/run/chv/snapshot-ready.json"
|
||||
EXECD_SOCK = "/run/chv/execd.sock"
|
||||
EXECD_TASKS = "/run/chv/execd-tasks.json"
|
||||
SYNC_STATUS = "/run/chv/sync-status.json"
|
||||
WORKSPACE = "/home/chv/workspace"
|
||||
HEARTBEAT_INTERVAL = 10.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 _loadavg():
|
||||
try:
|
||||
with open("/proc/loadavg") as fh:
|
||||
parts = fh.read().split()
|
||||
return [float(parts[0]), float(parts[1]), float(parts[2])]
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _meminfo():
|
||||
mem = {}
|
||||
try:
|
||||
with open("/proc/meminfo") as fh:
|
||||
for line in fh:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, val = line.split(":", 1)
|
||||
val = val.strip().split()
|
||||
if val and val[0].isdigit():
|
||||
mem[key.strip()] = int(val[0])
|
||||
except OSError:
|
||||
return None
|
||||
if not mem:
|
||||
return None
|
||||
return {"total_kb": mem.get("MemTotal"),
|
||||
"available_kb": mem.get("MemAvailable")}
|
||||
|
||||
|
||||
def _workspace_free():
|
||||
try:
|
||||
st = os.statvfs(WORKSPACE)
|
||||
return st.f_bavail * st.f_frsize
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _execd_task_count():
|
||||
data = _read_json(EXECD_TASKS)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
tasks = data.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
return None
|
||||
running = [t for t in tasks
|
||||
if isinstance(t, dict) and t.get("state") == "running"]
|
||||
return {"total": len(tasks), "running": len(running)}
|
||||
|
||||
|
||||
def _sync_summary():
|
||||
data = _read_json(SYNC_STATUS)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return {"last": data.get("last"), "at": data.get("at"),
|
||||
"head": data.get("head"),
|
||||
"error": data.get("error")}
|
||||
|
||||
|
||||
def build_heartbeat(pid, started_at, seq):
|
||||
"""组装一次心跳 dict(纯函数,便于单测与 --once)。"""
|
||||
now = time.time()
|
||||
return {"service": "chv-daemon", "pid": pid,
|
||||
"started_at": started_at, "seq": seq,
|
||||
"at": now, "uptime_s": round(now - started_at, 1),
|
||||
"loadavg": _loadavg(), "mem": _meminfo(),
|
||||
"workspace_free_bytes": _workspace_free(),
|
||||
"execd_tasks": _execd_task_count(),
|
||||
"sync": _sync_summary()}
|
||||
|
||||
|
||||
def _ask_execd_status(timeout=2.0):
|
||||
"""问 execd 要任务状态;联系不上返回 (None, reason)。"""
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout)
|
||||
sock.connect(EXECD_SOCK)
|
||||
sock.sendall(json.dumps({"op": "status"}).encode() + b"\n")
|
||||
buf = b""
|
||||
while len(buf) < MAX_REQUEST_BYTES:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
if buf.endswith(b"\n"):
|
||||
break
|
||||
data = json.loads(buf.decode())
|
||||
if not data.get("ok"):
|
||||
return None, "execd-status-not-ok"
|
||||
return data, None
|
||||
except (OSError, ValueError) as exc:
|
||||
return None, "execd-unreachable: %s" % exc
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def prepare_snapshot():
|
||||
"""快照配合:execd 有运行中任务或联系不上即拒绝。"""
|
||||
data, reason = _ask_execd_status()
|
||||
if data is None:
|
||||
return {"ok": False, "reason": reason}
|
||||
tasks = data.get("tasks") or []
|
||||
running = [t for t in tasks
|
||||
if isinstance(t, dict) and t.get("state") == "running"]
|
||||
if running:
|
||||
return {"ok": False, "reason": "task-running",
|
||||
"running": [t.get("id") for t in running]}
|
||||
marker = {"at": time.time(), "pid": os.getpid(),
|
||||
"note": "no running execd task"}
|
||||
_atomic_write(SNAPSHOT_READY_PATH,
|
||||
(json.dumps(marker) + "\n").encode())
|
||||
return {"ok": True, "marker": SNAPSHOT_READY_PATH}
|
||||
|
||||
|
||||
def handle_request(req):
|
||||
"""socket 请求分发(纯函数,便于单测)。"""
|
||||
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-daemon",
|
||||
"pid": os.getpid()}
|
||||
if op == "status":
|
||||
hb = _read_json(HEARTBEAT_PATH)
|
||||
return {"ok": True, "heartbeat": hb}
|
||||
if op == "prepare-snapshot":
|
||||
try:
|
||||
return prepare_snapshot()
|
||||
except OSError as exc:
|
||||
return {"ok": False, "reason": "marker-write-failed: %s"
|
||||
% exc}
|
||||
return {"ok": False, "error": "unknown op: %s" % (op,)}
|
||||
|
||||
|
||||
class Daemon:
|
||||
def __init__(self, interval=HEARTBEAT_INTERVAL):
|
||||
self.interval = interval
|
||||
self.pid = os.getpid()
|
||||
self.started_at = time.time()
|
||||
self.seq = 0
|
||||
old = _read_json(HEARTBEAT_PATH)
|
||||
if isinstance(old, dict) and isinstance(old.get("seq"), int):
|
||||
# 续号:宿主据 seq 连续判断心跳丢包还是 daemon 重启。
|
||||
self.seq = old["seq"]
|
||||
self._stop = threading.Event()
|
||||
self._server = None
|
||||
|
||||
def write_heartbeat(self):
|
||||
self.seq += 1
|
||||
hb = build_heartbeat(self.pid, self.started_at, self.seq)
|
||||
_atomic_write(HEARTBEAT_PATH,
|
||||
(json.dumps(hb) + "\n").encode())
|
||||
return hb
|
||||
|
||||
def _serve(self):
|
||||
if os.path.exists(DAEMON_SOCK):
|
||||
os.remove(DAEMON_SOCK)
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
srv.bind(DAEMON_SOCK)
|
||||
os.chmod(DAEMON_SOCK, 0o600)
|
||||
srv.listen(8)
|
||||
srv.settimeout(0.5)
|
||||
self._server = srv
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
conn, _ = srv.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
with conn:
|
||||
try:
|
||||
conn.settimeout(5.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 = handle_request(req)
|
||||
except (OSError, ValueError) as exc:
|
||||
resp = {"ok": False, "error": str(exc)}
|
||||
try:
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
srv.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.remove(DAEMON_SOCK)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
thread = threading.Thread(target=self._serve, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
self.write_heartbeat()
|
||||
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 = HEARTBEAT_INTERVAL
|
||||
once = False
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
if argv[i] == "--interval" and i + 1 < len(argv):
|
||||
interval = float(argv[i + 1])
|
||||
i += 2
|
||||
elif argv[i] == "--once":
|
||||
once = True
|
||||
i += 1
|
||||
else:
|
||||
print("用法:chv-daemon [--interval 秒] [--once]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
daemon = Daemon(interval=interval)
|
||||
if once:
|
||||
daemon.write_heartbeat()
|
||||
print(json.dumps({"ok": True,
|
||||
"heartbeat": HEARTBEAT_PATH,
|
||||
"seq": daemon.seq}))
|
||||
return 0
|
||||
print("chv-daemon pid=%d heartbeat=%s" % (daemon.pid,
|
||||
HEARTBEAT_PATH),
|
||||
flush=True)
|
||||
|
||||
def _on_signal(signum, _frame):
|
||||
daemon.stop()
|
||||
|
||||
signal.signal(signal.SIGTERM, _on_signal)
|
||||
signal.signal(signal.SIGINT, _on_signal)
|
||||
daemon.run()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user