feat(chv): 架构/可维护性/前端只读侧推进——生产实验分离、配置片段化、VM 只读展示

架构:WarmPool 从 cloud/chv/pool.py 移到 experimental_pool.py,
生产残留清理彻底分文件到 cloud/chv/orphans.py(facade 直接读
registry 文件清理,不构造池、不碰 prewarm/acquire/release);
cloud/chv 与 cloud/sandbox 不再导出 WarmPool,旧路径一律
ImportError(CHV 未合并、无生产用户,兼容保留已无保留理由);
worker 改走 orphans facade,不再持有 _warm_pool;#215 对齐从
接口形状推进到 REPLACEMENT_POINTS 替换清单(R1/R2 可机械替换、
R3 永不替换,#215 模块当前不存在由测试钉住)。

可维护性:build_chv_config 拆成 base/ssh/network/quota/disk/
timeout 六片段(组装只做 dict 合并,键冲突即报错;38 字段集
与取值不变,单项非法报错文案逐字一致);ADR-12 模块职责图
与禁 import 清单(AST 钉住);cloud/chv/ 公开 API 全量类型
标注、无 Any 逃逸,并修正三处旧标注错误;死代码扫一遍
(删实验池已无调用的 _quarantine 委托与未用 _LOGGER)。

前端(只读侧,不做控制按钮):chv.ts 新增纯逻辑谓词与文案
(执行器精确匹配、附件守卫、VM 状态文案、列表徽标);列表
行新增 VM 已报告死亡徽标;详情对全部 CHV 任务显示 VM 行
(已报告死亡/未报告死亡,不猜 live);WorkersSection 与成本
卡沿用已有数据,不新开池管理/成本页;服务端未新增第二个
生命周期字段(持久化事实只有三项,chv_vm_dead 已是完整派生),
只把派生抽成 _chv_vm_dead_from_row 并补 goal 回落形态测试。
This commit is contained in:
host committed 2026-10-31 06:55:26 +00:00
1 parent 42de20da68
commit df8c64bd64
30 files changed
+1263 -331

No files matched your search

+23 -8
View File
@@ -1,18 +1,33 @@
"""Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。
公开名与 #216 一致,cloud/sandbox.py 会 re-export 它们,
保证旧导入路径可用:
公开名与 #216 一致,cloud/sandbox.py 会 re-export 生产
接口,保证旧导入路径可用——但 WarmPool 除外(本轨破环
性清理,见下):
- CloudHypervisorExecutor:执行器本体(executor.py)
- WarmPool:预热池 + registry 持久化与孤儿清理(pool.py)
- ChvFailure:分类失败(errors.py)
- reconcile_orphans_for_executor:生产残留清理唯一入口
(orphans.py,只读 registry 文件做清理,不构造池)
子模块分工见 executor.py 头注释。本包顶层 import 不触
cloud.sandbox(执行器方法内按需 import 共享 helper),
所以任何导入顺序都不会循环。
破环性清理(feat-chv-arch-maint-fe,未合并前即生效,
合并时按 PR 描述的清单检查调用方):WarmPool 已从
cloud/chv/pool.py 移到 cloud/chv/experimental_pool.py,
本包与 cloud/sandbox.py 不再导出它。旧路径
`from cloud.chv import WarmPool`、
`from cloud.chv.pool import WarmPool`、
`from cloud.sandbox import WarmPool` 一律 ImportError;
实验用法只走
`from cloud.chv.experimental_pool import WarmPool`。
清理理由:CHV 尚未合并、尚无生产用户,兼容保留只是
“怕断 import”,留着反而让生产代码有路可走进实验接口。
子模块分工见 executor.py 头注释与 docs/chv-architecture.md
ADR-12。本包顶层 import 不触 cloud.sandbox(执行器方法
内按需 import 共享 helper),所以任何导入顺序都不会循环。
"""
from cloud.chv.errors import ChvFailure
from cloud.chv.executor import CloudHypervisorExecutor
from cloud.chv.pool import WarmPool
from cloud.chv.orphans import reconcile_orphans_for_executor
__all__ = ["ChvFailure", "CloudHypervisorExecutor", "WarmPool"]
__all__ = ["ChvFailure", "CloudHypervisorExecutor",
"reconcile_orphans_for_executor"]
+1 -1
View File
@@ -128,7 +128,7 @@ def _unix_socket_api(socket_path, method, path, payload=None):
return _parse_response(header, body)
def normalize_api_result(result) -> tuple:
def normalize_api_result(result: dict) -> tuple:
"""API 调用归一化:fake api_client 可返回 (status, body)
或 {"status":..., "body":...} 或带 .status 的对象,本
函数统一成 (status:int, body)。"""
+57 -2
View File
@@ -23,12 +23,67 @@ cloud/gates.py、cloud/quota.py、cloud/sandbox_policy.py。
不提供,测试钉住“CHV 不 import 它们”。
本模块只依赖标准库,是叶子模块,不 import cloud.sandbox。
合并后替换清单(REPLACEMENT_POINTS,本轨从“接口形状”
推进到可机械执行的清单;#215 未合并前一律不 import
cloud.gates / cloud.sandbox_policy / cloud.timeouts /
cloud.quota,测试钉住这些模块在本基线里不存在):
- R1 gates.resolve_inside:唯一生产调用方是
hardening._is_within(快照 URL 白名单)。合并后两步:
① hardening 改为 `from cloud.gates import resolve_inside`;
② 删本模块 resolve_inside。语义必须逐字一致,改完
重跑快照白名单测试(逃逸/符号链接/绝对路径三例)。
- R2 sandbox_policy.tmpfs_isolated:形状占位,当前无
生产调用方(只有测试)。合并后一步:删本模块
tmpfs_isolated;将来有调用方时直接 import #215 版,
不得再本地复刻。CHV 语义是“一律不隔离(fail-closed)”,
若 #215 版对 cloud-hypervisor 返回 True,替换即改行为,
必须先停下来核对,不得机械替换。
- R3 timeouts / quota:永不替换。#215 的 timeouts 是
审批/队列超时(None/0 = 不过期),quota 是按月美元
配额;CHV 的 api/ready 超时是秒数(0 无效)、配额是
VM 数/磁盘 GB/快照数,单位与语义都不同。清单里明写
“不替换”,防止合并时有人按名字硬套。
"""
import os
def resolve_inside(base, rel):
# 合并后替换清单:每项的 local/replacement/test 字段由测试
# 逐项核对(local 函数真实存在、#215 模块当前不存在、
# test 命名的测试类真实存在),清单本身不得漂移。
REPLACEMENT_POINTS = (
{
"id": "R1",
"upstream": "cloud.gates.resolve_inside",
"local": "cloud.chv.compat215.resolve_inside",
"caller": "cloud.chv.hardening._is_within",
"replacement": "hardening 改为 from cloud.gates import "
"resolve_inside,并删除本地 resolve_inside",
"test": "Compat215Test + 快照白名单测试",
},
{
"id": "R2",
"upstream": "cloud.sandbox_policy.tmpfs_isolated",
"local": "cloud.chv.compat215.tmpfs_isolated",
"caller": None,
"replacement": "删除本地 tmpfs_isolated;有调用方时"
"直接 import #215 版(先核对 CHV 一律 False 语义)",
"test": "Compat215Test.test_tmpfs_isolated_chv_and_unknown_"
"fail_closed",
},
{
"id": "R3",
"upstream": "cloud.timeouts / cloud.quota",
"local": None,
"caller": None,
"replacement": None,
"test": "Compat215Test.test_no_timeout_or_quota_map",
},
)
def resolve_inside(base: str, rel: str) -> str | None:
"""目录门:base 内的相对路径安全解析,越界回 None。
空串回 base 本身;绝对路径、.. 逃逸、符号链接逃逸、
@@ -48,7 +103,7 @@ def resolve_inside(base, rel):
return target
def tmpfs_isolated(executor_kind, path):
def tmpfs_isolated(executor_kind: str, path: str) -> tuple[bool, str]:
"""某执行器下某路径是否走 tmpfs 隔离:(isolated, reason)。
cloud-hypervisor:客机 /tmp 落在客机磁盘上,不走宿主
+102 -84
View File
@@ -15,20 +15,19 @@ cloud.sandbox,避免循环导入)。
直接 ValueError。
与 #215 的关系(只列清单,不复制模块):#215 未合并,
本轨不假装复用它。#215 合并后应再复用的点:超时类
(api_timeout/ready_timeout/ready_interval/task_timeout →
timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘
配额门、egress fail-closed → gates 模块)、配额类
(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、
隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死
→ sandbox_policy 模块)。见 PR 描述的复用清单。
本轨不假装复用它。上一轨 ADR-7 的泛化映射已被证伪
(#215 的 timeouts 是审批/队列超时、quota 是按月美元
配额,与 CHV 的秒数超时、VM 数/磁盘 GB 单位语义不同,
硬套会改行为),真实映射只有两处,见
cloud/chv/compat215.py 的 REPLACEMENT_POINTS 清单
(合并时按清单机械替换,不在本轨硬 import 未合并模块)。
"""
import os
import re
def _parse_memory_bytes(value):
def _parse_memory_bytes(value: str) -> int:
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
非法值 ValueError,绝不静默按默认算。"""
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
@@ -45,15 +44,44 @@ class ChvConfig:
执行器 __init__ 直接把字段抄到 self 上,旧测试读属性
的方式全部不变。"""
def __init__(self, **fields):
def __init__(self, **fields: object) -> None:
for key, value in fields.items():
setattr(self, key, value)
def _base_fragment(params: dict, env: dict) -> dict:
"""身份/资源基底片段:二进制、内核、根盘、CPU/内存、
state_dir、KVM/cpuinfo 路径、ch-remote、模型。无校验,
取值口径与旧 build_chv_config 内联 base 逐字一致。"""
return {
"binary": params.get("binary") or env.get("CHV_BINARY")
or "cloud-hypervisor",
"kernel": params.get("kernel") or env.get("CHV_KERNEL"),
"rootfs": params.get("rootfs") or params.get("image")
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"),
"cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"),
"memory": params.get("memory") or env.get("CHV_MEM") or "2G",
"state_dir": params.get("state_dir")
or env.get("CHV_STATE_DIR")
or os.path.join(os.path.expanduser("~"),
".local", "share", "codex-app-server", "chv-vms"),
"kvm_path": params.get("kvm_path")
if params.get("kvm_path") is not None else "/dev/kvm",
"cpuinfo_path": params.get("cpuinfo_path")
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo",
"ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE")
or "ch-remote",
"model": params.get("model"),
}
def _ssh_fragment(params: dict, env: dict) -> dict:
"""SSH/命令面配置片段:返回 dict,供 build_chv_config 组装。
错误文案与原内联代码逐字一致,校验时机不变(构造时)。"""
"""SSH/命令面片段:密钥、用户、端口、host key 模式、
客内 host.py 路径、内核命令行。错误文案与原内联代码
逐字一致,校验时机不变(构造时)。网络字段(net、
ssh_host、egress)已移到 _network_fragment,不在本
片段,避免“SSH”片段名不副实。"""
ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY")
if params.get("ssh_user") is not None \
and params.get("ssh_user") == "":
@@ -82,19 +110,45 @@ def _ssh_fragment(params: dict, env: dict) -> dict:
"guest_host_py": params.get("guest_host_py") or "/app/host.py",
"cmdline": params.get("cmdline") or env.get("CHV_CMDLINE")
or "console=hvc0 root=/dev/vda1 rw",
}
def _network_fragment(params: dict, env: dict) -> dict:
"""网络片段:tap/桥接描述、SSH 目标、端口转发开关、
egress 代理与直连开关。无数值校验(校验在 preflight
的 net/egress 门里,不在配置解析层)。egress 从旧
quota 片段移到这里:它是网络出口策略,不是 VM 配额。"""
return {
"net": params.get("net") or env.get("CHV_NET"),
"net_json": params.get("net_json") or env.get("CHV_NET_JSON"),
"ssh_host": params.get("ssh_host") or env.get("CHV_SSH_HOST"),
"ssh_forward": env.get("CHV_SSH_FORWARD") == "1",
"egress_proxy": env.get("CHV_EGRESS_PROXY"),
"allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS")
== "1",
}
def _quota_fragment(env: dict) -> dict:
"""配额/磁盘/快照配置片段:返回 dict,错误文案逐字一致。"""
"""配额片段:仅容量数——最大并发 VM 数与预热池大小。
磁盘/快照数上限不在这里,它们是磁盘行为,见
_disk_fragment。校验文案与旧代码逐字一致。"""
try:
max_vms = int(env.get("CHV_MAX_VMS") or 4)
except ValueError:
raise ValueError("CHV_MAX_VMS 必须是整数")
try:
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
return {"max_vms": max_vms, "warm_pool_size": warm_pool_size}
def _disk_fragment(env: dict) -> dict:
"""磁盘片段:磁盘模式/大小、单租户 reuse 实验开关、
快照数/总量/保留期/目录/远端开关与快照 URL。校验
文案与旧 quota 片段逐字一致(disk_mode 白名单、
disk_gb > 0、快照数非负)。"""
disk_mode = env.get("CHV_DISK_MODE") or "copy"
if disk_mode not in ("copy", "reuse"):
raise ValueError(
@@ -119,8 +173,7 @@ def _quota_fragment(env: dict) -> dict:
or snapshot_retention_days < 0:
raise ValueError("CHV_SNAPSHOT_* 不能为负数")
return {
"max_vms": max_vms, "disk_mode": disk_mode,
"disk_gb": disk_gb,
"disk_mode": disk_mode, "disk_gb": disk_gb,
"reuse_experiment": env.get(
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1",
"single_tenant_project": env.get("CHV_SINGLE_TENANT_PROJECT"),
@@ -130,21 +183,17 @@ def _quota_fragment(env: dict) -> dict:
"snapshot_dir": env.get("CHV_SNAPSHOT_DIR"),
"allow_remote_snapshot": env.get("CHV_ALLOW_REMOTE_SNAPSHOT")
== "1",
"egress_proxy": env.get("CHV_EGRESS_PROXY"),
"allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS")
== "1",
"snapshot_url": env.get("CHV_SNAPSHOT_URL"),
}
def _timeout_fragment(params: dict, env: dict) -> dict:
"""超时/池/快照 URL 配置片段:返回 dict,错误文案逐字一致。"""
"""超时片段:任务超时(None = 不限,旧语义)、VMM API
超时、客机就绪超时/间隔。ready_interval <= 0 时回落
5 秒(旧语义,非校验失败)。校验文案逐字一致。"""
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
task_timeout = float(task_timeout_raw) \
if task_timeout_raw else None
try:
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
try:
api_timeout = float(
params.get("api_timeout")
@@ -163,73 +212,42 @@ def _timeout_fragment(params: dict, env: dict) -> dict:
if ready_interval <= 0:
ready_interval = 5
return {
"task_timeout": task_timeout, "warm_pool_size": warm_pool_size,
"api_timeout": api_timeout, "ready_timeout": ready_timeout,
"task_timeout": task_timeout, "api_timeout": api_timeout,
"ready_timeout": ready_timeout,
"ready_interval": ready_interval,
"snapshot_url": env.get("CHV_SNAPSHOT_URL"),
}
def _assemble_chv_config(base: dict, ssh: dict, quota: dict,
timeouts: dict):
"""把基础字段 + SSH/配额/超时三个片段组装成 ChvConfig。
def _assemble_chv_config(fragments: list) -> "ChvConfig":
"""把各片段 dict 合并成 ChvConfig(纯合并,不改值)。
纯移动:字段名与取值逐字不变,build_chv_config 只解析。"""
return ChvConfig(
binary=base["binary"], kernel=base["kernel"],
rootfs=base["rootfs"], cpus=base["cpus"],
memory=base["memory"], state_dir=base["state_dir"],
ssh_key=ssh["ssh_key"], ssh_user=ssh["ssh_user"],
ssh_port=ssh["ssh_port"],
guest_host_py=ssh["guest_host_py"], cmdline=ssh["cmdline"],
net=ssh["net"], net_json=ssh["net_json"],
kvm_path=base["kvm_path"], cpuinfo_path=base["cpuinfo_path"],
ch_remote=base["ch_remote"], model=base["model"],
max_vms=quota["max_vms"], disk_mode=quota["disk_mode"],
disk_gb=quota["disk_gb"], ssh_host=ssh["ssh_host"],
ssh_forward=ssh["ssh_forward"],
reuse_experiment=quota["reuse_experiment"],
egress_proxy=quota["egress_proxy"],
allow_direct_egress=quota["allow_direct_egress"],
host_key_mode=ssh["host_key_mode"],
task_timeout=timeouts["task_timeout"],
snapshot_url=timeouts["snapshot_url"],
warm_pool_size=timeouts["warm_pool_size"],
api_timeout=timeouts["api_timeout"],
ready_timeout=timeouts["ready_timeout"],
ready_interval=timeouts["ready_interval"],
single_tenant_project=quota["single_tenant_project"],
snapshot_max_count=quota["snapshot_max_count"],
snapshot_max_total_gb=quota["snapshot_max_total_gb"],
snapshot_retention_days=quota["snapshot_retention_days"],
snapshot_dir=quota["snapshot_dir"],
allow_remote_snapshot=quota["allow_remote_snapshot"])
片段键集必须互不相交——键冲突是编程错误,直接
ValueError,绝不让后一片段静默覆盖前一片段的值。"""
fields: dict = {}
for fragment in fragments:
for key, value in fragment.items():
if key in fields:
raise ValueError(
"CHV 配置片段键冲突:%s" % key)
fields[key] = value
return ChvConfig(**fields)
def build_chv_config(params: dict, env: dict):
"""把显式构造参数与环境变量解析成 ChvConfig(见
_assemble_chv_config 的组装段;片段化与校验顺序不变)。"""
def build_chv_config(params: dict, env: dict) -> "ChvConfig":
"""把显式构造参数与环境变量解析成 ChvConfig。
片段调用顺序即旧校验顺序(SSH → 配额 → 磁盘 →
超时),只有一处可观测的顺序变化:warm_pool_size
从旧超时片段移到配额片段,因此“CHV_WARM_POOL_SIZE
非法 + 磁盘非法同时出现”时先报前者;单项非法时
报错与旧代码逐字一致。字段集合与取值与旧代码一致,
由测试的字段集断言钉住。"""
env = env if env is not None else {}
base = {
"binary": params.get("binary") or env.get("CHV_BINARY")
or "cloud-hypervisor",
"kernel": params.get("kernel") or env.get("CHV_KERNEL"),
"rootfs": params.get("rootfs") or params.get("image")
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"),
"cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"),
"memory": params.get("memory") or env.get("CHV_MEM") or "2G",
"state_dir": params.get("state_dir")
or env.get("CHV_STATE_DIR")
or os.path.join(os.path.expanduser("~"),
".local", "share", "codex-app-server", "chv-vms"),
"kvm_path": params.get("kvm_path")
if params.get("kvm_path") is not None else "/dev/kvm",
"cpuinfo_path": params.get("cpuinfo_path")
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo",
"ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE")
or "ch-remote",
"model": params.get("model"),
}
return _assemble_chv_config(
base, _ssh_fragment(params, env), _quota_fragment(env),
_timeout_fragment(params, env))
return _assemble_chv_config([
_base_fragment(params, env),
_ssh_fragment(params, env),
_quota_fragment(env),
_disk_fragment(env),
_network_fragment(params, env),
_timeout_fragment(params, env),
])
+8 -5
View File
@@ -33,7 +33,7 @@ def _dir_total_bytes(path):
return total
def check_disk_quota(rootfs: str, disk_gb: float, state_dir=None) -> None:
def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None) -> None:
"""磁盘配额门(feat-chv-fix 口径):
- disk_gb 未设时不检查;
@@ -162,8 +162,10 @@ def _save_snapshots(state_dir, vm_name, entries):
os.replace(tmp_path, path)
def prune_snapshots(state_dir, vm_name, retention_days,
now=None, delete_files=True):
def prune_snapshots(state_dir: str, vm_name: str,
retention_days: float | None,
now: float | None = None,
delete_files: bool = True) -> tuple[list, list]:
"""按保留期剪枝:删过期条目;delete_files 时同时删
对应的 file:// 快照文件(只删登记里记的 path,不猜
文件名)。返回(保留条目,剪掉条目)。"""
@@ -189,7 +191,8 @@ def prune_snapshots(state_dir, vm_name, retention_days,
def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int,
max_total_gb, retention_days):
max_total_gb: float,
retention_days: float | None) -> list:
"""快照上限预检(snapshot_vm 调 ch-remote 之前调):
先按保留期剪枝,再查数量(已有数 + 本次 > 上限即
snapshot_quota_exceeded)与总量(已有总量已达上限即
@@ -218,7 +221,7 @@ def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int,
return kept
def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float) -> dict:
def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float) -> tuple[list, list]:
"""快照成功后登记;file:// 目标记实际大小。登记后若
总量超上限,按最旧优先删登记外的旧快照文件(总量硬
帽,删动作由调用方审计——本函数返回被删的旧条目)。
+1 -1
View File
@@ -7,6 +7,6 @@ class ChvFailure(RuntimeError):
审计与失败分类用,绝不笼统只写 failed。kind 取值见
CloudHypervisorExecutor.preflight / wake_vm 的注释。"""
def __init__(self, kind, message):
def __init__(self, kind: str, message: str) -> None:
super().__init__("chv_failure:%s: %s" % (kind, message))
self.kind = kind
+66 -50
View File
@@ -7,7 +7,8 @@
- ssh_transport.py:SSH 命令构造、每 VM known_hosts 钉死、
ssh-keyscan 首次捕获(TOFU 边界见该模块)
- disk.py:每任务磁盘供给与磁盘配额门
- pool.py:WarmPool 预热池 + registry 持久化与孤儿清理
- orphans.py:生产残留清理唯一入口(registry 文件直读清理)
- experimental_pool.py:WarmPool 预热池(纯实验,生产禁 import)
- executor.py(本模块):执行器类本体——生命周期方法
(wake/pause/resume/snapshot/restore/hibernate/stop/
cleanup)与任务执行方法(dispatch/run/goal/execute)。
@@ -154,11 +155,11 @@ class CloudHypervisorExecutor(Executor):
- 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM;
每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访;
VM 内 root 只能影响本 VM。
- 快速启动:WarmPool(cloud/chv/pool.py)预热干净快照 VM,
- 快速启动:WarmPool(cloud/chv/experimental_pool.py)预热干净快照 VM,
acquire 消耗、release 销毁,池空回落冷启动,绝不降级;
池状态另落 state_dir/warm-pool.json registry,worker
重启后载入的条目一律当孤儿、只能 reconcile_orphans()
清理,绝不重新发给任务(见 pool.py)。
清理,绝不重新发给任务(见 orphans.py)。
- 长任务中途死亡:_run_with_vm 在客内命令抛传输异常、
或非 goal 结果已 failed 且 VM 登记进程已死时,改判
interrupted(断点可续跑)而不是笼统 failed;VM 还活
@@ -245,17 +246,30 @@ class CloudHypervisorExecutor(Executor):
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
"""
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):
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: object = None, launcher: object = 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: object = None,
sleeper: object = 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 = env if env is not None else os.environ
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
@@ -326,7 +340,7 @@ class CloudHypervisorExecutor(Executor):
return re.sub(r"[^A-Za-z0-9_.-]", "-",
str(task.get("id")))
def vm_name(self, task_or_name):
def vm_name(self, task_or_name: dict | str) -> str:
"""VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款
派生,同一任务每次算出同一名字,清理才能按名找)。
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
@@ -339,7 +353,7 @@ class CloudHypervisorExecutor(Executor):
return safe
return "chv-" + safe
def api_socket(self, task_or_name):
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")
@@ -559,7 +573,7 @@ class CloudHypervisorExecutor(Executor):
validate_net_entries(_entries, [])
except ChvFailure as exc:
missing.append((exc.kind, str(exc)))
def preflight(self):
def preflight(self) -> bool:
"""预检(缺一不可则 fail-fast):收集全部缺失项一次报出
具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind,
供审计与失败分类用),绝不回落 Docker/Local。
@@ -651,7 +665,7 @@ class CloudHypervisorExecutor(Executor):
cmd += ["--net", self.net]
return cmd
def known_hosts_path(self, task_or_name):
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,
@@ -671,7 +685,7 @@ class CloudHypervisorExecutor(Executor):
return str(entry["ip"])
return None
def resolve_ssh_host(self):
def resolve_ssh_host(self) -> str:
"""SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP):
显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下
@@ -690,7 +704,7 @@ class CloudHypervisorExecutor(Executor):
"里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 "
"CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口")
def provision_host_key(self, task, secret_env=None):
def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict:
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
立即写进该 VM 的 known_hosts(钉死),私钥只写进
@@ -741,7 +755,7 @@ class CloudHypervisorExecutor(Executor):
return {"paths": paths, "seed_dir": seed_dir,
"known_hosts": paths["known_hosts"]}
def capture_host_key(self, task):
def capture_host_key(self, task: dict) -> bool:
"""首次捕获客机 host key(TOFU,弱回落,边界见
ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机
回环端口抓 key 行,写入该 VM 的 known_hosts 文件
@@ -775,7 +789,7 @@ class CloudHypervisorExecutor(Executor):
"vm=%s host=%s" % (self.vm_name(task), ssh_host))
return True
def build_ssh_command(self, task, inner_cmd, extra_env=None):
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
@@ -821,7 +835,7 @@ class CloudHypervisorExecutor(Executor):
guest_env_file=guest_env_file)
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def build_vm_config(self, task_disk=None, seed_disk=None):
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict:
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
kernel{path}、cmdline{args}、disks[{path: 每任务磁盘}]、
@@ -968,7 +982,7 @@ class CloudHypervisorExecutor(Executor):
self.state_dir, self.vm_name(task), self.rootfs,
self.disk_mode)
def wait_api_ready(self, task):
def wait_api_ready(self, task: dict) -> bool:
"""等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整,
至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟
时墙钟循环会空转,次数循环在单测里是确定性的。
@@ -1014,7 +1028,7 @@ class CloudHypervisorExecutor(Executor):
return True
return False
def wait_guest_ready(self, task):
def wait_guest_ready(self, task: dict) -> bool:
"""等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪;
每轮先尽力捕获 host key(keyscan 模式),超时先关 VM
再 raise guest_not_ready,绝不在未就绪时派任务。"""
@@ -1298,7 +1312,7 @@ class CloudHypervisorExecutor(Executor):
return task_disk, socket_path
def wake_vm(self, task, extra_env=None):
def wake_vm(self, task: dict, extra_env: dict | None = None) -> dict:
"""主唤醒路径(API 模式,dispatch/run/goal 的前置步骤):
preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 →
launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 →
@@ -1335,14 +1349,14 @@ class CloudHypervisorExecutor(Executor):
return {"vm": name, "api_socket": socket_path,
"state": "running", "ready": True, "mode": "cold"}
def start_vm(self, task):
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):
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) \
@@ -1351,7 +1365,7 @@ class CloudHypervisorExecutor(Executor):
"vm=%s" % self.vm_name(task_or_name))
return True
def resume_vm(self, task_or_name):
def resume_vm(self, task_or_name: dict | str) -> bool:
"""唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。
注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它
(见 restore_vm),绝不能只 restore 就算醒。"""
@@ -1362,7 +1376,7 @@ class CloudHypervisorExecutor(Executor):
"vm=%s" % self.vm_name(task_or_name))
return True
def snapshot_vm(self, task_or_name, snapshot_url):
def snapshot_vm(self, task_or_name: dict | str, snapshot_url: str) -> str:
"""给(已暂停的)VM 做快照:ch-remote snapshot
<source_url>。snapshot_url 为空即 ValueError;成功
审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,
@@ -1454,7 +1468,7 @@ class CloudHypervisorExecutor(Executor):
self.resume_vm(task)
self.wait_guest_ready(task)
def restore_vm(self, task, snapshot_url):
def restore_vm(self, task: dict, snapshot_url: str) -> dict:
"""快照恢复唤醒:校验 → 只起 VMM → restore → 显式
resume → 等 SSH 就绪。恢复后默认 paused,不 resume
就不算醒。任一步失败都关 VM 再抛;成功返回
@@ -1474,7 +1488,7 @@ class CloudHypervisorExecutor(Executor):
return {"vm": name, "api_socket": self.api_socket(task),
"state": "running", "ready": True, "mode": "restored"}
def hibernate_vm(self, task_or_name, snapshot_url):
def hibernate_vm(self, task_or_name: dict | str, snapshot_url: str) -> dict:
"""休眠(CHV 无原生 hibernate 端点,等价实现):
pause + snapshot + shutdown。休眠后文件与 worktree
保留在快照里(快照是暂停时刻的完整状态),临时文件
@@ -1489,7 +1503,7 @@ class CloudHypervisorExecutor(Executor):
return {"vm": self.vm_name(task_or_name),
"state": "hibernated", "snapshot": snapshot_url}
def wake_from_hibernate(self, task, snapshot_url):
def wake_from_hibernate(self, task: dict, snapshot_url: str) -> dict:
"""从休眠唤醒:即 restore_vm(restore + 显式 resume
+ SSH 就绪),语义与 restore_vm 完全一致,只是名字
说明来源是休眠快照。"""
@@ -1560,7 +1574,7 @@ class CloudHypervisorExecutor(Executor):
except Exception:
pass
def stop_vm(self, task_or_name):
def stop_vm(self, task_or_name: dict | str) -> bool:
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
已关 VM 仍尽力调一次 ch-remote(返回 False),socket
残留删 socket(返回 True)。ch-remote shutdown 失败
@@ -1597,7 +1611,7 @@ class CloudHypervisorExecutor(Executor):
_LOGGER.info("chv_vm_stop vm=%s had=%s", name, had)
return had
def gc_stale_disks(self):
def gc_stale_disks(self) -> dict:
"""残留盘 GC(H3):扫描 state_dir/*/disk.raw,VM
非 live(无 api-socket 文件且无登记活进程)即删盘
并审计;删失败审计 vm_disk_delete_failed 并计入
@@ -1675,7 +1689,7 @@ class CloudHypervisorExecutor(Executor):
pass
return removed_priv, removed_seed
def cleanup_stale_secrets(self):
def cleanup_stale_secrets(self) -> list:
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
@@ -1700,18 +1714,18 @@ class CloudHypervisorExecutor(Executor):
% (entry, removed_priv, removed_seed))
return cleaned
def shutdown_vm(self, task_or_name):
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):
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):
def recycle_vm(self, task: dict) -> dict:
"""回收 VM(任务结束后的销毁语义):
先 shutdown,再返回保留/丢失清单。保留的只有宿主侧
的 task_checkpoints / task_attempts(数据库里的断点与
@@ -1767,7 +1781,7 @@ class CloudHypervisorExecutor(Executor):
if dead:
self._vms.pop(name, None)
def cleanup_stale_vms(self):
def cleanup_stale_vms(self) -> list:
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
socket 扫描与登记清理。只清 socket 残留与已死进程
对应的 VM;state_dir 不存在返回 [],绝不抛异常。"""
@@ -1907,7 +1921,7 @@ class CloudHypervisorExecutor(Executor):
"""每任务 VM 生命周期(冷启动正式路径,feat-chv-arch95
定案):wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 →
finally shutdown_vm。本方法绝不查 WarmPool:预热池是
实验性的(见 pool.py 头定案与 ADR-5),dispatch 永远
实验性的(见 experimental_pool.py 头定案与 ADR-5),dispatch 永远
冷启动,warm_pool_wired_to_dispatch 恒 False。
wake 失败时不跑
任务、直接抛分类失败。本方法不做内部重试:重试是
@@ -1951,7 +1965,7 @@ class CloudHypervisorExecutor(Executor):
except Exception:
pass
def execute(self, task) -> dict:
def execute(self, task: dict) -> dict:
"""只读执行:客内 host.py status,不直接跑模型回合
(与 Local/Docker 的 execute 同款语义)。
@@ -1987,7 +2001,7 @@ class CloudHypervisorExecutor(Executor):
"SSH 失败归类为未运行:%s" % exc)
raise
def dispatch_task(self, task, extra_env=None, model_provider=None) -> dict:
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")
@@ -2004,7 +2018,7 @@ class CloudHypervisorExecutor(Executor):
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:
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")
@@ -2017,7 +2031,7 @@ class CloudHypervisorExecutor(Executor):
effort=task.get("effort"), approval=task.get("approval"),
model_provider=model_provider)
def goal_task(self, task, extra_env=None, model_provider=None) -> dict:
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:
@@ -2048,8 +2062,10 @@ class CloudHypervisorExecutor(Executor):
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:
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)。"""
@@ -2067,9 +2083,9 @@ class CloudHypervisorExecutor(Executor):
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:
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 断点任务
@@ -1,4 +1,13 @@
"""快照预热池 WarmPool(实验性,非生产路径,feat-chv-arch95 定案)。
"""快照预热池 WarmPool(纯实验模块,非生产路径)。
本轨(feat-chv-arch-maint-fe)破环性清理:WarmPool 从
cloud/chv/pool.py 移到本文件,cloud/chv/__init__ 与
cloud/sandbox.py 不再 re-export 它,生产代码 import 本
模块即违规(见 docs/chv-architecture.md ADR-12 的禁
import 清单与测试钉住)。实验用法只走
`from cloud.chv.experimental_pool import WarmPool`。
生产残留清理已彻底分文件:见 cloud/chv/orphans.py 的
reconcile_orphans_for_executor(),它不 import 本模块。
定案(架构决策,见 docs/chv-architecture.md ADR-5):WarmPool
不再宣称为生产快速启动特性。原因:预热 VM 的磁盘是干净快照
@@ -7,10 +16,11 @@
语义三者都未实现),把预热盘直接当任务盘用会击穿“任务盘
隔离”不变量。acquire 内 resume 只是实验性接口,worker
dispatch 永远走冷启动(wake_vm),_run_with_vm 绝不查池。
pool 保留的唯一生产价值是 reconcile_orphans()(上一世残留
清理),worker 启动/关闭仍调它。warm_pool_wired_to_dispatch
恒为 False,任何把它改成 True 的改动都必须先实现任务盘
重供给并补不变量测试,否则不得合入。
本模块的 reconcile_orphans() 只留给实验/旧测试直接调用;
生产启动/关闭走 orphans facade,不再经本类。
warm_pool_wired_to_dispatch 恒为 False,任何把它改成
True 的改动都必须先实现任务盘重供给并补不变量测试,
否则不得合入。
隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的
@@ -43,13 +53,11 @@ Registry(本轨新增):
仍可用,重启后孤儿改由 socket 扫描兜底)。
"""
import json
import logging
import os
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
_REGISTRY_FILENAME = "warm-pool.json"
from cloud.chv.orphans import _REGISTRY_FILENAME
from cloud.chv.orphans import _read_registry
from cloud.chv.orphans import _write_registry
class WarmPool:
@@ -73,8 +81,9 @@ class WarmPool:
wired_to_dispatch = False
def __init__(self, executor, size=None, snapshot_url=None,
registry_path=None):
def __init__(self, executor: object, size: int | None = None,
snapshot_url: str | None = None,
registry_path: str | None = None) -> None:
self.executor = executor
self.size = int(size if size is not None
else getattr(executor, "warm_pool_size", 0))
@@ -93,24 +102,19 @@ class WarmPool:
self._load_registry()
# -- registry ------------------------------------------------------
def _load_registry(self):
def _load_registry(self) -> None:
"""载入 registry:读到的条目一律标 orphan(见模块
docstring,绝不直接当 warm 发给任务)。无文件时为空
池;损坏时 quarantine 并从空开始。"""
池;损坏时 quarantine 并从空开始。文件 IO 与损坏
语义唯一来源是 cloud.chv.orphans(生产模块),本
方法只做“条目 → 内存 orphan 态”的映射。"""
if not self.registry_path:
return
try:
with open(self.registry_path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return
except (OSError, ValueError):
data, corrupt = _read_registry(self.registry_path)
if corrupt:
self.registry_corrupt = True
self._quarantine_registry()
return
if not isinstance(data, dict):
self.registry_corrupt = True
self._quarantine_registry()
if data is None:
return
for name, entry in data.items():
if not isinstance(entry, dict) \
@@ -125,22 +129,10 @@ class WarmPool:
"snapshot_url": entry.get("snapshot_url"),
}
def _quarantine_registry(self):
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力
而为),原路径不再留损坏数据给下一次载入。"""
if not self.registry_path:
return
try:
os.replace(self.registry_path,
self.registry_path + ".corrupt")
except OSError:
_LOGGER.warning("chv_pool_registry_quarantine_failed "
"path=%s", self.registry_path)
def _save_registry(self):
def _save_registry(self) -> None:
"""落盘当前条目(只写 warm/assigned 态;orphan 是
待清理的上一世条目,reconcile 后即删,不写回)。
写失败只记日志,不炸任务流。"""
写失败只记日志,不炸任务流。落盘 IO 委托 orphans。"""
if not self.registry_path:
return
data = {}
@@ -156,23 +148,14 @@ class WarmPool:
"task_id": task_id,
"snapshot_url": entry.get("snapshot_url"),
}
try:
directory = os.path.dirname(self.registry_path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = self.registry_path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(data, fh)
os.replace(tmp_path, self.registry_path)
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s",
self.registry_path)
_write_registry(self.registry_path, data)
def reconcile_orphans(self) -> list:
"""清理上一世残留条目:对每个 orphan/assigned 条目
尽力 shutdown_vm(清 socket 与残留登记),条目清掉
并落盘,返回已清理的 VM 名列表。worker 启动/清理时
显式调它(见模块 docstring 的单活池假设)。"""
"""清理本池内存里的 orphan/assigned 条目(实验入口)。
对每个条目尽力 shutdown_vm,条目清掉并落盘,返回
已清理的 VM 名列表。生产启动/关闭不走本方法,走
cloud.chv.orphans.reconcile_orphans_for_executor()。"""
cleaned = []
for name, entry in list(self._entries.items()):
if entry["state"] not in ("orphan", "assigned"):
@@ -204,7 +187,7 @@ class WarmPool:
return sum(1 for e in self._entries.values()
if e["state"] in ("orphan", "assigned"))
def prewarm(self, count=None):
def prewarm(self, count: int | None = None) -> list:
"""预热到目标数:已有 warm 条目不重复建;每个新条目
用一个合成任务名 wake + pause(合成任务 id 为
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
@@ -237,7 +220,7 @@ class WarmPool:
self._save_registry()
return added
def acquire(self, task):
def acquire(self, task: dict) -> dict:
"""分配一个预热 VM 给任务(feat-chv-fix,P1-3 接线):
有 warm 条目时先调 executor.resume_vm() 把暂停的 VM
@@ -285,7 +268,7 @@ class WarmPool:
"pool_task": entry["task"]}
return {"mode": "cold", "vm": None, "pool_task": None}
def release(self, task, pool_vm=None):
def release(self, task: dict, pool_vm: str | None = None) -> bool:
"""释放:无论 warm 还是 cold 路径,VM 一律销毁
(executor.shutdown_vm),池条目清掉;绝不把用过
的 VM 放回 warm 池。返回 True 表示确实销毁了一个
@@ -304,13 +287,3 @@ class WarmPool:
"warm_pool_release", task,
"vm=%s destroyed=%s" % (pool_vm, destroyed))
return destroyed
def reconcile_orphans_for_executor(executor) -> list:
"""生产路径唯一的 WarmPool 入口:只做孤儿清理。
worker 启动/关闭调它,不构造 WarmPool 类、不调
prewarm/acquire/release——实验接口与生产路径彻底分离:
生产代码 import 本函数即可,acquire/prewarm 只存在于
实验用的 WarmPool 类与测试里。"""
return WarmPool(executor).reconcile_orphans()
+3 -2
View File
@@ -183,8 +183,9 @@ def _is_within(directory, path):
return resolve_inside(directory, rel) is not None
def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None,
allow_remote=False):
def validate_snapshot_url(snapshot_url: str, state_dir: str,
snapshot_dir: str | None = None,
allow_remote: bool = False) -> str | None:
"""快照 URL 白名单(L2):
- file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内,
+11 -9
View File
@@ -40,7 +40,7 @@ import shlex
GUEST_ENV_PATH = "/etc/chv/guest-env"
def host_key_paths(state_dir: str, vm_name: str) -> tuple:
def host_key_paths(state_dir: str, vm_name: str) -> dict:
"""该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。"""
base = os.path.join(state_dir, vm_name)
return {
@@ -59,7 +59,7 @@ def dirty_marker_path(state_dir: str, vm_name: str) -> str:
return os.path.join(state_dir, vm_name, ".cleanup-dirty")
def delete_host_private_key(state_dir: str, vm_name: str) -> None:
def delete_host_private_key(state_dir: str, vm_name: str) -> str:
"""删宿主侧 host 私钥文件(feat-chv-secfix,H5):seed
盘建好后宿主私钥已无存在必要(客机已有私钥、宿主只需
公钥 + known_hosts 做校验)。删失败即 OSError,由调用
@@ -70,7 +70,7 @@ def delete_host_private_key(state_dir: str, vm_name: str) -> None:
return path
def known_hosts_line(port, public_key_text, host="127.0.0.1"):
def known_hosts_line(port: int, public_key_text: str, host: str = "127.0.0.1") -> str:
"""公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。
公钥文件形如 'ssh-ed25519 AAAA... [comment]',known_hosts
@@ -88,7 +88,7 @@ def seed_disk_path(state_dir: str, vm_name: str) -> str:
return os.path.join(state_dir, vm_name, "seed.iso")
def build_seed_disk(seed_dir: str, seed_disk: str, runner=None) -> None:
def build_seed_disk(seed_dir: str, seed_disk: str, runner: object = None) -> str:
"""把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的
注入载体):genisoimage -volid CIDATA -joliet -rock。
@@ -121,7 +121,7 @@ def build_seed_disk(seed_dir: str, seed_disk: str, runner=None) -> None:
return seed_disk
def build_seed_user_data(private_key_text, public_key_text):
def build_seed_user_data(private_key_text: str, public_key_text: str) -> str:
"""cloud-init user-data:把 host key 对写进客机 /etc/ssh。
注意:user-data 本身含私钥,只写进 per-VM seed 目录
@@ -156,8 +156,9 @@ def build_seed_user_data(private_key_text, public_key_text):
return "\n".join(lines) + "\n"
def write_seed(seed_dir, vm_name, private_key_text, public_key_text,
secret_env=None):
def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
public_key_text: str,
secret_env: dict | None = None) -> str:
"""写 seed 目录:meta-data + user-data(均为 0600)。
返回 seed 目录路径。seed 目录本身不含已知主机之外的
@@ -204,8 +205,9 @@ def write_seed(seed_dir, vm_name, private_key_text, public_key_text,
return seed_dir
def generate_host_keypair(state_dir, vm_name, runner=None,
keygen="ssh-keygen"):
def generate_host_keypair(state_dir: str, vm_name: str,
runner: object = None,
keygen: str = "ssh-keygen") -> dict:
"""为该 VM 生成(或复用本 VM 已有的)host key 对。
同一 VM 重启/恢复时复用已有对(否则钉死的 known_hosts
+142
View File
@@ -0,0 +1,142 @@
"""CHV 上一世残留 VM 清理(生产路径唯一入口)。
本模块是叶子模块,只依赖标准库,绝不 import
cloud.chv.experimental_pool(实验接口)与 cloud.sandbox。
生产代码(worker 启动/关闭)只调
reconcile_orphans_for_executor(),它直接读 registry 文件、
对每个上一世条目调 executor.shutdown_vm(),不构造任何
池对象、不调 prewarm/acquire/release。
Registry 语义与旧 WarmPool._load_registry 完全一致
(feat-chv-arch95plus 的 fail-safe 口径,本轨只搬位置、
不改语义):
- 文件:state_dir/warm-pool.json,内容为
{<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。
- 读到的 warm/assigned 条目一律当 orphan:旧 VMM 进程
已随旧 worker 消失,无法验证它还是当初那个干净快照
VM,绝不发给任务,只能清理。
- 损坏 fail-safe:文件不是合法 JSON / 顶层不是 dict 时,
整个文件 quarantine 成 <path>.corrupt,清理从空开始;
单条目结构非法(无 vm、或 state 不在 warm/assigned)
时只丢该条,不连坐其它条目。
- 清理后落盘为空 dict(原子 tmp + rename);写失败只记
日志警告,不炸 worker 启动/关闭。shutdown_vm 抛错时该
条目仍计入已清理(旧 reconcile 语义:尽力清理、不因
单条失败卡住整批),_emit 失败同样 best-effort。
"""
import json
import logging
import os
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
_REGISTRY_FILENAME = "warm-pool.json"
def registry_path_for_executor(executor: object) -> str | None:
"""执行器的 registry 路径:state_dir 下的 warm-pool.json。
无 state_dir 时返回 None(调用方按“无残留”处理)。"""
state_dir = getattr(executor, "state_dir", None)
if not state_dir:
return None
return os.path.join(state_dir, _REGISTRY_FILENAME)
def _quarantine_registry(path: str) -> None:
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力而为)。"""
try:
os.replace(path, path + ".corrupt")
except OSError:
_LOGGER.warning("chv_pool_registry_quarantine_failed "
"path=%s", path)
def _read_registry(path: str) -> tuple[dict | None, bool]:
"""读 registry 原始 dict:(data, corrupt)。
无文件 → (None, False);损坏 → quarantine 后 (None, True);
顶层非 dict 同样算损坏。调用方按 corrupt 决定是否记
registry_corrupt(实验池需要区分“无文件”与“损坏”)。"""
try:
with open(path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return None, False
except (OSError, ValueError):
_quarantine_registry(path)
return None, True
if not isinstance(data, dict):
_quarantine_registry(path)
return None, True
return data, False
def _write_registry(path: str, data: dict) -> None:
"""原子落盘 registry(tmp + rename);写失败只记日志。"""
try:
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(data, fh)
os.replace(tmp_path, path)
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s", path)
def _orphan_entries(data: dict) -> list[dict]:
"""从原始 registry dict 里筛出可清理的上一世条目。
只留 state 为 warm/assigned 且有 vm 的条目;非法条目
静默丢弃(不连坐)。返回条目为 {"vm", "task_id",
"snapshot_url"} 的新 dict,不改原数据。"""
entries: list[dict] = []
for entry in data.values():
if not isinstance(entry, dict):
continue
if not entry.get("vm"):
continue
if entry.get("state") not in ("warm", "assigned"):
continue
entries.append({
"vm": entry["vm"],
"task_id": entry.get("task_id"),
"snapshot_url": entry.get("snapshot_url"),
})
return entries
def reconcile_orphans_for_executor(executor: object) -> list[str]:
"""生产路径唯一的残留清理入口:返回已清理的 VM 名列表。
只做清理:读 registry → 每个上一世条目尽力
shutdown_vm → 落盘为空 → best-effort 审计事件。
绝不构造 WarmPool、绝不调 prewarm/acquire/release,
绝不把任何条目发给任务。无 registry 路径时返回 []。"""
path = registry_path_for_executor(executor)
if not path:
return []
data, _corrupt = _read_registry(path)
if data is None:
return []
entries = _orphan_entries(data)
cleaned: list[str] = []
for entry in entries:
vm = entry["vm"]
try:
executor.shutdown_vm(vm) # type: ignore[attr-defined]
except Exception:
pass
cleaned.append(vm)
if cleaned:
_write_registry(path, {})
try:
executor._emit( # type: ignore[attr-defined]
"warm_pool_reconcile", {"id": None},
"cleaned=%s" % ",".join(cleaned))
except Exception:
pass
return cleaned
+10 -6
View File
@@ -63,8 +63,9 @@ def split_secret_env(extra_env: dict) -> tuple:
return secrets, plain
def remote_command(inner_cmd, egress_proxy=None, extra_env=None,
guest_env_file=None):
def remote_command(inner_cmd: list, egress_proxy: str | None = None,
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造远程 shell 命令的 token 列表(不含 ssh 前缀)。
每个 token 都经 shlex.quote:ssh 客户端把 argv 用空格拼
@@ -108,9 +109,12 @@ def known_hosts_path(state_dir: str, vm_name: str) -> str:
return os.path.join(state_dir, vm_name, "known_hosts")
def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
known_hosts_file, inner_cmd, ssh_host="127.0.0.1",
extra_env=None, guest_env_file=None):
def build_ssh_command(ssh_port: int, ssh_key: str | None,
ssh_user: str, egress_proxy: str | None,
known_hosts_file: str, inner_cmd: list,
ssh_host: str = "127.0.0.1",
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<ssh_host> + 客内命令(远程 token 均经 shlex.quote,
见 remote_command)。
@@ -138,7 +142,7 @@ def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
"%s@%s" % (ssh_user, ssh_host)] + remote
def parse_keyscan_output(text, host="127.0.0.1"):
def parse_keyscan_output(text: str, host: str = "127.0.0.1") -> list:
"""从 ssh-keyscan 的 stdout 里挑出目标主机的 host key
行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时
的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、