fix(chv): P2-20/P2-19/P2-17/P2-18 残留收尾 + NO_PROXY 合并

- P2-20:_guest_env_delivered 加专用锁,读写只走
  _delivered_secrets/_mark/_clear 三个入口,并发 hammer 测试钉死
- P2-19:_record_net_entries 失败发 net_registry_write_failed
  独立审计并返回 False;_boot_vm 见 False 即拒起(wake 随后清半醒 VM)
- P2-17:net.json 删失败发 vm_net_registry_delete_failed 审计;
  _write_dirty_marker 失败发 dirty_marker_write_failed 审计并返回
  False,stop 不抛的契约不变
- P2-18:新增 cloud/chv/atomic_file.py(mkstemp 随机名 + 文件
  fsync + rename + 目录 fsync),net.json/known_hosts/
  snapshots.json/pool registry/脏标记/spill 全部走它,固定 .tmp 清零
- H1 附带:NO_PROXY 实测确有覆盖 bug(env 重复赋值以后者为准,
  调用方 NO_PROXY 被 egress 的 localhost 值静默覆盖),改为合并
  单条发出;egress 已设时调用方代理变量(含小写)一律不发
- 测试:新增 cloud/tests/test_chv_residual.py(23 项),旧 closeup
  两处钉死旧语义的测试按新语义更新
This commit is contained in:
host committed 2026-11-06 18:41:50 +00:00
1 parent ba5df980bf
commit c39715659e
7 files changed
+850 -51

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
"""CHV 宿主侧小文件的原子写(P2-18 收尾)。
此前 net.json / snapshots.json / pool registry /
known_hosts 各自用固定名 `<path>.tmp` + 直接 os.replace,
存在两个问题:
1. 固定 tmp 名:同一进程内两个写者(或一次崩溃残留的
.tmp)会互相覆盖/误读;不同 VM 的文件虽在不同目录,
但同一目录内的快照登记与 net 登记仍共用可预测的
tmp 名,崩溃后残留的 .tmp 会被下一次写静默覆盖,
丢了“上次写了一半”的信号。
2. 无 fsync:os.replace 只保证目录项原子,不保证内容
已落盘;宿主断电/崩溃后新文件可能回到旧内容或
0 字节,而调用方已认为登记成功。
本模块是唯一写路径:mkstemp 随机名(0600)→ 写 →
flush + fsync 文件 → os.replace → fsync 父目录。
写失败时删临时文件再原样抛错,调用方按各自语义
处理(审计/拒绝/脏标记),本模块不吞错、不记日志。
"""
import os
import tempfile
def atomic_write(path: str, data: bytes, mode: int = 0o600) -> None:
"""原子写 bytes 到 path(随机 tmp 名 + 双 fsync)。
mode 是最终文件的权限(默认 0600:这些文件都在
0700 的 state_dir 内、且含登记/密钥类内容,按敏感
文件对待;调用方要放宽时显式传)。
"""
directory = os.path.dirname(path) or "."
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, path)
_fsync_dir(directory)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def atomic_write_text(path: str, text: str, mode: int = 0o600) -> None:
"""atomic_write 的 str 版(UTF-8 编码)。"""
atomic_write(path, text.encode("utf-8"), mode=mode)
def _fsync_dir(directory: str) -> None:
"""fsync 父目录,让 rename 本身落盘(best-effort:
某些文件系统不支持目录 fsync,失败即跳过——文件
内容的 fsync 已在 atomic_write 里做完,目录 fsync
丢了只影响“rename 是否在崩溃后可见”,不影响内容)。
"""
try:
dir_fd = os.open(directory, os.O_RDONLY)
except OSError:
return
try:
os.fsync(dir_fd)
except OSError:
pass
finally:
os.close(dir_fd)
+3 -4
View File
@@ -154,12 +154,11 @@ def _load_snapshots(state_dir, vm_name):
def _save_snapshots(state_dir, vm_name, entries):
import json
from cloud.chv.atomic_file import atomic_write_text
path = _snapshots_path(state_dir, vm_name)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(entries, fh)
os.replace(tmp_path, path)
# feat-chv-closeup(P2-18):随机 tmp 名 + 双 fsync。
atomic_write_text(path, json.dumps(entries))
def prune_snapshots(state_dir: str, vm_name: str,
+98 -20
View File
@@ -37,6 +37,7 @@ import time
from collections.abc import Callable
from cloud.chv.api_client import _unix_socket_api, normalize_api_result
from cloud.chv.atomic_file import atomic_write_text
from cloud.chv.config import _parse_memory_bytes, build_chv_config
from cloud.chv.disk import (
check_disk_quota,
@@ -366,6 +367,16 @@ class CloudHypervisorExecutor(Executor):
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
# “wake 没投递、执行时却按名 source 一个旧文件”)。
self._guest_env_delivered = {}
# feat-chv-closeup(P2-20):_guest_env_delivered 是
# 跨 wake/build_ssh_command/stop 的共享字典,wake 与
# 执行可并发在不同线程——此前无锁读写,set/get/pop
# 交错会丢投递记录(密钥被误判未投递而拒跑,或已清
# 的记录被误读为已投递)。专用锁护住全部访问(与
# _vms_lock 分开:投递记录与进程登记是两把不同的锁,
# 合一把会在 stop 路径上不必要地串行);所有访问点
# 必须走下面三个小方法,禁止直接读写字典(新方法
# 是唯一入口,回归测试钉死它)。
self._guest_env_lock = threading.Lock()
self.api_client = api_client if api_client is not None \
else _unix_socket_api
self.sleeper = sleeper if sleeper is not None else time.sleep
@@ -385,6 +396,25 @@ class CloudHypervisorExecutor(Executor):
self.launcher = _default_popen_launcher
self._vms = {}
def _delivered_secrets(self, name: str) -> set:
"""P2-20 唯一读入口:该 VM 已 seed 投递的密钥名集合。"""
with self._guest_env_lock:
return set(self._guest_env_delivered.get(name, set()))
def _mark_guest_env_delivered(self, name: str, secrets) -> None:
"""P2-20 唯一写入口:记下该 VM 已投递的密钥名集合
(secrets 为空即清记录,与旧语义一致)。"""
with self._guest_env_lock:
if secrets:
self._guest_env_delivered[name] = set(secrets)
else:
self._guest_env_delivered.pop(name, None)
def _clear_guest_env_delivered(self, name: str) -> None:
"""P2-20 唯一清除入口:stop 时清掉该 VM 的投递记录。"""
with self._guest_env_lock:
self._guest_env_delivered.pop(name, None)
def _copy_config_fields(self, _cfg) -> None:
"""把 build_chv_config 的字段抄到 self(纯移动,字段集不变)。"""
for _field in (
@@ -736,6 +766,11 @@ class CloudHypervisorExecutor(Executor):
"task_id": task_id})
with open(path, "a") as fh:
fh.write(line + "\n")
# feat-chv-closeup(P2-18):spill 是审计丢事
# 件后的唯一取证,写完即 fsync,否则宿主崩溃
# 后 spill 本身也可能丢。失败只记日志,不炸任务。
fh.flush()
os.fsync(fh.fileno())
except Exception as exc:
_LOGGER.error("chv_audit_spill_failed error=%s", exc)
@@ -942,8 +977,7 @@ class CloudHypervisorExecutor(Executor):
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())
delivered = self._delivered_secrets(self.vm_name(task))
missing: list = [name for name in secrets if name not in delivered]
if missing:
raise ChvFailure(
@@ -1296,18 +1330,34 @@ class CloudHypervisorExecutor(Executor):
return registered
def _record_net_entries(self, vm, entries):
"""wake 成功后登记本 VM 的 net 条目(best-effort 写,
失败只记日志——登记缺失的后果是漏判冲突,见上文)。"""
"""wake 成功后登记本 VM 的 net 条目(P2-19 收尾)。
返回 True=登记成功,False=登记失败。失败时三件事
同时做:warning 日志(旧行为保留)+ 独立审计事件
net_registry_write_failed(调用方与运维都能看见,
不再只有一条日志)+ 返回 False 让调用方感知。
调用方 _boot_vm 见 False 即拒绝启动(ChvFailure,
wake 随后 stop_vm 清掉半醒 VM):登记缺失的后果
是后来的 wake 漏判 tap/mac/ip 冲突(H7 的第二道
防线没了),继续启动等于带着缺口跑,不如不起;
state_dir 可写本就是 preflight 的 0700 门,走到
这里才写失败是真故障,不是预期内的可容忍情形。
写路径走 atomic_file(P2-18):随机 tmp 名 + 双 fsync。
"""
try:
vm_dir = os.path.join(self.state_dir, vm)
os.makedirs(vm_dir, exist_ok=True)
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"))
atomic_write_text(
os.path.join(vm_dir, "net.json"),
json.dumps(entries))
return True
except OSError as exc:
_LOGGER.warning("chv_net_registry_write_failed vm=%s "
"error=%s", vm, exc)
self._emit("net_registry_write_failed", None,
"vm=%s net 登记写失败:%s" % (vm, exc),
"net_registry_write_failed")
return False
def _check_api_socket_permissions(self, socket_path):
"""VMM api-socket 权限门(H6):socket 文件存在时,
@@ -1462,10 +1512,7 @@ class CloudHypervisorExecutor(Executor):
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._mark_guest_env_delivered(name, secrets)
return seed_disk
def _boot_vm(self, task, name, task_disk, seed_disk,
@@ -1485,7 +1532,13 @@ class CloudHypervisorExecutor(Executor):
self._emit("vm_create", task,
"vm=%s disk=%s" % (name, task_disk))
if net_entries:
self._record_net_entries(name, net_entries)
# feat-chv-closeup(P2-19):登记失败即拒起,
# 见 _record_net_entries(审计 + 返回 False)。
if not self._record_net_entries(name, net_entries):
raise ChvFailure(
"net_registry_write_failed",
"vm=%s net 登记写失败,拒绝启动(缺登记会"
"漏判 tap/mac/ip 冲突)" % name)
status, _ = self._api_call(socket_path, "PUT", "/vm.boot")
if status not in (200, 204):
raise ChvFailure(
@@ -1771,24 +1824,49 @@ class CloudHypervisorExecutor(Executor):
_net_path = os.path.join(self.state_dir, name, "net.json")
if os.path.exists(_net_path):
os.remove(_net_path)
except Exception:
except Exception as _net_exc:
# feat-chv-closeup(P2-17):net.json 删失败
# 此前只记 failed=True、无独立审计——删不掉的
# 登记会让后来的 wake 误判该 tap/mac/ip 仍被
# 占用(误拒)或与脏标记混在一起查不出;现发
# 独立审计 vm_net_registry_delete_failed。
failed = True
self._guest_env_delivered.pop(name, None)
self._emit("vm_net_registry_delete_failed", task,
"vm=%s error=%s" % (name, _net_exc))
self._clear_guest_env_delivered(name)
return failed
def _write_dirty_marker(self, name, failed):
"""M2 脏标记:清理失败写标记、成功清标记;写失败不炸关闭。"""
"""M2 脏标记:清理失败写标记、成功清标记(P2-17 收尾)。
返回 True=标记状态已如愿落盘(失败时标记已写、
成功时标记已清/本来就没有),False=写/删标记
本身失败。失败时发独立审计 dirty_marker_write_failed
+ error 日志,不再全静默——标记写不下去时拒起链
本身就不存在(provision/wake 读不到标记),部署
必须保证 state_dir 可写(preflight 的 0700 门)
并靠审计告警发现;stop 路径仍不抛错(stop_vm
“尽力而为、不抛异常”的契约不变,False 只作
调用方可观测的信号)。写路径走 atomic_file
(P2-18):随机 tmp 名 + 双 fsync。
"""
from cloud.chv.hostkeys import dirty_marker_path
try:
_marker = dirty_marker_path(self.state_dir, name)
if failed:
os.makedirs(os.path.dirname(_marker), exist_ok=True)
with open(_marker, "w") as fh:
fh.write("cleanup failed\n")
atomic_write_text(_marker, "cleanup failed\n")
elif os.path.exists(_marker):
os.remove(_marker)
except Exception:
pass
return True
except Exception as exc:
_LOGGER.error("chv_dirty_marker_write_failed vm=%s "
"failed=%s error=%s", name, failed, exc)
self._emit("dirty_marker_write_failed", None,
"vm=%s 脏标记写失败(failed=%s):%s"
% (name, failed, exc),
"dirty_marker_write_failed")
return False
def stop_vm(self, task_or_name: dict | str) -> bool:
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
+5 -5
View File
@@ -74,15 +74,15 @@ def _read_registry(path: str) -> tuple[dict | None, bool]:
def _write_registry(path: str, data: dict) -> None:
"""原子落盘 registry(tmp + rename);写失败只记日志。"""
"""原子落盘 registry(随机 tmp 名 + 双 fsync,P2-18);
写失败只记日志。"""
try:
from cloud.chv.atomic_file import atomic_write_text
import json
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)
atomic_write_text(path, json.dumps(data))
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s", path)
+64 -18
View File
@@ -44,6 +44,7 @@ StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
import os
import shlex
from cloud.chv.atomic_file import atomic_write_text
from cloud.chv.gitea_sync import (
GITEA_SYNC_PLAIN_KEYS,
GITEA_SYNC_SECRET_KEYS,
@@ -93,6 +94,63 @@ def split_guest_env(extra_env: dict | None) -> tuple[dict, dict]:
return guest_env, plain
def _merge_no_proxy(extra_env, egress_proxy):
"""调用方 NO_PROXY/no_proxy 与 egress 的 localhost、
127.0.0.1 去重合并(单条 NO_PROXY 的值)。"""
parts = []
if extra_env:
for key in ("NO_PROXY", "no_proxy"):
val = extra_env.get(key)
if val:
parts += [p.strip() for p in str(val).split(",")
if p.strip()]
merged = []
for part in parts:
if part not in merged:
merged.append(part)
if egress_proxy:
for part in ("localhost", "127.0.0.1"):
if part not in merged:
merged.append(part)
return merged
def _env_assignments(extra_env, egress_proxy):
"""env 前缀的赋值列表(feat-chv-closeup,H1 附带)。
NO_PROXY 覆盖顺序已核实并修掉:旧拼装是 extra_env 的
赋值在前、egress 的 NO_PROXY 在后,`env` 遇重复赋值以
后者为准(实测 env NO_PROXY=a NO_PROXY=b → b),调用方
传的 NO_PROXY 被 egress 的 localhost,127.0.0.1 静默覆盖。
现改为合并(见 _merge_no_proxy),单条 NO_PROXY 发出。
egress 代理本身须赢:egress 已设时调用方的
HTTPS_PROXY/HTTP_PROXY(含小写,curl 等工具读小写优先,
留着会架空 egress)一律不发出,只发 egress 的三条;
无 egress 时调用方的值原样保留(只去重,不丢)。"""
skip = {"NO_PROXY", "no_proxy"}
if egress_proxy:
skip |= {"HTTPS_PROXY", "HTTP_PROXY",
"https_proxy", "http_proxy"}
assignments = []
if extra_env:
for key in sorted(extra_env):
if key in skip:
continue
assignments.append("%s=%s"
% (key, shlex.quote(
str(extra_env[key]))))
merged = _merge_no_proxy(extra_env, egress_proxy)
if egress_proxy:
assignments += [
"HTTPS_PROXY=%s" % shlex.quote(egress_proxy),
"HTTP_PROXY=%s" % shlex.quote(egress_proxy),
"NO_PROXY=%s" % shlex.quote(",".join(merged))]
elif merged:
assignments += [
"NO_PROXY=%s" % shlex.quote(",".join(merged))]
return assignments
def remote_command(inner_cmd: list, egress_proxy: str | None = None,
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
@@ -104,7 +162,8 @@ def remote_command(inner_cmd: list, egress_proxy: str | None = None,
set +a`(把 seed 投递的密钥文件 source 进环境并 export),
再接 env 前缀与客内命令;文件路径本身不是密钥。
extra_env 含密钥名时 ValueError(调用方须先 split 出
密钥子集走 seed 投递,见 split_secret_env)。"""
密钥子集走 seed 投递,见 split_secret_env)。env 前缀的
拼装见 _env_assignments(NO_PROXY 合并、egress 优先)。"""
if extra_env:
secrets, _ = split_secret_env(extra_env)
if secrets:
@@ -115,17 +174,7 @@ def remote_command(inner_cmd: list, egress_proxy: str | None = None,
if guest_env_file:
tokens += ["set", "-a;", ".",
shlex.quote(guest_env_file) + ";", "set", "+a;"]
env_assignments = []
if extra_env:
for key in sorted(extra_env):
env_assignments.append("%s=%s"
% (key, shlex.quote(
str(extra_env[key]))))
if egress_proxy:
env_assignments += [
"HTTPS_PROXY=%s" % shlex.quote(egress_proxy),
"HTTP_PROXY=%s" % shlex.quote(egress_proxy),
"NO_PROXY=localhost,127.0.0.1"]
env_assignments = _env_assignments(extra_env, egress_proxy)
inner = [shlex.quote(str(arg)) for arg in inner_cmd]
if env_assignments:
tokens += ["env"] + env_assignments + inner
@@ -198,13 +247,10 @@ def parse_keyscan_output(text: str, host: str = "127.0.0.1") -> list:
def write_known_hosts(path: str, key_lines: list) -> None:
"""原子写 known_hosts:先写同目录临时文件再 rename,
"""原子写 known_hosts:随机 tmp 名 + fsync 后 rename
(feat-chv-closeup,P2-18:旧固定 `<path>.tmp` 无 fsync),
文件权限 0600(只含公钥行,仍按敏感文件对待)。"""
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
fh.write("\n".join(key_lines) + "\n")
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)
atomic_write_text(path, "\n".join(key_lines) + "\n", mode=0o600)