Files
codex-app-server/cloud/chv/executor.py
T
host 9d77ae55a7 feat(chv): 阶段 2 客机运行时三分 daemon/execd/syncd + restore 留 seed.iso + tap 退避
- 客机三分(chv-guest/chv-daemon.py、chv-execd.py、chv-syncd.py,
  仅标准库;systemd Restart=always;build.sh 拷入 + 单元 + enable;
  buildinfo/check/generate lockfile 登记):
  daemon=心跳/资源上报/快照配合(prepare-snapshot 问 execd,
  有运行中任务或联系不上即拒绝);execd=shell/文件读写/codex
  托管(chv-cell 起 host.py,setsid 脱离,任务登记落盘,
  重启按 pid 认领;SO_PEERCRED 只认 uid 0/1000;codex env
  白名单);syncd=常驻循环只调 chv-sync pull/push,规则一条
  不改(先 pull --ff-only 再 push,分叉即停、不 --force、
  不 rebase;仓不存在记 needs-boot 不擅自 clone)。
  host.py 仍是唯一任务/codex 引擎,broker/browser/cell 分工
  不变,不另起第二套 agent。
- 阶段 1 遗留修复:restore_vm 失败路径 stop_vm(keep_disk=True),
  seed.iso 与任务盘都留,重试有输入;vm.boot/restore 的 tap
  触点遇 Resource busy 等 tap 空闲后重试一次(_wait_tap_idle
  看 ip link state,_ch_remote 报错带 stderr 尾巴以便识别);
  诊断不用 pgrep -x(15 字符上限)。
- 单测 cloud/tests/test_chv_opt_stage2.py 25 项;P2 干跑夹具
  与 enable 行断言随构建接线更新。
- 全量 cloud/tests 2001 全过(skipped=8)。
2026-11-08 09:45:00 +00:00

3542 lines
173 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.atomic_file import atomic_write_text
from cloud.chv.config import _parse_memory_bytes, build_chv_config
from cloud.chv.disk import (
check_disk_quota,
check_snapshot_limits,
clear_persistent_vm,
data_disk_path,
delete_data_disk,
delete_task_disk,
find_snapshot_entry,
has_recorded_snapshots,
overlay_disk_path,
provision_data_disk,
provision_task_disk,
read_persistent_vm,
read_vm_tenant,
record_snapshot,
task_disk_path,
task_disk_paths,
write_persistent_vm,
write_vm_tenant,
)
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.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_guest_env,
split_secret_env,
write_known_hosts,
)
from cloud.executor_base import Executor
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
def _pid_gone(pid):
"""进程是否已不在(/proc 消失,或已成僵尸:父进程未收,
但内存/tap 等资源已释放,等同已死)。"""
try:
with open("/proc/%d/stat" % pid, "rb") as fh:
stat = fh.read()
except OSError:
return True
try:
state = stat.rsplit(b")", 1)[1].split()[0]
except (IndexError, ValueError):
return False
return state == b"Z"
def _terminate_pid(pid, sigterm_timeout=2.0):
"""SIGTERM→等→SIGKILL 杀一个进程:best-effort 不抛异常。
僵尸态即算已死(资源已释放),不等父进程收尸。"""
import signal
try:
os.kill(pid, signal.SIGTERM)
except OSError:
return
deadline = time.time() + sigterm_timeout
while time.time() < deadline:
if _pid_gone(pid):
return
time.sleep(0.05)
try:
os.kill(pid, signal.SIGKILL)
except OSError:
return
deadline = time.time() + sigterm_timeout
while time.time() < deadline:
if _pid_gone(pid):
return
time.sleep(0.05)
def _find_vmm_pids_by_socket(socket_path, binary_basename):
"""扫描 /proc,找命令行以 --api-socket 带该 socket 路径、
且 argv[0] 是给定 VMM 二进制的进程 pid 列表。三道门:
精确 socket 路径(每 VM 唯一)、VMM 二进制名(ch-remote
等客户端绝不认)、跳过自身与 pid 1。读不到 cmdline 的
进程跳过,不抛异常。"""
if not socket_path or not binary_basename:
return []
try:
entries = os.listdir("/proc")
except OSError:
return []
me = os.getpid()
found = []
for entry in entries:
if not entry.isdigit():
continue
pid = int(entry)
if pid in (1, me):
continue
try:
with open("/proc/%d/cmdline" % pid, "rb") as fh:
raw = fh.read()
except OSError:
continue
if not raw:
continue
tokens = [t.decode("utf-8", "surrogateescape")
for t in raw.split(b"\0") if t]
if not tokens:
continue
if os.path.basename(tokens[0]) != binary_basename:
continue
if socket_path not in tokens:
continue
if "--api-socket" not in tokens \
and not any(t.startswith("--api-socket=")
for t in tokens):
continue
found.append(pid)
return found
def _assert_snapshot_dir_persisted(snapshot_url):
"""file:// 快照的落盘校验(v54 起产物是目录):目标须是
已生成的非空目录,否则 ChvFailure snapshot_not_persisted。
非 file:// 目标无法从宿主验证,只认 ch-remote returncode。"""
if not snapshot_url.startswith("file://"):
return
snap_path = snapshot_url[len("file://"):]
if not os.path.isdir(snap_path) or not os.listdir(snap_path):
raise ChvFailure(
"snapshot_not_persisted",
"快照未落盘或为空目录:%s" % snapshot_url)
# SSH 连接建立阶段瞬时 reset 的 stderr 标记(V-7/F1):
# kex 阶段被 reset 时 OpenSSH 报 kex_exchange_identification
# + Connection reset,远端命令根本没开始执行。只认这组
# 标记:host key 拒绝(同为 rc=255)与远端命令失败不在此列。
_SSH_TRANSIENT_RESET_MARKERS = (
"kex_exchange_identification",
"Connection reset",
"Connection closed by",
)
def _is_transient_ssh_reset(result) -> bool:
"""单次 SSH 是否撞上连接建立阶段的瞬时 reset:returncode
255 + stderr 含 kex/reset 标记。stderr 可能是 bytes,
按文本解码后比对;无 stderr 即不认,不猜。"""
if getattr(result, "returncode", 0) != 255:
return False
stderr = getattr(result, "stderr", "") or ""
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", "replace")
return any(marker in stderr
for marker in _SSH_TRANSIENT_RESET_MARKERS)
def _ensure_snapshot_dir(snapshot_url, snapshot_path) -> None:
"""file:// 快照目标目录建好(V-7/D1):服务端要求目标是
已存在的目录,fresh URL 直接 snapshot 会 rc=1
(Destination is not a directory),mkdir 后同一 URL 即
通。非 file:// 目标(snapshot_path 为 None)跳过。目标
已存在且不是目录、或建目录失败时报 snapshot_not_persisted
(ch-remote 之前 fail-fast,不调 ch-remote)。"""
if snapshot_path is None:
return
if os.path.exists(snapshot_path) \
and not os.path.isdir(snapshot_path):
raise ChvFailure(
"snapshot_not_persisted",
"快照目标已存在且不是目录:%s" % snapshot_url)
try:
os.makedirs(snapshot_path, exist_ok=True)
except OSError as exc:
raise ChvFailure(
"snapshot_not_persisted",
"快照目录无法创建:%s:%s" % (snapshot_url, exc))
def _disk_image_type(path: str) -> str:
"""VmConfig 磁盘 image_type 枚举原名(feat-chv-opt-landing):
.qcow2 后缀即 Qcow2,其余即 Raw(raw 无魔数、JSON 里写
小写 raw 会报 unknown variant,见 _vm_config_disks)。"""
return "Qcow2" if str(path).endswith(".qcow2") else "Raw"
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。
- 快速启动:持久 VM + 预热池(feat-chv-opt-landing,
阶段 1,生产路径,见 pause/resume 与
dispatch_task_persistent/prewarm_persistent_vm):
同一租户的任务共用一台持久 VM(overlay 系统盘 +
独立数据盘),任务间隙 pause、到达时 resume;
CHV_WARM_POOL_SIZE>=1 时控制面常备 1 台已开机、已
完成 Gitea 首次同步的 paused 持久 VM,任务到达先
领它、结束暂停回池,池空回落冷启不报错;为 0(默认)
时全部退化为冷启旧语义。WarmPool(experimental_pool.py)
仍是纯实验(ADR-5/ADR-12),生产 dispatch 绝不查它,
阶段 1 的池不经过它;池状态另落
state_dir/persistent/<tenant>.json 登记,worker
重启后载入的条目须经 vm.info=Paused + socket 活性
+ tenant 归属三重核对才认领,否则只当孤儿清理,
绝不直接发给任务(见 orphans.py 与 acquire_for_task)。
- 长任务中途死亡:_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
_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-closeup(P2-20):_guest_env_delivered 是
# 跨 wake/build_ssh_command/stop 的共享字典,wake 与
# 执行可并发在不同线程——此前无锁读写,set/get/pop
# 交错会丢投递记录(密钥被误判未投递而拒跑,或已清
# 的记录被误读为已投递)。专用锁护住全部访问(与
# _vms_lock 分开:投递记录与进程登记是两把不同的锁,
# 合一把会在 stop 路径上不必要地串行);所有访问点
# 必须走下面三个小方法,禁止直接读写字典(新方法
# 是唯一入口,回归测试钉死它)。
self._guest_env_lock = threading.Lock()
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 _delivered_secrets(self, name: str) -> set:
"""P2-20 唯一读入口:该 VM 已 seed 投递的密钥名集合。"""
with self._guest_env_lock:
return set(self._guest_env_delivered.get(name, set()))
def _mark_guest_env_delivered(self, name: str, secrets) -> None:
"""P2-20 唯一写入口:记下该 VM 已投递的密钥名集合
(secrets 为空即清记录,与旧语义一致)。"""
with self._guest_env_lock:
if secrets:
self._guest_env_delivered[name] = set(secrets)
else:
self._guest_env_delivered.pop(name, None)
def _clear_guest_env_delivered(self, name: str) -> None:
"""P2-20 唯一清除入口:stop 时清掉该 VM 的投递记录。"""
with self._guest_env_lock:
self._guest_env_delivered.pop(name, None)
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",
"gitea_sync_env", "seed_user_data_extra_file",
"seed_network_config_file"):
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")
def _tenant_id(self, task) -> str | None:
"""归属租户 id(feat-chv-opt-landing):任务 dict 的
tenant_id > project_id > 单租户声明
CHV_SINGLE_TENANT_PROJECT;三者皆无即 None(无归属
可标,快照/overlay 按 legacy 处理,不伪造)。"""
if isinstance(task, dict):
tenant = task.get("tenant_id") or task.get("project_id")
if tenant:
return str(tenant)
return getattr(self, "single_tenant_project", None)
def persistent_vm_name(self, tenant_id: str | None) -> str:
"""该租户的持久 VM 名:chv-persist-<安全字符租户>
(阶段 1:同一租户的连续任务共用这一台 VM,任务间
暂停不断电、不删盘;租户为空即 default 单租户)。"""
safe = re.sub(r"[^A-Za-z0-9_.-]", "-",
str(tenant_id or "default"))
return "chv-persist-" + safe
# -- 前置校验 ------------------------------------------------------
@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)。
try:
int(self.cpus)
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_CPU 必须是整数:%r" % (self.cpus,)))
try:
_parse_memory_bytes(self.memory)
except ValueError:
missing.append(("config_invalid",
"CHV_MEM 格式非法:%r" % (self.memory,)))
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 必须是正数"))
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_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")
# feat-chv-closeup(P2-18):spill 是审计丢事
# 件后的唯一取证,写完即 fsync,否则宿主崩溃
# 后 spill 本身也可能丢。失败只记日志,不炸任务。
fh.flush()
os.fsync(fh.fileno())
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 时不再写死回环,否则钉死校验永远
对不上);解析失败时回环仅作钉死写入,不放宽校验。"""
from cloud.chv.hostkeys import known_hosts_line
from cloud.chv.ssh_transport import write_known_hosts
try:
ssh_host = self.resolve_ssh_host()
except ChvFailure:
ssh_host = "127.0.0.1"
write_known_hosts(
paths["known_hosts"],
[known_hosts_line(self.ssh_port, public_text,
host=ssh_host)])
def provision_host_key(self, task: dict, secret_env: dict | None = None,
guest_env: dict | None = None,
user_data_extra: str | None = None,
network_config: str | 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);guest_env 给定时与
secret_env 合并写同一客内文件(boot 时要用的非密钥
同步变量,见 gitea_sync);上次清理失败留下的
脏标记存在时直接拒绝(清理失败即拒起,不再复用
旧 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,
guest_env=guest_env,
user_data_extra=user_data_extra,
network_config=network_config,
ssh_user=getattr(self, "ssh_user", None))
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 将就跑。"""
try:
ssh_host = self.resolve_ssh_host()
except ChvFailure:
# 纯命令构造(旧测试/干跑)不炸:回环缺省仅作
# 构造兼容,wake/wait 路径仍严格解析。
ssh_host = "127.0.0.1"
secrets, plain = split_secret_env(extra_env)
guest_env_file = None
if secrets:
from cloud.chv.hostkeys import GUEST_ENV_PATH
delivered = self._delivered_secrets(self.vm_name(task))
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,
data_disk: str | None = None) -> list:
"""VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。
任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init
NoCloud 数据源)+ 可选数据盘(overlay 模式第二块盘,
工作区/浏览器 profile 载体,客内按 LABEL=CHVDATA 挂
载到 /home/chv/workspace);显式给的盘若不是基础
镜像本身,须在 state_dir 内,否则 VmConfig 不能挂
进宿主任意文件。
image_type 必须显式给:CHV 54 的磁盘类型靠文件
魔数探测,raw 盘无魔数、探测失败即 vm.boot 400
"Image type required for disk"(2026-11-07 WSL 真机
CHV 54.0.0 实测);值须是枚举原名 Raw(CLI 的小写
raw 是另一层映射,JSON 里写 raw 会报 unknown variant);
CLI 各轮一直传 image_type=raw(seed ISO 亦然,客内
仍以 /dev/vdb 读到 CIDATA)。
feat-chv-opt-landing:.qcow2 后缀的盘(overlay 与
数据盘)image_type 为 Qcow2(qcow2 有魔数可探测,
但与 raw 同款必须显式给枚举原名,不依赖探测)。"""
# 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘
# “seed 磁盘须在…”(label 本身含“磁盘”与空格)。
for path, label in ((task_disk, "任务磁盘"),
(seed_disk, "seed 磁盘"),
(data_disk, "数据磁盘")):
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,
"image_type": _disk_image_type(
task_disk or self.rootfs)}]
if seed_disk:
disks.append({"path": seed_disk, "readonly": True,
"image_type": "Raw"})
if data_disk:
disks.append({"path": data_disk,
"image_type": _disk_image_type(data_disk)})
return disks
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None,
data_disk: str | None = None) -> dict:
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
payload{kernel, cmdline}、disks[{path: 每任务磁盘}]、
rng{/dev/urandom},以及 CHV_NET_JSON 给出的 net 列表
(不给则不带 net——客机无网卡时 SSH 就绪会超时失败,
这是 fail-fast 前置条件,见类 docstring)。
task_disk 不给时用基础镜像路径(仅 lab/reuse 模式)。
payload 形态(CHV 54 API):内核与命令行须放在
payload 对象里(kernel/cmdline 均为直接字符串),
旧版顶层 kernel{path}/cmdline{args} 会被服务端忽略,
vm.create 照常 204、vm.boot 报 400 "No bootitem
provided: neither firmware nor kernel"(2026-11-07
WSL 真机 CHV 54.0.0 实测)。"""
try:
cpus = int(self.cpus)
except ValueError:
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
payload: dict = {}
if self.kernel:
payload["kernel"] = self.kernel
if self.cmdline:
payload["cmdline"] = self.cmdline
config = {
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
"memory": {"size": _parse_memory_bytes(self.memory)},
"payload": payload,
"disks": self._vm_config_disks(task_disk, seed_disk,
data_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 _drop_registered_proc(self, name):
"""取走并杀掉本实例登记的旧 VMM 进程(wake 与 restore
的共用准备步):返回旧进程或 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)
return old
def _kill_vmm_by_socket(self, socket_path):
"""按 api-socket 杀未登记的 VMM 进程(F2 跨实例 stop
与 V-3 restore 的共用机制):扫描 /proc 找命令行里
以 --api-socket 带该 socket 路径、且 argv[0] 是本执行
器 VMM 二进制的进程,SIGTERM→等→SIGKILL。
绝不误杀的三道门(见 _find_vmm_pids_by_socket):
精确 socket 路径、VMM 二进制名、跳过自身与 pid 1。
扫描/杀失败一律 best-effort 不抛异常——stop_vm
“尽力而为、不抛异常”的契约不变。返回杀掉的 pid 列表
(调用方审计用,测试断言用)。"""
try:
own_binary = os.path.basename(self.binary or "")
except Exception:
return []
killed = []
for pid in _find_vmm_pids_by_socket(socket_path, own_binary):
_terminate_pid(pid)
killed.append(pid)
return killed
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 _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
口径:总量 + 本次 + 宿主余量,镜像不可读不静默跳过)。
feat-chv-opt-landing:overlay 模式本次增量为 0(只
建空 overlay,不拷 base),配额仍按 base 大小硬帽。"""
increment = 0 if getattr(self, "disk_mode", "copy") \
== "overlay" else None
check_disk_quota(self.rootfs, self.disk_gb,
state_dir=self.state_dir,
increment_bytes=increment)
def _provision_task_disk(self, task):
"""每任务磁盘供给(实现在 cloud/chv/disk.py):
- copy(默认):把基础镜像复制到
state_dir/<vm>/disk.raw,VmConfig 用该路径;复制
慢但两个任务绝不写同一文件,测试用小文件可直接验证。
- reuse(仅单租户实验):直接返回基础镜像路径,磁盘
不独立;preflight 拦截(须显式实验二次确认并审计
放行,见 preflight,feat-chv-fix)。
- overlay(feat-chv-opt-landing,阶段 1):在
state_dir/<vm>/disk.qcow2 建空 overlay,backing
file 指向只读 base。
数据盘与 tenant 标记与 overlay 解耦(2026-11-07 真机
实测:CHV v54 的 vm.boot 对任何带 backing file 的
qcow2 都报 Maximum disk nesting depth exceeded,
overlay 在本 VMM 上不可用——见阶段 1 报告 §overlay;
但数据盘(工作区/浏览器 profile 载体,不进快照)与
tenant.json(持久 VM 归属)是独立持久/归属机制,
不依赖 backing file):copy 与 overlay 都供给独立
数据盘 state_dir/<vm>/data.raw 并写 tenant 归属
标记 tenant.json;只有 reuse 不写(盘就是共享 base,
标给某租户是错的)。
返回每任务磁盘路径。"""
disk = provision_task_disk(
self.state_dir, self.vm_name(task), self.rootfs,
self.disk_mode)
if getattr(self, "disk_mode", "copy") in ("overlay", "copy"):
provision_data_disk(self.state_dir, self.vm_name(task))
write_vm_tenant(self.state_dir, self.vm_name(task),
self._tenant_id(task), disk=disk)
return disk
def _provision_data_disk_path(self, task):
"""数据盘路径(VmConfig 第三块盘):copy 与 overlay 都
挂(见 _provision_task_disk 的解耦说明);只有 reuse
返回 None(共享 base,不挂私有数据盘)。"""
if getattr(self, "disk_mode", "copy") == "reuse":
return None
return data_disk_path(self.state_dir, self.vm_name(task))
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 _call_ssh_with_transient_retry(self, task, ssh_cmd,
timeout=None):
"""任务 SSH 的连接建立阶段瞬时 reset 重试(V-7/F1,
执行器内部唯一允许的重试——§5“执行器内部不重试”
的例外,见 _run_with_vm 的注):
崩溃后重 wake 的首条任务 SSH 曾撞 1 次 kex 阶段
Connection reset(rc=255),下一条即通;与防火墙
冒烟第一轮 cloud-init 收尾重启 sshd 的瞬态故障同款
签名。wait_guest_ready 的轮询本就容忍这类抖动,但
首条任务 SSH 是单次调用:不重试就把一次秒级抖动
放大成 worker 级重试 = 整台 VM 冷启动重跑(分钟级
+ 重复计费)。故只对“连接未建立即 reset”(rc=255
+ kex/reset 标记,见 _is_transient_ssh_reset)重试
恰好 1 次,重试前 sleeper(1);第二次仍失败即原样
返回,不再追。host key 拒绝、认证失败、远端命令
失败一律不重试;误重试的最坏后果也被 host.py 兜住:
dispatch 同名任务已存在即 loud fail(task 已存在),
不会静默重跑一轮 turn。重试发 vm_ssh_transient_retry
审计。"""
result = self._call_runner(ssh_cmd, timeout=timeout)
if _is_transient_ssh_reset(result):
self._emit("vm_ssh_transient_retry", task,
"vm=%s ssh 瞬时 reset(kex 阶段),1 秒后重试 1 次"
% self.vm_name(task),
"ssh_transient_retry")
self.sleeper(1)
result = self._call_runner(ssh_cmd, timeout=timeout)
return result
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 _wait_guest_env_usable(self, task: dict) -> None:
"""密钥已投递时,等客内 guest-env 对 SSH 用户可读再返回:
write_files 以 root:root 写文件(见 hostkeys.py——
write_files 在 users-groups 之前跑,owner 只能写 root),
客内 final 阶段的 99-chv-sync.cfg runcmd 才 chown 给
SSH 用户;SSH 就绪(vm_ready)比 final 阶段早约 30 秒
(V-9 真机:runcmd 脚本在 vm_ready 后约 31 秒才执行),
派发若在这窗口内 source 文件会 Permission denied。
判定用 `test -r`(SSH 用户本人可读与否,正是派发
source 的前置条件),不比对 owner 名。无密钥投递时
不等待、不轮询。超时先关 VM 再抛 guest_env_not_ready,
绝不带着读不到的密钥去跑任务。"""
if not self._delivered_secrets(self.vm_name(task)):
return
from cloud.chv.hostkeys import GUEST_ENV_PATH
attempts = max(1, int(self.ready_timeout / self.ready_interval))
for attempt in range(attempts):
try:
result = self._call_runner(self.build_ssh_command(
task, ["test", "-r", GUEST_ENV_PATH]))
except Exception:
result = None
if (result is not None
and getattr(result, "returncode", 1) == 0):
return
if attempt + 1 < attempts:
self.sleeper(self.ready_interval)
try:
self.stop_vm(task)
except Exception:
pass
raise ChvFailure(
"guest_env_not_ready",
"guest-env 在 %d 次轮询内仍对 SSH 用户不可读,已关 VM:"
"runcmd chown 未生效或 cloud-init 未完成"
% 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):
# feat-chv-opt-landing(阶段 2):stderr 尾巴进报错,
# 否则 tap-busy(Resource busy / os error 16)这类
# 原因只剩 returncode,调用方无法区分瞬时竞态与
# 真失败、重试 wrapper 无从下手。截断 300 字符。
tail = (getattr(result, "stderr", "") or "").strip()
tail = tail.splitlines()[-1] if tail else ""
raise RuntimeError(
"ch-remote %s 失败:returncode=%s%s"
% (" ".join(args), getattr(result, "returncode"),
(" stderr=%s" % tail[:300]) if tail else ""))
return result
# -- tap 空闲轮询与退避重试(阶段 2,§6.1/§6.2 遗留) ------------
def _tap_wait_seconds(self):
try:
return max(0.0, float(
self._env.get("CHV_TAP_WAIT_TIMEOUT", 30)))
except (TypeError, ValueError):
return 30.0
def _tap_wait_interval(self):
try:
return max(0.5, float(
self._env.get("CHV_TAP_WAIT_INTERVAL", 2)))
except (TypeError, ValueError):
return 2.0
@staticmethod
def _is_tap_busy_error(exc):
text = str(exc)
lowered = text.lower()
return ("resource busy" in lowered
or "os error 16" in lowered
or ("tap" in lowered and "busy" in lowered))
def _tap_names_for_task(self):
"""该任务 net 条目的 tap 名(去重去空)。"""
if not self.net_json:
return []
try:
entries = parse_net_entries(self.net_json)
except Exception:
return []
taps = []
for entry in entries:
tap = (entry.get("tap") or "").strip()
if tap and tap not in taps:
taps.append(tap)
return taps
def _wait_tap_idle(self, taps, timeout=None):
"""等 tap 空闲:`ip link show <tap>` 不存在即空闲;
state DOWN / NO-CARRIER 即空闲;否则轮询到超时。
无 tap 配置、ip 不可用、探查抛错、输出为空时返回 True
(按旧行为直接尝试,重试 wrapper 仍会接住 busy 再
等一次)。诊断一律不用 pgrep -x cloud-hypervisor
(15 字符上限永远匹配不到),只看 tap 设备状态,
或 ps -eo pid,cmd / 直接读 /proc。"""
if not taps:
return True
if timeout is None:
timeout = self._tap_wait_seconds()
interval = self._tap_wait_interval()
deadline = time.monotonic() + max(0.0, timeout)
while True:
idle = True
for tap in taps:
try:
result = self._call_runner(
["ip", "link", "show", tap], timeout=10)
except Exception:
continue
if getattr(result, "returncode", 0) != 0:
continue # tap 不存在 = 空闲
out = getattr(result, "stdout", "") or ""
if not out.strip():
continue # 无输出 = 探查无果,按空闲处理
if "state DOWN" in out or "NO-CARRIER" in out:
continue
idle = False
if idle:
return True
if time.monotonic() >= deadline:
return False
self.sleeper(interval)
def _retry_on_tap_busy(self, task, fn, what):
"""tap-busy 退避重试:fn 抛含 Resource busy 的错时,
等 tap 空闲(CHV_TAP_WAIT_TIMEOUT,默认 30 秒)后
重试一次;第二次仍 busy 即原样抛(调用方失败路径
处理,restore 失败路径现已保留 seed.iso 可再试)。"""
try:
return fn()
except Exception as exc:
if not self._is_tap_busy_error(exc):
raise
taps = self._tap_names_for_task()
self._emit("vm_tap_busy_retry", task,
"vm=%s %s tap busy,等 tap 空闲后重试一次"
% (self.vm_name(task), what))
self._wait_tap_idle(taps)
return fn()
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 条目(P2-19 收尾)。
返回 True=登记成功,False=登记失败。失败时三件事
同时做:warning 日志(旧行为保留)+ 独立审计事件
net_registry_write_failed(调用方与运维都能看见,
不再只有一条日志)+ 返回 False 让调用方感知。
调用方 _boot_vm 见 False 即拒绝启动(ChvFailure,
wake 随后 stop_vm 清掉半醒 VM):登记缺失的后果
是后来的 wake 漏判 tap/mac/ip 冲突(H7 的第二道
防线没了),继续启动等于带着缺口跑,不如不起;
state_dir 可写本就是 preflight 的 0700 门,走到
这里才写失败是真故障,不是预期内的可容忍情形。
写路径走 atomic_file(P2-18):随机 tmp 名 + 双 fsync。
"""
try:
vm_dir = os.path.join(self.state_dir, vm)
os.makedirs(vm_dir, exist_ok=True)
atomic_write_text(
os.path.join(vm_dir, "net.json"),
json.dumps(entries))
return True
except OSError as exc:
_LOGGER.warning("chv_net_registry_write_failed vm=%s "
"error=%s", vm, exc)
self._emit("net_registry_write_failed", None,
"vm=%s net 登记写失败:%s" % (vm, exc),
"net_registry_write_failed")
return False
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 _seed_extras(self) -> tuple:
"""读 seed 附加项文件(CHV_SEED_* 配置的路径):
返回 (user_data_extra, network_config),皆空即
(None, None)。路径已配但文件缺失/读不出时
ChvFailure(seed_extra_missing),wake 在起 VMM
之前拒绝——缺 users 块的 VM 起了也登不进去,缺
network-config 的 VM 在无 DHCP 的 tap 上拿不到 IP,
不如不起。错误只含路径,不含文件内容。"""
out = []
for attr in ("seed_user_data_extra_file",
"seed_network_config_file"):
path = getattr(self, attr, None)
if not path:
out.append(None)
continue
try:
with open(path) as fh:
out.append(fh.read())
except OSError as exc:
raise ChvFailure(
"seed_extra_missing",
"seed 附加文件读不出:%s:%s" % (path, exc))
return out[0], out[1]
def _effective_extra_env(self, task: dict,
extra_env: dict | None) -> dict | None:
"""wake 生效的 extra_env:把 Gitea 同步变量并进来。
优先级(低→高):执行器宿主配置 CHV_GITEA_*(构造时
已映射成 gitea_sync_env)< 任务 dict 的 gitea_sync <
显式 extra_env 同名键(见 gitea_sync.extract_...)。
同步已请求(任一同步键出现)但必填键不全时,在起
VMM 之前就 ChvFailure(gitea_sync_incomplete,
fail-closed;错误只含缺失键名,不含值)——半份同步
配置绝不能让 VM 带着“以为同步了”的状态启动。无同步
变量时原样返回(本次启动不同步)。
必须在 _wake_prechecks 之前调:任务/配置来源的
GITEA_TOKEN 也要受 keyscan 模式 fail-closed 检查。"""
from cloud.chv.gitea_sync import (
extract_gitea_sync_env,
validate_gitea_sync_env,
)
sync = extract_gitea_sync_env(
task=task, extra_env=extra_env,
config_sync=getattr(self, "gitea_sync_env", None))
try:
validate_gitea_sync_env(sync)
except ValueError as exc:
raise ChvFailure("gitea_sync_incomplete", str(exc))
if not sync:
return extra_env
merged = dict(extra_env or {})
merged.update(sync)
return merged
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,
guest_env: dict | None = None,
seed_extras: tuple | None = None) -> 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,
)
extra, netcfg = seed_extras or (None, None)
provisioned = self.provision_host_key(
task, secret_env=secrets or None,
guest_env=guest_env or None,
user_data_extra=extra, network_config=netcfg)
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)
self._mark_guest_env_delivered(name, secrets)
return seed_disk
def _boot_vm(self, task, name, task_disk, seed_disk,
net_entries) -> None:
"""VMM 启动→vm.create→vm.boot→SSH 就绪→密钥已投递时
等 guest-env 对 SSH 用户可读(V-9,见
_wait_guest_env_usable),纯移动。"""
socket_path = self.api_socket(task)
self._launch_api_only(task)
self.wait_api_ready(task)
self._check_api_socket_permissions(socket_path)
status, body = self._api_call(
socket_path, "PUT", "/vm.create",
self.build_vm_config(
task_disk, seed_disk=seed_disk,
data_disk=self._provision_data_disk_path(task)))
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:
# feat-chv-closeup(P2-19):登记失败即拒起,
# 见 _record_net_entries(审计 + 返回 False)。
if not self._record_net_entries(name, net_entries):
raise ChvFailure(
"net_registry_write_failed",
"vm=%s net 登记写失败,拒绝启动(缺登记会"
"漏判 tap/mac/ip 冲突)" % name)
# feat-chv-opt-landing(阶段 2):vm.boot 触 tap,停机
# →再启窗口的 tap 释放竞态在此处报 Resource busy;
# 响应体进报错(否则只剩 status,分不清瞬时竞态),
# busy 时等 tap 空闲后重试一次,不立即失败。
def _do_boot():
_status, _body = self._api_call(
socket_path, "PUT", "/vm.boot")
if _status not in (200, 204):
raise ChvFailure(
"vm_boot_failed",
"vm.boot 失败:status=%s body=%s"
% (_status, _body))
return _status
self._retry_on_tap_busy(task, _do_boot, "vm.boot")
self._emit("vm_boot", task, "vm=%s" % name)
self.wait_guest_ready(task)
# V-9:密钥已投递时等 guest-env 对 SSH 用户可读再报
# 就绪(runcmd chown 比 SSH 就绪晚约 30 秒)。
self._wait_guest_env_usable(task)
def _wake_prepare(self, task: dict, name: str) -> tuple:
"""wake 准备:reuse 审计、旧 VM 清理、任务盘供给、
直连 egress 审计,纯移动。返回 (task_disk, socket_path)。
feat-chv-v3(F1):旧 VM 在时先删旧任务盘再供给新盘。
根因:H5 在 seed 建好后删宿主私钥,re-wake 必然重新
生成 key 对;旧盘若复用,cloud-init 以 instance-id
(=VM 名)已应用为由不再重应用新 seed,sshd 仍呈旧
key,known_hosts 却钉了新 key → host_key_mismatch。
wake 按设计是冷启动(单任务单 VM,重复 wake 即重启),
旧盘从来不是要保留的状态(restore 另走 restore_vm,
暂停唤醒另走 resume);删盘失败即 ChvFailure fail-fast,
绝不带着旧盘 + 新 key 对去启动。reuse 模式不删(盘
就是基础镜像本身,删不得)。"""
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)))
self._drop_registered_proc(name)
# 旧盘不论旧 VMM 是否登记在册(活着重 wake / 崩溃后
# 重 wake)一律先删:delete_task_disk 无盘时返回 False,
# 不 extra 判断。
if getattr(self, "disk_mode", "copy") != "reuse":
try:
delete_task_disk(self.state_dir, name)
except OSError as exc:
raise ChvFailure(
"stale_disk_cleanup_failed",
"旧任务盘删不掉,拒绝复用旧盘启动:VM %s:%s"
% (name, exc))
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 就绪 → 密钥已投递时再等 guest-env 对 SSH 用户可读
(见 _wait_guest_env_usable,V-9)。任一步失败都
关 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。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
name = self.vm_name(task)
try:
extra_env = self._effective_extra_env(task, extra_env)
seed_extras = self._seed_extras()
except ChvFailure as exc:
self._emit("vm_failed", task,
"vm=%s wake 失败 kind=%s" % (name, exc.kind),
exc.kind)
raise
secrets, net_entries = self._wake_prechecks(task, name, extra_env)
guest_env, _plain = split_guest_env(extra_env)
task_disk, socket_path = self._wake_prepare(task, name)
try:
seed_disk = self._provision_seed_disk(
task, name, secrets, guest_env=guest_env or None,
seed_extras=seed_extras)
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
# -- 持久 VM 与预热池(feat-chv-opt-landing,阶段 1)-----------
# 模型(单租户灰度,CHV_WARM_POOL_SIZE=1 时启用):
# 同一租户的任务共用一台持久 VM(chv-persist-<tenant>,
# overlay 系统盘 + 独立数据盘);任务间隙 pause 存盘,
# 下个任务 resume,不走完整开机。预热池 = 常备 1 台已
# 开机、已完成 Gitea 首次同步、处于 paused 的持久 VM:
# 任务到达时先领它顶上,任务结束暂停回池(即补建回池);
# 池空(无 paused 登记)自动回落冷启,不报错。
# 与 experimental_pool.WarmPool 的区别(ADR-5 的缺口
# 在此补上):预热 VM 的盘从生下来就是该租户的持久盘
#(overlay + 数据盘 + tenant 标记),任务直接跑在它
# 自己的盘上,不存在“干净快照盘当任务盘用”的隔离击穿,
# 故 acquire 不需要热换盘。WarmPool 本身仍是实验模块,
# 生产路径不 import 它(ADR-12);本节是生产路径。
# 开关:CHV_WARM_POOL_SIZE(默认 0)——为 0 时本节全部
# 入口退化为冷启旧语义(dispatch_task_persistent 直接
# 调 dispatch_task;prewarm/refill 为 no-op),绝不硬
# 编码改默认值偷换全局行为;阶段 1 验证用
# CHV_WARM_POOL_SIZE=1。
def _vm_info_state(self, task_or_name) -> str | None:
"""GET /vm.info 取 VM 状态(Running/Paused/...):
取不到/无 state 字段即 None(调用方按未知处理,绝
不猜它是 paused 还是 running)。"""
try:
status, body = self._api_call(
self.api_socket(task_or_name), "GET", "/vm.info")
except Exception:
return None
if status != 200 or not isinstance(body, dict):
return None
state = body.get("state")
return state if isinstance(state, str) else None
def _persistent_task(self, tenant_id):
"""持久 VM 的合成任务 dict:id 固定为 persist-<租户>,
故 vm 名恒为 chv-persist-<租户>;gitea_sync 等任务
级同步变量由调用方经 extra_env 给,不进这里。"""
return {"id": "persist-%s" % (tenant_id or "default"),
"project_id": tenant_id}
def _wait_sync_workspace(self, vm_task, sync_workspace) -> bool:
"""等 Gitea 首次同步完成:轮询客内 test -d
<sync_workspace>/.git(clone 落盘的硬证据);每轮
间隔 ready_interval、上限 ready_timeout;超时即
False(调用方 fail-closed,不把未同步的 VM 当预热
机发给任务)。"""
attempts = max(
1, int(self.ready_timeout / self.ready_interval))
for attempt in range(attempts):
try:
result = self._call_runner(self.build_ssh_command(
vm_task, ["test", "-d",
sync_workspace + "/.git"]))
if getattr(result, "returncode", 1) == 0:
return True
except Exception:
pass
if attempt + 1 < attempts:
self.sleeper(self.ready_interval)
return False
def _prewarm_build_new(self, tenant_id, extra_env, sync_workspace):
"""预热新建段(prewarm_persistent_vm 的后半段):冷启
一台持久 VM;sync_workspace 给出时等首次同步完成,
等不到即关 VM 报 warm_pool_sync_incomplete(绝不把
未同步的 VM 记成预热机)。返回 (vm_task, gitea_synced)。"""
vm_task = self._persistent_task(tenant_id)
self.wake_vm(vm_task, extra_env=extra_env)
synced = False
if sync_workspace:
synced = self._wait_sync_workspace(
vm_task, sync_workspace)
if not synced:
try:
self.stop_vm(vm_task)
except Exception:
pass
raise ChvFailure(
"warm_pool_sync_incomplete",
"预热 VM %s 在 %s 秒内未完成 Gitea 首次同步"
"(%s/.git 未出现),已关 VM,不进池"
% (self.vm_name(vm_task), self.ready_timeout,
sync_workspace))
else:
synced = bool(getattr(self, "gitea_sync_env", None))
return vm_task, synced
def prewarm_persistent_vm(self, tenant_id: str | None,
extra_env: dict | None = None,
sync_workspace: str | None = None) -> dict:
"""预热池进池(幂等):CHV_WARM_POOL_SIZE>=1 才生效,
为 0 即 {pooled: False, reason: warm_pool_disabled}。
已有 paused 登记且 socket 活着即返回它(already-warm,
不重建);否则冷启一台持久 VM(overlay 盘 + Gitea
同步 env),sync_workspace 给出时等首次同步完成
(等不到即关 VM 报 warm_pool_sync_incomplete,绝不
把未同步的 VM 记成预热机),再 pause、写登记。
返回 {pooled, vm, mode, gitea_synced}。"""
if int(getattr(self, "warm_pool_size", 0)) <= 0:
return {"pooled": False, "vm": None,
"reason": "warm_pool_disabled"}
vm = self.persistent_vm_name(tenant_id)
record = read_persistent_vm(self.state_dir, tenant_id)
if record is not None and record.get("state") == "paused" \
and record.get("vm") == vm \
and os.path.exists(self.api_socket({"id": vm})):
return {"pooled": True, "vm": vm,
"mode": "already-warm",
"gitea_synced": bool(
record.get("gitea_synced"))}
vm_task, synced = self._prewarm_build_new(
tenant_id, extra_env, sync_workspace)
self.pause_vm(vm_task)
import time as _time
write_persistent_vm(self.state_dir, tenant_id, {
"vm": vm, "tenant_id": tenant_id,
"state": "paused", "origin": "prewarm",
"gitea_synced": synced, "updated_at": _time.time()})
self._emit("warm_pool_prewarm", vm_task,
"vm=%s gitea_synced=%s" % (vm, synced))
return {"pooled": True, "vm": vm, "mode": "prewarmed",
"gitea_synced": synced}
def refill_warm_pool(self, tenant_id: str | None,
extra_env: dict | None = None,
sync_workspace: str | None = None) -> dict:
"""后台补建回池(调用方自行起线程跑,本方法本身
同步):池里已有 paused 登记即 no-op;池空即
prewarm 一台。warm_pool_size==0 时 no-op。"""
record = read_persistent_vm(self.state_dir, tenant_id)
if record is not None and record.get("state") == "paused" \
and os.path.exists(self.api_socket(
{"id": record.get("vm") or ""})):
return {"pooled": True, "vm": record.get("vm"),
"mode": "already-warm",
"gitea_synced": bool(record.get("gitea_synced"))}
return self.prewarm_persistent_vm(
tenant_id, extra_env=extra_env,
sync_workspace=sync_workspace)
def _acquire_paused_candidate(self, tenant):
"""认领的登记核对(acquire_for_task 的前半段):登记
是 paused、socket 存在、tenant.json 归属一致才返回
(vm, record);任一不符即清登记、返回 (None, None)
(调用方回落冷启)。归属不符与 socket 消失是登记
本身已坏,清掉;状态未知(vm.info 取不到)不在此
判——那是认领时的第二道门,见 acquire_for_task。"""
record = read_persistent_vm(self.state_dir, tenant)
if record is None or record.get("state") != "paused":
return None, None
vm = record.get("vm")
if not vm:
return None, None
owner = read_vm_tenant(self.state_dir, vm)
if owner is not None and owner != tenant:
# 盘的归属与请求方不一致:这台不是你的 VM,绝不
# resume,清登记回落冷启(legacy 未标记盘 owner
# 为 None 时放行,见 _check_snapshot_tenant 同款)。
clear_persistent_vm(self.state_dir, tenant)
return None, None
if not os.path.exists(self.api_socket({"id": vm})):
clear_persistent_vm(self.state_dir, tenant)
return None, None
return vm, record
def acquire_for_task(self, task: dict,
extra_env: dict | None = None) -> dict:
"""取本租户的可用 VM(任务到达时的认领):
- 持久登记是 paused、socket 活着、vm.info 确认
Paused、tenant.json 归属一致 → resume + 等 SSH
就绪,返回 {mode: resumed, vm, resume_seconds,
warm_hit}(warm_hit 表示这台来自 prewarm,
不是上个任务暂停留下的)。
- 其它情形(无登记 / VM 已死 / 状态未知 /
resume 或 SSH 就绪失败)→ 丢弃该登记(若有),
返回 {mode: cold, vm: None},调用方回落冷启,
不报错。warm_pool_size==0 时直接 cold。
resume_seconds 只计 resume+SSH 就绪,不含任务
自身启动。"""
if int(getattr(self, "warm_pool_size", 0)) <= 0:
return {"mode": "cold", "vm": None}
tenant = self._tenant_id(task)
vm, record = self._acquire_paused_candidate(tenant)
if vm is None:
return {"mode": "cold", "vm": None}
state = self._vm_info_state({"id": vm})
if state != "Paused":
# 状态未知或非 paused 时不猜:不是 paused 就不
# 认领(running 的 VM resume 会错、死了的 VM
# resume 也会错),回落冷启,登记留给清理。
return {"mode": "cold", "vm": None}
return self._acquire_resume_recorded(task, tenant, vm, record)
def _acquire_resume_recorded(self, task, tenant, vm, record):
"""认领的 resume 段(acquire_for_task 的后半段):
resume + 等 SSH 就绪,计时写回登记(state=running)
并返回 resumed;resume/就绪失败即清登记、回落 cold。
resume_seconds 只计 resume+SSH 就绪。"""
import time as _time
t0 = _time.time()
try:
self.resume_vm({"id": vm})
self.wait_guest_ready({"id": vm})
except Exception as exc:
self._emit("warm_pool_acquire_failed", task,
"vm=%s resume/就绪失败,回落冷启:%s"
% (vm, exc), "warm_pool_acquire_failed")
clear_persistent_vm(self.state_dir, tenant)
return {"mode": "cold", "vm": None}
resume_seconds = _time.time() - t0
write_persistent_vm(self.state_dir, tenant, {
"vm": vm, "tenant_id": tenant,
"state": "running",
"origin": record.get("origin") or "task",
"gitea_synced": bool(record.get("gitea_synced")),
"updated_at": _time.time()})
self._emit("warm_pool_acquire", task,
"vm=%s mode=resumed resume_seconds=%.2f"
" warm_hit=%s"
% (vm, resume_seconds,
record.get("origin") == "prewarm"))
return {"mode": "resumed", "vm": vm,
"resume_seconds": resume_seconds,
"warm_hit": record.get("origin") == "prewarm"}
def suspend_after_task(self, task_or_name: dict | str,
tenant_id: str | None = None) -> bool:
"""任务间隙暂停:pause + 写持久登记(state=paused,
origin=task)。pause 失败或 VM 已死即清登记并返回
False(调用方不得把死 VM 记成可用预热机)。"""
vm = self.vm_name(task_or_name)
tenant = tenant_id or self._tenant_id(
task_or_name if isinstance(task_or_name, dict)
else {"id": task_or_name})
try:
self.pause_vm(task_or_name)
except Exception as exc:
self._emit("vm_suspend_failed",
task_or_name if isinstance(task_or_name, dict)
else {"id": task_or_name},
"vm=%s 暂停失败,清持久登记:%s" % (vm, exc),
"vm_suspend_failed")
if tenant:
clear_persistent_vm(self.state_dir, tenant)
return False
if tenant:
import time as _time
record = read_persistent_vm(self.state_dir, tenant) or {}
write_persistent_vm(self.state_dir, tenant, {
"vm": vm, "tenant_id": tenant,
"state": "paused", "origin": "task",
"gitea_synced": bool(record.get("gitea_synced")),
"updated_at": _time.time()})
return True
def _run_guest_on_vm(self, task, vm, inner_args, goal,
extra_env=None, **kwargs):
"""在指定 VM 上跑客内命令并做中途死亡分类(
_run_with_persistent_vm 的执行段):登记进程已死即
interrupted(断点可续跑),VM 活着时的异常/失败原
样处理,不误判。返回结果 dict(未附 vm_mode,调用
方贴)。"""
try:
if goal:
result = self._run_guest_goal(
task, inner_args, extra_env=extra_env,
vm_name=vm, **kwargs)
else:
result = self._run_guest(
task, inner_args, extra_env=extra_env,
vm_name=vm, **kwargs)
except Exception as exc:
if self._vm_proc_dead({"id": vm}):
return self._vm_death_result(
task, goal, str(exc)[:500])
raise
if not goal and result.get("status") == "failed" \
and self._vm_proc_dead({"id": vm}):
result = dict(result)
result["status"] = "interrupted"
result["vm_dead"] = True
result["interrupted_reason"] = (
"VM 中途死亡,任务未完成、断点可续跑:"
"客内命令已 failed 且登记的 VMM 进程已死")
return result
def _run_with_persistent_vm(self, task, inner_args, goal=False,
extra_env=None, **kwargs):
"""持久 VM 任务生命周期(阶段 1,与 _run_with_vm 对
照):acquire(resumed)或冷启持久 VM → 跑客内命令
→ finally 暂停回池(suspend_after_task),不关机、
不删盘。warm_pool_size==0 时本方法不被调(调用方
直接走 _run_with_vm)。冷启路径:wake 持久 VM
(合成任务 persist-<租户>),任务本身的 host 任务
名仍按真实任务 id 派生(_run_guest 的 vm_name 参数
分离 VM 身份与任务身份)。中途死亡分类见
_run_guest_on_vm(登记进程已死即 interrupted)。"""
tenant = self._tenant_id(task)
vm_task = self._persistent_task(tenant)
acquired = self.acquire_for_task(task, extra_env=extra_env)
vm = acquired.get("vm") or self.vm_name(vm_task)
mode = acquired["mode"]
resume_seconds = acquired.get("resume_seconds")
if mode == "cold":
self.wake_vm(vm_task, extra_env=extra_env)
try:
result = self._run_guest_on_vm(
task, vm, inner_args, goal,
extra_env=extra_env, **kwargs)
if isinstance(result, dict):
result = dict(result)
result["vm_mode"] = mode
if resume_seconds is not None:
result["resume_seconds"] = resume_seconds
return result
finally:
try:
if self._vm_proc_dead({"id": vm}):
# VM 已死:暂停无意义,清登记,免得下次
# acquire 把死 VM 当可用预热机。
if tenant:
clear_persistent_vm(self.state_dir, tenant)
else:
self.suspend_after_task({"id": vm},
tenant_id=tenant)
except Exception:
pass
def dispatch_task_persistent(self, task: dict,
extra_env: dict | None = None,
model_provider: str | None = None) -> dict:
"""持久 VM 任务入口(阶段 1):warm_pool_size>=1 时
走 _run_with_persistent_vm(resume/冷启持久 VM →
跑 → 暂停回池);==0 时退化为 dispatch_task(冷启
→ 跑 → 关机),与旧行为一致。结果 dict 附 vm_mode
(resumed/cold)与 resume_seconds(resumed 时)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
raise ValueError("任务缺少 prompt")
if int(getattr(self, "warm_pool_size", 0)) <= 0:
return self.dispatch_task(
task, extra_env=extra_env,
model_provider=model_provider)
args = ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
repo = task.get("repo")
if repo:
args += ["--repo", repo]
return self._run_with_persistent_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 recycle_persistent_vm(self, task_or_name: dict | str,
tenant_id: str | None = None) -> dict:
"""显式回收持久 VM(唯一删用户盘的入口):先 shutdown
(stop_vm,keep_disk=False),再删 overlay 与数据盘、
清 tenant 标记与持久登记。返回 {vm, state: recycled,
deleted_disks}。与 recycle_vm 的区别:后者只关 VM
不删持久盘(持久盘须显式回收才删,用户数据不静默丢)。"""
vm = self.vm_name(task_or_name)
tenant = tenant_id or self._tenant_id(
task_or_name if isinstance(task_or_name, dict)
else {"id": task_or_name})
self.shutdown_vm(task_or_name)
deleted = []
if delete_task_disk(self.state_dir, vm):
deleted.append("task")
if delete_data_disk(self.state_dir, vm):
deleted.append("data")
_tenant_path = os.path.join(self.state_dir, vm,
"tenant.json")
if os.path.exists(_tenant_path):
os.remove(_tenant_path)
if tenant:
clear_persistent_vm(self.state_dir, tenant)
self._emit("vm_persistent_recycled",
task_or_name if isinstance(task_or_name, dict)
else {"id": task_or_name},
"vm=%s deleted_disks=%s" % (vm, deleted))
return {"vm": vm, "state": "recycled",
"deleted_disks": deleted}
def snapshot_vm(self, task_or_name: dict | str, snapshot_url: str) -> str:
"""给(已暂停的)VM 做快照:ch-remote snapshot <裸 URL>。
注意:snapshot 的参数是裸 URL,不是 source_url=...
(那是 restore 的键);传 source_url=... 会被服务端
当成 URL 本身,报 Could not extract path from URL
(2026-11-07 WSL 真机 V-3 实测)。snapshot_url 为空
即 ValueError;成功审计 vm_snapshot。快照本身不改变
VM 状态(仍 paused,需 resume 才醒)。
v54 起快照产物是目录(含 config.json/state.json/
memory-ranges),不是文件:file:// 目标须是已生成
的非空目录,文件/空目录/缺失一律报
snapshot_not_persisted(旧 isfile 校验会把真快照
误判为未落盘)。
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
远端须显式放行)与数量/总量/保留期上限;成功后登
记并在总量超帽时按最旧优先剪枝(删动作审计)。
feat-chv-v3-repair(V-7/D1):file:// 目标目录在
配额预检后、ch-remote 前建好(见 _ensure_snapshot_dir)。"""
if not snapshot_url:
raise ValueError("snapshot_url 不能为空")
snapshot_path = validate_snapshot_url(
snapshot_url, self.state_dir,
snapshot_dir=getattr(self, "snapshot_dir", None),
allow_remote=getattr(self, "allow_remote_snapshot", False))
vm_name = self.vm_name(task_or_name)
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))
# feat-chv-v3-repair(V-7/D1):建目录放在配额预检
# 之后——配额拒绝先于任何文件改动,且目标是文件时
# 也不抢占配额错误的 kind;只在 snapshot_vm 这一处
# 建,hibernate_vm 经此调用,不重不漏。
_ensure_snapshot_dir(snapshot_url, snapshot_path)
self._ch_remote(task_or_name, "snapshot", snapshot_url)
# feat-chv-fix(P1-7)/feat-chv-v3:只查 returncode
# 不够,见 _assert_snapshot_dir_persisted——file://
# 快照须验证目标目录已生成且非空,否则报
# snapshot_not_persisted,调用方(hibernate_vm)据此
# 不 shutdown、不销毁原 VM;非 file:// 目标无法从宿
# 主验证落盘,仍只认 returncode,但文档明写该边界。
_assert_snapshot_dir_persisted(snapshot_url)
return self._record_snapshot_and_emit(
task_or_name, vm_name, snapshot_url)
def _record_snapshot_and_emit(self, task_or_name, vm_name,
snapshot_url):
"""快照成功后登记 + 总量超帽时最旧优先剪枝(删动作
审计)+ vm_snapshot 审计,返回 snapshot_url。
feat-chv-opt-landing:登记条目带 tenant 字段(归属
标记,见 disk.record_snapshot);审计 detail 同样
带 tenant,便于事后核对谁的快照。"""
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
tenant = self._tenant_id(task)
_entries, _removed = record_snapshot(
self.state_dir, vm_name, snapshot_url,
getattr(self, "snapshot_max_total_gb", 20.0),
tenant_id=tenant)
for _old in _removed:
self._emit("vm_snapshot_pruned", task,
"vm=%s pruned snapshot=%s(总量超帽,最旧优先)"
% (vm_name, _old.get("url")))
self._emit("vm_snapshot", task,
"vm=%s snapshot=%s tenant=%s"
% (self.vm_name(task_or_name), snapshot_url, tenant))
return snapshot_url
def _check_snapshot_tenant(self, task, snapshot_url) -> None:
"""restore 的归属校验(feat-chv-opt-landing,阶段 1
必须补的代码项):按 URL 在全部 VM 的快照登记里找
条目;条目带 tenant 且与请求方 tenant 不一致时,
直接拒绝(ChvFailure snapshot_tenant_mismatch)并
发 vm_restore_tenant_denied 审计——A 的快照绝不能
恢复到 B 名下。条目无 tenant 是阶段 1 之前的 legacy
登记:放行但发 vm_restore_tenant_unmarked 审计警告
(单租户灰度下的旧快照,不静默当作已验);条目根本
未登记:同样放行 + 警告(未登记快照无从核对归属,
登记才是归属的唯一账本——生产须要求快照必登记,
见报告残留)。请求方无 tenant(None)而条目有
tenant 时同样拒绝(无法证明归属即 fail-closed)。"""
_owner_vm, entry = find_snapshot_entry(
self.state_dir, snapshot_url)
if entry is None:
self._emit("vm_restore_tenant_unmarked", task,
"vm=%s snapshot=%s 未在登记中找到,归属"
"无法核对(未登记快照,放行并警告)"
% (self.vm_name(task), snapshot_url),
"snapshot_tenant_unmarked")
return
marked = entry.get("tenant")
if marked is None:
self._emit("vm_restore_tenant_unmarked", task,
"vm=%s snapshot=%s 登记无 tenant 字段"
"(legacy 旧快照,放行并警告)"
% (self.vm_name(task), snapshot_url),
"snapshot_tenant_unmarked")
return
requester = self._tenant_id(task)
if requester != marked:
self._emit("vm_restore_tenant_denied", task,
"vm=%s snapshot=%s 归属 tenant=%s,请求方"
" tenant=%s,拒绝跨用户恢复"
% (self.vm_name(task), snapshot_url,
marked, requester),
"snapshot_tenant_mismatch")
raise ChvFailure(
"snapshot_tenant_mismatch",
"快照归属 tenant=%s,与请求方 tenant=%s 不一致,"
"拒绝跨用户恢复:%s" % (marked, requester,
snapshot_url))
def _validate_restore(self, task, snapshot_url):
"""restore 前置校验:返回(归一化后的 snapshot_url,
归一化后的 net 条目)。
restore 不走 vm.create、没有 VmConfig 可挂 seed:
快照内已固化原 VM host key,重预置反而破坏钉死,
故 provisioned 下复用已钉死的 known_hosts、缺失
fail-fast、绝不 keyscan、绝不重生成。restore 时不
重复验快照文件存在(远端/对象存储源合法,ch-remote
本身会在源缺失时失败)。
feat-chv-cover100:restore 也走 H7 net 隔离——此前
restore 路径既不校验冲突、也不登记 net.json:已登
记 VM 的 tap/mac/ip 会被恢复出的 VM 静默复用,且
恢复出的 VM 自身不登记、后来的 wake 也看不到它,
两边都漏判。与 wake 的 _wake_prechecks 同款:冲突
在起 VMM 前拒绝(net_tap_conflict),登记失败见
_restore_boot(P2-19 语义)。"""
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._check_snapshot_tenant(task, snapshot_url)
self._check_snapshot_disk(snapshot_url)
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, self._validate_restore_net(task)
def _validate_restore_net(self, task):
"""restore 的 net 隔离段(_validate_restore 的尾段):
与 wake 的 _wake_prechecks 同款,冲突在起 VMM 前
拒绝(net_tap_conflict);net 未配即空列表。"""
if not self.net_json:
return []
try:
return validate_net_entries(
parse_net_entries(self.net_json),
self._registered_net_entries(
exclude_vm=self.vm_name(task)))
except ChvFailure as exc:
self._emit("vm_failed", task,
"vm=%s restore 前置失败 kind=%s"
% (self.vm_name(task), exc.kind),
exc.kind)
raise
def _check_snapshot_disk(self, snapshot_url):
"""restore 前的快照磁盘预检(feat-chv-v3,hibernate
评估结论):快照 config.json 引用原任务盘路径,
盘缺失时 restore 必在 ch-remote 侧失败;起 VMM 前
先 fail-fast(snapshot_disk_missing),不让调用方
拿一个含糊的 ch-remote 失败去猜。只对本地目录快照
且 config.json 可读可解析时校验:远端源、旧格式
无 config.json、解析失败时跳过(ch-remote 本身会
在源缺失时失败,不在这里多一层误拒)。"""
if not snapshot_url.startswith("file://"):
return
snap_dir = snapshot_url[len("file://"):]
if not os.path.isdir(snap_dir):
return
config_path = os.path.join(snap_dir, "config.json")
try:
with open(config_path, "r") as fh:
config = json.load(fh)
except (OSError, ValueError):
return
if not isinstance(config, dict):
return
disks = config.get("disks")
if not isinstance(disks, list):
return
missing = []
for disk in disks:
if not isinstance(disk, dict):
continue
path = disk.get("path")
if isinstance(path, str) and path \
and not os.path.exists(path):
missing.append(path)
if missing:
raise ChvFailure(
"snapshot_disk_missing",
"快照引用的任务盘缺失,restore 无法进行:%s"
% ", ".join(missing))
def _restore_prepare(self, task, name):
"""restore 准备(feat-chv-v3,与 _wake_prepare 对等):
杀旧 VMM(登记的 + 未登记跨实例的),清旧 socket。
restore 须落到全新 VMM 上:旧 VMM 还活着时新 VMM
抢不到 tap/socket,ch-remote restore 会被旧 VM 拒
掉(2026-11-07 WSL 真机 V-3 实测)。"""
socket_path = self.api_socket(task)
self._drop_registered_proc(name)
self._kill_vmm_by_socket(socket_path)
try:
if os.path.exists(socket_path):
os.remove(socket_path)
except OSError:
pass
def _restore_boot(self, task, snapshot_url, net_entries):
"""restore 的启动段:起 VMM → 等 ping → restore →
显式 resume → 等 SSH 就绪(恢复后默认 paused,不
resume 就不算醒)。"""
name = self.vm_name(task)
self._restore_prepare(task, name)
self._launch_api_only(task)
self.wait_api_ready(task)
self._check_api_socket_permissions(self.api_socket(task))
# feat-chv-opt-landing(阶段 2):restore 触 tap,停机
# →恢复窗口的 tap 释放竞态在此处报 Resource busy;
# 等 tap 空闲后重试一次,不立即失败(见 _retry_on_tap_busy)。
self._retry_on_tap_busy(
task,
lambda: self._ch_remote(task, "restore",
"source_url=%s" % snapshot_url),
"restore")
# feat-chv-cover100:与 _boot_vm 同款——恢复成功后登记
# net.json,登记失败即拒恢复(P2-19 语义:审计 +
# 返回 False + 调用方 ChvFailure);restore_vm 的
# except 会 stop_vm 清掉半恢复 VM。
if net_entries and not self._record_net_entries(
name, net_entries):
raise ChvFailure(
"net_registry_write_failed",
"vm=%s net 登记写失败,拒绝恢复(缺登记会"
"漏判 tap/mac/ip 冲突)" % name)
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"}。恢复前校验 net 冲突、恢复成功后
登记 net.json(与 wake 的 H7/P2-19 同款,见
_validate_restore/_restore_boot)。"""
snapshot_url, net_entries = self._validate_restore(
task, snapshot_url)
name = self.vm_name(task)
self._check_capacity(exclude=name)
try:
self._restore_boot(task, snapshot_url, net_entries)
except Exception:
try:
# feat-chv-opt-landing(阶段 2,§6.2 遗留缺陷):
# 失败路径原先 stop_vm(task)(keep_disk=False)
# 会删 seed.iso,而快照 config.json 的 disks
# 同时引用 disk.raw 与 seed.iso,删了之后任何
# 重试 restore 都报 seed.iso ENOENT——失败清理
# 毁掉了恢复输入。现改 keep_disk=True:任务盘
# 与 seed.iso 都留,重试成为可能;残留盘由
# GC(有登记快照的受保护、无登记的按孤儿清)
# 与显式回收接手,不长期滞留。
self.stop_vm(task, keep_disk=True)
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。
feat-chv-v3(V-3 附带评估,结论:必须留盘):
快照 config.json 引用原任务盘路径(state_dir/<vm>/
disk.raw),stop_vm 默认会删盘——修前 hibernate 的
stop 直接删掉了自己快照的输入,restore 时盘已不在、
必失败。所以休眠的 stop 用 keep_disk=True 留盘;
留下的盘由 gc_stale_disks 的快照登记保护(有登记
快照的 VM 不 GC 盘),快照被剪干净后盘才成孤儿可删。
restore 成功后 VM 再走正常 stop,盘照常删,不留
长期残留。
feat-chv-v3-repair(V-7/D2):keep_disk=True 同样留
seed.iso——config.json 的 disks 同时引用 disk.raw 与
seed.iso(readonly),只留盘不留 seed.iso 时 restore
仍在服务端失败(Cannot open disk path …/seed.iso),
预检 snapshot_disk_missing 先拒掉不是过严;有登记
快照的 VM 的 seed.iso 也不被 cleanup_stale_secrets
当孤儿清掉(见 _clean_one_vm_secrets)。"""
self.pause_vm(task_or_name)
self.snapshot_vm(task_or_name, snapshot_url)
self.stop_vm(task_or_name, keep_disk=True)
# 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 _remove_vm_seed_files(self, name, keep_disk):
"""删 seed 目录 + 宿主私钥 + seed.iso(stop 的第一块
清理,见 _cleanup_vm_files):keep_disk=True 时 seed.iso
留——快照 config.json 的 disks 含 seed.iso(readonly),
删了 restore 必在服务端失败(Cannot open disk path
…/seed.iso / No such file or directory),预检
snapshot_disk_missing 不是过严(V-7/D2)。seed 目录
与宿主私钥仍删,config.json 不引用它们。"""
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"):
if keep_disk and _suffix == "seed.iso":
continue
_p = os.path.join(self.state_dir, name, _suffix)
if os.path.exists(_p):
os.remove(_p)
def _persistent_record_for_vm(self, name):
"""该 VM 是否被某租户的持久登记认领:读 tenant.json
得租户,再读该租户的持久登记、核对 vm 名一致。
返回登记 dict 或 None(未认领:冷路径 VM 或旧 VM,
stop/GC 按旧语义处理)。"""
tenant = read_vm_tenant(self.state_dir, name)
if not tenant:
return None
record = read_persistent_vm(self.state_dir, tenant)
if record is not None and record.get("vm") == name:
return record
return None
def _overlay_disk_protected(self, name) -> bool:
"""该 VM 的任务盘是否受保护(stop/GC 不删):
有持久登记认领,或有登记快照(hibernate 的 restore
输入)。不分模式(2026-11-07:copy 模式也要能跑持
久 VM;无登记/无快照的盘仍按旧语义删除)。
数据盘 data.raw 永远不受 GC/stop 删除(另有显式
回收入口),本方法不覆盖它。"""
if self._persistent_record_for_vm(name) is not None:
return True
return has_recorded_snapshots(self.state_dir, name)
def _cleanup_vm_task_disk(self, name, task, keep_disk) -> bool:
"""stop 的任务盘删除块(_cleanup_vm_files 的中段):
keep_disk 或受保护盘(持久登记认领/有登记快照)即
跳过;删掉返回 False(无失败),删失败记审计并返回
True(脏标记由调用方写)。保护不分模式(2026-11-07:
copy 模式也要能跑持久 VM;无登记/无快照的 copy 盘
仍按旧语义删除,只有被认领/被快照引用的盘才留)。"""
_protected = self._overlay_disk_protected(name)
try:
if not keep_disk and not _protected \
and 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:
self._emit("vm_disk_delete_failed", task,
"vm=%s error=%s" % (name, _disk_exc))
return True
return False
def _cleanup_vm_files(self, name, task, keep_disk=False):
"""stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。
任一块失败记对应审计并返回 True(脏标记由调用方写);
seed/私钥与磁盘删除的审计名与文案逐字不变。
keep_disk=True 时跳过任务盘删除,并跳过 seed.iso
删除(hibernate 专用:快照 config.json 引用原盘路径
与 seed.iso,任一删了 restore 即失败——见
hibernate_vm 的评估注释与 V-7/D2;seed 目录与宿主
私钥仍删,config.json 不引用它们,私钥明文已嵌进
seed.iso、宿主侧不留)。
feat-chv-opt-landing:overlay 模式下,任务盘是持久
用户盘——该 VM 有持久登记认领或有登记快照时,stop
不删 overlay(删了等于销毁用户 VM / 毁掉快照输入);
删盘只走显式回收(recycle_persistent_vm)。无认领
又无快照的 overlay VM(冷路径残留)仍按旧语义删。"""
from cloud.chv.hostkeys import dirty_marker_path # noqa: F401
failed = False
try:
self._remove_vm_seed_files(name, keep_disk)
except Exception as _cleanup_exc:
failed = True
self._emit("vm_key_cleanup_failed", task,
"vm=%s error=%s" % (name, _cleanup_exc))
# feat-chv-opt-landing:overlay 模式下受保护的盘(持久
# 登记认领或有登记快照)stop 不删;copy 模式一律旧语义。
# 见 _cleanup_vm_task_disk。
if self._cleanup_vm_task_disk(name, task, keep_disk):
failed = True
try:
_net_path = os.path.join(self.state_dir, name, "net.json")
if os.path.exists(_net_path):
os.remove(_net_path)
except Exception as _net_exc:
# feat-chv-closeup(P2-17):net.json 删失败
# 此前只记 failed=True、无独立审计——删不掉的
# 登记会让后来的 wake 误判该 tap/mac/ip 仍被
# 占用(误拒)或与脏标记混在一起查不出;现发
# 独立审计 vm_net_registry_delete_failed。
failed = True
self._emit("vm_net_registry_delete_failed", task,
"vm=%s error=%s" % (name, _net_exc))
self._clear_guest_env_delivered(name)
return failed
def _write_dirty_marker(self, name, failed):
"""M2 脏标记:清理失败写标记、成功清标记(P2-17 收尾)。
返回 True=标记状态已如愿落盘(失败时标记已写、
成功时标记已清/本来就没有),False=写/删标记
本身失败。失败时发独立审计 dirty_marker_write_failed
+ error 日志,不再全静默——标记写不下去时拒起链
本身就不存在(provision/wake 读不到标记),部署
必须保证 state_dir 可写(preflight 的 0700 门)
并靠审计告警发现;stop 路径仍不抛错(stop_vm
“尽力而为、不抛异常”的契约不变,False 只作
调用方可观测的信号)。写路径走 atomic_file
(P2-18):随机 tmp 名 + 双 fsync。
"""
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)
atomic_write_text(_marker, "cleanup failed\n")
elif os.path.exists(_marker):
os.remove(_marker)
return True
except Exception as exc:
_LOGGER.error("chv_dirty_marker_write_failed vm=%s "
"failed=%s error=%s", name, failed, exc)
self._emit("dirty_marker_write_failed", None,
"vm=%s 脏标记写失败(failed=%s):%s"
% (name, failed, exc),
"dirty_marker_write_failed")
return False
def stop_vm(self, task_or_name: dict | str,
keep_disk: bool = False) -> bool:
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
已关 VM 仍尽力调一次 ch-remote(返回 False),socket
残留删 socket(返回 True)。ch-remote shutdown 失败
不炸,但命令本身仍会发出(测试据此断言关闭路径)。
feat-chv-v3(F2):新实例的 _vms 里无登记时,旧 VMM
也要按 socket 进程扫描清掉(_kill_vmm_by_socket),
否则 socket/磁盘已删、VMM 空壳进程仍活着占 tap。
keep_disk=True 时不删任务盘与 seed.iso(hibernate 专用,
见 hibernate_vm:快照 config.json 的 disks 同时引用
两者)。"""
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)
self._kill_vmm_by_socket(socket_path)
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,
keep_disk=keep_disk)
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 与
disk.qcow2,VM 非 live(无 api-socket 文件且无登记
活进程)即删盘并审计;删失败审计 vm_disk_delete_failed
并计入 failed。返回 {deleted, failed}。
feat-chv-v3:有登记快照的 VM 不 GC 盘——那是休眠
VM 的盘,restore 还要读它(见 hibernate_vm)。
feat-chv-opt-landing:有持久登记认领的 VM 同样不
GC(那是用户的持久盘);数据盘 data.raw 永不进
GC(用户数据,只走显式回收 recycle_persistent_vm)。"""
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
if not task_disk_paths(self.state_dir, entry):
continue
if has_recorded_snapshots(self.state_dir, entry):
continue
if self._persistent_record_for_vm(entry) is not None:
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 目录同理删,但有登记快照的 VM 除外
(feat-chv-v3-repair,V-7/D2:那是休眠 VM 的 restore
输入,seed.iso 被快照 config.json 的 disks 引用,与
gc_stale_disks 的盘保护同款逻辑;私钥仍删,restore
不读宿主私钥文件)。返回 (删私钥, 删 seed)。"""
removed_priv = 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:
pass
removed_seed = False
if not self._is_live_vm(entry) \
and not has_recorded_snapshots(self.state_dir, 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:
pass
return removed_priv, removed_seed
def cleanup_stale_secrets(self) -> list:
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;有登记快照的 VM 的 seed 不删(V-7/D2,
休眠 VM 的 restore 输入,见 _clean_one_vm_secrets);
返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
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 = \
self._clean_one_vm_secrets(entry, vm_dir)
if 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 一律跳过。"""
cleaned = []
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)
return cleaned
def _purge_dead_registry(self):
"""登记进程已死但 socket 已被删的登记项顺手清掉
(没有 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)
def cleanup_stale_vms(self) -> list:
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
socket 扫描与登记清理。只清 socket 残留与已死进程
对应的 VM;state_dir 不存在返回 [],绝不抛异常。"""
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, vm_name=None, **kwargs):
"""SSH 调客内 host.py,返回 Local/Docker 同款结果 dict
(executor 字段改写为 cloud-hypervisor,并附 vm 名与
api-socket)。runner 异常即原样传播,由 worker 侧统一
写回 failed——本类不在这里把异常吞成假成功。
extra_env(P1-1):经 SSH env 前缀进客机环境。
feat-chv-opt-landing:vm_name 给出时 SSH 目标是该
VM(持久 VM 路径:任务 id ≠ VM 名,host 任务名仍按
任务 id 派生);不给时目标即 vm_name(task),与旧
行为一致。"""
self._validate()
vm_task = {"id": vm_name} if vm_name else task
ssh_cmd = self.build_ssh_command(
vm_task, self._guest_host_command(*inner_args, **kwargs),
extra_env=extra_env)
result = self._call_ssh_with_transient_retry(
task, 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(vm_task)
parsed["api_socket"] = self.api_socket(vm_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,
vm_name=None, **kwargs):
self._validate()
vm_task = {"id": vm_name} if vm_name else task
ssh_cmd = self.build_ssh_command(
vm_task, self._guest_host_command(*inner_args, **kwargs),
extra_env=extra_env)
return self._goal_result(
task["id"],
self._call_ssh_with_transient_retry(
task, 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 启动。唯一例外:首条任务 SSH
的连接建立阶段瞬时 reset 由 _run_guest 经
_call_ssh_with_transient_retry 重试 1 次(传输抖动,非任务重试)。
中途死亡分类(本轨新增):客内命令抛传输异常、或
非 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)