每用户一台常驻 VM(暂停态,resume 到 SSH 5 秒内为待验证目标), 池空/失败回落冷启动;CHV 配置加 virtio-balloon + free-page-reporting 与 vsock notify 最小实现(SSH 仍是命令通道与就绪判定)。 新模块 resident_pool.py / vsock.py + 客内 vsock_notify.py; _config 增加 CHV_RESIDENT_IDLE_MINUTES / CHV_BALLOON / CHV_VSOCK; worker_adapter 接常驻孤儿清理 facade;ADR-17 差距表见 docs/chv-muse-p1-gap.md。 恢复收尾(分离后回归,见 gap.md §4):VmDeathTest 死亡模拟改杀 实际跑命令的 VM;两处冷路径旧测试显式关常驻;_balloon_vsock_payload 与 listen_notify 拆到 50 行内;resident_pool.py:208 mypy 修掉。 executor.py out 未注解与 marker_tools.py:142 是基线旧错,未动。 测试:全量 cloud 套件 1934 OK(skipped=8);mypy 在改动文件上 干净(仅基线旧错 4 处)。真机验证待第三棒让出 WSL 测试机。
370 lines
16 KiB
Python
370 lines
16 KiB
Python
"""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 未合并,
|
||
本轨不假装复用它。上一轨 ADR-7 的泛化映射已被证伪
|
||
(#215 的 timeouts 是审批/队列超时、quota 是按月美元
|
||
配额,与 CHV 的秒数超时、VM 数/磁盘 GB 单位语义不同,
|
||
硬套会改行为),真实映射只有两处,见
|
||
cloud/chv/compat215.py 的 REPLACEMENT_POINTS 清单
|
||
(合并时按清单机械替换,不在本轨硬 import 未合并模块)。
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
|
||
|
||
def _to_int(value: object) -> int:
|
||
"""动态配置值转 int(env/参数均未定型):bool 拒绝,
|
||
int/float 按 int(),str 按 strip 后 int(),其它 ValueError。
|
||
旧行为是 int(value) 抛 TypeError/ValueError 后由调用方
|
||
转 ValueError;这里直接统一成 ValueError,文案由调用方定。"""
|
||
if isinstance(value, bool):
|
||
raise ValueError("布尔值不是整数")
|
||
if isinstance(value, int):
|
||
return value
|
||
if isinstance(value, float):
|
||
return int(value)
|
||
if isinstance(value, str):
|
||
return int(value.strip())
|
||
raise ValueError("不是整数:%r" % (value,))
|
||
|
||
|
||
def _to_float(value: object) -> float:
|
||
"""动态配置值转 float,与 _to_int 同款口径。"""
|
||
if isinstance(value, bool):
|
||
raise ValueError("布尔值不是数字")
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
if isinstance(value, str):
|
||
return float(value.strip())
|
||
raise ValueError("不是数字:%r" % (value,))
|
||
|
||
|
||
def _parse_memory_bytes(value: str) -> int:
|
||
"""内存字符串转字节: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: object) -> None:
|
||
for key, value in fields.items():
|
||
setattr(self, key, value)
|
||
|
||
|
||
|
||
def _base_fragment(params: dict, env: dict) -> dict:
|
||
"""身份/资源基底片段:二进制、内核、根盘、CPU/内存、
|
||
state_dir、KVM/cpuinfo 路径、ch-remote、模型。无校验,
|
||
取值口径与旧 build_chv_config 内联 base 逐字一致。"""
|
||
return {
|
||
"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 os.path.join(os.path.expanduser("~"),
|
||
".local", "share", "codex-app-server", "chv-vms"),
|
||
"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"),
|
||
}
|
||
|
||
|
||
def _ssh_fragment(params: dict, env: dict) -> dict:
|
||
"""SSH/命令面片段:密钥、用户、端口、host key 模式、
|
||
客内 host.py 路径、内核命令行。错误文案与原内联代码
|
||
逐字一致,校验时机不变(构造时)。网络字段(net、
|
||
ssh_host、egress)已移到 _network_fragment,不在本
|
||
片段,避免“SSH”片段名不副实。"""
|
||
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 = _to_int(raw_port)
|
||
except (TypeError, ValueError):
|
||
raise ValueError("CHV_SSH_PORT 必须是整数")
|
||
if not 1 <= ssh_port <= 65535:
|
||
raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间")
|
||
host_key_mode = params.get("host_key_mode") \
|
||
or env.get("CHV_HOST_KEY_MODE") or "provisioned"
|
||
if host_key_mode not in ("provisioned", "keyscan"):
|
||
raise ValueError(
|
||
"CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan:%s"
|
||
% host_key_mode)
|
||
return {
|
||
"ssh_key": ssh_key, "ssh_user": ssh_user,
|
||
"ssh_port": ssh_port, "host_key_mode": host_key_mode,
|
||
"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",
|
||
}
|
||
|
||
|
||
def _network_fragment(params: dict, env: dict) -> dict:
|
||
"""网络片段:tap/桥接描述、SSH 目标、端口转发开关、
|
||
egress 代理与直连开关。无数值校验(校验在 preflight
|
||
的 net/egress 门里,不在配置解析层)。egress 从旧
|
||
quota 片段移到这里:它是网络出口策略,不是 VM 配额。"""
|
||
return {
|
||
"net": params.get("net") or env.get("CHV_NET"),
|
||
"net_json": params.get("net_json") or env.get("CHV_NET_JSON"),
|
||
"ssh_host": params.get("ssh_host") or env.get("CHV_SSH_HOST"),
|
||
"ssh_forward": env.get("CHV_SSH_FORWARD") == "1",
|
||
"egress_proxy": env.get("CHV_EGRESS_PROXY"),
|
||
"allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS")
|
||
== "1",
|
||
}
|
||
|
||
|
||
def _quota_fragment(env: dict) -> dict:
|
||
"""配额片段:仅容量数——最大并发 VM 数与预热池大小。
|
||
磁盘/快照数上限不在这里,它们是磁盘行为,见
|
||
_disk_fragment。校验文案与旧代码逐字一致。"""
|
||
try:
|
||
max_vms = _to_int(env.get("CHV_MAX_VMS") or 4)
|
||
except ValueError:
|
||
raise ValueError("CHV_MAX_VMS 必须是整数")
|
||
try:
|
||
warm_pool_size = _to_int(env.get("CHV_WARM_POOL_SIZE") or 0)
|
||
except ValueError:
|
||
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
|
||
return {"max_vms": max_vms, "warm_pool_size": warm_pool_size}
|
||
|
||
|
||
def _disk_fragment(env: dict) -> dict:
|
||
"""磁盘片段:磁盘模式/大小、单租户 reuse 实验开关、
|
||
快照数/总量/保留期/目录/远端开关与快照 URL。校验
|
||
文案与旧 quota 片段逐字一致(disk_mode 白名单、
|
||
disk_gb > 0、快照数非负)。"""
|
||
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)
|
||
try:
|
||
disk_gb = _to_float(env.get("CHV_DISK_GB")) \
|
||
if env.get("CHV_DISK_GB") else 50.0
|
||
except ValueError:
|
||
raise ValueError("CHV_DISK_GB 必须是数字")
|
||
if disk_gb <= 0:
|
||
raise ValueError("CHV_DISK_GB 必须大于 0")
|
||
try:
|
||
snapshot_max_count = _to_int(env.get("CHV_SNAPSHOT_MAX_COUNT")
|
||
or 10)
|
||
snapshot_max_total_gb = _to_float(
|
||
env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0)
|
||
snapshot_retention_days = _to_float(
|
||
env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0)
|
||
except ValueError:
|
||
raise ValueError("CHV_SNAPSHOT_* 必须是数字")
|
||
if snapshot_max_count < 0 or snapshot_max_total_gb < 0 \
|
||
or snapshot_retention_days < 0:
|
||
raise ValueError("CHV_SNAPSHOT_* 不能为负数")
|
||
return {
|
||
"disk_mode": disk_mode, "disk_gb": disk_gb,
|
||
"reuse_experiment": env.get(
|
||
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1",
|
||
"single_tenant_project": env.get("CHV_SINGLE_TENANT_PROJECT"),
|
||
"snapshot_max_count": snapshot_max_count,
|
||
"snapshot_max_total_gb": snapshot_max_total_gb,
|
||
"snapshot_retention_days": snapshot_retention_days,
|
||
"snapshot_dir": env.get("CHV_SNAPSHOT_DIR"),
|
||
"allow_remote_snapshot": env.get("CHV_ALLOW_REMOTE_SNAPSHOT")
|
||
== "1",
|
||
"snapshot_url": env.get("CHV_SNAPSHOT_URL"),
|
||
}
|
||
|
||
|
||
def _timeout_fragment(params: dict, env: dict) -> dict:
|
||
"""超时片段:任务超时(None = 不限,旧语义)、VMM API
|
||
超时、客机就绪超时/间隔。ready_interval <= 0 时回落
|
||
5 秒(旧语义,非校验失败)。校验文案逐字一致。"""
|
||
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
|
||
task_timeout = _to_float(task_timeout_raw) \
|
||
if task_timeout_raw else None
|
||
try:
|
||
api_timeout = _to_float(
|
||
params.get("api_timeout")
|
||
if params.get("api_timeout") is not None
|
||
else env.get("CHV_API_TIMEOUT") or 30)
|
||
ready_timeout = _to_float(
|
||
params.get("ready_timeout")
|
||
if params.get("ready_timeout") is not None
|
||
else env.get("CHV_READY_TIMEOUT") or 120)
|
||
ready_interval = _to_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 {
|
||
"task_timeout": task_timeout, "api_timeout": api_timeout,
|
||
"ready_timeout": ready_timeout,
|
||
"ready_interval": ready_interval,
|
||
}
|
||
|
||
|
||
def _gitea_fragment(env: dict) -> dict:
|
||
"""Gitea 同步片段:宿主配置环境变量 CHV_GITEA_*(见
|
||
gitea_sync.CHV_GITEA_ENV_MAP)映射成的 guest-env 键值
|
||
dict。无配置即空 dict(本次启动不同步);值本身不校验
|
||
完整性——完整性由 wake_vm 在起 VMM 前 fail-closed
|
||
(validate_gitea_sync_env),构造时不拦,免得一个配
|
||
错的宿主连执行器对象都建不出来、排障信息全丢。
|
||
import 在函数内:本模块是叶子模块,顶层只许 import
|
||
标准库(见 test_leaf_modules_only_import_stdlib),
|
||
与执行器的方法内 import 同款惯例。"""
|
||
from cloud.chv.gitea_sync import config_gitea_sync_env
|
||
return {"gitea_sync_env": config_gitea_sync_env(env)}
|
||
|
||
|
||
def _to_switch(raw: object, name: str) -> bool | None:
|
||
"""CHV_* 开关解析(P1 气球/vsock 用):未设 → None(调用方
|
||
再定默认值);"1" 开、"0" 关;其它值一律 ValueError,绝不
|
||
静默当关(与 CHV_SSH_FORWARD 的 `== "1"` 旧口径不同,本轨
|
||
新开关按差距表要求非法值即报错)。"""
|
||
if raw is None:
|
||
return None
|
||
if raw == "1":
|
||
return True
|
||
if raw == "0":
|
||
return False
|
||
raise ValueError("%s 只能是 0 或 1:%r" % (name, raw))
|
||
|
||
|
||
def _balloon_vsock_fragment(env: dict) -> dict:
|
||
"""气球/vsock 片段(P1,feat-chv-muse-p1):virtio-balloon
|
||
+ free-page-reporting 开关、vsock notify 通道开关与客 CID。
|
||
|
||
- CHV_BALLOON:默认 1(开)。只认 "1"/"0",其它值
|
||
ValueError。
|
||
- CHV_VSOCK:默认 1(开)。只认 "1"/"0",其它值
|
||
ValueError。
|
||
- CHV_VSOCK_CID:客机 vsock CID,可选,默认 3。必须是
|
||
整数且在 3-4294967295 之间:0/1/2 是 vsock 保留 CID
|
||
(Hypervisor/Loopback/Host,CHV 的 VmConfig 校验会
|
||
以 VsockSpecialCid 拒绝),-1(随机 CID)CHV 的
|
||
vm.create 同样 400 拒绝(上游 discussion #7531),
|
||
所以这里不接受保留值/负值;显式设了非法 CID 即
|
||
ValueError,哪怕 CHV_VSOCK=0(配错早炸,不留到起 VM
|
||
才暴露)。CID 由运维指定、不是 CHV 分配的:同一宿主
|
||
上各 VM 的 vsock socket 文件不同,CID 相同(默认 3)
|
||
并不冲突。"""
|
||
balloon = _to_switch(env.get("CHV_BALLOON"), "CHV_BALLOON")
|
||
if balloon is None:
|
||
balloon = True
|
||
vsock = _to_switch(env.get("CHV_VSOCK"), "CHV_VSOCK")
|
||
if vsock is None:
|
||
vsock = True
|
||
raw_cid = env.get("CHV_VSOCK_CID")
|
||
if raw_cid is None:
|
||
vsock_cid = 3
|
||
else:
|
||
try:
|
||
vsock_cid = _to_int(raw_cid)
|
||
except ValueError:
|
||
raise ValueError("CHV_VSOCK_CID 必须是整数")
|
||
if not 3 <= vsock_cid <= 0xFFFFFFFF:
|
||
raise ValueError(
|
||
"CHV_VSOCK_CID 必须在 3-4294967295 之间:%s"
|
||
% vsock_cid)
|
||
return {"balloon": balloon, "vsock": vsock,
|
||
"vsock_cid": vsock_cid}
|
||
|
||
|
||
def _seed_fragment(params: dict, env: dict) -> dict:
|
||
"""seed 附加项片段:只存宿主文件路径,不读文件内容
|
||
(读文件在 wake 时 fail-closed,见执行器 _seed_extras;
|
||
构造时不读,免得路径配错时连执行器对象都建不出来)。
|
||
CHV_SEED_USER_DATA_EXTRA_FILE 指向含 `users:` 块的文件,
|
||
CHV_SEED_NETWORK_CONFIG_FILE 指向 NoCloud network-config
|
||
文件;皆空即不附加(行为与旧 seed 一致)。"""
|
||
return {
|
||
"seed_user_data_extra_file":
|
||
params.get("seed_user_data_extra_file")
|
||
or env.get("CHV_SEED_USER_DATA_EXTRA_FILE"),
|
||
"seed_network_config_file":
|
||
params.get("seed_network_config_file")
|
||
or env.get("CHV_SEED_NETWORK_CONFIG_FILE"),
|
||
}
|
||
|
||
|
||
def _assemble_chv_config(fragments: list) -> "ChvConfig":
|
||
"""把各片段 dict 合并成 ChvConfig(纯合并,不改值)。
|
||
|
||
片段键集必须互不相交——键冲突是编程错误,直接
|
||
ValueError,绝不让后一片段静默覆盖前一片段的值。"""
|
||
fields: dict = {}
|
||
for fragment in fragments:
|
||
for key, value in fragment.items():
|
||
if key in fields:
|
||
raise ValueError(
|
||
"CHV 配置片段键冲突:%s" % key)
|
||
fields[key] = value
|
||
return ChvConfig(**fields)
|
||
|
||
|
||
def build_chv_config(params: dict, env: dict) -> "ChvConfig":
|
||
"""把显式构造参数与环境变量解析成 ChvConfig。
|
||
|
||
片段调用顺序即旧校验顺序(SSH → 配额 → 磁盘 →
|
||
气球/vsock → 超时),只有两处可观测的顺序变化:
|
||
warm_pool_size 从旧超时片段移到配额片段,因此
|
||
“CHV_WARM_POOL_SIZE 非法 + 磁盘非法同时出现”时先报
|
||
前者;单项非法时报错与旧代码逐字一致。字段集合 =
|
||
旧代码字段 + P1 气球/vsock 三字段(balloon/vsock/
|
||
vsock_cid),由测试的字段集断言钉住。"""
|
||
env = env if env is not None else {}
|
||
return _assemble_chv_config([
|
||
_base_fragment(params, env),
|
||
_ssh_fragment(params, env),
|
||
_quota_fragment(env),
|
||
_disk_fragment(env),
|
||
_balloon_vsock_fragment(env),
|
||
_network_fragment(params, env),
|
||
_timeout_fragment(params, env),
|
||
_gitea_fragment(env),
|
||
_seed_fragment(params, env),
|
||
])
|