"""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 _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), ])