From 67ca0b86cdca14c5d02cd0d26ad5ea2d14edf546 Mon Sep 17 00:00:00 2001 From: host Date: Fri, 6 Nov 2026 16:35:14 +0000 Subject: [PATCH] =?UTF-8?q?fix(chv-guest):=20seed=20user-data=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=87=AA=E5=B8=A6=20runcmd=EF=BC=8C=E9=98=B2=E9=A1=B6?= =?UTF-8?q?=E6=8E=89=E9=95=9C=E5=83=8F=E9=92=A9=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync-test 首轮实测:seed 的 runcmd 整体替换了镜像 /etc/cloud/cloud.cfg.d/99-chv-sync.cfg 的 runcmd(cloud-init 不跨配置类型合并,merge_how 救不了 conf.d),chv-sync boot 钩子静默不跑、启动 clone 没跑。 - hostkeys.build_seed_user_data 不再发 runcmd;chmod 600 已由 write_files 0600 保证;sshd 重启搬到镜像自己的 runcmd(cloud-final,sshd 首次启动之后)。 - build.sh:99-chv-sync.cfg 的 runcmd 增加 restart ssh 一行(在 chv-sync boot 之前)。 - README §5 同步硬性要求;回归测试 2 项(无 runcmd、 带 secret 仍单 write_files 列表无 runcmd)。 - 针对性 147 项全过(test_chv_to10/gaps/secfix/fix/ arch95plus)。 本地提交,未推未部署。 --- chv-guest/README.md | 9 +++++++- chv-guest/build.sh | 2 +- cloud/chv/hostkeys.py | 29 ++++++++++++++++++------- cloud/tests/test_chv_gaps.py | 41 ++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/chv-guest/README.md b/chv-guest/README.md index be3b8b8..10e95b4 100644 --- a/chv-guest/README.md +++ b/chv-guest/README.md @@ -83,7 +83,14 @@ KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出 - 装 cloud-init 且支持 NoCloud seed(seed 磁盘(CIDATA ISO,feat-chv-fix 起挂进 VmConfig 第二块只读盘)由执行器在 VM 启动前生成,含 meta-data 与 user-data); - 首启时先应用 seed 再启动 sshd,seed 里的 user-data - 把该 VM 的 host key 对写进 /etc/ssh/ 并重启 sshd; + 把该 VM 的 host key 对写进 /etc/ssh/(write_files, + 0600);seed 里绝不能带 `runcmd:`——user-data 的 + runcmd 会整体替换镜像 `/etc/cloud/cloud.cfg.d/` 里 + 的 runcmd(cloud-init 不跨配置类型合并),旧版 seed + 的 restart ssh 两行就这样顶掉了 `99-chv-sync.cfg` + 的 `chv-sync boot` 钩子(sync-test 首轮实测);sshd + 重启由镜像自己的 runcmd(`99-chv-sync.cfg`,cloud-final + 阶段)做,见 `cloud/chv/hostkeys.py` 根因禁令; - 镜像里已钉死 `ssh_deletekeys: false`(build.sh 已写),首启 不得无条件重新生成 host key——重生成会使预置的 known_hosts 立即失效,SSH 钉死直接拒绝,这是设计,不是 bug;无预置 key diff --git a/chv-guest/build.sh b/chv-guest/build.sh index 0fb84d3..aa01c31 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -244,7 +244,7 @@ VC_ARGS=(-a "$OUT/disk.raw" --copy-in "$REPO_DIR/cloud:/app/" --copy-in "$SCRIPT_DIR/chv-sync.sh:/usr/local/bin/" --run-command 'mv /usr/local/bin/chv-sync.sh /usr/local/bin/chv-sync && chmod +x /usr/local/bin/chv-sync' - --run-command 'printf "runcmd:\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 - /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/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index 34c40d0..7575b4c 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -128,7 +128,22 @@ def build_seed_user_data(private_key_text: str, public_key_text: str) -> str: 注意:user-data 本身含私钥,只写进 per-VM seed 目录 (0600),随 VM 销毁而删,绝不进镜像、绝不进日志、 - 绝不进任务 prompt 落库。""" + 绝不进任务 prompt 落库。 + + 根因禁令(sync-test 残留,2026-11-06 实测):seed 的 + user-data 里绝不能带 `runcmd:`。cloud-init 不跨配置 + 类型合并——user-data 的 `runcmd` 会整体替换掉镜像 + `/etc/cloud/cloud.cfg.d/` 里的 `runcmd`(`merge_how` + 只合并多个 user-data 段,救不了 conf.d,见 cloud-init + 合并文档);旧版 seed 自带的 chmod + restart ssh 两行 + 就这样顶掉了镜像 `99-chv-sync.cfg` 的 `chv-sync boot` + 钩子,首轮启动 clone 静默不跑。去掉的两行不需要 + 补在 seed 里:chmod 600 已由 write_files 的 + permissions '0600' 保证;sshd 重启已搬到镜像自己的 + runcmd(`99-chv-sync.cfg`,cloud-final 阶段,sshd + 首次启动之后),那条是存活的列表,不会被 seed 覆盖。 + 任何人再给 seed 加 runcmd 即重犯此 bug,回归测试 + `test_seed_user_data_has_no_runcmd` 会拦住。""" if not private_key_text or not public_key_text: raise ValueError("host key 对缺失,无法生成 seed") lines = [ @@ -150,11 +165,6 @@ def build_seed_user_data(private_key_text: str, public_key_text: str) -> str: ] for line in public_key_text.rstrip("\n").split("\n"): lines.append(" " + line) - lines += [ - "runcmd:", - " - chmod 600 /etc/ssh/ssh_host_ed25519_key", - " - systemctl restart ssh || service ssh restart || true", - ] return "\n".join(lines) + "\n" @@ -183,8 +193,11 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str, if secret_env: # 注意:只能把条目并进已有的 write_files 列表,绝不能 # 再写一个 write_files: 键(YAML 重复键会覆盖掉 host - # key 的两条写文件条目,预置即失效)。条目插在 runcmd - # 段之前、仍属同一列表。 + # key 的两条写文件条目,预置即失效)。seed 自 2026-11-07 + # 起不再带 runcmd 段(见 build_seed_user_data 的根因 + # 禁令),所以正常走 else 分支追加到 write_files 列表 + # 末尾;marker 分支只作防御(将来模板若恢复 runcmd, + # 条目仍插在其前、仍属同一列表)。 lines = [ " - path: %s" % GUEST_ENV_PATH, " permissions: '0600'", diff --git a/cloud/tests/test_chv_gaps.py b/cloud/tests/test_chv_gaps.py index 471fb0e..c7d067b 100644 --- a/cloud/tests/test_chv_gaps.py +++ b/cloud/tests/test_chv_gaps.py @@ -15,6 +15,8 @@ import unittest from pathlib import Path from types import SimpleNamespace +import yaml + try: from cloud.chv.hostkeys import ( build_seed_user_data, @@ -330,6 +332,45 @@ class HostKeyProvisionTest(ChvGapCase): with self.assertRaises(ValueError): build_seed_user_data("", "ssh-ed25519 AAAAx") + def test_seed_user_data_has_no_runcmd(self): + # 根因回归(sync-test 残留,2026-11-06 实测):seed + # 的 user-data 里绝不能带 runcmd——cloud-init 不跨配 + # 置类型合并,user-data 的 runcmd 会整体替换镜像 + # /etc/cloud/cloud.cfg.d/ 里的 runcmd,旧版 seed 的 + # 两行就这样顶掉了 99-chv-sync.cfg 的 chv-sync boot + # 钩子,首轮启动 clone 静默不跑。sshd 重启已搬到 + # 镜像自己的 runcmd(build.sh 写 99-chv-sync.cfg)。 + text = build_seed_user_data("PRIV\n", "ssh-ed25519 AAAAx\n") + parsed = yaml.safe_load(text) + self.assertNotIn("runcmd", parsed) + self.assertNotIn("runcmd:", text) + self.assertNotIn("systemctl restart", text) + self.assertNotIn("merge_how", parsed) + # write_files 两条 key 条目仍在,chmod 靠其 0600 保证。 + self.assertEqual(len(parsed["write_files"]), 2) + self.assertEqual( + parsed["write_files"][0]["permissions"], "0600") + + def test_write_seed_with_secret_has_no_runcmd(self): + # 带 secret_env 时 guest-env 条目仍须并进同一个 + # write_files 列表(单 write_files 键),且 user-data + # 里仍不能出现 runcmd。 + with tempfile.TemporaryDirectory() as tmp: + seed_dir = os.path.join(tmp, "seed") + write_seed(seed_dir, "vm1", "PRIV", "ssh-ed25519 AAAAx", + secret_env={"GITEA_TOKEN": "s3cr3t v"}) + with open(os.path.join(seed_dir, "user-data")) as fh: + content = fh.read() + self.assertEqual(content.count("write_files:"), 1) + parsed = yaml.safe_load(content) + self.assertNotIn("runcmd", parsed) + self.assertEqual(len(parsed["write_files"]), 3) + paths = [e["path"] for e in parsed["write_files"]] + self.assertIn("/etc/chv/guest-env", paths) + # 密钥值经 shlex.quote 引用后才进文件(值本身在 guest-env + # 条目内是设计内的,见 hostkeys.write_seed)。 + self.assertIn("GITEA_TOKEN='s3cr3t v'", content) + def test_keyscan_is_weak_fallback_only(self): # provisioned 模式下 wake 前必须预置;keyscan 不应被 # 当成生产路径:配置值本身可解析,且文档/代码里标弱。