Files
codex-app-server/cloud/chv/executor.py
T

2367 lines
115 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:每任务磁盘供给与磁盘配额门
- orphans.py:生产残留清理唯一入口(registry 文件直读清理)
- experimental_pool.py:WarmPool 预热池(纯实验,生产禁 import)
- 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 collections.abc import Callable
from cloud.chv.api_client import _unix_socket_api, normalize_api_result
from cloud.chv.config import _parse_memory_bytes, build_chv_config
from cloud.chv.disk import (
check_disk_quota,
check_snapshot_limits,
delete_task_disk,
provision_task_disk,
record_snapshot,
task_disk_path,
)
from cloud.chv.errors import ChvFailure
from cloud.chv.hardening import (
ensure_secure_state_dir,
parse_net_entries,
socket_is_live,
validate_egress_proxy,
validate_net_entries,
validate_snapshot_url,
)
from cloud.chv.locking import StateLock
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,
split_secret_env,
write_known_hosts,
)
from cloud.executor_base import Executor
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
def _default_popen_launcher(cmd):
"""默认 VMM 启动器:独立会话子进程,输出丢弃(纯移动)。"""
return subprocess.Popen(
cmd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
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。feat-chv-secfix 起网关
密钥改走 seed 盘客内文件投递(见 wake_vm 与
hostkeys.write_seed),keyscan 模式下密钥投递
fail-closed;非密钥 extra_env 仍走 env 前缀。
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/experimental_pool.py)预热干净快照 VM,
acquire 消耗、release 销毁,池空回落冷启动,绝不降级;
池状态另落 state_dir/warm-pool.json registry,worker
重启后载入的条目一律当孤儿、只能 reconcile_orphans()
清理,绝不重新发给任务(见 orphans.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 目录(默认
~/.local/share/codex-app-server/chv-vms,0700;
feat-chv-secfix 起不再默认 /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 的裸金属或
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
"""
# to10:mypy 属性声明(仅注解、不赋值,运行时仍由
# _copy_config_fields/_init_runtime 抄写)。没有这组声明,
# 动态 setattr 会让 mypy 认为全部配置属性不存在(基线
# 115 个报错里近 90 个属此类),类型检查等于没跑。
binary: str | None
kernel: str | None
rootfs: str | None
cpus: str
memory: str
state_dir: str
ssh_key: str | None
ssh_user: str
ssh_port: int
guest_host_py: str
cmdline: str
net: str | None
net_json: str | None
kvm_path: str
cpuinfo_path: str
ch_remote: str
model: str | None
max_vms: int
disk_mode: str
disk_gb: float
egress_proxy: str | None
allow_direct_egress: bool
task_timeout: float | None
snapshot_url: str | None
warm_pool_size: int
api_timeout: float
ready_timeout: float
ready_interval: float
host_key_mode: str
ssh_host: str | None
ssh_forward: bool
reuse_experiment: bool
single_tenant_project: str | None
snapshot_max_count: int
snapshot_max_total_gb: float
snapshot_retention_days: float
snapshot_dir: str | None
allow_remote_snapshot: bool
runner: "Callable[..., object]"
launcher: "Callable[..., object]"
api_client: "Callable[..., object]"
sleeper: "Callable[..., object]"
audit_callback: object
audit_failure_count: int
_state_lock: StateLock
_vms: dict
_guest_env_delivered: dict
def __init__(self, binary: str | None = None,
kernel: str | None = None,
rootfs: str | None = None,
image: str | None = None,
cpus: str | int | None = None,
memory: str | None = None,
state_dir: str | None = None,
runner: Callable[..., object] | None = None,
launcher: Callable[..., object] | None = None,
ssh_key: str | None = None,
ssh_user: str | None = None,
ssh_port: int | str | None = None,
guest_host_py: str = "/app/host.py",
cmdline: str | None = None,
net: str | None = None, net_json: str | None = None,
ssh_host: str | None = None,
kvm_path: str = "/dev/kvm",
cpuinfo_path: str = "/proc/cpuinfo",
env: dict | None = None,
model: str | None = None, ch_remote: str | None = None,
api_client: Callable[..., object] | None = None,
sleeper: Callable[..., object] | None = None,
api_timeout: float | None = None,
ready_timeout: float | None = None,
ready_interval: float | None = None,
host_key_mode: str | None = None) -> None:
self._env: dict = env if env is not None else os.environ # type: ignore[assignment]
# 配置解析已拆到 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)
self._copy_config_fields(_cfg)
self._init_runtime(runner, launcher, api_client, sleeper)
def _init_runtime(self, runner: Callable[..., object] | None,
launcher: Callable[..., object] | None,
api_client: Callable[..., object] | None,
sleeper: Callable[..., object] | None) -> None:
# to10:runner/launcher/api_client/sleeper 均为可调用注入对象;
# 形参保持 object 以兼容旧测试的任意 lambda,调用点用
# getattr 可调用检查后调用(见 _call_runner 等),mypy
# 的 object-not-callable 在 _call_runner 处以 Callable 局部
# 变量处理。
"""运行时注入:seed 投递集合、API 客户端、睡眠器、
runner/launcher 默认值(纯移动)。"""
# feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM
# 名 → 已投递密钥名集合。_run_guest 只对集合内的密钥
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
# “wake 没投递、执行时却按名 source 一个旧文件”)。
self._guest_env_delivered = {}
# feat-chv-p1fix(P1-05):容量/net/磁盘 check→act
# 与 purge/cleanup 的跨进程锁(flock,见 locking.py)。
# 只在 wake/restore/cleanup 时加锁,不在构造时建锁
# 文件(构造本身不应有 state_dir 写副作用)。
self._state_lock = StateLock(self.state_dir)
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.audit_failure_count = 0
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:
self.launcher = _default_popen_launcher
self._vms = {}
def _copy_config_fields(self, _cfg) -> None:
"""把 build_chv_config 的字段抄到 self(纯移动,字段集不变)。"""
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",
"single_tenant_project", "snapshot_max_count",
"snapshot_max_total_gb", "snapshot_retention_days",
"snapshot_dir", "allow_remote_snapshot"):
setattr(self, _field, getattr(_cfg, _field))
# -- 名字 / 路径 ---------------------------------------------------
def _host_name(self, task):
return re.sub(r"[^A-Za-z0-9_.-]", "-",
str(task.get("id")))
def vm_name(self, task_or_name: dict | str) -> str:
"""VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款
派生,同一任务每次算出同一名字,清理才能按名找)。
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
扫描 socket 文件名时传的就是 chv- 名,二次加前缀会
把 chv-dead 算成 chv-chv-dead、永远清不掉)。"""
# to10:id 按契约是 str;cast 只给 mypy 用,运行时原样
# 传给 re.sub(id 缺失时仍按旧行为 TypeError,不静默
# 编成 "None")。
from typing import cast as _cast
raw = _cast(str, 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: dict | str) -> str:
"""该 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: list) -> None:
"""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(仅单租户实验)"))
# feat-chv-secfix(M6):实验放行还须声明单租户项目。
# 代码无法从环境变量验证数据库里是否真的只有一个
# 租户——声明 + 审计就是本项的机制,文档明写该边界,
# 不假装已校验多租户状态。
if getattr(self, "disk_mode", "copy") == "reuse" \
and getattr(self, "reuse_experiment", False) \
and not getattr(self, "single_tenant_project", None):
missing.append(("reuse_tenant_undeclared",
"CHV_DISK_MODE=reuse 实验放行须同时声明"
" CHV_SINGLE_TENANT_PROJECT(单租户"
"项目 id,仅部署声明 + 审计,不验证"
"数据库多租户状态)"))
# P2-6:配置校验时机统一到 preflight。CPU 非整数、内存
# 字符串非法过去只在 build_vm_config 才炸,现在 preflight
# 一次报出(kind=config_invalid)。
# feat-chv-p1fix(P1-07):构造时校验拦不住“属性事后
# 改写/注入”的非法值,preflight 这里再验 >0——CPU
# 0/负、内存 0、任务超时 0/负一律干净 preflight 失败,
# 不进 VmConfig、不等 VMM 侧才炸。
try:
if int(self.cpus) <= 0:
raise ValueError("CHV_CPU")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_CPU 必须是大于 0 的整数:%r"
% (self.cpus,)))
try:
if _parse_memory_bytes(self.memory) <= 0:
raise ValueError("CHV_MEM")
except ValueError:
missing.append(("config_invalid",
"CHV_MEM 格式非法或为 0:%r" % (self.memory,)))
self._preflight_numeric_checks(missing)
def _preflight_numeric_checks(self, missing: list) -> None:
"""preflight 数值面:端口/配额/超时/快照统一校验,纯移动。
构造时已校验正常值;这里兜底属性事后被改写的情形。"""
# feat-chv-arch95(配置统一校验):构造时 build_chv_config
# 已校验过这些字段的正常值;这里再以 config_invalid
# 统一兜底属性被事后改写/注入非法值的情形,preflight
# 是生产路径最后一道统一校验,不再等到 build_vm_config
# 或 wake 时才在不同位置以不同文案报错。
try:
_port = int(getattr(self, "ssh_port", 2222))
if not 1 <= _port <= 65535:
raise ValueError("ssh_port")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_SSH_PORT 必须在 1-65535 之间"))
try:
_max_vms = int(getattr(self, "max_vms", 4))
if _max_vms < 0:
raise ValueError("max_vms")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_MAX_VMS 必须是非负整数"))
try:
_disk_gb = float(getattr(self, "disk_gb", 50.0))
if _disk_gb <= 0:
raise ValueError("disk_gb")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_DISK_GB 必须是大于 0 的数字"))
try:
if float(getattr(self, "api_timeout", 30)) <= 0 \
or float(getattr(self, "ready_timeout", 120)) <= 0 \
or float(getattr(self, "ready_interval", 5)) <= 0:
raise ValueError("timeout")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_*_TIMEOUT/INTERVAL 必须是正数"))
self._preflight_task_timeout_check(missing)
try:
if int(getattr(self, "warm_pool_size", 0)) < 0:
raise ValueError("warm_pool_size")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_WARM_POOL_SIZE 必须是非负整数"))
self._preflight_snapshot_checks(missing)
def _preflight_task_timeout_check(self, missing: list) -> None:
"""preflight 任务超时面(feat-chv-p1fix,P1-07 提取:
保持 _preflight_numeric_checks 在 50 行硬门禁内)。
None = 不限(旧语义);显式 0/负数即 preflight 失败。"""
try:
_task_timeout = getattr(self, "task_timeout", None)
if _task_timeout is not None \
and float(_task_timeout) <= 0:
raise ValueError("task_timeout")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_TASK_TIMEOUT 必须是大于 0 的数字"))
def _preflight_snapshot_checks(self, missing: list) -> None:
"""preflight 快照/host-key 面:统一校验,纯移动。"""
try:
if int(getattr(self, "snapshot_max_count", 10)) < 0 \
or float(getattr(self, "snapshot_max_total_gb", 20.0)) < 0 \
or float(getattr(self, "snapshot_retention_days", 30.0)) < 0:
raise ValueError("snapshot")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_SNAPSHOT_* 不能为负数"))
if getattr(self, "host_key_mode", "provisioned") \
not in ("provisioned", "keyscan"):
missing.append(("config_invalid",
"CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan"))
def _preflight_host_checks(self, missing: list) -> None:
"""preflight 主机面:二进制/KVM/CPU 标志,纯移动。"""
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))
def _preflight_file_checks(self, missing: list) -> None:
"""preflight 文件面:内核/镜像/SSH key,纯移动。"""
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))
def _preflight_net_checks(self, missing: list) -> None:
"""preflight 网络/目录面:egress/安全目录/net,纯移动。"""
if not self.egress_proxy and not self.allow_direct_egress:
missing.append(("egress_not_configured",
"CHV_EGRESS_PROXY 未设且未显式允许直连:"
"VM 出口必须过白名单代理,默认不给直连外网"))
if self.egress_proxy:
try:
validate_egress_proxy(self.egress_proxy)
except ChvFailure as exc:
missing.append((exc.kind, str(exc)))
# feat-chv-secfix(H6):state_dir 安全目录门。
# 默认值已出 /tmp(见 config.py);这里再校验一次:
# 目录须存在且 0700,否则 preflight 直接失败。
try:
ensure_secure_state_dir(self.state_dir)
except ChvFailure as exc:
missing.append((exc.kind, str(exc)))
# 补缺口轨 (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 桥接
# 本身是否真只走代理仍需宿主实测,代码不声称已验证。
# feat-chv-secfix(L4):再加字段白名单与条目内重复
# 校验;CHV_NET 与 CHV_NET_JSON 双设时只发警告审计
# (net_config_dual),不硬拒——CHV_NET 是手动直接
# 启动命令的透传值,双设不一定是误配,硬拒会断掉
# 手动运维路径;误配的代价是“API 模式忽略 CHV_NET”,
# 警告 + 文档已写清。
if self.net and self.net_json:
self._emit("net_config_dual", None,
"CHV_NET 与 CHV_NET_JSON 双设:API 模式只用"
" CHV_NET_JSON,CHV_NET 仅手动直接启动命令用")
if self.net_json:
try:
_entries = parse_net_entries(self.net_json)
validate_net_entries(_entries, [])
except ChvFailure as exc:
missing.append((exc.kind, str(exc)))
def preflight(self) -> bool:
"""预检(缺一不可则 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: list = [] # (kind, 文本) 列表,全部收集完再一次抛
self._preflight_host_checks(missing)
self._preflight_file_checks(missing)
self._preflight_net_checks(missing)
self._preflight_config_checks(missing)
if missing:
kind = missing[0][0]
detail = "; ".join(text for _, text in missing)
# feat-chv-secfix(M5/L3):preflight 失败本身也
# 审计(此前 try 块之前失败一律无审计事件)。
self._emit("preflight_failed", None,
"preflight 失败:%s" % detail, kind)
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 时只记日志。
feat-chv-secfix(M5):审计回调失败不再双重静默
pass——记宿主 error 日志(审计完整性本身没有保证,
但失败至少可见;任务仍不因此炸,审计失败不改变
任务状态是 worker 层的既定语义)。feat-chv-arch95plus
取舍(见 ADR-9):不改成“审计失败即中断任务”——
审计是可观测性不是门禁,DB 短暂不可用时硬中断会把
一次审计失败放大成任务失败,且与 worker 全局
best-effort 语义冲突;改为“失败记日志 + 计数 + 不
中断”,计数见 self.audit_failure_count(宿主可观测,
非任务状态)。审计 detail 里绝不含密钥值(调用方
负责:本类所有 _emit 的 detail 只含 VM 名、路径、
URL、kind,不含任何 key 值)。"""
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 as exc:
self.audit_failure_count = getattr(
self, "audit_failure_count", 0) + 1
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
event, kind, exc)
# to10(ADR-14):回调失败的事件再落盘一份——
# 宿主日志可能轮转、计数只剩数字,落盘文件是
# 唯一能事后找回“哪次事件丢了”的取证。落盘
# 本身 best-effort,失败只记日志,绝不炸任务。
self._spill_audit(event, task, detail, kind)
def _spill_audit(self, event, task, detail, kind) -> None:
"""审计失败事件落盘(to10,见 ADR-14 取舍):
state_dir/chv-audit-spill.jsonl,每行一 JSON
{ts, event, kind, detail, task_id};文件超 1MB 时
先轮转成 .old 再写。detail 沿用 _emit 契约,绝不
含密钥值。落盘不是 DB 替代:timeline 查询看不到它,
需宿主人工查阅;轮转只留最新两代,旧事件会丢。"""
try:
path = os.path.join(self.state_dir,
"chv-audit-spill.jsonl")
if os.path.exists(path) \
and os.path.getsize(path) > 1024 * 1024:
os.replace(path, path + ".old")
task_id = task.get("id") if isinstance(task, dict) \
else str(task)
line = json.dumps({"ts": time.time(), "event": event,
"kind": kind, "detail": detail,
"task_id": task_id})
with open(path, "a") as fh:
fh.write(line + "\n")
except Exception as exc:
_LOGGER.error("chv_audit_spill_failed error=%s", exc)
# -- 命令构造(纯函数,不做文件校验) -------------------------------
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: dict | str) -> str:
"""该 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) -> str | None:
"""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) -> str:
"""SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP):
显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下
的回环;三者皆无时报 ChvFailure(ssh_target_undeclared),
绝不无条件假设 127.0.0.1:2222 能通。"""
_ssh_host = self.ssh_host
if _ssh_host:
return _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 _read_keypair_texts(self, paths: dict) -> tuple:
"""读 key 对文本(to10 提取,provision_host_key 降行用)。"""
with open(paths["public"], "r") as fh:
public_text = fh.read()
with open(paths["private"], "r") as fh:
private_text = fh.read()
return public_text, private_text
def _write_provisioned_known_hosts(self, paths: dict,
public_text: str) -> None:
"""写预置 known_hosts(to10 提取)。主机部分跟 SSH
目标走(客机 IP 时不再写死回环,否则钉死校验永远
对不上)。feat-chv-p1fix(P1-04 同款):解析失败即
抛 ChvFailure,绝不回落回环写钉死——回环钉死会让
SSH 校验钉在一个“解析失败后猜出来的目标”上;
wake 路径在 provision 之前已在 _wake_prechecks 里
严格解析过,此处失败只发生在直接调用时,抛错是
正确语义。"""
from cloud.chv.hostkeys import known_hosts_line
from cloud.chv.ssh_transport import write_known_hosts
ssh_host = self.resolve_ssh_host()
write_known_hosts(
paths["known_hosts"],
[known_hosts_line(self.ssh_port, public_text,
host=ssh_host)])
def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict:
"""按 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 重启复用已有对(否则钉死立即失效)。
feat-chv-secfix(H2/M2):secret_env 给定时写进
seed 的客内密钥文件(0600);上次清理失败留下的
脏标记存在时直接拒绝(清理失败即拒起,不再复用
旧 key 对)。成功后审计 host_key_generated(只含
VM 名与密钥名,不含任何 key 值)。"""
# to10:known_hosts 写入已抽到 _write_provisioned_known_hosts,
# 此处只留生成与 seed 写入的 import(删后不再有未用 import)。
from cloud.chv.hostkeys import (
dirty_marker_path,
generate_host_keypair,
write_seed,
)
vm = self.vm_name(task)
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
raise ChvFailure(
"vm_dirty_refused",
"VM %s 上次清理失败(脏标记存在),拒绝复用旧 "
"host key 启动:请人工清理 state_dir 后删脏标记"
% vm)
paths = generate_host_keypair(
self.state_dir, vm,
runner=lambda cmd: self._call_runner(cmd))
public_text, private_text = self._read_keypair_texts(paths)
self._write_provisioned_known_hosts(paths, public_text)
seed_dir = write_seed(paths["seed_dir"], vm,
private_text, public_text,
secret_env=secret_env)
self._emit("host_key_generated", task,
"vm=%s secret_names=%s"
% (vm, ",".join(sorted(secret_env or {}))))
return {"paths": paths, "seed_dir": seed_dir,
"known_hosts": paths["known_hosts"]}
def capture_host_key(self, task: dict) -> bool:
"""首次捕获客机 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
# feat-chv-secfix(M5):捕获成功独立审计(只含 VM
# 名与主机,不含 key 行本身)。
self._emit("host_key_captured", task,
"vm=%s host=%s" % (self.vm_name(task), ssh_host))
return True
def build_ssh_command(self, task: dict, inner_cmd: list, extra_env: dict | None = None) -> list:
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<SSH 目标> + 客内命令。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。
feat-chv-secfix(H2):extra_env 先拆密钥子集与非
密钥子集。密钥子集须已在本实例的 wake 里 seed 投递
(_guest_env_delivered 有记录),否则直接 ChvFailure
(secret_env_not_delivered),绝不回落 argv;已投递
时远程命令 source 客内密钥文件。keyscan 模式下
wake 根本不建 seed,密钥子集到这里必然未投递——
即 CHV + 网关密钥现在要求 provisioned 模式(已是
默认),keyscan 模式下的网关任务 fail-closed,不再
把密钥值拼进 argv 将就跑。"""
# feat-chv-p1fix(P1-04):删除旧回环回落——构造失败
# 即抛 ssh_target_undeclared,调用方无法再把“显式
# 回环”与“解析失败后回落”混为一谈。显式回环仍可用:
# 设 CHV_SSH_HOST=127.0.0.1,或显式宣称
# CHV_SSH_FORWARD=1;wake/wait 路径本来就严格解析,
# 本改动只收紧公开构造方法,不放宽任何严格路径,
# 也没有新增 dry-run 例外参数(干跑同样不许猜目标)。
ssh_host = self.resolve_ssh_host()
secrets, plain = split_secret_env(extra_env)
guest_env_file = None
if secrets:
from cloud.chv.hostkeys import GUEST_ENV_PATH
delivered = self._guest_env_delivered.get(
self.vm_name(task), set())
missing: list = [name for name in secrets if name not in delivered]
if missing:
raise ChvFailure(
"secret_env_not_delivered",
"密钥环境变量 %s 未经 seed 投递,拒绝进 ssh "
"argv:provisioned 模式下 wake 时投递,"
"keyscan 模式不支持密钥投递"
% ",".join(sorted(missing)))
guest_env_file = GUEST_ENV_PATH
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=plain or None,
guest_env_file=guest_env_file)
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def _vm_config_disks(self, task_disk: str | None,
seed_disk: str | None) -> list:
"""VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。
任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init
NoCloud 数据源);显式给的盘若不是基础镜像本身,
须在 state_dir 内,否则 VmConfig 不能挂进宿主任意文件。"""
# 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘
# “seed 磁盘须在…”(label 本身含“磁盘”与空格)。
for path, label in ((task_disk, "任务磁盘"),
(seed_disk, "seed 磁盘")):
if path and path != self.rootfs:
if not os.path.realpath(path).startswith(
os.path.realpath(self.state_dir) + os.sep):
raise ChvFailure(
"disk_path_not_allowed",
"%s须在 state_dir 内:%s" % (label, path))
disks: list = [{"path": task_disk or self.rootfs}]
if seed_disk:
disks.append({"path": seed_disk, "readonly": True})
return disks
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict:
"""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": self._vm_config_disks(task_disk, seed_disk),
"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 _persistent_vm_count(self, exclude=None):
"""跨重启容量计数(feat-chv-fix,P1-4 + feat-chv-secfix
M7):本实例登记的活进程名 ∪ state_dir 下 chv-*.sock
对应的 VM 名。worker 重启后 _vms 为空,但旧 VMM 的
socket 仍在,仍计入容量——重启不再绕过 CHV_MAX_VMS。
M7 修正:残留 socket 不再无条件计入——只有 socket
真能连通(有监听者)才计数;空文件/死 socket 不计,
否则 CHV_MAX_VMS 会被崩溃残留占满、新任务全部被
拒(可用性 DoS)。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-"):
sock_path = os.path.join(self.state_dir, entry)
if socket_is_live(sock_path):
names.add(entry[:-5])
if exclude:
names.discard(exclude)
return len(names)
def _state_lock_ctx(self) -> StateLock:
"""当前 state_dir 的 StateLock(state_dir 若被事后改写,
换用新路径的锁实例,避免锁在旧目录上)。"""
if self._state_lock.state_dir != self.state_dir:
self._state_lock = StateLock(self.state_dir)
return self._state_lock
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: dict) -> bool:
"""等 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 _provisioned_key_guard(self, task):
"""provisioned 下缺 known_hosts 即 fail-fast,绝不 keyscan。
返回 True 表示已拦截(调用方须先关 VM 再抛
host_key_not_pinned);False 表示可继续轮询。"""
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
and not os.path.exists(self.known_hosts_path(task)):
return True
return False
def _check_host_key_mismatch(self, task, result):
"""SSH stderr 含钉死拒绝标记时审计并返回 True(须 fail-fast)。
MITM 尝试是独立安全事件,不轮询成普通超时;审计
detail 只含 VM 名与标记,不含 key 值。"""
stderr = getattr(result, "stderr", "") or ""
if "Host key verification failed" in stderr \
or "REMOTE HOST IDENTIFICATION" in stderr:
self._emit("host_key_mismatch", task,
"vm=%s ssh host key 被拒绝(钉死不匹配)"
% self.vm_name(task), "host_key_mismatch")
return True
return False
def wait_guest_ready(self, task: dict) -> bool:
"""等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪;
每轮先尽力捕获 host key(keyscan 模式),超时先关 VM
再 raise guest_not_ready,绝不在未就绪时派任务。"""
# P0-2:provisioned 下缺 known_hosts 即 fail-fast,绝不
# 调用 capture_host_key 回落 TOFU(keyscan 才走捕获分支)。
if self._provisioned_key_guard(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
# M5:host key 不匹配是独立安全事件,见
# _check_host_key_mismatch(审计 + fail-fast)。
if self._check_host_key_mismatch(task, result):
try:
self.stop_vm(task)
except Exception:
pass
raise ChvFailure(
"host_key_mismatch",
"SSH host key 不匹配(钉死拒绝):VM %s,"
"可能被替换或中间人,已关 VM"
% self.vm_name(task))
except ChvFailure:
raise
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 _registered_net_entries(self, exclude_vm=None):
"""已登记 VM 的 net 条目(H7):扫描 state_dir/*/net.json
(wake 成功后写,stop 时删)。文件损坏/缺失即跳过该
VM——登记缺失只会漏判冲突,不会误拒;漏判的兜底是
宿主防火墙部署清单里的“每 VM 独立 tap”检查项。"""
registered = []
try:
entries = os.listdir(self.state_dir)
except Exception:
return registered
for entry in entries:
if entry == exclude_vm:
continue
path = os.path.join(self.state_dir, entry, "net.json")
try:
with open(path, "r") as fh:
data = json.load(fh)
except Exception:
continue
if not isinstance(data, list):
continue
for item in data:
if isinstance(item, dict):
row = dict(item)
row["vm"] = entry
registered.append(row)
return registered
def _record_net_entries(self, vm, entries):
"""wake 成功后登记本 VM 的 net 条目(best-effort 写,
失败只记日志——登记缺失的后果是漏判冲突,见上文)。"""
try:
vm_dir = os.path.join(self.state_dir, vm)
os.makedirs(vm_dir, exist_ok=True)
tmp_path = os.path.join(vm_dir, "net.json.tmp")
with open(tmp_path, "w") as fh:
json.dump(entries, fh)
os.replace(tmp_path, os.path.join(vm_dir, "net.json"))
except OSError as exc:
_LOGGER.warning("chv_net_registry_write_failed vm=%s "
"error=%s", vm, exc)
def _check_api_socket_permissions(self, socket_path):
"""VMM api-socket 权限门(H6):socket 文件存在时,
组/其它可写(mode & 0o022)即 fail-fast——Unix socket
的 connect 需要写权限,可写即任何同组/其它用户都
能调 VMM API。仅禁写位,不禁读/执行位:VMM 默认
umask 下的 0755 类权限不误杀(VMM 默认值代码控制
不了,真机上的实际权限仍须按部署清单核对)。
socket 文件不存在时(mock/带外)跳过,不假装已验。
普通文件(mock 占位)也跳过——只对真正的 socket
文件校验权限。"""
if not os.path.exists(socket_path):
return
try:
import stat as _stat
st = os.stat(socket_path)
if not _stat.S_ISSOCK(st.st_mode):
return
mode = st.st_mode & 0o777
except OSError:
return
if mode & 0o022:
raise ChvFailure(
"socket_permissions_insecure",
"VMM api-socket 组/其它可写(%o),拒绝启动:%s"
% (mode, socket_path))
def _wake_prechecks(self, task: dict, name: str, extra_env) -> tuple:
"""wake 前置:preflight/容量/配额/net/密钥模式,纯移动。
失败时发 vm_failed 审计并原样抛 ChvFailure。"""
try:
self.preflight()
# feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。
# resolve 失败即 fail-fast(不先起 VMM 再超时);
# reuse 实验放行必须审计,留痕给运维复核。
self.resolve_ssh_host()
self._check_capacity(exclude=name)
self._check_disk_quota()
# feat-chv-secfix(H7):跨 VM tap/mac/ip 冲突在
# 起 VMM 前拒绝(preflight 只做了条目内校验)。
net_entries = []
if self.net_json:
net_entries = validate_net_entries(
parse_net_entries(self.net_json),
self._registered_net_entries(exclude_vm=name))
# feat-chv-secfix(H2):密钥子集的投递能力在起
# VM 前就定性——keyscan 模式无 seed 可投递,有
# 密钥即 fail-closed,不等到执行时才在 argv 前
# 被拦(那时 VM 已起,失败语义更脏)。
secrets, _plain = split_secret_env(extra_env)
if secrets and getattr(self, "host_key_mode",
"provisioned") != "provisioned":
raise ChvFailure(
"secret_env_requires_provisioned",
"密钥环境变量 %s 须走 seed 文件投递,仅 "
"provisioned 模式支持(当前 %s)"
% (",".join(sorted(secrets)),
getattr(self, "host_key_mode", "provisioned")))
except ChvFailure as exc:
self._emit("vm_failed", task,
"vm=%s wake 前置失败 kind=%s" % (name, exc.kind),
exc.kind)
raise
return secrets, net_entries
def _provision_seed_disk(self, task: dict, name: str, secrets) -> str | None:
"""provisioned 模式的 seed 盘供给:生成→制盘→挂盘,
任一步失败即抛(调用方 stop_vm 清理),纯移动。"""
seed_disk = None
if getattr(self, "host_key_mode", "provisioned") == "provisioned":
# 强方案:启动前预置每 VM host key 并钉死,
# 再把 seed 目录做成 CIDATA seed 磁盘并挂进
# VmConfig(P0-1 闭环:生成→制盘→挂盘三步缺
# 一即 fail-fast,绝不回落 keyscan、绝不只
# 生成不注入)。
# feat-chv-secfix(H2/H5):secret_env 随 seed
# 写进客内密钥文件;seed.iso 建好(0600,见
# build_seed_disk)后立即删宿主侧私钥——此后
# 宿主上只剩公钥 + known_hosts,私钥只活在客
# 机与 seed.iso(随 VM 销毁而删)里。删私钥
# 失败即 fail-fast(VM 尚未启动,此时拒绝最
# 安全,except 里 stop_vm 会清掉 seed)。
from cloud.chv.hostkeys import (
build_seed_disk,
delete_host_private_key,
seed_disk_path,
)
provisioned = self.provision_host_key(
task, secret_env=secrets or None)
seed_disk = seed_disk_path(self.state_dir, name)
build_seed_disk(
provisioned["seed_dir"], seed_disk,
runner=lambda cmd: self._call_runner(cmd))
delete_host_private_key(self.state_dir, name)
if secrets:
self._guest_env_delivered[name] = set(secrets)
else:
self._guest_env_delivered.pop(name, None)
return seed_disk
def _boot_vm(self, task, name, task_disk, seed_disk,
net_entries) -> None:
"""VMM 启动→vm.create→vm.boot→SSH 就绪,纯移动。"""
socket_path = self.api_socket(task)
self._launch_api_only(task)
self.wait_api_ready(task)
self._check_api_socket_permissions(socket_path)
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))
if net_entries:
self._record_net_entries(name, net_entries)
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)
def _wake_prepare(self, task: dict, name: str) -> tuple:
"""wake 准备:reuse 审计、旧 VM 清理、任务盘供给、
直连 egress 审计,纯移动。返回 (task_disk, socket_path)。"""
if getattr(self, "disk_mode", "copy") == "reuse":
self._emit("disk_reuse_experiment_allowed", task,
"vm=%s CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 "
"explicit single_tenant_project=%s"
% (name, getattr(self, "single_tenant_project",
None)))
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:
self._emit("egress_direct_allowed", task,
"vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name)
return task_disk, socket_path
def wake_vm(self, task: dict, extra_env: dict | None = None) -> dict:
"""主唤醒路径(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 共用一个任务名。
feat-chv-secfix:extra_env(_run_with_vm 透传)里的
密钥子集在 provisioned 模式下写进 seed 客内文件,
keyscan 模式下有密钥子集即 fail-closed;起 VM 前的
preflight/容量/配额失败也发 vm_failed 审计(L3);
net 隔离在起 VMM 前校验(H7),成功后登记 net.json。
feat-chv-p1fix(P1-05):全程持 state_dir flock——
容量/net/磁盘的 check→act 只有在“查完立即占位、
占位前不放行”时才成立;锁提前释放会在 socket 尚
未绑定前留出计数窗口,仍可超限。串行化代价只落在
wake/restore/cleanup 之间,已运行 VM 的任务执行
(_run_guest)不持此锁。"""
with self._state_lock_ctx():
return self._wake_vm_locked(task, extra_env)
def _wake_vm_locked(self, task: dict, extra_env: dict | None = None) -> dict:
"""持 state 锁的 wake 本体(见 wake_vm)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
name = self.vm_name(task)
secrets, net_entries = self._wake_prechecks(task, name, extra_env)
task_disk, socket_path = self._wake_prepare(task, name)
try:
seed_disk = self._provision_seed_disk(task, name, secrets)
self._boot_vm(task, name, task_disk, seed_disk, net_entries)
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: dict) -> dict:
"""旧名保留:即 wake_vm(API 模式)。直接启动命令
_build_vm_command() 只作手动等价写法(私有),不再是执行器
的启动路径——两条启动路径并存是上一版的半吊子,
本版只认 API 模式。"""
return self.wake_vm(task)
def pause_vm(self, task_or_name: dict | str) -> bool:
"""暂停 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: dict | str) -> bool:
"""唤醒已暂停的 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: dict | str, snapshot_url: str) -> str:
"""给(已暂停的)VM 做快照:ch-remote snapshot
<source_url>。snapshot_url 为空即 ValueError;成功
审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,
需 resume 才醒)。
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
远端须显式放行)与数量/总量/保留期上限;成功后登
记并在总量超帽时按最旧优先剪枝(删动作审计)。
feat-chv-p1fix(P1-08):保留期剪枝与总量帽剪枝的
删文件成功/失败均发审计;失败路径统一发
vm_snapshot_failed 审计再抛(此前失败路径无任何
快照失败审计,只有 wake 前置失败才有 vm_failed)。
本体拆到 _snapshot_create 以守 50 行硬门禁。"""
if not snapshot_url:
raise ValueError("snapshot_url 不能为空")
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
vm_name = self.vm_name(task_or_name)
try:
self._snapshot_create(task_or_name, task, vm_name,
snapshot_url)
except Exception as exc:
kind = getattr(exc, "kind", None) or type(exc).__name__
self._emit("vm_snapshot_failed", task,
"vm=%s snapshot=%s kind=%s error=%s"
% (vm_name, snapshot_url, kind, exc), kind)
raise
self._emit("vm_snapshot", task,
"vm=%s snapshot=%s"
% (self.vm_name(task_or_name), snapshot_url))
return snapshot_url
def _snapshot_create(self, task_or_name, task, vm_name,
snapshot_url) -> None:
"""快照创建段:白名单→上限/保留期剪枝→ch-remote→
落盘校验→登记/总量剪枝(见 snapshot_vm)。"""
validate_snapshot_url(
snapshot_url, self.state_dir,
snapshot_dir=getattr(self, "snapshot_dir", None),
allow_remote=getattr(
self, "allow_remote_snapshot", False))
prune_events: list = []
check_snapshot_limits(
self.state_dir, vm_name,
getattr(self, "snapshot_max_count", 10),
getattr(self, "snapshot_max_total_gb", 20.0),
getattr(self, "snapshot_retention_days", 30.0),
events=prune_events)
self._emit_prune_events(task, vm_name, prune_events, "保留期")
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)
record_events: list = []
record_snapshot(
self.state_dir, vm_name, snapshot_url,
getattr(self, "snapshot_max_total_gb", 20.0),
events=record_events)
self._emit_prune_events(task, vm_name, record_events,
"总量超帽")
def _emit_prune_events(self, task, vm_name, events, reason) -> None:
"""剪枝事件转审计(feat-chv-p1fix,P1-08):成功删
文件与登记剪枝发 vm_snapshot_pruned,删失败发
vm_snapshot_prune_failed;无文件可删的登记剪枝只
发 pruned(删动作本身不存在,无失败可言)。"""
deleted = 0
failed = 0
for status, entry, error in events:
url = entry.get("url") if isinstance(entry, dict) else None
if status == "delete_failed":
failed += 1
self._emit("vm_snapshot_prune_failed", task,
"vm=%s pruned snapshot=%s reason=%s "
"error=%s" % (vm_name, url, reason, error))
else:
deleted += 1
self._emit("vm_snapshot_pruned", task,
"vm=%s pruned snapshot=%s reason=%s "
"deleted=%s" % (
vm_name, url, reason,
status == "deleted"))
if failed:
_LOGGER.warning("chv_snapshot_prune_failed vm=%s "
"reason=%s failed=%d", vm_name, reason,
failed)
def _validate_restore(self, task, snapshot_url):
"""restore 前置校验:返回归一化后的 snapshot_url。
restore 不走 vm.create、没有 VmConfig 可挂 seed:
快照内已固化原 VM host key,重预置反而破坏钉死,
故 provisioned 下复用已钉死的 known_hosts、缺失
fail-fast、绝不 keyscan、绝不重生成。restore 时不
重复验快照文件存在(远端/对象存储源合法,ch-remote
本身会在源缺失时失败)。"""
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
validate_snapshot_url(
snapshot_url, self.state_dir,
snapshot_dir=getattr(self, "snapshot_dir", None),
allow_remote=getattr(self, "allow_remote_snapshot", False))
self.preflight()
self.resolve_ssh_host()
if self._provisioned_key_guard(task):
raise ChvFailure(
"host_key_not_pinned",
"provisioned 模式下 restore 缺 known_hosts,拒绝 "
"keyscan 回落:VM %s" % self.vm_name(task))
return snapshot_url
def _restore_boot(self, task, snapshot_url):
"""restore 的启动段:起 VMM → 等 ping → restore →
显式 resume → 等 SSH 就绪(恢复后默认 paused,不
resume 就不算醒)。"""
name = self.vm_name(task)
self._launch_api_only(task)
self.wait_api_ready(task)
self._check_api_socket_permissions(self.api_socket(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)
def restore_vm(self, task: dict, snapshot_url: str) -> dict:
"""快照恢复唤醒:校验 → 只起 VMM → restore → 显式
resume → 等 SSH 就绪。恢复后默认 paused,不 resume
就不算醒。任一步失败都关 VM 再抛;成功返回
{vm, api_socket, state: "running", ready: True,
mode: "restored"}。feat-chv-p1fix(P1-05):与 wake
同款,全程持 state 锁——restore 同样查容量、建 VM,
不持锁会与并发 wake 互相超限。"""
with self._state_lock_ctx():
return self._restore_vm_locked(task, snapshot_url)
def _restore_vm_locked(self, task: dict, snapshot_url: str) -> dict:
"""持 state 锁的 restore 本体(见 restore_vm)。"""
snapshot_url = self._validate_restore(task, snapshot_url)
name = self.vm_name(task)
self._check_capacity(exclude=name)
try:
self._restore_boot(task, snapshot_url)
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: dict | str, snapshot_url: str) -> dict:
"""休眠(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)
# to10:删死赋值(task 建出后未用,F841;返回值直接
# 用 task_or_name 算 VM 名,行为不变)。
return {"vm": self.vm_name(task_or_name),
"state": "hibernated", "snapshot": snapshot_url}
def wake_from_hibernate(self, task: dict, snapshot_url: str) -> dict:
"""从休眠唤醒:即 restore_vm(restore + 显式 resume
+ SSH 就绪),语义与 restore_vm 完全一致,只是名字
说明来源是休眠快照。"""
return self.restore_vm(task, snapshot_url)
def _stop_registered_proc(self, name, proc):
"""清登记进程:已死直接清,活着先杀再清(纯移动)。"""
if proc is None:
return
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)
def _cleanup_vm_files(self, name, task):
"""stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。
任一块失败记对应审计并返回 True(脏标记由调用方写);
seed/私钥与磁盘删除的审计名与文案逐字不变。"""
from cloud.chv.hostkeys import dirty_marker_path # noqa: F401
failed = False
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:
failed = True
self._emit("vm_key_cleanup_failed", task,
"vm=%s error=%s" % (name, _cleanup_exc))
try:
if delete_task_disk(self.state_dir, name):
self._emit("vm_disk_deleted", task,
"vm=%s disk deleted on stop" % name)
except Exception as _disk_exc:
failed = True
self._emit("vm_disk_delete_failed", task,
"vm=%s error=%s" % (name, _disk_exc))
try:
_net_path = os.path.join(self.state_dir, name, "net.json")
if os.path.exists(_net_path):
os.remove(_net_path)
except Exception:
failed = True
self._guest_env_delivered.pop(name, None)
return failed
def _write_dirty_marker(self, name, failed):
"""M2 脏标记:清理失败写标记、成功清标记;写失败不炸关闭。"""
from cloud.chv.hostkeys import dirty_marker_path
try:
_marker = dirty_marker_path(self.state_dir, name)
if failed:
os.makedirs(os.path.dirname(_marker), exist_ok=True)
with open(_marker, "w") as fh:
fh.write("cleanup failed\n")
elif os.path.exists(_marker):
os.remove(_marker)
except Exception:
pass
def stop_vm(self, task_or_name: dict | str) -> bool:
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
已关 VM 仍尽力调一次 ch-remote(返回 False),socket
残留删 socket(返回 True)。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
self._stop_registered_proc(name, proc)
try:
if os.path.exists(socket_path):
os.remove(socket_path)
except Exception:
pass
# per-VM seed 目录与 host 私钥随 VM 关闭而删,绝不留给
# 下一个 VM 复用;known_hosts 公钥保留供事后审计。
# 三块清理独立记账 + 脏标记(H3/M2,见 _cleanup_vm_files)。
_task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
_cleanup_failed = self._cleanup_vm_files(name, _task)
self._write_dirty_marker(name, _cleanup_failed)
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 gc_stale_disks(self) -> dict:
"""残留盘 GC(H3):扫描 state_dir/*/disk.raw,VM
非 live(无 api-socket 文件且无登记活进程)即删盘
并审计;删失败审计 vm_disk_delete_failed 并计入
failed。返回 {deleted, failed}。"""
deleted: list = []
failed: list = []
try:
entries = os.listdir(self.state_dir)
except Exception:
return {"deleted": deleted, "failed": failed}
for entry in entries:
if not entry.startswith("chv-"):
continue
disk = task_disk_path(self.state_dir, entry)
if not (os.path.islink(disk) or os.path.exists(disk)):
continue
live = os.path.exists(
os.path.join(self.state_dir, entry + ".sock"))
with self._vms_lock:
proc = self._vms.get(entry)
if proc is not None:
try:
live = live or proc.poll() is None
except Exception:
pass
if live:
continue
try:
delete_task_disk(self.state_dir, entry)
deleted.append(entry)
self._emit("vm_disk_gc", {"id": entry},
"vm=%s stale disk deleted" % entry)
except Exception as exc:
failed.append(entry)
self._emit("vm_disk_delete_failed", {"id": entry},
"vm=%s gc error=%s" % (entry, exc))
return {"deleted": deleted, "failed": failed}
def _is_live_vm(self, entry):
"""VM 是否活着:socket 存在或登记进程未死即活。"""
if os.path.exists(os.path.join(self.state_dir, entry + ".sock")):
return True
with self._vms_lock:
proc = self._vms.get(entry)
if proc is not None:
try:
return proc.poll() is None
except Exception:
pass
return False
def _clean_one_vm_secrets(self, entry, vm_dir):
"""单 VM 残留清理:私钥一律删(live VM 私钥在 seed 盘
建好后即删,残留只可能是崩溃遗留);非 live VM 的
seed.iso/seed 目录同理删。返回 (删私钥, 删 seed,
删失败)。
feat-chv-p1fix(P1-03):删失败不再静默 pass——
第三元组就是失败标志,调用方据此发审计、写脏标记,
删失败的 VM 绝不进 cleaned(调用方可区分成功与失败)。
"""
removed_priv = False
failed = False
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
if os.path.exists(priv):
try:
os.remove(priv)
removed_priv = True
except Exception:
failed = True
removed_seed = False
if not self._is_live_vm(entry):
for _suffix in ("seed.iso", "seed"):
_p = os.path.join(vm_dir, _suffix)
try:
if os.path.isdir(_p):
shutil.rmtree(_p)
removed_seed = True
elif os.path.exists(_p):
os.remove(_p)
removed_seed = True
except Exception:
failed = True
return removed_priv, removed_seed, failed
def cleanup_stale_secrets(self) -> list:
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。
feat-chv-p1fix(P1-03):删失败的 VM 不进 cleaned,
另发 host_secret_residue_delete_failed 审计并写脏
标记(下次 provision_host_key 见标记即拒起,见
vm_dirty_refused),直至人工清理——最敏感物残留
不再“重试前不可观测”。"""
cleaned: list = []
try:
entries = os.listdir(self.state_dir)
except Exception:
return cleaned
for entry in entries:
if not entry.startswith("chv-"):
continue
vm_dir = os.path.join(self.state_dir, entry)
if not os.path.isdir(vm_dir):
continue
removed_priv, removed_seed, failed = \
self._clean_one_vm_secrets(entry, vm_dir)
if failed:
self._emit("host_secret_residue_delete_failed",
{"id": entry},
"vm=%s private_key_removed=%s "
"seed_removed=%s" % (
entry, removed_priv, removed_seed))
self._write_dirty_marker(entry, True)
# 删失败时即使部分删掉了,也不进 cleaned:残留仍在,
# “已清理”名单里绝不出现未清干净的 VM。
if not failed and (removed_priv or removed_seed):
cleaned.append(entry)
self._emit("host_secret_residue_cleaned",
{"id": entry},
"vm=%s private_key=%s seed=%s"
% (entry, removed_priv, removed_seed))
return cleaned
def shutdown_vm(self, task_or_name: dict | str) -> bool:
"""关闭入口的正式名:即 stop_vm(ch-remote shutdown)。
任务结束/失败/取消时调它。"""
return self.stop_vm(task_or_name)
def cancel_vm(self, task_or_name: dict | str) -> bool:
"""取消入口:即 shutdown_vm。worker 的取消路径对
Docker 调 kill_container,对 CHV 调本方法——取消绝不
只改数据库状态,必须真关 VM。"""
return self.shutdown_vm(task_or_name)
def recycle_vm(self, task: dict) -> dict:
"""回收 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 _stop_dead_socket_vms(self, entries):
"""socket 扫描:登记进程仍活的跳过,其余走 stop_vm。
返回已清理 VM 名;非 chv- 前缀 socket 一律跳过。
feat-chv-p1fix(P1-05):登记进程的读持 _vms_lock,
不再无锁读 dict(与 purge/注册并发会读到中间态)。"""
cleaned = []
for entry in entries:
if not entry.endswith(".sock"):
continue
name = entry[:-len(".sock")]
if not name.startswith("chv-"):
continue
with self._vms_lock:
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)
return cleaned
def _purge_dead_registry(self):
"""登记进程已死但 socket 已被删的登记项顺手清掉
(没有 socket 可删,不计入清理数)。
feat-chv-p1fix(P1-05):先在锁内快照键列表,再
逐项在锁内读/删——旧“无锁 pop 与持锁遍历并发”
会致 dictionary changed size during iteration,
且 pop 本身无锁。"""
with self._vms_lock:
names = list(self._vms.keys())
for name in names:
with self._vms_lock:
proc = self._vms.get(name)
try:
dead = proc is not None and proc.poll() is not None
except Exception:
dead = True
if dead:
with self._vms_lock:
self._vms.pop(name, None)
def cleanup_stale_vms(self) -> list:
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
socket 扫描与登记清理。只清 socket 残留与已死进程
对应的 VM;state_dir 不存在返回 [],绝不抛异常。
feat-chv-p1fix(P1-05):全程持 state 锁,与 wake/
restore 互斥——清理删盘/删 socket 时不许另一路
wake 同时为同名 VM 供盘、建 VM。"""
with self._state_lock_ctx():
return self._cleanup_stale_vms_locked()
def _cleanup_stale_vms_locked(self) -> list:
"""持 state 锁的 cleanup 本体(见 cleanup_stale_vms)。"""
try:
self.cleanup_stale_secrets()
except Exception:
pass
try:
self.gc_stale_disks()
except Exception:
pass
try:
entries = os.listdir(self.state_dir)
except Exception:
return []
cleaned = self._stop_dead_socket_vms(entries)
self._purge_dead_registry()
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 SimpleNamespaceShim, _parse_host_result
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 生命周期(冷启动正式路径,feat-chv-arch95
定案):wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 →
finally shutdown_vm。本方法绝不查 WarmPool:预热池是
实验性的(见 experimental_pool.py 头定案与 ADR-5),dispatch 永远
冷启动,warm_pool_wired_to_dispatch 恒 False。
wake 失败时不跑
任务、直接抛分类失败。本方法不做内部重试:重试是
worker 的 max_retries 语义,执行器内部重试会把一次
失败放大成多次计费与多次 VM 启动。
中途死亡分类(本轨新增):客内命令抛传输异常、或
非 goal 结果已 failed 时,若登记的 VMM 进程已死,
判 interrupted(断点可续跑)而不是笼统 failed 或
原样抛异常;VM 还活着时的异常/失败原样处理,不
误判。goal 结果本身已有 interrupted 分类(见
parse_goal_result),这里不再二次改写 goal 结论。
feat-chv-secfix(H2):extra_env 透传给 wake_vm(密钥
子集在 provisioned 模式下随 seed 投递),再透传给
_run_guest(远程命令 source 客内密钥文件)。"""
self.wake_vm(task, extra_env=extra_env)
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) -> 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: dict, extra_env: dict | None = None, model_provider: str | None = 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: dict, extra_env: dict | None = None, model_provider: str | None = 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: dict, extra_env: dict | None = None, model_provider: str | None = 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: dict, goal_text: str | None = None,
max_rounds: int | None = None,
extra_env: dict | None = None,
model_provider: str | None = 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: dict,
model_provider: str | None = None,
extra_env: dict | None = 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)