From fde7bf4c3ee68d185428cb3c8d9b0d17f43154d5 Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 19:45:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20CHV=20=E6=89=A7=E8=A1=8C=E5=99=A8?= =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=88=E6=8B=86=E6=A8=A1=E5=9D=97/?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E5=A4=8D/SSH=20=E9=92=89=E6=AD=BB/=E6=B1=A0?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96/=E4=B8=AD=E9=80=94=E6=AD=BB?= =?UTF-8?q?=E4=BA=A1=E5=88=86=E7=B1=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆模块:CHV 从 cloud/sandbox.py 巨类拆到 cloud/chv/(executor/ pool/config/api_client/ssh_transport/disk/errors),sandbox 只留 兼容 re-export,旧导入路径与新路径是同一对象 - 去重复:goal 解析、host 顶层参数、runner 超时抽成共享 helper, Local 与 CHV 在矩阵输入下结论全等(Docker goal-resume 变体 有意保留内联版,见 PR 描述) - 安全:SSH host key 按 VM 钉死(state_dir//known_hosts, ssh-keyscan 首次捕获 TOFU),废除 no + /dev/null;CHV_SSH_USER 空串与 CHV_SSH_PORT 越界直接拒绝,不静默 fallback - execute 语义:只读已运行 VM,未运行报 vm_not_running,不为 status 起 VM - 预热池:registry 落盘、重启载入只当孤儿、reconcile 清理、 损坏 quarantine;条目仍一次性,绝不复用脏 VM - 长任务:VM 中途死亡判 interrupted(断点可续跑);中途心跳 经评估不做(无客内进度信号可采) - #215 未合并:只列复用清单,不复制模块 测试:原 42 项 + 新 24 项全过;相关旧套件与 cloud 全量 1496 项 全过(skipped=4)。验证仍是 mock + 干跑 + 单元测试,未做真机、 压测、安全审计;堆叠于 #216 之上,base 为 feat-cloud-hypervisor。 --- cloud/README.md | 24 +- cloud/chv/__init__.py | 18 + cloud/chv/api_client.py | 74 ++ cloud/chv/config.py | 146 +++ cloud/chv/disk.py | 43 + cloud/chv/errors.py | 12 + cloud/chv/executor.py | 1211 ++++++++++++++++++++ cloud/chv/pool.py | 257 +++++ cloud/chv/ssh_transport.py | 93 ++ cloud/executor_base.py | 30 + cloud/sandbox.py | 1580 +++----------------------- cloud/tests/test_chv_optimize.py | 586 ++++++++++ cloud/tests/test_cloud_hypervisor.py | 12 + docs/chv-production-readiness.md | 28 + 14 files changed, 2706 insertions(+), 1408 deletions(-) create mode 100644 cloud/chv/__init__.py create mode 100644 cloud/chv/api_client.py create mode 100644 cloud/chv/config.py create mode 100644 cloud/chv/disk.py create mode 100644 cloud/chv/errors.py create mode 100644 cloud/chv/executor.py create mode 100644 cloud/chv/pool.py create mode 100644 cloud/chv/ssh_transport.py create mode 100644 cloud/executor_base.py create mode 100644 cloud/tests/test_chv_optimize.py diff --git a/cloud/README.md b/cloud/README.md index 180d455..e3d6d68 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -1288,8 +1288,9 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳 错误文本或返回结果。worker CLI 的 `--executor` 新增 cloud 选项, 构造 CloudExecutor;worker 的 run_one 走 dispatch_task, CloudExecutor 的 dispatch_task/run_task 直接委托给 execute。 -- CloudHypervisorExecutor(生产级 v1,`cloud/sandbox.py`, - 执行器名 `cloud-hypervisor`,绝不用 `cloud` 缩写): +- CloudHypervisorExecutor(生产级 v1,`cloud/chv/`,旧路径 + `cloud/sandbox.py` 只留兼容 re-export,执行器名 + `cloud-hypervisor`,绝不用 `cloud` 缩写): 单任务单 VM:一个任务一个 VM、独立内核/文件系统/网络, 任务结束销毁,租户之间无共享路径、无东西向互访;VM 内 agent 可有 root,但只能影响本 VM。密钥按现有惯例走运行 @@ -1307,7 +1308,24 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳 用 pause + snapshot + shutdown 等价实现,恢复走 restore。 预热池 `WarmPool`:预热条目是一次性干净快照 VM,acquire 消耗、release 一律销毁,绝不跨任务复用脏 VM;池空回落 - 冷启动,绝不降级 Docker/Local。关闭与清理:任务结束/ + 冷启动,绝不降级 Docker/Local;池状态另落 + `state_dir/warm-pool.json` registry,worker 重启后载入的 + 条目一律当孤儿、只能 `reconcile_orphans()` 清理,绝不 + 重新发给任务,registry 损坏时 quarantine 成 `.corrupt` 并 + 从空池开始。SSH host key 按 VM 钉死(#216 的 + `StrictHostKeyChecking=no` + `/dev/null` 已废除):每 VM + 在 `state_dir//known_hosts` 有独立文件,首次启动经 + ssh-keyscan 在本机回环端口捕获 key 写入该文件(首次信任 + TOFU,只防首次之后的替换,不验证首次本身),此后 SSH + 一律 `StrictHostKeyChecking=yes` + 该文件校验;客机用户 + (`CHV_SSH_USER`,默认 root,显式空串直接拒绝)与端口 + (`CHV_SSH_PORT`,默认 2222,1-65535)为显式配置。 + `execute()` 只读已运行 VM:未运行报 `vm_not_running` + 分类失败,不为一次 status 起 VM。长任务中途死亡 + (传输异常、或结果已 failed 且登记 VMM 进程已死)判 + `interrupted` 而非笼统 failed;中途心跳经评估未做 + (host.py 单次 SSH 长回合无客内进度信号可采,另起轮询 + 只能证明 VM 活着)。关闭与清理:任务结束/ 失败/取消时 `shutdown_vm`(ch-remote shutdown + 杀进程 + 删 socket),`cleanup_stale_vms` 处理已死/已关/socket 残留 三种情况。审计:VM 的 create/boot/ready/pause/resume/ diff --git a/cloud/chv/__init__.py b/cloud/chv/__init__.py new file mode 100644 index 0000000..f60164d --- /dev/null +++ b/cloud/chv/__init__.py @@ -0,0 +1,18 @@ +"""Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。 + +公开名与 #216 一致,cloud/sandbox.py 会 re-export 它们, +保证旧导入路径可用: +- CloudHypervisorExecutor:执行器本体(executor.py) +- WarmPool:预热池 + registry 持久化与孤儿清理(pool.py) +- ChvFailure:分类失败(errors.py) + +子模块分工见 executor.py 头注释。本包顶层 import 不触 +cloud.sandbox(执行器方法内按需 import 共享 helper), +所以任何导入顺序都不会循环。 +""" + +from cloud.chv.errors import ChvFailure +from cloud.chv.executor import CloudHypervisorExecutor +from cloud.chv.pool import WarmPool + +__all__ = ["ChvFailure", "CloudHypervisorExecutor", "WarmPool"] diff --git a/cloud/chv/api_client.py b/cloud/chv/api_client.py new file mode 100644 index 0000000..92c5f53 --- /dev/null +++ b/cloud/chv/api_client.py @@ -0,0 +1,74 @@ +"""Cloud Hypervisor REST API 传输(叶子模块,不依赖 +cloud.sandbox,避免循环导入)。 + +只用标准库 raw HTTP over Unix socket,无第三方依赖。 +""" + +import json +import os +import socket + + +def _unix_socket_api(socket_path, method, path, payload=None): + """Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP + over Unix socket(无第三方依赖)。返回 (status, body), + body 能解析 JSON 时为 dict,否则为原文本。socket 不存在 + 或连接失败时 raise RuntimeError(调用方据此判 api 超时 + /失败,不编造 200)。""" + if not os.path.exists(socket_path): + raise RuntimeError("api socket 不存在:%s" % socket_path) + body = b"" + if payload is not None: + body = json.dumps(payload).encode("utf-8") + req_lines = ["%s /api/v1%s HTTP/1.1" % (method, path), + "Host: localhost", + "Accept: application/json", + "Content-Type: application/json", + "Content-Length: %d" % len(body), + "Connection: close", "", ""] + raw = "\r\n".join(req_lines).encode("utf-8") + body + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(5) + try: + sock.connect(socket_path) + sock.sendall(raw) + chunks = [] + while True: + try: + data = sock.recv(65536) + except socket.timeout: + break + if not data: + break + chunks.append(data) + finally: + sock.close() + text = b"".join(chunks).decode("utf-8", errors="replace") + status = 0 + if text.startswith("HTTP/"): + try: + status = int(text.split()[1]) + except (IndexError, ValueError): + status = 0 + body_text = text.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in text \ + else "" + try: + parsed = json.loads(body_text) if body_text.strip() else {} + except ValueError: + parsed = body_text + return status, parsed + + +def normalize_api_result(result): + """API 调用归一化:fake api_client 可返回 (status, body) + 或 {"status":..., "body":...} 或带 .status 的对象,本 + 函数统一成 (status:int, body)。""" + if isinstance(result, tuple) and len(result) == 2: + return int(result[0]), result[1] + if isinstance(result, dict): + status = result.get("status", result.get("status_code")) + body = result.get("body", result) + return (int(status) if status is not None else 0), body + status = getattr(result, "status", None) \ + or getattr(result, "status_code", None) + return (int(status) if status is not None else 0), result diff --git a/cloud/chv/config.py b/cloud/chv/config.py new file mode 100644 index 0000000..f444069 --- /dev/null +++ b/cloud/chv/config.py @@ -0,0 +1,146 @@ +"""Cloud Hypervisor 执行器配置解析(叶子模块,不依赖 +cloud.sandbox,避免循环导入)。 + +职责只有两件:内存字符串解析,以及把构造参数 + 环境变量 +解析成一份显式配置。参数优先于环境变量、环境变量优先于 +默认值——与 #216 原 __init__ 的 `or` 链完全一致,只收紧了 +两处原来会静默 fallback 的 nonsense 输入(见下文),收紧点 +在 PR 描述里明写、单测钉住: + +- CHV_SSH_USER 显式传空字符串:原来 `or` 链会静默 fallback + 成 root(调用方以为没给用户、实际跑了 root),现在直接 + ValueError。 +- CHV_SSH_PORT 显式传 0、或解析后超出 1-65535:原来 0 会 + 静默 fallback 成 2222、其它越界值会原样进 ssh 命令,现在 + 直接 ValueError。 + +与 #215 的关系(只列清单,不复制模块):#215 未合并, +本轨不假装复用它。#215 合并后应再复用的点:超时类 +(api_timeout/ready_timeout/ready_interval/task_timeout → +timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘 +配额门、egress fail-closed → gates 模块)、配额类 +(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、 +隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死 +→ sandbox_policy 模块)。见 PR 描述的复用清单。 +""" + +import re + + +def _parse_memory_bytes(value): + """内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。 + 非法值 ValueError,绝不静默按默认算。""" + m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$", + str(value)) + if not m: + raise ValueError("CHV_MEM 格式非法:%r" % (value,)) + mult = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3} + return int(m.group(1)) * mult[m.group(2).upper()] + + +class ChvConfig: + """解析后的 CHV 配置:纯数据对象,字段名与 + CloudHypervisorExecutor 原 __init__ 的属性名一一对应, + 执行器 __init__ 直接把字段抄到 self 上,旧测试读属性 + 的方式全部不变。""" + + def __init__(self, **fields): + for key, value in fields.items(): + setattr(self, key, value) + + +def build_chv_config(params, env): + """把显式构造参数(params,None=未给)与环境变量(env, + 默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。 + + 显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、 + 磁盘配额、egress、快照、预热池目标数本来就没有显式参数, + 仍只走环境变量(与 #216 一致,不悄悄加新参数面)。""" + env = env if env is not None else {} + binary = params.get("binary") or env.get("CHV_BINARY") \ + or "cloud-hypervisor" + kernel = params.get("kernel") or env.get("CHV_KERNEL") + rootfs = params.get("rootfs") or params.get("image") \ + or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE") + cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2") + memory = params.get("memory") or env.get("CHV_MEM") or "2G" + state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \ + or "/tmp/chv-vms" + ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY") + if params.get("ssh_user") is not None \ + and params.get("ssh_user") == "": + raise ValueError("CHV_SSH_USER 不能为空字符串") + ssh_user = params.get("ssh_user") or env.get("CHV_SSH_USER") \ + or "root" + if params.get("ssh_port") is not None: + raw_port = params.get("ssh_port") + else: + raw_port = env.get("CHV_SSH_PORT") or 2222 + try: + ssh_port = int(raw_port) + except (TypeError, ValueError): + raise ValueError("CHV_SSH_PORT 必须是整数") + if not 1 <= ssh_port <= 65535: + raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间") + guest_host_py = params.get("guest_host_py") or "/app/host.py" + cmdline = params.get("cmdline") or env.get("CHV_CMDLINE") \ + or "console=hvc0 root=/dev/vda1 rw" + net = params.get("net") or env.get("CHV_NET") + net_json = params.get("net_json") or env.get("CHV_NET_JSON") + kvm_path = params.get("kvm_path") \ + if params.get("kvm_path") is not None else "/dev/kvm" + cpuinfo_path = params.get("cpuinfo_path") \ + if params.get("cpuinfo_path") is not None else "/proc/cpuinfo" + ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \ + or "ch-remote" + model = params.get("model") + try: + max_vms = int(env.get("CHV_MAX_VMS") or 4) + except ValueError: + raise ValueError("CHV_MAX_VMS 必须是整数") + disk_mode = env.get("CHV_DISK_MODE") or "copy" + if disk_mode not in ("copy", "reuse"): + raise ValueError( + "CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode) + disk_gb_raw = env.get("CHV_DISK_GB") + disk_gb = float(disk_gb_raw) if disk_gb_raw else None + egress_proxy = env.get("CHV_EGRESS_PROXY") + allow_direct_egress = env.get("CHV_ALLOW_DIRECT_EGRESS") == "1" + task_timeout_raw = env.get("CHV_TASK_TIMEOUT") + task_timeout = float(task_timeout_raw) \ + if task_timeout_raw else None + snapshot_url = env.get("CHV_SNAPSHOT_URL") + try: + warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0) + except ValueError: + raise ValueError("CHV_WARM_POOL_SIZE 必须是整数") + try: + api_timeout = float( + params.get("api_timeout") + if params.get("api_timeout") is not None + else env.get("CHV_API_TIMEOUT") or 30) + ready_timeout = float( + params.get("ready_timeout") + if params.get("ready_timeout") is not None + else env.get("CHV_READY_TIMEOUT") or 120) + ready_interval = float( + params.get("ready_interval") + if params.get("ready_interval") is not None + else env.get("CHV_READY_INTERVAL") or 5) + except ValueError: + raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字") + if ready_interval <= 0: + ready_interval = 5 + return ChvConfig( + binary=binary, kernel=kernel, rootfs=rootfs, 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, kvm_path=kvm_path, + cpuinfo_path=cpuinfo_path, ch_remote=ch_remote, model=model, + max_vms=max_vms, disk_mode=disk_mode, disk_gb=disk_gb, + egress_proxy=egress_proxy, + allow_direct_egress=allow_direct_egress, + task_timeout=task_timeout, snapshot_url=snapshot_url, + warm_pool_size=warm_pool_size, api_timeout=api_timeout, + ready_timeout=ready_timeout, ready_interval=ready_interval) diff --git a/cloud/chv/disk.py b/cloud/chv/disk.py new file mode 100644 index 0000000..061a1c2 --- /dev/null +++ b/cloud/chv/disk.py @@ -0,0 +1,43 @@ +"""Cloud Hypervisor 每任务磁盘供给(叶子模块)。 + +单任务单 VM 的文件系统独立性就靠这里:copy 模式每任务 +复制一份基础镜像,两个任务绝不写同一文件;reuse 模式 +直接返回基础镜像路径(仅单租户实验,生产禁用,见 +CloudHypervisorExecutor 的类 docstring)。 +""" + +import os +import shutil + +from cloud.chv.errors import ChvFailure + + +def check_disk_quota(rootfs, disk_gb): + """磁盘配额门:CHV_DISK_GB 已设且基础镜像超过该 GB 数 + 即拒绝(kind=disk_quota_exceeded)。未设则不检查; + 镜像读不到时不拦(preflight 自会报 image_missing, + 这里不抢它的错)。""" + if disk_gb is None or not rootfs: + return + try: + size = os.path.getsize(rootfs) + except OSError: + return + if size > disk_gb * 1024 ** 3: + raise ChvFailure( + "disk_quota_exceeded", + "磁盘镜像 %d 字节超过配额 %.1f GB" % (size, disk_gb)) + + +def provision_task_disk(state_dir, vm_name, rootfs, disk_mode): + """每任务磁盘路径:copy 模式复制到 + state_dir//disk.raw(已存在则复用,不重复复制); + reuse 模式直接返回基础镜像路径。""" + if disk_mode == "reuse": + return rootfs + task_dir = os.path.join(state_dir, vm_name) + os.makedirs(task_dir, exist_ok=True) + task_disk = os.path.join(task_dir, "disk.raw") + if not os.path.exists(task_disk): + shutil.copyfile(rootfs, task_disk) + return task_disk diff --git a/cloud/chv/errors.py b/cloud/chv/errors.py new file mode 100644 index 0000000..45dd40c --- /dev/null +++ b/cloud/chv/errors.py @@ -0,0 +1,12 @@ +"""Cloud Hypervisor 执行器的分类失败(叶子模块,不依赖 +cloud.sandbox,避免循环导入)。""" + +class ChvFailure(RuntimeError): + """Cloud Hypervisor 分类失败:RuntimeError 的子类(worker + 现有 except Exception 路径照常捕获),另带 kind 字段供 + 审计与失败分类用,绝不笼统只写 failed。kind 取值见 + CloudHypervisorExecutor.preflight / wake_vm 的注释。""" + + def __init__(self, kind, message): + super().__init__("chv_failure:%s: %s" % (kind, message)) + self.kind = kind diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py new file mode 100644 index 0000000..f013300 --- /dev/null +++ b/cloud/chv/executor.py @@ -0,0 +1,1211 @@ +"""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.py),本类的 + goal_resume_breakpoint_task 已实现,供直接调用与后续 + worker 接线,不通过 worker 迂回假装已通。 + - 网关现状: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, 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): + 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, "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}, 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"): + 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(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 出口必须过白名单代理,默认不给直连外网")) + 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 启动命令(干跑可测)。 + + 顺序固定:<二进制> --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 capture_host_key(self, task): + """首次捕获客机 host key(TOFU,边界见 ssh_transport.py): + ssh-keyscan 经本机回环端口抓 key 行,写入该 VM 的 + known_hosts 文件(0600)。抓不到可用 key 行时返回 + False(不写文件、不报错),由 wait_guest_ready 下一轮 + 再试;sshd 还没起来时这就是常态,不是失败。""" + try: + result = self._call_runner( + ["ssh-keyscan", "-p", str(self.ssh_port), + "127.0.0.1"], timeout=10) + stdout = getattr(result, "stdout", "") or "" + except Exception: + return False + key_lines = parse_keyscan_output(stdout) + 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): + """构造 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。""" + return _build_ssh_cmd( + self.ssh_port, self.ssh_key, self.ssh_user, + self.egress_proxy, self.known_hosts_path(task), + inner_cmd) + + # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- + def build_vm_config(self, task_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"}, + } + 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 _check_capacity(self): + """宿主容量门:已登记 VM 数达到 max_vms 即明确拒绝 + (kind=host_capacity_exceeded,调用方应排队/拒绝, + 绝不降级 Docker/Local)。""" + if self._active_count() >= self.max_vms: + raise ChvFailure( + "host_capacity_exceeded", + "宿主 CHV 容量已满:已登记 %d 台,上限 %d," + "请排队或拒绝本任务" % (self._active_count(), + self.max_vms)) + + def _check_disk_quota(self): + """磁盘配额门(实现在 cloud/chv/disk.py): + CHV_DISK_GB 已设且基础镜像超过该 GB 数即拒绝 + (kind=disk_quota_exceeded)。未设则不检查。""" + check_disk_quota(self.rootfs, self.disk_gb) + + def _provision_task_disk(self, task): + """每任务磁盘供给(实现在 cloud/chv/disk.py): + - copy(默认):把基础镜像复制到 + state_dir//disk.raw,VmConfig 用该路径;复制 + 慢但两个任务绝不写同一文件,测试用小文件可直接验证。 + - reuse(仅单租户实验):直接返回基础镜像路径,磁盘 + 不独立,生产禁用(preflight 不拦它,但类 docstring + 与就绪清单里明写 reuse 不可用于生产)。 + 返回每任务磁盘路径。""" + 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),绝不在未就绪时 + 派任务。""" + 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() + self._check_capacity() + self._check_disk_quota() + name = self.vm_name(task) + 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) + try: + 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)) + 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) + 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._check_capacity() + name = self.vm_name(task) + 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 + 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, **kwargs): + """SSH 调客内 host.py,返回 Local/Docker 同款结果 dict + (executor 字段改写为 cloud-hypervisor,并附 vm 名与 + api-socket)。runner 异常即原样传播,由 worker 侧统一 + 写回 failed——本类不在这里把异常吞成假成功。""" + self._validate() + ssh_cmd = self.build_ssh_command( + task, self._guest_host_command(*inner_args, **kwargs)) + 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, **kwargs): + self._validate() + ssh_cmd = self.build_ssh_command( + task, self._guest_host_command(*inner_args, **kwargs)) + 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, **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, **kwargs) + result = self._run_guest(task, inner_args, **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)) + return self._run_guest( + task, ("status", "--task", self._host_name(task))) + + def dispatch_task(self, task) -> 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, model=task.get("model"), + effort=task.get("effort"), approval=task.get("approval"), + sandbox=("read-only" if task.get("plan") else None)) + + def run_task(self, task) -> 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"]), + model=task.get("model"), effort=task.get("effort"), + approval=task.get("approval")) + + def goal_task(self, task) -> 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, model=task.get("model"), + effort=task.get("effort"), approval=task.get("approval")) + + def goal_resume_task(self, task, goal_text=None, + max_rounds=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, model=task.get("model"), + effort=task.get("effort"), approval=task.get("approval")) + + def goal_resume_breakpoint_task(self, task) -> dict: + """客内 host.py goal-resume 断点续跑:无 --goal-text、 + 无 --max-rounds,host 沿用原任务从断点轮次续跑。 + 注意 worker 层对非 Local 执行器的断点续跑目前仍明确 + 失败,本方法供直接调用与后续 worker 接线(见类 + docstring),不要理解成 worker 路径已通。""" + 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, model=task.get("model"), + effort=task.get("effort"), approval=task.get("approval")) + + diff --git a/cloud/chv/pool.py b/cloud/chv/pool.py new file mode 100644 index 0000000..cb539cf --- /dev/null +++ b/cloud/chv/pool.py @@ -0,0 +1,257 @@ +"""快照预热池 WarmPool(含 state_dir registry 持久化与孤儿清理)。 + +隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的 +干净快照 VM,acquire 消耗掉它,release 一律销毁,绝不把 +一个任务用过的 VM(带任务文件、带 agent 痕迹)翻出来给 +下一个任务/租户复用;池子空了就回落冷启动(wake_vm), +绝不因为池空就降级 Docker/Local。 + +Registry(本轨新增): +- 文件:state_dir/warm-pool.json(registry_path 可覆盖), + 内容为 {<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。 + 每次 prewarm/acquire/release 后立即落盘(临时文件 + 原子 + rename),worker 崩溃后重启仍能发现上一世的条目。 +- 载入时绝不复用:新池实例从 registry 读到的条目一律标为 + orphan(不计入 warm_count,acquire 绝不发给任务),因为 + 旧 VMM 进程已随旧 worker 消失,无法验证它还是当初那个 + 干净快照 VM。它们只能走 reconcile_orphans() 清理。 +- reconcile_orphans():worker 启动/清理时的显式入口,对 + 每个 orphan/assigned 条目尽力调 executor.shutdown_vm + (清 socket 与残留登记),条目清掉并落盘,返回已清理的 + VM 名列表。registry 里没有的残留 socket 仍由执行器的 + cleanup_stale_vms() 扫 state_dir 处理,两者互补,不互相 + 替代;reconcile 绝不在构造时自动执行(同一 state_dir 只 + 能有一个活池,构造时自动清理会误杀另一个活池的 VM)。 +- 损坏 fail-safe:registry 不是合法 JSON / 顶层不是 dict 时, + 整个文件 quarantine 成 .corrupt(尽力而为),池子 + 从空开始,绝不用损坏数据复用 VM;单条目结构非法时只 + 丢该条(记 registry_corrupt_entries),不连坐其它条目。 + registry 写失败时只记日志警告、不炸任务流(池子内存态 + 仍可用,重启后孤儿改由 socket 扫描兜底)。 +""" + +import json +import logging +import os + +_LOGGER = logging.getLogger("cloud.cloud_hypervisor") + +_REGISTRY_FILENAME = "warm-pool.json" + + +class WarmPool: + """快照预热池(快速启动设计,接口与状态机在代码里)。 + + 目标:冷启动(preflight + 起 VMM + create + boot + SSH + 就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务 + 来时 acquire 直接 resume,省掉冷启动的大头。 + 状态机:cold(未预热)→ prewarming(wake 中)→ warm + (已暂停待分配)→ assigned(已分配给某任务)→ + running(已 resume)→ recycled/destroyed(release 后)。 + prewarm 的实现:对每个条目调 executor.wake_vm(冷启动) + 再 pause_vm,得到 paused 的干净 VM;mock 单测里 wake/ + pause 均可注入假实现,真机上这一步需要 KVM 宿主(见 + 就绪清单“需 KVM 宿主实测”)。snapshot_url 未设时 + prewarm 返回 [](无基准快照可预热,不是静默造快照)。 + """ + + def __init__(self, executor, size=None, snapshot_url=None, + registry_path=None): + self.executor = executor + self.size = int(size if size is not None + else getattr(executor, "warm_pool_size", 0)) + self.snapshot_url = snapshot_url \ + if snapshot_url is not None \ + else getattr(executor, "snapshot_url", None) + self._entries = {} # 合成名 -> {"state", "vm", "task", ...} + self.registry_path = registry_path + if self.registry_path is None: + state_dir = getattr(executor, "state_dir", None) + if state_dir: + self.registry_path = os.path.join( + state_dir, _REGISTRY_FILENAME) + self.registry_corrupt = False + self.registry_corrupt_entries = 0 + self._load_registry() + + # -- registry ------------------------------------------------------ + def _load_registry(self): + """载入 registry:读到的条目一律标 orphan(见模块 + docstring,绝不直接当 warm 发给任务)。无文件时为空 + 池;损坏时 quarantine 并从空开始。""" + if not self.registry_path: + return + try: + with open(self.registry_path, "r") as fh: + data = json.load(fh) + except FileNotFoundError: + return + except (OSError, ValueError): + self.registry_corrupt = True + self._quarantine_registry() + return + if not isinstance(data, dict): + self.registry_corrupt = True + self._quarantine_registry() + return + for name, entry in data.items(): + if not isinstance(entry, dict) \ + or not entry.get("vm") \ + or entry.get("state") not in ("warm", "assigned"): + self.registry_corrupt_entries += 1 + continue + self._entries[name] = { + "state": "orphan", + "vm": entry["vm"], + "task": {"id": entry.get("task_id") or name}, + "snapshot_url": entry.get("snapshot_url"), + } + + def _quarantine_registry(self): + """把损坏的 registry 文件挪到 .corrupt 旁(尽力 + 而为),原路径不再留损坏数据给下一次载入。""" + if not self.registry_path: + return + try: + os.replace(self.registry_path, + self.registry_path + ".corrupt") + except OSError: + _LOGGER.warning("chv_pool_registry_quarantine_failed " + "path=%s", self.registry_path) + + def _save_registry(self): + """落盘当前条目(只写 warm/assigned 态;orphan 是 + 待清理的上一世条目,reconcile 后即删,不写回)。 + 写失败只记日志,不炸任务流。""" + if not self.registry_path: + return + data = {} + for name, entry in self._entries.items(): + if entry["state"] not in ("warm", "assigned"): + continue + task = entry.get("task") or {} + task_id = task.get("id") if isinstance(task, dict) \ + else None + data[name] = { + "state": entry["state"], + "vm": entry.get("vm"), + "task_id": task_id, + "snapshot_url": entry.get("snapshot_url"), + } + try: + directory = os.path.dirname(self.registry_path) + if directory: + os.makedirs(directory, exist_ok=True) + tmp_path = self.registry_path + ".tmp" + with open(tmp_path, "w") as fh: + json.dump(data, fh) + os.replace(tmp_path, self.registry_path) + except OSError: + _LOGGER.warning("chv_pool_registry_save_failed path=%s", + self.registry_path) + + def reconcile_orphans(self): + """清理上一世残留条目:对每个 orphan/assigned 条目 + 尽力 shutdown_vm(清 socket 与残留登记),条目清掉 + 并落盘,返回已清理的 VM 名列表。worker 启动/清理时 + 显式调它(见模块 docstring 的单活池假设)。""" + cleaned = [] + for name, entry in list(self._entries.items()): + if entry["state"] not in ("orphan", "assigned"): + continue + vm = entry.get("vm") or name + try: + self.executor.shutdown_vm(vm) + except Exception: + pass + self._entries.pop(name, None) + cleaned.append(vm) + if cleaned: + self._save_registry() + try: + self.executor._emit( + "warm_pool_reconcile", {"id": None}, + "cleaned=%s" % ",".join(cleaned)) + except Exception: + pass + return cleaned + + # -- 池操作 ---------------------------------------------------------- + def warm_count(self): + return sum(1 for e in self._entries.values() + if e["state"] == "warm") + + def orphan_count(self): + """上一世残留条目数(待 reconcile,不可分配)。""" + return sum(1 for e in self._entries.values() + if e["state"] in ("orphan", "assigned")) + + def prewarm(self, count=None): + """预热到目标数:已有 warm 条目不重复建;每个新条目 + 用一个合成任务名 wake + pause(合成任务 id 为 + warm-<序号>,绝不与真实任务 id 撞名)。返回新增的 + warm VM 名列表。""" + if self.snapshot_url is None: + return [] + target = int(count) if count is not None else self.size + added = [] + i = 0 + while self.warm_count() < target: + i += 1 + name = "warm-%d" % i + if name in self._entries: + continue + task = {"id": name} + self._entries[name] = {"state": "prewarming", + "task": task} + try: + self.executor.wake_vm(task) + self.executor.pause_vm(task) + except Exception: + self._entries.pop(name, None) + raise + vm = self.executor.vm_name(task) + self._entries[name] = { + "state": "warm", "task": task, "vm": vm, + "snapshot_url": self.snapshot_url} + added.append(name) + if added: + self._save_registry() + return added + + def acquire(self, task): + """分配一个预热 VM 给任务:有 warm 条目时消耗一个 + (状态 assigned,返回 mode=warm + 该条目的合成任务 + 名,供调用方 resume 后使用);无 warm 条目时返回 + mode=cold(调用方回落 wake_vm 冷启动)。""" + for name, entry in list(self._entries.items()): + if entry["state"] == "warm": + entry["state"] = "assigned" + entry["task"] = task + self._save_registry() + self.executor._emit( + "warm_pool_acquire", task, + "vm=%s" % name) + return {"mode": "warm", "vm": name, + "pool_task": entry["task"]} + return {"mode": "cold", "vm": None, "pool_task": None} + + def release(self, task, pool_vm=None): + """释放:无论 warm 还是 cold 路径,VM 一律销毁 + (executor.shutdown_vm),池条目清掉;绝不把用过 + 的 VM 放回 warm 池。返回 True 表示确实销毁了一个 + 条目或 VM。""" + destroyed = False + if pool_vm and pool_vm in self._entries: + self._entries.pop(pool_vm, None) + destroyed = True + try: + if self.executor.shutdown_vm(task): + destroyed = True + except Exception: + pass + self._save_registry() + self.executor._emit( + "warm_pool_release", task, + "vm=%s destroyed=%s" % (pool_vm, destroyed)) + return destroyed diff --git a/cloud/chv/ssh_transport.py b/cloud/chv/ssh_transport.py new file mode 100644 index 0000000..5001f49 --- /dev/null +++ b/cloud/chv/ssh_transport.py @@ -0,0 +1,93 @@ +"""Cloud Hypervisor 客机 SSH 传输(叶子模块,不依赖 +cloud.sandbox,避免循环导入)。 + +安全模型(本轨的核心变化,替代 #216 的 +StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null): + +- 每 VM 一个独立 known_hosts 文件: + state_dir//known_hosts(与每任务磁盘同目录, + 任务销毁时不自动删——known_hosts 留在宿主 state_dir 里, + 供事后审计该 VM 当时钉死的 key;VM 名本身已含任务 id, + 不会串到别的 VM 上)。 +- 首次信任(TOFU,边界必须写清,不假装是强验证): + 首次启动时客机 host key 未知,执行器用 ssh-keyscan 经 + 宿主转发端口(127.0.0.1:,只走本机回环)抓一次 + 当时呈现的 key,写入该 VM 的 known_hosts 文件;此后 + 同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes + + 该文件校验,VM 被替换 / 中间人换 key 时 SSH 直接拒绝, + 不再静默接受。首次捕获本身不验证“这台客机就是我们要 + 的那台”——它只防首次之后的替换;更强的方案是镜像构建 + /cloud-init 时把客机 host key 预置进 known_hosts(未来 + 工作,见 docs/chv-production-readiness.md 的待办),本轨 + 在 mock 测试里只能验证“捕获→写文件→后续命令带钉死 + 选项”的管线,绝不声称 mock 验证了首次密钥的真实性。 +- 密钥红线不变:argv 里只有 key 文件路径,绝无 key 值; + known_hosts 文件只含 host key 公钥行,不含任何私钥。 +""" + +import os + + +def known_hosts_path(state_dir, vm_name): + """该 VM 的 known_hosts 文件路径:state_dir//known_hosts。""" + return os.path.join(state_dir, vm_name, "known_hosts") + + +def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy, + known_hosts_file, inner_cmd): + """构造 SSH 命令:ssh -p <端口> -i + 钉死选项 + + user@127.0.0.1 + 客内命令。 + + 与 #216 的区别只有 host key 选项:StrictHostKeyChecking=yes + + UserKnownHostsFile=<每 VM 文件>,不再是 no + /dev/null。 + BatchMode=yes 不变(绝不交互问 host key / 口令)。 + egress_proxy 已设时客内命令前加 env 前缀(代理地址本身 + 不是密钥;客机出口只走该白名单代理)。""" + inner = list(inner_cmd) + if egress_proxy: + inner = ["env", + "HTTPS_PROXY=%s" % egress_proxy, + "HTTP_PROXY=%s" % egress_proxy, + "NO_PROXY=localhost,127.0.0.1"] + inner + return ["ssh", "-p", str(ssh_port), + "-i", ssh_key, + "-o", "StrictHostKeyChecking=yes", + "-o", "UserKnownHostsFile=%s" % known_hosts_file, + "-o", "BatchMode=yes", + "%s@127.0.0.1" % ssh_user] + inner + + +def parse_keyscan_output(text): + """从 ssh-keyscan 的 stdout 里挑出本机回环端口的 host key + 行:形如 "[127.0.0.1]: ssh-ed25519 AAAA..."(-p 时 + 的标准写法)或 "127.0.0.1 ssh-rsa AAAA..."。其它主机行、 + 注释行、空行一律丢掉,绝不把陌生主机的 key 写进文件。""" + kept = [] + for line in (text or "").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) < 3: + continue + host, keytype = parts[0], parts[1] + if host != "127.0.0.1" \ + and not host.startswith("[127.0.0.1]:"): + continue + if not keytype.startswith("ssh-"): + continue + kept.append(line) + return kept + + +def write_known_hosts(path, key_lines): + """原子写 known_hosts:先写同目录临时文件再 rename, + 文件权限 0600(只含公钥行,仍按敏感文件对待)。""" + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + tmp_path = path + ".tmp" + with open(tmp_path, "w") as fh: + fh.write("\n".join(key_lines) + "\n") + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) diff --git a/cloud/executor_base.py b/cloud/executor_base.py new file mode 100644 index 0000000..e9da5eb --- /dev/null +++ b/cloud/executor_base.py @@ -0,0 +1,30 @@ +"""执行器基类(叶子模块,不依赖 cloud.sandbox 与 cloud.chv, +避免循环导入)。 + +原来在 cloud/sandbox.py 里;拆分后 Local/Docker/Cloud +执行器(sandbox.py 内)与 CloudHypervisorExecutor +(cloud/chv/executor.py)共用这一个类对象,isinstance +判断与旧路径 `from cloud.sandbox import Executor` 都不变 +(sandbox.py 会 re-export 它)。 +""" + + +class Executor: + """执行器基类。 + + task 是 dict,含 id、project_id、repo、prompt、branch(可选)。 + execute(task) -> dict,至少含 executor、task_id、status、stdout。 + """ + + def execute(self, task) -> dict: + raise NotImplementedError("子类必须实现 execute(task)") + + def goal_task(self, task) -> dict: + """目标循环(host.py goal):turn→验测→喂回重试。 + + 子类实现;默认不支持(CloudExecutor 的通用 HTTP 协议里 + 没有 goal/验测概念,调它会如实失败,不静默降级成普通 + dispatch)。结果 dict 除 execute() 的字段外另含: + goal_met(True/False/None=无结论)、goal_error。 + """ + raise NotImplementedError("该执行器不支持 goal 模式") diff --git a/cloud/sandbox.py b/cloud/sandbox.py index 3e1a5c4..c8b776e 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -12,10 +12,12 @@ 容器。网络白名单不在本类里假装实现(见 cloud/README.md)。 - CloudExecutor:占位,未接通云端执行。 - CloudHypervisorExecutor:生产级 v1,Cloud Hypervisor VM 内经 - SSH 调 host.py(API 唤醒流程见该类 docstring:preflight → - vmm.ping → vm.create → vm.boot → SSH 就绪 → 跑任务 → - finally 关 VM;任务内 LLM 绝不能发现/调用它,执行器选择只 - 在任务创建前由 server 白名单/环境默认值/worker 认领决定)。 + SSH 调 host.py(已拆到 cloud/chv/,API 唤醒流程见该类 + docstring:preflight → vmm.ping → vm.create → vm.boot → + SSH 就绪 → 跑任务 → finally 关 VM;任务内 LLM 绝不能 + 发现/调用它,执行器选择只在任务创建前由 server 白名单/ + 环境默认值/worker 认领决定)。本模块只留它的兼容 + re-export,旧导入路径仍可用。 """ import json @@ -75,25 +77,10 @@ def _full_stdout(text): _DOCKER_LOGGER = logging.getLogger("cloud.docker") -class Executor: - """执行器基类。 - - task 是 dict,含 id、project_id、repo、prompt、branch(可选)。 - execute(task) -> dict,至少含 executor、task_id、status、stdout。 - """ - - def execute(self, task) -> dict: - raise NotImplementedError("子类必须实现 execute(task)") - - def goal_task(self, task) -> dict: - """目标循环(host.py goal):turn→验测→喂回重试。 - - 子类实现;默认不支持(CloudExecutor 的通用 HTTP 协议里 - 没有 goal/验测概念,调它会如实失败,不静默降级成普通 - dispatch)。结果 dict 除 execute() 的字段外另含: - goal_met(True/False/None=无结论)、goal_error。 - """ - raise NotImplementedError("该执行器不支持 goal 模式") +# 执行器基类已移到叶子模块 cloud/executor_base.py(供 +# cloud/chv/executor.py 共用,避免循环导入);这里 re-export, +# 旧路径 `from cloud.sandbox import Executor` 仍可用。 +from cloud.executor_base import Executor # noqa: F401 def _clean_usage(raw): @@ -321,6 +308,133 @@ def _approval_cli_args(approval): return [] +def _host_command_options(model=None, effective_model=None, + model_provider=None, effort=None, + approval=None, sandbox=None): + """host.py 顶层全局参数(Local/Docker/CHV 三执行器共享, + 本轨去重复):顺序固定为 --model → --model-provider → + --effort → approval 参数 → --sandbox,且必须放在子命令 + 之前。approval=full 自带 danger-full-access,显式 sandbox + 在后(argparse 里后出现的 --sandbox 覆盖前面的,见 + LocalExecutor._base_command 的旧注释)。""" + cmd = [] + effective = model or effective_model + if effective: + cmd += ["--model", effective] + if model_provider: + cmd += ["--model-provider", model_provider] + if effort: + cmd += ["--effort", effort] + cmd += _approval_cli_args(approval) + if sandbox: + cmd += ["--sandbox", sandbox] + return cmd + + +def _call_runner_with_timeout(runner, cmd, timeout=None): + """runner 调用共享 helper(CHV 与 Docker 的 env=None 分支 + 共用):timeout 给定时优先传 timeout,假 runner 只接受 + cmd 时 TypeError 回退到只传 cmd(超时护栏丢了,但绝不 + 因此静默换执行器;worker 层 task_timeout 仍是外层硬护栏)。 + Docker 的 env 分支与 Local 的 _run_local 语义不同(env + 丢了必须 loud fail,绝不静默降级),所以它们不走这里。""" + if timeout is None: + return runner(cmd) + try: + return runner(cmd, timeout=timeout) + except TypeError: + return runner(cmd) + + +def parse_goal_result(task_id, result, executor_name): + """host.py goal 输出解析的正典实现(Local 与 CHV 共享, + 本轨去重复;Docker 的 goal_task 仍保留内联版,见下文)。 + + 解析用完整 stdout(结论在末尾),存库的 stdout 才截尾; + returncode 非 0 时:已打印 met/unmet 结论的仍判 failed, + 无结论的异常退出判 interrupted(断点在盘上,可续跑, + 绝不能一律 failed);host 打印了 interrupted 标记时也判 + interrupted。为 0 时无结论仍判 failed,绝不把“没打印 + 结论”当成达标。host 任务已存在时(同任务 /retry 撞名, + goal 没有 run 续跑语义)错误写明“goal 失败请用继续而 + 非重试”。断点丢失拒绝(goal-resume 拒绝行)判 failed + 终态,不进自动恢复。 + + Docker 未收敛的原因(有意保留,不是不小心漏掉): + Docker 的 goal-resume 变体故意没有“已存在”分支 + (goal-resume 本来就是操作已有 host 任务,不存在 + dispatch 撞名问题),且它的超时证据路径与解析绑在 + 一起;硬收敛会改 Docker 生产路径的语义,本轨不动它。 + 三执行器在通用输入下结论一致由 test_chv_optimize.py + 的矩阵测试钉住(CHV 与 Local 全等;Docker 的通用 + goal 路径由既有 docker 测试覆盖)。""" + returncode = getattr(result, "returncode", 1) + stdout_full = getattr(result, "stdout", "") or "" + stderr = getattr(result, "stderr", "") or "" + met, goal_error = _parse_goal_output(stdout_full) + interrupted_reason = _goal_interrupted_reason(stdout_full) + refusal = _goal_resume_refusal(stdout_full) + combined = stdout_full + stderr + perm_kind, perm_snippet = _classify_goal_error( + stdout_full, stderr) + if refusal is not None and met is None: + # 断点丢失拒绝:终态 failed,不进自动恢复。 + met, goal_error = None, refusal + status = "failed" + elif "已存在" in combined: + met, goal_error = None, ( + "host 任务已存在:goal 失败请用继续而非重试——" + "同任务重试会撞已存在 host-state,worker 会先" + "尝试断点续跑,断点续跑仍失败时请用继续(不要" + "重排同一任务)") + status = "failed" + elif interrupted_reason is not None: + # host 侧已记 interrupted:异常中断不是失败,云端跟 + # 着记 interrupted,任务可 /goal-resume 断点续跑。 + status = "interrupted" + goal_error = (interrupted_reason + or "host goal 异常中断,断点可续跑") + elif (met is None and returncode != 0 + and perm_kind == "permanent"): + # P0 三件其二:永久错误(断点 KeyError、配置缺失等) + # 的无结论异常退出——同代码同断点重试必然再失败, + # 判 failed 终态进死信,绝不判 interrupted 空转。 + # host 已明言 interrupted 的在上一分支,不受影响。 + status = "failed" + goal_error = perm_snippet or "goal 永久错误" + elif returncode != 0: + if met is None: + # 无结论的异常退出(进程被杀、连接断后恢复时抛错 + # 等):断点在盘上,记 interrupted 保住续跑活路, + # 绝不能一律 failed——2026-10-28 长 goal 实弹教训。 + status = "interrupted" + goal_error = ((stderr.strip()[-500:] if stderr.strip() + else stdout_full.strip()[-500:]) + or "host goal 异常退出(无结论),断点可续跑") + else: + status = "failed" + elif met is True: + status = "completed" + elif met is False: + status = "failed" + else: + status = "failed" + goal_error = goal_error or "host goal 输出无结论" + return { + "executor": executor_name, + "task_id": task_id, + "status": status, + "stdout": _goal_stdout_tail(stdout_full), + "stderr": stderr, + "returncode": returncode, + "host_status": ("goal-met" if met is True else + "goal-unmet" if met is False else None), + "goal_met": met, + "goal_error": goal_error, + "usage": _parse_goal_usage(stdout_full), + } + + class LocalExecutor(Executor): """本地执行器:调 host.py CLI 子进程做执行封装。 @@ -363,22 +477,17 @@ class LocalExecutor(Executor): if self.config_path is not None: cmd += ["--config", self.config_path] # G9:顶层全局参数,必须放在子命令之前(调用方在 - # _base_command 之后才追加子命令)。 + # _base_command 之后才追加子命令)。选项顺序与 + # Docker/CHV 共享 _host_command_options;显式沙箱放 + # 最后:argparse 里后出现的 --sandbox 覆盖前面的 + # (approval=full 自带 danger-full-access);server 侧 + # 已拒绝 plan+full 同存,这里是第二道防线。 if getattr(self, "ignore_host_budget", False): cmd += ["--ignore-host-budget"] - effective = model or self.model - if effective: - cmd += ["--model", effective] - if model_provider: - cmd += ["--model-provider", model_provider] - if effort: - cmd += ["--effort", effort] - cmd += _approval_cli_args(approval) - # 显式沙箱放最后:argparse 里后出现的 --sandbox 覆盖前面 - # 的(approval=full 自带 danger-full-access);server 侧 - # 已拒绝 plan+full 同存,这里是第二道防线。 - if sandbox: - cmd += ["--sandbox", sandbox] + cmd += _host_command_options( + model=model, effective_model=self.model, + model_provider=model_provider, effort=effort, + approval=approval, sandbox=sandbox) return cmd def _run_local(self, cmd, extra_env): @@ -718,81 +827,15 @@ class LocalExecutor(Executor): def _goal_result_from_runner(self, task_id, result): """把 host.py goal 的 runner 返回值解析成 goal 结果 dict。 - 解析用完整 stdout(结论在末尾),存库的 stdout 才截尾; - returncode 非 0 时:已打印 met/unmet 结论的仍判 failed, - 无结论的异常退出判 interrupted(断点在盘上,可续跑, - 绝不能一律 failed);host 打印了 interrupted 标记时也判 - interrupted。为 0 时无结论仍判 failed,绝不把“没打印 - 结论”当成达标。host 任务已存在时(同任务 /retry 撞名, - goal 没有 run 续跑语义)错误写明“goal 失败请用继续而 - 非重试”(P1:重试在已存在 host-state 上必失败,断点续 - 跑才是正确路径,见 worker 的回落逻辑)。 + 直接委托给共享的 parse_goal_result(正典语义,见该 + 函数 docstring):解析用完整 stdout(结论在末尾), + 存库的 stdout 才截尾;returncode 非 0 时已打印结论 + 的仍判 failed,无结论的异常退出判 interrupted;为 0 + 时无结论仍判 failed;“已存在”撞名与断点丢失拒绝判 + failed 终态。本方法只定 executor="local",语义与旧 + 内联版逐行一致(旧版就是正典的来源)。 """ - returncode = getattr(result, "returncode", 1) - stdout_full = getattr(result, "stdout", "") or "" - stderr = getattr(result, "stderr", "") or "" - met, goal_error = _parse_goal_output(stdout_full) - interrupted_reason = _goal_interrupted_reason(stdout_full) - refusal = _goal_resume_refusal(stdout_full) - combined = stdout_full + stderr - perm_kind, perm_snippet = _classify_goal_error( - stdout_full, stderr) - if refusal is not None and met is None: - # 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。 - met, goal_error = None, refusal - status = "failed" - elif "已存在" in combined: - met, goal_error = None, ( - "host 任务已存在:goal 失败请用继续而非重试——" - "同任务重试会撞已存在 host-state,worker 会先" - "尝试断点续跑,断点续跑仍失败时请用继续(不要" - "重排同一任务)") - status = "failed" - elif interrupted_reason is not None: - # host 侧已记 interrupted:异常中断不是失败,云端跟 - # 着记 interrupted,任务可 /goal-resume 断点续跑。 - status = "interrupted" - goal_error = (interrupted_reason - or "host goal 异常中断,断点可续跑") - elif (met is None and returncode != 0 - and perm_kind == "permanent"): - # P0 三件其二:永久错误(断点 KeyError、配置缺失等) - # 的无结论异常退出——同代码同断点重试必然再失败, - # 判 failed 终态进死信,绝不判 interrupted 空转。 - # host 已明言 interrupted 的在上一分支,不受影响。 - status = "failed" - goal_error = perm_snippet or "goal 永久错误" - elif returncode != 0: - if met is None: - # 无结论的异常退出(进程被杀、连接断后恢复时抛错 - # 等):断点在盘上,记 interrupted 保住续跑活路, - # 绝不能一律 failed——2026-10-28 长 goal 实弹教训。 - status = "interrupted" - goal_error = ((stderr.strip()[-500:] if stderr.strip() - else stdout_full.strip()[-500:]) - or "host goal 异常退出(无结论),断点可续跑") - else: - status = "failed" - elif met is True: - status = "completed" - elif met is False: - status = "failed" - else: - status = "failed" - goal_error = goal_error or "host goal 输出无结论" - return { - "executor": "local", - "task_id": task_id, - "status": status, - "stdout": _goal_stdout_tail(stdout_full), - "stderr": stderr, - "returncode": returncode, - "host_status": ("goal-met" if met is True else - "goal-unmet" if met is False else None), - "goal_met": met, - "goal_error": goal_error, - "usage": _parse_goal_usage(stdout_full), - } + return parse_goal_result(task_id, result, "local") def popen_task(self, task, kind="dispatch", seed_dir=None, extra_env=None, model_provider=None): @@ -1186,18 +1229,13 @@ class DockerExecutor(Executor): cmd += ["--config", "/app/" + os.path.basename(self.config_path)] # G9:与 LocalExecutor 同款,顶层全局参数,放子命令之前。 + # 选项顺序与 Local/CHV 共享 _host_command_options。 if getattr(self, "ignore_host_budget", False): cmd += ["--ignore-host-budget"] - effective = model or self.model - if effective: - cmd += ["--model", effective] - if model_provider: - cmd += ["--model-provider", model_provider] - if effort: - cmd += ["--effort", effort] - cmd += _approval_cli_args(approval) - if sandbox: - cmd += ["--sandbox", sandbox] + 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 @@ -1213,12 +1251,9 @@ class DockerExecutor(Executor): 丢掉密钥跑任务(那样容器内 key 缺失,任务必失败还难查)。 """ if env is None: - try: - return self.runner( - cmd, - timeout=self.timeout if timeout is None else timeout) - except TypeError: - return self.runner(cmd) + return _call_runner_with_timeout( + self.runner, cmd, + self.timeout if timeout is None else timeout) try: return self.runner( cmd, @@ -2155,1283 +2190,18 @@ class CloudExecutor(Executor): return self.execute(task) -_CHV_LOGGER = logging.getLogger("cloud.cloud_hypervisor") -class ChvFailure(RuntimeError): - """Cloud Hypervisor 分类失败:RuntimeError 的子类(worker - 现有 except Exception 路径照常捕获),另带 kind 字段供 - 审计与失败分类用,绝不笼统只写 failed。kind 取值见 - CloudHypervisorExecutor.preflight / wake_vm 的注释。""" - - def __init__(self, kind, message): - super().__init__("chv_failure:%s: %s" % (kind, message)) - self.kind = kind - - -def _parse_memory_bytes(value): - """内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。 - 非法值 ValueError,绝不静默按默认算。""" - m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$", - str(value)) - if not m: - raise ValueError("CHV_MEM 格式非法:%r" % (value,)) - mult = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3} - return int(m.group(1)) * mult[m.group(2).upper()] - - -def _unix_socket_api(socket_path, method, path, payload=None): - """Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP - over Unix socket(无第三方依赖)。返回 (status, body), - body 能解析 JSON 时为 dict,否则为原文本。socket 不存在 - 或连接失败时 raise RuntimeError(调用方据此判 api 超时 - /失败,不编造 200)。""" - if not os.path.exists(socket_path): - raise RuntimeError("api socket 不存在:%s" % socket_path) - body = b"" - if payload is not None: - body = json.dumps(payload).encode("utf-8") - req_lines = ["%s /api/v1%s HTTP/1.1" % (method, path), - "Host: localhost", - "Accept: application/json", - "Content-Type: application/json", - "Content-Length: %d" % len(body), - "Connection: close", "", ""] - raw = "\r\n".join(req_lines).encode("utf-8") + body - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(5) - try: - sock.connect(socket_path) - sock.sendall(raw) - chunks = [] - while True: - try: - data = sock.recv(65536) - except socket.timeout: - break - if not data: - break - chunks.append(data) - finally: - sock.close() - text = b"".join(chunks).decode("utf-8", errors="replace") - status = 0 - if text.startswith("HTTP/"): - try: - status = int(text.split()[1]) - except (IndexError, ValueError): - status = 0 - body_text = text.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in text \ - else "" - try: - parsed = json.loads(body_text) if body_text.strip() else {} - except ValueError: - parsed = body_text - return status, parsed - - -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 生命周期”),且 v1 用 - StrictHostKeyChecking=no + BatchMode=yes:客机是临时 - 启动的、host key 每次都变,严格校验会误杀;生产加固时 - 应改为预置 known_hosts 钉死客机 key,本 v1 不假装已做。 - - 密钥红线: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。 - - 清理要能处理三种情况:已死进程(poll() 非 None)、 - 已关 VM(无登记进程、无 socket,stop 返回 False)、 - socket 残留(有 socket、无登记进程,照常删 socket 并 - 计入清理)。cleanup_stale_vms() 只清 chv- 前缀的 - socket,绝不按前缀全删其它执行器的东西。 - - 生产级口径(对齐公开沙箱保障口径,不声称复刻某家内部架构): - - 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM; - 每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访; - VM 内 root 只能影响本 VM。 - - 快速启动:WarmPool(本文件末尾)预热干净快照 VM,acquire - 消耗、release 销毁,池空回落冷启动,绝不降级。 - - 资源与成本: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.py),本类的 - goal_resume_breakpoint_task 已实现,供直接调用与后续 - worker 接线,不通过 worker 迂回假装已通。 - - 网关现状: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, 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): - self._env = env if env is not None else os.environ - self.binary = binary or self._env.get("CHV_BINARY") \ - or "cloud-hypervisor" - self.kernel = kernel or self._env.get("CHV_KERNEL") - self.rootfs = rootfs or image \ - or self._env.get("CHV_ROOTFS") \ - or self._env.get("CHV_IMAGE") - self.cpus = str(cpus or self._env.get("CHV_CPU") or "2") - self.memory = memory or self._env.get("CHV_MEM") or "2G" - self.state_dir = state_dir or self._env.get("CHV_STATE_DIR") \ - or "/tmp/chv-vms" - self.ssh_key = ssh_key or self._env.get("CHV_SSH_KEY") - self.ssh_user = ssh_user or self._env.get("CHV_SSH_USER") \ - or "root" - try: - self.ssh_port = int(ssh_port - or self._env.get("CHV_SSH_PORT") - or 2222) - except (TypeError, ValueError): - raise ValueError("CHV_SSH_PORT 必须是整数") - self.guest_host_py = guest_host_py - self.cmdline = cmdline or self._env.get("CHV_CMDLINE") \ - or "console=hvc0 root=/dev/vda1 rw" - self.net = net or self._env.get("CHV_NET") - self.net_json = net_json or self._env.get("CHV_NET_JSON") - self.kvm_path = kvm_path - self.cpuinfo_path = cpuinfo_path - self.ch_remote = ch_remote or self._env.get("CHV_REMOTE") \ - or "ch-remote" - self.model = model - # 生产级配置(参数优先于环境变量;见类 docstring 与 - # cloud/README.md 的 CHV 一节,默认值全部显式写出): - # - max_vms:本执行器实例同时最多几个 VM(宿主容量门, - # 超限明确拒绝,绝不降级 Docker/Local) - # - disk_mode:copy=每任务复制一份磁盘镜像(默认,慢但 - # 文件系统真正独立);reuse=复用基础镜像路径(仅单租户 - # 实验用,多租户下磁盘不独立,绝不能用于生产) - # - disk_gb:基础镜像超过该 GB 数即拒绝(磁盘配额门) - # - egress_proxy:客机出口白名单代理地址(必填,除非 - # allow_direct_egress 显式开;直连外网默认不给) - # - task_timeout:SSH 单次任务命令超时秒数(None=不限, - # 但 worker 层 task_timeout 仍是外层硬护栏) - # - snapshot_url:快照池的基准快照(无则冷启动) - # - warm_pool_size:预热池目标数(0=关闭预热池) - try: - self.max_vms = int( - self._env.get("CHV_MAX_VMS") or 4) - except ValueError: - raise ValueError("CHV_MAX_VMS 必须是整数") - self.disk_mode = (self._env.get("CHV_DISK_MODE") or "copy") - if self.disk_mode not in ("copy", "reuse"): - raise ValueError( - "CHV_DISK_MODE 只能是 copy 或 reuse:%s" - % self.disk_mode) - disk_gb_raw = self._env.get("CHV_DISK_GB") - self.disk_gb = float(disk_gb_raw) if disk_gb_raw else None - self.egress_proxy = self._env.get("CHV_EGRESS_PROXY") - self.allow_direct_egress = ( - self._env.get("CHV_ALLOW_DIRECT_EGRESS") == "1") - task_timeout_raw = self._env.get("CHV_TASK_TIMEOUT") - self.task_timeout = float(task_timeout_raw) \ - if task_timeout_raw else None - self.snapshot_url = self._env.get("CHV_SNAPSHOT_URL") - try: - self.warm_pool_size = int( - self._env.get("CHV_WARM_POOL_SIZE") or 0) - except ValueError: - raise ValueError("CHV_WARM_POOL_SIZE 必须是整数") - 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 - try: - self.api_timeout = float( - api_timeout if api_timeout is not None - else self._env.get("CHV_API_TIMEOUT") or 30) - self.ready_timeout = float( - ready_timeout if ready_timeout is not None - else self._env.get("CHV_READY_TIMEOUT") or 120) - self.ready_interval = float( - ready_interval if ready_interval is not None - else self._env.get("CHV_READY_INTERVAL") or 5) - except ValueError: - raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字") - if self.ready_interval <= 0: - self.ready_interval = 5 - self.audit_callback = None - self._vms_lock = threading.Lock() - self.runner = runner if runner is not None else _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(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 出口必须过白名单代理,默认不给直连外网")) - 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: - _CHV_LOGGER.info("chv_audit event=%s kind=%s %s", - event, kind, detail) - except Exception: - pass - - # -- 命令构造(纯函数,不做文件校验) ------------------------------- - def build_vm_command(self, task): - """构造 cloud-hypervisor 启动命令(干跑可测)。 - - 顺序固定:<二进制> --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 build_ssh_command(self, task, inner_cmd): - """构造 SSH 命令:ssh -p <端口> -i + 安全选项 - + user@127.0.0.1 + 客内命令。argv 里只有 key 文件路径, - 绝无任何 key 值。egress_proxy 已设时客内命令前加 env - 前缀(HTTPS_PROXY/HTTP_PROXY/NO_PROXY,代理地址本身 - 不是密钥;客机出口只走该白名单代理,见类 docstring)。""" - inner = list(inner_cmd) - if self.egress_proxy: - inner = ["env", - "HTTPS_PROXY=%s" % self.egress_proxy, - "HTTP_PROXY=%s" % self.egress_proxy, - "NO_PROXY=localhost,127.0.0.1"] + inner - return ["ssh", "-p", str(self.ssh_port), - "-i", self.ssh_key, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "BatchMode=yes", - "%s@127.0.0.1" % self.ssh_user] + inner - - # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- - def build_vm_config(self, task_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"}, - } - 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 调用归一化:fake api_client 可返回 (status, body) - 或 {"status":..., "body":...} 或带 .status 的对象,本 - 方法统一成 (status:int, body)。""" - result = self.api_client(socket_path, method, path, payload) - if isinstance(result, tuple) and len(result) == 2: - return int(result[0]), result[1] - if isinstance(result, dict): - status = result.get("status", result.get("status_code")) - body = result.get("body", result) - return (int(status) if status is not None else 0), body - status = getattr(result, "status", None) \ - or getattr(result, "status_code", None) - return (int(status) if status is not None else 0), result - - def _call_runner(self, cmd, timeout=None): - """runner 调用:timeout 给定时优先传 timeout,假 runner - 只接受 cmd 时 TypeError 回退到只传 cmd(超时护栏丢了, - 但绝不因此静默换执行器;worker 层 task_timeout 仍是 - 外层硬护栏)。""" - if timeout is None: - return self.runner(cmd) - try: - return self.runner(cmd, timeout=timeout) - except TypeError: - return self.runner(cmd) - - 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 _check_capacity(self): - """宿主容量门:已登记 VM 数达到 max_vms 即明确拒绝 - (kind=host_capacity_exceeded,调用方应排队/拒绝, - 绝不降级 Docker/Local)。""" - if self._active_count() >= self.max_vms: - raise ChvFailure( - "host_capacity_exceeded", - "宿主 CHV 容量已满:已登记 %d 台,上限 %d," - "请排队或拒绝本任务" % (self._active_count(), - self.max_vms)) - - def _check_disk_quota(self): - """磁盘配额门:CHV_DISK_GB 已设且基础镜像超过该 GB 数 - 即拒绝(kind=disk_quota_exceeded)。未设则不检查。""" - if self.disk_gb is None or not self.rootfs: - return - try: - size = os.path.getsize(self.rootfs) - except OSError: - return - if size > self.disk_gb * 1024 ** 3: - raise ChvFailure( - "disk_quota_exceeded", - "磁盘镜像 %d 字节超过配额 %.1f GB" - % (size, self.disk_gb)) - - def _provision_task_disk(self, task): - """每任务磁盘供给(单任务单 VM 的文件系统独立性): - - copy(默认):把基础镜像复制到 - state_dir//disk.raw,VmConfig 用该路径;复制 - 慢但两个任务绝不写同一文件,测试用小文件可直接验证。 - - reuse(仅单租户实验):直接返回基础镜像路径,磁盘 - 不独立,生产禁用(preflight 不拦它,但类 docstring - 与就绪清单里明写 reuse 不可用于生产)。 - 返回每任务磁盘路径。""" - if self.disk_mode == "reuse": - return self.rootfs - task_dir = os.path.join(self.state_dir, self.vm_name(task)) - os.makedirs(task_dir, exist_ok=True) - task_disk = os.path.join(task_dir, "disk.raw") - if not os.path.exists(task_disk): - shutil.copyfile(self.rootfs, task_disk) - return task_disk - - 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 次。 - 超时时先关 VM 再 raise ChvFailure(kind=guest_not_ready), - 绝不在未就绪时派任务。""" - attempts = max(1, int(self.ready_timeout / self.ready_interval)) - for attempt in range(attempts): - try: - 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 同款(必须在 - 子命令之前)。guest_host_py 默认 /app/host.py:客机镜像 - 须把 host.py 放在该路径(与 Docker 的 /app:ro 同款约定, - 只是载体从容器换成 VM 镜像)。""" - cmd = ["python3", self.guest_host_py] - effective = model or self.model - if effective: - cmd += ["--model", effective] - if model_provider: - cmd += ["--model-provider", model_provider] - if effort: - cmd += ["--effort", effort] - cmd += _approval_cli_args(approval) - if sandbox: - cmd += ["--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() - self._check_capacity() - self._check_disk_quota() - name = self.vm_name(task) - 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) - try: - 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)) - 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) - 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._check_capacity() - name = self.vm_name(task) - 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 - 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) - _CHV_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, **kwargs): - """SSH 调客内 host.py,返回 Local/Docker 同款结果 dict - (executor 字段改写为 cloud-hypervisor,并附 vm 名与 - api-socket)。runner 异常即原样传播,由 worker 侧统一 - 写回 failed——本类不在这里把异常吞成假成功。""" - self._validate() - ssh_cmd = self.build_ssh_command( - task, self._guest_host_command(*inner_args, **kwargs)) - result = self._call_runner(ssh_cmd, timeout=self.task_timeout) - 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 输出解析:结论在末尾,解析规则与 - LocalExecutor._goal_result_from_runner 同款语义(met - True=达标 completed,False=未达标 failed,interrupted - 标记/无结论异常退出=interrupted 保住断点续跑活路, - 永久错误无结论=failed 死信),executor 字段为 - cloud-hypervisor。""" - returncode = getattr(result, "returncode", 1) - stdout_full = getattr(result, "stdout", "") or "" - stderr = getattr(result, "stderr", "") or "" - met, goal_error = _parse_goal_output(stdout_full) - interrupted_reason = _goal_interrupted_reason(stdout_full) - refusal = _goal_resume_refusal(stdout_full) - perm_kind, perm_snippet = _classify_goal_error( - stdout_full, stderr) - if refusal is not None and met is None: - status, goal_error = "failed", refusal - elif interrupted_reason is not None: - status = "interrupted" - goal_error = (interrupted_reason - or "host goal 异常中断,断点可续跑") - elif met is None and returncode != 0 \ - and perm_kind == "permanent": - status, goal_error = "failed", perm_snippet or "goal 永久错误" - elif returncode != 0 and met is None: - status = "interrupted" - goal_error = ((stderr.strip()[-500:] if stderr.strip() - else stdout_full.strip()[-500:]) - or "host goal 异常退出(无结论),断点可续跑") - elif met is True: - status = "completed" - elif met is False: - status = "failed" - else: - status = "failed" - goal_error = goal_error or "host goal 输出无结论" - return { - "executor": "cloud-hypervisor", - "task_id": task_id, - "status": status, - "stdout": _goal_stdout_tail(stdout_full), - "stderr": stderr, - "returncode": returncode, - "host_status": ("goal-met" if met is True else - "goal-unmet" if met is False else None), - "goal_met": met, - "goal_error": goal_error, - "usage": _parse_goal_usage(stdout_full), - } - - def _run_guest_goal(self, task, inner_args, **kwargs): - self._validate() - ssh_cmd = self.build_ssh_command( - task, self._guest_host_command(*inner_args, **kwargs)) - return self._goal_result( - task["id"], - self._call_runner(ssh_cmd, timeout=self.task_timeout)) - - def _run_with_vm(self, task, inner_args, goal=False, **kwargs): - """每任务 VM 生命周期:wake_vm(API 模式 + SSH 就绪) - → 跑客内命令 → finally shutdown_vm。wake 失败时不跑 - 任务、直接抛分类失败;跑任务抛异常时 finally 仍关 VM, - 异常原样传播(worker 侧统一写回 failed)。本方法不做 - 内部重试:重试是 worker 的 max_retries 语义,执行器 - 内部重试会把一次失败放大成多次计费与多次 VM 启动。""" - self.wake_vm(task) - try: - if goal: - return self._run_guest_goal(task, inner_args, **kwargs) - return self._run_guest(task, inner_args, **kwargs) - finally: - try: - self.shutdown_vm(task) - except Exception: - pass - - def execute(self, task) -> dict: - """只读执行:客内 host.py status,不直接跑模型回合 - (与 Local/Docker 的 execute 同款语义)。""" - if not isinstance(task, dict) or task.get("id") is None: - raise ValueError("任务缺少 id") - return self._run_guest( - task, ("status", "--task", self._host_name(task))) - - def dispatch_task(self, task) -> 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, model=task.get("model"), - effort=task.get("effort"), approval=task.get("approval"), - sandbox=("read-only" if task.get("plan") else None)) - - def run_task(self, task) -> 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"]), - model=task.get("model"), effort=task.get("effort"), - approval=task.get("approval")) - - def goal_task(self, task) -> 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, model=task.get("model"), - effort=task.get("effort"), approval=task.get("approval")) - - def goal_resume_task(self, task, goal_text=None, - max_rounds=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, model=task.get("model"), - effort=task.get("effort"), approval=task.get("approval")) - - def goal_resume_breakpoint_task(self, task) -> dict: - """客内 host.py goal-resume 断点续跑:无 --goal-text、 - 无 --max-rounds,host 沿用原任务从断点轮次续跑。 - 注意 worker 层对非 Local 执行器的断点续跑目前仍明确 - 失败,本方法供直接调用与后续 worker 接线(见类 - docstring),不要理解成 worker 路径已通。""" - 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, model=task.get("model"), - effort=task.get("effort"), approval=task.get("approval")) - - -class WarmPool: - """快照预热池(快速启动设计,接口与状态机在代码里)。 - - 目标:冷启动(preflight + 起 VMM + create + boot + SSH - 就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务 - 来时 acquire 直接 resume,省掉冷启动的大头。 - 隔离红线(绝不妥协):预热条目是一次性的干净快照 VM, - acquire 消耗掉它,release 一律销毁,绝不把一个任务用 - 过的 VM(带任务文件、带 agent 痕迹)翻出来给下一个 - 任务/租户复用;池子空了就回落冷启动(wake_vm),绝不 - 因为池空就降级 Docker/Local。 - 状态机:cold(未预热)→ prewarming(wake 中)→ warm - (已暂停待分配)→ assigned(已分配给某任务)→ - running(已 resume)→ recycled/destroyed(release 后)。 - prewarm 的实现:对每个条目调 executor.wake_vm(冷启动) - 再 pause_vm,得到 paused 的干净 VM;mock 单测里 wake/ - pause 均可注入假实现,真机上这一步需要 KVM 宿主(见 - 就绪清单“需 KVM 宿主实测”)。snapshot_url 未设时 - prewarm 返回 [](无基准快照可预热,不是静默造快照)。 - """ - - def __init__(self, executor, size=None, snapshot_url=None): - self.executor = executor - self.size = int(size if size is not None - else getattr(executor, "warm_pool_size", 0)) - self.snapshot_url = snapshot_url \ - if snapshot_url is not None \ - else getattr(executor, "snapshot_url", None) - self._entries = {} # vm 名 -> {"state": ..., "task": ...} - - def warm_count(self): - return sum(1 for e in self._entries.values() - if e["state"] == "warm") - - def prewarm(self, count=None): - """预热到目标数:已有 warm 条目不重复建;每个新条目 - 用一个合成任务名 wake + pause(合成任务 id 为 - warm-<序号>,绝不与真实任务 id 撞名)。返回新增的 - warm VM 名列表。""" - if self.snapshot_url is None: - return [] - target = int(count) if count is not None else self.size - added = [] - i = 0 - while self.warm_count() < target: - i += 1 - name = "warm-%d" % i - if name in self._entries: - continue - task = {"id": name} - self._entries[name] = {"state": "prewarming", - "task": task} - try: - self.executor.wake_vm(task) - self.executor.pause_vm(task) - except Exception: - self._entries.pop(name, None) - raise - self._entries[name] = {"state": "warm", "task": task} - added.append(name) - return added - - def acquire(self, task): - """分配一个预热 VM 给任务:有 warm 条目时消耗一个 - (状态 assigned,返回 mode=warm + 该条目的合成任务 - 名,供调用方 resume 后使用);无 warm 条目时返回 - mode=cold(调用方回落 wake_vm 冷启动)。""" - for name, entry in list(self._entries.items()): - if entry["state"] == "warm": - entry["state"] = "assigned" - entry["task"] = task - self.executor._emit( - "warm_pool_acquire", task, - "vm=%s" % name) - return {"mode": "warm", "vm": name, - "pool_task": entry["task"]} - return {"mode": "cold", "vm": None, "pool_task": None} - - def release(self, task, pool_vm=None): - """释放:无论 warm 还是 cold 路径,VM 一律销毁 - (executor.shutdown_vm),池条目清掉;绝不把用过 - 的 VM 放回 warm 池。返回 True 表示确实销毁了一个 - 条目或 VM。""" - destroyed = False - if pool_vm and pool_vm in self._entries: - self._entries.pop(pool_vm, None) - destroyed = True - try: - if self.executor.shutdown_vm(task): - destroyed = True - except Exception: - pass - self.executor._emit( - "warm_pool_release", task, - "vm=%s destroyed=%s" % (pool_vm, destroyed)) - return destroyed +# -- Cloud Hypervisor 执行器(已拆到 cloud/chv/,这里只留兼容 re-export) -- +# #216 的 CHV 类(原 2233-3342 行)与 WarmPool(原 3342-3437 行)、 +# _parse_memory_bytes、_unix_socket_api 已分别移到 cloud/chv/ +# 的 executor.py / pool.py / config.py / api_client.py;本模块 +# 不再保留它们的副本,旧导入路径(from cloud.sandbox import +# CloudHypervisorExecutor / WarmPool / ChvFailure)仍可用, +# 新代码优先从 cloud.chv import。执行器方法内按需 import +# 本模块共享 helper 的原因见 cloud/chv/executor.py 头注释。 +from cloud.chv import ChvFailure # noqa: F401 +from cloud.chv import CloudHypervisorExecutor # noqa: F401 +from cloud.chv import WarmPool # noqa: F401 +from cloud.chv.api_client import _unix_socket_api # noqa: F401 +from cloud.chv.config import _parse_memory_bytes # noqa: F401 diff --git a/cloud/tests/test_chv_optimize.py b/cloud/tests/test_chv_optimize.py new file mode 100644 index 0000000..f9cc4de --- /dev/null +++ b/cloud/tests/test_chv_optimize.py @@ -0,0 +1,586 @@ +"""Cloud Hypervisor 优化轨(feat-chv-optimize,堆叠于 #216 之上)测试。 + +只用标准库与 mock runner/launcher/api_client,绝不启动真实 +VM(与 test_cloud_hypervisor.py 同款边界)。覆盖: +- 拆模块:cloud/chv/* 新路径与 cloud/sandbox 旧路径是同一对象 +- 去重复:Local 与 CHV 的 goal 解析在矩阵输入下结论全等; + 三执行器 host 顶层参数顺序全等;runner 超时 helper +- 安全:每 VM known_hosts 钉死、ssh-keyscan 首次捕获(TOFU)、 + 非 root 用户与端口显式配置与收紧校验 +- execute 语义:只读已运行 VM,未运行报 vm_not_running +- 预热池:registry 持久化、重启载入只当孤儿、reconcile 清理、 + 损坏 quarantine、一 次性不复用 +- 长任务:VM 中途死亡判 interrupted 而非 failed/抛异常 +""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +try: + from cloud.chv import ChvFailure + from cloud.chv import CloudHypervisorExecutor + from cloud.chv import WarmPool + from cloud.chv.api_client import _unix_socket_api as _chv_api + from cloud.chv.config import _parse_memory_bytes as _chv_mem + from cloud.sandbox import ChvFailure as _sb_failure + from cloud.sandbox import CloudHypervisorExecutor as _sb_exec + from cloud.sandbox import DockerExecutor + from cloud.sandbox import LocalExecutor + from cloud.sandbox import WarmPool as _sb_pool + from cloud.sandbox import _call_runner_with_timeout + from cloud.sandbox import _parse_memory_bytes as _sb_mem + from cloud.sandbox import _unix_socket_api as _sb_api +except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.chv import ChvFailure + from cloud.chv import CloudHypervisorExecutor + from cloud.chv import WarmPool + from cloud.chv.api_client import _unix_socket_api as _chv_api + from cloud.chv.config import _parse_memory_bytes as _chv_mem + from cloud.sandbox import ChvFailure as _sb_failure + from cloud.sandbox import CloudHypervisorExecutor as _sb_exec + from cloud.sandbox import DockerExecutor + from cloud.sandbox import LocalExecutor + from cloud.sandbox import WarmPool as _sb_pool + from cloud.sandbox import _call_runner_with_timeout + from cloud.sandbox import _parse_memory_bytes as _sb_mem + from cloud.sandbox import _unix_socket_api as _sb_api + + +class FakeResult: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class FakeProc: + def __init__(self, alive=True): + self._alive = alive + self.terminated = False + + def poll(self): + return None if self._alive else 1 + + def terminate(self): + self.terminated = True + self._alive = False + + def wait(self, timeout=None): + return 0 + + def kill(self): + self._alive = False + + +def _write(path, content=b"x"): + with open(path, "wb") as fh: + fh.write(content) + os.chmod(path, 0o755) + return path + + +class ChvOptCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.binary = _write(os.path.join(self.dir, "cloud-hypervisor")) + self.ch_remote = _write(os.path.join(self.dir, "ch-remote")) + self.kernel = _write(os.path.join(self.dir, "vmlinux")) + self.rootfs = _write(os.path.join(self.dir, "disk.raw"), + b"R" * 100) + self.ssh_key = _write(os.path.join(self.dir, "id_chv")) + self.kvm = _write(os.path.join(self.dir, "kvm")) + self.cpuinfo = os.path.join(self.dir, "cpuinfo") + with open(self.cpuinfo, "w") as fh: + fh.write("flags : fpu vmx sse\n") + self.state_dir = os.path.join(self.dir, "state") + self.calls = [] + self.api_calls = [] + self.launches = [] + + def tearDown(self): + self.tmp.cleanup() + + def make_exec(self, runner=None, api_client=None, launcher=None, + env_extra=None, **overrides): + env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888"} + if env_extra: + env.update(env_extra) + if runner is None: + runner = self._default_runner + if api_client is None: + api_client = self._default_api + if launcher is None: + launcher = self._default_launcher + kwargs = dict( + binary=self.binary, ch_remote=self.ch_remote, + kernel=self.kernel, rootfs=self.rootfs, + ssh_key=self.ssh_key, kvm_path=self.kvm, + cpuinfo_path=self.cpuinfo, state_dir=self.state_dir, + runner=runner, launcher=launcher, api_client=api_client, + sleeper=lambda s: None, env=env) + kwargs.update(overrides) + return CloudHypervisorExecutor(**kwargs) + + def _default_runner(self, cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd[0] == "ssh": + if cmd[-1] == "true": + return FakeResult(0, "", "") + if "dispatch" in cmd: + return FakeResult(0, json.dumps( + {"status": "completed", "text": "ok-dispatch"}), "") + if "goal" in cmd: + return FakeResult(0, "goal met after rounds\n", "") + if "status" in cmd: + return FakeResult(0, json.dumps( + {"status": "completed", "text": "ok-status"}), "") + return FakeResult(0, "", "") + return FakeResult(0, "", "") + + def _default_api(self, socket_path, method, path, payload=None): + self.api_calls.append((method, path, payload)) + return (200, {}) + + def _default_launcher(self, cmd): + self.launches.append(list(cmd)) + return FakeProc(alive=True) + + def _task(self, task_id="abc123"): + return {"id": task_id, "prompt": "做一个加法函数", + "project_id": "proj1"} + + +# -- 拆模块 --------------------------------------------------------------- +class SplitTest(unittest.TestCase): + def test_new_and_old_import_paths_are_same_objects(self): + self.assertIs(CloudHypervisorExecutor, _sb_exec) + self.assertIs(WarmPool, _sb_pool) + self.assertIs(ChvFailure, _sb_failure) + self.assertIs(_chv_mem, _sb_mem) + self.assertIs(_chv_api, _sb_api) + + def test_classes_live_in_chv_modules(self): + self.assertEqual(CloudHypervisorExecutor.__module__, + "cloud.chv.executor") + self.assertEqual(WarmPool.__module__, "cloud.chv.pool") + self.assertEqual(ChvFailure.__module__, "cloud.chv.errors") + + +# -- 去重复:goal 解析与 host 命令 ------------------------------------------ +class SharedGoalTest(unittest.TestCase): + CASES = [ + # (returncode, stdout, stderr) + (0, "goal met after rounds\n", ""), + (0, "round 1 verify_out=bad\ngoal unmet after rounds\n", ""), + (1, "goal interrupted after rounds: boom\n", ""), + (1, "KeyError: 'x'\n", ""), + (1, "some odd failure\n", ""), + (0, "nothing conclusive\n", ""), + (1, "goal-resume 拒绝:断点丢失\n", ""), + (1, "任务已存在\n", ""), + (1, "goal met after rounds\n", ""), + (1, "goal unmet after rounds\n", ""), + (1, "", "HTTP 503 Service Unavailable\n"), + ] + + def test_local_and_chv_goal_conclusions_match(self): + local = LocalExecutor("/tmp/host.py") + chv = ChvOptCase() + chv.setUp() + try: + ex = chv.make_exec() + for rc, out, err in self.CASES: + res = FakeResult(rc, out, err) + a = local._goal_result_from_runner("t1", res) + b = ex._goal_result("t1", res) + self.assertEqual( + (a["status"], a["goal_met"]), + (b["status"], b["goal_met"]), + "rc=%s stdout=%r" % (rc, out)) + self.assertEqual(a["executor"], "local") + self.assertEqual(b["executor"], "cloud-hypervisor") + finally: + chv.tearDown() + + def test_already_exists_message_points_to_continue(self): + chv = ChvOptCase() + chv.setUp() + try: + ex = chv.make_exec() + out = ex._goal_result( + "t1", FakeResult(1, "任务已存在\n", "")) + self.assertEqual(out["status"], "failed") + self.assertIn("继续", out["goal_error"]) + finally: + chv.tearDown() + + def test_host_command_options_identical_across_executors(self): + chv = ChvOptCase() + chv.setUp() + try: + ex = chv.make_exec() + local = LocalExecutor("/tmp/host.py") + docker = DockerExecutor() + for kwargs in ( + {"model": "m1", "effort": "low", "approval": "ask"}, + {"model": "m1", "effort": "low", "approval": "full", + "sandbox": "read-only", + "model_provider": "sub2api"}, + ): + lcmd = local._base_command(**kwargs) + dcmd = docker._inner_host_command("status", **kwargs) + ccmd = ex._guest_host_command("status", **kwargs) + self.assertEqual(lcmd[2:], dcmd[2:-1]) + self.assertEqual(lcmd[2:], ccmd[2:-1]) + finally: + chv.tearDown() + + def test_runner_timeout_helper(self): + seen = [] + + def only_cmd(cmd): + seen.append(("cmd-only", list(cmd))) + return FakeResult(0, "", "") + + out = _call_runner_with_timeout(only_cmd, ["x"], timeout=5) + self.assertEqual(out.returncode, 0) + self.assertEqual(seen, [("cmd-only", ["x"])]) + + def with_timeout(cmd, timeout=None): + seen.append(("with-timeout", timeout)) + return FakeResult(0, "", "") + + _call_runner_with_timeout(with_timeout, ["x"], timeout=7) + self.assertEqual(seen[-1], ("with-timeout", 7)) + _call_runner_with_timeout(with_timeout, ["x"], timeout=None) + self.assertEqual(seen[-1], ("with-timeout", None)) + + +# -- 安全:known_hosts 钉死 ------------------------------------------------- +class SshPinningTest(ChvOptCase): + def test_known_hosts_path_is_per_vm(self): + ex = self.make_exec() + a = ex.known_hosts_path({"id": "t-a"}) + b = ex.known_hosts_path({"id": "t-b"}) + self.assertNotEqual(a, b) + self.assertEqual(a, os.path.join(self.state_dir, "chv-t-a", + "known_hosts")) + + def test_capture_host_key_writes_pinned_file_0600(self): + def keyscan(cmd, timeout=None): + self.calls.append(list(cmd)) + return FakeResult( + 0, "[127.0.0.1]:2222 ssh-ed25519 AAAAB3test\n" + "evil.example.com ssh-rsa AAAAEvil\n" + "# comment\n", "") + ex = self.make_exec(runner=keyscan) + self.assertTrue(ex.capture_host_key(self._task())) + path = ex.known_hosts_path(self._task()) + with open(path) as fh: + content = fh.read() + self.assertIn("[127.0.0.1]:2222 ssh-ed25519 AAAAB3test", + content) + self.assertNotIn("evil.example.com", content) + self.assertEqual(os.stat(path).st_mode & 0o777, 0o600) + + def test_capture_host_key_empty_is_false_not_error(self): + ex = self.make_exec( + runner=lambda cmd, timeout=None: FakeResult(0, "", "")) + self.assertFalse(ex.capture_host_key(self._task())) + self.assertFalse(os.path.exists( + ex.known_hosts_path(self._task()))) + + def test_wait_guest_ready_captures_before_ssh(self): + def runner(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd[0] == "ssh-keyscan": + return FakeResult( + 0, "[127.0.0.1]:2222 ssh-ed25519 AAAAB3test\n", + "") + if cmd[0] == "ssh" and cmd[-1] == "true": + return FakeResult(0, "", "") + return FakeResult(0, "", "") + ex = self.make_exec(runner=runner) + self.assertTrue(ex.wait_guest_ready(self._task())) + self.assertTrue(os.path.exists( + ex.known_hosts_path(self._task()))) + keyscan_idx = next(i for i, c in enumerate(self.calls) + if c[0] == "ssh-keyscan") + ssh_idx = next(i for i, c in enumerate(self.calls) + if c[0] == "ssh" and c[-1] == "true") + self.assertLess(keyscan_idx, ssh_idx) + + def test_ssh_user_port_explicit_config(self): + ex = self.make_exec(ssh_user="codex", ssh_port=2201) + cmd = ex.build_ssh_command(self._task(), ["true"]) + joined = " ".join(cmd) + self.assertIn("codex@127.0.0.1", joined) + self.assertNotIn("root@127.0.0.1", joined) + self.assertIn("2201", joined) + + def test_ssh_user_port_tightened_validation(self): + with self.assertRaises(ValueError): + self.make_exec(ssh_user="") + with self.assertRaises(ValueError): + self.make_exec(ssh_port=0) + with self.assertRaises(ValueError): + self.make_exec(ssh_port=70000) + with self.assertRaises(ValueError): + self.make_exec(ssh_port="abc") + with self.assertRaises(ValueError): + self.make_exec(env_extra={"CHV_SSH_PORT": "0"}) + + +# -- execute 语义 ------------------------------------------------------------- +class ExecuteGateTest(ChvOptCase): + def test_execute_without_running_vm_is_classified(self): + ex = self.make_exec() + with self.assertRaises(ChvFailure) as ctx: + ex.execute(self._task()) + self.assertEqual(ctx.exception.kind, "vm_not_running") + self.assertEqual(self.launches, []) + self.assertFalse(any(c[0] == "ssh" for c in self.calls)) + + def test_execute_with_dead_proc_and_no_socket_is_classified(self): + ex = self.make_exec() + ex._vms["chv-abc123"] = FakeProc(alive=False) + with self.assertRaises(ChvFailure) as ctx: + ex.execute(self._task()) + self.assertEqual(ctx.exception.kind, "vm_not_running") + + def test_execute_with_socket_but_no_registry_proceeds(self): + ex = self.make_exec() + os.makedirs(self.state_dir, exist_ok=True) + with open(os.path.join(self.state_dir, + "chv-abc123.sock"), "w") as fh: + fh.write("") + out = ex.execute(self._task()) + self.assertIn("ok-status", out["stdout"]) + self.assertEqual(self.launches, []) + + +# -- 预热池持久化与孤儿清理 ---------------------------------------------------- +class StubExecutor: + """WarmPool 测试替身:只实现池子调用的鸭子接口。""" + + def __init__(self, state_dir): + self.state_dir = state_dir + self.warm_pool_size = 2 + self.snapshot_url = "file:///snap/base" + self.wake_calls = [] + self.pause_calls = [] + self.shutdown_calls = [] + self.audit = [] + + def vm_name(self, task): + raw = task.get("id") if isinstance(task, dict) else str(task) + return raw if raw.startswith("chv-") else "chv-" + raw + + def wake_vm(self, task): + self.wake_calls.append(task["id"]) + return {"vm": self.vm_name(task), "state": "running"} + + def pause_vm(self, task): + self.pause_calls.append(task["id"]) + return True + + def shutdown_vm(self, task_or_name): + name = task_or_name if isinstance(task_or_name, str) \ + else self.vm_name(task_or_name) + self.shutdown_calls.append(name) + return True + + def _emit(self, event, task, detail="", kind=None): + self.audit.append(event) + + +class PoolRegistryTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.state_dir = os.path.join(self.tmp.name, "state") + os.makedirs(self.state_dir, exist_ok=True) + self.registry = os.path.join(self.state_dir, "warm-pool.json") + + def tearDown(self): + self.tmp.cleanup() + + def test_persist_load_orphan_reconcile(self): + stub = StubExecutor(self.state_dir) + pool = WarmPool(stub, size=2, snapshot_url="file:///snap/base") + added = pool.prewarm() + self.assertEqual(len(added), 2) + self.assertTrue(os.path.exists(self.registry)) + with open(self.registry) as fh: + data = json.load(fh) + self.assertEqual(len(data), 2) + # 新池实例载入:上一世条目只当孤儿,绝不发给任务: + stub2 = StubExecutor(self.state_dir) + pool2 = WarmPool(stub2, size=2, + snapshot_url="file:///snap/base") + self.assertEqual(pool2.warm_count(), 0) + self.assertEqual(pool2.orphan_count(), 2) + self.assertEqual(pool2.acquire({"id": "t1"})["mode"], "cold") + cleaned = pool2.reconcile_orphans() + self.assertEqual(len(cleaned), 2) + self.assertEqual(sorted(stub2.shutdown_calls), sorted(cleaned)) + self.assertEqual(pool2.orphan_count(), 0) + with open(self.registry) as fh: + self.assertEqual(json.load(fh), {}) + # 第三实例:无残留: + pool3 = WarmPool(StubExecutor(self.state_dir), size=2, + snapshot_url="file:///snap/base") + self.assertEqual(pool3.orphan_count(), 0) + + def test_corrupt_registry_quarantines_and_starts_empty(self): + with open(self.registry, "w") as fh: + fh.write("{not json") + pool = WarmPool(StubExecutor(self.state_dir), size=2, + snapshot_url="file:///snap/base") + self.assertTrue(pool.registry_corrupt) + self.assertEqual(pool.warm_count(), 0) + self.assertEqual(pool.orphan_count(), 0) + self.assertTrue(os.path.exists(self.registry + ".corrupt")) + self.assertFalse(os.path.exists(self.registry)) + # 损坏后仍可正常预热(registry 重建): + self.assertEqual(len(pool.prewarm()), 2) + self.assertTrue(os.path.exists(self.registry)) + + def test_assigned_entry_on_load_is_never_served(self): + with open(self.registry, "w") as fh: + json.dump({"warm-1": {"state": "assigned", + "vm": "chv-warm-1", + "task_id": "old-task", + "snapshot_url": "file:///snap/base"}}, + fh) + pool = WarmPool(StubExecutor(self.state_dir), size=2, + snapshot_url="file:///snap/base") + self.assertEqual(pool.warm_count(), 0) + self.assertEqual(pool.acquire({"id": "t1"})["mode"], "cold") + self.assertEqual(pool.reconcile_orphans(), ["chv-warm-1"]) + + def test_release_never_rewarms_and_registry_clears(self): + stub = StubExecutor(self.state_dir) + pool = WarmPool(stub, size=1, snapshot_url="file:///snap/base") + added = pool.prewarm() + got = pool.acquire({"id": "t1"}) + self.assertEqual(got["mode"], "warm") + self.assertTrue(pool.release({"id": "t1"}, pool_vm=added[0])) + self.assertEqual(pool.warm_count(), 0) + with open(self.registry) as fh: + self.assertEqual(json.load(fh), {}) + + +# -- 长任务中途死亡 -------------------------------------------------------------- +class MidDeathTest(ChvOptCase): + def _dispatch_with(self, runner): + ex = self.make_exec(runner=runner) + return ex.dispatch_task(self._task()) + + def test_failed_result_with_dead_vm_is_interrupted(self): + proc_holder = [] + + def launcher(cmd): + proc = FakeProc(alive=True) + proc_holder.append(proc) + self.launches.append(list(cmd)) + return proc + + def runner(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd[0] == "ssh" and "dispatch" in cmd: + proc_holder[0]._alive = False + return FakeResult(1, "", + "Connection closed by remote host") + if cmd[0] == "ssh": + return FakeResult(0, "", "") + return FakeResult(0, "", "") + + ex = self.make_exec(runner=runner, launcher=launcher) + out = ex.dispatch_task(self._task()) + self.assertEqual(out["status"], "interrupted") + self.assertTrue(out["vm_dead"]) + + def test_failed_result_with_live_vm_stays_failed(self): + def runner(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd[0] == "ssh" and "dispatch" in cmd: + return FakeResult(1, "", "host dispatch failed") + if cmd[0] == "ssh": + return FakeResult(0, "", "") + return FakeResult(0, "", "") + + ex = self.make_exec(runner=runner) + out = ex.dispatch_task(self._task()) + self.assertEqual(out["status"], "failed") + self.assertNotIn("vm_dead", out) + + def test_exception_with_dead_vm_is_interrupted_not_raised(self): + proc_holder = [] + + def launcher(cmd): + proc = FakeProc(alive=True) + proc_holder.append(proc) + self.launches.append(list(cmd)) + return proc + + def runner(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd[0] == "ssh" and "dispatch" in cmd: + proc_holder[0]._alive = False + raise RuntimeError("ssh transport broken") + if cmd[0] == "ssh": + return FakeResult(0, "", "") + return FakeResult(0, "", "") + + ex = self.make_exec(runner=runner, launcher=launcher) + out = ex.dispatch_task(self._task()) + self.assertEqual(out["status"], "interrupted") + self.assertTrue(out["vm_dead"]) + + def test_exception_with_live_vm_propagates(self): + def runner(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd[0] == "ssh" and "dispatch" in cmd: + raise RuntimeError("ssh transport broken") + if cmd[0] == "ssh": + return FakeResult(0, "", "") + return FakeResult(0, "", "") + + ex = self.make_exec(runner=runner) + with self.assertRaises(RuntimeError): + ex.dispatch_task(self._task()) + + def test_goal_exception_with_dead_vm_is_interrupted(self): + proc_holder = [] + + def launcher(cmd): + proc = FakeProc(alive=True) + proc_holder.append(proc) + self.launches.append(list(cmd)) + return proc + + def runner(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd[0] == "ssh" and "goal" in cmd: + proc_holder[0]._alive = False + raise RuntimeError("ssh transport broken") + if cmd[0] == "ssh": + return FakeResult(0, "", "") + return FakeResult(0, "", "") + + ex = self.make_exec(runner=runner, launcher=launcher) + out = ex.goal_task({"id": "g1", "prompt": "做目标", + "goal_mode": True}) + self.assertEqual(out["status"], "interrupted") + self.assertIsNone(out["goal_met"]) + self.assertTrue(out["vm_dead"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_cloud_hypervisor.py b/cloud/tests/test_cloud_hypervisor.py index 044b9fb..56e76e5 100644 --- a/cloud/tests/test_cloud_hypervisor.py +++ b/cloud/tests/test_cloud_hypervisor.py @@ -279,6 +279,14 @@ class ConfigCommandTest(ChvCase): self.assertNotIn("SECRET", joined) # egress 代理地址在 env 前缀里(不是密钥),且只在代理已设时出现: self.assertIn("HTTPS_PROXY=http://10.0.0.1:8888", joined) + # 本轨安全默认变化(#216 的 no + /dev/null 已废除): + # host key 钉死在每 VM 的 known_hosts 文件上: + self.assertIn("StrictHostKeyChecking=yes", joined) + self.assertNotIn("StrictHostKeyChecking=no", joined) + self.assertNotIn("/dev/null", joined) + self.assertIn(os.path.join(self.state_dir, + "chv-abc123", "known_hosts"), + joined) def test_isolation_names_and_disks_differ(self): ex = self.make_exec() @@ -534,6 +542,10 @@ class DispatchTest(ChvCase): def test_execute_is_ssh_status_only(self): ex = self.make_exec() + # 本轨 execute 语义门禁:只读已运行 VM。先登记一个活 + # 的 VMM 进程表示 VM 已醒(未登记时 execute 报 + # vm_not_running,见 test_chv_optimize.py): + ex._vms["chv-abc123"] = FakeProc(alive=True) out = ex.execute(self._task()) self.assertEqual(out["executor"], "cloud-hypervisor") self.assertIn("ok-status", out["stdout"]) diff --git a/docs/chv-production-readiness.md b/docs/chv-production-readiness.md index afa8a64..f92ac28 100644 --- a/docs/chv-production-readiness.md +++ b/docs/chv-production-readiness.md @@ -42,6 +42,17 @@ - ✅🧪 密钥不进镜像/代码/prompt:SSH argv 只有 key 文件路径; 模型 key 由客机 cloud-init 预置的环境变量名注入;测试断言 命令中不含哨兵密钥值。 +- ✅🧪 SSH host key 按 VM 钉死(优化轨,#216 的 no + /dev/null + 已废除):每 VM 在 state_dir//known_hosts 有独立文件, + 首次启动经 ssh-keyscan 在本机回环端口捕获 key 写入该文件, + 此后 SSH 一律 StrictHostKeyChecking=yes + 该文件校验; + 非 root 客机用户(CHV_SSH_USER,显式空串直接拒绝)与端口 + (CHV_SSH_PORT,1-65535)为显式配置。mock 只验证捕获→ + 写文件→钉死选项的管线,不验证首次密钥真实性。 +- 🔒 首次信任边界未闭合:首次捕获是 TOFU,只防首次之后的 + 替换,不验证首次本身;更强的方案是镜像构建/cloud-init 时 + 把客机 host key 预置进 known_hosts,仍是待办,安全审计时 + 须单独立项确认。 - 🔒 需安全审计:VM 逃逸面(virtio 设备、tap 桥接、SSH 配置)、 客机 root 提权面、租户间东西向隔离的宿主网络配置,须由 安全审计逐项确认,代码自查不能代替审计。 @@ -55,6 +66,12 @@ 冷启动,绝不降级 Docker/Local。 - ✅🧪 预热条目一次性:acquire 消耗、release 一律销毁,绝不跨 任务复用脏 VM。 +- ✅🧪 预热池 registry 与孤儿清理(优化轨):池状态落 + state_dir/warm-pool.json(原子写),worker 重启后载入的 + 条目一律当孤儿、只能 reconcile_orphans() 清理,绝不重新 + 发给任务;registry 损坏时 quarantine 成 .corrupt 并从空池 + 开始;registry 之外的残留 socket 仍由 cleanup_stale_vms() + 扫描处理。 - ⏳ 需 KVM 宿主实测:预热池的真实冷启动耗时、resume 耗时、 池大小与命中率、快照来源一致性,均需真机数据,mock 数字 不能当性能结论。 @@ -75,6 +92,17 @@ shutdown + 杀进程 + 删 socket)、recycle_vm(保留 task_checkpoints/task_attempts,丢失 guest /tmp 与未拷出 的 worktree 临时物,绝不声称恢复临时文件)。 +- ✅🧪 execute 只读已运行 VM(优化轨):execute() 先查 VM + 是否在运行(登记活进程或 api-socket 存在),未运行报 + ChvFailure(kind=vm_not_running),不为一次 status 起 VM, + 也不向不存在的端口发 SSH 再拿连接失败冒充任务状态。 +- ✅🧪 长任务中途死亡判 interrupted(优化轨):客内命令抛 + 传输异常、或非 goal 结果已 failed 且登记 VMM 进程已死时, + _run_with_vm 改判 interrupted(断点可续跑)而非笼统 + failed;VM 还活着时的异常/失败原样处理。中途心跳未做: + host.py 单次 SSH 长回合没有客内进度信号可采,另起一路 + SSH 轮询只能证明 VM 活着、不能证明任务在进展,评估后 + 认为风险大于收益,宁可只做死亡分类。 - ⏳ 需 KVM 宿主实测:上述全部生命周期在真机上的状态转换、 快照恢复后 guest 状态一致性、休眠后文件/worktree 保留, 均未在真机验证。