Files
codex-app-server/cloud/chv/config.py
T
host a90cf78d84 feat(chv): 对齐 Muse P3 收尾——authd 替身凭证 + Sentinel 出口审批
- 新测试 cloud/tests/test_chv_muse_p3.py 30 项:替身过期、真 token
  泄露面、SSRF 挡截、deny/ask 路径、authd-seed 拆分、wake 预审、
  客内 authd 守护进程、git credential helper 端到端(真 git fill)
- 8 个旧测试按新 authd 行为改写(未删测试、未放宽断言):
  GITEA_TOKEN 改走 authd-seed,build_ssh_command 不再 source 进
  codex 环境;wake 测试补 Sentinel 白名单 + 假解析器
- 7 处超 50 行函数拆到 50 行内(不改行为);补 authd/sentinel
  公共 API 注解,mypy 在改动文件上干净
- 真问题修复:git 默认不把 path 发给 helper,chv-sync boot 加
  credential.useHttpPath=true,否则 authd 路径每次都拒绝
- 全量 cloud 套件 1897 项 OK(skipped=8)

真机待验证(未做,等第三棒让出 WSL 测试机):见
docs/chv-muse-p3-gap.md §3.6 清单
2026-11-06 21:21:55 +00:00

320 lines
14 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 逐字一致。"""
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",
# feat-chv-muse-p3:Sentinel 白名单主机(逗号分隔),
# 默认只放行配置的 Gitea 主机(执行器 wake 预审时追加)。
"sentinel_allow_hosts": tuple(
h.strip().lower()
for h in (env.get("CHV_SENTINEL_ALLOW_HOSTS") or "")
.split(",") if h.strip()),
}
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 _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 → 配额 → 磁盘 →
超时),只有一处可观测的顺序变化: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),
_gitea_fragment(env),
_seed_fragment(params, env),
])