Files
codex-app-server/cloud/chv/executor.py
T
host 445376743d feat: CHV 审核修复 P2(范围恢复为全做)
P2-1:preflight reuse/CPU/内存校验块小提取为
_preflight_config_checks(纯移动,深耦合处如实停)。
P2-2:build_vm_command 降级为私有 _build_vm_command;
Docker goal 内联解析顶部加正典分叉说明 + 同步检查清单。
P2-3:gaps grep 文本断言改成真实 Worker._run_attempt
行为断言。P2-4:README/build.sh 口径对齐(章节顺序、
BUILDINFO 名字列表、Node.js 不装写实)。P2-5:stop_vm
删除失败记审计。P2-6:CPU/内存校验统一进 preflight。
测试:原 84 + 新增 33 = 117 项,mock,无真 VM。
2026-10-31 02:01:03 +00:00

1518 lines
75 KiB
Python
Raw Blame History

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