From 717efc4688ac902496151663d54744c7044ee70e Mon Sep 17 00:00:00 2001 From: host Date: Sat, 7 Nov 2026 00:48:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(chv-muse-round11):=20=E7=A7=BB=E6=A4=8D=20?= =?UTF-8?q?V-9=20=E7=9C=9F=E6=9C=BA=E4=BF=AE=E5=A4=8D=EF=BC=889f929b1/912e?= =?UTF-8?q?def/9fb0f4b=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lockfile 两脚本 DECLARED_SOURCES 加 P2 三件套 chv-cell/broker/ browser -> repo_commit(9f929b1)。 - build.sh 99-chv-sync.cfg runcmd 在 chv-sync boot 之前加 chown 行:guest-env 由 write_files 以 root:root 写(建用户之前), 须在 final 阶段改归 chv(912edef;本分支 runcmd 多一行 start chv-authd,chown 插在它之后、boot 之前)。 - executor._wait_guest_env_usable:密钥已投递时等客内 guest-env 对 SSH 用户可读再报 ready,超时关 VM 抛 guest_env_not_ready (9fb0f4b;runcmd chown 比 SSH 就绪晚约 30 秒)。 - hostkeys.py:本分支 _guest_env_entry 本来就写死 owner root:root(P3 重构时已无 ssh_user 分支),912edef 的 hostkeys 改动无内容可移,只补 V-9 根因注释防回退。 - 测试(5 个新测试,8 个运行):owner 恒 root:root、build.sh chown 位置、chv 用户下 wake 后 owner 仍 root、等到可读、 永不可读抛 guest_env_not_ready。 全量 cloud 套件 2019 OK(skipped=8)。 --- chv-guest/build.sh | 2 +- chv-guest/check_lockfile.py | 5 ++ chv-guest/generate_lockfile.py | 5 ++ cloud/chv/executor.py | 47 ++++++++++++- cloud/chv/hostkeys.py | 8 ++- cloud/tests/test_chv_gitea_sync.py | 104 ++++++++++++++++++++++++++++- 6 files changed, 165 insertions(+), 6 deletions(-) diff --git a/chv-guest/build.sh b/chv-guest/build.sh index 09a9fc6..6cc41d5 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -304,7 +304,7 @@ VC_ARGS=(-a "$OUT/disk.raw" --copy-in "$SCRIPT_DIR/chv-authd.service:/etc/systemd/system/" --run-command 'mv /usr/local/bin/authd.py /usr/local/bin/chv-authd && chmod +x /usr/local/bin/chv-authd /usr/local/bin/git-credential-chv-authd' --run-command 'systemctl enable chv-authd || true' - --run-command 'printf "runcmd:\n - systemctl restart ssh || service ssh restart || true\n - systemctl start chv-authd || true\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg' + --run-command 'printf "runcmd:\n - systemctl restart ssh || service ssh restart || true\n - systemctl start chv-authd || true\n - test -f /etc/chv/guest-env && chown chv:chv /etc/chv/guest-env || true\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg' --run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main' --run-command "$GT_INSTALL_CMD" --run-command '/usr/local/bin/gt version > /app/guest-versions-gt.txt' diff --git a/chv-guest/check_lockfile.py b/chv-guest/check_lockfile.py index 53492ef..98bd1b8 100644 --- a/chv-guest/check_lockfile.py +++ b/chv-guest/check_lockfile.py @@ -25,6 +25,11 @@ DECLARED_SOURCES = { "/app/host.py": "repo_commit", "gt": "gt_version", "chv-sync": "repo_commit", + # 对齐 Muse P2:nspawn 单元启动器 + broker + 浏览器子代理, + # 均是仓库内脚本,版本随 repo_commit(与 chv-sync 同款)。 + "chv-cell": "repo_commit", + "chv-broker": "repo_commit", + "chv-browser": "repo_commit", } diff --git a/chv-guest/generate_lockfile.py b/chv-guest/generate_lockfile.py index cfe3c77..8876799 100644 --- a/chv-guest/generate_lockfile.py +++ b/chv-guest/generate_lockfile.py @@ -46,6 +46,11 @@ DECLARED_SOURCES = { "/app/host.py": "repo_commit", "gt": "gt_version", "chv-sync": "repo_commit", + # 对齐 Muse P2:nspawn 单元启动器 + broker + 浏览器子代理, + # 均是仓库内脚本,版本随 repo_commit(与 chv-sync 同款)。 + "chv-cell": "repo_commit", + "chv-broker": "repo_commit", + "chv-browser": "repo_commit", } diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index f70f51c..48c75fa 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -1415,6 +1415,43 @@ class CloudHypervisorExecutor(Executor): "guest SSH 就绪超时:%d 次轮询内 sshd 未通,已关 VM" % attempts) + def _wait_guest_env_usable(self, task: dict) -> None: + """密钥已投递时,等客内 guest-env 对 SSH 用户可读再返回: + write_files 以 root:root 写文件(见 hostkeys.py—— + write_files 在 users-groups 之前跑,owner 只能写 root), + 客内 final 阶段的 99-chv-sync.cfg runcmd 才 chown 给 + SSH 用户;SSH 就绪(vm_ready)比 final 阶段早约 30 秒 + (V-9 真机:runcmd 脚本在 vm_ready 后约 31 秒才执行), + 派发若在这窗口内 source 文件会 Permission denied。 + 判定用 `test -r`(SSH 用户本人可读与否,正是派发 + source 的前置条件),不比对 owner 名。无密钥投递时 + 不等待、不轮询。超时先关 VM 再抛 guest_env_not_ready, + 绝不带着读不到的密钥去跑任务。移植自 V-9(9fb0f4b)。""" + if not self._delivered_secrets(self.vm_name(task)): + return + from cloud.chv.hostkeys import GUEST_ENV_PATH + attempts = max(1, int(self.ready_timeout / self.ready_interval)) + for attempt in range(attempts): + try: + result = self._call_runner(self.build_ssh_command( + task, ["test", "-r", GUEST_ENV_PATH])) + except Exception: + result = None + if (result is not None + and getattr(result, "returncode", 1) == 0): + return + if attempt + 1 < attempts: + self.sleeper(self.ready_interval) + try: + self.stop_vm(task) + except Exception: + pass + raise ChvFailure( + "guest_env_not_ready", + "guest-env 在 %d 次轮询内仍对 SSH 用户不可读,已关 VM:" + "runcmd chown 未生效或 cloud-init 未完成" + % attempts) + def _guest_host_command(self, *args, model=None, effort=None, approval=None, sandbox=None, model_provider=None): @@ -1820,7 +1857,9 @@ class CloudHypervisorExecutor(Executor): def _boot_vm(self, task, name, task_disk, seed_disk, net_entries) -> None: - """VMM 启动→vm.create→vm.boot→SSH 就绪,纯移动。""" + """VMM 启动→vm.create→vm.boot→SSH 就绪→密钥已投递时 + 等 guest-env 对 SSH 用户可读(V-9 移植,见 + _wait_guest_env_usable),纯移动。""" socket_path = self.api_socket(task) self._launch_api_only(task) self.wait_api_ready(task) @@ -1849,6 +1888,9 @@ class CloudHypervisorExecutor(Executor): "vm.boot 失败:status=%s" % status) self._emit("vm_boot", task, "vm=%s" % name) self.wait_guest_ready(task) + # V-9 移植:密钥已投递时等 guest-env 对 SSH 用户可读再报 + # 就绪(runcmd chown 比 SSH 就绪晚约 30 秒)。 + self._wait_guest_env_usable(task) def _wake_prepare(self, task: dict, name: str) -> tuple: """wake 准备:reuse 审计、旧 VM 清理、任务盘供给、 @@ -1878,7 +1920,8 @@ class CloudHypervisorExecutor(Executor): preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 → launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 → PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest - SSH 就绪。任一步失败都关 VM(stop_vm)再抛 + SSH 就绪 → 密钥已投递时再等 guest-env 对 SSH 用户可读 + (见 _wait_guest_env_usable,V-9 移植)。任一步失败都关 VM(stop_vm)再抛 ChvFailure,绝不带着半醒的 VM 去跑任务。 成功返回 {vm, api_socket, state: "running", ready: True, mode: "cold"},并审计 vm_create/vm_boot/vm_ready。 diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index 8973ca1..853a4b4 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -213,7 +213,13 @@ def _guest_env_entry(merged_env: dict) -> list: """guest-env 的 write_files 条目行(0600 root,值经 shlex.quote 引用):只能并进已有的 write_files 列表, 绝不能再写一个 write_files: 键(YAML 重复键会覆盖掉 - host key 的两条写文件条目,预置即失效)。""" + host key 的两条写文件条目,预置即失效)。 + owner 恒为 root:root(V-9 真机根因:cloud-init 的 config + 阶段里 write_files 在 users-groups 之前跑,owner 写 + chv:chv 时 getpwnam('chv') 尚不存在,整个 write_files + 模块失败、guest-env 文件根本不产生)。文件改归 SSH 用户 + 由客内 final 阶段 99-chv-sync.cfg runcmd 在 chv-sync + boot 之前 chown(用户那时已建好),见 chv-guest/build.sh。""" lines = [ " - path: %s" % GUEST_ENV_PATH, " permissions: '0600'", diff --git a/cloud/tests/test_chv_gitea_sync.py b/cloud/tests/test_chv_gitea_sync.py index 3478b8d..12f303e 100644 --- a/cloud/tests/test_chv_gitea_sync.py +++ b/cloud/tests/test_chv_gitea_sync.py @@ -17,6 +17,9 @@ VM、不联网、不含任何真实 token(测试值全是假值)。 GITEA_TOKEN 且 argv 里绝无 token 值; - worker:_gitea_sync_env 取值;_run_attempt 只对 CHV 执行器 把同步变量并进 gateway_env。 +- V-9 移植:guest-env owner 恒 root:root(write_files 先于建用户) + + 客内 runcmd chown(build.sh 位置断言)+ wake 等 guest-env + 可读再报 ready(超时关 VM 抛 guest_env_not_ready)。 """ import os @@ -279,6 +282,47 @@ class WriteSeedGuestEnvTest(unittest.TestCase): self.assertNotIn("runcmd", parsed) self.assertEqual(len(parsed["write_files"]), 3) + def test_guest_env_owner_always_root(self): + # V-9 移植:write_files 在 users-groups 之前跑,owner + # 写 chv:chv 时 getpwnam 失败、整个模块失败,文件根本 + # 不产生;故 owner 恒为 root:root,改归 SSH 用户由客内 + # final 阶段 99-chv-sync.cfg runcmd chown(本分支的 + # write_seed 本来就没有 ssh_user 参数,owner 写死 + # root:root——此测试是防回退断言)。 + with tempfile.TemporaryDirectory() as tmp: + seed_dir = os.path.join(tmp, "seed") + write_seed(seed_dir, "vm1", "PRIV", "ssh-ed25519 AAAAx", + secret_env={"SUB2API_API_KEY": "s3cr3t"}) + with open(os.path.join(seed_dir, "user-data")) as fh: + parsed = yaml.safe_load(fh.read()) + guest = [e for e in parsed["write_files"] + if e["path"] == "/etc/chv/guest-env"] + self.assertEqual(len(guest), 1) + self.assertEqual(guest[0]["owner"], "root:root") + self.assertEqual(guest[0]["permissions"], "0600") + + +# -- (3b) build.sh:客内 runcmd chown -------------------------------------- +ROOT = Path(__file__).resolve().parents[2] + + +class BuildShGuestEnvChownTest(unittest.TestCase): + def test_build_sh_runcmd_chowns_guest_env_before_sync_boot(self): + # V-9 移植:guest-env 由 write_files 以 root:root 写 + # (建用户之前),须在 final 阶段 chv-sync boot 之前 + # 改归 chv,否则 SSH 派发 source 它会 Permission + # denied;无文件时 test -f 护栏使该行空操作,runcmd + # 失败不影响启动。 + text = (ROOT / "chv-guest" / "build.sh").read_text() + chown = ("test -f /etc/chv/guest-env && " + "chown chv:chv /etc/chv/guest-env || true") + self.assertIn(chown, text) + # "chv-sync boot" 在文件前部注释里先出现过一次,故从 + # chown 位置往后找同一 runcmd 行里的 boot。 + chown_at = text.index(chown) + boot_at = text.index("/usr/local/bin/chv-sync boot", chown_at) + self.assertLess(chown_at, boot_at) + # -- (4) 执行器 wake_vm ------------------------------------------------- class WakeGuestEnvCase(unittest.TestCase): @@ -324,7 +368,7 @@ class WakeGuestEnvCase(unittest.TestCase): self.launches.append(list(cmd)) return FakeProc(alive=True) - def make_exec(self, env_extra=None): + def make_exec(self, env_extra=None, runner=None, ssh_user=None): env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", "CHV_HOST_KEY_MODE": "provisioned", "CHV_SSH_FORWARD": "1", @@ -338,9 +382,11 @@ class WakeGuestEnvCase(unittest.TestCase): kernel=self.kernel, rootfs=self.rootfs, ssh_key=self.ssh_key, kvm_path=self.kvm, cpuinfo_path=self.cpuinfo, state_dir=self.state_dir, - runner=self._runner, launcher=self._launcher, + runner=runner if runner is not None else self._runner, + launcher=self._launcher, api_client=lambda s, m, p, payload=None: (200, {}), sleeper=lambda s: None, env=env, + ssh_user=ssh_user, sentinel_resolver=lambda host, port: ["93.184.216.34"]) ex.audit_callback = lambda e, t, d, k=None: self.audit.append( (e, t.get("id") if isinstance(t, dict) else t, d, k)) @@ -456,6 +502,60 @@ class WakeGuestEnvCase(unittest.TestCase): ex.wake_vm({"id": "sync6", "gitea_sync": dict(FAKE_SYNC)}) self.assertEqual(self.launches, []) + def test_wake_with_chv_ssh_user_guest_env_owner_still_root(self): + # V-9 移植:owner 恒为 root:root(write_files 先于建用户), + # 改归 chv 由客内 99-chv-sync.cfg runcmd chown——即使 + # SSH 用户就是 chv,seed 里的 owner 也不许写 chv:chv。 + ex = self.make_exec(ssh_user="chv") + out = ex.wake_vm({"id": "envown1"}, + extra_env={"SUB2API_API_KEY": "s3cr3t"}) + self.assertEqual(out["state"], "running") + parsed = yaml.safe_load( + _seed_user_text(self.state_dir, "chv-envown1")) + guest = [e for e in parsed["write_files"] + if e["path"] == "/etc/chv/guest-env"] + self.assertEqual(len(guest), 1) + self.assertEqual(guest[0]["owner"], "root:root") + + def _readable_runner(self, answers): + """Fake runner:`test -r /etc/chv/guest-env` 按 answers + 依次回可读与否(用完后重复最后一个),其余命令走默认 + runner。answers 是 bool 列表。移植自 V-9(9fb0f4b)。""" + state = {"n": 0} + + def run(cmd, timeout=None): + if ("test" in cmd + and cmd[-1] == "/etc/chv/guest-env"): + idx = min(state["n"], len(answers) - 1) + state["n"] += 1 + return FakeResult(0 if answers[idx] else 1, "", "") + return self._runner(cmd, timeout) + run.readable_calls = lambda: state["n"] + return run + + def test_wake_waits_for_guest_env_readable(self): + # V-9 移植:runcmd chown 比 SSH 就绪晚约 30 秒,wake 须等 + # guest-env 对 SSH 用户可读再报 ready,否则首个派发 + # source 它即 Permission denied。 + runner = self._readable_runner([False, False, True]) + ex = self.make_exec(runner=runner, ssh_user="chv") + out = ex.wake_vm({"id": "envwait1"}, + extra_env={"SUB2API_API_KEY": "s3cr3t"}) + self.assertEqual(out["state"], "running") + self.assertEqual(runner.readable_calls(), 3) + + def test_wake_fails_when_guest_env_never_readable(self): + # V-9 移植:guest-env 一直对 SSH 用户不可读时 wake 超时即 + # 关 VM 再抛 guest_env_not_ready,绝不带着读不到的密钥 + # 跑任务。 + runner = self._readable_runner([False]) + ex = self.make_exec(runner=runner, ssh_user="chv") + with self.assertRaises(ChvFailure) as ctx: + ex.wake_vm({"id": "envwait2"}, + extra_env={"SUB2API_API_KEY": "s3cr3t"}) + self.assertEqual(ctx.exception.kind, "guest_env_not_ready") + self.assertGreater(runner.readable_calls(), 1) + # -- (4b) seed 附加项(users 块 + network-config)------------------------ class SeedExtrasTest(unittest.TestCase):