fix(chv): V-7 复测缺陷复修 D1/D2 + F1 瞬时 reset 重试

D1:snapshot_vm 在配额预检后、ch-remote 前建好 file:// 目标
目录(新 helper _ensure_snapshot_dir;fresh URL 直接 snapshot
曾 rc=1 Destination is not a directory)。建目录只在 snapshot_vm
一处,hibernate 经此调用;配额拒绝仍先于任何文件改动。
D2:keep_disk=True 时 stop 保留 seed.iso(_remove_vm_seed_files
抽出;快照 config.json 的 disks 同时引用 disk.raw 与 seed.iso),
且 cleanup_stale_secrets 不清有登记快照的 VM 的 seed(与
gc_stale_disks 的盘保护同款逻辑);正常 stop 仍删两者。
F1:首条任务 SSH 撞 kex 阶段瞬时 reset(rc=255 + kex/reset 标记)
时重试恰好 1 次(_call_ssh_with_transient_retry,_run_guest 与
_run_guest_goal 共用;host key 拒绝/认证失败/远端失败不重试;
审计 vm_ssh_transient_retry)。§5“执行器内部不重试”的唯一例外,
结论写入 docs/chv-production-readiness.md §3/§5/§7。
测试:test_chv_v3_fixes 新增 12 项(D1×3、D2×4、F1×5);旧代码
上 12 项中 9 项失败(余 3 项为守卫既有行为)。全量 test_chv*
470 测试通过。
This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent 65504ae8cb
commit 1836c5af32
3 files changed
+388 -24

No files matched your search

+147 -22
View File
@@ -165,6 +165,52 @@ def _assert_snapshot_dir_persisted(snapshot_url):
"快照未落盘或为空目录:%s" % snapshot_url)
# SSH 连接建立阶段瞬时 reset 的 stderr 标记(V-7/F1):
# kex 阶段被 reset 时 OpenSSH 报 kex_exchange_identification
# + Connection reset,远端命令根本没开始执行。只认这组
# 标记:host key 拒绝(同为 rc=255)与远端命令失败不在此列。
_SSH_TRANSIENT_RESET_MARKERS = (
"kex_exchange_identification",
"Connection reset",
"Connection closed by",
)
def _is_transient_ssh_reset(result) -> bool:
"""单次 SSH 是否撞上连接建立阶段的瞬时 reset:returncode
255 + stderr 含 kex/reset 标记。stderr 可能是 bytes,
按文本解码后比对;无 stderr 即不认,不猜。"""
if getattr(result, "returncode", 0) != 255:
return False
stderr = getattr(result, "stderr", "") or ""
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", "replace")
return any(marker in stderr
for marker in _SSH_TRANSIENT_RESET_MARKERS)
def _ensure_snapshot_dir(snapshot_url, snapshot_path) -> None:
"""file:// 快照目标目录建好(V-7/D1):服务端要求目标是
已存在的目录,fresh URL 直接 snapshot 会 rc=1
(Destination is not a directory),mkdir 后同一 URL 即
通。非 file:// 目标(snapshot_path 为 None)跳过。目标
已存在且不是目录、或建目录失败时报 snapshot_not_persisted
(ch-remote 之前 fail-fast,不调 ch-remote)。"""
if snapshot_path is None:
return
if os.path.exists(snapshot_path) \
and not os.path.isdir(snapshot_path):
raise ChvFailure(
"snapshot_not_persisted",
"快照目标已存在且不是目录:%s" % snapshot_url)
try:
os.makedirs(snapshot_path, exist_ok=True)
except OSError as exc:
raise ChvFailure(
"snapshot_not_persisted",
"快照目录无法创建:%s:%s" % (snapshot_url, exc))
def _default_popen_launcher(cmd):
"""默认 VMM 启动器:独立会话子进程,输出丢弃(纯移动)。"""
return subprocess.Popen(
@@ -1334,6 +1380,36 @@ class CloudHypervisorExecutor(Executor):
return True
return False
def _call_ssh_with_transient_retry(self, task, ssh_cmd,
timeout=None):
"""任务 SSH 的连接建立阶段瞬时 reset 重试(V-7/F1,
执行器内部唯一允许的重试——§5“执行器内部不重试”
的例外,见 _run_with_vm 的注):
崩溃后重 wake 的首条任务 SSH 曾撞 1 次 kex 阶段
Connection reset(rc=255),下一条即通;与防火墙
冒烟第一轮 cloud-init 收尾重启 sshd 的瞬态故障同款
签名。wait_guest_ready 的轮询本就容忍这类抖动,但
首条任务 SSH 是单次调用:不重试就把一次秒级抖动
放大成 worker 级重试 = 整台 VM 冷启动重跑(分钟级
+ 重复计费)。故只对“连接未建立即 reset”(rc=255
+ kex/reset 标记,见 _is_transient_ssh_reset)重试
恰好 1 次,重试前 sleeper(1);第二次仍失败即原样
返回,不再追。host key 拒绝、认证失败、远端命令
失败一律不重试;误重试的最坏后果也被 host.py 兜住:
dispatch 同名任务已存在即 loud fail(task 已存在),
不会静默重跑一轮 turn。重试发 vm_ssh_transient_retry
审计。"""
result = self._call_runner(ssh_cmd, timeout=timeout)
if _is_transient_ssh_reset(result):
self._emit("vm_ssh_transient_retry", task,
"vm=%s ssh 瞬时 reset(kex 阶段),1 秒后重试 1 次"
% self.vm_name(task),
"ssh_transient_retry")
self.sleeper(1)
result = self._call_runner(ssh_cmd, timeout=timeout)
return result
def wait_guest_ready(self, task: dict) -> bool:
"""等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪;
每轮先尽力捕获 host key(keyscan 模式),超时先关 VM
@@ -1806,10 +1882,12 @@ class CloudHypervisorExecutor(Executor):
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
远端须显式放行)与数量/总量/保留期上限;成功后登
记并在总量超帽时按最旧优先剪枝(删动作审计)。"""
记并在总量超帽时按最旧优先剪枝(删动作审计)。
feat-chv-v3-repair(V-7/D1):file:// 目标目录在
配额预检后、ch-remote 前建好(见 _ensure_snapshot_dir)。"""
if not snapshot_url:
raise ValueError("snapshot_url 不能为空")
validate_snapshot_url(
snapshot_path = validate_snapshot_url(
snapshot_url, self.state_dir,
snapshot_dir=getattr(self, "snapshot_dir", None),
allow_remote=getattr(self, "allow_remote_snapshot", False))
@@ -1819,6 +1897,11 @@ class CloudHypervisorExecutor(Executor):
getattr(self, "snapshot_max_count", 10),
getattr(self, "snapshot_max_total_gb", 20.0),
getattr(self, "snapshot_retention_days", 30.0))
# feat-chv-v3-repair(V-7/D1):建目录放在配额预检
# 之后——配额拒绝先于任何文件改动,且目标是文件时
# 也不抢占配额错误的 kind;只在 snapshot_vm 这一处
# 建,hibernate_vm 经此调用,不重不漏。
_ensure_snapshot_dir(snapshot_url, snapshot_path)
self._ch_remote(task_or_name, "snapshot", snapshot_url)
# feat-chv-fix(P1-7)/feat-chv-v3:只查 returncode
# 不够,见 _assert_snapshot_dir_persisted——file://
@@ -1827,6 +1910,13 @@ class CloudHypervisorExecutor(Executor):
# 不 shutdown、不销毁原 VM;非 file:// 目标无法从宿
# 主验证落盘,仍只认 returncode,但文档明写该边界。
_assert_snapshot_dir_persisted(snapshot_url)
return self._record_snapshot_and_emit(
task_or_name, vm_name, snapshot_url)
def _record_snapshot_and_emit(self, task_or_name, vm_name,
snapshot_url):
"""快照成功后登记 + 总量超帽时最旧优先剪枝(删动作
审计)+ vm_snapshot 审计,返回 snapshot_url。"""
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
_entries, _removed = record_snapshot(
@@ -2010,7 +2100,14 @@ class CloudHypervisorExecutor(Executor):
留下的盘由 gc_stale_disks 的快照登记保护(有登记
快照的 VM 不 GC 盘),快照被剪干净后盘才成孤儿可删。
restore 成功后 VM 再走正常 stop,盘照常删,不留
长期残留。"""
长期残留。
feat-chv-v3-repair(V-7/D2):keep_disk=True 同样留
seed.iso——config.json 的 disks 同时引用 disk.raw 与
seed.iso(readonly),只留盘不留 seed.iso 时 restore
仍在服务端失败(Cannot open disk path …/seed.iso),
预检 snapshot_disk_missing 先拒掉不是过严;有登记
快照的 VM 的 seed.iso 也不被 cleanup_stale_secrets
当孤儿清掉(见 _clean_one_vm_secrets)。"""
self.pause_vm(task_or_name)
self.snapshot_vm(task_or_name, snapshot_url)
self.stop_vm(task_or_name, keep_disk=True)
@@ -2038,26 +2135,41 @@ class CloudHypervisorExecutor(Executor):
if alive:
self._kill_proc(proc)
def _remove_vm_seed_files(self, name, keep_disk):
"""删 seed 目录 + 宿主私钥 + seed.iso(stop 的第一块
清理,见 _cleanup_vm_files):keep_disk=True 时 seed.iso
留——快照 config.json 的 disks 含 seed.iso(readonly),
删了 restore 必在服务端失败(Cannot open disk path
…/seed.iso / No such file or directory),预检
snapshot_disk_missing 不是过严(V-7/D2)。seed 目录
与宿主私钥仍删,config.json 不引用它们。"""
seed_dir = os.path.join(self.state_dir, name, "seed")
if os.path.isdir(seed_dir):
shutil.rmtree(seed_dir)
for _suffix in ("ssh_host_ed25519_key",
"ssh_host_ed25519_key.pub",
"seed.iso"):
if keep_disk and _suffix == "seed.iso":
continue
_p = os.path.join(self.state_dir, name, _suffix)
if os.path.exists(_p):
os.remove(_p)
def _cleanup_vm_files(self, name, task, keep_disk=False):
"""stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。
任一块失败记对应审计并返回 True(脏标记由调用方写);
seed/私钥与磁盘删除的审计名与文案逐字不变。
keep_disk=True 时跳过任务盘删除(hibernate 专用:
快照 config.json 引用原盘路径,盘删了 restore 即
失败——见 hibernate_vm 的评估注释)。"""
keep_disk=True 时跳过任务盘删除,并跳过 seed.iso
删除(hibernate 专用:快照 config.json 引用原盘路径
与 seed.iso,任一删了 restore 即失败——见
hibernate_vm 的评估注释与 V-7/D2;seed 目录与宿主
私钥仍删,config.json 不引用它们,私钥明文已嵌进
seed.iso、宿主侧不留)。"""
from cloud.chv.hostkeys import dirty_marker_path # noqa: F401
failed = False
try:
seed_dir = os.path.join(self.state_dir, name, "seed")
if os.path.isdir(seed_dir):
shutil.rmtree(seed_dir)
for _suffix in ("ssh_host_ed25519_key",
"ssh_host_ed25519_key.pub",
"seed.iso"):
_p = os.path.join(self.state_dir, name, _suffix)
if os.path.exists(_p):
os.remove(_p)
self._remove_vm_seed_files(name, keep_disk)
except Exception as _cleanup_exc:
failed = True
self._emit("vm_key_cleanup_failed", task,
@@ -2127,7 +2239,9 @@ class CloudHypervisorExecutor(Executor):
feat-chv-v3(F2):新实例的 _vms 里无登记时,旧 VMM
也要按 socket 进程扫描清掉(_kill_vmm_by_socket),
否则 socket/磁盘已删、VMM 空壳进程仍活着占 tap。
keep_disk=True 时不删任务盘(hibernate 专用)。"""
keep_disk=True 时不删任务盘与 seed.iso(hibernate 专用,
见 hibernate_vm:快照 config.json 的 disks 同时引用
两者)。"""
name = self.vm_name(task_or_name)
socket_path = os.path.join(self.state_dir, name + ".sock")
with self._vms_lock:
@@ -2221,7 +2335,11 @@ class CloudHypervisorExecutor(Executor):
def _clean_one_vm_secrets(self, entry, vm_dir):
"""单 VM 残留清理:私钥一律删(live VM 私钥在 seed 盘
建好后即删,残留只可能是崩溃遗留);非 live VM 的
seed.iso/seed 目录同理删。返回 (删私钥, 删 seed)。"""
seed.iso/seed 目录同理删,但有登记快照的 VM 除外
(feat-chv-v3-repair,V-7/D2:那是休眠 VM 的 restore
输入,seed.iso 被快照 config.json 的 disks 引用,与
gc_stale_disks 的盘保护同款逻辑;私钥仍删,restore
不读宿主私钥文件)。返回 (删私钥, 删 seed)。"""
removed_priv = False
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
if os.path.exists(priv):
@@ -2231,7 +2349,8 @@ class CloudHypervisorExecutor(Executor):
except Exception:
pass
removed_seed = False
if not self._is_live_vm(entry):
if not self._is_live_vm(entry) \
and not has_recorded_snapshots(self.state_dir, entry):
for _suffix in ("seed.iso", "seed"):
_p = os.path.join(vm_dir, _suffix)
try:
@@ -2247,7 +2366,9 @@ class CloudHypervisorExecutor(Executor):
def cleanup_stale_secrets(self) -> list:
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;返回已清理 VM 名。worker 启动与
同理删并审计;有登记快照的 VM 的 seed 不删(V-7/D2,
休眠 VM 的 restore 输入,见 _clean_one_vm_secrets);
返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
cleaned: list = []
try:
@@ -2368,7 +2489,8 @@ class CloudHypervisorExecutor(Executor):
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)
result = self._call_ssh_with_transient_retry(
task, ssh_cmd, timeout=self.task_timeout)
# 方法内 import:见模块头注释的循环导入说明。
from cloud.sandbox import SimpleNamespaceShim, _parse_host_result
parsed = _parse_host_result(task["id"],
@@ -2403,7 +2525,8 @@ class CloudHypervisorExecutor(Executor):
extra_env=extra_env)
return self._goal_result(
task["id"],
self._call_runner(ssh_cmd, timeout=self.task_timeout))
self._call_ssh_with_transient_retry(
task, ssh_cmd, timeout=self.task_timeout))
def _vm_proc_dead(self, task_or_name):
"""该 VM 的登记进程是否已死:只认本实例登记的进程
@@ -2481,7 +2604,9 @@ class CloudHypervisorExecutor(Executor):
wake 失败时不跑
任务、直接抛分类失败。本方法不做内部重试:重试是
worker 的 max_retries 语义,执行器内部重试会把一次
失败放大成多次计费与多次 VM 启动。
失败放大成多次计费与多次 VM 启动。唯一例外:首条任务 SSH
的连接建立阶段瞬时 reset 由 _run_guest 经
_call_ssh_with_transient_retry 重试 1 次(传输抖动,非任务重试)。
中途死亡分类(本轨新增):客内命令抛传输异常、或
非 goal 结果已 failed 时,若登记的 VMM 进程已死,