- 拆模块: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/<vm>/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。
1212 lines
59 KiB
Python
1212 lines
59 KiB
Python
"""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.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/<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(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 <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 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 <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。"""
|
||
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/<vm>/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=<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
|
||
<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)
|
||
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"))
|
||
|
||
|