From 400b19cf4277e9f7340d5f2c85e831272126e961 Mon Sep 17 00:00:00 2001 From: catborise Date: Sun, 25 Oct 2026 15:48:22 +0300 Subject: [PATCH] fix(instances): a VM whose guest agent hangs no longer stops its page The VM page read the guest's addresses with interfaceAddresses() from the agent, and the OS details with agent commands without a timeout. A connected agent that hangs (a frozen guest) kept the page from loading at all and held a worker: seen still loading after 120 s. Both now go through the 10 s guest agent command the root password and SSH key use. The addresses come from guest-network-get-interfaces, in the form the page's address code already reads; an agent that does not answer leaves the ARP address. With the agent frozen the page loads in about 10 s and the OS details answer with the agent's error (400) in 5 s, instead of a server error. --- instances/tests/test_root_pass.py | 18 +++++ instances/views.py | 7 +- vrtManager/instance.py | 69 ++++++++------------ vrtManager/tests/test_interface_addresses.py | 54 ++++++++++----- 4 files changed, 88 insertions(+), 60 deletions(-) diff --git a/instances/tests/test_root_pass.py b/instances/tests/test_root_pass.py index 67442d42..05b01197 100644 --- a/instances/tests/test_root_pass.py +++ b/instances/tests/test_root_pass.py @@ -183,3 +183,21 @@ class GuestAgentAccessTestCase(TestCase): self.proxy.set_user_password.side_effect = err self.assertEqual(self.post("rootpasswd", {"passwd": "S3cret-pass"}), ["The host of this virtual machine cannot be reached right now."]) + + +class OsInfoErrorTestCase(TestCase): + """The VM page asks for the guest's OS details; an agent that does not + answer in time is an error answer, not a server error.""" + + def setUp(self): + self.client.login(request=RequestFactory().get("/"), username="admin", password="admin") + compute = Compute.objects.create(name="osinfo-compute", hostname="192.0.2.1", type=1) + self.instance = Instance.objects.create(compute=compute, name="osinfo-vm", + uuid="22222222-3333-4444-5555-666666666666") + + @patch.object(Instance, "proxy") + def test_a_silent_agent_is_an_error_answer(self, proxy): + proxy.osinfo.side_effect = libvirtError("guest agent didn't respond to command within '10' seconds") + response = self.client.get(reverse("instances:osinfo", args=[self.instance.id])) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"error": "guest agent didn't respond to command within '10' seconds"}) diff --git a/instances/views.py b/instances/views.py index 2f8f9785..89cec857 100755 --- a/instances/views.py +++ b/instances/views.py @@ -420,9 +420,10 @@ def stats(request, pk): def osinfo(request, pk): instance = get_instance(request.user, pk) - results = instance.proxy.osinfo() - - return JsonResponse(results) + try: + return JsonResponse(instance.proxy.osinfo()) + except libvirtError as err: + return JsonResponse({"error": error_text(request, err)}, status=400) @superuser_only diff --git a/vrtManager/instance.py b/vrtManager/instance.py index bb064034..3cd3862b 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -15,7 +15,6 @@ try: from libvirt import ( VIR_DOMAIN_AFFECT_CONFIG, VIR_DOMAIN_AFFECT_LIVE, - VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP, VIR_DOMAIN_XML_SECURE, VIR_DOMAIN_XML_INACTIVE, @@ -51,7 +50,7 @@ try: VIR_ERR_NO_DOMAIN_BACKUP, libvirtError, ) - from libvirt_qemu import VIR_DOMAIN_QEMU_AGENT_COMMAND_DEFAULT, qemuAgentCommand + from libvirt_qemu import qemuAgentCommand except Exception: from libvirt import libvirtError, VIR_DOMAIN_XML_SECURE, VIR_DOMAIN_XML_INACTIVE, VIR_MIGRATE_LIVE @@ -375,36 +374,10 @@ class wvmInstance(wvmConnect): self.instance = self.wvm.lookupByUUIDString(uuid) if uuid else self.get_instance(vname) def osinfo(self): - info_results = qemuAgentCommand( - self.instance, - '{"execute":"guest-get-osinfo"}', - VIR_DOMAIN_QEMU_AGENT_COMMAND_DEFAULT, - 0, - ) - - timezone_results = qemuAgentCommand( - self.instance, - '{"execute":"guest-get-timezone"}', - VIR_DOMAIN_QEMU_AGENT_COMMAND_DEFAULT, - 0, - ) - - hostname_results = qemuAgentCommand( - self.instance, - '{"execute":"guest-get-host-name"}', - VIR_DOMAIN_QEMU_AGENT_COMMAND_DEFAULT, - 0, - ) - - info_results = json.loads(info_results).get("return") - - timezone_results = json.loads(timezone_results).get("return") - hostname_results = json.loads(hostname_results).get("return") - - info_results.update(timezone_results) - info_results.update(hostname_results) - - return info_results + info = self._agent_command("guest-get-osinfo") + info.update(self._agent_command("guest-get-timezone")) + info.update(self._agent_command("guest-get-host-name")) + return info def start(self, flags=0): self.instance.createWithFlags(flags) @@ -602,6 +575,18 @@ class wvmInstance(wvmConnect): return self.instance.interfaceAddresses(source) return {} + def _agent_interfaces(self): + """The guest's interfaces from its agent, in interfaceAddresses()'s + form, which waits for a hung agent indefinitely.""" + interfaces = {} + for iface in self._agent_command("guest-network-get-interfaces"): + addrs = [] + for address in iface.get("ip-addresses", []): + kind = 0 if address["ip-address-type"] == "ipv4" else 1 + addrs.append({"type": kind, "addr": address["ip-address"], "prefix": address["prefix"]}) + interfaces[iface["name"]] = {"hwaddr": iface.get("hardware-address"), "addrs": addrs} + return interfaces + def refresh_interface_addresses(self): self._ip_cache = {"qemuga": {}, "arp": {}} @@ -609,9 +594,8 @@ class wvmInstance(wvmConnect): return if self.is_agent_ready(): - self._ip_cache["qemuga"] = self._get_interface_addresses( - VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT - ) + with contextlib.suppress(libvirtError): + self._ip_cache["qemuga"] = self._agent_interfaces() self._ip_cache["arp"] = self._get_interface_addresses(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP) @@ -2525,12 +2509,15 @@ class wvmInstance(wvmConnect): return util.get_xml_path(self._XMLDesc(0), func=_get_agent) - def _agent_command(self, command, arguments): - """One guest agent command that waits AGENT_TIMEOUT seconds at most: - libvirt's own calls for it wait for a hung agent indefinitely, and - the domain's agent timeout is shared by every agent call.""" - request = json.dumps({"execute": command, "arguments": arguments}) - qemuAgentCommand(self.instance, request, AGENT_TIMEOUT, 0) + def _agent_command(self, command, arguments=None): + """The answer of one guest agent command that waits AGENT_TIMEOUT + seconds at most: libvirt's own calls for it wait for a hung agent + indefinitely, and the domain's agent timeout is shared by every agent + call.""" + request = {"execute": command} + if arguments is not None: + request["arguments"] = arguments + return json.loads(qemuAgentCommand(self.instance, json.dumps(request), AGENT_TIMEOUT, 0))["return"] def set_user_password(self, user, crypted): """Set a user's password in the running guest through its agent; diff --git a/vrtManager/tests/test_interface_addresses.py b/vrtManager/tests/test_interface_addresses.py index 29ebc573..ab234890 100644 --- a/vrtManager/tests/test_interface_addresses.py +++ b/vrtManager/tests/test_interface_addresses.py @@ -1,6 +1,9 @@ """A running VM's addresses come from the guest agent when it answers and -from the host's ARP table, so a VM without an agent shows its IPv4 address.""" +from the host's ARP table, so a VM without an agent shows its IPv4 address. +The agent is asked with a timeout: interfaceAddresses() would wait for a +hung agent indefinitely, and the VM page with it.""" +import json import unittest from unittest.mock import MagicMock, patch @@ -9,45 +12,64 @@ from django.conf import settings if not settings.configured: settings.configure(MAC_OUI="52:54:10") -from libvirt import VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP +from libvirt import VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP, libvirtError +from vrtManager import instance as module from vrtManager.instance import wvmInstance MAC = "52:54:00:00:00:01" ARP = {"vnet0": {"hwaddr": MAC, "addrs": [{"type": 0, "addr": "192.0.2.10", "prefix": 0}]}} -AGENT = {"eth0": {"hwaddr": MAC, "addrs": [{"type": 0, "addr": "192.0.2.20", "prefix": 24}]}} +# guest-network-get-interfaces: the loopback has no hardware address +AGENT = [{"name": "lo", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "127.0.0.1", "prefix": 8}]}, + {"name": "eth0", "hardware-address": MAC, "ip-addresses": [ + {"ip-address-type": "ipv4", "ip-address": "192.0.2.20", "prefix": 24}, + {"ip-address-type": "ipv6", "ip-address": "2001:db8::20", "prefix": 64}]}] def vm(agent): inst = wvmInstance.__new__(wvmInstance) inst._ip_cache = None inst.instance = MagicMock() - inst.instance.interfaceAddresses.side_effect = lambda source: { - VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT: AGENT, - VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP: ARP, - }[source] + inst.instance.interfaceAddresses.side_effect = lambda source: {VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP: ARP}[source] patch.object(inst, "get_status", return_value=1).start() patch.object(inst, "is_agent_ready", return_value=agent).start() return inst class InterfaceAddressesTestCase(unittest.TestCase): + def setUp(self): + self.agent = patch.object(module, "qemuAgentCommand", return_value=json.dumps({"return": AGENT})).start() + def tearDown(self): patch.stopall() def test_a_vm_without_an_agent_shows_its_arp_address(self): inst = vm(agent=False) self.assertEqual(inst.get_interface_addresses(MAC), (["192.0.2.10"], [])) - self.assertEqual( - [c.args for c in inst.instance.interfaceAddresses.call_args_list], [(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,)] - ) + self.agent.assert_not_called() - def test_the_agents_address_comes_first(self): + def test_the_agents_address_comes_first_and_is_asked_with_a_timeout(self): inst = vm(agent=True) - self.assertEqual(inst.get_interface_addresses(MAC), (["192.0.2.20"], [])) - self.assertEqual( - [c.args for c in inst.instance.interfaceAddresses.call_args_list], - [(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT,), (VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,)], - ) + self.assertEqual(inst.get_interface_addresses(MAC), (["192.0.2.20"], ["2001:db8::20/64"])) + self.agent.assert_called_once_with(inst.instance, '{"execute": "guest-network-get-interfaces"}', 10, 0) + self.assertEqual([c.args for c in inst.instance.interfaceAddresses.call_args_list], + [(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,)]) + + def test_an_agent_that_does_not_answer_leaves_the_arp_address(self): + self.agent.side_effect = libvirtError("guest agent didn't respond to command within '10' seconds") + self.assertEqual(vm(agent=True).get_interface_addresses(MAC), (["192.0.2.10"], [])) + + +class OsInfoTestCase(unittest.TestCase): + def test_the_os_details_are_asked_with_a_timeout(self): + answers = {"guest-get-osinfo": {"kernel-release": "6.12"}, "guest-get-timezone": {"zone": "UTC"}, + "guest-get-host-name": {"host-name": "web"}} + inst = wvmInstance.__new__(wvmInstance) + inst.instance = MagicMock() + with patch.object(module, "qemuAgentCommand", + side_effect=lambda dom, cmd, timeout, flags: json.dumps( + {"return": answers[json.loads(cmd)["execute"]]})) as agent: + self.assertEqual(inst.osinfo(), {"kernel-release": "6.12", "zone": "UTC", "host-name": "web"}) + self.assertEqual({c.args[2] for c in agent.call_args_list}, {10}) if __name__ == "__main__":