"""P1-B(feat-chv-muse-p1):气球/vsock 配置、payload 与 vsock 编解码测试。 覆盖三件: - config:CHV_BALLOON/CHV_VSOCK 开关默认开、只认 0/1, CHV_VSOCK_CID 默认 3 且拒绝保留 CID(0/1/2)与非法值; - executor:build_vm_config 默认带 balloon/vsock 设备,开关 关时对应键整个不出现,vsock socket 路径按任务盘派生; - vsock(宿主侧)与客内脚本:JSON 行编解码、监听/超时、 客内脚本静默失败(连不上宿主即 exit 0,不影响 sshd/任务)。 全部用假执行器与本地 Unix socket,绝不连真实网络、不起 真实 VM。CHV 54 的 balloon size=0 与 vsock 端到端收发尚未 真机验证(本机无 /dev/kvm,第三棒占着 WSL 测试机),见 汇报的待验证项——这里只钉住配置层 + payload 形态。 """ import ast import importlib.util import json import os import socket import sys import tempfile import threading import time import unittest from pathlib import Path from cloud.chv import vsock as chv_vsock from cloud.chv.config import _balloon_vsock_fragment from cloud.chv.config import _base_fragment from cloud.chv.config import _disk_fragment from cloud.chv.config import _network_fragment from cloud.chv.config import _quota_fragment from cloud.chv.config import _ssh_fragment from cloud.chv.config import _timeout_fragment from cloud.chv.config import build_chv_config from cloud.chv.executor import CloudHypervisorExecutor ROOT = Path(__file__).resolve().parents[2] GUEST_SCRIPT = ROOT / "cloud" / "chv" / "guest" / "vsock_notify.py" def _load_guest_module(): spec = importlib.util.spec_from_file_location( "vsock_notify_guest", str(GUEST_SCRIPT)) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def _stdlib_only(path): tree = ast.parse(Path(path).read_text()) mods = set() for node in ast.walk(tree): if isinstance(node, ast.Import): mods.update(a.name.split(".")[0] for a in node.names) elif isinstance(node, ast.ImportFrom) and node.module: mods.add(node.module.split(".")[0]) return mods <= set(sys.stdlib_module_names) class BalloonVsockConfigTest(unittest.TestCase): def test_defaults_on(self): cfg = build_chv_config({}, {}) self.assertTrue(cfg.balloon) self.assertTrue(cfg.vsock) self.assertEqual(cfg.vsock_cid, 3) def test_switches_off(self): cfg = build_chv_config( {}, {"CHV_BALLOON": "0", "CHV_VSOCK": "0"}) self.assertFalse(cfg.balloon) self.assertFalse(cfg.vsock) self.assertEqual(cfg.vsock_cid, 3) def test_switches_on_explicit(self): cfg = build_chv_config( {}, {"CHV_BALLOON": "1", "CHV_VSOCK": "1"}) self.assertTrue(cfg.balloon) self.assertTrue(cfg.vsock) def test_illegal_switch_values_raise(self): for env in ({"CHV_BALLOON": "2"}, {"CHV_BALLOON": "yes"}, {"CHV_BALLOON": "true"}, {"CHV_VSOCK": "on"}, {"CHV_VSOCK": ""}): with self.assertRaises(ValueError): build_chv_config({}, env) def test_vsock_cid_override(self): cfg = build_chv_config({}, {"CHV_VSOCK_CID": "7"}) self.assertEqual(cfg.vsock_cid, 7) cfg2 = build_chv_config({}, {"CHV_VSOCK_CID": "4294967295"}) self.assertEqual(cfg2.vsock_cid, 4294967295) def test_vsock_cid_reserved_and_illegal_raise(self): # 0/1/2 是 vsock 保留 CID(Hypervisor/Loopback/Host), # -1 随机 CID 也被 CHV vm.create 400 拒绝。 for cid in ("0", "1", "2", "-1", "4294967296", "abc", ""): with self.assertRaises(ValueError, msg=cid): build_chv_config({}, {"CHV_VSOCK_CID": cid}) def test_fragment_keys_disjoint(self): frag = _balloon_vsock_fragment({}) self.assertEqual(set(frag), {"balloon", "vsock", "vsock_cid"}) others = [_base_fragment({}, {}), _ssh_fragment({}, {}), _network_fragment({}, {}), _quota_fragment({}), _disk_fragment({}), _timeout_fragment({}, {})] seen = set() for other in others: seen |= set(other) self.assertFalse(seen & set(frag), "新片段键与旧片段冲突") class _PayloadCase(unittest.TestCase): def setUp(self): self.dir = tempfile.mkdtemp(prefix="chv-p1b-") self.state_dir = os.path.join(self.dir, "state") os.makedirs(self.state_dir) def tearDown(self): import shutil shutil.rmtree(self.dir, ignore_errors=True) def make_exec(self, env_extra=None): env = dict(env_extra or {}) return CloudHypervisorExecutor( kernel=os.path.join(self.dir, "vmlinux"), rootfs=os.path.join(self.dir, "root.raw"), ssh_key=os.path.join(self.dir, "id_ed25519"), state_dir=self.state_dir, runner=lambda cmd, timeout=None: None, launcher=lambda cmd: None, api_client=lambda *a: (200, {}), sleeper=lambda s: None, env=env) class BalloonVsockPayloadTest(_PayloadCase): def test_default_payload_has_balloon_and_vsock(self): ex = self.make_exec() cfg = ex.build_vm_config() # CHV 54 BalloonConfig:size 是目标气球尺寸(字节), # 0 = 启动不预扣,只靠 free_page_reporting 被动回收。 self.assertEqual(cfg["balloon"], { "size": 0, "deflate_on_oom": True, "free_page_reporting": True}) self.assertEqual(cfg["vsock"], { "cid": 3, "socket": os.path.join(self.state_dir, "vsock.sock")}) def test_switches_off_drop_keys(self): ex = self.make_exec({"CHV_BALLOON": "0", "CHV_VSOCK": "0"}) cfg = ex.build_vm_config() self.assertNotIn("balloon", cfg) self.assertNotIn("vsock", cfg) # 旧键原样保留。 self.assertIn("cpus", cfg) self.assertIn("memory", cfg) self.assertIn("payload", cfg) self.assertIn("disks", cfg) self.assertIn("rng", cfg) def test_balloon_off_only(self): ex = self.make_exec({"CHV_BALLOON": "0"}) cfg = ex.build_vm_config() self.assertNotIn("balloon", cfg) self.assertIn("vsock", cfg) def test_vsock_cid_honored(self): ex = self.make_exec({"CHV_VSOCK_CID": "9"}) cfg = ex.build_vm_config() self.assertEqual(cfg["vsock"]["cid"], 9) def test_vsock_socket_derived_from_task_disk_dir(self): ex = self.make_exec() disk = os.path.join(self.state_dir, "chv-t1", "disk.raw") cfg = ex.build_vm_config(disk) self.assertEqual( cfg["vsock"]["socket"], os.path.join(self.state_dir, "chv-t1", "vsock.sock")) def test_vsock_socket_falls_back_when_disk_outside_state(self): # reuse/lab:任务盘即基础镜像(state_dir 外),socket # 落 state_dir/vsock.sock(单 VM 可用,见 helper 注释)。 ex = self.make_exec() cfg = ex.build_vm_config() self.assertEqual( cfg["vsock"]["socket"], os.path.join(self.state_dir, "vsock.sock")) def test_explicit_vsock_socket_wins(self): ex = self.make_exec() cfg = ex.build_vm_config( vsock_socket="/tmp/per-vm/v.sock") self.assertEqual(cfg["vsock"]["socket"], "/tmp/per-vm/v.sock") def test_helper_direct(self): ex = self.make_exec({"CHV_BALLOON": "0"}) extra = ex._balloon_vsock_payload( vsock_socket="/tmp/s.sock") self.assertEqual(extra, {"vsock": {"cid": 3, "socket": "/tmp/s.sock"}}) class VsockCodecTest(unittest.TestCase): def test_encode_decode_round_trip(self): raw = chv_vsock.encode_notify("ready", "chv-t1", ts=1234.5) self.assertTrue(raw.endswith(b"\n")) msg = chv_vsock.decode_notify(raw) self.assertEqual(msg, {"type": "ready", "vm": "chv-t1", "ts": 1234.5}) def test_encode_rejects_bad_type_or_vm(self): with self.assertRaises(ValueError): chv_vsock.encode_notify("bogus", "chv-t1") with self.assertRaises(ValueError): chv_vsock.encode_notify("ready", "") def test_decode_rejects_garbage(self): cases = [b"not json\n", b"[1, 2]\n", b"123\n", b"\n", b'{"type": "bogus", "vm": "v", "ts": 1}\n', b'{"type": "ready", "vm": "", "ts": 1}\n', b'{"type": "ready", "vm": 7, "ts": 1}\n', b'{"type": "ready", "vm": "v"}\n', b'{"type": "ready", "vm": "v", "ts": "x"}\n', b'{"type": "ready", "vm": "v", "ts": true}\n', b'\xff\xfe bad utf8\n'] for line in cases: self.assertIsNone(chv_vsock.decode_notify(line), line) def test_decode_accepts_int_ts(self): msg = chv_vsock.decode_notify( b'{"type": "heartbeat", "vm": "v", "ts": 7}') self.assertEqual(msg["ts"], 7) def test_paths(self): self.assertEqual( chv_vsock.vsock_socket_path("/s", "chv-t1"), "/s/chv-t1/vsock.sock") self.assertEqual( chv_vsock.notify_socket_path("/s/chv-t1/vsock.sock"), "/s/chv-t1/vsock.sock_1024") self.assertEqual(chv_vsock.HOST_CID, 2) self.assertEqual(chv_vsock.VSOCK_NOTIFY_PORT, 1024) def _wait_for_socket(path, timeout=2.0): deadline = time.monotonic() + timeout while time.monotonic() < deadline: if os.path.exists(path): return True time.sleep(0.02) return False class VsockListenTest(unittest.TestCase): def setUp(self): self.dir = tempfile.mkdtemp(prefix="chv-p1b-vsock-") def tearDown(self): import shutil shutil.rmtree(self.dir, ignore_errors=True) def _client(self, path, lines, delay=0.05): """后台线程:等 socket 出现,连上发 lines 后关闭。 connect 带重试:监听方 bind 与 listen 之间、以及删 残留文件重 bind 的窗口里,单次 connect 可能被拒; 须重试到成功或超时,否则测试会偶发失败。""" def run(): if not _wait_for_socket(path): return deadline = time.monotonic() + 5 sock = None while time.monotonic() < deadline: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: sock.connect(path) break except OSError: try: sock.close() except OSError: pass sock = None time.sleep(0.02) if sock is None: return try: time.sleep(delay) for line in lines: sock.sendall(line) except OSError: pass finally: try: sock.close() except OSError: pass thread = threading.Thread(target=run, daemon=True) thread.start() return thread def test_listen_returns_ready(self): path = os.path.join(self.dir, "n.sock_1024") thread = self._client( path, [chv_vsock.encode_notify("ready", "chv-t1")]) msg = chv_vsock.listen_notify(path, timeout=5) thread.join(timeout=5) self.assertIsNotNone(msg) self.assertEqual(msg["type"], "ready") self.assertEqual(msg["vm"], "chv-t1") self.assertFalse(os.path.exists(path), "监听完须清 socket 文件") def test_listen_timeout_returns_none(self): path = os.path.join(self.dir, "t.sock_1024") start = time.monotonic() self.assertIsNone(chv_vsock.listen_notify(path, timeout=0.3)) self.assertLess(time.monotonic() - start, 2) self.assertFalse(os.path.exists(path)) def test_listen_skips_invalid_lines_and_wrong_type(self): path = os.path.join(self.dir, "w.sock_1024") thread = self._client(path, [ b"not json\n", chv_vsock.encode_notify("heartbeat", "chv-t1"), chv_vsock.encode_notify("ready", "chv-t1"), ]) msg = chv_vsock.listen_notify(path, timeout=5, want_type="ready") thread.join(timeout=5) self.assertIsNotNone(msg) self.assertEqual(msg["type"], "ready") def test_listen_survives_stale_socket_file(self): path = os.path.join(self.dir, "s.sock_1024") with open(path, "w") as fh: fh.write("stale") thread = self._client( path, [chv_vsock.encode_notify("ready", "chv-t1")]) msg = chv_vsock.listen_notify(path, timeout=5) thread.join(timeout=5) self.assertIsNotNone(msg) self.assertEqual(msg["type"], "ready") def test_wait_ready_end_to_end(self): vm_dir = os.path.join(self.dir, "chv-t1") os.makedirs(vm_dir) path = chv_vsock.notify_socket_path( chv_vsock.vsock_socket_path(self.dir, "chv-t1")) thread = self._client( path, [chv_vsock.encode_notify("ready", "chv-t1")]) msg = chv_vsock.wait_ready(self.dir, "chv-t1", timeout=5) thread.join(timeout=5) self.assertIsNotNone(msg) self.assertEqual(msg["type"], "ready") self.assertEqual(msg["vm"], "chv-t1") def test_wait_ready_timeout_returns_none(self): vm_dir = os.path.join(self.dir, "chv-t2") os.makedirs(vm_dir) self.assertIsNone( chv_vsock.wait_ready(self.dir, "chv-t2", timeout=0.3)) class GuestNotifyScriptTest(unittest.TestCase): def test_script_exists_and_stdlib_only(self): self.assertTrue(GUEST_SCRIPT.is_file()) self.assertTrue(_stdlib_only(GUEST_SCRIPT)) self.assertTrue( _stdlib_only(ROOT / "cloud" / "chv" / "vsock.py")) def test_vm_name_sources(self): mod = _load_guest_module() self.assertEqual(mod._vm_name(["prog", "chv-t1"]), "chv-t1") old = os.environ.pop("CHV_VM_NAME", None) try: os.environ["CHV_VM_NAME"] = "chv-env" self.assertEqual(mod._vm_name(["prog"]), "chv-env") finally: if old is None: os.environ.pop("CHV_VM_NAME", None) else: os.environ["CHV_VM_NAME"] = old name = mod._vm_name(["prog"]) self.assertIsInstance(name, str) self.assertTrue(name) def test_send_writes_json_line(self): mod = _load_guest_module() client, server = socket.socketpair() try: mod._send(client, "heartbeat", "chv-t1") server.settimeout(2) data = server.recv(4096) msg = json.loads(data.decode("utf-8")) self.assertEqual(msg["type"], "heartbeat") self.assertEqual(msg["vm"], "chv-t1") self.assertIsInstance(msg["ts"], float) finally: client.close() server.close() def test_main_exits_silently_when_host_unreachable(self): # 本机无 vsock 监听:main 应在重试窗口内静默返回 0, # 绝不抛异常、不影响调用方。 mod = _load_guest_module() old = mod.CONNECT_RETRY_SECONDS mod.CONNECT_RETRY_SECONDS = 0 try: self.assertEqual(mod.main(["prog", "chv-t1"]), 0) finally: mod.CONNECT_RETRY_SECONDS = old def test_ports_agree(self): mod = _load_guest_module() self.assertEqual(mod.NOTIFY_PORT, chv_vsock.VSOCK_NOTIFY_PORT) self.assertEqual(mod.HOST_CID, chv_vsock.HOST_CID) if __name__ == "__main__": unittest.main()