carry-over: 在途 V-9 根因修复 + Muse P2 对齐原样带入(未改一行业务逻辑)

来源:feat-chv-muse-p2 @ c918d69 的 6 个未提交文件(+157/-29):
build.sh(GT 哈希轮换 + 99-chv-sync.cfg runcmd chown)、
check_lockfile.py / generate_lockfile.py(DECLARED_SOURCES 加
chv-cell/chv-broker/chv-browser)、executor.py(_wait_guest_env_usable)、
hostkeys.py(guest-env owner 恒 root:root)、test_chv_v3_fixes.py。
单测:python3 -m unittest cloud.tests.test_chv_v3_fixes 38/38 通过。
本提交只搬运,不含阶段 1 改动;阶段 1 另起提交。
This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent 1836c5af32
commit c904ae9358
6 files changed
+157 -29

No files matched your search

+2 -2
View File
@@ -105,7 +105,7 @@ CHROMIUM_HEADLESS_SHELL_REV="${CHROMIUM_HEADLESS_SHELL_REV:-}"
if [ -n "$CHROMIUM_HEADLESS_SHELL_REV" ] && ! printf '%s' "$CHROMIUM_HEADLESS_SHELL_REV" | grep -Eq '^[0-9]+$'; then
echo "CHROMIUM_HEADLESS_SHELL_REV 须为纯数字修订号(如 1248):$CHROMIUM_HEADLESS_SHELL_REV" >&2; exit 1
fi
GT_INSTALL_SH_SHA256="${GT_INSTALL_SH_SHA256:-cf82acecc0e3ffa6f690d33902eefcf1cc21a5b11004103bf23c4436664288d5}"
GT_INSTALL_SH_SHA256="${GT_INSTALL_SH_SHA256:-49ffe0909be9117e36cf7c8a35b5937939639090322063cbad7667e0bde7c9e9}"
# B-03/B-04:输入格式校验(干跑与全量都做,报错点提前,
# 不等到 curl 404 或 sha256sum -c 才失败)。
if ! printf '%s' "$UBUNTU_VERSION" | grep -Eq '^[0-9]+\.[0-9]+$'; then
@@ -285,7 +285,7 @@ VC_ARGS=(-a "$OUT/disk.raw"
--copy-in "$SCRIPT_DIR/chv-broker.py:/usr/local/bin/"
--copy-in "$SCRIPT_DIR/chv-browser.py:/usr/local/bin/"
--run-command 'mv /usr/local/bin/chv-cell.py /usr/local/bin/chv-cell && mv /usr/local/bin/chv-broker.py /usr/local/bin/chv-broker && mv /usr/local/bin/chv-browser.py /usr/local/bin/chv-browser && chmod +x /usr/local/bin/chv-cell /usr/local/bin/chv-broker /usr/local/bin/chv-browser'
--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 'printf "runcmd:\n - systemctl restart ssh || service ssh restart || 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'
+5
View File
@@ -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",
}
+5
View File
@@ -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",
}
+47 -3
View File
@@ -1461,6 +1461,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,
绝不带着读不到的密钥去跑任务。"""
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):
@@ -1722,7 +1759,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)
@@ -1751,6 +1790,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 清理、任务盘供给、
@@ -1796,8 +1838,10 @@ class CloudHypervisorExecutor(Executor):
preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 →
launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 →
PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest
SSH 就绪。任一步失败都关 VM(stop_vm)再抛
ChvFailure,绝不带着半醒的 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。
单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新
+23 -16
View File
@@ -231,19 +231,24 @@ def _apply_seed_extras(seed_dir: str, user_data: str,
return user_data
def _insert_guest_env_entry(user_data: str, merged_env: dict,
ssh_user: str | None) -> str:
def _insert_guest_env_entry(user_data: str, merged_env: dict) -> str:
"""把 guest-env 条目并进 user-data 已有的 write_files
列表(绝不再写一个 write_files: 键,见 _guest_env_entry)。
owner:SSH 用户非 root 时写 "<用户>:<用户>"(0600 不变),
否则 root:root。条目插在 runcmd 段之前、仍属同一
write_files 列表(seed 自 2026-11-07 起不再带 runcmd,
见 build_seed_user_data 的根因禁令;marker 分支只作
防御,将来模板若恢复 runcmd 仍插在其前)。"""
owner = "root:root"
if ssh_user and ssh_user != "root":
owner = "%s:%s" % (ssh_user, ssh_user)
lines = _guest_env_entry(merged_env, owner=owner)
owner 恒为 root:root——V-9 真机根因(2026-11-07):
cloud-init 的 config 阶段里 write_files 在 users-groups
之前跑,owner 写 chv:chv 时 getpwnam('chv') 尚不存在,
整个 write_files 模块失败(cloud-init.log:
OSError('Unknown user or group: "getpwnam(): name not
found: \'chv\'"')),guest-env 文件根本不产生(host
key 两条 root:root 的条目在前、已写入,所以 PUBKEY-MATCH
仍 True——只看钉死匹配会漏判此缺陷)。文件改归实际读
它的 SSH 用户,由客内 final 阶段的 99-chv-sync.cfg
runcmd 在 chv-sync boot 之前 chown(用户那时已建好),
见 chv-guest/build.sh。条目插在 runcmd 段之前、仍属
同一 write_files 列表(seed 自 2026-11-07 起不再带
runcmd,见 build_seed_user_data 的根因禁令;marker
分支只作防御,将来模板若恢复 runcmd 仍插在其前)。"""
lines = _guest_env_entry(merged_env, owner="root:root")
marker = "runcmd:\n"
if marker in user_data:
head, tail = user_data.split(marker, 1)
@@ -268,9 +273,12 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
装 boot 时要用的非密钥同步变量(GITEA_URL 等,见
gitea_sync):只走 SSH 前缀到不了 cloud-init 时运行
的 chv-sync boot。两者皆空时不写该条目。
ssh_user:远程 shell 的用户;非 root 时 guest-env 条目
owner 写 "<用户>:<用户>"(0600 不变),否则该用户读
不到文件(V-4);root/未给时仍 root:root。
ssh_user:远程 shell 的用户。guest-env 条目 owner 恒为
root:root(V-9 根因:write_files 在 users-groups 之前跑,
owner 写非 root 用户时该用户尚不存在、整个模块失败),
文件改归 SSH 用户由客内 final 阶段 99-chv-sync.cfg
runcmd 在 chv-sync boot 之前 chown,见
_insert_guest_env_entry 与 chv-guest/build.sh。
user_data_extra / network_config 见 _apply_seed_extras
(客机用户与静态网络的供给,皆空即不写)。"""
os.makedirs(seed_dir, exist_ok=True)
@@ -288,8 +296,7 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
merged_env = dict(guest_env or {})
merged_env.update(secret_env or {})
if merged_env:
user_data = _insert_guest_env_entry(
user_data, merged_env, ssh_user)
user_data = _insert_guest_env_entry(user_data, merged_env)
user_data = _apply_seed_extras(seed_dir, user_data,
user_data_extra, network_config)
atomic_write_text(user_path, user_data)
+75 -8
View File
@@ -10,7 +10,10 @@
不重应用 = host_key_mismatch);
- F2:跨实例 stop 按 socket 进程扫描清未登记 VMM;
- V-4:remote_command source 失败 fail-fast(exit 126),
guest-env 归 SSH 用户所有;
guest-env 归 SSH 用户所有;V-9:owner 恒 root:root(write_files
先于建用户)+ 客内 runcmd chown,wake 在密钥投递后等
guest-env 对 SSH 用户可读再报 ready(runcmd chown 比 SSH
就绪晚约 30 秒),超时关 VM 再抛 guest_env_not_ready;
- V-1 附带:build.sh 预建 /app/work(1777 粘滞位 + 断言);
- V-1 附带:host.py default_codex_bin() 走 PATH 查找。
- V-7 复修(feat-chv-v3-repair):D1 snapshot 先建目录(旧
@@ -434,12 +437,14 @@ class GuestEnvV4Test(V3Case):
tokens = remote_command(["true"])
self.assertEqual(tokens, ["true"])
def test_write_seed_owner_follows_ssh_user(self):
def test_write_seed_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。
import yaml
for ssh_user, want_owner in (
(None, "root:root"), ("root", "root:root"),
("chv", "chv:chv")):
seed_dir = os.path.join(self.dir, "seed-%s" % want_owner)
for ssh_user in (None, "root", "chv"):
seed_dir = os.path.join(self.dir, "seed-%s" % ssh_user)
write_seed(seed_dir, "vm1", "PRIV",
"ssh-ed25519 AAAAx",
secret_env={"SUB2API_API_KEY": "s3cr3t"},
@@ -449,7 +454,7 @@ class GuestEnvV4Test(V3Case):
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"], want_owner)
self.assertEqual(guest[0]["owner"], "root:root")
self.assertEqual(guest[0]["permissions"], "0600")
def test_provisioned_wake_writes_guest_env_for_chv_user(self):
@@ -467,7 +472,54 @@ class GuestEnvV4Test(V3Case):
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"], "chv:chv")
# V-9:owner 恒为 root:root(write_files 先于建用户),
# 改归 chv 由客内 99-chv-sync.cfg runcmd chown。
self.assertEqual(guest[0]["owner"], "root:root")
def _readable_runner(self, answers):
"""Fake runner:`test -r /etc/chv/guest-env` 按 answers
依次回可读与否(用完后重复最后一个),其余命令走默认
runner。answers 是 bool 列表。"""
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._default_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,
env_extra={"CHV_HOST_KEY_MODE": "provisioned"},
ssh_user="chv")
out = ex.wake_vm(self._task(),
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,
env_extra={"CHV_HOST_KEY_MODE": "provisioned"},
ssh_user="chv")
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task(),
extra_env={"SUB2API_API_KEY": "s3cr3t"})
self.assertEqual(ctx.exception.kind, "guest_env_not_ready")
self.assertGreater(runner.readable_calls(), 1)
# -- V-1 附带:build.sh /app/work + host.py default_codex_bin --------------
@@ -478,6 +530,21 @@ class GuestBuildAndHostBinV3Test(unittest.TestCase):
self.assertIn("chmod 1777 /app/work", text)
self.assertIn('stat -c %a /app/work', text)
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)
def test_build_sh_dry_run_prints_app_work_step(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)