diff --git a/computes/templates/computes/instances.html b/computes/templates/computes/instances.html index ca5bb03c..0213c999 100644 --- a/computes/templates/computes/instances.html +++ b/computes/templates/computes/instances.html @@ -50,11 +50,18 @@
+ {% if compute.status is not True %} +
+ {% trans 'Problem occurred with host' %} {{ compute.name }}: {{ compute.connection_error }} +
+ {% endif %} {% if not instances %} + {% if compute.status is True %}
{% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: {% trans "Hypervisor doesn't have any Instances" %}
+ {% endif %}
{% else %} @@ -74,7 +81,7 @@ - + diff --git a/instances/models.py b/instances/models.py index 0ca07c47..7fd11912 100644 --- a/instances/models.py +++ b/instances/models.py @@ -2,7 +2,7 @@ from computes.models import Compute from django.db import models from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from libvirt import VIR_DOMAIN_XML_SECURE +from libvirt import VIR_DOMAIN_XML_SECURE, libvirtError from vrtManager.instance import wvmInstance from webvirtcloud.settings import QEMU_CONSOLE_LISTENER_ADDRESSES @@ -64,9 +64,13 @@ class Instance(models.Model): return self.proxy.get_disk_devices() @cached_property - def info(self) -> list: - """virDomainGetInfo, read once: state, max memory KiB, memory KiB, vCPUs, CPU time""" - return self.proxy.instance.info() + def info(self) -> list | None: + """virDomainGetInfo, read once: state, max memory KiB, memory KiB, vCPUs, CPU time. + None if the VM cannot be read (host down, VM gone): lists show it as unknown.""" + try: + return self.proxy.instance.info() + except libvirtError: + return None @cached_property def status(self) -> int: diff --git a/instances/templates/allinstances_index_grouped.html b/instances/templates/allinstances_index_grouped.html index 8e9f7f9a..e4efefed 100644 --- a/instances/templates/allinstances_index_grouped.html +++ b/instances/templates/allinstances_index_grouped.html @@ -17,7 +17,7 @@ {% for compute in computes %} - {% if compute.status is True and compute.instance_set.count > 0 %} + {% if compute.instance_set.count > 0 %} {% if app_settings.VM_DRBD_STATUS == 'True' %} {% endif %} + {% if compute.status is True %} + {% else %} + + + + {% endif %} {% for instance in compute.instance_set.all %} @@ -49,7 +59,7 @@ {% if app_settings.VM_DRBD_STATUS == 'True' %} {% endif %} - + diff --git a/instances/templates/allinstances_index_nongrouped.html b/instances/templates/allinstances_index_nongrouped.html index 5a35c243..9ed5682f 100644 --- a/instances/templates/allinstances_index_nongrouped.html +++ b/instances/templates/allinstances_index_nongrouped.html @@ -17,14 +17,13 @@ {% for instance in instances %} - {% if instance.compute.status is True %} {% if request.user.is_superuser %} {% if app_settings.VM_DRBD_STATUS == 'True' %} {% endif %} - + - {% endif %} {% endfor %}
{{ instance.name }}
-

{{ instance.title }}

+ {% if instance.info %}

{{ instance.title }}

{% endif %}
@@ -96,9 +103,10 @@ {% if instance.info.0 == 3 %} {% trans "Suspended" %} {% endif %} + {% if instance.info is None %}{% trans "Unknown" %}{% endif %} {{ instance.info.3 }}

{% widthratio instance.info.1 1024 1 %} MiB

{% if instance.info %}{% widthratio instance.info.1 1024 1 %} MiB{% endif %}

{% include 'instance_actions.html' %}
@@ -28,11 +28,16 @@ - {% trans "Connected" %} + {% if compute.status is True %} + {% trans "Connected" %} + {% else %} + {% trans "Not Connected" %} + {% endif %} {{ compute.cpu_count }} {{ compute.ram_size|filesizeformat }} @@ -42,6 +47,11 @@
{{ instance.name }}
-

{{ instance.title }}

+ {% if instance.info %}

{{ instance.title }}

{% endif %}
@@ -67,6 +77,7 @@ {% if instance.info.0 == 3 %} {% trans "Suspended" %} {% endif %} + {% if instance.info is None %}{% trans "Unknown" %}{% endif %} @@ -74,7 +85,7 @@ {{ instance.info.3 }}{% widthratio instance.info.2 1024 1 %} MB{% if instance.info %}{% widthratio instance.info.2 1024 1 %} MB{% endif %} {% include 'instance_actions.html' %}
{{ instance.name }}
-

{{ instance.title }}

+ {% if instance.info %}

{{ instance.title }}

{% endif %}
@@ -46,6 +45,7 @@ class="text-danger">{% trans "Off" %}{% endif %} {% if instance.info.0 == 3 %}{% trans "Suspended" %}{% endif %} + {% if instance.info is None %}{% trans "Unknown" %}{% endif %} @@ -53,12 +53,11 @@ {{ instance.info.3 }}{% widthratio instance.info.2 1024 1 %} MB{% if instance.info %}{% widthratio instance.info.2 1024 1 %} MB{% endif %} {% include 'instance_actions.html' %}
diff --git a/instances/templates/instance_actions.html b/instances/templates/instance_actions.html index f1b56e86..6818e3ae 100644 --- a/instances/templates/instance_actions.html +++ b/instances/templates/instance_actions.html @@ -1,5 +1,6 @@ {% load i18n %} {% load bootstrap_icons %} +{% if instance.info %}
{% if instance.info.0 == 5 %} {% if instance.is_template %} @@ -73,4 +74,5 @@ window.open(url, '', 'width=850,height=650') } -{% endblock script %} \ No newline at end of file +{% endblock script %} +{% endif %} diff --git a/instances/test_list_unreachable.py b/instances/test_list_unreachable.py new file mode 100644 index 00000000..59ac6be6 --- /dev/null +++ b/instances/test_list_unreachable.py @@ -0,0 +1,107 @@ +"""A VM that cannot be read shows as unknown in the lists instead of turning +the whole page into an error page (host down, failed sync, VM gone).""" + +from types import SimpleNamespace +from unittest.mock import PropertyMock, patch + +from appsettings.models import AppSettings +from computes.models import Compute +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from instances.models import Instance +from vrtManager.util import OperationError + +ERROR = "Connection Failed: No route to host" +UP_INFO = [1, 4194304, 2097152, 3, 0] + + +class Domain: + def info(self): + return UP_INFO + + +def live(*args, **kwargs): + return SimpleNamespace(instance=Domain(), get_title=lambda: "", get_uuid=lambda: "uuid") + + +def unreachable(*args, **kwargs): + raise OperationError(ERROR) + + +class UnreachableListTests(TestCase): + def setUp(self): + self.client.force_login( + get_user_model().objects.create_superuser(username="down-admin", password="pwd", email="down@example.com") + ) + self.compute = Compute.objects.create(name="down-compute", hostname="192.0.2.1", type=1, login="u", password="p") + self.vm_a = Instance.objects.create(compute=self.compute, name="vm-a", uuid="11111111-1111-1111-1111-111111111111") + self.vm_b = Instance.objects.create(compute=self.compute, name="vm-b", uuid="22222222-2222-2222-2222-222222222222") + + def get(self, url, wvm_instance, up=False): + def no_live_host_data(_self): + raise AssertionError("host data read for a host that is down") + + props = {"status": up, "connection_error": None if up else ERROR} + patches = [patch.object(Compute, k, new_callable=PropertyMock, return_value=v) for k, v in props.items()] + for name in ("cpu_count", "ram_size", "ram_usage", "cpu_usage"): + if up: + patches.append(patch.object(Compute, name, new_callable=PropertyMock, return_value=1)) + else: + patches.append(patch.object(Compute, name, property(no_live_host_data))) + patches += [ + patch("instances.views.utils.refr"), + patch("computes.views.utils.refresh_instance_database"), + patch("instances.models.wvmInstance", side_effect=wvm_instance), + ] + for p in patches: + p.start() + try: + response = self.client.get(url) + finally: + for p in reversed(patches): + p.stop() + self.assertEqual(response.status_code, 200) + return response + + def assert_unknown(self, response, instances): + for vm in instances: + self.assertContains(response, vm.name) + self.assertNotContains(response, reverse("instances:poweron", args=[vm.id])) + self.assertNotContains(response, reverse("instances:poweroff", args=[vm.id])) + self.assertContains(response, "Unknown", count=len(instances)) + + def test_nongrouped_index_lists_the_vms_of_a_down_host(self): + AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped") + response = self.get(reverse("instances:index"), unreachable) + self.assert_unknown(response, [self.vm_a, self.vm_b]) + self.assertContains(response, ERROR) + + def test_grouped_index_lists_the_vms_of_a_down_host(self): + AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="grouped") + response = self.get(reverse("instances:index"), unreachable) + self.assert_unknown(response, [self.vm_a, self.vm_b]) + self.assertContains(response, "Not Connected") + + def test_one_unreadable_vm_does_not_hide_the_others(self): + AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped") + + def vm_b_gone(*args, uuid=None, **kwargs): + if uuid == self.vm_b.uuid: + raise OperationError("Domain not found") + return live() + + response = self.get(reverse("instances:index"), vm_b_gone, up=True) + self.assert_unknown(response, [self.vm_b]) + self.assertContains(response, reverse("instances:poweroff", args=[self.vm_a.id])) + + def test_compute_instances_page_of_a_down_host(self): + response = self.get(reverse("instances", args=[self.compute.id]), unreachable) + self.assert_unknown(response, [self.vm_a, self.vm_b]) + self.assertContains(response, ERROR) + + def test_compute_instances_page_does_not_claim_a_down_host_is_empty(self): + Instance.objects.all().delete() + response = self.get(reverse("instances", args=[self.compute.id]), unreachable) + self.assertNotContains(response, "have any Instances") + self.assertContains(response, ERROR)