架构:WarmPool 从 cloud/chv/pool.py 移到 experimental_pool.py, 生产残留清理彻底分文件到 cloud/chv/orphans.py(facade 直接读 registry 文件清理,不构造池、不碰 prewarm/acquire/release); cloud/chv 与 cloud/sandbox 不再导出 WarmPool,旧路径一律 ImportError(CHV 未合并、无生产用户,兼容保留已无保留理由); worker 改走 orphans facade,不再持有 _warm_pool;#215 对齐从 接口形状推进到 REPLACEMENT_POINTS 替换清单(R1/R2 可机械替换、 R3 永不替换,#215 模块当前不存在由测试钉住)。 可维护性:build_chv_config 拆成 base/ssh/network/quota/disk/ timeout 六片段(组装只做 dict 合并,键冲突即报错;38 字段集 与取值不变,单项非法报错文案逐字一致);ADR-12 模块职责图 与禁 import 清单(AST 钉住);cloud/chv/ 公开 API 全量类型 标注、无 Any 逃逸,并修正三处旧标注错误;死代码扫一遍 (删实验池已无调用的 _quarantine 委托与未用 _LOGGER)。 前端(只读侧,不做控制按钮):chv.ts 新增纯逻辑谓词与文案 (执行器精确匹配、附件守卫、VM 状态文案、列表徽标);列表 行新增 VM 已报告死亡徽标;详情对全部 CHV 任务显示 VM 行 (已报告死亡/未报告死亡,不猜 live);WorkersSection 与成本 卡沿用已有数据,不新开池管理/成本页;服务端未新增第二个 生命周期字段(持久化事实只有三项,chv_vm_dead 已是完整派生), 只把派生抽成 _chv_vm_dead_from_row 并补 goal 回落形态测试。
259 lines
10 KiB
Python
259 lines
10 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 无重复。"""
|
||
if path == "/vm.create":
|
||
self.api_payloads.append(payload)
|
||
disks = [d.get("path") for d in payload.get("disks", [])]
|
||
self.assertEqual(len(disks), 2, "VmConfig 必须恰好两盘")
|
||
self.assertTrue(disks[0].endswith("disk.raw"),
|
||
"第一盘必须是任务盘")
|
||
self.assertTrue(disks[1].endswith("seed.iso"),
|
||
"第二盘必须是 seed 盘,缺 seed 即假保证")
|
||
self.assertTrue(payload.get("kernel", {}).get("path"))
|
||
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))
|
||
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()
|