feat: CHV 执行器优化(拆模块/去重复/SSH 钉死/池持久化/中途死亡分类)
- 拆模块:CHV 从 cloud/sandbox.py 巨类拆到 cloud/chv/(executor/ pool/config/api_client/ssh_transport/disk/errors),sandbox 只留 兼容 re-export,旧导入路径与新路径是同一对象 - 去重复:goal 解析、host 顶层参数、runner 超时抽成共享 helper, Local 与 CHV 在矩阵输入下结论全等(Docker goal-resume 变体 有意保留内联版,见 PR 描述) - 安全:SSH host key 按 VM 钉死(state_dir/<vm>/known_hosts, ssh-keyscan 首次捕获 TOFU),废除 no + /dev/null;CHV_SSH_USER 空串与 CHV_SSH_PORT 越界直接拒绝,不静默 fallback - execute 语义:只读已运行 VM,未运行报 vm_not_running,不为 status 起 VM - 预热池:registry 落盘、重启载入只当孤儿、reconcile 清理、 损坏 quarantine;条目仍一次性,绝不复用脏 VM - 长任务:VM 中途死亡判 interrupted(断点可续跑);中途心跳 经评估不做(无客内进度信号可采) - #215 未合并:只列复用清单,不复制模块 测试:原 42 项 + 新 24 项全过;相关旧套件与 cloud 全量 1496 项 全过(skipped=4)。验证仍是 mock + 干跑 + 单元测试,未做真机、 压测、安全审计;堆叠于 #216 之上,base 为 feat-cloud-hypervisor。
This commit is contained in:
1 parent
c371bbdf1e
commit
fde7bf4c3e
14 files changed
+2706
-1408
No files matched your search
@@ -0,0 +1,18 @@
|
||||
"""Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。
|
||||
|
||||
公开名与 #216 一致,cloud/sandbox.py 会 re-export 它们,
|
||||
保证旧导入路径可用:
|
||||
- CloudHypervisorExecutor:执行器本体(executor.py)
|
||||
- WarmPool:预热池 + registry 持久化与孤儿清理(pool.py)
|
||||
- ChvFailure:分类失败(errors.py)
|
||||
|
||||
子模块分工见 executor.py 头注释。本包顶层 import 不触
|
||||
cloud.sandbox(执行器方法内按需 import 共享 helper),
|
||||
所以任何导入顺序都不会循环。
|
||||
"""
|
||||
|
||||
from cloud.chv.errors import ChvFailure
|
||||
from cloud.chv.executor import CloudHypervisorExecutor
|
||||
from cloud.chv.pool import WarmPool
|
||||
|
||||
__all__ = ["ChvFailure", "CloudHypervisorExecutor", "WarmPool"]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Cloud Hypervisor REST API 传输(叶子模块,不依赖
|
||||
cloud.sandbox,避免循环导入)。
|
||||
|
||||
只用标准库 raw HTTP over Unix socket,无第三方依赖。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
|
||||
|
||||
def _unix_socket_api(socket_path, method, path, payload=None):
|
||||
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP
|
||||
over Unix socket(无第三方依赖)。返回 (status, body),
|
||||
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在
|
||||
或连接失败时 raise RuntimeError(调用方据此判 api 超时
|
||||
/失败,不编造 200)。"""
|
||||
if not os.path.exists(socket_path):
|
||||
raise RuntimeError("api socket 不存在:%s" % socket_path)
|
||||
body = b""
|
||||
if payload is not None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req_lines = ["%s /api/v1%s HTTP/1.1" % (method, path),
|
||||
"Host: localhost",
|
||||
"Accept: application/json",
|
||||
"Content-Type: application/json",
|
||||
"Content-Length: %d" % len(body),
|
||||
"Connection: close", "", ""]
|
||||
raw = "\r\n".join(req_lines).encode("utf-8") + body
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
try:
|
||||
sock.connect(socket_path)
|
||||
sock.sendall(raw)
|
||||
chunks = []
|
||||
while True:
|
||||
try:
|
||||
data = sock.recv(65536)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
finally:
|
||||
sock.close()
|
||||
text = b"".join(chunks).decode("utf-8", errors="replace")
|
||||
status = 0
|
||||
if text.startswith("HTTP/"):
|
||||
try:
|
||||
status = int(text.split()[1])
|
||||
except (IndexError, ValueError):
|
||||
status = 0
|
||||
body_text = text.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in text \
|
||||
else ""
|
||||
try:
|
||||
parsed = json.loads(body_text) if body_text.strip() else {}
|
||||
except ValueError:
|
||||
parsed = body_text
|
||||
return status, parsed
|
||||
|
||||
|
||||
def normalize_api_result(result):
|
||||
"""API 调用归一化:fake api_client 可返回 (status, body)
|
||||
或 {"status":..., "body":...} 或带 .status 的对象,本
|
||||
函数统一成 (status:int, body)。"""
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
return int(result[0]), result[1]
|
||||
if isinstance(result, dict):
|
||||
status = result.get("status", result.get("status_code"))
|
||||
body = result.get("body", result)
|
||||
return (int(status) if status is not None else 0), body
|
||||
status = getattr(result, "status", None) \
|
||||
or getattr(result, "status_code", None)
|
||||
return (int(status) if status is not None else 0), result
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Cloud Hypervisor 执行器配置解析(叶子模块,不依赖
|
||||
cloud.sandbox,避免循环导入)。
|
||||
|
||||
职责只有两件:内存字符串解析,以及把构造参数 + 环境变量
|
||||
解析成一份显式配置。参数优先于环境变量、环境变量优先于
|
||||
默认值——与 #216 原 __init__ 的 `or` 链完全一致,只收紧了
|
||||
两处原来会静默 fallback 的 nonsense 输入(见下文),收紧点
|
||||
在 PR 描述里明写、单测钉住:
|
||||
|
||||
- CHV_SSH_USER 显式传空字符串:原来 `or` 链会静默 fallback
|
||||
成 root(调用方以为没给用户、实际跑了 root),现在直接
|
||||
ValueError。
|
||||
- CHV_SSH_PORT 显式传 0、或解析后超出 1-65535:原来 0 会
|
||||
静默 fallback 成 2222、其它越界值会原样进 ssh 命令,现在
|
||||
直接 ValueError。
|
||||
|
||||
与 #215 的关系(只列清单,不复制模块):#215 未合并,
|
||||
本轨不假装复用它。#215 合并后应再复用的点:超时类
|
||||
(api_timeout/ready_timeout/ready_interval/task_timeout →
|
||||
timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘
|
||||
配额门、egress fail-closed → gates 模块)、配额类
|
||||
(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、
|
||||
隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死
|
||||
→ sandbox_policy 模块)。见 PR 描述的复用清单。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def _parse_memory_bytes(value):
|
||||
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
|
||||
非法值 ValueError,绝不静默按默认算。"""
|
||||
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
|
||||
str(value))
|
||||
if not m:
|
||||
raise ValueError("CHV_MEM 格式非法:%r" % (value,))
|
||||
mult = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
|
||||
return int(m.group(1)) * mult[m.group(2).upper()]
|
||||
|
||||
|
||||
class ChvConfig:
|
||||
"""解析后的 CHV 配置:纯数据对象,字段名与
|
||||
CloudHypervisorExecutor 原 __init__ 的属性名一一对应,
|
||||
执行器 __init__ 直接把字段抄到 self 上,旧测试读属性
|
||||
的方式全部不变。"""
|
||||
|
||||
def __init__(self, **fields):
|
||||
for key, value in fields.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
def build_chv_config(params, env):
|
||||
"""把显式构造参数(params,None=未给)与环境变量(env,
|
||||
默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。
|
||||
|
||||
显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、
|
||||
磁盘配额、egress、快照、预热池目标数本来就没有显式参数,
|
||||
仍只走环境变量(与 #216 一致,不悄悄加新参数面)。"""
|
||||
env = env if env is not None else {}
|
||||
binary = params.get("binary") or env.get("CHV_BINARY") \
|
||||
or "cloud-hypervisor"
|
||||
kernel = params.get("kernel") or env.get("CHV_KERNEL")
|
||||
rootfs = params.get("rootfs") or params.get("image") \
|
||||
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE")
|
||||
cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2")
|
||||
memory = params.get("memory") or env.get("CHV_MEM") or "2G"
|
||||
state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \
|
||||
or "/tmp/chv-vms"
|
||||
ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY")
|
||||
if params.get("ssh_user") is not None \
|
||||
and params.get("ssh_user") == "":
|
||||
raise ValueError("CHV_SSH_USER 不能为空字符串")
|
||||
ssh_user = params.get("ssh_user") or env.get("CHV_SSH_USER") \
|
||||
or "root"
|
||||
if params.get("ssh_port") is not None:
|
||||
raw_port = params.get("ssh_port")
|
||||
else:
|
||||
raw_port = env.get("CHV_SSH_PORT") or 2222
|
||||
try:
|
||||
ssh_port = int(raw_port)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("CHV_SSH_PORT 必须是整数")
|
||||
if not 1 <= ssh_port <= 65535:
|
||||
raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间")
|
||||
guest_host_py = params.get("guest_host_py") or "/app/host.py"
|
||||
cmdline = params.get("cmdline") or env.get("CHV_CMDLINE") \
|
||||
or "console=hvc0 root=/dev/vda1 rw"
|
||||
net = params.get("net") or env.get("CHV_NET")
|
||||
net_json = params.get("net_json") or env.get("CHV_NET_JSON")
|
||||
kvm_path = params.get("kvm_path") \
|
||||
if params.get("kvm_path") is not None else "/dev/kvm"
|
||||
cpuinfo_path = params.get("cpuinfo_path") \
|
||||
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo"
|
||||
ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \
|
||||
or "ch-remote"
|
||||
model = params.get("model")
|
||||
try:
|
||||
max_vms = int(env.get("CHV_MAX_VMS") or 4)
|
||||
except ValueError:
|
||||
raise ValueError("CHV_MAX_VMS 必须是整数")
|
||||
disk_mode = env.get("CHV_DISK_MODE") or "copy"
|
||||
if disk_mode not in ("copy", "reuse"):
|
||||
raise ValueError(
|
||||
"CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode)
|
||||
disk_gb_raw = env.get("CHV_DISK_GB")
|
||||
disk_gb = float(disk_gb_raw) if disk_gb_raw else None
|
||||
egress_proxy = env.get("CHV_EGRESS_PROXY")
|
||||
allow_direct_egress = env.get("CHV_ALLOW_DIRECT_EGRESS") == "1"
|
||||
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
|
||||
task_timeout = float(task_timeout_raw) \
|
||||
if task_timeout_raw else None
|
||||
snapshot_url = env.get("CHV_SNAPSHOT_URL")
|
||||
try:
|
||||
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
|
||||
except ValueError:
|
||||
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
|
||||
try:
|
||||
api_timeout = float(
|
||||
params.get("api_timeout")
|
||||
if params.get("api_timeout") is not None
|
||||
else env.get("CHV_API_TIMEOUT") or 30)
|
||||
ready_timeout = float(
|
||||
params.get("ready_timeout")
|
||||
if params.get("ready_timeout") is not None
|
||||
else env.get("CHV_READY_TIMEOUT") or 120)
|
||||
ready_interval = float(
|
||||
params.get("ready_interval")
|
||||
if params.get("ready_interval") is not None
|
||||
else env.get("CHV_READY_INTERVAL") or 5)
|
||||
except ValueError:
|
||||
raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字")
|
||||
if ready_interval <= 0:
|
||||
ready_interval = 5
|
||||
return ChvConfig(
|
||||
binary=binary, kernel=kernel, rootfs=rootfs, cpus=cpus,
|
||||
memory=memory, state_dir=state_dir, ssh_key=ssh_key,
|
||||
ssh_user=ssh_user, ssh_port=ssh_port,
|
||||
guest_host_py=guest_host_py, cmdline=cmdline, net=net,
|
||||
net_json=net_json, kvm_path=kvm_path,
|
||||
cpuinfo_path=cpuinfo_path, ch_remote=ch_remote, model=model,
|
||||
max_vms=max_vms, disk_mode=disk_mode, disk_gb=disk_gb,
|
||||
egress_proxy=egress_proxy,
|
||||
allow_direct_egress=allow_direct_egress,
|
||||
task_timeout=task_timeout, snapshot_url=snapshot_url,
|
||||
warm_pool_size=warm_pool_size, api_timeout=api_timeout,
|
||||
ready_timeout=ready_timeout, ready_interval=ready_interval)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Cloud Hypervisor 每任务磁盘供给(叶子模块)。
|
||||
|
||||
单任务单 VM 的文件系统独立性就靠这里:copy 模式每任务
|
||||
复制一份基础镜像,两个任务绝不写同一文件;reuse 模式
|
||||
直接返回基础镜像路径(仅单租户实验,生产禁用,见
|
||||
CloudHypervisorExecutor 的类 docstring)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from cloud.chv.errors import ChvFailure
|
||||
|
||||
|
||||
def check_disk_quota(rootfs, disk_gb):
|
||||
"""磁盘配额门:CHV_DISK_GB 已设且基础镜像超过该 GB 数
|
||||
即拒绝(kind=disk_quota_exceeded)。未设则不检查;
|
||||
镜像读不到时不拦(preflight 自会报 image_missing,
|
||||
这里不抢它的错)。"""
|
||||
if disk_gb is None or not rootfs:
|
||||
return
|
||||
try:
|
||||
size = os.path.getsize(rootfs)
|
||||
except OSError:
|
||||
return
|
||||
if size > disk_gb * 1024 ** 3:
|
||||
raise ChvFailure(
|
||||
"disk_quota_exceeded",
|
||||
"磁盘镜像 %d 字节超过配额 %.1f GB" % (size, disk_gb))
|
||||
|
||||
|
||||
def provision_task_disk(state_dir, vm_name, rootfs, disk_mode):
|
||||
"""每任务磁盘路径:copy 模式复制到
|
||||
state_dir/<vm>/disk.raw(已存在则复用,不重复复制);
|
||||
reuse 模式直接返回基础镜像路径。"""
|
||||
if disk_mode == "reuse":
|
||||
return rootfs
|
||||
task_dir = os.path.join(state_dir, vm_name)
|
||||
os.makedirs(task_dir, exist_ok=True)
|
||||
task_disk = os.path.join(task_dir, "disk.raw")
|
||||
if not os.path.exists(task_disk):
|
||||
shutil.copyfile(rootfs, task_disk)
|
||||
return task_disk
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Cloud Hypervisor 执行器的分类失败(叶子模块,不依赖
|
||||
cloud.sandbox,避免循环导入)。"""
|
||||
|
||||
class ChvFailure(RuntimeError):
|
||||
"""Cloud Hypervisor 分类失败:RuntimeError 的子类(worker
|
||||
现有 except Exception 路径照常捕获),另带 kind 字段供
|
||||
审计与失败分类用,绝不笼统只写 failed。kind 取值见
|
||||
CloudHypervisorExecutor.preflight / wake_vm 的注释。"""
|
||||
|
||||
def __init__(self, kind, message):
|
||||
super().__init__("chv_failure:%s: %s" % (kind, message))
|
||||
self.kind = kind
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,257 @@
|
||||
"""快照预热池 WarmPool(含 state_dir registry 持久化与孤儿清理)。
|
||||
|
||||
隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的
|
||||
干净快照 VM,acquire 消耗掉它,release 一律销毁,绝不把
|
||||
一个任务用过的 VM(带任务文件、带 agent 痕迹)翻出来给
|
||||
下一个任务/租户复用;池子空了就回落冷启动(wake_vm),
|
||||
绝不因为池空就降级 Docker/Local。
|
||||
|
||||
Registry(本轨新增):
|
||||
- 文件:state_dir/warm-pool.json(registry_path 可覆盖),
|
||||
内容为 {<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。
|
||||
每次 prewarm/acquire/release 后立即落盘(临时文件 + 原子
|
||||
rename),worker 崩溃后重启仍能发现上一世的条目。
|
||||
- 载入时绝不复用:新池实例从 registry 读到的条目一律标为
|
||||
orphan(不计入 warm_count,acquire 绝不发给任务),因为
|
||||
旧 VMM 进程已随旧 worker 消失,无法验证它还是当初那个
|
||||
干净快照 VM。它们只能走 reconcile_orphans() 清理。
|
||||
- reconcile_orphans():worker 启动/清理时的显式入口,对
|
||||
每个 orphan/assigned 条目尽力调 executor.shutdown_vm
|
||||
(清 socket 与残留登记),条目清掉并落盘,返回已清理的
|
||||
VM 名列表。registry 里没有的残留 socket 仍由执行器的
|
||||
cleanup_stale_vms() 扫 state_dir 处理,两者互补,不互相
|
||||
替代;reconcile 绝不在构造时自动执行(同一 state_dir 只
|
||||
能有一个活池,构造时自动清理会误杀另一个活池的 VM)。
|
||||
- 损坏 fail-safe:registry 不是合法 JSON / 顶层不是 dict 时,
|
||||
整个文件 quarantine 成 <path>.corrupt(尽力而为),池子
|
||||
从空开始,绝不用损坏数据复用 VM;单条目结构非法时只
|
||||
丢该条(记 registry_corrupt_entries),不连坐其它条目。
|
||||
registry 写失败时只记日志警告、不炸任务流(池子内存态
|
||||
仍可用,重启后孤儿改由 socket 扫描兜底)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||||
|
||||
_REGISTRY_FILENAME = "warm-pool.json"
|
||||
|
||||
|
||||
class WarmPool:
|
||||
"""快照预热池(快速启动设计,接口与状态机在代码里)。
|
||||
|
||||
目标:冷启动(preflight + 起 VMM + create + boot + SSH
|
||||
就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务
|
||||
来时 acquire 直接 resume,省掉冷启动的大头。
|
||||
状态机:cold(未预热)→ prewarming(wake 中)→ warm
|
||||
(已暂停待分配)→ assigned(已分配给某任务)→
|
||||
running(已 resume)→ recycled/destroyed(release 后)。
|
||||
prewarm 的实现:对每个条目调 executor.wake_vm(冷启动)
|
||||
再 pause_vm,得到 paused 的干净 VM;mock 单测里 wake/
|
||||
pause 均可注入假实现,真机上这一步需要 KVM 宿主(见
|
||||
就绪清单“需 KVM 宿主实测”)。snapshot_url 未设时
|
||||
prewarm 返回 [](无基准快照可预热,不是静默造快照)。
|
||||
"""
|
||||
|
||||
def __init__(self, executor, size=None, snapshot_url=None,
|
||||
registry_path=None):
|
||||
self.executor = executor
|
||||
self.size = int(size if size is not None
|
||||
else getattr(executor, "warm_pool_size", 0))
|
||||
self.snapshot_url = snapshot_url \
|
||||
if snapshot_url is not None \
|
||||
else getattr(executor, "snapshot_url", None)
|
||||
self._entries = {} # 合成名 -> {"state", "vm", "task", ...}
|
||||
self.registry_path = registry_path
|
||||
if self.registry_path is None:
|
||||
state_dir = getattr(executor, "state_dir", None)
|
||||
if state_dir:
|
||||
self.registry_path = os.path.join(
|
||||
state_dir, _REGISTRY_FILENAME)
|
||||
self.registry_corrupt = False
|
||||
self.registry_corrupt_entries = 0
|
||||
self._load_registry()
|
||||
|
||||
# -- registry ------------------------------------------------------
|
||||
def _load_registry(self):
|
||||
"""载入 registry:读到的条目一律标 orphan(见模块
|
||||
docstring,绝不直接当 warm 发给任务)。无文件时为空
|
||||
池;损坏时 quarantine 并从空开始。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
try:
|
||||
with open(self.registry_path, "r") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
self.registry_corrupt = True
|
||||
self._quarantine_registry()
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
self.registry_corrupt = True
|
||||
self._quarantine_registry()
|
||||
return
|
||||
for name, entry in data.items():
|
||||
if not isinstance(entry, dict) \
|
||||
or not entry.get("vm") \
|
||||
or entry.get("state") not in ("warm", "assigned"):
|
||||
self.registry_corrupt_entries += 1
|
||||
continue
|
||||
self._entries[name] = {
|
||||
"state": "orphan",
|
||||
"vm": entry["vm"],
|
||||
"task": {"id": entry.get("task_id") or name},
|
||||
"snapshot_url": entry.get("snapshot_url"),
|
||||
}
|
||||
|
||||
def _quarantine_registry(self):
|
||||
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力
|
||||
而为),原路径不再留损坏数据给下一次载入。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
try:
|
||||
os.replace(self.registry_path,
|
||||
self.registry_path + ".corrupt")
|
||||
except OSError:
|
||||
_LOGGER.warning("chv_pool_registry_quarantine_failed "
|
||||
"path=%s", self.registry_path)
|
||||
|
||||
def _save_registry(self):
|
||||
"""落盘当前条目(只写 warm/assigned 态;orphan 是
|
||||
待清理的上一世条目,reconcile 后即删,不写回)。
|
||||
写失败只记日志,不炸任务流。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
data = {}
|
||||
for name, entry in self._entries.items():
|
||||
if entry["state"] not in ("warm", "assigned"):
|
||||
continue
|
||||
task = entry.get("task") or {}
|
||||
task_id = task.get("id") if isinstance(task, dict) \
|
||||
else None
|
||||
data[name] = {
|
||||
"state": entry["state"],
|
||||
"vm": entry.get("vm"),
|
||||
"task_id": task_id,
|
||||
"snapshot_url": entry.get("snapshot_url"),
|
||||
}
|
||||
try:
|
||||
directory = os.path.dirname(self.registry_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
tmp_path = self.registry_path + ".tmp"
|
||||
with open(tmp_path, "w") as fh:
|
||||
json.dump(data, fh)
|
||||
os.replace(tmp_path, self.registry_path)
|
||||
except OSError:
|
||||
_LOGGER.warning("chv_pool_registry_save_failed path=%s",
|
||||
self.registry_path)
|
||||
|
||||
def reconcile_orphans(self):
|
||||
"""清理上一世残留条目:对每个 orphan/assigned 条目
|
||||
尽力 shutdown_vm(清 socket 与残留登记),条目清掉
|
||||
并落盘,返回已清理的 VM 名列表。worker 启动/清理时
|
||||
显式调它(见模块 docstring 的单活池假设)。"""
|
||||
cleaned = []
|
||||
for name, entry in list(self._entries.items()):
|
||||
if entry["state"] not in ("orphan", "assigned"):
|
||||
continue
|
||||
vm = entry.get("vm") or name
|
||||
try:
|
||||
self.executor.shutdown_vm(vm)
|
||||
except Exception:
|
||||
pass
|
||||
self._entries.pop(name, None)
|
||||
cleaned.append(vm)
|
||||
if cleaned:
|
||||
self._save_registry()
|
||||
try:
|
||||
self.executor._emit(
|
||||
"warm_pool_reconcile", {"id": None},
|
||||
"cleaned=%s" % ",".join(cleaned))
|
||||
except Exception:
|
||||
pass
|
||||
return cleaned
|
||||
|
||||
# -- 池操作 ----------------------------------------------------------
|
||||
def warm_count(self):
|
||||
return sum(1 for e in self._entries.values()
|
||||
if e["state"] == "warm")
|
||||
|
||||
def orphan_count(self):
|
||||
"""上一世残留条目数(待 reconcile,不可分配)。"""
|
||||
return sum(1 for e in self._entries.values()
|
||||
if e["state"] in ("orphan", "assigned"))
|
||||
|
||||
def prewarm(self, count=None):
|
||||
"""预热到目标数:已有 warm 条目不重复建;每个新条目
|
||||
用一个合成任务名 wake + pause(合成任务 id 为
|
||||
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
|
||||
warm VM 名列表。"""
|
||||
if self.snapshot_url is None:
|
||||
return []
|
||||
target = int(count) if count is not None else self.size
|
||||
added = []
|
||||
i = 0
|
||||
while self.warm_count() < target:
|
||||
i += 1
|
||||
name = "warm-%d" % i
|
||||
if name in self._entries:
|
||||
continue
|
||||
task = {"id": name}
|
||||
self._entries[name] = {"state": "prewarming",
|
||||
"task": task}
|
||||
try:
|
||||
self.executor.wake_vm(task)
|
||||
self.executor.pause_vm(task)
|
||||
except Exception:
|
||||
self._entries.pop(name, None)
|
||||
raise
|
||||
vm = self.executor.vm_name(task)
|
||||
self._entries[name] = {
|
||||
"state": "warm", "task": task, "vm": vm,
|
||||
"snapshot_url": self.snapshot_url}
|
||||
added.append(name)
|
||||
if added:
|
||||
self._save_registry()
|
||||
return added
|
||||
|
||||
def acquire(self, task):
|
||||
"""分配一个预热 VM 给任务:有 warm 条目时消耗一个
|
||||
(状态 assigned,返回 mode=warm + 该条目的合成任务
|
||||
名,供调用方 resume 后使用);无 warm 条目时返回
|
||||
mode=cold(调用方回落 wake_vm 冷启动)。"""
|
||||
for name, entry in list(self._entries.items()):
|
||||
if entry["state"] == "warm":
|
||||
entry["state"] = "assigned"
|
||||
entry["task"] = task
|
||||
self._save_registry()
|
||||
self.executor._emit(
|
||||
"warm_pool_acquire", task,
|
||||
"vm=%s" % name)
|
||||
return {"mode": "warm", "vm": name,
|
||||
"pool_task": entry["task"]}
|
||||
return {"mode": "cold", "vm": None, "pool_task": None}
|
||||
|
||||
def release(self, task, pool_vm=None):
|
||||
"""释放:无论 warm 还是 cold 路径,VM 一律销毁
|
||||
(executor.shutdown_vm),池条目清掉;绝不把用过
|
||||
的 VM 放回 warm 池。返回 True 表示确实销毁了一个
|
||||
条目或 VM。"""
|
||||
destroyed = False
|
||||
if pool_vm and pool_vm in self._entries:
|
||||
self._entries.pop(pool_vm, None)
|
||||
destroyed = True
|
||||
try:
|
||||
if self.executor.shutdown_vm(task):
|
||||
destroyed = True
|
||||
except Exception:
|
||||
pass
|
||||
self._save_registry()
|
||||
self.executor._emit(
|
||||
"warm_pool_release", task,
|
||||
"vm=%s destroyed=%s" % (pool_vm, destroyed))
|
||||
return destroyed
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Cloud Hypervisor 客机 SSH 传输(叶子模块,不依赖
|
||||
cloud.sandbox,避免循环导入)。
|
||||
|
||||
安全模型(本轨的核心变化,替代 #216 的
|
||||
StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
|
||||
|
||||
- 每 VM 一个独立 known_hosts 文件:
|
||||
state_dir/<vm 名>/known_hosts(与每任务磁盘同目录,
|
||||
任务销毁时不自动删——known_hosts 留在宿主 state_dir 里,
|
||||
供事后审计该 VM 当时钉死的 key;VM 名本身已含任务 id,
|
||||
不会串到别的 VM 上)。
|
||||
- 首次信任(TOFU,边界必须写清,不假装是强验证):
|
||||
首次启动时客机 host key 未知,执行器用 ssh-keyscan 经
|
||||
宿主转发端口(127.0.0.1:<port>,只走本机回环)抓一次
|
||||
当时呈现的 key,写入该 VM 的 known_hosts 文件;此后
|
||||
同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes +
|
||||
该文件校验,VM 被替换 / 中间人换 key 时 SSH 直接拒绝,
|
||||
不再静默接受。首次捕获本身不验证“这台客机就是我们要
|
||||
的那台”——它只防首次之后的替换;更强的方案是镜像构建
|
||||
/cloud-init 时把客机 host key 预置进 known_hosts(未来
|
||||
工作,见 docs/chv-production-readiness.md 的待办),本轨
|
||||
在 mock 测试里只能验证“捕获→写文件→后续命令带钉死
|
||||
选项”的管线,绝不声称 mock 验证了首次密钥的真实性。
|
||||
- 密钥红线不变:argv 里只有 key 文件路径,绝无 key 值;
|
||||
known_hosts 文件只含 host key 公钥行,不含任何私钥。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def known_hosts_path(state_dir, vm_name):
|
||||
"""该 VM 的 known_hosts 文件路径:state_dir/<vm>/known_hosts。"""
|
||||
return os.path.join(state_dir, vm_name, "known_hosts")
|
||||
|
||||
|
||||
def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
|
||||
known_hosts_file, inner_cmd):
|
||||
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
|
||||
+ user@127.0.0.1 + 客内命令。
|
||||
|
||||
与 #216 的区别只有 host key 选项:StrictHostKeyChecking=yes
|
||||
+ UserKnownHostsFile=<每 VM 文件>,不再是 no + /dev/null。
|
||||
BatchMode=yes 不变(绝不交互问 host key / 口令)。
|
||||
egress_proxy 已设时客内命令前加 env 前缀(代理地址本身
|
||||
不是密钥;客机出口只走该白名单代理)。"""
|
||||
inner = list(inner_cmd)
|
||||
if egress_proxy:
|
||||
inner = ["env",
|
||||
"HTTPS_PROXY=%s" % egress_proxy,
|
||||
"HTTP_PROXY=%s" % egress_proxy,
|
||||
"NO_PROXY=localhost,127.0.0.1"] + inner
|
||||
return ["ssh", "-p", str(ssh_port),
|
||||
"-i", ssh_key,
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "UserKnownHostsFile=%s" % known_hosts_file,
|
||||
"-o", "BatchMode=yes",
|
||||
"%s@127.0.0.1" % ssh_user] + inner
|
||||
|
||||
|
||||
def parse_keyscan_output(text):
|
||||
"""从 ssh-keyscan 的 stdout 里挑出本机回环端口的 host key
|
||||
行:形如 "[127.0.0.1]:<port> ssh-ed25519 AAAA..."(-p 时
|
||||
的标准写法)或 "127.0.0.1 ssh-rsa AAAA..."。其它主机行、
|
||||
注释行、空行一律丢掉,绝不把陌生主机的 key 写进文件。"""
|
||||
kept = []
|
||||
for line in (text or "").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
host, keytype = parts[0], parts[1]
|
||||
if host != "127.0.0.1" \
|
||||
and not host.startswith("[127.0.0.1]:"):
|
||||
continue
|
||||
if not keytype.startswith("ssh-"):
|
||||
continue
|
||||
kept.append(line)
|
||||
return kept
|
||||
|
||||
|
||||
def write_known_hosts(path, key_lines):
|
||||
"""原子写 known_hosts:先写同目录临时文件再 rename,
|
||||
文件权限 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)
|
||||
Reference in new issue
Block a user