堆叠于 #246(feat-chv-secfix),只到 PR OPEN,未合并、未部署。 安全语义不变:egress、key seed 投递、磁盘删除、默认 provisioned、 tap 校验一律保留;前端零改动。 - WarmPool 定案为实验性(ADR-5):dispatch 永远冷启动, wired_to_dispatch 恒 False,acquire/resume 只保留实验接口, worker 仍调 reconcile_orphans 清理残留。 - 长函数提取:preflight 126→31、wake_vm 135→36、__init__ 69→31、 build_chv_config 159→58;错误文案逐字不变。 - 严格测试替身:fake API 校验 VmConfig(任务盘+seed 盘、net 无重复),fake SSH 校验 known_hosts 钉死;新增 5 项测试。 - Docker 同步守卫:8 个真实标记用例下 Docker 内联版与正典 parse_goal_result 状态全等,改分叉即 fail。 - 配置统一校验:preflight 以 config_invalid 统一兜底端口/配额/ 超时/快照/host-key 模式(构造时已校验正常值,preflight 兜底 事后改写);CHV_NET 与 CHV_NET_JSON 双设仍警告不硬拒。 - 类型与 lint:chv 叶子模块公开 API 补类型注解;stdlib 扫描 揪出 executor 内 1 个真死 import(host_key_paths)并删除。 - ADR:docs/chv-architecture.md(ADR-1 至 ADR-7)。 测试:CHV 163 项全过;cloud 全量 1593 项三跑结果为 8 errors (首跑详细日志因 tail 截断丢失)、全过、1 失败(浏览器代理 时序测试,与 CHV 无关),全量本身存在时序偶发,CHV 套件 确定性全过。
2070 lines
102 KiB
Python
2070 lines
102 KiB
Python
"""Cloud Hypervisor 执行器(从 cloud/sandbox.py 巨类拆出)。
|
||
|
||
模块分工(cloud/chv/):
|
||
- errors.py:ChvFailure 分类失败
|
||
- config.py:配置解析(参数/env/默认值)与内存字符串解析
|
||
- api_client.py:Unix socket 上的 VMM REST API 传输与归一化
|
||
- ssh_transport.py:SSH 命令构造、每 VM known_hosts 钉死、
|
||
ssh-keyscan 首次捕获(TOFU 边界见该模块)
|
||
- disk.py:每任务磁盘供给与磁盘配额门
|
||
- pool.py:WarmPool 预热池 + registry 持久化与孤儿清理
|
||
- executor.py(本模块):执行器类本体——生命周期方法
|
||
(wake/pause/resume/snapshot/restore/hibernate/stop/
|
||
cleanup)与任务执行方法(dispatch/run/goal/execute)。
|
||
类体仍较长是故意的:生命周期与执行入口共享同一份
|
||
_vms 登记与审计状态,再按方法拆 mixin 只会把状态机
|
||
藏进多文件继承链;真正的可拆部分(配置/传输/磁盘/
|
||
池)已经拆出去了。
|
||
|
||
循环导入说明(必读):cloud/sandbox.py 顶层 re-export
|
||
本模块(保证 `from cloud.sandbox import
|
||
CloudHypervisorExecutor` 旧路径可用),所以本模块顶层
|
||
绝不能 import cloud.sandbox;凡是原来 sandbox.py 里的
|
||
共享 helper(goal 解析、host 命令选项、runner 超时、
|
||
_parse_host_result),一律在方法内按需 import——调用时
|
||
sandbox 早已加载完成,不会撞上半初始化的模块。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
|
||
from cloud.chv.api_client import _unix_socket_api
|
||
from cloud.chv.api_client import normalize_api_result
|
||
from cloud.executor_base import Executor
|
||
from cloud.chv.config import _parse_memory_bytes
|
||
from cloud.chv.config import build_chv_config
|
||
from cloud.chv.disk import check_disk_quota
|
||
from cloud.chv.disk import check_snapshot_limits
|
||
from cloud.chv.disk import delete_task_disk
|
||
from cloud.chv.disk import provision_task_disk
|
||
from cloud.chv.disk import record_snapshot
|
||
from cloud.chv.disk import task_disk_path
|
||
from cloud.chv.errors import ChvFailure
|
||
from cloud.chv.hardening import ensure_secure_state_dir
|
||
from cloud.chv.hardening import parse_net_entries
|
||
from cloud.chv.hardening import socket_is_live
|
||
from cloud.chv.hardening import validate_egress_proxy
|
||
from cloud.chv.hardening import validate_net_entries
|
||
from cloud.chv.hardening import 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
|
||
from cloud.chv.ssh_transport import split_secret_env
|
||
from cloud.chv.ssh_transport import write_known_hosts
|
||
|
||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||
|
||
|
||
def _default_popen_launcher(cmd):
|
||
"""默认 VMM 启动器:独立会话子进程,输出丢弃(纯移动)。"""
|
||
return subprocess.Popen(
|
||
cmd, stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||
|
||
|
||
class CloudHypervisorExecutor(Executor):
|
||
"""Cloud Hypervisor 执行器 v1(命名:cloud-hypervisor,绝不用
|
||
cloud/ch 缩写——cloud 已是通用 HTTP runner 占位,撞名会让
|
||
认领过滤与白名单全部错乱)。
|
||
|
||
调度层选择,不是任务内工具(补充口径,必读):
|
||
- 任务内的 LLM 绝不能发现、调用本执行器:执行器是在任务
|
||
创建前由 server 白名单(POST /tasks 的 executor 字段)、
|
||
环境默认值(CLOUD_DEFAULT_EXECUTOR)或 worker CLI
|
||
--executor 认领决定的。任务内 agent 若能自选沙箱,等
|
||
于能从隔离强的执行器降级逃逸到隔离弱的执行器。
|
||
- 本模块不提供任何 MCP/工具/skill 给任务内 agent;需要可
|
||
发现的是创建任务那一层:POST /tasks 的 executor 可选值、
|
||
cloud/README.md 说明、控制台选项(只改文档与白名单,
|
||
不新增任务内调用入口)。
|
||
|
||
客内通道:只选 SSH,不做 vsock 第二套。
|
||
- 选 SSH 的理由:客内 host.py 的调用就是一条普通命令,
|
||
SSH 直接复用 OpenSSH 的密钥认证与端口转发,云镜像自带
|
||
sshd,无需在客内再写一个 vsock agent、也无需在宿主再写
|
||
一个 vsock 代理去翻译 host.py 协议。
|
||
- 放弃 vsock 的代价:需要运维提供从宿主到客机的 SSH 通路
|
||
(tap/桥接 + 端口转发,见下文“VM 生命周期”)。
|
||
- Host key 钉死(本轨 #216 后的加固,默认行为变化):
|
||
不再用 StrictHostKeyChecking=no + UserKnownHostsFile=
|
||
/dev/null。每 VM 在 state_dir/<vm>/known_hosts 有独立
|
||
文件;首次启动时 wait_guest_ready 经 ssh-keyscan 在本
|
||
机回环端口上捕获当时呈现的 host key 写入该文件(首次
|
||
信任 TOFU:只防首次之后的替换,不验证首次本身,边界
|
||
见 cloud/chv/ssh_transport.py),此后同一 VM 的每次
|
||
SSH 都用 StrictHostKeyChecking=yes + 该文件校验,换
|
||
key 直接拒绝。客机用户与端口是显式配置:CHV_SSH_USER
|
||
(默认 root,显式空字符串直接 ValueError,绝不静默
|
||
fallback 回 root)、CHV_SSH_PORT(默认 2222,必须在
|
||
1-65535 内);非 root 用户要求客机镜像里该用户已配
|
||
sshd 与 authorized_keys(运维责任)。
|
||
- 密钥红线:SSH 只传 -i 密钥文件路径,绝不把任何 key 值、
|
||
模型 key 值放进 argv、日志或返回结果;客内 host.py 的
|
||
模型 key 由客机镜像/cloud-init 预置(运维责任),v1 不
|
||
在 SSH 命令里注入任务级 key。feat-chv-secfix 起网关
|
||
密钥改走 seed 盘客内文件投递(见 wake_vm 与
|
||
hostkeys.write_seed),keyscan 模式下密钥投递
|
||
fail-closed;非密钥 extra_env 仍走 env 前缀。
|
||
|
||
VM 唤醒流程(写进代码,不是只留文档;API 模式为准):
|
||
- 主唤醒路径是 API 模式,不是“一条直接启动命令就完事”:
|
||
wake_vm() 先 preflight,再经 launcher 以 --api-socket
|
||
只启动 VMM 进程(CLI 上不带内核/磁盘配置),等
|
||
vmm.ping 通,再 PUT vm.create(VmConfig)+ PUT vm.boot。
|
||
_build_vm_command() 保留的直接启动命令只是文档里的手动
|
||
等价写法(运维手工起 VM 时用),执行器代码里不走它。
|
||
- 就绪判定只选 SSH,不做 vsock 第二套:boot 后轮询 SSH
|
||
“true”命令,直到 CHV_READY_TIMEOUT(默认 120 秒,
|
||
轮询间隔 CHV_READY_INTERVAL,默认 5 秒)内成功才跑
|
||
host.py/任务;超时则关 VM 并报失败,绝不在未就绪时
|
||
就派任务。vsock 放弃理由同客内通道一节(需客内 agent
|
||
+ 宿主代理两套新代码,v1 不拼半吊子)。
|
||
- 暂停与快照唤醒是两条独立代码分支:
|
||
pause_vm()/resume_vm() 走 ch-remote pause/resume;
|
||
restore_vm() 先 ch-remote restore(source_url=...),
|
||
再显式 resume_vm()——恢复后的 VM 默认是 paused,不
|
||
resume 就不算醒,绝不能只 restore 就返回成功。
|
||
- 关闭与清理:任务结束/失败/取消时走 shutdown_vm()/
|
||
stop_vm():ch-remote shutdown + 终止登记进程 + 删
|
||
socket。dispatch/run/goal 均走“wake → 跑任务 →
|
||
finally shutdown”的每任务 VM 生命周期;execute() 是
|
||
只读 status,仍走 SSH 直连(要求 VM 已醒,调用方先
|
||
wake_vm),不为一次 status 就起一台 VM。本轨把这条
|
||
从“文档约定”收成“代码门禁”:execute() 先查 VM 是否
|
||
在运行(本实例登记了活进程,或 api-socket 文件存在
|
||
视为带外已醒的 VM),未运行直接报 ChvFailure
|
||
(kind=vm_not_running),绝不向一个不存在的端口发
|
||
SSH 再拿连接失败冒充任务状态(execute 语义二选一
|
||
的结论:选“只读已运行 VM + 未运行报分类失败”,
|
||
不选“execute 先唤醒”——唤醒是 dispatch/run/goal
|
||
的职责,status 不应有起 VM 的副作用)。
|
||
- 清理要能处理三种情况:已死进程(poll() 非 None)、
|
||
已关 VM(无登记进程、无 socket,stop 返回 False)、
|
||
socket 残留(有 socket、无登记进程,照常删 socket 并
|
||
计入清理)。cleanup_stale_vms() 只清 chv- 前缀的
|
||
socket,绝不按前缀全删其它执行器的东西。
|
||
|
||
生产级口径(对齐公开沙箱保障口径,不声称复刻某家内部架构):
|
||
- 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM;
|
||
每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访;
|
||
VM 内 root 只能影响本 VM。
|
||
- 快速启动:WarmPool(cloud/chv/pool.py)预热干净快照 VM,
|
||
acquire 消耗、release 销毁,池空回落冷启动,绝不降级;
|
||
池状态另落 state_dir/warm-pool.json registry,worker
|
||
重启后载入的条目一律当孤儿、只能 reconcile_orphans()
|
||
清理,绝不重新发给任务(见 pool.py)。
|
||
- 长任务中途死亡:_run_with_vm 在客内命令抛传输异常、
|
||
或非 goal 结果已 failed 且 VM 登记进程已死时,改判
|
||
interrupted(断点可续跑)而不是笼统 failed;VM 还活
|
||
着时的异常/失败原样处理,不误判。中途心跳未做:评估
|
||
结论是 host.py 单次 SSH 长回合没有客内进度信号可采,
|
||
另起一路 SSH 轮询只能证明“VM 活着”不能证明“任务
|
||
在进展”,徒增连接 churn,不做(见 PR 描述)。
|
||
- 与 #215 收敛模块的关系:#215 未合并,base 里没有
|
||
timeouts/gates/quota/sandbox_policy 模块,本轨不复制
|
||
它们;#215 合并后应再复用的点清单见 cloud/chv/
|
||
config.py 头注释与 PR 描述。
|
||
- 资源与成本:CPU/内存/磁盘配额门、宿主容量门(CHV_MAX_VMS)、
|
||
SSH 超时、goal 100 轮上限、执行器内部不重试;成本与配额
|
||
复用 worker 现有 quota 语义,不另起计费。
|
||
- 网络与治理:出口默认 fail-closed(必须过 CHV_EGRESS_PROXY
|
||
白名单代理);审批仍走任务 approval 与 server merge_gate,
|
||
VM 内 agent 不能自批。
|
||
- 可观测:VM 生命周期事件经 audit_callback 发出(worker 接线
|
||
后转 AuthStore 审计),失败带 ChvFailure.kind 分类;断点
|
||
与 task_checkpoints/task_attempts 语义对齐(recycle 保留
|
||
两者,丢失临时文件)。
|
||
- 默认关闭:server 默认仍 docker、worker 默认仍 local,CHV
|
||
只在显式选择且有 CHV worker 时才跑;无 CHV_* 配置时
|
||
preflight 直接失败。完整清单见
|
||
docs/chv-production-readiness.md。
|
||
- worker 层现状:通用路径已能走 dispatch_task/goal_task;
|
||
断点续跑(goal-resume-breakpoint)已由补缺口轨接线:
|
||
worker 通用分支对任何提供该方法的执行器调用它,
|
||
Local 仍走原路径;网关任务仍被 worker 侧 loud fail
|
||
守卫拦住,不放宽网关规则。
|
||
- 网关现状:worker 通用路径在 sub2api 网关下对非 Local
|
||
执行器直接 loud fail(与 CloudExecutor 同款),v1 不
|
||
改这条门,所以 CHV + sub2api 组合目前在 worker 层就
|
||
明确失败,不宣称生产可用。
|
||
|
||
配置(只走构造参数或环境变量,不写文件;参数优先):
|
||
- CHV_BINARY:cloud-hypervisor 二进制(默认同名 PATH 查找)
|
||
- CHV_KERNEL:客机内核路径(必填,文件必须存在)
|
||
- CHV_ROOTFS:客机根文件系统镜像路径(必填,文件必须
|
||
存在;CHV_IMAGE 是同义别名,仅当 CHV_ROOTFS 未设时用)
|
||
- CHV_CPU:vCPU 数(默认 2,命令里写 boot=N)
|
||
- CHV_MEM:内存(默认 2G,命令里写 size=2G)
|
||
- CHV_STATE_DIR:api-socket 目录(默认
|
||
~/.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 的裸金属或
|
||
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
|
||
"""
|
||
|
||
def __init__(self, binary=None, kernel=None, rootfs=None,
|
||
image=None, cpus=None, memory=None, state_dir=None,
|
||
runner=None, launcher=None, ssh_key=None,
|
||
ssh_user=None, ssh_port=None,
|
||
guest_host_py="/app/host.py", cmdline=None,
|
||
net=None, net_json=None, ssh_host=None,
|
||
kvm_path="/dev/kvm",
|
||
cpuinfo_path="/proc/cpuinfo", env=None,
|
||
model=None, ch_remote=None, api_client=None,
|
||
sleeper=None, api_timeout=None, ready_timeout=None,
|
||
ready_interval=None, host_key_mode=None):
|
||
self._env = env if env is not None else os.environ
|
||
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
|
||
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
|
||
# 见 config.py 头注释)。字段名与原属性一一对应,旧
|
||
# 测试读属性的方式全部不变。
|
||
_cfg = build_chv_config({
|
||
"binary": binary, "kernel": kernel, "rootfs": rootfs,
|
||
"image": image, "cpus": cpus, "memory": memory,
|
||
"state_dir": state_dir, "ssh_key": ssh_key,
|
||
"ssh_user": ssh_user, "ssh_port": ssh_port,
|
||
"guest_host_py": guest_host_py, "cmdline": cmdline,
|
||
"net": net, "net_json": net_json, "ssh_host": ssh_host,
|
||
"kvm_path": kvm_path,
|
||
"cpuinfo_path": cpuinfo_path, "ch_remote": ch_remote,
|
||
"model": model, "api_timeout": api_timeout,
|
||
"ready_timeout": ready_timeout,
|
||
"ready_interval": ready_interval,
|
||
"host_key_mode": host_key_mode}, self._env)
|
||
self._copy_config_fields(_cfg)
|
||
self._init_runtime(runner, launcher, api_client, sleeper)
|
||
|
||
def _init_runtime(self, runner, launcher, api_client, sleeper) -> None:
|
||
"""运行时注入:seed 投递集合、API 客户端、睡眠器、
|
||
runner/launcher 默认值(纯移动)。"""
|
||
# feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM
|
||
# 名 → 已投递密钥名集合。_run_guest 只对集合内的密钥
|
||
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
|
||
# “wake 没投递、执行时却按名 source 一个旧文件”)。
|
||
self._guest_env_delivered = {}
|
||
self.api_client = api_client if api_client is not None \
|
||
else _unix_socket_api
|
||
self.sleeper = sleeper if sleeper is not None else time.sleep
|
||
self.audit_callback = None
|
||
self._vms_lock = threading.Lock()
|
||
if runner is not None:
|
||
self.runner = runner
|
||
else:
|
||
# 方法内 import:sandbox 顶层 re-export 本模块,
|
||
# 顶层 import 会循环;调用时 sandbox 早已加载完成。
|
||
from cloud.sandbox import _default_runner
|
||
self.runner = _default_runner
|
||
if launcher is not None:
|
||
self.launcher = launcher
|
||
else:
|
||
self.launcher = _default_popen_launcher
|
||
self._vms = {}
|
||
|
||
def _copy_config_fields(self, _cfg) -> None:
|
||
"""把 build_chv_config 的字段抄到 self(纯移动,字段集不变)。"""
|
||
for _field in (
|
||
"binary", "kernel", "rootfs", "cpus", "memory",
|
||
"state_dir", "ssh_key", "ssh_user", "ssh_port",
|
||
"guest_host_py", "cmdline", "net", "net_json",
|
||
"kvm_path", "cpuinfo_path", "ch_remote", "model",
|
||
"max_vms", "disk_mode", "disk_gb", "egress_proxy",
|
||
"allow_direct_egress", "task_timeout", "snapshot_url",
|
||
"warm_pool_size", "api_timeout", "ready_timeout",
|
||
"ready_interval", "host_key_mode", "ssh_host",
|
||
"ssh_forward", "reuse_experiment",
|
||
"single_tenant_project", "snapshot_max_count",
|
||
"snapshot_max_total_gb", "snapshot_retention_days",
|
||
"snapshot_dir", "allow_remote_snapshot"):
|
||
setattr(self, _field, getattr(_cfg, _field))
|
||
|
||
# -- 名字 / 路径 ---------------------------------------------------
|
||
def _host_name(self, task):
|
||
return re.sub(r"[^A-Za-z0-9_.-]", "-",
|
||
str(task.get("id")))
|
||
|
||
def vm_name(self, task_or_name):
|
||
"""VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款
|
||
派生,同一任务每次算出同一名字,清理才能按名找)。
|
||
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
|
||
扫描 socket 文件名时传的就是 chv- 名,二次加前缀会
|
||
把 chv-dead 算成 chv-chv-dead、永远清不掉)。"""
|
||
raw = task_or_name.get("id") if isinstance(task_or_name, dict) \
|
||
else str(task_or_name)
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
|
||
if safe.startswith("chv-"):
|
||
return safe
|
||
return "chv-" + safe
|
||
|
||
def api_socket(self, task_or_name):
|
||
"""该 VM 的 api-socket 路径:state_dir/<vm 名>.sock。"""
|
||
return os.path.join(self.state_dir,
|
||
self.vm_name(task_or_name) + ".sock")
|
||
|
||
# -- 前置校验 ------------------------------------------------------
|
||
@staticmethod
|
||
def _binary_ok(path):
|
||
if "/" in path:
|
||
return os.path.isfile(path) and os.access(path, os.X_OK)
|
||
return shutil.which(path) is not None
|
||
|
||
def _preflight_config_checks(self, missing: 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):
|
||
"""预检(缺一不可则 fail-fast):收集全部缺失项一次报出
|
||
具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind,
|
||
供审计与失败分类用),绝不回落 Docker/Local。
|
||
检查项与 kind:
|
||
- binary_missing:cloud-hypervisor 二进制不存在/不可执行
|
||
- binary_missing:ch-remote 二进制不存在/不可执行
|
||
- kvm_missing:/dev/kvm 不存在
|
||
- kvm_missing:/dev/kvm 不可读/不可写(分开报)
|
||
- cpu_flag_missing:/proc/cpuinfo 无 vmx/svm 标志
|
||
- kernel_missing:CHV_KERNEL 未设/文件不存在/不可读
|
||
- image_missing:CHV_ROOTFS 未设/文件不存在/不可读
|
||
- ssh_key_missing:CHV_SSH_KEY 未设/文件不存在/不可读
|
||
- egress_not_configured:CHV_EGRESS_PROXY 未设且未显式
|
||
允许直连(默认 fail-closed,VM 出口必须过白名单代理)
|
||
"""
|
||
missing = [] # (kind, 文本) 列表,全部收集完再一次抛
|
||
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 层的既定语义)。审计 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:
|
||
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
|
||
event, kind, 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):
|
||
"""该 VM 的 known_hosts 文件:state_dir/<vm>/known_hosts
|
||
(与每任务磁盘同目录,见 ssh_transport.py 的钉死模型)。"""
|
||
return _known_hosts_path(self.state_dir,
|
||
self.vm_name(task_or_name))
|
||
|
||
def _net_guest_ip(self):
|
||
"""VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。"""
|
||
if not self.net_json:
|
||
return None
|
||
try:
|
||
net = json.loads(self.net_json)
|
||
except ValueError:
|
||
return None
|
||
entries = net if isinstance(net, list) else [net]
|
||
for entry in entries:
|
||
if isinstance(entry, dict) and entry.get("ip"):
|
||
return str(entry["ip"])
|
||
return None
|
||
|
||
def resolve_ssh_host(self):
|
||
"""SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP):
|
||
|
||
显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下
|
||
的回环;三者皆无时报 ChvFailure(ssh_target_undeclared),
|
||
绝不无条件假设 127.0.0.1:2222 能通。"""
|
||
if getattr(self, "ssh_host", None):
|
||
return self.ssh_host
|
||
guest_ip = self._net_guest_ip()
|
||
if guest_ip:
|
||
return guest_ip
|
||
if getattr(self, "ssh_forward", False):
|
||
return "127.0.0.1"
|
||
raise ChvFailure(
|
||
"ssh_target_undeclared",
|
||
"SSH 目标未声明:须设 CHV_SSH_HOST、或在 CHV_NET_JSON "
|
||
"里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 "
|
||
"CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口")
|
||
|
||
def provision_host_key(self, task, secret_env=None):
|
||
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
|
||
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
|
||
立即写进该 VM 的 known_hosts(钉死),私钥只写进
|
||
per-VM seed 目录的 cloud-init user-data,随 VM 销毁
|
||
而删。返回 {paths, seed_dir, known_hosts}。keygen
|
||
失败即 raise,provisioned 模式下绝不回落 keyscan;
|
||
不同 VM 的 key 对不同(state_dir/<vm> 隔离),同一
|
||
VM 重启复用已有对(否则钉死立即失效)。
|
||
feat-chv-secfix(H2/M2):secret_env 给定时写进
|
||
seed 的客内密钥文件(0600);上次清理失败留下的
|
||
脏标记存在时直接拒绝(清理失败即拒起,不再复用
|
||
旧 key 对)。成功后审计 host_key_generated(只含
|
||
VM 名与密钥名,不含任何 key 值)。"""
|
||
from cloud.chv.hostkeys import (
|
||
dirty_marker_path, generate_host_keypair,
|
||
known_hosts_line, write_seed)
|
||
from cloud.chv.ssh_transport import write_known_hosts
|
||
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))
|
||
with open(paths["public"], "r") as fh:
|
||
public_text = fh.read()
|
||
with open(paths["private"], "r") as fh:
|
||
private_text = fh.read()
|
||
# feat-chv-fix:known_hosts 行的主机部分跟 SSH 目标走
|
||
# (客机 IP 时不再写死回环,否则钉死校验永远对不上)。
|
||
try:
|
||
ssh_host = self.resolve_ssh_host()
|
||
except ChvFailure:
|
||
ssh_host = "127.0.0.1"
|
||
write_known_hosts(
|
||
paths["known_hosts"],
|
||
[known_hosts_line(self.ssh_port, public_text,
|
||
host=ssh_host)])
|
||
seed_dir = write_seed(paths["seed_dir"], vm,
|
||
private_text, public_text,
|
||
secret_env=secret_env)
|
||
self._emit("host_key_generated", task,
|
||
"vm=%s secret_names=%s"
|
||
% (vm, ",".join(sorted(secret_env or {}))))
|
||
return {"paths": paths, "seed_dir": seed_dir,
|
||
"known_hosts": paths["known_hosts"]}
|
||
|
||
def capture_host_key(self, task):
|
||
"""首次捕获客机 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, inner_cmd, extra_env=None):
|
||
"""构造 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._guest_env_delivered.get(
|
||
self.vm_name(task), set())
|
||
missing = [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 build_vm_config(self, task_disk=None, seed_disk=None):
|
||
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
|
||
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
|
||
kernel{path}、cmdline{args}、disks[{path: 每任务磁盘}]、
|
||
rng{/dev/urandom},以及 CHV_NET_JSON 给出的 net 列表
|
||
(不给则不带 net——客机无网卡时 SSH 就绪会超时失败,
|
||
这是 fail-fast 前置条件,见类 docstring)。
|
||
task_disk 不给时用基础镜像路径(仅 lab/reuse 模式)。"""
|
||
try:
|
||
cpus = int(self.cpus)
|
||
except ValueError:
|
||
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
|
||
# feat-chv-secfix(L2):磁盘路径白名单。显式给的
|
||
# task_disk 若不是基础镜像本身(reuse 模式的合法
|
||
# 情形),须在 state_dir 内;seed 盘须在 state_dir
|
||
# 内——VmConfig 再也不能挂进宿主任意文件。
|
||
if task_disk and task_disk != self.rootfs:
|
||
if not os.path.realpath(task_disk).startswith(
|
||
os.path.realpath(self.state_dir) + os.sep):
|
||
raise ChvFailure(
|
||
"disk_path_not_allowed",
|
||
"任务磁盘须在 state_dir 内:%s" % task_disk)
|
||
if seed_disk:
|
||
if not os.path.realpath(seed_disk).startswith(
|
||
os.path.realpath(self.state_dir) + os.sep):
|
||
raise ChvFailure(
|
||
"disk_path_not_allowed",
|
||
"seed 磁盘须在 state_dir 内:%s" % seed_disk)
|
||
config = {
|
||
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
|
||
"memory": {"size": _parse_memory_bytes(self.memory)},
|
||
"kernel": {"path": self.kernel},
|
||
"cmdline": {"args": self.cmdline},
|
||
"disks": [{"path": task_disk or self.rootfs}],
|
||
"rng": {"src": "/dev/urandom"},
|
||
}
|
||
# feat-chv-fix(P0-1):seed 磁盘是 VmConfig 第二块
|
||
# 只读盘(CIDATA 卷标 ISO,客机 cloud-init NoCloud
|
||
# 数据源),不是宿主目录——目录本身从不进 VmConfig。
|
||
if seed_disk:
|
||
config["disks"].append({"path": seed_disk,
|
||
"readonly": True})
|
||
if self.net_json:
|
||
try:
|
||
net = json.loads(self.net_json)
|
||
except ValueError:
|
||
raise ValueError("CHV_NET_JSON 不是合法 JSON")
|
||
config["net"] = net if isinstance(net, list) else [net]
|
||
return config
|
||
|
||
def _api_call(self, socket_path, method, path, payload=None):
|
||
"""API 调用归一化(实现在 cloud/chv/api_client.py)。"""
|
||
return normalize_api_result(
|
||
self.api_client(socket_path, method, path, payload))
|
||
|
||
def _call_runner(self, cmd, timeout=None):
|
||
"""runner 调用(共享 helper 在 cloud/sandbox.py):
|
||
timeout 给定时优先传 timeout,假 runner 只接受 cmd 时
|
||
TypeError 回退到只传 cmd(超时护栏丢了,但绝不因此静默
|
||
换执行器;worker 层 task_timeout 仍是外层硬护栏)。"""
|
||
from cloud.sandbox import _call_runner_with_timeout
|
||
return _call_runner_with_timeout(self.runner, cmd, timeout)
|
||
|
||
def _kill_proc(self, proc):
|
||
"""终止登记进程:terminate → 等 5 秒 → kill,全部
|
||
best-effort,绝不抛异常。"""
|
||
if proc is None:
|
||
return
|
||
try:
|
||
proc.terminate()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except Exception:
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
|
||
def _active_count(self):
|
||
with self._vms_lock:
|
||
return len(self._vms)
|
||
|
||
def _persistent_vm_count(self, exclude=None):
|
||
"""跨重启容量计数(feat-chv-fix,P1-4 + 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
|
||
口径:总量 + 本次 + 宿主余量,镜像不可读不静默跳过)。"""
|
||
check_disk_quota(self.rootfs, self.disk_gb,
|
||
state_dir=self.state_dir)
|
||
|
||
def _provision_task_disk(self, task):
|
||
"""每任务磁盘供给(实现在 cloud/chv/disk.py):
|
||
- copy(默认):把基础镜像复制到
|
||
state_dir/<vm>/disk.raw,VmConfig 用该路径;复制
|
||
慢但两个任务绝不写同一文件,测试用小文件可直接验证。
|
||
- reuse(仅单租户实验):直接返回基础镜像路径,磁盘
|
||
不独立;preflight 拦截(须显式实验二次确认并审计
|
||
放行,见 preflight,feat-chv-fix)。
|
||
返回每任务磁盘路径。"""
|
||
return provision_task_disk(
|
||
self.state_dir, self.vm_name(task), self.rootfs,
|
||
self.disk_mode)
|
||
|
||
def wait_api_ready(self, task):
|
||
"""等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整,
|
||
至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟
|
||
时墙钟循环会空转,次数循环在单测里是确定性的。
|
||
超时 raise ChvFailure(kind=api_timeout)。"""
|
||
socket_path = self.api_socket(task)
|
||
attempts = max(1, int(self.api_timeout))
|
||
for attempt in range(attempts):
|
||
try:
|
||
status, _ = self._api_call(
|
||
socket_path, "GET", "/vmm.ping")
|
||
if status == 200:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
if attempt + 1 < attempts:
|
||
self.sleeper(1)
|
||
raise ChvFailure(
|
||
"api_timeout",
|
||
"vmm.ping 超时:%d 次内 API 未通,socket=%s"
|
||
% (attempts, socket_path))
|
||
|
||
def wait_guest_ready(self, task):
|
||
"""等 guest SSH 就绪(只选 SSH,见类 docstring):
|
||
轮询 ssh … "true",returncode 0 即就绪;次数 =
|
||
ready_timeout/ready_interval 取整,至少 1 次。
|
||
每轮先尽力捕获 host key(known_hosts 文件不存在时;
|
||
sshd 未起时抓不到是常态,下轮再试),再发 ssh
|
||
"true"——钉死式 SSH 在文件缺失时会直接拒绝未知主机,
|
||
不先捕获就永远等不到就绪。超时时先关 VM 再 raise
|
||
ChvFailure(kind=guest_not_ready),绝不在未就绪时
|
||
派任务。"""
|
||
# feat-chv-fix(P0-2):provisioned 模式下缺 known_hosts
|
||
# 时直接 fail-fast,绝不调用 capture_host_key 回落 TOFU。
|
||
# (keyscan 模式才走下面的捕获分支。)
|
||
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
||
and not os.path.exists(self.known_hosts_path(task)):
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"host_key_not_pinned",
|
||
"provisioned 模式下 known_hosts 缺失,拒绝 keyscan "
|
||
"回落:VM %s 未钉死 host key" % self.vm_name(task))
|
||
attempts = max(1, int(self.ready_timeout / self.ready_interval))
|
||
for attempt in range(attempts):
|
||
try:
|
||
if not os.path.exists(self.known_hosts_path(task)):
|
||
self.capture_host_key(task)
|
||
result = self._call_runner(
|
||
self.build_ssh_command(task, ["true"]))
|
||
if getattr(result, "returncode", 1) == 0:
|
||
return True
|
||
# feat-chv-secfix(M5):host key 不匹配是独立
|
||
# 安全事件,不是“sshd 没起”。SSH stderr 含
|
||
# 钉死拒绝标记时立即审计 host_key_mismatch 并
|
||
# 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")
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"host_key_mismatch",
|
||
"SSH host key 不匹配(钉死拒绝):VM %s,"
|
||
"可能被替换或中间人,已关 VM"
|
||
% self.vm_name(task))
|
||
except ChvFailure:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
if attempt + 1 < attempts:
|
||
self.sleeper(self.ready_interval)
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"guest_not_ready",
|
||
"guest SSH 就绪超时:%d 次轮询内 sshd 未通,已关 VM"
|
||
% attempts)
|
||
|
||
def _guest_host_command(self, *args, model=None, effort=None,
|
||
approval=None, sandbox=None,
|
||
model_provider=None):
|
||
"""客内 host.py 命令:python3 /app/host.py [顶层全局参数]
|
||
[子命令...],顶层参数顺序与 Local/Docker 同款(必须在
|
||
子命令之前,共享 helper 在 cloud/sandbox.py)。
|
||
guest_host_py 默认 /app/host.py:客机镜像须把 host.py
|
||
放在该路径(与 Docker 的 /app:ro 同款约定,只是载体
|
||
从容器换成 VM 镜像)。"""
|
||
from cloud.sandbox import _host_command_options
|
||
cmd = ["python3", self.guest_host_py]
|
||
cmd += _host_command_options(
|
||
model=model, effective_model=self.model,
|
||
model_provider=model_provider, effort=effort,
|
||
approval=approval, sandbox=sandbox)
|
||
cmd += list(args)
|
||
return cmd
|
||
|
||
# -- 生命周期:API 唤醒 / 暂停恢复 / 快照 / 关闭 --------------------
|
||
def _launch_api_only(self, task):
|
||
"""只启动 VMM 进程(API 模式第一步):CLI 上只带
|
||
--api-socket,不带内核/磁盘配置;VM 配置随后走
|
||
vm.create 请求体。返回登记的进程。"""
|
||
socket_path = self.api_socket(task)
|
||
cmd = [self.binary, "--api-socket", socket_path]
|
||
proc = self.launcher(cmd)
|
||
with self._vms_lock:
|
||
self._vms[self.vm_name(task)] = proc
|
||
return proc
|
||
|
||
def _ch_remote(self, task_or_name, *args):
|
||
"""ch-remote 调用:[ch-remote --api-socket=<socket> args...]。
|
||
returncode 非 0 即 raise RuntimeError(fail-fast),
|
||
runner 本身抛异常时原样传播。返回 runner 结果。"""
|
||
name = self.vm_name(task_or_name)
|
||
socket_path = os.path.join(self.state_dir, name + ".sock")
|
||
result = self.runner([self.ch_remote,
|
||
"--api-socket=%s" % socket_path]
|
||
+ list(args))
|
||
if getattr(result, "returncode", 0) not in (0, None):
|
||
raise RuntimeError(
|
||
"ch-remote %s 失败:returncode=%s"
|
||
% (" ".join(args), getattr(result, "returncode")))
|
||
return result
|
||
|
||
def _registered_net_entries(self, exclude_vm=None):
|
||
"""已登记 VM 的 net 条目(H7):扫描 state_dir/*/net.json
|
||
(wake 成功后写,stop 时删)。文件损坏/缺失即跳过该
|
||
VM——登记缺失只会漏判冲突,不会误拒;漏判的兜底是
|
||
宿主防火墙部署清单里的“每 VM 独立 tap”检查项。"""
|
||
registered = []
|
||
try:
|
||
entries = os.listdir(self.state_dir)
|
||
except Exception:
|
||
return registered
|
||
for entry in entries:
|
||
if entry == exclude_vm:
|
||
continue
|
||
path = os.path.join(self.state_dir, entry, "net.json")
|
||
try:
|
||
with open(path, "r") as fh:
|
||
data = json.load(fh)
|
||
except Exception:
|
||
continue
|
||
if not isinstance(data, list):
|
||
continue
|
||
for item in data:
|
||
if isinstance(item, dict):
|
||
row = dict(item)
|
||
row["vm"] = entry
|
||
registered.append(row)
|
||
return registered
|
||
|
||
def _record_net_entries(self, vm, entries):
|
||
"""wake 成功后登记本 VM 的 net 条目(best-effort 写,
|
||
失败只记日志——登记缺失的后果是漏判冲突,见上文)。"""
|
||
try:
|
||
vm_dir = os.path.join(self.state_dir, vm)
|
||
os.makedirs(vm_dir, exist_ok=True)
|
||
tmp_path = os.path.join(vm_dir, "net.json.tmp")
|
||
with open(tmp_path, "w") as fh:
|
||
json.dump(entries, fh)
|
||
os.replace(tmp_path, os.path.join(vm_dir, "net.json"))
|
||
except OSError as exc:
|
||
_LOGGER.warning("chv_net_registry_write_failed vm=%s "
|
||
"error=%s", vm, exc)
|
||
|
||
def _check_api_socket_permissions(self, socket_path):
|
||
"""VMM api-socket 权限门(H6):socket 文件存在时,
|
||
组/其它可写(mode & 0o022)即 fail-fast——Unix socket
|
||
的 connect 需要写权限,可写即任何同组/其它用户都
|
||
能调 VMM API。仅禁写位,不禁读/执行位:VMM 默认
|
||
umask 下的 0755 类权限不误杀(VMM 默认值代码控制
|
||
不了,真机上的实际权限仍须按部署清单核对)。
|
||
socket 文件不存在时(mock/带外)跳过,不假装已验。
|
||
普通文件(mock 占位)也跳过——只对真正的 socket
|
||
文件校验权限。"""
|
||
if not os.path.exists(socket_path):
|
||
return
|
||
try:
|
||
import stat as _stat
|
||
st = os.stat(socket_path)
|
||
if not _stat.S_ISSOCK(st.st_mode):
|
||
return
|
||
mode = st.st_mode & 0o777
|
||
except OSError:
|
||
return
|
||
if mode & 0o022:
|
||
raise ChvFailure(
|
||
"socket_permissions_insecure",
|
||
"VMM api-socket 组/其它可写(%o),拒绝启动:%s"
|
||
% (mode, socket_path))
|
||
|
||
def _wake_prechecks(self, task: dict, name: str, extra_env) -> tuple:
|
||
"""wake 前置:preflight/容量/配额/net/密钥模式,纯移动。
|
||
失败时发 vm_failed 审计并原样抛 ChvFailure。"""
|
||
try:
|
||
self.preflight()
|
||
# feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。
|
||
# resolve 失败即 fail-fast(不先起 VMM 再超时);
|
||
# reuse 实验放行必须审计,留痕给运维复核。
|
||
self.resolve_ssh_host()
|
||
self._check_capacity(exclude=name)
|
||
self._check_disk_quota()
|
||
# feat-chv-secfix(H7):跨 VM tap/mac/ip 冲突在
|
||
# 起 VMM 前拒绝(preflight 只做了条目内校验)。
|
||
net_entries = []
|
||
if self.net_json:
|
||
net_entries = validate_net_entries(
|
||
parse_net_entries(self.net_json),
|
||
self._registered_net_entries(exclude_vm=name))
|
||
# feat-chv-secfix(H2):密钥子集的投递能力在起
|
||
# VM 前就定性——keyscan 模式无 seed 可投递,有
|
||
# 密钥即 fail-closed,不等到执行时才在 argv 前
|
||
# 被拦(那时 VM 已起,失败语义更脏)。
|
||
secrets, _plain = split_secret_env(extra_env)
|
||
if secrets and getattr(self, "host_key_mode",
|
||
"provisioned") != "provisioned":
|
||
raise ChvFailure(
|
||
"secret_env_requires_provisioned",
|
||
"密钥环境变量 %s 须走 seed 文件投递,仅 "
|
||
"provisioned 模式支持(当前 %s)"
|
||
% (",".join(sorted(secrets)),
|
||
getattr(self, "host_key_mode", "provisioned")))
|
||
except ChvFailure as exc:
|
||
self._emit("vm_failed", task,
|
||
"vm=%s wake 前置失败 kind=%s" % (name, exc.kind),
|
||
exc.kind)
|
||
raise
|
||
return secrets, net_entries
|
||
|
||
def _provision_seed_disk(self, task: dict, name: str, secrets) -> str:
|
||
"""provisioned 模式的 seed 盘供给:生成→制盘→挂盘,
|
||
任一步失败即抛(调用方 stop_vm 清理),纯移动。"""
|
||
seed_disk = None
|
||
if getattr(self, "host_key_mode", "provisioned") == "provisioned":
|
||
# 强方案:启动前预置每 VM host key 并钉死,
|
||
# 再把 seed 目录做成 CIDATA seed 磁盘并挂进
|
||
# VmConfig(P0-1 闭环:生成→制盘→挂盘三步缺
|
||
# 一即 fail-fast,绝不回落 keyscan、绝不只
|
||
# 生成不注入)。
|
||
# feat-chv-secfix(H2/H5):secret_env 随 seed
|
||
# 写进客内密钥文件;seed.iso 建好(0600,见
|
||
# build_seed_disk)后立即删宿主侧私钥——此后
|
||
# 宿主上只剩公钥 + known_hosts,私钥只活在客
|
||
# 机与 seed.iso(随 VM 销毁而删)里。删私钥
|
||
# 失败即 fail-fast(VM 尚未启动,此时拒绝最
|
||
# 安全,except 里 stop_vm 会清掉 seed)。
|
||
from cloud.chv.hostkeys import (
|
||
build_seed_disk, delete_host_private_key,
|
||
seed_disk_path)
|
||
provisioned = self.provision_host_key(
|
||
task, secret_env=secrets or None)
|
||
seed_disk = seed_disk_path(self.state_dir, name)
|
||
build_seed_disk(
|
||
provisioned["seed_dir"], seed_disk,
|
||
runner=lambda cmd: self._call_runner(cmd))
|
||
delete_host_private_key(self.state_dir, name)
|
||
if secrets:
|
||
self._guest_env_delivered[name] = set(secrets)
|
||
else:
|
||
self._guest_env_delivered.pop(name, None)
|
||
return seed_disk
|
||
|
||
def _boot_vm(self, task, name, task_disk, seed_disk,
|
||
net_entries) -> None:
|
||
"""VMM 启动→vm.create→vm.boot→SSH 就绪,纯移动。"""
|
||
socket_path = self.api_socket(task)
|
||
self._launch_api_only(task)
|
||
self.wait_api_ready(task)
|
||
self._check_api_socket_permissions(socket_path)
|
||
status, _ = self._api_call(
|
||
socket_path, "PUT", "/vm.create",
|
||
self.build_vm_config(task_disk, seed_disk=seed_disk))
|
||
if status not in (200, 204):
|
||
raise ChvFailure(
|
||
"vm_create_failed",
|
||
"vm.create 失败:status=%s" % status)
|
||
self._emit("vm_create", task,
|
||
"vm=%s disk=%s" % (name, task_disk))
|
||
if net_entries:
|
||
self._record_net_entries(name, net_entries)
|
||
status, _ = self._api_call(socket_path, "PUT", "/vm.boot")
|
||
if status not in (200, 204):
|
||
raise ChvFailure(
|
||
"vm_boot_failed",
|
||
"vm.boot 失败:status=%s" % status)
|
||
self._emit("vm_boot", task, "vm=%s" % name)
|
||
self.wait_guest_ready(task)
|
||
|
||
def _wake_prepare(self, task: dict, name: str) -> tuple:
|
||
"""wake 准备:reuse 审计、旧 VM 清理、任务盘供给、
|
||
直连 egress 审计,纯移动。返回 (task_disk, socket_path)。"""
|
||
if getattr(self, "disk_mode", "copy") == "reuse":
|
||
self._emit("disk_reuse_experiment_allowed", task,
|
||
"vm=%s CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 "
|
||
"explicit single_tenant_project=%s"
|
||
% (name, getattr(self, "single_tenant_project",
|
||
None)))
|
||
old = None
|
||
with self._vms_lock:
|
||
if name in self._vms:
|
||
old = self._vms.pop(name, None)
|
||
if old is not None:
|
||
self._kill_proc(old)
|
||
task_disk = self._provision_task_disk(task)
|
||
socket_path = self.api_socket(task)
|
||
if self.allow_direct_egress:
|
||
self._emit("egress_direct_allowed", task,
|
||
"vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name)
|
||
return task_disk, socket_path
|
||
|
||
|
||
def wake_vm(self, task, extra_env=None):
|
||
"""主唤醒路径(API 模式,dispatch/run/goal 的前置步骤):
|
||
preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 →
|
||
launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 →
|
||
PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest
|
||
SSH 就绪。任一步失败都关 VM(stop_vm)再抛
|
||
ChvFailure,绝不带着半醒的 VM 去跑任务。
|
||
成功返回 {vm, api_socket, state: "running", ready: True,
|
||
mode: "cold"},并审计 vm_create/vm_boot/vm_ready。
|
||
单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新
|
||
的,绝不两个 VM 共用一个任务名。
|
||
feat-chv-secfix:extra_env(_run_with_vm 透传)里的
|
||
密钥子集在 provisioned 模式下写进 seed 客内文件,
|
||
keyscan 模式下有密钥子集即 fail-closed;起 VM 前的
|
||
preflight/容量/配额失败也发 vm_failed 审计(L3);
|
||
net 隔离在起 VMM 前校验(H7),成功后登记 net.json。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
name = self.vm_name(task)
|
||
secrets, net_entries = self._wake_prechecks(task, name, extra_env)
|
||
task_disk, socket_path = self._wake_prepare(task, name)
|
||
try:
|
||
seed_disk = self._provision_seed_disk(task, name, secrets)
|
||
self._boot_vm(task, name, task_disk, seed_disk, net_entries)
|
||
except Exception:
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
if isinstance(task, dict):
|
||
self._emit("vm_failed", task,
|
||
"vm=%s wake 失败" % name)
|
||
raise
|
||
self._emit("vm_ready", task, "vm=%s" % name)
|
||
return {"vm": name, "api_socket": socket_path,
|
||
"state": "running", "ready": True, "mode": "cold"}
|
||
|
||
def start_vm(self, task):
|
||
"""旧名保留:即 wake_vm(API 模式)。直接启动命令
|
||
_build_vm_command() 只作手动等价写法(私有),不再是执行器
|
||
的启动路径——两条启动路径并存是上一版的半吊子,
|
||
本版只认 API 模式。"""
|
||
return self.wake_vm(task)
|
||
|
||
def pause_vm(self, task_or_name):
|
||
"""暂停 VM:ch-remote pause。成功审计 vm_pause。"""
|
||
self._ch_remote(task_or_name, "pause")
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
self._emit("vm_pause", task,
|
||
"vm=%s" % self.vm_name(task_or_name))
|
||
return True
|
||
|
||
def resume_vm(self, task_or_name):
|
||
"""唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。
|
||
注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它
|
||
(见 restore_vm),绝不能只 restore 就算醒。"""
|
||
self._ch_remote(task_or_name, "resume")
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
self._emit("vm_resume", task,
|
||
"vm=%s" % self.vm_name(task_or_name))
|
||
return True
|
||
|
||
def snapshot_vm(self, task_or_name, snapshot_url):
|
||
"""给(已暂停的)VM 做快照:ch-remote snapshot
|
||
<source_url>。snapshot_url 为空即 ValueError;成功
|
||
审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,
|
||
需 resume 才醒)。
|
||
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
|
||
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
|
||
远端须显式放行)与数量/总量/保留期上限;成功后登
|
||
记并在总量超帽时按最旧优先剪枝(删动作审计)。"""
|
||
if not snapshot_url:
|
||
raise ValueError("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))
|
||
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))
|
||
self._ch_remote(task_or_name, "snapshot",
|
||
"source_url=%s" % snapshot_url)
|
||
# feat-chv-fix(P1-7):只查 returncode 不够。file://
|
||
# 快照须验证目标文件存在且非空,否则报
|
||
# snapshot_not_persisted,调用方(hibernate_vm)据此
|
||
# 不 shutdown、不销毁原 VM;非 file:// 目标无法从宿
|
||
# 主验证落盘,仍只认 returncode,但文档明写该边界。
|
||
if snapshot_url.startswith("file://"):
|
||
_snap_path = snapshot_url[len("file://"):]
|
||
if not os.path.isfile(_snap_path) \
|
||
or os.path.getsize(_snap_path) == 0:
|
||
raise ChvFailure(
|
||
"snapshot_not_persisted",
|
||
"快照未落盘或为空:%s" % snapshot_url)
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
_entries, _removed = record_snapshot(
|
||
self.state_dir, vm_name, snapshot_url,
|
||
getattr(self, "snapshot_max_total_gb", 20.0))
|
||
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"
|
||
% (self.vm_name(task_or_name), snapshot_url))
|
||
return snapshot_url
|
||
|
||
def restore_vm(self, task, snapshot_url):
|
||
"""快照恢复唤醒:preflight → 只起 VMM(--api-socket)
|
||
→ 等 vmm.ping → ch-remote restore → 显式 resume →
|
||
等 guest SSH 就绪。恢复后的 VM 默认是 paused,不
|
||
resume 就不算醒——本方法绝不省略 resume 那一步。
|
||
任一步失败都关 VM 再抛。成功返回 {vm, api_socket,
|
||
state: "running", ready: True, mode: "restored"}。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not snapshot_url:
|
||
raise ValueError("snapshot_url 不能为空")
|
||
if "://" not in snapshot_url:
|
||
snapshot_url = "file://%s" % snapshot_url
|
||
# feat-chv-secfix(L2):恢复源同样过 URL 白名单
|
||
# (file:// 须在白名单目录内,远端须显式放行),
|
||
# 在 preflight/起 VMM 之前就拒绝。
|
||
validate_snapshot_url(
|
||
snapshot_url, self.state_dir,
|
||
snapshot_dir=getattr(self, "snapshot_dir", None),
|
||
allow_remote=getattr(self, "allow_remote_snapshot", False))
|
||
self.preflight()
|
||
self.resolve_ssh_host()
|
||
# feat-chv-fix(P0-2 修正版):restore 不走 vm.create,
|
||
# 没有 VmConfig 可挂 seed——审计报告“restore 须把 seed
|
||
# 接入 VmConfig”的说法在此不适用,纠正为:快照内已
|
||
# 固化原 VM 的 host key,重新预置新 key 反而会破坏
|
||
# 钉死。正确不变量是:provisioned 下复用已钉死的
|
||
# known_hosts(stop 时公钥保留、私钥已删但快照内仍有
|
||
# 原私钥),缺失时 fail-fast、绝不 keyscan、绝不重
|
||
# 生成。file:// 快照须先验存在且非空,否则不启动。
|
||
# 注意:restore 时不重复验快照文件存在——快照落盘
|
||
# 校验是 snapshot_vm/hibernate_vm 创建时的门(P1-7),
|
||
# restore 的 ch-remote 本身会在源缺失时失败;在这里
|
||
# 再验一次会把“源在远端/对象存储”的合法路径也挡掉。
|
||
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
||
and not os.path.exists(self.known_hosts_path(task)):
|
||
raise ChvFailure(
|
||
"host_key_not_pinned",
|
||
"provisioned 模式下 restore 缺 known_hosts,拒绝 "
|
||
"keyscan 回落:VM %s" % self.vm_name(task))
|
||
name = self.vm_name(task)
|
||
self._check_capacity(exclude=name)
|
||
try:
|
||
self._launch_api_only(task)
|
||
self.wait_api_ready(task)
|
||
self._check_api_socket_permissions(self.api_socket(task))
|
||
self._ch_remote(task, "restore",
|
||
"source_url=%s" % snapshot_url)
|
||
self._emit("vm_restore", task,
|
||
"vm=%s snapshot=%s" % (name, snapshot_url))
|
||
self.resume_vm(task)
|
||
self.wait_guest_ready(task)
|
||
except Exception:
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise
|
||
return {"vm": name, "api_socket": self.api_socket(task),
|
||
"state": "running", "ready": True, "mode": "restored"}
|
||
|
||
def hibernate_vm(self, task_or_name, snapshot_url):
|
||
"""休眠(CHV 无原生 hibernate 端点,等价实现):
|
||
pause + snapshot + shutdown。休眠后文件与 worktree
|
||
保留在快照里(快照是暂停时刻的完整状态),临时文件
|
||
不在恢复保证内(见 recycle_vm 的说明)。
|
||
成功返回 {vm, state: "hibernated", snapshot: ...}。
|
||
本方法明写“等价实现”,绝不声称接了原生 hibernate。"""
|
||
self.pause_vm(task_or_name)
|
||
self.snapshot_vm(task_or_name, snapshot_url)
|
||
self.stop_vm(task_or_name)
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
return {"vm": self.vm_name(task_or_name),
|
||
"state": "hibernated", "snapshot": snapshot_url}
|
||
|
||
def wake_from_hibernate(self, task, snapshot_url):
|
||
"""从休眠唤醒:即 restore_vm(restore + 显式 resume
|
||
+ SSH 就绪),语义与 restore_vm 完全一致,只是名字
|
||
说明来源是休眠快照。"""
|
||
return self.restore_vm(task, snapshot_url)
|
||
|
||
def stop_vm(self, task_or_name):
|
||
"""停止 VM(尽力而为,不抛异常;三种情况):
|
||
- 已死进程:登记进程 poll() 非 None,跳过 terminate
|
||
等待,直接删 socket、清登记,返回 True。
|
||
- 已关 VM:无登记进程、无 socket,ch-remote 仍尽力
|
||
调过一次,返回 False(本来就没有什么可清)。
|
||
- socket 残留:有 socket、无登记进程,删 socket 并
|
||
返回 True。
|
||
ch-remote shutdown 失败不炸(VM 可能已死),但
|
||
ch-remote 命令本身仍会发出——测试据此断言关闭路径
|
||
确实用了 ch-remote shutdown,而不是只杀进程。"""
|
||
name = self.vm_name(task_or_name)
|
||
socket_path = os.path.join(self.state_dir, name + ".sock")
|
||
with self._vms_lock:
|
||
had_proc = name in self._vms
|
||
proc = self._vms.get(name)
|
||
had = had_proc or os.path.exists(socket_path)
|
||
try:
|
||
self.runner([self.ch_remote,
|
||
"--api-socket=%s" % socket_path, "shutdown"])
|
||
except Exception:
|
||
pass
|
||
if proc is not None:
|
||
with self._vms_lock:
|
||
self._vms.pop(name, None)
|
||
try:
|
||
alive = proc.poll() is None
|
||
except Exception:
|
||
alive = False
|
||
if alive:
|
||
self._kill_proc(proc)
|
||
try:
|
||
if os.path.exists(socket_path):
|
||
os.remove(socket_path)
|
||
except Exception:
|
||
pass
|
||
# 补缺口轨:per-VM seed 目录与 host 私钥随 VM
|
||
# 关闭而删,绝不留给下一个 VM 复用;known_hosts
|
||
# 公钥文件保留供事后审计,删失败不炸关闭。下次
|
||
# 同名 VM 预置时若私钥已删即重新生成并覆盖
|
||
# known_hosts,不会用旧钉死去校验新 VM。
|
||
# feat-chv-secfix(H3/M2):清理分三块独立记账——
|
||
# seed/私钥块、任务磁盘块、net 登记块,任一块失败
|
||
# 都记对应审计 + 写脏标记(清理失败即拒起),绝不
|
||
# 再以“一块失败、整段吞掉”收场;任务磁盘块是 H3
|
||
# 的主修复:stop 即删 disk.raw(保留期 0;需取证
|
||
# 须在 shutdown 前经 SSH 拷出,见 recycle_vm 文档)。
|
||
# net.json 是 live 预约(非审计 artifact),随 VM
|
||
# 关闭而删,tap/mac/ip 预约随即释放。
|
||
_task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
_cleanup_failed = False
|
||
try:
|
||
seed_dir = os.path.join(self.state_dir, name, "seed")
|
||
if os.path.isdir(seed_dir):
|
||
shutil.rmtree(seed_dir)
|
||
for _suffix in ("ssh_host_ed25519_key",
|
||
"ssh_host_ed25519_key.pub",
|
||
"seed.iso"):
|
||
_p = os.path.join(self.state_dir, name, _suffix)
|
||
if os.path.exists(_p):
|
||
os.remove(_p)
|
||
except Exception as _cleanup_exc:
|
||
_cleanup_failed = True
|
||
self._emit("vm_key_cleanup_failed", _task,
|
||
"vm=%s error=%s" % (name, _cleanup_exc))
|
||
try:
|
||
if delete_task_disk(self.state_dir, name):
|
||
self._emit("vm_disk_deleted", _task,
|
||
"vm=%s disk deleted on stop" % name)
|
||
except Exception as _disk_exc:
|
||
_cleanup_failed = True
|
||
self._emit("vm_disk_delete_failed", _task,
|
||
"vm=%s error=%s" % (name, _disk_exc))
|
||
try:
|
||
_net_path = os.path.join(self.state_dir, name, "net.json")
|
||
if os.path.exists(_net_path):
|
||
os.remove(_net_path)
|
||
except Exception:
|
||
_cleanup_failed = True
|
||
self._guest_env_delivered.pop(name, None)
|
||
if _cleanup_failed:
|
||
# M2 根治:脏标记。标记本身写失败也不炸关闭,
|
||
# 但下次 provision_host_key 仍会因残留文件复用
|
||
# 旧 key——该残留情形由 cleanup_stale_secrets 的
|
||
# 扫描兜底(见 cleanup_stale_vms),文档明写。
|
||
try:
|
||
from cloud.chv.hostkeys import dirty_marker_path
|
||
_marker = dirty_marker_path(self.state_dir, name)
|
||
os.makedirs(os.path.dirname(_marker), exist_ok=True)
|
||
with open(_marker, "w") as fh:
|
||
fh.write("cleanup failed\n")
|
||
except Exception:
|
||
pass
|
||
else:
|
||
try:
|
||
from cloud.chv.hostkeys import dirty_marker_path
|
||
_marker = dirty_marker_path(self.state_dir, name)
|
||
if os.path.exists(_marker):
|
||
os.remove(_marker)
|
||
except Exception:
|
||
pass
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
if had:
|
||
self._emit("vm_shutdown", task, "vm=%s" % name)
|
||
self._emit("vm_destroy", task, "vm=%s" % name)
|
||
_LOGGER.info("chv_vm_stop vm=%s had=%s", name, had)
|
||
return had
|
||
|
||
def gc_stale_disks(self):
|
||
"""残留盘 GC(H3):扫描 state_dir/*/disk.raw,VM
|
||
非 live(无 api-socket 文件且无登记活进程)即删盘
|
||
并审计;删失败审计 vm_disk_delete_failed 并计入
|
||
failed。返回 {deleted, failed}。"""
|
||
deleted, failed = [], []
|
||
try:
|
||
entries = os.listdir(self.state_dir)
|
||
except Exception:
|
||
return {"deleted": deleted, "failed": failed}
|
||
for entry in entries:
|
||
if not entry.startswith("chv-"):
|
||
continue
|
||
disk = task_disk_path(self.state_dir, entry)
|
||
if not (os.path.islink(disk) or os.path.exists(disk)):
|
||
continue
|
||
live = os.path.exists(
|
||
os.path.join(self.state_dir, entry + ".sock"))
|
||
with self._vms_lock:
|
||
proc = self._vms.get(entry)
|
||
if proc is not None:
|
||
try:
|
||
live = live or proc.poll() is None
|
||
except Exception:
|
||
pass
|
||
if live:
|
||
continue
|
||
try:
|
||
delete_task_disk(self.state_dir, entry)
|
||
deleted.append(entry)
|
||
self._emit("vm_disk_gc", {"id": entry},
|
||
"vm=%s stale disk deleted" % entry)
|
||
except Exception as exc:
|
||
failed.append(entry)
|
||
self._emit("vm_disk_delete_failed", {"id": entry},
|
||
"vm=%s gc error=%s" % (entry, exc))
|
||
return {"deleted": deleted, "failed": failed}
|
||
|
||
def cleanup_stale_secrets(self):
|
||
"""崩溃残留扫描(H5):state_dir 下的 host 私钥文件
|
||
在本轨不变量下只可能是残留——live VM 的私钥在 seed
|
||
盘建好后即删(见 wake_vm),所以任何残留私钥文件
|
||
一律删除并审计;非 live VM 的 seed.iso/seed 目录同
|
||
理。返回已清理的 VM 名列表。worker 启动与
|
||
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
|
||
cleaned = []
|
||
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
|
||
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
|
||
removed_priv = False
|
||
if os.path.exists(priv):
|
||
try:
|
||
os.remove(priv)
|
||
removed_priv = True
|
||
except Exception:
|
||
pass
|
||
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
|
||
removed_seed = False
|
||
if not live:
|
||
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
|
||
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):
|
||
"""关闭入口的正式名:即 stop_vm(ch-remote shutdown)。
|
||
任务结束/失败/取消时调它。"""
|
||
return self.stop_vm(task_or_name)
|
||
|
||
def cancel_vm(self, task_or_name):
|
||
"""取消入口:即 shutdown_vm。worker 的取消路径对
|
||
Docker 调 kill_container,对 CHV 调本方法——取消绝不
|
||
只改数据库状态,必须真关 VM。"""
|
||
return self.shutdown_vm(task_or_name)
|
||
|
||
def recycle_vm(self, task):
|
||
"""回收 VM(任务结束后的销毁语义):
|
||
先 shutdown,再返回保留/丢失清单。保留的只有宿主侧
|
||
的 task_checkpoints / task_attempts(数据库里的断点与
|
||
尝试账本,VM 销毁不影响它们,goal 断点续跑据此接续);
|
||
丢失的是客机内的临时文件与未拷出的 worktree 临时物
|
||
——recycle 绝不声称“恢复临时文件”。需要拷出的工件
|
||
必须在 shutdown 之前由调用方经 SSH 拷出,本方法不
|
||
假装替调用方拷了。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
self.shutdown_vm(task)
|
||
self._emit("vm_recycled", task,
|
||
"vm=%s" % self.vm_name(task))
|
||
return {
|
||
"vm": self.vm_name(task),
|
||
"state": "recycled",
|
||
"preserved": ["task_checkpoints", "task_attempts"],
|
||
"lost": ["guest /tmp", "guest worktree temp files"],
|
||
}
|
||
|
||
def cleanup_stale_vms(self):
|
||
"""清理残留 VM:只清 socket 残留与已死进程对应的 VM。
|
||
|
||
feat-chv-secfix(H3/H5):先跑残留盘 GC 与残留
|
||
密钥扫描(不依赖 socket 是否存在,覆盖 worker 崩溃
|
||
后 socket 已删、文件仍在的情形),再走原 socket 扫描。
|
||
扫描 state_dir 下 *.sock:登记进程仍活(poll() 为 None)
|
||
的跳过;其余一律走 stop_vm。返回已清理的 VM 名列表;
|
||
state_dir 不存在时返回 [],绝不抛异常。基础设施或
|
||
其它执行器的 socket 不在 state_dir 里,不会被误删;
|
||
同目录下非 chv- 前缀的 socket 也跳过(只认本执行器
|
||
的 chv- 命名)。
|
||
"""
|
||
cleaned = []
|
||
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 []
|
||
for entry in entries:
|
||
if not entry.endswith(".sock"):
|
||
continue
|
||
name = entry[:-len(".sock")]
|
||
if not name.startswith("chv-"):
|
||
continue
|
||
proc = self._vms.get(name)
|
||
if proc is not None:
|
||
try:
|
||
alive = proc.poll() is None
|
||
except Exception:
|
||
alive = False
|
||
if alive:
|
||
continue
|
||
if self.stop_vm(name):
|
||
cleaned.append(name)
|
||
# 登记进程已死但 socket 文件已被删的情况:登记项本身
|
||
# 也是残留,顺手清掉登记,不计入 cleaned(没有 socket
|
||
# 可删,不算一次清理)。
|
||
for name in list(self._vms.keys()):
|
||
proc = self._vms.get(name)
|
||
try:
|
||
dead = proc is not None and proc.poll() is not None
|
||
except Exception:
|
||
dead = True
|
||
if dead:
|
||
self._vms.pop(name, None)
|
||
return cleaned
|
||
|
||
# -- 客内执行 ------------------------------------------------------
|
||
def _run_guest(self, task, inner_args, extra_env=None, **kwargs):
|
||
"""SSH 调客内 host.py,返回 Local/Docker 同款结果 dict
|
||
(executor 字段改写为 cloud-hypervisor,并附 vm 名与
|
||
api-socket)。runner 异常即原样传播,由 worker 侧统一
|
||
写回 failed——本类不在这里把异常吞成假成功。
|
||
extra_env(P1-1):经 SSH env 前缀进客机环境。"""
|
||
self._validate()
|
||
ssh_cmd = self.build_ssh_command(
|
||
task, self._guest_host_command(*inner_args, **kwargs),
|
||
extra_env=extra_env)
|
||
result = self._call_runner(ssh_cmd, timeout=self.task_timeout)
|
||
# 方法内 import:见模块头注释的循环导入说明。
|
||
from cloud.sandbox import _parse_host_result
|
||
from cloud.sandbox import SimpleNamespaceShim
|
||
parsed = _parse_host_result(task["id"],
|
||
SimpleNamespaceShim(result))
|
||
parsed["executor"] = "cloud-hypervisor"
|
||
parsed["vm"] = self.vm_name(task)
|
||
parsed["api_socket"] = self.api_socket(task)
|
||
return parsed
|
||
|
||
def _goal_result(self, task_id, result):
|
||
"""客内 host.py goal 输出解析:直接委托给三执行器共享
|
||
的 parse_goal_result(cloud/sandbox.py,Local 的
|
||
_goal_result_from_runner 同款正典语义),executor
|
||
字段为 cloud-hypervisor。
|
||
|
||
本轨对齐说明(#216 的 CHV 与正典有两处分叉,本轨
|
||
收敛,PR 描述里明写):① 非 0 退出但已打印 met 结论
|
||
时,正典判 failed(结论不可信),CHV 旧代码判
|
||
completed;② 输出含“已存在”撞名时,正典判 failed
|
||
并提示“用继续而非重试”,CHV 旧代码判 interrupted。
|
||
其余分支(met/unmet、interrupted 标记、永久错误、
|
||
无结论异常退出)本来就一致,不变。"""
|
||
from cloud.sandbox import parse_goal_result
|
||
return parse_goal_result(task_id, result,
|
||
"cloud-hypervisor")
|
||
|
||
def _run_guest_goal(self, task, inner_args, extra_env=None,
|
||
**kwargs):
|
||
self._validate()
|
||
ssh_cmd = self.build_ssh_command(
|
||
task, self._guest_host_command(*inner_args, **kwargs),
|
||
extra_env=extra_env)
|
||
return self._goal_result(
|
||
task["id"],
|
||
self._call_runner(ssh_cmd, timeout=self.task_timeout))
|
||
|
||
def _vm_proc_dead(self, task_or_name):
|
||
"""该 VM 的登记进程是否已死:只认本实例登记的进程
|
||
(poll() 非 None 即死);未登记时返回 False(不认
|
||
socket 残留——崩掉的 VMM 也会留下 socket 文件,单凭
|
||
socket 在不能断定 VM 活着还是死了,中途死亡判定只
|
||
用登记进程这个硬证据)。"""
|
||
name = self.vm_name(task_or_name)
|
||
with self._vms_lock:
|
||
proc = self._vms.get(name)
|
||
if proc is None:
|
||
return False
|
||
try:
|
||
return proc.poll() is not None
|
||
except Exception:
|
||
return False
|
||
|
||
def _vm_is_running(self, task_or_name):
|
||
"""execute() 的“VM 在运行”判定:本实例登记了活进程,
|
||
或 api-socket 文件存在(带外已醒的 VM,例如另一进
|
||
程 restore 的,登记不在本实例里)。两者皆无即未运行。"""
|
||
name = self.vm_name(task_or_name)
|
||
with self._vms_lock:
|
||
proc = self._vms.get(name)
|
||
if proc is not None:
|
||
try:
|
||
if proc.poll() is None:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return os.path.exists(self.api_socket(task_or_name))
|
||
|
||
def _vm_death_result(self, task, goal, evidence):
|
||
"""VM 中途死亡的 interrupted 结果:goal 形态带
|
||
goal_met/goal_error(断点可续跑),非 goal 形态带
|
||
vm_dead 标记与原因,status 一律 interrupted,绝不
|
||
笼统 failed。"""
|
||
reason = "VM 中途死亡,任务未完成、断点可续跑:%s" \
|
||
% (evidence or "客机连接在任务进行中丢失")
|
||
if goal:
|
||
return {
|
||
"executor": "cloud-hypervisor",
|
||
"task_id": task["id"],
|
||
"status": "interrupted",
|
||
"stdout": "",
|
||
"stderr": evidence or "",
|
||
"returncode": None,
|
||
"host_status": None,
|
||
"goal_met": None,
|
||
"goal_error": reason,
|
||
"usage": None,
|
||
"vm": self.vm_name(task),
|
||
"vm_dead": True,
|
||
}
|
||
return {
|
||
"executor": "cloud-hypervisor",
|
||
"task_id": task["id"],
|
||
"status": "interrupted",
|
||
"stdout": "",
|
||
"stderr": evidence or "",
|
||
"returncode": None,
|
||
"host_status": None,
|
||
"vm": self.vm_name(task),
|
||
"vm_dead": True,
|
||
"interrupted_reason": reason,
|
||
}
|
||
|
||
def _run_with_vm(self, task, inner_args, goal=False, extra_env=None,
|
||
**kwargs):
|
||
"""每任务 VM 生命周期(冷启动正式路径,feat-chv-arch95
|
||
定案):wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 →
|
||
finally shutdown_vm。本方法绝不查 WarmPool:预热池是
|
||
实验性的(见 pool.py 头定案与 ADR-5),dispatch 永远
|
||
冷启动,warm_pool_wired_to_dispatch 恒 False。
|
||
wake 失败时不跑
|
||
任务、直接抛分类失败。本方法不做内部重试:重试是
|
||
worker 的 max_retries 语义,执行器内部重试会把一次
|
||
失败放大成多次计费与多次 VM 启动。
|
||
|
||
中途死亡分类(本轨新增):客内命令抛传输异常、或
|
||
非 goal 结果已 failed 时,若登记的 VMM 进程已死,
|
||
判 interrupted(断点可续跑)而不是笼统 failed 或
|
||
原样抛异常;VM 还活着时的异常/失败原样处理,不
|
||
误判。goal 结果本身已有 interrupted 分类(见
|
||
parse_goal_result),这里不再二次改写 goal 结论。
|
||
feat-chv-secfix(H2):extra_env 透传给 wake_vm(密钥
|
||
子集在 provisioned 模式下随 seed 投递),再透传给
|
||
_run_guest(远程命令 source 客内密钥文件)。"""
|
||
self.wake_vm(task, extra_env=extra_env)
|
||
try:
|
||
try:
|
||
if goal:
|
||
return self._run_guest_goal(
|
||
task, inner_args, extra_env=extra_env, **kwargs)
|
||
result = self._run_guest(task, inner_args,
|
||
extra_env=extra_env, **kwargs)
|
||
except Exception as exc:
|
||
if self._vm_proc_dead(task):
|
||
return self._vm_death_result(
|
||
task, goal, str(exc)[:500])
|
||
raise
|
||
if not goal and result.get("status") == "failed" \
|
||
and self._vm_proc_dead(task):
|
||
result = dict(result)
|
||
result["status"] = "interrupted"
|
||
result["vm_dead"] = True
|
||
result["interrupted_reason"] = (
|
||
"VM 中途死亡,任务未完成、断点可续跑:"
|
||
"客内命令已 failed 且登记的 VMM 进程已死")
|
||
return result
|
||
finally:
|
||
try:
|
||
self.shutdown_vm(task)
|
||
except Exception:
|
||
pass
|
||
|
||
def execute(self, task) -> dict:
|
||
"""只读执行:客内 host.py status,不直接跑模型回合
|
||
(与 Local/Docker 的 execute 同款语义)。
|
||
|
||
execute 语义二选一结论(本轨落代码):只用于已运行
|
||
VM 的只读 status。VM 未运行时报分类失败
|
||
ChvFailure(kind=vm_not_running),绝不为一次 status
|
||
起一台 VM,也绝不向不存在的端口发 SSH 再拿连接
|
||
失败冒充任务状态。需要跑任务时调 dispatch/run/goal,
|
||
它们自带 wake_vm。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not self._vm_is_running(task):
|
||
raise ChvFailure(
|
||
"vm_not_running",
|
||
"execute() 只读已运行 VM 的 status:VM %s 未运行,"
|
||
"请先 wake_vm() 再调 execute(),不要用 execute "
|
||
"唤醒 VM" % self.vm_name(task))
|
||
try:
|
||
return self._run_guest(
|
||
task, ("status", "--task", self._host_name(task)))
|
||
except Exception as exc:
|
||
# feat-chv-fix(P1-8):socket 残留但 VMM 已死时,
|
||
# _vm_is_running 会因 socket 存在而放行,随后的
|
||
# SSH 失败不得以原始传输异常抛出——登记进程已死
|
||
# 或 socket 已消失即统一归类 vm_not_running;VM
|
||
# 仍活着时的 SSH 失败原样传播(那是客内 status
|
||
# 失败,不是 VM 未运行)。
|
||
if self._vm_proc_dead(task) \
|
||
or not os.path.exists(self.api_socket(task)):
|
||
raise ChvFailure(
|
||
"vm_not_running",
|
||
"execute() 时 VM 已死(socket 残留/进程已死),"
|
||
"SSH 失败归类为未运行:%s" % exc)
|
||
raise
|
||
|
||
def dispatch_task(self, task, extra_env=None, model_provider=None) -> dict:
|
||
"""客内 host.py dispatch:建 host 任务并跑一轮 turn。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not task.get("prompt"):
|
||
raise ValueError("任务缺少 prompt")
|
||
args = ["dispatch", "--name", self._host_name(task),
|
||
"--task", task["prompt"]]
|
||
repo = task.get("repo")
|
||
if repo:
|
||
args += ["--repo", repo]
|
||
return self._run_with_vm(
|
||
task, args, extra_env=extra_env, model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider,
|
||
sandbox=("read-only" if task.get("plan") else None))
|
||
|
||
def run_task(self, task, extra_env=None, model_provider=None) -> dict:
|
||
"""客内 host.py run:在已有 host 任务上再跑一轮 turn。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not task.get("prompt"):
|
||
raise ValueError("任务缺少 prompt")
|
||
return self._run_with_vm(
|
||
task, ("run", "--task", self._host_name(task),
|
||
"--prompt", task["prompt"]),
|
||
extra_env=extra_env, model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
def goal_task(self, task, extra_env=None, model_provider=None) -> dict:
|
||
"""客内 host.py goal:目标循环,轮次规则与 Local 同款
|
||
(缺省 3 轮,>100 轮 loud fail,旧验收形态 0 轮禁用)。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
goal_text = task.get("goal") or task.get("prompt")
|
||
if not goal_text:
|
||
raise ValueError("任务缺少 prompt")
|
||
verify = task.get("verify_command")
|
||
rounds = task.get("goal_max_rounds")
|
||
if rounds is None:
|
||
rounds = 3
|
||
if rounds is not None and rounds > 100:
|
||
raise ValueError("goal_max_rounds 超过上限 100 轮")
|
||
if rounds == 0 and verify and not task.get("goal_mode"):
|
||
raise ValueError(
|
||
"旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用")
|
||
args = ["goal", "--name", self._host_name(task),
|
||
"--task", goal_text]
|
||
if verify:
|
||
args += ["--verify", verify]
|
||
args += ["--max-rounds", str(rounds)]
|
||
repo = task.get("repo")
|
||
if repo:
|
||
args += ["--repo", repo]
|
||
return self._run_with_vm(
|
||
task, args, goal=True, extra_env=extra_env,
|
||
model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
def goal_resume_task(self, task, goal_text=None,
|
||
max_rounds=None, extra_env=None, model_provider=None) -> dict:
|
||
"""客内 host.py goal-resume:goal_text=None 为断点续跑
|
||
(不带 --goal-text,沿用原 goal+verify),给 goal_text
|
||
为开新目标(带 --goal-text 与 --max-rounds,默认 0)。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
args = ["goal-resume", "--task", self._host_name(task)]
|
||
if goal_text:
|
||
args += ["--goal-text", goal_text, "--max-rounds",
|
||
str(max_rounds if max_rounds is not None else 0)]
|
||
elif max_rounds is not None:
|
||
args += ["--max-rounds", str(max_rounds)]
|
||
return self._run_with_vm(
|
||
task, args, goal=True, extra_env=extra_env,
|
||
model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
def goal_resume_breakpoint_task(self, task,
|
||
model_provider=None,
|
||
extra_env=None) -> dict:
|
||
"""客内 host.py goal-resume 断点续跑:无 --goal-text、
|
||
无 --max-rounds,host 沿用原任务从断点轮次续跑。
|
||
worker 层已接线(补缺口轨):非网关的 CHV 断点任务
|
||
经 worker 的通用断点分支调到这里,model_provider
|
||
按名透传给客内 host.py(与 Local/Docker 同款口径,
|
||
首轮 thread 的 provider 必须一致);网关任务在 worker
|
||
侧仍被 loud fail 守卫拦住,不到这里。模型 key
|
||
本身绝不经本方法传值,只按环境变量名注入。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
return self._run_with_vm(
|
||
task, ("goal-resume", "--task", self._host_name(task)),
|
||
goal=True, extra_env=extra_env, model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
|