"""Cloud Hypervisor 执行器(从 cloud/sandbox.py 巨类拆出)。 模块分工(cloud/chv/): - errors.py:ChvFailure 分类失败 - config.py:配置解析(参数/env/默认值)与内存字符串解析 - api_client.py:Unix socket 上的 VMM REST API 传输与归一化 - ssh_transport.py:SSH 命令构造、每 VM known_hosts 钉死、 ssh-keyscan 首次捕获(TOFU 边界见该模块) - disk.py:每任务磁盘供给与磁盘配额门 - pool.py:WarmPool 预热池 + registry 持久化与孤儿清理 - executor.py(本模块):执行器类本体——生命周期方法 (wake/pause/resume/snapshot/restore/hibernate/stop/ cleanup)与任务执行方法(dispatch/run/goal/execute)。 类体仍较长是故意的:生命周期与执行入口共享同一份 _vms 登记与审计状态,再按方法拆 mixin 只会把状态机 藏进多文件继承链;真正的可拆部分(配置/传输/磁盘/ 池)已经拆出去了。 循环导入说明(必读):cloud/sandbox.py 顶层 re-export 本模块(保证 `from cloud.sandbox import CloudHypervisorExecutor` 旧路径可用),所以本模块顶层 绝不能 import cloud.sandbox;凡是原来 sandbox.py 里的 共享 helper(goal 解析、host 命令选项、runner 超时、 _parse_host_result),一律在方法内按需 import——调用时 sandbox 早已加载完成,不会撞上半初始化的模块。 """ import json import logging import os import re import shutil import subprocess import threading import time from cloud.chv.api_client import _unix_socket_api from cloud.chv.api_client import normalize_api_result from cloud.executor_base import Executor from cloud.chv.config import _parse_memory_bytes from cloud.chv.config import build_chv_config from cloud.chv.disk import check_disk_quota from cloud.chv.disk import provision_task_disk from cloud.chv.errors import ChvFailure from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path from cloud.chv.ssh_transport import parse_keyscan_output from cloud.chv.ssh_transport import write_known_hosts _LOGGER = logging.getLogger("cloud.cloud_hypervisor") class CloudHypervisorExecutor(Executor): """Cloud Hypervisor 执行器 v1(命名:cloud-hypervisor,绝不用 cloud/ch 缩写——cloud 已是通用 HTTP runner 占位,撞名会让 认领过滤与白名单全部错乱)。 调度层选择,不是任务内工具(补充口径,必读): - 任务内的 LLM 绝不能发现、调用本执行器:执行器是在任务 创建前由 server 白名单(POST /tasks 的 executor 字段)、 环境默认值(CLOUD_DEFAULT_EXECUTOR)或 worker CLI --executor 认领决定的。任务内 agent 若能自选沙箱,等 于能从隔离强的执行器降级逃逸到隔离弱的执行器。 - 本模块不提供任何 MCP/工具/skill 给任务内 agent;需要可 发现的是创建任务那一层:POST /tasks 的 executor 可选值、 cloud/README.md 说明、控制台选项(只改文档与白名单, 不新增任务内调用入口)。 客内通道:只选 SSH,不做 vsock 第二套。 - 选 SSH 的理由:客内 host.py 的调用就是一条普通命令, SSH 直接复用 OpenSSH 的密钥认证与端口转发,云镜像自带 sshd,无需在客内再写一个 vsock agent、也无需在宿主再写 一个 vsock 代理去翻译 host.py 协议。 - 放弃 vsock 的代价:需要运维提供从宿主到客机的 SSH 通路 (tap/桥接 + 端口转发,见下文“VM 生命周期”)。 - Host key 钉死(本轨 #216 后的加固,默认行为变化): 不再用 StrictHostKeyChecking=no + UserKnownHostsFile= /dev/null。每 VM 在 state_dir//known_hosts 有独立 文件;首次启动时 wait_guest_ready 经 ssh-keyscan 在本 机回环端口上捕获当时呈现的 host key 写入该文件(首次 信任 TOFU:只防首次之后的替换,不验证首次本身,边界 见 cloud/chv/ssh_transport.py),此后同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes + 该文件校验,换 key 直接拒绝。客机用户与端口是显式配置:CHV_SSH_USER (默认 root,显式空字符串直接 ValueError,绝不静默 fallback 回 root)、CHV_SSH_PORT(默认 2222,必须在 1-65535 内);非 root 用户要求客机镜像里该用户已配 sshd 与 authorized_keys(运维责任)。 - 密钥红线:SSH 只传 -i 密钥文件路径,绝不把任何 key 值、 模型 key 值放进 argv、日志或返回结果;客内 host.py 的 模型 key 由客机镜像/cloud-init 预置(运维责任),v1 不 在 SSH 命令里注入任务级 key。 VM 唤醒流程(写进代码,不是只留文档;API 模式为准): - 主唤醒路径是 API 模式,不是“一条直接启动命令就完事”: wake_vm() 先 preflight,再经 launcher 以 --api-socket 只启动 VMM 进程(CLI 上不带内核/磁盘配置),等 vmm.ping 通,再 PUT vm.create(VmConfig)+ PUT vm.boot。 _build_vm_command() 保留的直接启动命令只是文档里的手动 等价写法(运维手工起 VM 时用),执行器代码里不走它。 - 就绪判定只选 SSH,不做 vsock 第二套:boot 后轮询 SSH “true”命令,直到 CHV_READY_TIMEOUT(默认 120 秒, 轮询间隔 CHV_READY_INTERVAL,默认 5 秒)内成功才跑 host.py/任务;超时则关 VM 并报失败,绝不在未就绪时 就派任务。vsock 放弃理由同客内通道一节(需客内 agent + 宿主代理两套新代码,v1 不拼半吊子)。 - 暂停与快照唤醒是两条独立代码分支: pause_vm()/resume_vm() 走 ch-remote pause/resume; restore_vm() 先 ch-remote restore(source_url=...), 再显式 resume_vm()——恢复后的 VM 默认是 paused,不 resume 就不算醒,绝不能只 restore 就返回成功。 - 关闭与清理:任务结束/失败/取消时走 shutdown_vm()/ stop_vm():ch-remote shutdown + 终止登记进程 + 删 socket。dispatch/run/goal 均走“wake → 跑任务 → finally shutdown”的每任务 VM 生命周期;execute() 是 只读 status,仍走 SSH 直连(要求 VM 已醒,调用方先 wake_vm),不为一次 status 就起一台 VM。本轨把这条 从“文档约定”收成“代码门禁”:execute() 先查 VM 是否 在运行(本实例登记了活进程,或 api-socket 文件存在 视为带外已醒的 VM),未运行直接报 ChvFailure (kind=vm_not_running),绝不向一个不存在的端口发 SSH 再拿连接失败冒充任务状态(execute 语义二选一 的结论:选“只读已运行 VM + 未运行报分类失败”, 不选“execute 先唤醒”——唤醒是 dispatch/run/goal 的职责,status 不应有起 VM 的副作用)。 - 清理要能处理三种情况:已死进程(poll() 非 None)、 已关 VM(无登记进程、无 socket,stop 返回 False)、 socket 残留(有 socket、无登记进程,照常删 socket 并 计入清理)。cleanup_stale_vms() 只清 chv- 前缀的 socket,绝不按前缀全删其它执行器的东西。 生产级口径(对齐公开沙箱保障口径,不声称复刻某家内部架构): - 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM; 每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访; VM 内 root 只能影响本 VM。 - 快速启动:WarmPool(cloud/chv/pool.py)预热干净快照 VM, acquire 消耗、release 销毁,池空回落冷启动,绝不降级; 池状态另落 state_dir/warm-pool.json registry,worker 重启后载入的条目一律当孤儿、只能 reconcile_orphans() 清理,绝不重新发给任务(见 pool.py)。 - 长任务中途死亡:_run_with_vm 在客内命令抛传输异常、 或非 goal 结果已 failed 且 VM 登记进程已死时,改判 interrupted(断点可续跑)而不是笼统 failed;VM 还活 着时的异常/失败原样处理,不误判。中途心跳未做:评估 结论是 host.py 单次 SSH 长回合没有客内进度信号可采, 另起一路 SSH 轮询只能证明“VM 活着”不能证明“任务 在进展”,徒增连接 churn,不做(见 PR 描述)。 - 与 #215 收敛模块的关系:#215 未合并,base 里没有 timeouts/gates/quota/sandbox_policy 模块,本轨不复制 它们;#215 合并后应再复用的点清单见 cloud/chv/ config.py 头注释与 PR 描述。 - 资源与成本:CPU/内存/磁盘配额门、宿主容量门(CHV_MAX_VMS)、 SSH 超时、goal 100 轮上限、执行器内部不重试;成本与配额 复用 worker 现有 quota 语义,不另起计费。 - 网络与治理:出口默认 fail-closed(必须过 CHV_EGRESS_PROXY 白名单代理);审批仍走任务 approval 与 server merge_gate, VM 内 agent 不能自批。 - 可观测:VM 生命周期事件经 audit_callback 发出(worker 接线 后转 AuthStore 审计),失败带 ChvFailure.kind 分类;断点 与 task_checkpoints/task_attempts 语义对齐(recycle 保留 两者,丢失临时文件)。 - 默认关闭:server 默认仍 docker、worker 默认仍 local,CHV 只在显式选择且有 CHV worker 时才跑;无 CHV_* 配置时 preflight 直接失败。完整清单见 docs/chv-production-readiness.md。 - worker 层现状:通用路径已能走 dispatch_task/goal_task; 断点续跑(goal-resume-breakpoint)已由补缺口轨接线: worker 通用分支对任何提供该方法的执行器调用它, Local 仍走原路径;网关任务仍被 worker 侧 loud fail 守卫拦住,不放宽网关规则。 - 网关现状:worker 通用路径在 sub2api 网关下对非 Local 执行器直接 loud fail(与 CloudExecutor 同款),v1 不 改这条门,所以 CHV + sub2api 组合目前在 worker 层就 明确失败,不宣称生产可用。 配置(只走构造参数或环境变量,不写文件;参数优先): - CHV_BINARY:cloud-hypervisor 二进制(默认同名 PATH 查找) - CHV_KERNEL:客机内核路径(必填,文件必须存在) - CHV_ROOTFS:客机根文件系统镜像路径(必填,文件必须 存在;CHV_IMAGE 是同义别名,仅当 CHV_ROOTFS 未设时用) - CHV_CPU:vCPU 数(默认 2,命令里写 boot=N) - CHV_MEM:内存(默认 2G,命令里写 size=2G) - CHV_STATE_DIR:api-socket 目录(默认 /tmp/chv-vms) - CHV_SSH_KEY:SSH 私钥文件路径(必填,文件必须存在) - CHV_SSH_USER:SSH 用户(默认 root) - CHV_SSH_PORT:宿主侧转发到客机的 SSH 端口(默认 2222) - CHV_CMDLINE:客机内核命令行(默认 "console=hvc0 root=/dev/vda1 rw") - CHV_NET:直接启动命令里的 --net 原样透传值(例如 tap=,mac=...,运维按实际 tap 配置给;不给则命令里不带 --net)。API 模式不用它,API 的网络配置走 CHV_NET_JSON。 - CHV_NET_JSON:API 模式 VmConfig 的 net 列表 JSON(例如 '[{"tap":"","mac":"12:34:56:78:90:01"}]',运维按实际 tap 给;不给则 VmConfig 不带 net,客机无网卡、SSH 就 绪必然超时失败——这是 fail-fast 前置条件,不是静默成功) - CHV_REMOTE:ch-remote 二进制(默认同名 PATH 查找; preflight 必查,缺失即 fail-fast;stop/cleanup 里调用 失败时尽力而为不炸) - CHV_API_TIMEOUT:等 vmm.ping 通的上限秒数(默认 30) - CHV_READY_TIMEOUT:等 guest SSH 就绪的上限秒数(默认 120) - CHV_READY_INTERVAL:SSH 就绪轮询间隔秒数(默认 5) 前置校验(preflight,执行入口必调,缺一即 fail-fast): 收集全部缺失项一次报出具体缺哪一项,绝不笼统报错: cloud-hypervisor 与 ch-remote 二进制存在、/dev/kvm 存在 且可读写、CPU 有 vmx/svm 标志、内核与磁盘镜像路径存在 且可读、SSH key 存在且可读。任一项缺失都 raise RuntimeError,绝不静默回落 Docker/Local。_validate() 即 preflight(旧名保留,供旧调用点用)。 命令构造(_build_vm_command/build_ssh_command/ build_vm_config)是纯函数,不做文件存在校验,单测可 直接断言命令与 VmConfig 形状。 真实验证边界(写进 PR 描述,不可删): 未做真实 Cloud Hypervisor 启动验证。当前环境没有可跑真 VM 的宿主:生产主机 124.222.165.170 本身就是 CVM (systemd-detect-virt=kvm,DMI product=CVM),/dev/kvm 不存在、无 vmx/svm 标志、无 kvm 模块、未装 cloud-hypervisor,无嵌套虚拟化透传;本 VM 容器内同样 无 /dev/kvm。v1 验证只能是 mock runner + 干跑命令构造 + 单元测试。真 VM 验证需另备带 /dev/kvm 的裸金属或 开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。 """ def __init__(self, binary=None, kernel=None, rootfs=None, image=None, cpus=None, memory=None, state_dir=None, runner=None, launcher=None, ssh_key=None, ssh_user=None, ssh_port=None, guest_host_py="/app/host.py", cmdline=None, net=None, net_json=None, ssh_host=None, kvm_path="/dev/kvm", cpuinfo_path="/proc/cpuinfo", env=None, model=None, ch_remote=None, api_client=None, sleeper=None, api_timeout=None, ready_timeout=None, ready_interval=None, host_key_mode=None): self._env = env if env is not None else os.environ # 配置解析已拆到 cloud/chv/config.py(参数优先于环境 # 变量,默认值与 #216 一致;显式空用户/越界端口的收紧 # 见 config.py 头注释)。字段名与原属性一一对应,旧 # 测试读属性的方式全部不变。 _cfg = build_chv_config({ "binary": binary, "kernel": kernel, "rootfs": rootfs, "image": image, "cpus": cpus, "memory": memory, "state_dir": state_dir, "ssh_key": ssh_key, "ssh_user": ssh_user, "ssh_port": ssh_port, "guest_host_py": guest_host_py, "cmdline": cmdline, "net": net, "net_json": net_json, "ssh_host": ssh_host, "kvm_path": kvm_path, "cpuinfo_path": cpuinfo_path, "ch_remote": ch_remote, "model": model, "api_timeout": api_timeout, "ready_timeout": ready_timeout, "ready_interval": ready_interval, "host_key_mode": host_key_mode}, self._env) for _field in ( "binary", "kernel", "rootfs", "cpus", "memory", "state_dir", "ssh_key", "ssh_user", "ssh_port", "guest_host_py", "cmdline", "net", "net_json", "kvm_path", "cpuinfo_path", "ch_remote", "model", "max_vms", "disk_mode", "disk_gb", "egress_proxy", "allow_direct_egress", "task_timeout", "snapshot_url", "warm_pool_size", "api_timeout", "ready_timeout", "ready_interval", "host_key_mode", "ssh_host", "ssh_forward", "reuse_experiment"): setattr(self, _field, getattr(_cfg, _field)) self.api_client = api_client if api_client is not None \ else _unix_socket_api self.sleeper = sleeper if sleeper is not None else time.sleep self.audit_callback = None self._vms_lock = threading.Lock() if runner is not None: self.runner = runner else: # 方法内 import:sandbox 顶层 re-export 本模块, # 顶层 import 会循环;调用时 sandbox 早已加载完成。 from cloud.sandbox import _default_runner self.runner = _default_runner if launcher is not None: self.launcher = launcher else: def _popen(cmd): return subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) self.launcher = _popen self._vms = {} # -- 名字 / 路径 --------------------------------------------------- def _host_name(self, task): return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task.get("id"))) def vm_name(self, task_or_name): """VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款 派生,同一任务每次算出同一名字,清理才能按名找)。 幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理 扫描 socket 文件名时传的就是 chv- 名,二次加前缀会 把 chv-dead 算成 chv-chv-dead、永远清不掉)。""" raw = task_or_name.get("id") if isinstance(task_or_name, dict) \ else str(task_or_name) safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw) if safe.startswith("chv-"): return safe return "chv-" + safe def api_socket(self, task_or_name): """该 VM 的 api-socket 路径:state_dir/.sock。""" return os.path.join(self.state_dir, self.vm_name(task_or_name) + ".sock") # -- 前置校验 ------------------------------------------------------ @staticmethod def _binary_ok(path): if "/" in path: return os.path.isfile(path) and os.access(path, os.X_OK) return shutil.which(path) is not None def _preflight_config_checks(self, missing): """preflight 的配置校验块(feat-chv-fix,P2-1 小提取: 仅把 reuse 拦截与 CPU/内存校验从 preflight() 里抽出, 语义逐行不变;其余 preflight 检查项与 config.py 的 字段解析深耦合,本轨如实停、不硬拆)。""" # P0-3:reuse 生产拦截。一行配置再也不能击穿租户隔离: # 只有显式实验二次确认 CHV_REUSE_SINGLE_TENANT_ # EXPERIMENT=1 时才放行,且放行本身在 wake_vm 里审计; # 否则 preflight 直接拒绝(kind=disk_mode_reuse_blocked)。 if getattr(self, "disk_mode", "copy") == "reuse" \ and not getattr(self, "reuse_experiment", False): missing.append(("disk_mode_reuse_blocked", "CHV_DISK_MODE=reuse 未经显式实验确认:" "须同时设 CHV_REUSE_SINGLE_TENANT_" "EXPERIMENT=1(仅单租户实验)")) # P2-6:配置校验时机统一到 preflight。CPU 非整数、内存 # 字符串非法过去只在 build_vm_config 才炸,现在 preflight # 一次报出(kind=config_invalid)。 try: int(self.cpus) except (TypeError, ValueError): missing.append(("config_invalid", "CHV_CPU 必须是整数:%r" % (self.cpus,))) try: _parse_memory_bytes(self.memory) except ValueError: missing.append(("config_invalid", "CHV_MEM 格式非法:%r" % (self.memory,))) def preflight(self): """预检(缺一不可则 fail-fast):收集全部缺失项一次报出 具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind, 供审计与失败分类用),绝不回落 Docker/Local。 检查项与 kind: - binary_missing:cloud-hypervisor 二进制不存在/不可执行 - binary_missing:ch-remote 二进制不存在/不可执行 - kvm_missing:/dev/kvm 不存在 - kvm_missing:/dev/kvm 不可读/不可写(分开报) - cpu_flag_missing:/proc/cpuinfo 无 vmx/svm 标志 - kernel_missing:CHV_KERNEL 未设/文件不存在/不可读 - image_missing:CHV_ROOTFS 未设/文件不存在/不可读 - ssh_key_missing:CHV_SSH_KEY 未设/文件不存在/不可读 - egress_not_configured:CHV_EGRESS_PROXY 未设且未显式 允许直连(默认 fail-closed,VM 出口必须过白名单代理) """ missing = [] # (kind, 文本) 列表,全部收集完再一次抛 if not self._binary_ok(self.binary): missing.append(("binary_missing", "cloud-hypervisor 二进制缺失或不可执行:%s" % self.binary)) if not self._binary_ok(self.ch_remote): missing.append(("binary_missing", "ch-remote 二进制缺失或不可执行:%s" % self.ch_remote)) if not os.path.exists(self.kvm_path): missing.append(("kvm_missing", "/dev/kvm 不存在:%s,宿主无 KVM" % self.kvm_path)) else: if not os.access(self.kvm_path, os.R_OK): missing.append(("kvm_missing", "/dev/kvm 不可读:%s" % self.kvm_path)) if not os.access(self.kvm_path, os.W_OK): missing.append(("kvm_missing", "/dev/kvm 不可写:%s" % self.kvm_path)) cpuinfo_ok = False try: with open(self.cpuinfo_path, "r") as fh: for line in fh: if line.startswith("flags"): flags = line.split(":", 1)[1].split() if "vmx" in flags or "svm" in flags: cpuinfo_ok = True break except OSError: missing.append(("cpu_flag_missing", "CPU 信息文件缺失:%s" % self.cpuinfo_path)) else: if not cpuinfo_ok: missing.append(("cpu_flag_missing", "CPU 无虚拟化标志(无 vmx/svm):%s" % self.cpuinfo_path)) if not self.kernel: missing.append(("kernel_missing", "CHV_KERNEL 未设")) elif not os.path.isfile(self.kernel): missing.append(("kernel_missing", "内核文件不存在:%s" % self.kernel)) elif not os.access(self.kernel, os.R_OK): missing.append(("kernel_missing", "内核文件不可读:%s" % self.kernel)) if not self.rootfs: missing.append(("image_missing", "CHV_ROOTFS 未设")) elif not os.path.isfile(self.rootfs): missing.append(("image_missing", "磁盘镜像文件不存在:%s" % self.rootfs)) elif not os.access(self.rootfs, os.R_OK): missing.append(("image_missing", "磁盘镜像文件不可读:%s" % self.rootfs)) if not self.ssh_key: missing.append(("ssh_key_missing", "CHV_SSH_KEY 未设")) elif not os.path.isfile(self.ssh_key): missing.append(("ssh_key_missing", "SSH key 文件不存在:%s" % self.ssh_key)) elif not os.access(self.ssh_key, os.R_OK): missing.append(("ssh_key_missing", "SSH key 文件不可读:%s" % self.ssh_key)) if not self.egress_proxy and not self.allow_direct_egress: missing.append(("egress_not_configured", "CHV_EGRESS_PROXY 未设且未显式允许直连:" "VM 出口必须过白名单代理,默认不给直连外网")) # 补缺口轨 (4):tap/网络配置校验前置到 preflight。 # CHV_NET_JSON 非法 JSON 或非 list/dict 时直接报 # net_config_invalid,不等到 vm.create 才炸;直连 # 只在显式 CHV_ALLOW_DIRECT_EGRESS=1 时放行,且 # wake_vm 里发 egress_direct_allowed 审计。tap 桥接 # 本身是否真只走代理仍需宿主实测,代码不声称已验证。 if self.net_json: try: _net = json.loads(self.net_json) except ValueError: missing.append(("net_config_invalid", "CHV_NET_JSON 不是合法 JSON")) else: if not isinstance(_net, (list, dict)): missing.append(("net_config_invalid", "CHV_NET_JSON 须为 JSON list 或 dict")) self._preflight_config_checks(missing) if missing: kind = missing[0][0] detail = "; ".join(text for _, text in missing) raise ChvFailure( kind, "cloud-hypervisor preflight 失败:%s" % detail) return True def _validate(self): """旧名保留:即 preflight。""" return self.preflight() # -- 审计 ---------------------------------------------------------- def _emit(self, event, task, detail="", kind=None): """VM 生命周期审计事件:有 audit_callback 时调它 (worker 接线后转 AuthStore 审计,即任务 timeline 的 审计来源),无 callback 时只记日志,绝不因审计失败炸 任务。event 取值:vm_create/vm_boot/vm_ready/vm_pause/ vm_resume/vm_snapshot/vm_restore/vm_shutdown/vm_destroy/ vm_failed/vm_recycled/warm_pool_acquire/warm_pool_release。""" try: if self.audit_callback is not None: self.audit_callback(event, task, detail, kind) else: _LOGGER.info("chv_audit event=%s kind=%s %s", event, kind, detail) except Exception: pass # -- 命令构造(纯函数,不做文件校验) ------------------------------- def _build_vm_command(self, task): """构造 cloud-hypervisor 启动命令(干跑可测,仅私有)。 feat-chv-fix(P2-2):原公开 build_vm_command() 降级为 私有——执行器唯一启动路径是 API 模式(wake_vm),公开 的第二启动路径容易被后来人误当成可走的启动入口;手动 等价写法保留在本方法与类 docstring 里,运维手工起 VM 时仍可按此形状拼命令,不再经公开方法调用。 顺序固定:<二进制> --api-socket --cpus boot=N --memory size=M --kernel <内核> --cmdline <命令行> --disk path=<镜像> [--net <运维透传值>]。 --net 不给时不带:tap/桥接是运维侧配置,本类不编造 一个不存在的 tap 名。 """ cmd = [self.binary, "--api-socket", self.api_socket(task), "--cpus", "boot=%s" % self.cpus, "--memory", "size=%s" % self.memory, "--kernel", self.kernel, "--cmdline", self.cmdline, "--disk", "path=%s" % self.rootfs] if self.net: cmd += ["--net", self.net] return cmd def known_hosts_path(self, task_or_name): """该 VM 的 known_hosts 文件:state_dir//known_hosts (与每任务磁盘同目录,见 ssh_transport.py 的钉死模型)。""" return _known_hosts_path(self.state_dir, self.vm_name(task_or_name)) def _net_guest_ip(self): """VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。""" if not self.net_json: return None try: net = json.loads(self.net_json) except ValueError: return None entries = net if isinstance(net, list) else [net] for entry in entries: if isinstance(entry, dict) and entry.get("ip"): return str(entry["ip"]) return None def resolve_ssh_host(self): """SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP): 显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下 的回环;三者皆无时报 ChvFailure(ssh_target_undeclared), 绝不无条件假设 127.0.0.1:2222 能通。""" if getattr(self, "ssh_host", None): return self.ssh_host guest_ip = self._net_guest_ip() if guest_ip: return guest_ip if getattr(self, "ssh_forward", False): return "127.0.0.1" raise ChvFailure( "ssh_target_undeclared", "SSH 目标未声明:须设 CHV_SSH_HOST、或在 CHV_NET_JSON " "里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 " "CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口") def provision_host_key(self, task): """按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py): 启动前为该 VM 生成一次性 ed25519 host key 对,公钥 立即写进该 VM 的 known_hosts(钉死),私钥只写进 per-VM seed 目录的 cloud-init user-data,随 VM 销毁 而删。返回 {paths, seed_dir, known_hosts}。keygen 失败即 raise,provisioned 模式下绝不回落 keyscan; 不同 VM 的 key 对不同(state_dir/ 隔离),同一 VM 重启复用已有对(否则钉死立即失效)。""" from cloud.chv.hostkeys import ( generate_host_keypair, host_key_paths, known_hosts_line, write_seed) from cloud.chv.ssh_transport import write_known_hosts vm = self.vm_name(task) paths = generate_host_keypair( self.state_dir, vm, runner=lambda cmd: self._call_runner(cmd)) with open(paths["public"], "r") as fh: public_text = fh.read() with open(paths["private"], "r") as fh: private_text = fh.read() # feat-chv-fix:known_hosts 行的主机部分跟 SSH 目标走 # (客机 IP 时不再写死回环,否则钉死校验永远对不上)。 try: ssh_host = self.resolve_ssh_host() except ChvFailure: ssh_host = "127.0.0.1" write_known_hosts( paths["known_hosts"], [known_hosts_line(self.ssh_port, public_text, host=ssh_host)]) seed_dir = write_seed(paths["seed_dir"], vm, private_text, public_text) return {"paths": paths, "seed_dir": seed_dir, "known_hosts": paths["known_hosts"]} def capture_host_key(self, task): """首次捕获客机 host key(TOFU,弱回落,边界见 ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机 回环端口抓 key 行,写入该 VM 的 known_hosts 文件 (0600)。生产部署必须用 provisioned 预置模式, 本方法只在 CHV_HOST_KEY_MODE=keyscan 时作回落, 它只能防首次之后的替换,不能验证首次本身。 抓不到可用 key 行时返回 False(不写文件、不报错), 由 wait_guest_ready 下一轮再试;sshd 还没起来时 这就是常态,不是失败。""" try: ssh_host = self.resolve_ssh_host() except ChvFailure: return False try: result = self._call_runner( ["ssh-keyscan", "-p", str(self.ssh_port), ssh_host], timeout=10) stdout = getattr(result, "stdout", "") or "" except Exception: return False key_lines = parse_keyscan_output(stdout, host=ssh_host) if not key_lines: return False try: write_known_hosts(self.known_hosts_path(task), key_lines) except OSError: return False return True def build_ssh_command(self, task, inner_cmd, extra_env=None): """构造 SSH 命令:ssh -p <端口> -i + 钉死选项 + user@127.0.0.1 + 客内命令。argv 里只有 key 文件路径, 绝无任何 key 值。egress_proxy 已设时客内命令前加 env 前缀(HTTPS_PROXY/HTTP_PROXY/NO_PROXY,代理地址本身 不是密钥;客机出口只走该白名单代理,见类 docstring)。 Host key 选项是钉死式(StrictHostKeyChecking=yes + 每 VM known_hosts 文件),#216 的 no + /dev/null 已 在本轨废除,见类 docstring 与 ssh_transport.py。""" try: ssh_host = self.resolve_ssh_host() except ChvFailure: # 纯命令构造(旧测试/干跑)不炸:回环缺省仅作 # 构造兼容,wake/wait 路径仍严格解析。 ssh_host = "127.0.0.1" return _build_ssh_cmd( self.ssh_port, self.ssh_key, self.ssh_user, self.egress_proxy, self.known_hosts_path(task), inner_cmd, ssh_host=ssh_host, extra_env=extra_env) # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- def build_vm_config(self, task_disk=None, seed_disk=None): """API 模式的 VmConfig(vm.create 请求体,纯函数): cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、 kernel{path}、cmdline{args}、disks[{path: 每任务磁盘}]、 rng{/dev/urandom},以及 CHV_NET_JSON 给出的 net 列表 (不给则不带 net——客机无网卡时 SSH 就绪会超时失败, 这是 fail-fast 前置条件,见类 docstring)。 task_disk 不给时用基础镜像路径(仅 lab/reuse 模式)。""" try: cpus = int(self.cpus) except ValueError: raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,)) config = { "cpus": {"boot_vcpus": cpus, "max_vcpus": cpus}, "memory": {"size": _parse_memory_bytes(self.memory)}, "kernel": {"path": self.kernel}, "cmdline": {"args": self.cmdline}, "disks": [{"path": task_disk or self.rootfs}], "rng": {"src": "/dev/urandom"}, } # feat-chv-fix(P0-1):seed 磁盘是 VmConfig 第二块 # 只读盘(CIDATA 卷标 ISO,客机 cloud-init NoCloud # 数据源),不是宿主目录——目录本身从不进 VmConfig。 if seed_disk: config["disks"].append({"path": seed_disk, "readonly": True}) if self.net_json: try: net = json.loads(self.net_json) except ValueError: raise ValueError("CHV_NET_JSON 不是合法 JSON") config["net"] = net if isinstance(net, list) else [net] return config def _api_call(self, socket_path, method, path, payload=None): """API 调用归一化(实现在 cloud/chv/api_client.py)。""" return normalize_api_result( self.api_client(socket_path, method, path, payload)) def _call_runner(self, cmd, timeout=None): """runner 调用(共享 helper 在 cloud/sandbox.py): timeout 给定时优先传 timeout,假 runner 只接受 cmd 时 TypeError 回退到只传 cmd(超时护栏丢了,但绝不因此静默 换执行器;worker 层 task_timeout 仍是外层硬护栏)。""" from cloud.sandbox import _call_runner_with_timeout return _call_runner_with_timeout(self.runner, cmd, timeout) def _kill_proc(self, proc): """终止登记进程:terminate → 等 5 秒 → kill,全部 best-effort,绝不抛异常。""" if proc is None: return try: proc.terminate() except Exception: pass try: proc.wait(timeout=5) except Exception: try: proc.kill() except Exception: pass def _active_count(self): with self._vms_lock: return len(self._vms) def _persistent_vm_count(self, exclude=None): """跨重启容量计数(feat-chv-fix,P1-4):本实例登记 的活进程名 ∪ state_dir 下 chv-*.sock 对应的 VM 名。 worker 重启后 _vms 为空,但旧 VMM 的 socket 仍在, 仍计入容量——重启不再绕过 CHV_MAX_VMS。注意这是 保守计数:已死 VMM 的残留 socket 在清理前也计入, 直到 cleanup_stale_vms / worker reconcile 清掉它。 exclude:调用方自己的 VM 名(同任务重复 wake 时先 关旧 VM,不应被自己的残留 socket 误拒)。""" names = set() with self._vms_lock: for name, proc in self._vms.items(): try: alive = proc.poll() is None except Exception: alive = False if alive: names.add(name) try: entries = os.listdir(self.state_dir) except Exception: entries = [] for entry in entries: if entry.endswith(".sock") and entry[:-5].startswith("chv-"): names.add(entry[:-5]) if exclude: names.discard(exclude) return len(names) def _check_capacity(self, exclude=None): """宿主容量门:持久化计数(见 _persistent_vm_count) 达到 max_vms 即明确拒绝(kind=host_capacity_exceeded, 调用方应排队/拒绝,绝不降级 Docker/Local)。""" count = self._persistent_vm_count(exclude=exclude) if count >= self.max_vms: raise ChvFailure( "host_capacity_exceeded", "宿主 CHV 容量已满:已计数 %d 台,上限 %d," "请排队或拒绝本任务" % (count, self.max_vms)) def _check_disk_quota(self): """磁盘配额门(实现在 cloud/chv/disk.py,feat-chv-fix 口径:总量 + 本次 + 宿主余量,镜像不可读不静默跳过)。""" check_disk_quota(self.rootfs, self.disk_gb, state_dir=self.state_dir) def _provision_task_disk(self, task): """每任务磁盘供给(实现在 cloud/chv/disk.py): - copy(默认):把基础镜像复制到 state_dir//disk.raw,VmConfig 用该路径;复制 慢但两个任务绝不写同一文件,测试用小文件可直接验证。 - reuse(仅单租户实验):直接返回基础镜像路径,磁盘 不独立;preflight 拦截(须显式实验二次确认并审计 放行,见 preflight,feat-chv-fix)。 返回每任务磁盘路径。""" return provision_task_disk( self.state_dir, self.vm_name(task), self.rootfs, self.disk_mode) def wait_api_ready(self, task): """等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整, 至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟 时墙钟循环会空转,次数循环在单测里是确定性的。 超时 raise ChvFailure(kind=api_timeout)。""" socket_path = self.api_socket(task) attempts = max(1, int(self.api_timeout)) for attempt in range(attempts): try: status, _ = self._api_call( socket_path, "GET", "/vmm.ping") if status == 200: return True except Exception: pass if attempt + 1 < attempts: self.sleeper(1) raise ChvFailure( "api_timeout", "vmm.ping 超时:%d 次内 API 未通,socket=%s" % (attempts, socket_path)) def wait_guest_ready(self, task): """等 guest SSH 就绪(只选 SSH,见类 docstring): 轮询 ssh … "true",returncode 0 即就绪;次数 = ready_timeout/ready_interval 取整,至少 1 次。 每轮先尽力捕获 host key(known_hosts 文件不存在时; sshd 未起时抓不到是常态,下轮再试),再发 ssh "true"——钉死式 SSH 在文件缺失时会直接拒绝未知主机, 不先捕获就永远等不到就绪。超时时先关 VM 再 raise ChvFailure(kind=guest_not_ready),绝不在未就绪时 派任务。""" # feat-chv-fix(P0-2):provisioned 模式下缺 known_hosts # 时直接 fail-fast,绝不调用 capture_host_key 回落 TOFU。 # (keyscan 模式才走下面的捕获分支。) if getattr(self, "host_key_mode", "keyscan") == "provisioned" \ and not os.path.exists(self.known_hosts_path(task)): try: self.stop_vm(task) except Exception: pass raise ChvFailure( "host_key_not_pinned", "provisioned 模式下 known_hosts 缺失,拒绝 keyscan " "回落:VM %s 未钉死 host key" % self.vm_name(task)) attempts = max(1, int(self.ready_timeout / self.ready_interval)) for attempt in range(attempts): try: if not os.path.exists(self.known_hosts_path(task)): self.capture_host_key(task) result = self._call_runner( self.build_ssh_command(task, ["true"])) if getattr(result, "returncode", 1) == 0: return True except Exception: pass if attempt + 1 < attempts: self.sleeper(self.ready_interval) try: self.stop_vm(task) except Exception: pass raise ChvFailure( "guest_not_ready", "guest SSH 就绪超时:%d 次轮询内 sshd 未通,已关 VM" % attempts) def _guest_host_command(self, *args, model=None, effort=None, approval=None, sandbox=None, model_provider=None): """客内 host.py 命令:python3 /app/host.py [顶层全局参数] [子命令...],顶层参数顺序与 Local/Docker 同款(必须在 子命令之前,共享 helper 在 cloud/sandbox.py)。 guest_host_py 默认 /app/host.py:客机镜像须把 host.py 放在该路径(与 Docker 的 /app:ro 同款约定,只是载体 从容器换成 VM 镜像)。""" from cloud.sandbox import _host_command_options cmd = ["python3", self.guest_host_py] cmd += _host_command_options( model=model, effective_model=self.model, model_provider=model_provider, effort=effort, approval=approval, sandbox=sandbox) cmd += list(args) return cmd # -- 生命周期:API 唤醒 / 暂停恢复 / 快照 / 关闭 -------------------- def _launch_api_only(self, task): """只启动 VMM 进程(API 模式第一步):CLI 上只带 --api-socket,不带内核/磁盘配置;VM 配置随后走 vm.create 请求体。返回登记的进程。""" socket_path = self.api_socket(task) cmd = [self.binary, "--api-socket", socket_path] proc = self.launcher(cmd) with self._vms_lock: self._vms[self.vm_name(task)] = proc return proc def _ch_remote(self, task_or_name, *args): """ch-remote 调用:[ch-remote --api-socket= args...]。 returncode 非 0 即 raise RuntimeError(fail-fast), runner 本身抛异常时原样传播。返回 runner 结果。""" name = self.vm_name(task_or_name) socket_path = os.path.join(self.state_dir, name + ".sock") result = self.runner([self.ch_remote, "--api-socket=%s" % socket_path] + list(args)) if getattr(result, "returncode", 0) not in (0, None): raise RuntimeError( "ch-remote %s 失败:returncode=%s" % (" ".join(args), getattr(result, "returncode"))) return result def wake_vm(self, task): """主唤醒路径(API 模式,dispatch/run/goal 的前置步骤): preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 → launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 → PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest SSH 就绪。任一步失败都关 VM(stop_vm)再抛 ChvFailure,绝不带着半醒的 VM 去跑任务。 成功返回 {vm, api_socket, state: "running", ready: True, mode: "cold"},并审计 vm_create/vm_boot/vm_ready。 单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新 的,绝不两个 VM 共用一个任务名。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") self.preflight() # feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。 # resolve 失败即 fail-fast(不先起 VMM 再超时); # reuse 实验放行必须审计,留痕给运维复核。 self.resolve_ssh_host() name = self.vm_name(task) self._check_capacity(exclude=name) self._check_disk_quota() if getattr(self, "disk_mode", "copy") == "reuse": self._emit("disk_reuse_experiment_allowed", task, "vm=%s CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 " "explicit" % name) old = None with self._vms_lock: if name in self._vms: old = self._vms.pop(name, None) if old is not None: self._kill_proc(old) task_disk = self._provision_task_disk(task) socket_path = self.api_socket(task) if self.allow_direct_egress: # (4) 直连 egress 只在显式 CHV_ALLOW_DIRECT_EGRESS=1 # 时放行,且必须审计;默认 fail-closed 在 preflight # 里已拦(无代理且未显式允许即缺项)。tap 桥接 # 本身是否真只走代理仍需宿主实测,代码不声称已验证。 self._emit("egress_direct_allowed", task, "vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name) try: seed_disk = None if getattr(self, "host_key_mode", "keyscan") == "provisioned": # 强方案:启动前预置每 VM host key 并钉死, # 再把 seed 目录做成 CIDATA seed 磁盘并挂进 # VmConfig(P0-1 闭环:生成→制盘→挂盘三步缺 # 一即 fail-fast,绝不回落 keyscan、绝不只 # 生成不注入)。 from cloud.chv.hostkeys import ( build_seed_disk, seed_disk_path) provisioned = self.provision_host_key(task) seed_disk = seed_disk_path(self.state_dir, name) build_seed_disk( provisioned["seed_dir"], seed_disk, runner=lambda cmd: self._call_runner(cmd)) self._launch_api_only(task) self.wait_api_ready(task) status, _ = self._api_call( socket_path, "PUT", "/vm.create", self.build_vm_config(task_disk, seed_disk=seed_disk)) if status not in (200, 204): raise ChvFailure( "vm_create_failed", "vm.create 失败:status=%s" % status) self._emit("vm_create", task, "vm=%s disk=%s" % (name, task_disk)) status, _ = self._api_call(socket_path, "PUT", "/vm.boot") if status not in (200, 204): raise ChvFailure( "vm_boot_failed", "vm.boot 失败:status=%s" % status) self._emit("vm_boot", task, "vm=%s" % name) self.wait_guest_ready(task) except Exception: try: self.stop_vm(task) except Exception: pass if isinstance(task, dict): self._emit("vm_failed", task, "vm=%s wake 失败" % name) raise self._emit("vm_ready", task, "vm=%s" % name) return {"vm": name, "api_socket": socket_path, "state": "running", "ready": True, "mode": "cold"} def start_vm(self, task): """旧名保留:即 wake_vm(API 模式)。直接启动命令 _build_vm_command() 只作手动等价写法(私有),不再是执行器 的启动路径——两条启动路径并存是上一版的半吊子, 本版只认 API 模式。""" return self.wake_vm(task) def pause_vm(self, task_or_name): """暂停 VM:ch-remote pause。成功审计 vm_pause。""" self._ch_remote(task_or_name, "pause") task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} self._emit("vm_pause", task, "vm=%s" % self.vm_name(task_or_name)) return True def resume_vm(self, task_or_name): """唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。 注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它 (见 restore_vm),绝不能只 restore 就算醒。""" self._ch_remote(task_or_name, "resume") task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} self._emit("vm_resume", task, "vm=%s" % self.vm_name(task_or_name)) return True def snapshot_vm(self, task_or_name, snapshot_url): """给(已暂停的)VM 做快照:ch-remote snapshot 。snapshot_url 为空即 ValueError;成功 审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused, 需 resume 才醒)。""" if not snapshot_url: raise ValueError("snapshot_url 不能为空") self._ch_remote(task_or_name, "snapshot", "source_url=%s" % snapshot_url) # feat-chv-fix(P1-7):只查 returncode 不够。file:// # 快照须验证目标文件存在且非空,否则报 # snapshot_not_persisted,调用方(hibernate_vm)据此 # 不 shutdown、不销毁原 VM;非 file:// 目标无法从宿 # 主验证落盘,仍只认 returncode,但文档明写该边界。 if snapshot_url.startswith("file://"): _snap_path = snapshot_url[len("file://"):] if not os.path.isfile(_snap_path) \ or os.path.getsize(_snap_path) == 0: raise ChvFailure( "snapshot_not_persisted", "快照未落盘或为空:%s" % snapshot_url) task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} self._emit("vm_snapshot", task, "vm=%s snapshot=%s" % (self.vm_name(task_or_name), snapshot_url)) return snapshot_url def restore_vm(self, task, snapshot_url): """快照恢复唤醒:preflight → 只起 VMM(--api-socket) → 等 vmm.ping → ch-remote restore → 显式 resume → 等 guest SSH 就绪。恢复后的 VM 默认是 paused,不 resume 就不算醒——本方法绝不省略 resume 那一步。 任一步失败都关 VM 再抛。成功返回 {vm, api_socket, state: "running", ready: True, mode: "restored"}。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not snapshot_url: raise ValueError("snapshot_url 不能为空") if "://" not in snapshot_url: snapshot_url = "file://%s" % snapshot_url self.preflight() self.resolve_ssh_host() # feat-chv-fix(P0-2 修正版):restore 不走 vm.create, # 没有 VmConfig 可挂 seed——审计报告“restore 须把 seed # 接入 VmConfig”的说法在此不适用,纠正为:快照内已 # 固化原 VM 的 host key,重新预置新 key 反而会破坏 # 钉死。正确不变量是:provisioned 下复用已钉死的 # known_hosts(stop 时公钥保留、私钥已删但快照内仍有 # 原私钥),缺失时 fail-fast、绝不 keyscan、绝不重 # 生成。file:// 快照须先验存在且非空,否则不启动。 # 注意:restore 时不重复验快照文件存在——快照落盘 # 校验是 snapshot_vm/hibernate_vm 创建时的门(P1-7), # restore 的 ch-remote 本身会在源缺失时失败;在这里 # 再验一次会把“源在远端/对象存储”的合法路径也挡掉。 if getattr(self, "host_key_mode", "keyscan") == "provisioned" \ and not os.path.exists(self.known_hosts_path(task)): raise ChvFailure( "host_key_not_pinned", "provisioned 模式下 restore 缺 known_hosts,拒绝 " "keyscan 回落:VM %s" % self.vm_name(task)) name = self.vm_name(task) self._check_capacity(exclude=name) try: self._launch_api_only(task) self.wait_api_ready(task) self._ch_remote(task, "restore", "source_url=%s" % snapshot_url) self._emit("vm_restore", task, "vm=%s snapshot=%s" % (name, snapshot_url)) self.resume_vm(task) self.wait_guest_ready(task) except Exception: try: self.stop_vm(task) except Exception: pass raise return {"vm": name, "api_socket": self.api_socket(task), "state": "running", "ready": True, "mode": "restored"} def hibernate_vm(self, task_or_name, snapshot_url): """休眠(CHV 无原生 hibernate 端点,等价实现): pause + snapshot + shutdown。休眠后文件与 worktree 保留在快照里(快照是暂停时刻的完整状态),临时文件 不在恢复保证内(见 recycle_vm 的说明)。 成功返回 {vm, state: "hibernated", snapshot: ...}。 本方法明写“等价实现”,绝不声称接了原生 hibernate。""" self.pause_vm(task_or_name) self.snapshot_vm(task_or_name, snapshot_url) self.stop_vm(task_or_name) task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} return {"vm": self.vm_name(task_or_name), "state": "hibernated", "snapshot": snapshot_url} def wake_from_hibernate(self, task, snapshot_url): """从休眠唤醒:即 restore_vm(restore + 显式 resume + SSH 就绪),语义与 restore_vm 完全一致,只是名字 说明来源是休眠快照。""" return self.restore_vm(task, snapshot_url) def stop_vm(self, task_or_name): """停止 VM(尽力而为,不抛异常;三种情况): - 已死进程:登记进程 poll() 非 None,跳过 terminate 等待,直接删 socket、清登记,返回 True。 - 已关 VM:无登记进程、无 socket,ch-remote 仍尽力 调过一次,返回 False(本来就没有什么可清)。 - socket 残留:有 socket、无登记进程,删 socket 并 返回 True。 ch-remote shutdown 失败不炸(VM 可能已死),但 ch-remote 命令本身仍会发出——测试据此断言关闭路径 确实用了 ch-remote shutdown,而不是只杀进程。""" name = self.vm_name(task_or_name) socket_path = os.path.join(self.state_dir, name + ".sock") with self._vms_lock: had_proc = name in self._vms proc = self._vms.get(name) had = had_proc or os.path.exists(socket_path) try: self.runner([self.ch_remote, "--api-socket=%s" % socket_path, "shutdown"]) except Exception: pass if proc is not None: with self._vms_lock: self._vms.pop(name, None) try: alive = proc.poll() is None except Exception: alive = False if alive: self._kill_proc(proc) try: if os.path.exists(socket_path): os.remove(socket_path) except Exception: pass # 补缺口轨:per-VM seed 目录与 host 私钥随 VM # 关闭而删,绝不留给下一个 VM 复用;known_hosts # 公钥文件保留供事后审计,删失败不炸关闭。下次 # 同名 VM 预置时若私钥已删即重新生成并覆盖 # known_hosts,不会用旧钉死去校验新 VM。 try: seed_dir = os.path.join(self.state_dir, name, "seed") if os.path.isdir(seed_dir): shutil.rmtree(seed_dir) for _suffix in ("ssh_host_ed25519_key", "ssh_host_ed25519_key.pub", "seed.iso"): _p = os.path.join(self.state_dir, name, _suffix) if os.path.exists(_p): os.remove(_p) except Exception as _cleanup_exc: # feat-chv-fix(P2-5):seed/私钥删除失败不再静默 # 吞掉——记 vm_key_cleanup_failed 审计;且下次同名 # VM 预置时若私钥残留,generate_host_keypair 的 # “复用已有对”语义不变,但审计痕迹让“一性”破 # 坏可被发现(彻底根治须另做“清理失败即拒起”, # 见 PR 描述的遗留项,不在本轨假装已根治)。 task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} self._emit("vm_key_cleanup_failed", task, "vm=%s error=%s" % (name, _cleanup_exc)) task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} if had: self._emit("vm_shutdown", task, "vm=%s" % name) self._emit("vm_destroy", task, "vm=%s" % name) _LOGGER.info("chv_vm_stop vm=%s had=%s", name, had) return had def shutdown_vm(self, task_or_name): """关闭入口的正式名:即 stop_vm(ch-remote shutdown)。 任务结束/失败/取消时调它。""" return self.stop_vm(task_or_name) def cancel_vm(self, task_or_name): """取消入口:即 shutdown_vm。worker 的取消路径对 Docker 调 kill_container,对 CHV 调本方法——取消绝不 只改数据库状态,必须真关 VM。""" return self.shutdown_vm(task_or_name) def recycle_vm(self, task): """回收 VM(任务结束后的销毁语义): 先 shutdown,再返回保留/丢失清单。保留的只有宿主侧 的 task_checkpoints / task_attempts(数据库里的断点与 尝试账本,VM 销毁不影响它们,goal 断点续跑据此接续); 丢失的是客机内的临时文件与未拷出的 worktree 临时物 ——recycle 绝不声称“恢复临时文件”。需要拷出的工件 必须在 shutdown 之前由调用方经 SSH 拷出,本方法不 假装替调用方拷了。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") self.shutdown_vm(task) self._emit("vm_recycled", task, "vm=%s" % self.vm_name(task)) return { "vm": self.vm_name(task), "state": "recycled", "preserved": ["task_checkpoints", "task_attempts"], "lost": ["guest /tmp", "guest worktree temp files"], } def cleanup_stale_vms(self): """清理残留 VM:只清 socket 残留与已死进程对应的 VM。 扫描 state_dir 下 *.sock:登记进程仍活(poll() 为 None) 的跳过;其余一律走 stop_vm。返回已清理的 VM 名列表; state_dir 不存在时返回 [],绝不抛异常。基础设施或 其它执行器的 socket 不在 state_dir 里,不会被误删; 同目录下非 chv- 前缀的 socket 也跳过(只认本执行器 的 chv- 命名)。 """ cleaned = [] try: entries = os.listdir(self.state_dir) except Exception: return [] for entry in entries: if not entry.endswith(".sock"): continue name = entry[:-len(".sock")] if not name.startswith("chv-"): continue proc = self._vms.get(name) if proc is not None: try: alive = proc.poll() is None except Exception: alive = False if alive: continue if self.stop_vm(name): cleaned.append(name) # 登记进程已死但 socket 文件已被删的情况:登记项本身 # 也是残留,顺手清掉登记,不计入 cleaned(没有 socket # 可删,不算一次清理)。 for name in list(self._vms.keys()): proc = self._vms.get(name) try: dead = proc is not None and proc.poll() is not None except Exception: dead = True if dead: self._vms.pop(name, None) return cleaned # -- 客内执行 ------------------------------------------------------ def _run_guest(self, task, inner_args, extra_env=None, **kwargs): """SSH 调客内 host.py,返回 Local/Docker 同款结果 dict (executor 字段改写为 cloud-hypervisor,并附 vm 名与 api-socket)。runner 异常即原样传播,由 worker 侧统一 写回 failed——本类不在这里把异常吞成假成功。 extra_env(P1-1):经 SSH env 前缀进客机环境。""" self._validate() ssh_cmd = self.build_ssh_command( task, self._guest_host_command(*inner_args, **kwargs), extra_env=extra_env) result = self._call_runner(ssh_cmd, timeout=self.task_timeout) # 方法内 import:见模块头注释的循环导入说明。 from cloud.sandbox import _parse_host_result from cloud.sandbox import SimpleNamespaceShim parsed = _parse_host_result(task["id"], SimpleNamespaceShim(result)) parsed["executor"] = "cloud-hypervisor" parsed["vm"] = self.vm_name(task) parsed["api_socket"] = self.api_socket(task) return parsed def _goal_result(self, task_id, result): """客内 host.py goal 输出解析:直接委托给三执行器共享 的 parse_goal_result(cloud/sandbox.py,Local 的 _goal_result_from_runner 同款正典语义),executor 字段为 cloud-hypervisor。 本轨对齐说明(#216 的 CHV 与正典有两处分叉,本轨 收敛,PR 描述里明写):① 非 0 退出但已打印 met 结论 时,正典判 failed(结论不可信),CHV 旧代码判 completed;② 输出含“已存在”撞名时,正典判 failed 并提示“用继续而非重试”,CHV 旧代码判 interrupted。 其余分支(met/unmet、interrupted 标记、永久错误、 无结论异常退出)本来就一致,不变。""" from cloud.sandbox import parse_goal_result return parse_goal_result(task_id, result, "cloud-hypervisor") def _run_guest_goal(self, task, inner_args, extra_env=None, **kwargs): self._validate() ssh_cmd = self.build_ssh_command( task, self._guest_host_command(*inner_args, **kwargs), extra_env=extra_env) return self._goal_result( task["id"], self._call_runner(ssh_cmd, timeout=self.task_timeout)) def _vm_proc_dead(self, task_or_name): """该 VM 的登记进程是否已死:只认本实例登记的进程 (poll() 非 None 即死);未登记时返回 False(不认 socket 残留——崩掉的 VMM 也会留下 socket 文件,单凭 socket 在不能断定 VM 活着还是死了,中途死亡判定只 用登记进程这个硬证据)。""" name = self.vm_name(task_or_name) with self._vms_lock: proc = self._vms.get(name) if proc is None: return False try: return proc.poll() is not None except Exception: return False def _vm_is_running(self, task_or_name): """execute() 的“VM 在运行”判定:本实例登记了活进程, 或 api-socket 文件存在(带外已醒的 VM,例如另一进 程 restore 的,登记不在本实例里)。两者皆无即未运行。""" name = self.vm_name(task_or_name) with self._vms_lock: proc = self._vms.get(name) if proc is not None: try: if proc.poll() is None: return True except Exception: pass return os.path.exists(self.api_socket(task_or_name)) def _vm_death_result(self, task, goal, evidence): """VM 中途死亡的 interrupted 结果:goal 形态带 goal_met/goal_error(断点可续跑),非 goal 形态带 vm_dead 标记与原因,status 一律 interrupted,绝不 笼统 failed。""" reason = "VM 中途死亡,任务未完成、断点可续跑:%s" \ % (evidence or "客机连接在任务进行中丢失") if goal: return { "executor": "cloud-hypervisor", "task_id": task["id"], "status": "interrupted", "stdout": "", "stderr": evidence or "", "returncode": None, "host_status": None, "goal_met": None, "goal_error": reason, "usage": None, "vm": self.vm_name(task), "vm_dead": True, } return { "executor": "cloud-hypervisor", "task_id": task["id"], "status": "interrupted", "stdout": "", "stderr": evidence or "", "returncode": None, "host_status": None, "vm": self.vm_name(task), "vm_dead": True, "interrupted_reason": reason, } def _run_with_vm(self, task, inner_args, goal=False, extra_env=None, **kwargs): """每任务 VM 生命周期:wake_vm(API 模式 + SSH 就绪) → 跑客内命令 → finally shutdown_vm。wake 失败时不跑 任务、直接抛分类失败。本方法不做内部重试:重试是 worker 的 max_retries 语义,执行器内部重试会把一次 失败放大成多次计费与多次 VM 启动。 中途死亡分类(本轨新增):客内命令抛传输异常、或 非 goal 结果已 failed 时,若登记的 VMM 进程已死, 判 interrupted(断点可续跑)而不是笼统 failed 或 原样抛异常;VM 还活着时的异常/失败原样处理,不 误判。goal 结果本身已有 interrupted 分类(见 parse_goal_result),这里不再二次改写 goal 结论。""" self.wake_vm(task) try: try: if goal: return self._run_guest_goal( task, inner_args, extra_env=extra_env, **kwargs) result = self._run_guest(task, inner_args, extra_env=extra_env, **kwargs) except Exception as exc: if self._vm_proc_dead(task): return self._vm_death_result( task, goal, str(exc)[:500]) raise if not goal and result.get("status") == "failed" \ and self._vm_proc_dead(task): result = dict(result) result["status"] = "interrupted" result["vm_dead"] = True result["interrupted_reason"] = ( "VM 中途死亡,任务未完成、断点可续跑:" "客内命令已 failed 且登记的 VMM 进程已死") return result finally: try: self.shutdown_vm(task) except Exception: pass def execute(self, task) -> dict: """只读执行:客内 host.py status,不直接跑模型回合 (与 Local/Docker 的 execute 同款语义)。 execute 语义二选一结论(本轨落代码):只用于已运行 VM 的只读 status。VM 未运行时报分类失败 ChvFailure(kind=vm_not_running),绝不为一次 status 起一台 VM,也绝不向不存在的端口发 SSH 再拿连接 失败冒充任务状态。需要跑任务时调 dispatch/run/goal, 它们自带 wake_vm。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not self._vm_is_running(task): raise ChvFailure( "vm_not_running", "execute() 只读已运行 VM 的 status:VM %s 未运行," "请先 wake_vm() 再调 execute(),不要用 execute " "唤醒 VM" % self.vm_name(task)) try: return self._run_guest( task, ("status", "--task", self._host_name(task))) except Exception as exc: # feat-chv-fix(P1-8):socket 残留但 VMM 已死时, # _vm_is_running 会因 socket 存在而放行,随后的 # SSH 失败不得以原始传输异常抛出——登记进程已死 # 或 socket 已消失即统一归类 vm_not_running;VM # 仍活着时的 SSH 失败原样传播(那是客内 status # 失败,不是 VM 未运行)。 if self._vm_proc_dead(task) \ or not os.path.exists(self.api_socket(task)): raise ChvFailure( "vm_not_running", "execute() 时 VM 已死(socket 残留/进程已死)," "SSH 失败归类为未运行:%s" % exc) raise def dispatch_task(self, task, extra_env=None, model_provider=None) -> dict: """客内 host.py dispatch:建 host 任务并跑一轮 turn。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") args = ["dispatch", "--name", self._host_name(task), "--task", task["prompt"]] repo = task.get("repo") if repo: args += ["--repo", repo] return self._run_with_vm( task, args, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider, sandbox=("read-only" if task.get("plan") else None)) def run_task(self, task, extra_env=None, model_provider=None) -> dict: """客内 host.py run:在已有 host 任务上再跑一轮 turn。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") return self._run_with_vm( task, ("run", "--task", self._host_name(task), "--prompt", task["prompt"]), extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) def goal_task(self, task, extra_env=None, model_provider=None) -> dict: """客内 host.py goal:目标循环,轮次规则与 Local 同款 (缺省 3 轮,>100 轮 loud fail,旧验收形态 0 轮禁用)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") goal_text = task.get("goal") or task.get("prompt") if not goal_text: raise ValueError("任务缺少 prompt") verify = task.get("verify_command") rounds = task.get("goal_max_rounds") if rounds is None: rounds = 3 if rounds is not None and rounds > 100: raise ValueError("goal_max_rounds 超过上限 100 轮") if rounds == 0 and verify and not task.get("goal_mode"): raise ValueError( "旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用") args = ["goal", "--name", self._host_name(task), "--task", goal_text] if verify: args += ["--verify", verify] args += ["--max-rounds", str(rounds)] repo = task.get("repo") if repo: args += ["--repo", repo] return self._run_with_vm( task, args, goal=True, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) def goal_resume_task(self, task, goal_text=None, max_rounds=None, extra_env=None, model_provider=None) -> dict: """客内 host.py goal-resume:goal_text=None 为断点续跑 (不带 --goal-text,沿用原 goal+verify),给 goal_text 为开新目标(带 --goal-text 与 --max-rounds,默认 0)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") args = ["goal-resume", "--task", self._host_name(task)] if goal_text: args += ["--goal-text", goal_text, "--max-rounds", str(max_rounds if max_rounds is not None else 0)] elif max_rounds is not None: args += ["--max-rounds", str(max_rounds)] return self._run_with_vm( task, args, goal=True, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) def goal_resume_breakpoint_task(self, task, model_provider=None, extra_env=None) -> dict: """客内 host.py goal-resume 断点续跑:无 --goal-text、 无 --max-rounds,host 沿用原任务从断点轮次续跑。 worker 层已接线(补缺口轨):非网关的 CHV 断点任务 经 worker 的通用断点分支调到这里,model_provider 按名透传给客内 host.py(与 Local/Docker 同款口径, 首轮 thread 的 provider 必须一致);网关任务在 worker 侧仍被 loud fail 守卫拦住,不到这里。模型 key 本身绝不经本方法传值,只按环境变量名注入。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") return self._run_with_vm( task, ("goal-resume", "--task", self._host_name(task)), goal=True, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider)