From 802e90c79fdd93b909c923e390466faad0fd623f Mon Sep 17 00:00:00 2001 From: catborise Date: Tue, 6 Oct 2026 10:08:48 +0300 Subject: [PATCH] fix: connection errors on VM actions name the host only to administrators When a VM action failed because its host could not be reached (the connection could not be opened, or it dropped during the call), the error page showed libvirt's connection error to any user: it names the host and how it is reached, e.g. "ssh: connect to host 10.x port 22". The lists and the VM page already keep that to administrators. - Opening a connection now fails with ConnectionFailed (a subclass of OperationError). A dropped connection is recognized by its error domain and code (RPC/remote/SSH layer with a transport code, measured: a keepalive timeout is VIR_FROM_RPC/VIR_ERR_INTERNAL_ERROR); an operation the daemon does not support (VIR_ERR_NO_SUPPORT) is not treated as one. - ExceptionMiddleware (pages and API) and the views that report the error themselves (snapshot, revert, disk deletion, clone) show it to superusers and view_instances users, and to everyone else "The host of this virtual machine cannot be reached right now." Errors of the operation itself ("domain is not running") are shown as before. Checked on the test setup: powering on a VM of an unreachable compute shows the owner the generic text and the administrator the error. The tests fail on the previous code. --- instances/views.py | 9 +-- vrtManager/connection.py | 2 +- vrtManager/util.py | 5 ++ webvirtcloud/middleware.py | 40 +++++++++++- webvirtcloud/test_connection_errors.py | 84 ++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 webvirtcloud/test_connection_errors.py diff --git a/instances/views.py b/instances/views.py index f441386a..27fb334a 100755 --- a/instances/views.py +++ b/instances/views.py @@ -37,6 +37,7 @@ from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM, VIR_DOMAIN_RUNNING, libvirtError) from logs.views import addlogmsg +from webvirtcloud.middleware import error_text from vrtManager import util from vrtManager.create import wvmCreate from vrtManager.instance import wvmInstances @@ -622,7 +623,7 @@ def destroy(request, pk): try: proxy.get_volume_by_path(path).delete(0) except libvirtError as err: - messages.error(request, _("Disk %(path)s was not deleted: %(err)s") % {"path": path, "err": err}) + messages.error(request, _("Disk %(path)s was not deleted: %(err)s") % {"path": path, "err": error_text(request, err)}) for path in shared: messages.warning(request, _("Disk %(path)s is used by another VM and was kept") % {"path": path}) addlogmsg( @@ -1265,7 +1266,7 @@ def snapshot(request, pk): instance.proxy.create_snapshot(name, desc) except libvirtError as err: # e.g. UEFI firmware whose NVRAM format does not allow internal snapshots - messages.error(request, _("Snapshot was not created: %(err)s") % {"err": err}) + messages.error(request, _("Snapshot was not created: %(err)s") % {"err": error_text(request, err)}) else: msg = _("Create snapshot: %(snap)s") % {"snap": name} addlogmsg(request.user.username, instance.compute.name, instance.name, msg) @@ -1303,7 +1304,7 @@ def revert_snapshot(request, pk): try: instance.proxy.snapshot_revert(snap_name) except libvirtError as err: - messages.error(request, _("Snapshot was not reverted: %(err)s") % {"err": err}) + messages.error(request, _("Snapshot was not reverted: %(err)s") % {"err": error_text(request, err)}) return _back(request, pk, "managesnapshot") msg = _("Successful revert snapshot: ") msg += snap_name @@ -1882,7 +1883,7 @@ def clone(request, pk): return redirect(reverse("instances:instance", args=[new_instance.id])) except (libvirtError, ValueError, OSError) as e: # OSError includes a lock timeout - messages.error(request, e) + messages.error(request, error_text(request, e)) return get_safe_redirect(request, default=reverse("instances:instance", args=[pk])) diff --git a/vrtManager/connection.py b/vrtManager/connection.py index 44a8e253..81220cd2 100644 --- a/vrtManager/connection.py +++ b/vrtManager/connection.py @@ -342,7 +342,7 @@ class wvmConnectionManager(object): return connection.connection else: # raise libvirt error - raise util.OperationError(connection.last_error) + raise util.ConnectionFailed(connection.last_error) def host_is_up(self, conn_type, hostname, timeout=1): """True if the libvirt endpoint of hostname accepts a TCP (or Unix diff --git a/vrtManager/util.py b/vrtManager/util.py index de32a1d3..c1bdb84f 100644 --- a/vrtManager/util.py +++ b/vrtManager/util.py @@ -22,6 +22,11 @@ class OperationError(libvirt.libvirtError): self.err = None +class ConnectionFailed(OperationError): + """The host could not be connected to; the message may name it and how it + is reached.""" + + def is_kvm_available(xml): kvm_domains = get_xml_path(xml, "//domain/@type='kvm'") return kvm_domains > 0 diff --git a/webvirtcloud/middleware.py b/webvirtcloud/middleware.py index 8064725b..8cd97b03 100644 --- a/webvirtcloud/middleware.py +++ b/webvirtcloud/middleware.py @@ -2,9 +2,45 @@ from django.contrib import messages from django.http import JsonResponse from django.shortcuts import render from django.utils.translation import gettext_lazy as _ +import libvirt from libvirt import libvirtError +from vrtManager.util import ConnectionFailed API_PREFIX = "/api/" +# errors of the connection to the host, not of the operation: both the +# domain and the code must match (a daemon that lacks a procedure answers +# VIR_FROM_RPC / VIR_ERR_NO_SUPPORT, which the user should see) +TRANSPORT_ERROR_DOMAINS = {libvirt.VIR_FROM_RPC, libvirt.VIR_FROM_REMOTE, libvirt.VIR_FROM_SSH, libvirt.VIR_FROM_LIBSSH} +TRANSPORT_ERROR_CODES = { + libvirt.VIR_ERR_INTERNAL_ERROR, # "connection closed due to keepalive timeout" + libvirt.VIR_ERR_SYSTEM_ERROR, # "Cannot recv data: ..." + libvirt.VIR_ERR_RPC, + libvirt.VIR_ERR_NO_CONNECT, + libvirt.VIR_ERR_AUTH_FAILED, + libvirt.VIR_ERR_AUTH_UNAVAILABLE, + libvirt.VIR_ERR_AUTH_CANCELLED, + libvirt.VIR_ERR_SSH, + libvirt.VIR_ERR_LIBSSH, +} +UNREACHABLE = _("The host of this virtual machine cannot be reached right now.") + + +def error_text(request, exception): + """ + The libvirt error to show. A connection error names the host and how it + is reached, which only administrators (superusers and the global + view_instances permission, who see the hosts) get; other users learn + that the host cannot be reached. + """ + connection_error = isinstance(exception, ConnectionFailed) or ( + isinstance(exception, libvirtError) + and exception.get_error_domain() in TRANSPORT_ERROR_DOMAINS + and exception.get_error_code() in TRANSPORT_ERROR_CODES + ) + user = getattr(request, "user", None) + if connection_error and not (user and (user.is_superuser or user.has_perm("instances.view_instances"))): + return str(UNREACHABLE) + return str(exception) class ExceptionMiddleware: @@ -18,14 +54,14 @@ class ExceptionMiddleware: # API clients get JSON with a failure status, not the HTML error page. if request.path_info.startswith(API_PREFIX): if isinstance(exception, libvirtError): - return JsonResponse({"detail": str(exception)}, status=400) + return JsonResponse({"detail": error_text(request, exception)}, status=400) if isinstance(exception, TimeoutError): return JsonResponse({"detail": "Operation is busy. Please retry."}, status=409) return None if isinstance(exception, libvirtError): messages.error( request, - _("libvirt Error - %(exception)s") % {"exception": exception}, + _("libvirt Error - %(exception)s") % {"exception": error_text(request, exception)}, ) return render(request, "500.html", {"libvirt_error": True}, status=500) # TODO: check connecting to host via VPN diff --git a/webvirtcloud/test_connection_errors.py b/webvirtcloud/test_connection_errors.py new file mode 100644 index 00000000..ebe53a0b --- /dev/null +++ b/webvirtcloud/test_connection_errors.py @@ -0,0 +1,84 @@ +"""An error page for a host that cannot be reached shows the connection error +(host, address) only to administrators; errors of the VM operation itself +are shown to everyone, as they explain what to do.""" + +from unittest.mock import MagicMock + +import libvirt +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.messages import get_messages +from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import RequestFactory, TestCase +from vrtManager.util import ConnectionFailed +from webvirtcloud.middleware import ExceptionMiddleware, error_text + +HOST_DETAIL = "ssh: connect to host 10.0.0.9 port 22: No route to host" + + +def libvirt_error(domain, text, code=libvirt.VIR_ERR_SYSTEM_ERROR): + err = libvirt.libvirtError.__new__(libvirt.libvirtError) + Exception.__init__(err, text) + err.err = (code, domain, text, 2, None, None, None, 0, 0) + return err + + +class ConnectionErrorPageTests(TestCase): + def setUp(self): + User = get_user_model() + self.owner = User.objects.create_user("ce-owner", password="pw") + self.admin = User.objects.create_superuser("ce-admin", "ce@example.com", "pw") + self.viewer = User.objects.create_user("ce-viewer", password="pw") + self.viewer.user_permissions.add(Permission.objects.get(codename="view_instances")) + self.viewer = User.objects.get(pk=self.viewer.pk) + + def handle(self, user, exception, path="/instances/1/poweron/"): + request = RequestFactory().post(path) + SessionMiddleware(lambda r: None).process_request(request) + request._messages = FallbackStorage(request) + request.user = user + response = ExceptionMiddleware(MagicMock()).process_exception(request, exception) + return response, [str(m) for m in get_messages(request)] + + def test_owner_does_not_see_connection_details(self): + for error in ( + ConnectionFailed(f"Connection Failed: Cannot recv data: {HOST_DETAIL}"), + libvirt_error(libvirt.VIR_FROM_RPC, f"Cannot recv data: {HOST_DETAIL}"), + ): + with self.subTest(error=str(error)): + response, messages = self.handle(self.owner, error) + self.assertEqual(response.status_code, 500) + self.assertNotIn("10.0.0.9", " ".join(messages)) + self.assertTrue(any("cannot be reached" in m for m in messages), messages) + + def test_administrators_see_connection_details(self): + for user in (self.admin, self.viewer): + with self.subTest(user=user.username): + _, messages = self.handle(user, libvirt_error(libvirt.VIR_FROM_RPC, f"Cannot recv data: {HOST_DETAIL}")) + self.assertIn("10.0.0.9", " ".join(messages)) + + def test_operation_errors_are_shown_to_everyone(self): + for error, text in ( + (libvirt_error(libvirt.VIR_FROM_QEMU, "Requested operation is not valid: domain is not running", + libvirt.VIR_ERR_OPERATION_INVALID), "domain is not running"), + # a reachable daemon without the procedure answers from the RPC layer + (libvirt_error(libvirt.VIR_FROM_RPC, "this function is not supported by the connection driver: virDomainFoo", + libvirt.VIR_ERR_NO_SUPPORT), "not supported"), + ): + with self.subTest(text=text): + _, messages = self.handle(self.owner, error) + self.assertIn(text, " ".join(messages)) + + def test_api_answers_the_same_way(self): + response, _ = self.handle(self.owner, ConnectionFailed(f"Connection Failed: {HOST_DETAIL}"), path="/api/v1/instances/1/") + self.assertEqual(response.status_code, 400) + self.assertNotIn(b"10.0.0.9", response.content) + response, _ = self.handle(self.admin, ConnectionFailed(f"Connection Failed: {HOST_DETAIL}"), path="/api/v1/instances/1/") + self.assertIn(b"10.0.0.9", response.content) + + def test_views_that_report_the_error_themselves(self): + request = RequestFactory().post("/") + request.user = self.owner + self.assertNotIn("10.0.0.9", error_text(request, libvirt_error(libvirt.VIR_FROM_RPC, HOST_DETAIL))) + self.assertEqual(error_text(request, ValueError("bad name")), "bad name") # clone also reports these