Files
codex-app-server/cloud/chv/config.py
T

310 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 逐字一致,
只有一处新增(feat-chv-p1fix,P1-07):能解析出的
CPU/内存非正值直接 ValueError,不再一路进 VmConfig
到 VMM 侧才炸;解析不出的格式仍留给 preflight 报
config_invalid(构造时不改旧报错时机分工)。"""
# 显式 cpus=0 不再经 or 链静默回落成 2:0 是非法值,
# 应报出来(P1-07);None 仍走 env/默认值。
_cpus_raw = params.get("cpus") if params.get("cpus") is not None \
else (env.get("CHV_CPU") or "2")
cpus = str(_cpus_raw)
# 解析不出的格式留给 preflight(旧时机分工不变):
# 只对“解析成功且 <=0”的值在这里报,格式错误不报。
try:
_cpus_parsed = int(cpus)
except (TypeError, ValueError):
_cpus_parsed = None
if _cpus_parsed is not None and _cpus_parsed <= 0:
raise ValueError("CHV_CPU 必须是大于 0 的整数")
memory = params.get("memory") or env.get("CHV_MEM") or "2G"
try:
_mem_parsed = _parse_memory_bytes(memory)
except ValueError:
_mem_parsed = None
if _mem_parsed is not None and _mem_parsed <= 0:
raise ValueError("CHV_MEM 必须是大于 0")
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": cpus,
"memory": memory,
"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
# feat-chv-p1fix(P1-07):显式设的任务超时非正即非法。
# None = 不限(旧语义,保留);0/负数过去一路进 VmConfig
# 相关路径(runner timeout=0/负),现在构造时即 ValueError。
if task_timeout is not None and task_timeout <= 0:
raise ValueError("CHV_TASK_TIMEOUT 必须是大于 0 的数字")
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 _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 → 配额 → 磁盘 →
超时),只有一处可观测的顺序变化:warm_pool_size
从旧超时片段移到配额片段,因此“CHV_WARM_POOL_SIZE
非法 + 磁盘非法同时出现”时先报前者;单项非法时
报错与旧代码逐字一致。字段集合与取值与旧代码一致,
由测试的字段集断言钉住。"""
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),
_network_fragment(params, env),
_timeout_fragment(params, env),
])