"""Gitea 同步变量供给(CHV guest-env 集成,叶子模块)。 背景:客内 chv-sync boot 在 cloud-init 时运行、SSH 之前, 只能读 seed 投递的 /etc/chv/guest-env,看不到 SSH env 前缀。 因此同步所需的 6 个变量必须随 seed 进 guest-env,不能只靠 extra_env 的 SSH 前缀;其中 GITEA_TOKEN 是密钥,只进 seed, 绝不进 argv/日志/镜像。 变量与来源(优先级低→高,后者覆盖前者): 1. 宿主配置环境变量(主来源,生产口径):CHV_GITEA_URL、 CHV_GITEA_USER、CHV_GITEA_TOKEN、CHV_SYNC_REPO、 CHV_SYNC_BRANCH、CHV_GITEA_EMAIL。由部署该 worker/执行器 的运维在宿主环境里设置,与 CHV_SSH_KEY、CHV_EGRESS_PROXY 同款口径;CHV 是单租户单 VM 模型,一台宿主对应一个 Gitea 身份。密钥来源绝不硬编码进代码。 2. 任务级覆盖(内存态):任务 dict 的 `gitea_sync` 子 dict, 键即 6 个变量名。仅在调用方以内存态构造任务 dict 时生效 (直接调 wake_vm、测试、手工驱动);tasks 表目前没有 JSON/元数据列,DB 往返会丢掉它——这是已知边界,不做 静默声称,需要每任务不同仓库时再做任务表迁移。 3. 显式 extra_env:调用方直接传给 wake_vm/dispatch 的 extra_env 里的同名键,优先级最高。 缺失语义(fail-closed,不跳过同步): - 6 个键一个都没有 → 本次启动不同步(chv-sync boot 见无 guest-env 静默成功,VM 照常用)。 - 任一键出现、但必填 4 键(GITEA_URL、GITEA_USER、 GITEA_TOKEN、SYNC_REPO)缺失 → 校验失败,wake 在起 VMM 之前直接拒绝;半份同步配置绝不能让 VM 带着“我以为同步 了”的状态启动。SYNC_BRANCH、GITEA_EMAIL 可选。 - 校验错误只含缺失的键名,绝不含任何变量值。 本模块是纯函数,不读文件、不跑 VM、不含任何密钥值。 """ # 同步变量全集(guest-env 里应有的键)。 GITEA_SYNC_KEYS = frozenset({ "GITEA_URL", "GITEA_USER", "GITEA_TOKEN", "SYNC_REPO", "SYNC_BRANCH", "GITEA_EMAIL", }) # 其中密钥:只进 seed,绝不进 argv(见 ssh_transport 的 # _CHV_SECRET_ENV_NAMES)。 GITEA_SYNC_SECRET_KEYS = frozenset({"GITEA_TOKEN"}) # 其中 authd 专用密钥(feat-chv-muse-p3):只进客内 authd 的 # 独立 seed 文件 /etc/chv/authd-seed(见 hostkeys.AUTHD_SEED_PATH), # 不进 guest-env、不进 codex 进程环境。本期只有 GITEA_TOKEN。 AUTHD_SECRET_KEYS = frozenset({"GITEA_TOKEN"}) def split_authd_env(secrets: dict | None) -> tuple[dict, dict]: """把密钥集拆成(走 authd 通道的子集,仍走 guest-env 的子集)。 authd 子集 = AUTHD_SECRET_KEYS 命中的键(本期只有 GITEA_TOKEN); 其余密钥(如 SUB2API_API_KEY)仍走 guest-env(见差距表 §3.3, 网关 key 的 privsep 改造是后续工作)。""" authd, rest = {}, {} for key, value in dict(secrets or {}).items(): if key in AUTHD_SECRET_KEYS: authd[key] = value else: rest[key] = value return authd, rest # 其中非密钥、但 boot 时就要用、也必须进 guest-env 的键 #(SSH env 前缀到不了 cloud-init 时运行的 chv-sync boot)。 GITEA_SYNC_PLAIN_KEYS = GITEA_SYNC_KEYS - GITEA_SYNC_SECRET_KEYS # 必填键:缺一即 fail-closed(见模块头缺失语义)。 GITEA_SYNC_REQUIRED_KEYS = frozenset({ "GITEA_URL", "GITEA_USER", "GITEA_TOKEN", "SYNC_REPO", }) # 宿主配置环境变量名 → guest-env 键名。 CHV_GITEA_ENV_MAP = { "CHV_GITEA_URL": "GITEA_URL", "CHV_GITEA_USER": "GITEA_USER", "CHV_GITEA_TOKEN": "GITEA_TOKEN", "CHV_SYNC_REPO": "SYNC_REPO", "CHV_SYNC_BRANCH": "SYNC_BRANCH", "CHV_GITEA_EMAIL": "GITEA_EMAIL", } def _clean(mapping: dict | None) -> dict: """只留白名单键:值须为非空字符串(strip 后),其它 类型/空值/未知键一律丢掉——调用方拼错的键名绝不静默 进 seed 变成客内无人读的脏变量。""" cleaned = {} for key, value in dict(mapping or {}).items(): if key not in GITEA_SYNC_KEYS: continue if not isinstance(value, str): continue value = value.strip() if not value: continue cleaned[key] = value return cleaned def config_gitea_sync_env(env: dict | None) -> dict: """宿主配置环境变量(CHV_GITEA_*)→ guest-env 键名。""" mapped = {} for env_name, key in CHV_GITEA_ENV_MAP.items(): value = (env or {}).get(env_name) if value is not None: mapped[key] = value return _clean(mapped) def task_gitea_sync_env(task: dict | None) -> dict: """任务 dict 的 `gitea_sync` 子 dict → 白名单后的键值。 不是 dict(或缺失)即空——绝不把任务其它字段误读成 同步变量。""" if not isinstance(task, dict): return {} return _clean(task.get("gitea_sync")) def extract_gitea_sync_env(task: dict | None = None, extra_env: dict | None = None, config_sync: dict | None = None) -> dict: """三源合并(优先级:config_sync < 任务 gitea_sync < extra_env 同名键)。返回白名单后的 dict,可能为空 (空 = 本次启动不同步)。""" merged = {} merged.update(_clean(config_sync)) merged.update(task_gitea_sync_env(task)) merged.update(_clean(extra_env)) return merged def validate_gitea_sync_env(env: dict | None) -> None: """校验同步变量完整性:空即不同步(通过);非空但必填 键缺失即 ValueError(只含缺失键名,不含值)。调用方 (执行器 wake_vm)在起 VMM 前调本函数做 fail-closed。""" env = dict(env or {}) if not env: return missing = sorted(GITEA_SYNC_REQUIRED_KEYS - set(env)) if missing: raise ValueError( "Gitea 同步变量不完整,缺失:%s(同步已请求但必填" "键不全,拒绝启动)" % ",".join(missing))