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 @@
{{ 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' %}
|
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 %}
|
@@ -28,11 +28,16 @@
|
|
- {% trans "Connected" %}
+ {% if compute.status is True %}
+ {% trans "Connected" %}
+ {% else %}
+ {% trans "Not Connected" %}
+ {% endif %}
|
{% if app_settings.VM_DRBD_STATUS == 'True' %}
|
{% endif %}
+ {% if compute.status is True %}
{{ compute.cpu_count }} |
{{ compute.ram_size|filesizeformat }} |
@@ -42,6 +47,11 @@
|
+ {% else %}
+ |
+ |
+ |
+ {% endif %}
{% for instance in compute.instance_set.all %}
@@ -49,7 +59,7 @@
{{ 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 %}
|
{% if app_settings.VM_DRBD_STATUS == 'True' %}
@@ -74,7 +85,7 @@
|
{% endif %}
{{ 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/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 %}
{{ instance.name }}
- {{ instance.title }}
+ {% if instance.info %}{{ instance.title }} {% endif %}
|
{% if request.user.is_superuser %}
@@ -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 %}
|
{% if app_settings.VM_DRBD_STATUS == 'True' %}
@@ -53,12 +53,11 @@
|
{% endif %}
{{ 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' %}
|
- {% endif %}
{% endfor %}
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)