fix(chv): 生产级审核 P1-01~P1-10 修复(堆叠于 #270 之上)
This commit is contained in:
1 parent
8c8d62f70d
commit
971e054938
12 files changed
+1545
-120
No files matched your search
+193
-47
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user