真机根因(2026-11-07,CHV v54 三变体实证):vm.boot 对任何 带 backing file 的 qcow2 都报 Maximum disk nesting depth exceeded——overlay-only 与 overlay-rawbase 均 500,base 直接 启动(reuse)22.5 秒成功,数据盘与压缩均已排除。overlay 在 本 VMM 上不可用,持久 VM 须能在 copy 模式下跑。 - _provision_task_disk:copy 与 overlay 都供数据盘并写 tenant.json(只有 reuse 不供不写);_provision_data_disk_path 同理(reuse 才返回 None)。 - _cleanup_vm_task_disk:保护(持久登记/登记快照)不再只限 overlay 模式;无登记无快照的 copy 盘仍按旧语义删除。 - provision_data_disk:数据盘改 raw 稀疏盘(os.truncate, 零外部依赖)——copy 模式本来就不需要 qemu-img,数据盘 绝不能悄悄加上这个依赖;CHV 按后缀认 Raw。 - 测试:新增 copy 供数据盘+tenant 与 copy 持久盘 stop 保护; VmConfig 三盘断言(任务+seed+数据)更新 arch95/fix 两处 旧“恰好两盘”契约与本轨 qcow2 类型断言。 回归:test_chv_*.py、test_cloud_hypervisor.py 42、test_chv_v3_fixes 38 全过(全量 cloud/tests 在跑)。 不推、不合、不部署;只本地分支。
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""CHV 架构 9.5 提升轨测试(feat-chv-arch95,堆叠于 #246)。
|
||
|
||
只用标准库与 mock,绝不启动真实 VM。本文件是“严格测试替身”
|
||
的载体:替身不再无条件放行——fake API 校验 VmConfig 内容
|
||
(任务盘 + seed 盘、net 条目),fake SSH 校验 known_hosts
|
||
钉死与 StrictHostKeyChecking=yes,mock 再也不能把被测逻辑
|
||
本身也 mock 掉。测试里只用 FAKE 占位密钥,绝无真实密钥。
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
try:
|
||
from cloud.chv.experimental_pool import WarmPool
|
||
from cloud.sandbox import (
|
||
ChvFailure,
|
||
CloudHypervisorExecutor,
|
||
DockerExecutor,
|
||
parse_goal_result,
|
||
)
|
||
except ImportError:
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from cloud.chv.experimental_pool import WarmPool
|
||
from cloud.sandbox import (
|
||
ChvFailure,
|
||
CloudHypervisorExecutor,
|
||
DockerExecutor,
|
||
parse_goal_result,
|
||
)
|
||
|
||
|
||
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
|
||
|
||
def poll(self):
|
||
return None if self._alive else 1
|
||
|
||
def terminate(self):
|
||
self._alive = False
|
||
|
||
def wait(self, timeout=None):
|
||
return 0
|
||
|
||
def kill(self):
|
||
self._alive = False
|
||
|
||
|
||
def _write(path, content=b"x"):
|
||
with open(path, "wb") as fh:
|
||
fh.write(content)
|
||
os.chmod(path, 0o755)
|
||
return path
|
||
|
||
|
||
class Arch95Case(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.audit = []
|
||
self.api_payloads = []
|
||
|
||
def tearDown(self):
|
||
self.tmp.cleanup()
|
||
|
||
def _runner(self, cmd, timeout=None):
|
||
self.calls.append(list(cmd))
|
||
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 _strict_api(self, socket_path, method, path, payload=None):
|
||
"""严格替身:vm.create 必须带任务盘 + seed 盘 + 数据盘、
|
||
net 无重复(阶段 1:数据盘是第三块盘,见
|
||
_provision_task_disk 的解耦说明)。"""
|
||
if path == "/vm.create":
|
||
self.api_payloads.append(payload)
|
||
disks = [d.get("path") for d in payload.get("disks", [])]
|
||
self.assertEqual(len(disks), 3,
|
||
"VmConfig 必须恰好三盘(任务+seed+数据)")
|
||
self.assertTrue(disks[0].endswith("disk.raw"),
|
||
"第一盘必须是任务盘")
|
||
self.assertTrue(disks[1].endswith("seed.iso"),
|
||
"第二盘必须是 seed 盘,缺 seed 即假保证")
|
||
self.assertTrue(disks[2].endswith("data.raw"),
|
||
"第三盘必须是数据盘")
|
||
self.assertTrue(payload.get("payload", {}).get("kernel"))
|
||
nets = payload.get("net", [])
|
||
taps = [n.get("tap") for n in nets]
|
||
self.assertEqual(len(taps), len(set(taps)), "net tap 重复")
|
||
return (200, {})
|
||
|
||
def make_exec(self, api=None, runner=None, env_extra=None):
|
||
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
|
||
"CHV_SSH_FORWARD": "1"}
|
||
if env_extra:
|
||
env.update(env_extra)
|
||
ex = CloudHypervisorExecutor(
|
||
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._runner,
|
||
launcher=lambda cmd: FakeProc(alive=True),
|
||
api_client=api or self._strict_api,
|
||
sleeper=lambda s: None, env=env)
|
||
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
|
||
|
||
|
||
class WarmPoolDecisionTest(Arch95Case):
|
||
def test_pool_is_experimental_and_dispatch_is_cold_only(self):
|
||
self.assertFalse(WarmPool.wired_to_dispatch)
|
||
ex = self.make_exec()
|
||
pool = WarmPool(ex)
|
||
# 手工放一个 warm 条目:dispatch 必须不消耗它(冷启动路径)。
|
||
pool._entries["warm-1"] = {"state": "warm",
|
||
"task": {"id": "warm-1"},
|
||
"vm": "chv-warm-1",
|
||
"snapshot_url": None}
|
||
ex.wake_vm({"id": "t1"})
|
||
self.assertEqual(pool._entries["warm-1"]["state"], "warm")
|
||
self.assertEqual(pool.warm_count(), 1)
|
||
|
||
|
||
class StrictVmConfigTest(Arch95Case):
|
||
def test_wake_vm_config_has_task_disk_and_seed_disk(self):
|
||
ex = self.make_exec()
|
||
out = ex.wake_vm({"id": "t1"})
|
||
self.assertEqual(out["state"], "running")
|
||
self.assertEqual(len(self.api_payloads), 1)
|
||
# known_hosts 钉死:文件存在且含预置公钥,SSH 命令带钉死选项。
|
||
kh = ex.known_hosts_path({"id": "t1"})
|
||
with open(kh) as fh:
|
||
content = fh.read()
|
||
self.assertIn("AAAAfake1", content)
|
||
cmd = ex.build_ssh_command({"id": "t1"}, ["true"])
|
||
self.assertIn("StrictHostKeyChecking=yes", " ".join(cmd))
|
||
self.assertIn(kh, " ".join(cmd))
|
||
|
||
def test_strict_ssh_runner_rejects_missing_known_hosts(self):
|
||
def strict_runner(cmd, timeout=None):
|
||
if cmd and cmd[0] == "ssh":
|
||
self.assertIn("StrictHostKeyChecking=yes", cmd)
|
||
found = False
|
||
for i, a in enumerate(cmd):
|
||
if a.startswith("UserKnownHostsFile="):
|
||
path = a.split("=", 1)[1]
|
||
found = True
|
||
elif a == "UserKnownHostsFile":
|
||
path = cmd[i + 1]
|
||
found = True
|
||
else:
|
||
continue
|
||
with open(path) as fh:
|
||
self.assertTrue(fh.read().strip(),
|
||
"known_hosts 为空即假钉死")
|
||
self.assertTrue(found, "SSH 命令缺 known_hosts 钉死选项")
|
||
return self._runner(cmd, timeout=timeout)
|
||
ex = self.make_exec(runner=strict_runner)
|
||
ex.wake_vm({"id": "t1"})
|
||
# 严格 runner 在 _run_guest 里再跑一次 status 风格调用:
|
||
# 这里直接验证 build 出的命令能过严格 runner 的检查。
|
||
cmd = ex.build_ssh_command({"id": "t1"}, ["true"])
|
||
strict_runner(cmd)
|
||
|
||
|
||
class ConfigUnifiedTest(Arch95Case):
|
||
def test_preflight_unified_config_invalid(self):
|
||
cases = [
|
||
("ssh_port", 0), ("ssh_port", 70000),
|
||
("max_vms", -1), ("disk_gb", 0),
|
||
("api_timeout", 0), ("ready_timeout", -1),
|
||
("ready_interval", 0), ("warm_pool_size", -1),
|
||
("snapshot_max_count", -1),
|
||
("snapshot_max_total_gb", -1),
|
||
("snapshot_retention_days", -1),
|
||
("host_key_mode", "bogus"),
|
||
]
|
||
for field, bad in cases:
|
||
with self.subTest(field=field, bad=bad):
|
||
ex = self.make_exec()
|
||
setattr(ex, field, bad)
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.preflight()
|
||
self.assertEqual(ctx.exception.kind, "config_invalid")
|
||
|
||
|
||
class DockerSyncGuardTest(unittest.TestCase):
|
||
"""Docker goal 内联版与正典 parse_goal_result 的同步守卫:
|
||
7 个真实标记用例 + 已存在撞名用例,两者状态必须全等;
|
||
任一实现改了分叉,测试即 fail(不再只靠文档清单)。"""
|
||
|
||
def _docker_status(self, stdout, returncode, stderr=""):
|
||
old = os.environ.get("XCM_API_KEY")
|
||
os.environ["XCM_API_KEY"] = "FAKE-TEST-KEY"
|
||
try:
|
||
ex = DockerExecutor(runner=lambda cmd, timeout=None:
|
||
FakeResult(returncode, stdout, stderr))
|
||
# to10:goal 解析是纯函数测试,绝不依赖本机 docker
|
||
# 二进制(无 docker 的机器上 _check_docker 会直接炸)。
|
||
ex._check_docker = lambda: None
|
||
ex._call_runner = lambda cmd, timeout=None, env=None: \
|
||
FakeResult(returncode, stdout, stderr)
|
||
return ex.goal_task({"id": "t1", "goal": "do it",
|
||
"goal_max_rounds": 3})["status"]
|
||
finally:
|
||
if old is None:
|
||
del os.environ["XCM_API_KEY"]
|
||
else:
|
||
os.environ["XCM_API_KEY"] = old
|
||
|
||
def test_docker_and_canonical_goal_status_equal(self):
|
||
cases = [
|
||
("goal met after rounds\n", 0, ""),
|
||
("goal unmet after rounds\nround 1\nverify_out=fail\n", 0, ""),
|
||
("goal interrupted after rounds: boom\n", 1, ""),
|
||
("", 1, "boom"),
|
||
("KeyError: breakpoint\n", 1, ""),
|
||
("", 0, ""),
|
||
("goal-resume 拒绝:breakpoint lost\n", 1, ""),
|
||
("任务已存在\n", 1, ""),
|
||
]
|
||
for stdout, rc, stderr in cases:
|
||
with self.subTest(stdout=stdout[:30], rc=rc):
|
||
canon = parse_goal_result(
|
||
"t1", FakeResult(rc, stdout, stderr), "local")
|
||
docker = self._docker_status(stdout, rc, stderr)
|
||
self.assertEqual(docker, canon["status"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|