feat-chv-to10:往 10 分方向提升(类型/lint 清零、worker 适配抽取、审计落盘、lockfile 生成脚本、前端四态与统一文案、就绪清单 §13)

This commit is contained in:
host committed 2026-10-31 10:20:42 +00:00
1 parent df8c64bd64
commit 2f3ff8b3ec
22 files changed
+1400 -282

No files matched your search

+195 -80
View File
@@ -34,30 +34,35 @@ import shutil
import subprocess
import threading
import time
from collections.abc import Callable
from cloud.chv.api_client import _unix_socket_api
from cloud.chv.api_client import normalize_api_result
from cloud.executor_base import Executor
from cloud.chv.config import _parse_memory_bytes
from cloud.chv.config import build_chv_config
from cloud.chv.disk import check_disk_quota
from cloud.chv.disk import check_snapshot_limits
from cloud.chv.disk import delete_task_disk
from cloud.chv.disk import provision_task_disk
from cloud.chv.disk import record_snapshot
from cloud.chv.disk import task_disk_path
from cloud.chv.api_client import _unix_socket_api, normalize_api_result
from cloud.chv.config import _parse_memory_bytes, build_chv_config
from cloud.chv.disk import (
check_disk_quota,
check_snapshot_limits,
delete_task_disk,
provision_task_disk,
record_snapshot,
task_disk_path,
)
from cloud.chv.errors import ChvFailure
from cloud.chv.hardening import ensure_secure_state_dir
from cloud.chv.hardening import parse_net_entries
from cloud.chv.hardening import socket_is_live
from cloud.chv.hardening import validate_egress_proxy
from cloud.chv.hardening import validate_net_entries
from cloud.chv.hardening import validate_snapshot_url
from cloud.chv.hardening import (
ensure_secure_state_dir,
parse_net_entries,
socket_is_live,
validate_egress_proxy,
validate_net_entries,
validate_snapshot_url,
)
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 parse_keyscan_output
from cloud.chv.ssh_transport import split_secret_env
from cloud.chv.ssh_transport import write_known_hosts
from cloud.chv.ssh_transport import (
parse_keyscan_output,
split_secret_env,
write_known_hosts,
)
from cloud.executor_base import Executor
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
@@ -246,6 +251,57 @@ class CloudHypervisorExecutor(Executor):
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
"""
# to10:mypy 属性声明(仅注解、不赋值,运行时仍由
# _copy_config_fields/_init_runtime 抄写)。没有这组声明,
# 动态 setattr 会让 mypy 认为全部配置属性不存在(基线
# 115 个报错里近 90 个属此类),类型检查等于没跑。
binary: str | None
kernel: str | None
rootfs: str | None
cpus: str
memory: str
state_dir: str
ssh_key: str | None
ssh_user: str
ssh_port: int
guest_host_py: str
cmdline: str
net: str | None
net_json: str | None
kvm_path: str
cpuinfo_path: str
ch_remote: str
model: str | None
max_vms: int
disk_mode: str
disk_gb: float
egress_proxy: str | None
allow_direct_egress: bool
task_timeout: float | None
snapshot_url: str | None
warm_pool_size: int
api_timeout: float
ready_timeout: float
ready_interval: float
host_key_mode: str
ssh_host: str | None
ssh_forward: bool
reuse_experiment: bool
single_tenant_project: str | None
snapshot_max_count: int
snapshot_max_total_gb: float
snapshot_retention_days: float
snapshot_dir: str | None
allow_remote_snapshot: bool
runner: "Callable[..., object]"
launcher: "Callable[..., object]"
api_client: "Callable[..., object]"
sleeper: "Callable[..., object]"
audit_callback: object
audit_failure_count: int
_vms: dict
_guest_env_delivered: dict
def __init__(self, binary: str | None = None,
kernel: str | None = None,
rootfs: str | None = None,
@@ -253,7 +309,8 @@ class CloudHypervisorExecutor(Executor):
cpus: str | int | None = None,
memory: str | None = None,
state_dir: str | None = None,
runner: object = None, launcher: object = None,
runner: Callable[..., object] | None = None,
launcher: Callable[..., object] | None = None,
ssh_key: str | None = None,
ssh_user: str | None = None,
ssh_port: int | str | None = None,
@@ -265,12 +322,13 @@ class CloudHypervisorExecutor(Executor):
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,
api_client: Callable[..., object] | None = None,
sleeper: Callable[..., object] | None = 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
self._env: dict = env if env is not None else os.environ # type: ignore[assignment]
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
# 见 config.py 头注释)。字段名与原属性一一对应,旧
@@ -291,7 +349,15 @@ class CloudHypervisorExecutor(Executor):
self._copy_config_fields(_cfg)
self._init_runtime(runner, launcher, api_client, sleeper)
def _init_runtime(self, runner, launcher, api_client, sleeper) -> None:
def _init_runtime(self, runner: Callable[..., object] | None,
launcher: Callable[..., object] | None,
api_client: Callable[..., object] | None,
sleeper: Callable[..., object] | None) -> None:
# to10:runner/launcher/api_client/sleeper 均为可调用注入对象;
# 形参保持 object 以兼容旧测试的任意 lambda,调用点用
# getattr 可调用检查后调用(见 _call_runner 等),mypy
# 的 object-not-callable 在 _call_runner 处以 Callable 局部
# 变量处理。
"""运行时注入:seed 投递集合、API 客户端、睡眠器、
runner/launcher 默认值(纯移动)。"""
# feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM
@@ -346,7 +412,11 @@ class CloudHypervisorExecutor(Executor):
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
扫描 socket 文件名时传的就是 chv- 名,二次加前缀会
把 chv-dead 算成 chv-chv-dead、永远清不掉)。"""
raw = task_or_name.get("id") if isinstance(task_or_name, dict) \
# to10:id 按契约是 str;cast 只给 mypy 用,运行时原样
# 传给 re.sub(id 缺失时仍按旧行为 TypeError,不静默
# 编成 "None")。
from typing import cast as _cast
raw = _cast(str, task_or_name.get("id")) if isinstance(task_or_name, dict) \
else str(task_or_name)
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
if safe.startswith("chv-"):
@@ -589,7 +659,7 @@ class CloudHypervisorExecutor(Executor):
- egress_not_configured:CHV_EGRESS_PROXY 未设且未显式
允许直连(默认 fail-closed,VM 出口必须过白名单代理)
"""
missing = [] # (kind, 文本) 列表,全部收集完再一次抛
missing: list = [] # (kind, 文本) 列表,全部收集完再一次抛
self._preflight_host_checks(missing)
self._preflight_file_checks(missing)
self._preflight_net_checks(missing)
@@ -637,6 +707,34 @@ class CloudHypervisorExecutor(Executor):
self, "audit_failure_count", 0) + 1
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
event, kind, exc)
# to10(ADR-14):回调失败的事件再落盘一份——
# 宿主日志可能轮转、计数只剩数字,落盘文件是
# 唯一能事后找回“哪次事件丢了”的取证。落盘
# 本身 best-effort,失败只记日志,绝不炸任务。
self._spill_audit(event, task, detail, kind)
def _spill_audit(self, event, task, detail, kind) -> None:
"""审计失败事件落盘(to10,见 ADR-14 取舍):
state_dir/chv-audit-spill.jsonl,每行一 JSON
{ts, event, kind, detail, task_id};文件超 1MB 时
先轮转成 .old 再写。detail 沿用 _emit 契约,绝不
含密钥值。落盘不是 DB 替代:timeline 查询看不到它,
需宿主人工查阅;轮转只留最新两代,旧事件会丢。"""
try:
path = os.path.join(self.state_dir,
"chv-audit-spill.jsonl")
if os.path.exists(path) \
and os.path.getsize(path) > 1024 * 1024:
os.replace(path, path + ".old")
task_id = task.get("id") if isinstance(task, dict) \
else str(task)
line = json.dumps({"ts": time.time(), "event": event,
"kind": kind, "detail": detail,
"task_id": task_id})
with open(path, "a") as fh:
fh.write(line + "\n")
except Exception as exc:
_LOGGER.error("chv_audit_spill_failed error=%s", exc)
# -- 命令构造(纯函数,不做文件校验) -------------------------------
def _build_vm_command(self, task):
@@ -671,7 +769,7 @@ class CloudHypervisorExecutor(Executor):
return _known_hosts_path(self.state_dir,
self.vm_name(task_or_name))
def _net_guest_ip(self):
def _net_guest_ip(self) -> str | None:
"""VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。"""
if not self.net_json:
return None
@@ -691,8 +789,9 @@ class CloudHypervisorExecutor(Executor):
显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下
的回环;三者皆无时报 ChvFailure(ssh_target_undeclared),
绝不无条件假设 127.0.0.1:2222 能通。"""
if getattr(self, "ssh_host", None):
return self.ssh_host
_ssh_host = self.ssh_host
if _ssh_host:
return _ssh_host
guest_ip = self._net_guest_ip()
if guest_ip:
return guest_ip
@@ -704,6 +803,30 @@ class CloudHypervisorExecutor(Executor):
"里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 "
"CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口")
def _read_keypair_texts(self, paths: dict) -> tuple:
"""读 key 对文本(to10 提取,provision_host_key 降行用)。"""
with open(paths["public"], "r") as fh:
public_text = fh.read()
with open(paths["private"], "r") as fh:
private_text = fh.read()
return public_text, private_text
def _write_provisioned_known_hosts(self, paths: dict,
public_text: str) -> None:
"""写预置 known_hosts(to10 提取)。主机部分跟 SSH
目标走(客机 IP 时不再写死回环,否则钉死校验永远
对不上);解析失败时回环仅作钉死写入,不放宽校验。"""
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"
write_known_hosts(
paths["known_hosts"],
[known_hosts_line(self.ssh_port, public_text,
host=ssh_host)])
def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict:
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
@@ -718,10 +841,13 @@ class CloudHypervisorExecutor(Executor):
脏标记存在时直接拒绝(清理失败即拒起,不再复用
旧 key 对)。成功后审计 host_key_generated(只含
VM 名与密钥名,不含任何 key 值)。"""
# to10:known_hosts 写入已抽到 _write_provisioned_known_hosts,
# 此处只留生成与 seed 写入的 import(删后不再有未用 import)。
from cloud.chv.hostkeys import (
dirty_marker_path, generate_host_keypair,
known_hosts_line, write_seed)
from cloud.chv.ssh_transport import write_known_hosts
dirty_marker_path,
generate_host_keypair,
write_seed,
)
vm = self.vm_name(task)
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
raise ChvFailure(
@@ -732,20 +858,8 @@ class CloudHypervisorExecutor(Executor):
paths = generate_host_keypair(
self.state_dir, vm,
runner=lambda cmd: self._call_runner(cmd))
with open(paths["public"], "r") as fh:
public_text = fh.read()
with open(paths["private"], "r") as fh:
private_text = fh.read()
# feat-chv-fix:known_hosts 行的主机部分跟 SSH 目标走
# (客机 IP 时不再写死回环,否则钉死校验永远对不上)。
try:
ssh_host = self.resolve_ssh_host()
except ChvFailure:
ssh_host = "127.0.0.1"
write_known_hosts(
paths["known_hosts"],
[known_hosts_line(self.ssh_port, public_text,
host=ssh_host)])
public_text, private_text = self._read_keypair_texts(paths)
self._write_provisioned_known_hosts(paths, public_text)
seed_dir = write_seed(paths["seed_dir"], vm,
private_text, public_text,
secret_env=secret_env)
@@ -819,7 +933,7 @@ class CloudHypervisorExecutor(Executor):
from cloud.chv.hostkeys import GUEST_ENV_PATH
delivered = self._guest_env_delivered.get(
self.vm_name(task), set())
missing = [name for name in secrets if name not in delivered]
missing: list = [name for name in secrets if name not in delivered]
if missing:
raise ChvFailure(
"secret_env_not_delivered",
@@ -835,6 +949,27 @@ class CloudHypervisorExecutor(Executor):
guest_env_file=guest_env_file)
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def _vm_config_disks(self, task_disk: str | None,
seed_disk: str | None) -> list:
"""VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。
任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init
NoCloud 数据源);显式给的盘若不是基础镜像本身,
须在 state_dir 内,否则 VmConfig 不能挂进宿主任意文件。"""
# 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘
# “seed 磁盘须在…”(label 本身含“磁盘”与空格)。
for path, label in ((task_disk, "任务磁盘"),
(seed_disk, "seed 磁盘")):
if path and path != self.rootfs:
if not os.path.realpath(path).startswith(
os.path.realpath(self.state_dir) + os.sep):
raise ChvFailure(
"disk_path_not_allowed",
"%s须在 state_dir 内:%s" % (label, path))
disks: list = [{"path": task_disk or self.rootfs}]
if seed_disk:
disks.append({"path": seed_disk, "readonly": True})
return disks
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: 字节}、
@@ -847,36 +982,14 @@ class CloudHypervisorExecutor(Executor):
cpus = int(self.cpus)
except ValueError:
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
# feat-chv-secfix(L2):磁盘路径白名单。显式给的
# task_disk 若不是基础镜像本身(reuse 模式的合法
# 情形),须在 state_dir 内;seed 盘须在 state_dir
# 内——VmConfig 再也不能挂进宿主任意文件。
if task_disk and task_disk != self.rootfs:
if not os.path.realpath(task_disk).startswith(
os.path.realpath(self.state_dir) + os.sep):
raise ChvFailure(
"disk_path_not_allowed",
"任务磁盘须在 state_dir 内:%s" % task_disk)
if seed_disk:
if not os.path.realpath(seed_disk).startswith(
os.path.realpath(self.state_dir) + os.sep):
raise ChvFailure(
"disk_path_not_allowed",
"seed 磁盘须在 state_dir 内:%s" % seed_disk)
config = {
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
"memory": {"size": _parse_memory_bytes(self.memory)},
"kernel": {"path": self.kernel},
"cmdline": {"args": self.cmdline},
"disks": [{"path": task_disk or self.rootfs}],
"disks": self._vm_config_disks(task_disk, seed_disk),
"rng": {"src": "/dev/urandom"},
}
# feat-chv-fix(P0-1):seed 磁盘是 VmConfig 第二块
# 只读盘(CIDATA 卷标 ISO,客机 cloud-init NoCloud
# 数据源),不是宿主目录——目录本身从不进 VmConfig。
if seed_disk:
config["disks"].append({"path": seed_disk,
"readonly": True})
if self.net_json:
try:
net = json.loads(self.net_json)
@@ -1230,7 +1343,7 @@ class CloudHypervisorExecutor(Executor):
raise
return secrets, net_entries
def _provision_seed_disk(self, task: dict, name: str, secrets) -> str:
def _provision_seed_disk(self, task: dict, name: str, secrets) -> str | None:
"""provisioned 模式的 seed 盘供给:生成→制盘→挂盘,
任一步失败即抛(调用方 stop_vm 清理),纯移动。"""
seed_disk = None
@@ -1248,8 +1361,10 @@ class CloudHypervisorExecutor(Executor):
# 失败即 fail-fast(VM 尚未启动,此时拒绝最
# 安全,except 里 stop_vm 会清掉 seed)。
from cloud.chv.hostkeys import (
build_seed_disk, delete_host_private_key,
seed_disk_path)
build_seed_disk,
delete_host_private_key,
seed_disk_path,
)
provisioned = self.provision_host_key(
task, secret_env=secrets or None)
seed_disk = seed_disk_path(self.state_dir, name)
@@ -1498,8 +1613,8 @@ class CloudHypervisorExecutor(Executor):
self.pause_vm(task_or_name)
self.snapshot_vm(task_or_name, snapshot_url)
self.stop_vm(task_or_name)
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
# to10:删死赋值(task 建出后未用,F841;返回值直接
# 用 task_or_name 算 VM 名,行为不变)。
return {"vm": self.vm_name(task_or_name),
"state": "hibernated", "snapshot": snapshot_url}
@@ -1616,7 +1731,8 @@ class CloudHypervisorExecutor(Executor):
非 live(无 api-socket 文件且无登记活进程)即删盘
并审计;删失败审计 vm_disk_delete_failed 并计入
failed。返回 {deleted, failed}。"""
deleted, failed = [], []
deleted: list = []
failed: list = []
try:
entries = os.listdir(self.state_dir)
except Exception:
@@ -1693,7 +1809,7 @@ class CloudHypervisorExecutor(Executor):
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
cleaned = []
cleaned: list = []
try:
entries = os.listdir(self.state_dir)
except Exception:
@@ -1814,8 +1930,7 @@ class CloudHypervisorExecutor(Executor):
extra_env=extra_env)
result = self._call_runner(ssh_cmd, timeout=self.task_timeout)
# 方法内 import:见模块头注释的循环导入说明。
from cloud.sandbox import _parse_host_result
from cloud.sandbox import SimpleNamespaceShim
from cloud.sandbox import SimpleNamespaceShim, _parse_host_result
parsed = _parse_host_result(task["id"],
SimpleNamespaceShim(result))
parsed["executor"] = "cloud-hypervisor"