feat(chv): 架构与代码质量提升——WarmPool 定案、长函数提取、严格测试替身、Docker 同步守卫、配置统一校验、类型注解与 ADR

堆叠于 #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 套件
确定性全过。
This commit is contained in:
host committed 2026-10-31 05:50:56 +00:00
1 parent baf26beb78
commit c908a0147a
12 files changed
+725 -239

No files matched your search

+221 -112
View File
@@ -61,6 +61,13 @@ 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 占位,撞名会让
@@ -267,20 +274,12 @@ class CloudHypervisorExecutor(Executor):
"ready_timeout": ready_timeout,
"ready_interval": ready_interval,
"host_key_mode": host_key_mode}, self._env)
for _field in (
"binary", "kernel", "rootfs", "cpus", "memory",
"state_dir", "ssh_key", "ssh_user", "ssh_port",
"guest_host_py", "cmdline", "net", "net_json",
"kvm_path", "cpuinfo_path", "ch_remote", "model",
"max_vms", "disk_mode", "disk_gb", "egress_proxy",
"allow_direct_egress", "task_timeout", "snapshot_url",
"warm_pool_size", "api_timeout", "ready_timeout",
"ready_interval", "host_key_mode", "ssh_host",
"ssh_forward", "reuse_experiment",
"single_tenant_project", "snapshot_max_count",
"snapshot_max_total_gb", "snapshot_retention_days",
"snapshot_dir", "allow_remote_snapshot"):
setattr(self, _field, getattr(_cfg, _field))
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 客内文件,未投递的密钥名一律拒绝(防止
@@ -301,13 +300,26 @@ class CloudHypervisorExecutor(Executor):
if launcher is not None:
self.launcher = launcher
else:
def _popen(cmd):
return subprocess.Popen(
cmd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
self.launcher = _popen
self.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_.-]", "-",
@@ -338,7 +350,7 @@ class CloudHypervisorExecutor(Executor):
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):
def _preflight_config_checks(self, missing: list) -> None:
"""preflight 的配置校验块(feat-chv-fix,P2-1 小提取:
仅把 reuse 拦截与 CPU/内存校验从 preflight() 里抽出,
语义逐行不变;其余 preflight 检查项与 config.py 的
@@ -378,24 +390,70 @@ class CloudHypervisorExecutor(Executor):
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(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, 文本) 列表,全部收集完再一次抛
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"
@@ -432,6 +490,9 @@ class CloudHypervisorExecutor(Executor):
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):
@@ -456,6 +517,9 @@ class CloudHypervisorExecutor(Executor):
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 未设且未显式允许直连:"
@@ -494,6 +558,26 @@ class CloudHypervisorExecutor(Executor):
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]
@@ -612,7 +696,7 @@ class CloudHypervisorExecutor(Executor):
旧 key 对)。成功后审计 host_key_generated(只含
VM 名与密钥名,不含任何 key 值)。"""
from cloud.chv.hostkeys import (
dirty_marker_path, generate_host_keypair, host_key_paths,
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)
@@ -1077,25 +1161,9 @@ class CloudHypervisorExecutor(Executor):
"VMM api-socket 组/其它可写(%o),拒绝启动:%s"
% (mode, 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)
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 放行在启动前就定性。
@@ -1129,6 +1197,70 @@ class CloudHypervisorExecutor(Executor):
"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 "
@@ -1144,62 +1276,35 @@ class CloudHypervisorExecutor(Executor):
task_disk = self._provision_task_disk(task)
socket_path = self.api_socket(task)
if self.allow_direct_egress:
# (4) 直连 egress 只在显式 CHV_ALLOW_DIRECT_EGRESS=1
# 时放行,且必须审计;默认 fail-closed 在 preflight
# 里已拦(无代理且未显式允许即缺项)。tap 桥接
# 本身是否真只走代理仍需宿主实测,代码不声称已验证。
self._emit("egress_direct_allowed", task,
"vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name)
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 = 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)
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)
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)
@@ -1779,8 +1884,12 @@ class CloudHypervisorExecutor(Executor):
def _run_with_vm(self, task, inner_args, goal=False, extra_env=None,
**kwargs):
"""每任务 VM 生命周期:wake_vm(API 模式 + SSH 就绪)
→ 跑客内命令 → finally shutdown_vm。wake 失败时不跑
"""每任务 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 启动。