feat(chv-guest): seed 附加项供客机用户与静态网络(wake 路径前置)
wake_vm 在 WSL 台架上跑通需要客机身份与网络:镜像本身不建 用户、tap 无 DHCP,缺 users 块的 VM 登不进去、缺 network-config 的 VM 拿不到静态 IP。最小补法(复用 write_seed 机制): - hostkeys.write_seed 新增 user_data_extra(原样追加进 user-data,供 users: 块)与 network_config(写 seed network-config 文件,0600);附加项含 runcmd: 即 ValueError - config _seed_fragment:CHV_SEED_USER_DATA_EXTRA_FILE / CHV_SEED_NETWORK_CONFIG_FILE(只存路径) - executor._seed_extras 在 wake 起 VMM 前读入,缺失即 ChvFailure seed_extra_missing;provision_host_key 透传 - README §7 残留更新:guest-env 集成已做 - 测试:test_chv_gitea_sync 新增 5 项(写盘/禁 runcmd/ 配置映射/wake 带附加项/缺失 fail-closed)
This commit is contained in:
1 parent
e7f699c62b
commit
d7cc482ed5
5 files changed
+184
-11
No files matched your search
+13
-3
@@ -143,6 +143,16 @@ rebase、不静默丢提交。
|
||||
- 验收(用户视角):VM 启动后仓在 `~/workspace` 且是最新
|
||||
HEAD;VM 内改文件 commit push 后 Gitea 网页可见该提交;
|
||||
Gitea 上改一次后 VM 内 pull 能同步回来。双向都通才算集成通。
|
||||
- 残留:执行器生成 seed 时自动写 guest-env 的集成未做(本轨
|
||||
验收用手工 seed 测通链路);专用 token 与测试仓名须用户给,
|
||||
本轨不自编 token、不动生产 Gitea 配置。
|
||||
- 集成(2026-11-07,第二轮第一棒):执行器生成 seed 时自动写
|
||||
guest-env 已做(`cloud/chv/gitea_sync.py` + `wake_vm`):
|
||||
GITEA_TOKEN 进密钥集(只进 seed,绝不进 argv),其余 5 键
|
||||
也随 seed 进 guest-env;来源是宿主 `CHV_GITEA_*` 环境变量
|
||||
或任务 `gitea_sync` dict;必填 4 键缺失时起 VMM 前
|
||||
fail-closed(`gitea_sync_incomplete`),无同步变量时静默
|
||||
不同步。客机用户与静态网络由 seed 附加项供给:
|
||||
`CHV_SEED_USER_DATA_EXTRA_FILE`(含 `users:` 块的文件)与
|
||||
`CHV_SEED_NETWORK_CONFIG_FILE`(NoCloud network-config),
|
||||
路径配了但文件读不出时 wake 前 fail-closed
|
||||
(`seed_extra_missing`);附加 user-data 里禁 `runcmd:`。
|
||||
专用 token 与测试仓名须用户给,本轨不自编 token、不动
|
||||
生产 Gitea 配置。
|
||||
@@ -259,6 +259,23 @@ def _gitea_fragment(env: dict) -> dict:
|
||||
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(纯合并,不改值)。
|
||||
|
||||
@@ -292,4 +309,5 @@ def build_chv_config(params: dict, env: dict) -> "ChvConfig":
|
||||
_network_fragment(params, env),
|
||||
_timeout_fragment(params, env),
|
||||
_gitea_fragment(env),
|
||||
_seed_fragment(params, env),
|
||||
])
|
||||
+40
-6
@@ -400,7 +400,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
"single_tenant_project", "snapshot_max_count",
|
||||
"snapshot_max_total_gb", "snapshot_retention_days",
|
||||
"snapshot_dir", "allow_remote_snapshot",
|
||||
"gitea_sync_env"):
|
||||
"gitea_sync_env", "seed_user_data_extra_file",
|
||||
"seed_network_config_file"):
|
||||
setattr(self, _field, getattr(_cfg, _field))
|
||||
|
||||
# -- 名字 / 路径 ---------------------------------------------------
|
||||
@@ -830,7 +831,9 @@ class CloudHypervisorExecutor(Executor):
|
||||
host=ssh_host)])
|
||||
|
||||
def provision_host_key(self, task: dict, secret_env: dict | None = None,
|
||||
guest_env: dict | None = None) -> dict:
|
||||
guest_env: dict | None = None,
|
||||
user_data_extra: str | None = None,
|
||||
network_config: str | None = None) -> dict:
|
||||
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
|
||||
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
|
||||
立即写进该 VM 的 known_hosts(钉死),私钥只写进
|
||||
@@ -868,7 +871,9 @@ class CloudHypervisorExecutor(Executor):
|
||||
seed_dir = write_seed(paths["seed_dir"], vm,
|
||||
private_text, public_text,
|
||||
secret_env=secret_env,
|
||||
guest_env=guest_env)
|
||||
guest_env=guest_env,
|
||||
user_data_extra=user_data_extra,
|
||||
network_config=network_config)
|
||||
self._emit("host_key_generated", task,
|
||||
"vm=%s secret_names=%s"
|
||||
% (vm, ",".join(sorted(secret_env or {}))))
|
||||
@@ -1311,6 +1316,30 @@ class CloudHypervisorExecutor(Executor):
|
||||
"VMM api-socket 组/其它可写(%o),拒绝启动:%s"
|
||||
% (mode, socket_path))
|
||||
|
||||
def _seed_extras(self) -> tuple:
|
||||
"""读 seed 附加项文件(CHV_SEED_* 配置的路径):
|
||||
返回 (user_data_extra, network_config),皆空即
|
||||
(None, None)。路径已配但文件缺失/读不出时
|
||||
ChvFailure(seed_extra_missing),wake 在起 VMM
|
||||
之前拒绝——缺 users 块的 VM 起了也登不进去,缺
|
||||
network-config 的 VM 在无 DHCP 的 tap 上拿不到 IP,
|
||||
不如不起。错误只含路径,不含文件内容。"""
|
||||
out = []
|
||||
for attr in ("seed_user_data_extra_file",
|
||||
"seed_network_config_file"):
|
||||
path = getattr(self, attr, None)
|
||||
if not path:
|
||||
out.append(None)
|
||||
continue
|
||||
try:
|
||||
with open(path) as fh:
|
||||
out.append(fh.read())
|
||||
except OSError as exc:
|
||||
raise ChvFailure(
|
||||
"seed_extra_missing",
|
||||
"seed 附加文件读不出:%s:%s" % (path, exc))
|
||||
return out[0], out[1]
|
||||
|
||||
def _effective_extra_env(self, task: dict,
|
||||
extra_env: dict | None) -> dict | None:
|
||||
"""wake 生效的 extra_env:把 Gitea 同步变量并进来。
|
||||
@@ -1381,7 +1410,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
return secrets, net_entries
|
||||
|
||||
def _provision_seed_disk(self, task: dict, name: str, secrets,
|
||||
guest_env: dict | None = None) -> str | None:
|
||||
guest_env: dict | None = None,
|
||||
seed_extras: tuple | None = None) -> str | None:
|
||||
"""provisioned 模式的 seed 盘供给:生成→制盘→挂盘,
|
||||
任一步失败即抛(调用方 stop_vm 清理),纯移动。"""
|
||||
seed_disk = None
|
||||
@@ -1403,9 +1433,11 @@ class CloudHypervisorExecutor(Executor):
|
||||
delete_host_private_key,
|
||||
seed_disk_path,
|
||||
)
|
||||
extra, netcfg = seed_extras or (None, None)
|
||||
provisioned = self.provision_host_key(
|
||||
task, secret_env=secrets or None,
|
||||
guest_env=guest_env or None)
|
||||
guest_env=guest_env or None,
|
||||
user_data_extra=extra, network_config=netcfg)
|
||||
seed_disk = seed_disk_path(self.state_dir, name)
|
||||
build_seed_disk(
|
||||
provisioned["seed_dir"], seed_disk,
|
||||
@@ -1487,6 +1519,7 @@ class CloudHypervisorExecutor(Executor):
|
||||
name = self.vm_name(task)
|
||||
try:
|
||||
extra_env = self._effective_extra_env(task, extra_env)
|
||||
seed_extras = self._seed_extras()
|
||||
except ChvFailure as exc:
|
||||
self._emit("vm_failed", task,
|
||||
"vm=%s wake 失败 kind=%s" % (name, exc.kind),
|
||||
@@ -1497,7 +1530,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
task_disk, socket_path = self._wake_prepare(task, name)
|
||||
try:
|
||||
seed_disk = self._provision_seed_disk(
|
||||
task, name, secrets, guest_env=guest_env or None)
|
||||
task, name, secrets, guest_env=guest_env or None,
|
||||
seed_extras=seed_extras)
|
||||
self._boot_vm(task, name, task_disk, seed_disk, net_entries)
|
||||
except Exception:
|
||||
try:
|
||||
|
||||
+37
-2
@@ -185,10 +185,41 @@ def _guest_env_entry(merged_env: dict) -> list:
|
||||
return lines
|
||||
|
||||
|
||||
def _apply_seed_extras(seed_dir: str, user_data: str,
|
||||
user_data_extra: str | None,
|
||||
network_config: str | None) -> str:
|
||||
"""seed 可选附加项(执行器 wake 路径的客机身份/网络供给):
|
||||
user_data_extra 原样追加进 user-data(调用方用它供
|
||||
`users:` 块——chv 用户 + authorized_keys,镜像本身不
|
||||
建用户);network_config 写成 seed 目录的 network-config
|
||||
文件(NoCloud 静态 IP,tap 无 DHCP 时客机才有地址)。
|
||||
两者皆空即原样返回。user_data_extra 里含 runcmd: 行即
|
||||
ValueError——seed 禁 runcmd 是 2026-11-06 根因禁令,
|
||||
附加项也不得绕过(镜像的 99-chv-sync.cfg 靠它活着)。"""
|
||||
if user_data_extra:
|
||||
for line in user_data_extra.splitlines():
|
||||
if line.strip().startswith("runcmd:"):
|
||||
raise ValueError(
|
||||
"seed 附加 user-data 里不得带 runcmd:")
|
||||
if not user_data.endswith("\n"):
|
||||
user_data += "\n"
|
||||
user_data += user_data_extra
|
||||
if not user_data.endswith("\n"):
|
||||
user_data += "\n"
|
||||
if network_config:
|
||||
net_path = os.path.join(seed_dir, "network-config")
|
||||
with open(net_path, "w") as fh:
|
||||
fh.write(network_config)
|
||||
os.chmod(net_path, 0o600)
|
||||
return user_data
|
||||
|
||||
|
||||
def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
|
||||
public_key_text: str,
|
||||
secret_env: dict | None = None,
|
||||
guest_env: dict | None = None) -> str:
|
||||
guest_env: dict | None = None,
|
||||
user_data_extra: str | None = None,
|
||||
network_config: str | None = None) -> str:
|
||||
"""写 seed 目录:meta-data + user-data(均为 0600),
|
||||
返回 seed 目录路径。调用方负责随 VM 销毁删掉它。
|
||||
|
||||
@@ -198,7 +229,9 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
|
||||
个条目(secret 优先于同名键,防两行同名);guest_env
|
||||
装 boot 时要用的非密钥同步变量(GITEA_URL 等,见
|
||||
gitea_sync):只走 SSH 前缀到不了 cloud-init 时运行
|
||||
的 chv-sync boot。两者皆空时不写该条目。"""
|
||||
的 chv-sync boot。两者皆空时不写该条目。
|
||||
user_data_extra / network_config 见 _apply_seed_extras
|
||||
(客机用户与静态网络的供给,皆空即不写)。"""
|
||||
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")
|
||||
@@ -221,6 +254,8 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
|
||||
user_data = head + "\n".join(lines) + "\n" + marker + tail
|
||||
else:
|
||||
user_data = user_data + "\n".join(lines) + "\n"
|
||||
user_data = _apply_seed_extras(seed_dir, user_data,
|
||||
user_data_extra, network_config)
|
||||
with open(user_path, "w") as fh:
|
||||
fh.write(user_data)
|
||||
os.chmod(user_path, 0o600)
|
||||
|
||||
@@ -407,6 +407,82 @@ class WakeGuestEnvCase(unittest.TestCase):
|
||||
self.assertEqual(self.launches, [])
|
||||
|
||||
|
||||
# -- (4b) seed 附加项(users 块 + network-config)------------------------
|
||||
class SeedExtrasTest(unittest.TestCase):
|
||||
USERS_BLOCK = ("users:\n"
|
||||
" - name: chv\n"
|
||||
" sudo: ALL=(ALL) NOPASSWD:ALL\n"
|
||||
" shell: /bin/bash\n"
|
||||
" ssh_authorized_keys:\n"
|
||||
" - ssh-ed25519 AAAAfake chv-guest\n")
|
||||
NETCFG = ("version: 2\nethernets:\n ens4:\n"
|
||||
" addresses: [192.168.10.10/24]\n")
|
||||
|
||||
def test_write_seed_extras(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
seed_dir = os.path.join(tmp, "seed")
|
||||
write_seed(seed_dir, "vm1", "PRIV", "ssh-ed25519 AAAAx",
|
||||
user_data_extra=self.USERS_BLOCK,
|
||||
network_config=self.NETCFG)
|
||||
with open(os.path.join(seed_dir, "user-data")) as fh:
|
||||
content = fh.read()
|
||||
with open(os.path.join(seed_dir, "network-config")) as fh:
|
||||
net = fh.read()
|
||||
net_mode = oct(os.stat(
|
||||
os.path.join(seed_dir, "network-config")).st_mode
|
||||
& 0o777)
|
||||
parsed = yaml.safe_load(content)
|
||||
self.assertNotIn("runcmd", parsed)
|
||||
self.assertIn("users:", content)
|
||||
self.assertIn("ssh-ed25519 AAAAfake chv-guest", content)
|
||||
self.assertEqual(net, self.NETCFG)
|
||||
self.assertEqual(net_mode, "0o600")
|
||||
|
||||
def test_write_seed_extra_with_runcmd_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises(ValueError):
|
||||
write_seed(os.path.join(tmp, "seed"), "vm1",
|
||||
"PRIV", "ssh-ed25519 AAAAx",
|
||||
user_data_extra="runcmd:\n - echo hi\n")
|
||||
|
||||
def test_config_fragment_maps_seed_files(self):
|
||||
from cloud.chv.config import build_chv_config
|
||||
cfg = build_chv_config({}, {
|
||||
"CHV_SEED_USER_DATA_EXTRA_FILE": "/tmp/users.yaml",
|
||||
"CHV_SEED_NETWORK_CONFIG_FILE": "/tmp/net.yaml"})
|
||||
self.assertEqual(cfg.seed_user_data_extra_file, "/tmp/users.yaml")
|
||||
self.assertEqual(cfg.seed_network_config_file, "/tmp/net.yaml")
|
||||
self.assertIsNone(build_chv_config({}, {}).seed_user_data_extra_file)
|
||||
|
||||
|
||||
class WakeSeedExtrasCase(WakeGuestEnvCase):
|
||||
def test_wake_with_seed_extras(self):
|
||||
users_path = os.path.join(self.dir, "users.yaml")
|
||||
net_path = os.path.join(self.dir, "net.yaml")
|
||||
with open(users_path, "w") as fh:
|
||||
fh.write(SeedExtrasTest.USERS_BLOCK)
|
||||
with open(net_path, "w") as fh:
|
||||
fh.write(SeedExtrasTest.NETCFG)
|
||||
ex = self.make_exec(env_extra={
|
||||
"CHV_SEED_USER_DATA_EXTRA_FILE": users_path,
|
||||
"CHV_SEED_NETWORK_CONFIG_FILE": net_path})
|
||||
out = ex.wake_vm({"id": "extra1"})
|
||||
self.assertEqual(out["state"], "running")
|
||||
seed_dir = os.path.join(self.state_dir, "chv-extra1", "seed")
|
||||
with open(os.path.join(seed_dir, "user-data")) as fh:
|
||||
self.assertIn("ssh-ed25519 AAAAfake chv-guest", fh.read())
|
||||
with open(os.path.join(seed_dir, "network-config")) as fh:
|
||||
self.assertEqual(fh.read(), SeedExtrasTest.NETCFG)
|
||||
|
||||
def test_wake_missing_seed_extra_fails_closed(self):
|
||||
ex = self.make_exec(env_extra={
|
||||
"CHV_SEED_USER_DATA_EXTRA_FILE": "/nonexistent/users.yaml"})
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.wake_vm({"id": "extra2"})
|
||||
self.assertEqual(ctx.exception.kind, "seed_extra_missing")
|
||||
self.assertEqual(self.launches, [])
|
||||
|
||||
|
||||
# -- (5) worker ---------------------------------------------------------
|
||||
class WorkerGiteaSyncTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
Reference in new issue
Block a user