From 7038494b4997ec679ec046a112324d7c4d3bf2a2 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Wed, 8 Jul 2026 14:09:40 +0200 Subject: [PATCH] fix(vm_provision_mixin): get_vm_status hit a non-existent guest-agent path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent.network_get_interfaces.get() built the URL path segment literally ("network_get_interfaces"), but the real Proxmox REST endpoint uses hyphens ("network-get-interfaces") and must be reached via agent(...) as a callable resource — the underscored attribute path 404ed silently on every poll, so wait_for_ip always ran out the full timeout even though the guest agent was reporting the IP to Proxmox correctly the whole time. Also stopped assuming interfaces[0] is the real NIC — the guest agent commonly reports "lo" first, matching the working pattern already used in vm_mixin.py (skip "lo", require ip-address-type == "ipv4"). --- napalm_proxmox/vm_provision_mixin.py | 48 ++++++++++++------------- tests/test_vm_provision_mixin.py | 52 ++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/napalm_proxmox/vm_provision_mixin.py b/napalm_proxmox/vm_provision_mixin.py index ec2c76e..3bd2344 100644 --- a/napalm_proxmox/vm_provision_mixin.py +++ b/napalm_proxmox/vm_provision_mixin.py @@ -537,20 +537,13 @@ class ProxmoxVMProvisionMixin: vmid_int = int(vmid) _logger.debug(f"Getting status for VM {vmid}") - # Get VM config to infer net0 MAC (for matching guest-agent results) + # VM must exist / be readable before we start polling. try: - config = self._node_api().qemu(vmid_int).config.get() + self._node_api().qemu(vmid_int).config.get() except Exception: # VM may not exist yet or config not readable return {"status": "unknown"} - # Parse net0 MAC from config (if present) - net0_line = config.get("net0", "") - expected_mac = None - # Example: "virtio,bridge=vmbr0,tag=10" — no explicit MAC - # Proxmox auto-generates MACs in a deterministic pattern, but we'll - # match by looking for the first NIC's IP in guest-agent results - # Polling loop start_time = time.time() while True: @@ -559,27 +552,34 @@ class ProxmoxVMProvisionMixin: raise RuntimeError(f"VM {vmid} failed to acquire IP within {timeout}s") try: - # Query guest-agent network interfaces - agent_info = self._node_api().qemu(vmid_int).agent.network_get_interfaces.get() - interfaces = agent_info.get("result", []) + # Query guest-agent network interfaces. Proxmox's REST path is + # "network-get-interfaces" (hyphens) — it must be passed as a + # resource id via __call__, not dotted attribute access (which + # would silently build a non-existent "network_get_interfaces" + # path and 404 on every poll). + agent_info = ( + self._node_api().qemu(vmid_int).agent("network-get-interfaces").get() + ) + interfaces = (agent_info or {}).get("result", []) - # Find net0 (first interface with IP) - if interfaces: - net0_iface = interfaces[0] # Assumes net0 is first in list - net0_mac = net0_iface.get("hardware-address", "") - ip_addresses = net0_iface.get("ip-addresses", []) - - if ip_addresses: - # Found IP - ip_info = ip_addresses[0] - ip_addr = ip_info.get("ip-address", "") + # The guest agent does not report interfaces in a fixed order — + # "lo" commonly comes first. Skip it and take the first real + # NIC that has an IPv4 address. + for iface in interfaces: + name = iface.get("name", "") + if not name or name == "lo": + continue + for addr in iface.get("ip-addresses", []): + if addr.get("ip-address-type") != "ipv4": + continue + ip_addr = addr.get("ip-address", "") if ip_addr: _logger.info(f"VM {vmid} acquired IP {ip_addr}") return { "status": "running", "ip_address": ip_addr, - "hostname": net0_iface.get("name", ""), - "mac_address": net0_mac, + "hostname": name, + "mac_address": iface.get("hardware-address", ""), } except Exception as e: diff --git a/tests/test_vm_provision_mixin.py b/tests/test_vm_provision_mixin.py index 8581ce6..12e9b07 100644 --- a/tests/test_vm_provision_mixin.py +++ b/tests/test_vm_provision_mixin.py @@ -335,9 +335,12 @@ def test_get_vm_status_with_wait_for_ip(): 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 = [ + # The real Proxmox REST path is "network-get-interfaces" (hyphens), + # reached via agent(...) as a callable resource, not dotted attribute + # access — mock that exact call shape. + mock_agent_call = MagicMock() + mock_vm.agent.return_value = mock_agent_call + mock_agent_call.get.side_effect = [ {"result": [{"name": "eth0", "hardware-address": "aa:bb:cc:dd:ee:00"}]}, { "result": [ @@ -349,16 +352,51 @@ def test_get_vm_status_with_wait_for_ip(): ] }, ] - mock_vm.agent = mock_agent with patch("time.sleep"): result = mixin.get_vm_status("101", wait_for_ip=True, timeout=30, poll_interval=1) + mock_vm.agent.assert_called_with("network-get-interfaces") 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_skips_loopback_interface(): + """The guest agent commonly reports "lo" before the real NIC — it must + be skipped rather than mistaken for the VM's address.""" + mixin = ProxmoxVMProvisionMixin() + + mock_node = MagicMock() + mixin._node_api = MagicMock(return_value=mock_node) + + mock_vm = MagicMock() + mock_node.qemu.return_value = mock_vm + mock_vm.config.get.return_value = {"net0": "virtio,bridge=vmbr0"} + + mock_agent_call = MagicMock() + mock_vm.agent.return_value = mock_agent_call + mock_agent_call.get.return_value = { + "result": [ + { + "name": "lo", + "hardware-address": "00:00:00:00:00:00", + "ip-addresses": [{"ip-address": "127.0.0.1", "ip-address-type": "ipv4"}], + }, + { + "name": "eth0", + "hardware-address": "aa:bb:cc:dd:ee:00", + "ip-addresses": [{"ip-address": "10.0.0.101", "ip-address-type": "ipv4"}], + }, + ] + } + + with patch("time.sleep"): + result = mixin.get_vm_status("101", wait_for_ip=True, timeout=30, poll_interval=1) + + assert result["ip_address"] == "10.0.0.101" + + def test_get_vm_status_timeout_waiting_for_ip(): """get_vm_status raises RuntimeError if IP acquisition times out.""" mixin = ProxmoxVMProvisionMixin() @@ -372,11 +410,11 @@ def test_get_vm_status_timeout_waiting_for_ip(): 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 = { + mock_agent_call = MagicMock() + mock_vm.agent.return_value = mock_agent_call + mock_agent_call.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"):