ci / test (push) Successful in 1h8m45s
全量复核 4676 项出现 4 失败 6 错误,全是集成时翻为默认开启的 严格快照校验(设计 §2.2)的连锁反应——旧测试用未登记/未标记 快照走 legacy 放行路径。按新语义逐项适配,不改生产校验本身: - test_chv_opt_stage1.CrossTenantRestoreTest:跨租户拒绝的审计 事件改断言 vm_restore_denied(严格路径的设计事件; vm_restore_tenant_denied 只在显式关闭严格校验时出现); legacy 警告放行测试改走显式 CHV_SNAPSHOT_STRICT_TENANT=0。 - test_chv_v3_fixes(5 个 restore 测试):登记带匹配 tenant (proj1)的快照,意图不变(盘预检 / source_url / 清旧 VMM 仍由各自断言覆盖)。 - test_cloud_hypervisor.LifecycleTest:登记 file:///snap/1 (tenant proj1),restore/resume 顺序断言不变。 - 长函数:B 轨合入把 _run_with_persistent_vm 推到 53 行,超 50 行架构上限;finally 收尾块抽为 _suspend_persistent_after_task。 针对性 12 项全部通过;全量待重跑。
849 lines
36 KiB
Python
849 lines
36 KiB
Python
"""CHV 第三棒(feat-chv-v3)回归测试:WSL 真机 V-1~V-4/F1/F2
|
||
实测缺陷的代码层回归。
|
||
|
||
- V-3① snapshot 传裸 URL(source_url= 是 restore 的键);
|
||
- V-3② 快照产物是目录(v54),校验改为目录非空;
|
||
- V-3③ restore 起 VMM 前清旧 VMM + 旧 socket;
|
||
- V-3 附带:hibernate 留盘(快照 config.json 引用原盘),
|
||
GC 不删有登记快照的 VM 的盘,restore 前预检快照引用的盘;
|
||
- F1:re-wake 删旧盘(H5 删私钥致新 key 对 + 旧盘 cloud-init
|
||
不重应用 = host_key_mismatch);
|
||
- F2:跨实例 stop 按 socket 进程扫描清未登记 VMM;
|
||
- V-4:remote_command source 失败 fail-fast(exit 126),
|
||
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 先建目录(旧
|
||
fixture 预建目录 + mock ch-remote 不校验,458 测试全过也
|
||
盖不住缺失目录的真实路径)、D2 hibernate 留 seed.iso +
|
||
残留清理保护有登记快照的 VM 的 seed、F1 任务 SSH 连接建立
|
||
阶段瞬时 reset 重试恰好 1 次。
|
||
|
||
全部用假 runner/launcher/api_client + 真实 /proc 扫描(F2
|
||
的被杀进程是真实 spawn 的 sleep 进程,argv[0] 伪装成
|
||
cloud-hypervisor),绝不启动真实 VM。
|
||
"""
|
||
|
||
import importlib.util
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
try:
|
||
from cloud.chv.disk import record_snapshot
|
||
from cloud.chv.executor import CloudHypervisorExecutor
|
||
from cloud.chv.hostkeys import write_seed
|
||
from cloud.chv.ssh_transport import remote_command
|
||
from cloud.sandbox import ChvFailure
|
||
except ImportError:
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from cloud.chv.disk import record_snapshot
|
||
from cloud.chv.executor import CloudHypervisorExecutor
|
||
from cloud.chv.hostkeys import write_seed
|
||
from cloud.chv.ssh_transport import remote_command
|
||
from cloud.sandbox import ChvFailure
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
|
||
|
||
class FakeResult:
|
||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||
self.returncode = returncode
|
||
self.stdout = stdout
|
||
self.stderr = stderr
|
||
|
||
|
||
class FakeProc:
|
||
def __init__(self, alive=True):
|
||
self._alive = alive
|
||
self.terminated = False
|
||
self.killed = False
|
||
|
||
def poll(self):
|
||
return None if self._alive else 1
|
||
|
||
def terminate(self):
|
||
self.terminated = True
|
||
self._alive = False
|
||
|
||
def wait(self, timeout=None):
|
||
return 0
|
||
|
||
def kill(self):
|
||
self.killed = True
|
||
self._alive = False
|
||
|
||
|
||
def _write(path, content=b"x"):
|
||
with open(path, "wb") as fh:
|
||
fh.write(content)
|
||
os.chmod(path, 0o755)
|
||
return path
|
||
|
||
|
||
def _load_host_py():
|
||
spec = importlib.util.spec_from_file_location(
|
||
"chv_host_py", str(ROOT / "host.py"))
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
class V3Case(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.dir = self.tmp.name
|
||
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
|
||
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
|
||
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
|
||
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
|
||
b"R" * 100)
|
||
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
|
||
self.kvm = _write(os.path.join(self.dir, "kvm"))
|
||
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
|
||
with open(self.cpuinfo, "w") as fh:
|
||
fh.write("flags : fpu vmx sse\n")
|
||
self.state_dir = os.path.join(self.dir, "state")
|
||
self.calls = []
|
||
self.api_calls = []
|
||
self.launches = []
|
||
self.audit = []
|
||
self.proc = None
|
||
self.spawned = []
|
||
|
||
def tearDown(self):
|
||
for proc in self.spawned:
|
||
try:
|
||
if proc.poll() is None:
|
||
proc.terminate()
|
||
proc.wait(timeout=5)
|
||
except Exception:
|
||
pass
|
||
self.tmp.cleanup()
|
||
|
||
def make_exec(self, runner=None, api_client=None, launcher=None,
|
||
env_extra=None, **overrides):
|
||
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
|
||
"CHV_HOST_KEY_MODE": "keyscan",
|
||
"CHV_SSH_FORWARD": "1"}
|
||
if env_extra:
|
||
env.update(env_extra)
|
||
kwargs = dict(
|
||
binary=self.binary, ch_remote=self.ch_remote,
|
||
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=runner or self._default_runner,
|
||
launcher=launcher or self._default_launcher,
|
||
api_client=api_client or self._default_api,
|
||
sleeper=lambda s: None, env=env)
|
||
kwargs.update(overrides)
|
||
ex = CloudHypervisorExecutor(**kwargs)
|
||
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
|
||
(e, t.get("id") if isinstance(t, dict) else t, d, k))
|
||
return ex
|
||
|
||
def _default_runner(self, cmd, timeout=None):
|
||
self.calls.append(list(cmd))
|
||
if cmd and cmd[0] == "ssh":
|
||
if cmd[-1] == "true":
|
||
return FakeResult(0, "", "")
|
||
return FakeResult(0, "", "")
|
||
if cmd and cmd[0] == "ssh-keygen":
|
||
priv = cmd[cmd.index("-f") + 1]
|
||
with open(priv, "w") as fh:
|
||
fh.write("-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n")
|
||
os.chmod(priv, 0o600)
|
||
with open(priv + ".pub", "w") as fh:
|
||
fh.write("ssh-ed25519 AAAAfake1 vm-key\n")
|
||
return FakeResult(0, "", "")
|
||
if cmd and cmd[0] == "genisoimage":
|
||
out = cmd[cmd.index("-output") + 1]
|
||
with open(out, "wb") as fh:
|
||
fh.write(b"CIDATA" * 16)
|
||
return FakeResult(0, "", "")
|
||
return FakeResult(0, "", "")
|
||
|
||
def _default_api(self, socket_path, method, path, payload=None):
|
||
self.api_calls.append((method, path, payload))
|
||
return (200, {})
|
||
|
||
def _default_launcher(self, cmd):
|
||
self.launches.append(list(cmd))
|
||
self.proc = FakeProc(alive=True)
|
||
return self.proc
|
||
|
||
def _task(self, task_id="abc123"):
|
||
return {"id": task_id, "prompt": "做一个加法函数",
|
||
"project_id": "proj1"}
|
||
|
||
def _snap_dir(self, name="snap1", files=None):
|
||
"""建一个 v54 形态的快照目录,返回 (url, path)。"""
|
||
path = os.path.join(self.state_dir, name)
|
||
os.makedirs(path, exist_ok=True)
|
||
for fname, content in (files or {
|
||
"config.json": b"{}",
|
||
"state.json": b"{}",
|
||
"memory-ranges": b"m"}).items():
|
||
with open(os.path.join(path, fname), "wb") as fh:
|
||
fh.write(content)
|
||
return "file://" + path, path
|
||
|
||
def _register_snapshot(self, url, tenant="proj1"):
|
||
# 严格快照校验(设计 §2.2,默认开启):restore 测试的
|
||
# 快照须登记带 tenant 且与 _task() 的 project_id 一致;
|
||
# 本 helper 只登记,不改变测试本身的意图(盘预检 /
|
||
# source_url / 清旧 VMM 仍由各自断言覆盖)。
|
||
record_snapshot(self.state_dir, "chv-abc123", url, 20.0,
|
||
tenant_id=tenant)
|
||
|
||
def _spawn_fake_vmm(self, sock_path, argv0="cloud-hypervisor"):
|
||
"""真实 spawn 一个长眠进程,argv[0] 伪装成 VMM,
|
||
命令行带 --api-socket <sock>(F2/V-3③ 的扫描目标)。"""
|
||
proc = subprocess.Popen(
|
||
[argv0, "-c", "import time; time.sleep(30)",
|
||
"--api-socket", sock_path],
|
||
executable=sys.executable,
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
self.spawned.append(proc)
|
||
return proc
|
||
|
||
|
||
# -- V-3①/② 快照:裸 URL + 目录校验 ---------------------------------------
|
||
class SnapshotV3Test(V3Case):
|
||
def test_snapshot_passes_bare_url(self):
|
||
url, _ = self._snap_dir()
|
||
ex = self.make_exec()
|
||
out = ex.snapshot_vm(self._task(), url)
|
||
self.assertEqual(out, url)
|
||
snap_calls = [c for c in self.calls
|
||
if c[0] == self.ch_remote and "snapshot" in c]
|
||
self.assertEqual(len(snap_calls), 1)
|
||
self.assertEqual(snap_calls[0][-1], url)
|
||
self.assertFalse(any(a.startswith("source_url")
|
||
for a in snap_calls[0]))
|
||
|
||
def test_restore_still_passes_source_url(self):
|
||
url, _ = self._snap_dir()
|
||
self._register_snapshot(url)
|
||
ex = self.make_exec()
|
||
out = ex.restore_vm(self._task(), url)
|
||
self.assertEqual(out["mode"], "restored")
|
||
restore_calls = [c for c in self.calls
|
||
if c[0] == self.ch_remote and "restore" in c]
|
||
self.assertEqual(len(restore_calls), 1)
|
||
self.assertEqual(restore_calls[0][-1], "source_url=%s" % url)
|
||
|
||
def test_snapshot_dir_records_dir_total_size(self):
|
||
url, path = self._snap_dir(files={
|
||
"config.json": b"C" * 30, "state.json": b"S" * 30})
|
||
ex = self.make_exec()
|
||
ex.snapshot_vm(self._task(), url)
|
||
with open(os.path.join(self.state_dir, "chv-abc123",
|
||
"snapshots.json")) as fh:
|
||
entries = json.load(fh)
|
||
self.assertEqual(len(entries), 1)
|
||
self.assertEqual(entries[0]["size"], 60)
|
||
self.assertEqual(entries[0]["path"], path)
|
||
|
||
|
||
# -- V-3③ restore 准备:清旧 VMM + 旧 socket ------------------------------
|
||
class RestorePrepareV3Test(V3Case):
|
||
def test_restore_kills_registered_old_vmm_and_socket(self):
|
||
url, _ = self._snap_dir()
|
||
self._register_snapshot(url)
|
||
ex = self.make_exec()
|
||
ex.wake_vm(self._task())
|
||
old_proc = self.proc
|
||
self.assertTrue(old_proc.poll() is None)
|
||
sock = os.path.join(self.state_dir, "chv-abc123.sock")
|
||
with open(sock, "w") as fh:
|
||
fh.write("")
|
||
out = ex.restore_vm(self._task(), url)
|
||
self.assertEqual(out["mode"], "restored")
|
||
self.assertTrue(old_proc.terminated)
|
||
# 新 VMM 已起(第 2 次 launch),旧 socket 文件在
|
||
# launch 前已被清掉(fake launcher 不重建 socket 文件)。
|
||
self.assertEqual(len(self.launches), 2)
|
||
self.assertFalse(os.path.exists(sock))
|
||
|
||
def test_restore_kills_unregistered_vmm_by_socket(self):
|
||
url, _ = self._snap_dir()
|
||
self._register_snapshot(url)
|
||
ex = self.make_exec()
|
||
sock = os.path.join(self.state_dir, "chv-abc123.sock")
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
with open(sock, "w") as fh:
|
||
fh.write("")
|
||
victim = self._spawn_fake_vmm(sock)
|
||
self.assertIsNone(victim.poll())
|
||
out = ex.restore_vm(self._task(), url)
|
||
self.assertEqual(out["mode"], "restored")
|
||
self.assertIsNotNone(victim.wait(timeout=5))
|
||
|
||
|
||
# -- V-3 附带:hibernate 留盘 + GC 保护 + restore 盘预检 -------------------
|
||
class HibernateDiskV3Test(V3Case):
|
||
def test_hibernate_keeps_task_disk(self):
|
||
ex = self.make_exec()
|
||
ex.wake_vm(self._task())
|
||
disk = os.path.join(self.state_dir, "chv-abc123", "disk.raw")
|
||
self.assertTrue(os.path.exists(disk))
|
||
url, _ = self._snap_dir()
|
||
out = ex.hibernate_vm(self._task(), url)
|
||
self.assertEqual(out["state"], "hibernated")
|
||
self.assertTrue(os.path.exists(disk),
|
||
"hibernate 的 stop 不得删盘:快照 config.json "
|
||
"引用该盘,删了 restore 即失败")
|
||
|
||
def test_gc_spares_disk_while_snapshot_recorded(self):
|
||
ex = self.make_exec()
|
||
ex.wake_vm(self._task())
|
||
url, _ = self._snap_dir()
|
||
ex.hibernate_vm(self._task(), url)
|
||
disk = os.path.join(self.state_dir, "chv-abc123", "disk.raw")
|
||
result = ex.gc_stale_disks()
|
||
self.assertEqual(result["deleted"], [])
|
||
self.assertTrue(os.path.exists(disk))
|
||
# 快照登记清掉后,盘成孤儿,GC 才可删。
|
||
os.remove(os.path.join(self.state_dir, "chv-abc123",
|
||
"snapshots.json"))
|
||
result = ex.gc_stale_disks()
|
||
self.assertEqual(result["deleted"], ["chv-abc123"])
|
||
self.assertFalse(os.path.exists(disk))
|
||
|
||
def test_restore_missing_snapshot_disk_fail_fast(self):
|
||
ex = self.make_exec()
|
||
missing_disk = os.path.join(self.state_dir, "gone", "disk.raw")
|
||
url, _ = self._snap_dir(files={
|
||
"config.json": json.dumps(
|
||
{"disks": [{"path": missing_disk}]}).encode(),
|
||
"state.json": b"{}"})
|
||
self._register_snapshot(url)
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.restore_vm(self._task(), url)
|
||
self.assertEqual(ctx.exception.kind, "snapshot_disk_missing")
|
||
self.assertEqual(self.launches, [])
|
||
|
||
def test_restore_snapshot_disk_present_passes_precheck(self):
|
||
ex = self.make_exec()
|
||
ex.wake_vm(self._task())
|
||
disk = os.path.join(self.state_dir, "chv-abc123", "disk.raw")
|
||
url, _ = self._snap_dir(files={
|
||
"config.json": json.dumps(
|
||
{"disks": [{"path": disk}]}).encode(),
|
||
"state.json": b"{}"})
|
||
self._register_snapshot(url)
|
||
out = ex.restore_vm(self._task(), url)
|
||
self.assertEqual(out["mode"], "restored")
|
||
|
||
|
||
# -- F1:re-wake 删旧盘 -----------------------------------------------------
|
||
class RewakeDiskV3Test(V3Case):
|
||
def _corrupt_disk(self, task_id="abc123"):
|
||
disk = os.path.join(self.state_dir, "chv-" + task_id,
|
||
"disk.raw")
|
||
with open(disk, "wb") as fh:
|
||
fh.write(b"STALE" * 20)
|
||
return disk
|
||
|
||
def test_rewake_live_vm_replaces_stale_disk(self):
|
||
ex = self.make_exec()
|
||
ex.wake_vm(self._task())
|
||
disk = self._corrupt_disk()
|
||
out = ex.wake_vm(self._task())
|
||
self.assertEqual(out["state"], "running")
|
||
with open(disk, "rb") as fh:
|
||
self.assertEqual(fh.read(), b"R" * 100,
|
||
"re-wake 须重拷新盘:旧盘复用 + 新 key 对 "
|
||
"= cloud-init 不重应用 = host_key_mismatch")
|
||
|
||
def test_rewake_after_crash_replaces_stale_disk(self):
|
||
ex = self.make_exec()
|
||
ex.wake_vm(self._task())
|
||
disk = self._corrupt_disk()
|
||
ex2 = self.make_exec()
|
||
out = ex2.wake_vm(self._task())
|
||
self.assertEqual(out["state"], "running")
|
||
with open(disk, "rb") as fh:
|
||
self.assertEqual(fh.read(), b"R" * 100)
|
||
|
||
def test_provisioned_rewake_regenerates_keypair_on_fresh_disk(self):
|
||
ex = self.make_exec(
|
||
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
|
||
out = ex.wake_vm(self._task())
|
||
self.assertEqual(out["state"], "running")
|
||
vm_dir = os.path.join(self.state_dir, "chv-abc123")
|
||
# H5:首 wake 后宿主私钥已删,re-wake 必然重新生成 key 对。
|
||
self.assertFalse(os.path.exists(
|
||
os.path.join(vm_dir, "ssh_host_ed25519_key")))
|
||
self.assertTrue(os.path.exists(
|
||
os.path.join(vm_dir, "known_hosts")))
|
||
disk = self._corrupt_disk()
|
||
geniso_before = sum(1 for c in self.calls
|
||
if c and c[0] == "genisoimage")
|
||
out2 = ex.wake_vm(self._task())
|
||
self.assertEqual(out2["state"], "running")
|
||
with open(disk, "rb") as fh:
|
||
self.assertEqual(fh.read(), b"R" * 100)
|
||
geniso_after = sum(1 for c in self.calls
|
||
if c and c[0] == "genisoimage")
|
||
self.assertEqual(geniso_after, geniso_before + 1,
|
||
"re-wake 须重建 seed 盘(新 key 对进新 seed)")
|
||
|
||
|
||
# -- F2:跨实例 stop 按 socket 进程扫描 -------------------------------------
|
||
class CrossInstanceStopV3Test(V3Case):
|
||
def test_stop_kills_unregistered_vmm_by_socket(self):
|
||
ex = self.make_exec()
|
||
sock = os.path.join(self.state_dir, "chv-abc123.sock")
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
with open(sock, "w") as fh:
|
||
fh.write("")
|
||
victim = self._spawn_fake_vmm(sock)
|
||
self.assertIsNone(victim.poll())
|
||
self.assertTrue(ex.stop_vm(self._task()))
|
||
self.assertIsNotNone(victim.wait(timeout=5))
|
||
self.assertFalse(os.path.exists(sock))
|
||
|
||
def test_stop_does_not_kill_others_vmm_or_chremote(self):
|
||
ex = self.make_exec()
|
||
sock = os.path.join(self.state_dir, "chv-abc123.sock")
|
||
other_sock = os.path.join(self.state_dir, "chv-other.sock")
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
for path in (sock, other_sock):
|
||
with open(path, "w") as fh:
|
||
fh.write("")
|
||
other_vmm = self._spawn_fake_vmm(other_sock)
|
||
# 同带本 socket 的 ch-remote 客户端:真实形态是
|
||
# `ch-remote --api-socket=<sock> shutdown`,不是 VMM,
|
||
# 绝不杀。
|
||
chremote = subprocess.Popen(
|
||
["ch-remote", "-c", "import time; time.sleep(30)",
|
||
"--api-socket=" + sock],
|
||
executable=sys.executable,
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
self.spawned.append(chremote)
|
||
self.assertIsNone(other_vmm.poll())
|
||
self.assertIsNone(chremote.poll())
|
||
ex.stop_vm(self._task())
|
||
self.assertIsNone(other_vmm.poll())
|
||
self.assertIsNone(chremote.poll())
|
||
|
||
|
||
# -- V-4:guest-env source 失败 fail-fast + 归 SSH 用户所有 ------------------
|
||
class GuestEnvV4Test(V3Case):
|
||
def test_remote_command_source_failure_exits(self):
|
||
tokens = remote_command(["python3", "/app/host.py", "status"],
|
||
guest_env_file="/etc/chv/guest-env")
|
||
joined = " ".join(tokens)
|
||
self.assertIn(". /etc/chv/guest-env || exit 126;", joined)
|
||
self.assertNotIn("/etc/chv/guest-env';", joined)
|
||
|
||
def test_remote_command_without_guest_env_unchanged(self):
|
||
tokens = remote_command(["true"])
|
||
self.assertEqual(tokens, ["true"])
|
||
|
||
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 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"},
|
||
ssh_user=ssh_user)
|
||
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")
|
||
|
||
def test_provisioned_wake_writes_guest_env_for_chv_user(self):
|
||
import yaml
|
||
ex = self.make_exec(
|
||
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")
|
||
user_data = os.path.join(
|
||
self.state_dir, "chv-abc123", "seed", "user-data")
|
||
with open(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)
|
||
# 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 --------------
|
||
class GuestBuildAndHostBinV3Test(unittest.TestCase):
|
||
def test_build_sh_precreates_app_work_writable(self):
|
||
text = (ROOT / "chv-guest" / "build.sh").read_text()
|
||
self.assertIn("mkdir -p /app/work", text)
|
||
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)
|
||
bindir = os.path.join(tmp.name, "bin")
|
||
os.makedirs(os.path.join(bindir, "codex-resources"))
|
||
for name in ("codex", "codex-code-mode-host"):
|
||
p = os.path.join(bindir, name)
|
||
with open(p, "w") as fh:
|
||
fh.write("#!/bin/sh\necho \"codex-cli 0.164.0-alpha.9\"\n")
|
||
os.chmod(p, 0o755)
|
||
repo = os.path.join(tmp.name, "repo")
|
||
os.makedirs(repo)
|
||
with open(os.path.join(repo, "host.py"), "w") as fh:
|
||
fh.write("# host\n")
|
||
subprocess.run(["git", "init", "-q", repo],
|
||
check=True, capture_output=True)
|
||
subprocess.run(["git", "-C", repo, "config", "user.email", "t@t"],
|
||
check=True, capture_output=True)
|
||
subprocess.run(["git", "-C", repo, "config", "user.name", "t"],
|
||
check=True, capture_output=True)
|
||
subprocess.run(["git", "-C", repo, "add", "-A"],
|
||
check=True, capture_output=True)
|
||
subprocess.run(["git", "-C", repo, "commit", "-qm", "init"],
|
||
check=True, capture_output=True)
|
||
commit = subprocess.run(
|
||
["git", "-C", repo, "rev-parse", "--short", "HEAD"],
|
||
check=True, capture_output=True, text=True).stdout.strip()
|
||
env = dict(os.environ)
|
||
env.update({"CODEX_BIN_DIR": bindir,
|
||
"UBUNTU_VERSION": "24.04",
|
||
"CODEX_VERSION": "0.164.0-alpha.9",
|
||
"REPO_COMMIT": commit,
|
||
"UBUNTU_IMAGE_SHA256": "0" * 64,
|
||
"REPO_DIR": repo})
|
||
r = subprocess.run(
|
||
["bash", str(ROOT / "chv-guest" / "build.sh"),
|
||
"--dry-run", os.path.join(tmp.name, "out")],
|
||
check=False, capture_output=True, text=True, env=env)
|
||
self.assertEqual(r.returncode, 0, r.stderr)
|
||
self.assertIn("/app/work", r.stdout)
|
||
self.assertIn("1777", r.stdout)
|
||
|
||
def test_default_codex_bin_uses_path_lookup(self):
|
||
mod = _load_host_py()
|
||
tmp = tempfile.TemporaryDirectory()
|
||
self.addCleanup(tmp.cleanup)
|
||
fake = os.path.join(tmp.name, "codex")
|
||
with open(fake, "w") as fh:
|
||
fh.write("#!/bin/sh\n")
|
||
os.chmod(fake, 0o755)
|
||
env = dict(os.environ)
|
||
env.pop("CODEX_BIN", None)
|
||
env["PATH"] = tmp.name + os.pathsep + env.get("PATH", "")
|
||
with mock.patch.dict(os.environ, env, clear=True):
|
||
self.assertEqual(mod.default_codex_bin(), fake)
|
||
|
||
def test_default_codex_bin_falls_back_to_bare_name(self):
|
||
mod = _load_host_py()
|
||
tmp = tempfile.TemporaryDirectory()
|
||
self.addCleanup(tmp.cleanup)
|
||
env = {"PATH": tmp.name}
|
||
with mock.patch.dict(os.environ, env, clear=True):
|
||
self.assertEqual(mod.default_codex_bin(), "codex")
|
||
|
||
def test_default_codex_bin_env_still_wins(self):
|
||
mod = _load_host_py()
|
||
with mock.patch.dict(os.environ,
|
||
{"CODEX_BIN": "/opt/custom/codex"}):
|
||
self.assertEqual(mod.default_codex_bin(),
|
||
"/opt/custom/codex")
|
||
|
||
|
||
# -- V-7/D1:snapshot 先建目标目录 ---------------------------------------
|
||
class SnapshotMkdirV7Test(V3Case):
|
||
def _snapshot_creating_runner(self):
|
||
"""模拟真实 ch-remote:往目标目录写快照文件。目录不
|
||
存在时 open 即 FileNotFoundError——若执行器没先
|
||
mkdir,测试直接失败,这正是旧 fixture 盖不住的点
|
||
(旧 fixture 预建目录,mock 又不写文件)。"""
|
||
def runner(cmd, timeout=None):
|
||
if cmd and cmd[0] == self.ch_remote and "snapshot" in cmd:
|
||
self.calls.append(list(cmd))
|
||
target = cmd[-1][len("file://"):]
|
||
for fname, content in (
|
||
("config.json", b"{}"),
|
||
("state.json", b"{}"),
|
||
("memory-ranges", b"m")):
|
||
with open(os.path.join(target, fname), "wb") as fh:
|
||
fh.write(content)
|
||
return FakeResult(0, "", "")
|
||
return self._default_runner(cmd, timeout)
|
||
return runner
|
||
|
||
def test_snapshot_creates_missing_snapshot_dir(self):
|
||
ex = self.make_exec(runner=self._snapshot_creating_runner())
|
||
url = "file://" + os.path.join(self.state_dir, "fresh-snap")
|
||
self.assertFalse(os.path.exists(url[len("file://"):]))
|
||
out = ex.snapshot_vm(self._task(), url)
|
||
self.assertEqual(out, url)
|
||
snap_path = url[len("file://"):]
|
||
self.assertTrue(os.path.isdir(snap_path))
|
||
self.assertIn("config.json", os.listdir(snap_path))
|
||
with open(os.path.join(self.state_dir, "chv-abc123",
|
||
"snapshots.json")) as fh:
|
||
entries = json.load(fh)
|
||
self.assertEqual(entries[0]["path"], snap_path)
|
||
|
||
def test_hibernate_creates_missing_snapshot_dir(self):
|
||
ex = self.make_exec(runner=self._snapshot_creating_runner())
|
||
ex.wake_vm(self._task())
|
||
url = "file://" + os.path.join(self.state_dir, "fresh-snap")
|
||
out = ex.hibernate_vm(self._task(), url)
|
||
self.assertEqual(out["state"], "hibernated")
|
||
self.assertTrue(os.path.isdir(url[len("file://"):]))
|
||
|
||
def test_snapshot_target_existing_file_rejected(self):
|
||
ex = self.make_exec()
|
||
target = os.path.join(self.state_dir, "not-a-dir")
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
with open(target, "wb") as fh:
|
||
fh.write(b"x")
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.snapshot_vm(self._task(), "file://" + target)
|
||
self.assertEqual(ctx.exception.kind, "snapshot_not_persisted")
|
||
self.assertEqual(self.calls, [],
|
||
"目标不是目录时 ch-remote 根本不应被调")
|
||
|
||
|
||
# -- V-7/D2:hibernate 留 seed.iso ----------------------------------------
|
||
class HibernateSeedIsoV7Test(V3Case):
|
||
def _prov_exec(self):
|
||
# keyscan 模式不制 seed.iso;provisioned 模式 wake 即
|
||
# 建 seed.iso(真机休眠的形态),用它走全链路。
|
||
return self.make_exec(
|
||
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
|
||
|
||
def _seed_iso(self):
|
||
return os.path.join(self.state_dir, "chv-abc123", "seed.iso")
|
||
|
||
def _disk(self):
|
||
return os.path.join(self.state_dir, "chv-abc123", "disk.raw")
|
||
|
||
def _snap_with_disks(self):
|
||
"""快照 config.json 同时引用 disk.raw 与 seed.iso
|
||
(真机快照的 disks 形态)。"""
|
||
return self._snap_dir(files={
|
||
"config.json": json.dumps(
|
||
{"disks": [{"path": self._disk()},
|
||
{"path": self._seed_iso()}]}).encode(),
|
||
"state.json": b"{}"})
|
||
|
||
def test_hibernate_keeps_seed_iso(self):
|
||
ex = self._prov_exec()
|
||
ex.wake_vm(self._task())
|
||
self.assertTrue(os.path.exists(self._seed_iso()))
|
||
url, _ = self._snap_dir()
|
||
out = ex.hibernate_vm(self._task(), url)
|
||
self.assertEqual(out["state"], "hibernated")
|
||
self.assertTrue(os.path.exists(self._disk()),
|
||
"hibernate 仍须留 disk.raw")
|
||
self.assertTrue(os.path.exists(self._seed_iso()),
|
||
"hibernate 须留 seed.iso:快照 config.json "
|
||
"的 disks 引用它,删了 restore 即失败")
|
||
# 只留 iso:seed 目录(制盘输入)与宿主私钥仍删,
|
||
# config.json 不引用它们,私钥明文不留宿主。
|
||
self.assertFalse(os.path.exists(
|
||
os.path.join(self.state_dir, "chv-abc123", "seed")))
|
||
self.assertFalse(os.path.exists(
|
||
os.path.join(self.state_dir, "chv-abc123",
|
||
"ssh_host_ed25519_key")))
|
||
|
||
def test_stop_without_keep_disk_removes_seed_iso(self):
|
||
ex = self._prov_exec()
|
||
ex.wake_vm(self._task())
|
||
self.assertTrue(ex.stop_vm(self._task()))
|
||
self.assertFalse(os.path.exists(self._seed_iso()),
|
||
"正常 stop 仍须删 seed.iso:它含宿主私钥"
|
||
"明文,不得随 VM 残留")
|
||
self.assertFalse(os.path.exists(self._disk()))
|
||
|
||
def test_cleanup_stale_secrets_spares_seed_iso_while_snapshot_recorded(self):
|
||
ex = self._prov_exec()
|
||
ex.wake_vm(self._task())
|
||
url, _ = self._snap_dir()
|
||
ex.hibernate_vm(self._task(), url)
|
||
cleaned = ex.cleanup_stale_secrets()
|
||
self.assertNotIn("chv-abc123", cleaned)
|
||
self.assertTrue(os.path.exists(self._seed_iso()),
|
||
"有登记快照的 VM 的 seed.iso 不是孤儿,"
|
||
"残留清理不得清掉(与 gc_stale_disks 的"
|
||
"盘保护同款逻辑)")
|
||
# 快照登记清掉后,seed.iso 成孤儿,才可清。
|
||
os.remove(os.path.join(self.state_dir, "chv-abc123",
|
||
"snapshots.json"))
|
||
cleaned = ex.cleanup_stale_secrets()
|
||
self.assertIn("chv-abc123", cleaned)
|
||
self.assertFalse(os.path.exists(self._seed_iso()))
|
||
|
||
def test_restore_after_hibernate_with_seed_iso_passes_precheck(self):
|
||
ex = self._prov_exec()
|
||
ex.wake_vm(self._task())
|
||
url, _ = self._snap_with_disks()
|
||
ex.hibernate_vm(self._task(), url)
|
||
out = ex.restore_vm(self._task(), url)
|
||
self.assertEqual(out["mode"], "restored")
|
||
|
||
|
||
# -- V-7/F1:任务 SSH 连接建立阶段瞬时 reset 重试 1 次 --------------------
|
||
class SshTransientRetryV7Test(V3Case):
|
||
def _dispatch_runner(self, first, second=None):
|
||
"""首条 dispatch SSH 返回 first,之后返回 second
|
||
(默认成功)。只记 dispatch SSH 的调用次数。"""
|
||
state = {"n": 0}
|
||
|
||
def runner(cmd, timeout=None):
|
||
if cmd and cmd[0] == "ssh" and "dispatch" in cmd:
|
||
state["n"] += 1
|
||
if state["n"] == 1:
|
||
return first
|
||
return second or FakeResult(
|
||
0, json.dumps({"status": "completed",
|
||
"text": "ok"}), "")
|
||
return self._default_runner(cmd, timeout)
|
||
runner.state = state
|
||
return runner
|
||
|
||
def test_dispatch_retries_once_on_kex_reset_then_succeeds(self):
|
||
runner = self._dispatch_runner(FakeResult(
|
||
255, "",
|
||
"kex_exchange_identification: Connection reset by peer"))
|
||
ex = self.make_exec(runner=runner)
|
||
out = ex.dispatch_task(self._task())
|
||
self.assertEqual(out["status"], "completed")
|
||
self.assertEqual(runner.state["n"], 2,
|
||
"kex 阶段瞬时 reset 只重试恰好 1 次")
|
||
self.assertTrue(any(e == "vm_ssh_transient_retry"
|
||
for e, _t, _d, _k in self.audit))
|
||
|
||
def test_dispatch_does_not_retry_on_host_key_mismatch(self):
|
||
runner = self._dispatch_runner(FakeResult(
|
||
255, "", "Host key verification failed."))
|
||
ex = self.make_exec(runner=runner)
|
||
out = ex.dispatch_task(self._task())
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertEqual(runner.state["n"], 1,
|
||
"host key 拒绝(同为 255)绝不重试")
|
||
self.assertFalse(any(e == "vm_ssh_transient_retry"
|
||
for e, _t, _d, _k in self.audit))
|
||
|
||
def test_dispatch_does_not_retry_on_remote_failure(self):
|
||
runner = self._dispatch_runner(FakeResult(
|
||
1, json.dumps({"status": "failed", "text": "boom"}), ""))
|
||
ex = self.make_exec(runner=runner)
|
||
out = ex.dispatch_task(self._task())
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertEqual(runner.state["n"], 1, "远端命令失败不重试")
|
||
|
||
def test_dispatch_retries_at_most_once(self):
|
||
runner = self._dispatch_runner(
|
||
FakeResult(255, "", "Connection reset by peer"),
|
||
second=FakeResult(255, "", "Connection reset by peer"))
|
||
ex = self.make_exec(runner=runner)
|
||
out = ex.dispatch_task(self._task())
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertEqual(runner.state["n"], 2, "第二次失败即停,不再追")
|
||
|
||
def test_transient_retry_helper_retries_once(self):
|
||
"""helper 本身(_run_guest 与 _run_guest_goal 共用):
|
||
首条 255 + reset 标记即重试 1 次,第二次成功即返回。"""
|
||
calls = []
|
||
|
||
def runner(cmd, timeout=None):
|
||
calls.append(list(cmd))
|
||
if len(calls) == 1:
|
||
return FakeResult(255, "", "Connection reset by peer")
|
||
return FakeResult(0, "ok", "")
|
||
|
||
ex = self.make_exec(runner=runner)
|
||
result = ex._call_ssh_with_transient_retry(
|
||
self._task(), ["ssh", "true"])
|
||
self.assertEqual(result.returncode, 0)
|
||
self.assertEqual(len(calls), 2)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|