fix(vm_provision_mixin): get_vm_status hit a non-existent guest-agent path

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").
This commit is contained in:
Christian Manivong
2026-07-08 14:09:40 +02:00
parent 0da4ca3c69
commit 7038494b49
2 changed files with 69 additions and 31 deletions
+24 -24
View File
@@ -537,20 +537,13 @@ class ProxmoxVMProvisionMixin:
vmid_int = int(vmid) vmid_int = int(vmid)
_logger.debug(f"Getting status for VM {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: try:
config = self._node_api().qemu(vmid_int).config.get() self._node_api().qemu(vmid_int).config.get()
except Exception: except Exception:
# VM may not exist yet or config not readable # VM may not exist yet or config not readable
return {"status": "unknown"} 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 # Polling loop
start_time = time.time() start_time = time.time()
while True: while True:
@@ -559,27 +552,34 @@ class ProxmoxVMProvisionMixin:
raise RuntimeError(f"VM {vmid} failed to acquire IP within {timeout}s") raise RuntimeError(f"VM {vmid} failed to acquire IP within {timeout}s")
try: try:
# Query guest-agent network interfaces # Query guest-agent network interfaces. Proxmox's REST path is
agent_info = self._node_api().qemu(vmid_int).agent.network_get_interfaces.get() # "network-get-interfaces" (hyphens) — it must be passed as a
interfaces = agent_info.get("result", []) # 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) # The guest agent does not report interfaces in a fixed order —
if interfaces: # "lo" commonly comes first. Skip it and take the first real
net0_iface = interfaces[0] # Assumes net0 is first in list # NIC that has an IPv4 address.
net0_mac = net0_iface.get("hardware-address", "") for iface in interfaces:
ip_addresses = net0_iface.get("ip-addresses", []) name = iface.get("name", "")
if not name or name == "lo":
if ip_addresses: continue
# Found IP for addr in iface.get("ip-addresses", []):
ip_info = ip_addresses[0] if addr.get("ip-address-type") != "ipv4":
ip_addr = ip_info.get("ip-address", "") continue
ip_addr = addr.get("ip-address", "")
if ip_addr: if ip_addr:
_logger.info(f"VM {vmid} acquired IP {ip_addr}") _logger.info(f"VM {vmid} acquired IP {ip_addr}")
return { return {
"status": "running", "status": "running",
"ip_address": ip_addr, "ip_address": ip_addr,
"hostname": net0_iface.get("name", ""), "hostname": name,
"mac_address": net0_mac, "mac_address": iface.get("hardware-address", ""),
} }
except Exception as e: except Exception as e:
+45 -7
View File
@@ -335,9 +335,12 @@ def test_get_vm_status_with_wait_for_ip():
mock_node.qemu.return_value = mock_vm mock_node.qemu.return_value = mock_vm
mock_vm.config.get.return_value = {"net0": "virtio,bridge=vmbr0,tag=10"} mock_vm.config.get.return_value = {"net0": "virtio,bridge=vmbr0,tag=10"}
# Mock guest-agent: first no IP, then with IP # The real Proxmox REST path is "network-get-interfaces" (hyphens),
mock_agent = MagicMock() # reached via agent(...) as a callable resource, not dotted attribute
mock_agent.network_get_interfaces.get.side_effect = [ # 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": [{"name": "eth0", "hardware-address": "aa:bb:cc:dd:ee:00"}]},
{ {
"result": [ "result": [
@@ -349,16 +352,51 @@ def test_get_vm_status_with_wait_for_ip():
] ]
}, },
] ]
mock_vm.agent = mock_agent
with patch("time.sleep"): with patch("time.sleep"):
result = mixin.get_vm_status("101", wait_for_ip=True, timeout=30, poll_interval=1) 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["status"] == "running"
assert result["ip_address"] == "10.0.0.100" assert result["ip_address"] == "10.0.0.100"
assert result["mac_address"] == "aa:bb:cc:dd:ee:00" 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(): def test_get_vm_status_timeout_waiting_for_ip():
"""get_vm_status raises RuntimeError if IP acquisition times out.""" """get_vm_status raises RuntimeError if IP acquisition times out."""
mixin = ProxmoxVMProvisionMixin() 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_vm.config.get.return_value = {"net0": "virtio,bridge=vmbr0"}
# Mock guest-agent that never returns IP # Mock guest-agent that never returns IP
mock_agent = MagicMock() mock_agent_call = MagicMock()
mock_agent.network_get_interfaces.get.return_value = { 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"}] "result": [{"name": "eth0", "hardware-address": "aa:bb:cc:dd:ee:00"}]
} }
mock_vm.agent = mock_agent
with pytest.raises(RuntimeError, match="timeout|IP"): with pytest.raises(RuntimeError, match="timeout|IP"):
with patch("time.sleep"): with patch("time.sleep"):