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