diff --git a/chv-guest/README.md b/chv-guest/README.md index 10e95b4..5646bdd 100644 --- a/chv-guest/README.md +++ b/chv-guest/README.md @@ -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 配置。 diff --git a/cloud/chv/config.py b/cloud/chv/config.py index 0fdbf2d..a9d2554 100644 --- a/cloud/chv/config.py +++ b/cloud/chv/config.py @@ -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), ]) diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index 764f568..efff2f1 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -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: diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index 5898308..e6e716d 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -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) diff --git a/cloud/tests/test_chv_gitea_sync.py b/cloud/tests/test_chv_gitea_sync.py index fec9fd6..fa812a5 100644 --- a/cloud/tests/test_chv_gitea_sync.py +++ b/cloud/tests/test_chv_gitea_sync.py @@ -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):