feat(chv-guest): Gitea 同步变量经 seed 进 guest-env(执行器集成)

- 新增 cloud/chv/gitea_sync.py:6 键白名单,三源合并
  (宿主 CHV_GITEA_* < 任务 gitea_sync < extra_env),
  必填 4 键缺失即 fail-closed,空即不同步
- ssh_transport:GITEA_TOKEN 进密钥集(argv 拒绝),其余
  5 键进 guest-env 集(split_guest_env)
- hostkeys.write_seed:guest_env 与 secret_env 合并写同一
  条目,仍无 runcmd、0600
- config:_gitea_fragment 产 gitea_sync_env(函数内 import,
  叶子模块顶层只许标准库)
- executor.wake_vm:起 VMM 前合并+校验(gitea_sync_incomplete),
  guest-env 随 seed 投递;provision_host_key 透传 guest_env
- worker:_run_attempt 仅对 CHV 执行器把同步变量并进 gateway_env
- 测试:cloud/tests/test_chv_gitea_sync.py 20 项
- 边界:tasks 表无 JSON 列,DB 往返丢任务 gitea_sync;
  生产主来源是宿主 CHV_GITEA_* 环境变量
This commit is contained in:
host committed 2026-11-06 16:55:32 +00:00
1 parent 67ca0b86cd
commit e7f699c62b
7 files changed
+785 -37

No files matched your search

+15
View File
@@ -245,6 +245,20 @@ def _timeout_fragment(params: dict, env: dict) -> dict:
}
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 _assemble_chv_config(fragments: list) -> "ChvConfig":
"""把各片段 dict 合并成 ChvConfig(纯合并,不改值)。
@@ -277,4 +291,5 @@ def build_chv_config(params: dict, env: dict) -> "ChvConfig":
_disk_fragment(env),
_network_fragment(params, env),
_timeout_fragment(params, env),
_gitea_fragment(env),
])
+55 -7
View File
@@ -59,6 +59,7 @@ from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd
from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path
from cloud.chv.ssh_transport import (
parse_keyscan_output,
split_guest_env,
split_secret_env,
write_known_hosts,
)
@@ -398,7 +399,8 @@ class CloudHypervisorExecutor(Executor):
"ssh_forward", "reuse_experiment",
"single_tenant_project", "snapshot_max_count",
"snapshot_max_total_gb", "snapshot_retention_days",
"snapshot_dir", "allow_remote_snapshot"):
"snapshot_dir", "allow_remote_snapshot",
"gitea_sync_env"):
setattr(self, _field, getattr(_cfg, _field))
# -- 名字 / 路径 ---------------------------------------------------
@@ -827,7 +829,8 @@ class CloudHypervisorExecutor(Executor):
[known_hosts_line(self.ssh_port, public_text,
host=ssh_host)])
def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict:
def provision_host_key(self, task: dict, secret_env: dict | None = None,
guest_env: dict | None = None) -> dict:
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
立即写进该 VM 的 known_hosts(钉死),私钥只写进
@@ -837,7 +840,9 @@ class CloudHypervisorExecutor(Executor):
不同 VM 的 key 对不同(state_dir/<vm> 隔离),同一
VM 重启复用已有对(否则钉死立即失效)。
feat-chv-secfix(H2/M2):secret_env 给定时写进
seed 的客内密钥文件(0600);上次清理失败留下的
seed 的客内密钥文件(0600);guest_env 给定时与
secret_env 合并写同一客内文件(boot 时要用的非密钥
同步变量,见 gitea_sync);上次清理失败留下的
脏标记存在时直接拒绝(清理失败即拒起,不再复用
旧 key 对)。成功后审计 host_key_generated(只含
VM 名与密钥名,不含任何 key 值)。"""
@@ -862,7 +867,8 @@ class CloudHypervisorExecutor(Executor):
self._write_provisioned_known_hosts(paths, public_text)
seed_dir = write_seed(paths["seed_dir"], vm,
private_text, public_text,
secret_env=secret_env)
secret_env=secret_env,
guest_env=guest_env)
self._emit("host_key_generated", task,
"vm=%s secret_names=%s"
% (vm, ",".join(sorted(secret_env or {}))))
@@ -1305,6 +1311,37 @@ class CloudHypervisorExecutor(Executor):
"VMM api-socket 组/其它可写(%o),拒绝启动:%s"
% (mode, socket_path))
def _effective_extra_env(self, task: dict,
extra_env: dict | None) -> dict | None:
"""wake 生效的 extra_env:把 Gitea 同步变量并进来。
优先级(低→高):执行器宿主配置 CHV_GITEA_*(构造时
已映射成 gitea_sync_env)< 任务 dict 的 gitea_sync <
显式 extra_env 同名键(见 gitea_sync.extract_...)。
同步已请求(任一同步键出现)但必填键不全时,在起
VMM 之前就 ChvFailure(gitea_sync_incomplete,
fail-closed;错误只含缺失键名,不含值)——半份同步
配置绝不能让 VM 带着“以为同步了”的状态启动。无同步
变量时原样返回(本次启动不同步)。
必须在 _wake_prechecks 之前调:任务/配置来源的
GITEA_TOKEN 也要受 keyscan 模式 fail-closed 检查。"""
from cloud.chv.gitea_sync import (
extract_gitea_sync_env,
validate_gitea_sync_env,
)
sync = extract_gitea_sync_env(
task=task, extra_env=extra_env,
config_sync=getattr(self, "gitea_sync_env", None))
try:
validate_gitea_sync_env(sync)
except ValueError as exc:
raise ChvFailure("gitea_sync_incomplete", str(exc))
if not sync:
return extra_env
merged = dict(extra_env or {})
merged.update(sync)
return merged
def _wake_prechecks(self, task: dict, name: str, extra_env) -> tuple:
"""wake 前置:preflight/容量/配额/net/密钥模式,纯移动。
失败时发 vm_failed 审计并原样抛 ChvFailure。"""
@@ -1343,7 +1380,8 @@ class CloudHypervisorExecutor(Executor):
raise
return secrets, net_entries
def _provision_seed_disk(self, task: dict, name: str, secrets) -> str | None:
def _provision_seed_disk(self, task: dict, name: str, secrets,
guest_env: dict | None = None) -> str | None:
"""provisioned 模式的 seed 盘供给:生成→制盘→挂盘,
任一步失败即抛(调用方 stop_vm 清理),纯移动。"""
seed_disk = None
@@ -1366,7 +1404,8 @@ class CloudHypervisorExecutor(Executor):
seed_disk_path,
)
provisioned = self.provision_host_key(
task, secret_env=secrets or None)
task, secret_env=secrets or None,
guest_env=guest_env or None)
seed_disk = seed_disk_path(self.state_dir, name)
build_seed_disk(
provisioned["seed_dir"], seed_disk,
@@ -1446,10 +1485,19 @@ class CloudHypervisorExecutor(Executor):
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
name = self.vm_name(task)
try:
extra_env = self._effective_extra_env(task, extra_env)
except ChvFailure as exc:
self._emit("vm_failed", task,
"vm=%s wake 失败 kind=%s" % (name, exc.kind),
exc.kind)
raise
secrets, net_entries = self._wake_prechecks(task, name, extra_env)
guest_env, _plain = split_guest_env(extra_env)
task_disk, socket_path = self._wake_prepare(task, name)
try:
seed_disk = self._provision_seed_disk(task, name, secrets)
seed_disk = self._provision_seed_disk(
task, name, secrets, guest_env=guest_env or None)
self._boot_vm(task, name, task_disk, seed_disk, net_entries)
except Exception:
try:
+126
View File
@@ -0,0 +1,126 @@
"""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"})
# 其中非密钥、但 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))
+36 -29
View File
@@ -168,20 +168,37 @@ def build_seed_user_data(private_key_text: str, public_key_text: str) -> str:
return "\n".join(lines) + "\n"
def _guest_env_entry(merged_env: dict) -> list:
"""guest-env 的 write_files 条目行(0600 root,值经
shlex.quote 引用):只能并进已有的 write_files 列表,
绝不能再写一个 write_files: 键(YAML 重复键会覆盖掉
host key 的两条写文件条目,预置即失效)。"""
lines = [
" - path: %s" % GUEST_ENV_PATH,
" permissions: '0600'",
" owner: root:root",
" content: |",
]
for key in sorted(merged_env):
lines.append(" %s=%s"
% (key, shlex.quote(str(merged_env[key]))))
return lines
def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
public_key_text: str,
secret_env: dict | None = None) -> str:
"""写 seed 目录:meta-data + user-data(均为 0600)。
secret_env: dict | None = None,
guest_env: dict | None = None) -> str:
"""写 seed 目录:meta-data + user-data(均为 0600),
返回 seed 目录路径。调用方负责随 VM 销毁删掉它。
返回 seed 目录路径。seed 目录本身不含已知主机之外的
共享数据,调用方(执行器关闭/回收)负责删除它。
feat-chv-secfix(H2):secret_env 给定时,user-data 再
写一个 write_files 条目,把密钥环境变量写进客内
GUEST_ENV_PATH(0600 root),值经 shlex.quote 引用后
才进文件内容——远程命令 source 该文件即可得到密钥
环境,密钥值从不出现在宿主 ssh argv、宿主进程环境与
审计明文里。secret_env 为空时不写该条目(客内文件不
存在时远程命令也不 source 它,见执行器)。"""
secret_env 给定时写进客内 GUEST_ENV_PATH(0600 root,
远程命令 source 它得密钥环境,值不进宿主 argv/环境/
审计明文)。guest_env 给定时与 secret_env 合并写同一
个条目(secret 优先于同名键,防两行同名);guest_env
装 boot 时要用的非密钥同步变量(GITEA_URL 等,见
gitea_sync):只走 SSH 前缀到不了 cloud-init 时运行
的 chv-sync boot。两者皆空时不写该条目。"""
os.makedirs(seed_dir, exist_ok=True)
meta_path = os.path.join(seed_dir, "meta-data")
user_path = os.path.join(seed_dir, "user-data")
@@ -190,24 +207,14 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
% (vm_name, vm_name))
os.chmod(meta_path, 0o600)
user_data = build_seed_user_data(private_key_text, public_key_text)
if secret_env:
# 注意:只能把条目并进已有的 write_files 列表,绝不能
# 再写一个 write_files: 键(YAML 重复键会覆盖掉 host
# key 的两条写文件条目,预置即失效)。seed 自 2026-11-07
# 起不再带 runcmd 段(见 build_seed_user_data 的根因
# 禁令),所以正常走 else 分支追加到 write_files 列表
# 末尾;marker 分支只作防御(将来模板若恢复 runcmd,
# 条目仍插在其前、仍属同一列表)。
lines = [
" - path: %s" % GUEST_ENV_PATH,
" permissions: '0600'",
" owner: root:root",
" content: |",
]
for key in sorted(secret_env):
lines.append(" %s=%s"
% (key, shlex.quote(str(secret_env[key]))))
# 条目插在 runcmd 段之前、仍属同一 write_files 列表。
merged_env = dict(guest_env or {})
merged_env.update(secret_env or {})
if merged_env:
lines = _guest_env_entry(merged_env)
# 条目插在 runcmd 段之前、仍属同一 write_files 列表
#(seed 自 2026-11-07 起不再带 runcmd,见
# build_seed_user_data 的根因禁令;marker 分支只作
# 防御,将来模板若恢复 runcmd 仍插在其前)。
marker = "runcmd:\n"
if marker in user_data:
head, tail = user_data.split(marker, 1)
+31 -1
View File
@@ -44,12 +44,25 @@ StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
import os
import shlex
from cloud.chv.gitea_sync import (
GITEA_SYNC_PLAIN_KEYS,
GITEA_SYNC_SECRET_KEYS,
)
# CHV 密钥类环境变量名:值绝不进 ssh argv。Docker 线是
# “-e 只传名、值由 CLI 子进程环境提供”(值仍在宿主进程
# 环境里,/proc/<pid>/environ 对同用户可见);CHV 线是
# “值只进 seed 盘客内文件”(宿主进程表与宿主进程环境里
# 都没有值),比 Docker 线更严——差异见执行器文档。
_CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"})
_CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"}) \
| GITEA_SYNC_SECRET_KEYS
# CHV guest-env 类非密钥变量名:值可进 SSH env 前缀(按定义
# 不是密钥),但 chv-sync boot 在 cloud-init 时运行、SSH 之
# 前,只能读 seed 投递的 /etc/chv/guest-env,所以这些键也
# 必须随 seed 写进 guest-env(见 split_guest_env 与执行器
# wake_vm),只走 SSH 前缀是不够的。
_CHV_GUEST_ENV_NAMES = frozenset(GITEA_SYNC_PLAIN_KEYS)
def split_secret_env(extra_env: dict | None) -> tuple[dict, dict]:
@@ -63,6 +76,23 @@ def split_secret_env(extra_env: dict | None) -> tuple[dict, dict]:
return secrets, plain
def split_guest_env(extra_env: dict | None) -> tuple[dict, dict]:
"""把 extra_env 拆成(须写进 seed guest-env 的子集,
非密钥子集)。
guest-env 子集 = 密钥子集 + _CHV_GUEST_ENV_NAMES 命中的
非密钥同步变量(GITEA_URL 等 boot 时就要用的键);第二
个返回仍是全部非密钥子集(含 guest-env 命中键——它们
任务期也要经 SSH env 前缀进客机环境,seed 只解决
boot 时可见性,不替代任务期前缀)。"""
secrets, plain = split_secret_env(extra_env)
guest_env = dict(secrets)
for key, value in plain.items():
if key in _CHV_GUEST_ENV_NAMES:
guest_env[key] = value
return guest_env, plain
def remote_command(inner_cmd: list, egress_proxy: str | None = None,
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list: