"""HypervisorDriver contract methods: VM lookup, power actions, get_vm_config. netOrk used to call Proxmox's own ``power_vm`` and reach into ``_node_api()`` for a VM's hardware. Both are Proxmox-only, so a second hypervisor could not serve the same endpoints. These pin the contract methods that replace them. """ from __future__ import annotations from unittest.mock import MagicMock import pytest from napalm_proxmox.vm_contract_mixin import parse_vm_config QEMU_LIST = [{"vmid": 100, "name": "web01", "status": "running"}] LXC_LIST = [{"vmid": 200, "name": "dns01", "status": "running"}] @pytest.fixture def api(driver): node = driver._node_api() node.qemu.get.return_value = QEMU_LIST node.lxc.get.return_value = LXC_LIST node.qemu.return_value.status.start.post.return_value = "UPID:start" driver._wait_for_task = MagicMock() return node class TestGetVmsReportsStringIds: def test_vmid_is_a_string(self, driver, api): driver.get_vm_interfaces = MagicMock(return_value=({}, False, False)) driver._get_vm_disk_and_boot = MagicMock(return_value=([], False)) assert [vm["vmid"] for vm in driver.get_vms()] == ["100", "200"] def test_ordered_numerically_not_lexically(self, driver, api): api.qemu.get.return_value = [{"vmid": 1000, "name": "a"}, {"vmid": 99, "name": "b"}] api.lxc.get.return_value = [] driver.get_vm_interfaces = MagicMock(return_value=({}, False, False)) driver._get_vm_disk_and_boot = MagicMock(return_value=([], False)) assert [vm["vmid"] for vm in driver.get_vms()] == ["99", "1000"] class TestResolveVm: def test_by_vmid_string(self, driver, api): assert driver._resolve_vm("100") == (100, "vm") def test_by_name(self, driver, api): assert driver._resolve_vm("dns01") == (200, "container") def test_unknown_raises_value_error(self, driver, api): with pytest.raises(ValueError, match="nope"): driver._resolve_vm("nope") class TestPowerActions: def test_start_posts_and_waits_for_the_task(self, driver, api): driver.start_vm("web01") api.qemu.return_value.status.start.post.assert_called_once() driver._wait_for_task.assert_called_once_with("UPID:start", timeout=120) @pytest.mark.parametrize(("force", "action"), [(False, "shutdown"), (True, "stop")]) def test_stop_graceful_or_forced(self, driver, api, force, action): driver.stop_vm("100", force=force) getattr(api.qemu.return_value.status, action).post.assert_called_once() @pytest.mark.parametrize(("force", "action"), [(False, "reboot"), (True, "reset")]) def test_reboot_graceful_or_forced(self, driver, api, force, action): driver.reboot_vm("100", force=force) getattr(api.qemu.return_value.status, action).post.assert_called_once() def test_forced_reboot_of_a_container_is_a_stop_and_start(self, driver, api): """LXC has no reset; stop + start is the closest thing to pulling the plug.""" driver.reboot_vm("200", force=True) status = api.lxc.return_value.status status.stop.post.assert_called_once() status.start.post.assert_called_once() def test_suspend_vm(self, driver, api): driver.suspend_vm("100") api.qemu.return_value.status.suspend.post.assert_called_once() def test_suspend_container_is_refused(self, driver, api): with pytest.raises(RuntimeError, match="container"): driver.suspend_vm("200") def test_api_error_becomes_runtime_error(self, driver, api): api.qemu.return_value.status.start.post.side_effect = Exception("locked") with pytest.raises(RuntimeError, match="locked"): driver.start_vm("100") QEMU_CONFIG = { "name": "web01", "cores": 2, "sockets": 2, "memory": "8192", "ostype": "l26", "cpu": "host,flags=+aes", "bios": "ovmf", "machine": "q35", "boot": "order=scsi0;ide2;net0", "scsi0": "local-lvm:vm-100-disk-0,size=32G,format=raw", "virtio1": "tank:vm-100-disk-1,size=512M", "ide2": "local:iso/debian.iso,media=cdrom", "efidisk0": "local-lvm:vm-100-disk-2,size=4M", "net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1", "net1": "e1000=BC:24:11:AA:BB:DD,bridge=vmbr1", "hostpci0": "0000:01:00.0,pcie=1", "usb0": "host=1234:5678", "description": "Production web server", "tags": "prod;web", } LXC_CONFIG = { "hostname": "dns01", "cores": 1, "memory": 512, "ostype": "debian", "rootfs": "local-lvm:vm-200-disk-0,size=8G", "mp0": "tank:subvol-200-disk-1,mp=/data,size=1T", "net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:00:00:01,ip=dhcp,tag=20,type=veth", } class TestParseQemuConfig: @pytest.fixture def cfg(self): return parse_vm_config("100", "vm", QEMU_CONFIG) def test_core_fields(self, cfg): assert cfg["vmid"] == "100" assert cfg["name"] == "web01" assert cfg["vcpus"] == 4 assert cfg["memory"] == 8192 assert cfg["os_type"] == "l26" assert cfg["description"] == "Production web server" assert cfg["tags"] == ["prod", "web"] def test_boot_order(self, cfg): assert cfg["boot_order"] == ["scsi0", "ide2", "net0"] def test_disks_skip_cdrom_and_normalise_size(self, cfg): by_dev = {d["device"]: d for d in cfg["disks"]} assert set(by_dev) == {"scsi0", "virtio1", "efidisk0"} assert by_dev["scsi0"] == { "device": "scsi0", "storage": "local-lvm", "size": 32, "format": "raw", "bootable": True, } assert by_dev["virtio1"]["size"] == 0 # 512M rounds down to 0 GB assert by_dev["virtio1"]["bootable"] is False def test_nics(self, cfg): assert cfg["nics"] == [ { "device": "net0", "mac": "BC:24:11:AA:BB:CC", "model": "virtio", "bridge": "vmbr0", "vlan_id": 10, }, { "device": "net1", "mac": "BC:24:11:AA:BB:DD", "model": "e1000", "bridge": "vmbr1", "vlan_id": 0, }, ] def test_hardware_details(self, cfg): assert cfg["cpu_type"] == "host" assert cfg["sockets"] == 2 assert cfg["cores_per_socket"] == 2 assert cfg["firmware"] == "efi" assert cfg["machine"] == "q35" def test_passthrough(self, cfg): assert cfg["passthrough"] == [ {"slot": "hostpci0", "kind": "pci", "config": "0000:01:00.0,pcie=1"}, {"slot": "usb0", "kind": "usb", "config": "host=1234:5678"}, ] def test_defaults_for_a_bare_config(self): cfg = parse_vm_config("101", "vm", {}) assert cfg["name"] == "vm-101" assert cfg["vcpus"] == 1 assert cfg["cpu_type"] == "kvm64" assert cfg["firmware"] == "bios" assert "machine" not in cfg assert cfg["boot_order"] == [] def test_legacy_bootdisk(self): cfg = parse_vm_config("101", "vm", {"boot": "cdn", "bootdisk": "scsi0"}) assert cfg["boot_order"] == ["scsi0"] class TestParseLxcConfig: @pytest.fixture def cfg(self): return parse_vm_config("200", "container", LXC_CONFIG) def test_core_fields(self, cfg): assert cfg["name"] == "dns01" assert cfg["vcpus"] == 1 assert cfg["memory"] == 512 assert cfg["tags"] == [] def test_rootfs_and_mountpoint(self, cfg): assert [(d["device"], d["storage"], d["size"]) for d in cfg["disks"]] == [ ("rootfs", "local-lvm", 8), ("mp0", "tank", 1024), ] def test_veth_nic(self, cfg): assert cfg["nics"] == [ { "device": "net0", "mac": "BC:24:11:00:00:01", "model": "veth", "bridge": "vmbr0", "vlan_id": 20, } ] def test_no_vm_only_hardware_fields(self, cfg): assert "firmware" not in cfg assert "sockets" not in cfg class TestGetVmConfig: def test_fetches_the_right_config(self, driver, api): api.lxc.return_value.config.get.return_value = LXC_CONFIG cfg = driver.get_vm_config("dns01") assert cfg["vmid"] == "200" assert cfg["name"] == "dns01"