"""Tests for ProxmoxVMProvisionMixin — VM creation, destruction, status polling.""" from __future__ import annotations import pytest from unittest.mock import MagicMock, patch from napalm_proxmox.driver import ProxmoxDriver from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_wait_for_task_success(): """_wait_for_task succeeds when task reaches stopped status with exitstatus OK.""" mixin = ProxmoxVMProvisionMixin() # Mock the _node_api() to return a mock that supports task polling mock_node = MagicMock() mock_task_status = MagicMock() mock_task_status.status.get.side_effect = [ {"status": "running", "exitstatus": None}, {"status": "stopped", "exitstatus": "OK"}, ] mock_node.tasks.return_value = mock_task_status mixin._node_api = MagicMock(return_value=mock_node) # Should complete without raising with patch("time.sleep"): # Speed up polling result = mixin._wait_for_task("UPID:pve1:123:456:789:clone:100:root@pam:", timeout=30) assert result is None def test_wait_for_task_error(): """_wait_for_task raises RuntimeError when task exits with non-OK status.""" mixin = ProxmoxVMProvisionMixin() mock_node = MagicMock() mock_task_status = MagicMock() mock_task_status.status.get.return_value = {"status": "stopped", "exitstatus": "FAILED"} mock_node.tasks.return_value = mock_task_status mixin._node_api = MagicMock(return_value=mock_node) with pytest.raises(RuntimeError, match="FAILED"): with patch("time.sleep"): mixin._wait_for_task("UPID:pve1:123:456:789:clone:100:root@pam:", timeout=30) def test_wait_for_task_timeout(): """_wait_for_task raises RuntimeError on timeout.""" mixin = ProxmoxVMProvisionMixin() mock_node = MagicMock() mock_task_status = MagicMock() mock_task_status.status.get.return_value = {"status": "running"} # Always running mock_node.tasks.return_value = mock_task_status mixin._node_api = MagicMock(return_value=mock_node) with pytest.raises(RuntimeError, match="timed out"): with patch("napalm_proxmox.vm_provision_mixin.time.time") as mock_time: # Simulate time passing: return incremented values to exceed timeout quickly mock_time.side_effect = [0, 2, 4] # After 2 iterations, time > timeout=1 with patch("napalm_proxmox.vm_provision_mixin.time.sleep"): mixin._wait_for_task("UPID:pve1:123:456:789:clone:100:root@pam:", timeout=1) def test_create_vm_from_cloud_init_happy_path(): """create_vm_from_cloud_init clones template, configures NICs, injects Cloud-Init, starts VM.""" mixin = ProxmoxVMProvisionMixin() mixin._node_name = "pve1" # Mock API hierarchy mock_api = MagicMock() mock_api.cluster.nextid.get.return_value = 101 mock_api.storage.get.return_value = [ {"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1} ] mock_node = MagicMock() mixin._api = mock_api mixin._node_api = MagicMock(return_value=mock_node) # Mock VM operations mock_vm = MagicMock() mock_node.qemu.return_value = mock_vm mock_vm.clone.post.return_value = "UPID:pve1:123:clone" mock_vm.config.post.return_value = None mock_vm.status.start.post.return_value = "UPID:pve1:124:start" # Mock task completion mock_task = MagicMock() mock_task.status.get.return_value = {"status": "stopped", "exitstatus": "OK"} mock_node.tasks.return_value = mock_task # Mock storage upload mock_storage = MagicMock() mock_storage.upload.post.return_value = {"filename": "snippets:snippets/101-user-data.yaml"} mock_node.storage.return_value = mock_storage with patch("time.sleep"): result = mixin.create_vm_from_cloud_init( name="test-vm", template="100", cpu=2, memory=2048, mgmt_bridge="vmbr0", mgmt_vlan_tag=10, capture_bridge="vmbr1", capture_vlan_tags=[20, 30], cloud_init_config={"bootstrap_token": "xyz"}, timeout=120, ) assert result["vmid"] == "101" assert result["name"] == "test-vm" assert result["node"] == "pve1" assert mock_vm.clone.post.called def test_create_vm_missing_snippet_storage(): """create_vm_from_cloud_init raises ValueError if snippet storage unavailable.""" mixin = ProxmoxVMProvisionMixin() mixin._node_name = "pve1" # Mock API with no snippet storage mock_api = MagicMock() mock_api.cluster.nextid.get.return_value = 101 mock_api.storage.get.return_value = [ {"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1} ] mixin._api = mock_api # Mock node for clone operation (so we get to the storage check) mock_node = MagicMock() mixin._node_api = MagicMock(return_value=mock_node) mock_vm = MagicMock() mock_node.qemu.return_value = mock_vm mock_vm.clone.post.return_value = "UPID:pve1:123:clone" mock_vm.config.post.return_value = None # Mock task to allow clone to complete mock_task = MagicMock() mock_task.status.get.return_value = {"status": "stopped", "exitstatus": "OK"} mock_node.tasks.return_value = mock_task with pytest.raises(ValueError, match="snippet"): with patch("napalm_proxmox.vm_provision_mixin.time.sleep"): mixin.create_vm_from_cloud_init( name="test-vm", template="100", cpu=2, memory=2048, mgmt_bridge="vmbr0", mgmt_vlan_tag=10, capture_bridge="vmbr1", capture_vlan_tags=[20], cloud_init_config={}, ) def test_get_vm_status_with_wait_for_ip(): """get_vm_status(wait_for_ip=True) polls guest-agent until IP acquired.""" mixin = ProxmoxVMProvisionMixin() mock_node = MagicMock() mixin._node_api = MagicMock(return_value=mock_node) # Mock VM config mock_vm = MagicMock() mock_node.qemu.return_value = mock_vm mock_vm.config.get.return_value = {"net0": "virtio,bridge=vmbr0,tag=10"} # Mock guest-agent: first no IP, then with IP mock_agent = MagicMock() mock_agent.network_get_interfaces.get.side_effect = [ {"result": [{"name": "eth0", "hardware-address": "aa:bb:cc:dd:ee:00"}]}, { "result": [ { "name": "eth0", "hardware-address": "aa:bb:cc:dd:ee:00", "ip-addresses": [{"ip-address": "10.0.0.100", "ip-address-type": "ipv4"}], } ] }, ] mock_vm.agent = mock_agent with patch("time.sleep"): result = mixin.get_vm_status("101", wait_for_ip=True, timeout=30, poll_interval=1) assert result["status"] == "running" assert result["ip_address"] == "10.0.0.100" assert result["mac_address"] == "aa:bb:cc:dd:ee:00" def test_get_vm_status_timeout_waiting_for_ip(): """get_vm_status raises RuntimeError if IP acquisition times out.""" mixin = ProxmoxVMProvisionMixin() mock_node = MagicMock() mixin._node_api = MagicMock(return_value=mock_node) # Mock VM config mock_vm = MagicMock() mock_node.qemu.return_value = mock_vm mock_vm.config.get.return_value = {"net0": "virtio,bridge=vmbr0"} # Mock guest-agent that never returns IP mock_agent = MagicMock() mock_agent.network_get_interfaces.get.return_value = { "result": [{"name": "eth0", "hardware-address": "aa:bb:cc:dd:ee:00"}] } mock_vm.agent = mock_agent with pytest.raises(RuntimeError, match="timeout|IP"): with patch("time.sleep"): mixin.get_vm_status("101", wait_for_ip=True, timeout=1, poll_interval=0.5) def test_destroy_vm_success(): """destroy_vm stops running VM, deletes it, cleans up snippets.""" mixin = ProxmoxVMProvisionMixin() mock_node = MagicMock() mixin._node_api = MagicMock(return_value=mock_node) # Mock VM operations mock_vm = MagicMock() mock_node.qemu.return_value = mock_vm mock_vm.config.get.return_value = {"cicustom": "user=snippets:snippets/101-user-data.yaml"} mock_vm.status.stop.post.return_value = "UPID:pve1:125:stop" mock_vm.delete.return_value = None # Mock task completion mock_task = MagicMock() mock_task.status.get.return_value = {"status": "stopped", "exitstatus": "OK"} mock_node.tasks.return_value = mock_task # Mock snippet deletion (best-effort) mock_storage = MagicMock() mock_node.storage.return_value = mock_storage mock_content = MagicMock() mock_storage.content.return_value = mock_content mock_content.delete.return_value = None with patch("time.sleep"): mixin.destroy_vm("101", remove_disk=True, timeout=60) assert mock_vm.delete.called def test_destroy_vm_already_stopped(): """destroy_vm succeeds even if VM already stopped.""" mixin = ProxmoxVMProvisionMixin() mock_node = MagicMock() mixin._node_api = MagicMock(return_value=mock_node) # Mock VM operations mock_vm = MagicMock() mock_node.qemu.return_value = mock_vm mock_vm.config.get.return_value = {} mock_vm.delete.return_value = None with patch("time.sleep"): mixin.destroy_vm("101", remove_disk=True, timeout=60) # Verify delete was called (stop may fail or not be needed) assert mock_vm.delete.called