fix(chv): 生产级审核 P1-01~P1-10 修复(堆叠于 #270 之上)

This commit is contained in:
muse-agent committed 2026-11-02 03:25:49 +00:00
1 parent 8c8d62f70d
commit 971e054938
12 files changed
+1545 -120

No files matched your search

+193 -47
View File
@@ -55,6 +55,7 @@ from cloud.chv.hardening import (
validate_net_entries,
validate_snapshot_url,
)
from cloud.chv.locking import StateLock
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 (
@@ -299,6 +300,7 @@ class CloudHypervisorExecutor(Executor):
sleeper: "Callable[..., object]"
audit_callback: object
audit_failure_count: int
_state_lock: StateLock
_vms: dict
_guest_env_delivered: dict
@@ -365,6 +367,11 @@ class CloudHypervisorExecutor(Executor):
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
# “wake 没投递、执行时却按名 source 一个旧文件”)。
self._guest_env_delivered = {}
# feat-chv-p1fix(P1-05):容量/net/磁盘 check→act
# 与 purge/cleanup 的跨进程锁(flock,见 locking.py)。
# 只在 wake/restore/cleanup 时加锁,不在构造时建锁
# 文件(构造本身不应有 state_dir 写副作用)。
self._state_lock = StateLock(self.state_dir)
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
@@ -465,16 +472,23 @@ class CloudHypervisorExecutor(Executor):
# P2-6:配置校验时机统一到 preflight。CPU 非整数、内存
# 字符串非法过去只在 build_vm_config 才炸,现在 preflight
# 一次报出(kind=config_invalid)。
# feat-chv-p1fix(P1-07):构造时校验拦不住“属性事后
# 改写/注入”的非法值,preflight 这里再验 >0——CPU
# 0/负、内存 0、任务超时 0/负一律干净 preflight 失败,
# 不进 VmConfig、不等 VMM 侧才炸。
try:
int(self.cpus)
if int(self.cpus) <= 0:
raise ValueError("CHV_CPU")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_CPU 必须是整数:%r" % (self.cpus,)))
"CHV_CPU 必须是大于 0 的整数:%r"
% (self.cpus,)))
try:
_parse_memory_bytes(self.memory)
if _parse_memory_bytes(self.memory) <= 0:
raise ValueError("CHV_MEM")
except ValueError:
missing.append(("config_invalid",
"CHV_MEM 格式非法:%r" % (self.memory,)))
"CHV_MEM 格式非法或为 0:%r" % (self.memory,)))
self._preflight_numeric_checks(missing)
def _preflight_numeric_checks(self, missing: list) -> None:
"""preflight 数值面:端口/配额/超时/快照统一校验,纯移动。
@@ -513,6 +527,7 @@ class CloudHypervisorExecutor(Executor):
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_*_TIMEOUT/INTERVAL 必须是正数"))
self._preflight_task_timeout_check(missing)
try:
if int(getattr(self, "warm_pool_size", 0)) < 0:
raise ValueError("warm_pool_size")
@@ -520,6 +535,19 @@ class CloudHypervisorExecutor(Executor):
missing.append(("config_invalid",
"CHV_WARM_POOL_SIZE 必须是非负整数"))
self._preflight_snapshot_checks(missing)
def _preflight_task_timeout_check(self, missing: list) -> None:
"""preflight 任务超时面(feat-chv-p1fix,P1-07 提取:
保持 _preflight_numeric_checks 在 50 行硬门禁内)。
None = 不限(旧语义);显式 0/负数即 preflight 失败。"""
try:
_task_timeout = getattr(self, "task_timeout", None)
if _task_timeout is not None \
and float(_task_timeout) <= 0:
raise ValueError("task_timeout")
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_TASK_TIMEOUT 必须是大于 0 的数字"))
def _preflight_snapshot_checks(self, missing: list) -> None:
"""preflight 快照/host-key 面:统一校验,纯移动。"""
try:
@@ -815,13 +843,15 @@ class CloudHypervisorExecutor(Executor):
public_text: str) -> None:
"""写预置 known_hosts(to10 提取)。主机部分跟 SSH
目标走(客机 IP 时不再写死回环,否则钉死校验永远
对不上);解析失败时回环仅作钉死写入,不放宽校验。"""
对不上)。feat-chv-p1fix(P1-04 同款):解析失败即
抛 ChvFailure,绝不回落回环写钉死——回环钉死会让
SSH 校验钉在一个“解析失败后猜出来的目标”上;
wake 路径在 provision 之前已在 _wake_prechecks 里
严格解析过,此处失败只发生在直接调用时,抛错是
正确语义。"""
from cloud.chv.hostkeys import known_hosts_line
from cloud.chv.ssh_transport import write_known_hosts
try:
ssh_host = self.resolve_ssh_host()
except ChvFailure:
ssh_host = "127.0.0.1"
ssh_host = self.resolve_ssh_host()
write_known_hosts(
paths["known_hosts"],
[known_hosts_line(self.ssh_port, public_text,
@@ -921,12 +951,14 @@ class CloudHypervisorExecutor(Executor):
即 CHV + 网关密钥现在要求 provisioned 模式(已是
默认),keyscan 模式下的网关任务 fail-closed,不再
把密钥值拼进 argv 将就跑。"""
try:
ssh_host = self.resolve_ssh_host()
except ChvFailure:
# 纯命令构造(旧测试/干跑)不炸:回环缺省仅作
# 构造兼容,wake/wait 路径仍严格解析。
ssh_host = "127.0.0.1"
# feat-chv-p1fix(P1-04):删除旧回环回落——构造失败
# 即抛 ssh_target_undeclared,调用方无法再把“显式
# 回环”与“解析失败后回落”混为一谈。显式回环仍可用:
# 设 CHV_SSH_HOST=127.0.0.1,或显式宣称
# CHV_SSH_FORWARD=1;wake/wait 路径本来就严格解析,
# 本改动只收紧公开构造方法,不放宽任何严格路径,
# 也没有新增 dry-run 例外参数(干跑同样不许猜目标)。
ssh_host = self.resolve_ssh_host()
secrets, plain = split_secret_env(extra_env)
guest_env_file = None
if secrets:
@@ -1065,6 +1097,13 @@ class CloudHypervisorExecutor(Executor):
names.discard(exclude)
return len(names)
def _state_lock_ctx(self) -> StateLock:
"""当前 state_dir 的 StateLock(state_dir 若被事后改写,
换用新路径的锁实例,避免锁在旧目录上)。"""
if self._state_lock.state_dir != self.state_dir:
self._state_lock = StateLock(self.state_dir)
return self._state_lock
def _check_capacity(self, exclude=None):
"""宿主容量门:持久化计数(见 _persistent_vm_count)
达到 max_vms 即明确拒绝(kind=host_capacity_exceeded,
@@ -1442,7 +1481,18 @@ class CloudHypervisorExecutor(Executor):
密钥子集在 provisioned 模式下写进 seed 客内文件,
keyscan 模式下有密钥子集即 fail-closed;起 VM 前的
preflight/容量/配额失败也发 vm_failed 审计(L3);
net 隔离在起 VMM 前校验(H7),成功后登记 net.json。"""
net 隔离在起 VMM 前校验(H7),成功后登记 net.json。
feat-chv-p1fix(P1-05):全程持 state_dir flock——
容量/net/磁盘的 check→act 只有在“查完立即占位、
占位前不放行”时才成立;锁提前释放会在 socket 尚
未绑定前留出计数窗口,仍可超限。串行化代价只落在
wake/restore/cleanup 之间,已运行 VM 的任务执行
(_run_guest)不持此锁。"""
with self._state_lock_ctx():
return self._wake_vm_locked(task, extra_env)
def _wake_vm_locked(self, task: dict, extra_env: dict | None = None) -> dict:
"""持 state 锁的 wake 本体(见 wake_vm)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
name = self.vm_name(task)
@@ -1499,19 +1549,48 @@ class CloudHypervisorExecutor(Executor):
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
远端须显式放行)与数量/总量/保留期上限;成功后登
记并在总量超帽时按最旧优先剪枝(删动作审计)。"""
记并在总量超帽时按最旧优先剪枝(删动作审计)。
feat-chv-p1fix(P1-08):保留期剪枝与总量帽剪枝的
删文件成功/失败均发审计;失败路径统一发
vm_snapshot_failed 审计再抛(此前失败路径无任何
快照失败审计,只有 wake 前置失败才有 vm_failed)。
本体拆到 _snapshot_create 以守 50 行硬门禁。"""
if not snapshot_url:
raise ValueError("snapshot_url 不能为空")
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
vm_name = self.vm_name(task_or_name)
try:
self._snapshot_create(task_or_name, task, vm_name,
snapshot_url)
except Exception as exc:
kind = getattr(exc, "kind", None) or type(exc).__name__
self._emit("vm_snapshot_failed", task,
"vm=%s snapshot=%s kind=%s error=%s"
% (vm_name, snapshot_url, kind, exc), kind)
raise
self._emit("vm_snapshot", task,
"vm=%s snapshot=%s"
% (self.vm_name(task_or_name), snapshot_url))
return snapshot_url
def _snapshot_create(self, task_or_name, task, vm_name,
snapshot_url) -> None:
"""快照创建段:白名单→上限/保留期剪枝→ch-remote→
落盘校验→登记/总量剪枝(见 snapshot_vm)。"""
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)
allow_remote=getattr(
self, "allow_remote_snapshot", False))
prune_events: list = []
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))
getattr(self, "snapshot_retention_days", 30.0),
events=prune_events)
self._emit_prune_events(task, vm_name, prune_events, "保留期")
self._ch_remote(task_or_name, "snapshot",
"source_url=%s" % snapshot_url)
# feat-chv-fix(P1-7):只查 returncode 不够。file://
@@ -1526,19 +1605,39 @@ class CloudHypervisorExecutor(Executor):
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(
record_events: list = []
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
getattr(self, "snapshot_max_total_gb", 20.0),
events=record_events)
self._emit_prune_events(task, vm_name, record_events,
"总量超帽")
def _emit_prune_events(self, task, vm_name, events, reason) -> None:
"""剪枝事件转审计(feat-chv-p1fix,P1-08):成功删
文件与登记剪枝发 vm_snapshot_pruned,删失败发
vm_snapshot_prune_failed;无文件可删的登记剪枝只
发 pruned(删动作本身不存在,无失败可言)。"""
deleted = 0
failed = 0
for status, entry, error in events:
url = entry.get("url") if isinstance(entry, dict) else None
if status == "delete_failed":
failed += 1
self._emit("vm_snapshot_prune_failed", task,
"vm=%s pruned snapshot=%s reason=%s "
"error=%s" % (vm_name, url, reason, error))
else:
deleted += 1
self._emit("vm_snapshot_pruned", task,
"vm=%s pruned snapshot=%s reason=%s "
"deleted=%s" % (
vm_name, url, reason,
status == "deleted"))
if failed:
_LOGGER.warning("chv_snapshot_prune_failed vm=%s "
"reason=%s failed=%d", vm_name, reason,
failed)
def _validate_restore(self, task, snapshot_url):
"""restore 前置校验:返回归一化后的 snapshot_url。
@@ -1588,7 +1687,14 @@ class CloudHypervisorExecutor(Executor):
resume → 等 SSH 就绪。恢复后默认 paused,不 resume
就不算醒。任一步失败都关 VM 再抛;成功返回
{vm, api_socket, state: "running", ready: True,
mode: "restored"}。"""
mode: "restored"}。feat-chv-p1fix(P1-05):与 wake
同款,全程持 state 锁——restore 同样查容量、建 VM,
不持锁会与并发 wake 互相超限。"""
with self._state_lock_ctx():
return self._restore_vm_locked(task, snapshot_url)
def _restore_vm_locked(self, task: dict, snapshot_url: str) -> dict:
"""持 state 锁的 restore 本体(见 restore_vm)。"""
snapshot_url = self._validate_restore(task, snapshot_url)
name = self.vm_name(task)
self._check_capacity(exclude=name)
@@ -1781,15 +1887,22 @@ 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 目录同理删。返回 (删私钥, 删 seed,
删失败)。
feat-chv-p1fix(P1-03):删失败不再静默 pass——
第三元组就是失败标志,调用方据此发审计、写脏标记,
删失败的 VM 绝不进 cleaned(调用方可区分成功与失败)。
"""
removed_priv = False
failed = False
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
if os.path.exists(priv):
try:
os.remove(priv)
removed_priv = True
except Exception:
pass
failed = True
removed_seed = False
if not self._is_live_vm(entry):
for _suffix in ("seed.iso", "seed"):
@@ -1802,13 +1915,18 @@ class CloudHypervisorExecutor(Executor):
os.remove(_p)
removed_seed = True
except Exception:
pass
return removed_priv, removed_seed
failed = True
return removed_priv, removed_seed, failed
def cleanup_stale_secrets(self) -> list:
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
cleanup_stale_vms 时调它,不依赖 socket 是否存在。
feat-chv-p1fix(P1-03):删失败的 VM 不进 cleaned,
另发 host_secret_residue_delete_failed 审计并写脏
标记(下次 provision_host_key 见标记即拒起,见
vm_dirty_refused),直至人工清理——最敏感物残留
不再“重试前不可观测”。"""
cleaned: list = []
try:
entries = os.listdir(self.state_dir)
@@ -1820,9 +1938,18 @@ class CloudHypervisorExecutor(Executor):
vm_dir = os.path.join(self.state_dir, entry)
if not os.path.isdir(vm_dir):
continue
removed_priv, removed_seed = \
removed_priv, removed_seed, failed = \
self._clean_one_vm_secrets(entry, vm_dir)
if removed_priv or removed_seed:
if failed:
self._emit("host_secret_residue_delete_failed",
{"id": entry},
"vm=%s private_key_removed=%s "
"seed_removed=%s" % (
entry, removed_priv, removed_seed))
self._write_dirty_marker(entry, True)
# 删失败时即使部分删掉了,也不进 cleaned:残留仍在,
# “已清理”名单里绝不出现未清干净的 VM。
if not failed and (removed_priv or removed_seed):
cleaned.append(entry)
self._emit("host_secret_residue_cleaned",
{"id": entry},
@@ -1865,7 +1992,9 @@ class CloudHypervisorExecutor(Executor):
def _stop_dead_socket_vms(self, entries):
"""socket 扫描:登记进程仍活的跳过,其余走 stop_vm。
返回已清理 VM 名;非 chv- 前缀 socket 一律跳过。"""
返回已清理 VM 名;非 chv- 前缀 socket 一律跳过。
feat-chv-p1fix(P1-05):登记进程的读持 _vms_lock,
不再无锁读 dict(与 purge/注册并发会读到中间态)。"""
cleaned = []
for entry in entries:
if not entry.endswith(".sock"):
@@ -1873,7 +2002,8 @@ class CloudHypervisorExecutor(Executor):
name = entry[:-len(".sock")]
if not name.startswith("chv-"):
continue
proc = self._vms.get(name)
with self._vms_lock:
proc = self._vms.get(name)
if proc is not None:
try:
alive = proc.poll() is None
@@ -1887,20 +2017,36 @@ class CloudHypervisorExecutor(Executor):
def _purge_dead_registry(self):
"""登记进程已死但 socket 已被删的登记项顺手清掉
(没有 socket 可删,不计入清理数)。"""
for name in list(self._vms.keys()):
proc = self._vms.get(name)
(没有 socket 可删,不计入清理数)。
feat-chv-p1fix(P1-05):先在锁内快照键列表,再
逐项在锁内读/删——旧“无锁 pop 与持锁遍历并发”
会致 dictionary changed size during iteration,
且 pop 本身无锁。"""
with self._vms_lock:
names = list(self._vms.keys())
for name in names:
with self._vms_lock:
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)
with self._vms_lock:
self._vms.pop(name, None)
def cleanup_stale_vms(self) -> list:
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
socket 扫描与登记清理。只清 socket 残留与已死进程
对应的 VM;state_dir 不存在返回 [],绝不抛异常。"""
对应的 VM;state_dir 不存在返回 [],绝不抛异常。
feat-chv-p1fix(P1-05):全程持 state 锁,与 wake/
restore 互斥——清理删盘/删 socket 时不许另一路
wake 同时为同名 VM 供盘、建 VM。"""
with self._state_lock_ctx():
return self._cleanup_stale_vms_locked()
def _cleanup_stale_vms_locked(self) -> list:
"""持 state 锁的 cleanup 本体(见 cleanup_stale_vms)。"""
try:
self.cleanup_stale_secrets()
except Exception: