diff --git a/computes/views.py b/computes/views.py index 2e175035..69d759c3 100644 --- a/computes/views.py +++ b/computes/views.py @@ -9,6 +9,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from instances.models import Instance +from instances.utils import read_list_rows from libvirt import libvirtError from logs.audit import audit, changed_fields, snapshot from operations import claims @@ -134,7 +135,8 @@ def instances(request, compute_id): compute = get_object_or_404(Compute, pk=compute_id) utils.refresh_instance_database(compute) - instances = Instance.objects.filter(compute=compute).order_by("name").prefetch_related("userinstance_set") + instances = list(Instance.objects.filter(compute=compute).order_by("name").prefetch_related("userinstance_set")) + read_list_rows(instances) return render( request, "computes/instances.html", diff --git a/instances/templates/allinstances.html b/instances/templates/allinstances.html index ae62ccb2..2e83df3d 100644 --- a/instances/templates/allinstances.html +++ b/instances/templates/allinstances.html @@ -39,7 +39,7 @@ {% endif %}
- {% if instances.exists %} + {% if instances %} {# the state filter: table_filter.js counts each state's VMs the search finds #}
{% endif %} - {% if not instances.exists %} + {% if not instances %}

{% trans "There are no VMs here yet." %}

{% elif app_settings.VIEW_INSTANCES_LIST_STYLE == 'grouped' and request.user.is_superuser %} {% include 'allinstances_index_grouped.html' %} diff --git a/instances/tests/test_list_rpc.py b/instances/tests/test_list_rpc.py index ac8c5b0e..88aa2df4 100644 --- a/instances/tests/test_list_rpc.py +++ b/instances/tests/test_list_rpc.py @@ -1,8 +1,11 @@ -"""Instance lists ask libvirt for a VM's info once per row.""" +"""Instance lists read their VMs once a host (get_domain_rows): the states in +one call and each VM's live XML once, no lookup or info call a VM; on +libvirt's test driver.""" -from types import SimpleNamespace +from collections import Counter from unittest.mock import PropertyMock, patch +import libvirt from appsettings.models import AppSettings from computes.models import Compute from django.contrib.auth import get_user_model @@ -10,78 +13,71 @@ from django.test import TestCase from django.urls import reverse from instances.models import Instance -# virDomainGetInfo: state, max memory KiB, memory KiB, vCPUs, CPU time -INFO = [1, 4194304, 2097152, 3, 0] +DOMAIN = """{name}{uuid}{name} title +409620484 +hvm""" +VMS = {"vm-a": "11111111-1111-1111-1111-111111111111", "vm-b": "22222222-2222-2222-2222-222222222222"} -class FakeDomain: - # a plain class: Django templates do not call MagicMock attributes - def __init__(self): - self.info_calls = 0 - self.xml_calls = 0 - - def info(self): - self.info_calls += 1 - return INFO - - def XMLDesc(self, flags=0): - self.xml_calls += 1 - return "2097152" - - -class ListInfoCallTests(TestCase): +class ListReadTests(TestCase): def setUp(self): self.client.force_login( get_user_model().objects.create_superuser(username="rpc-admin", password="pwd", email="rpc@example.com") ) + self.conn = libvirt.open("test:///default") + self.addCleanup(self.conn.close) self.compute = Compute.objects.create(name="rpc-compute", hostname="localhost", type=4, login="", password="") - Instance.objects.create(compute=self.compute, name="vm-a", uuid="11111111-1111-1111-1111-111111111111") - Instance.objects.create(compute=self.compute, name="vm-b", uuid="22222222-2222-2222-2222-222222222222") - self.domains = [] - - def proxy(self, *args, **kwargs): - domain = FakeDomain() - self.domains.append(domain) - return SimpleNamespace( - instance=domain, - get_uuid=lambda: "uuid", - get_title=lambda: domain.XMLDesc() and "", - get_cur_memory=lambda: int(domain.XMLDesc().split(">")[4].split("<")[0]) // 1024, - ) + for name, uuid in VMS.items(): + domain = self.conn.defineXML(DOMAIN.format(name=name, uuid=uuid)) + domain.create() + self.addCleanup(domain.undefine) + self.addCleanup(domain.destroy) + Instance.objects.create(compute=self.compute, name=name, uuid=uuid) def get(self, url): - compute_patches = {"status": True, "cpu_count": 1, "ram_size": 1024, "ram_usage": 0} - with patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), patch( - "instances.models.wvmInstance", side_effect=self.proxy - ): - patches = [patch.object(Compute, k, new_callable=PropertyMock, return_value=v) for k, v in compute_patches.items()] - for p in patches: - p.start() - try: - response = self.client.get(url) - finally: - for p in patches: - p.stop() - self.assertEqual(response.status_code, 200) - return response + calls = Counter() - def assert_one_info_per_vm(self, response): - self.assertEqual(len(self.domains), 2, "one proxy per VM row") - for domain in self.domains: - self.assertEqual(domain.info_calls, 1) - self.assertEqual(domain.xml_calls, 1, "only the title reads the XML") - self.assertContains(response, "2048 MiB", count=2) + def counted(cls, name): + real = getattr(cls, name) + + def call(*args, **kwargs): + calls[name] += 1 + return real(*args, **kwargs) + + return patch.object(cls, name, call) + + patches = [patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), + patch("vrtManager.connection.connection_manager.get_connection", return_value=self.conn), + counted(libvirt.virDomain, "info"), counted(libvirt.virDomain, "XMLDesc"), + counted(libvirt.virConnect, "lookupByUUIDString")] + # the grouped list's host line; the test driver has no memory stats + patches += [patch.object(Compute, k, new_callable=PropertyMock, return_value=v) + for k, v in {"cpu_count": 1, "ram_size": 1024, "ram_usage": 0}.items()] + 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, calls + + def assert_read_once_a_host(self, url): + response, calls = self.get(url) + # every domain of the host once (the test driver's own, and other tests', too) + self.assertEqual(calls, {"XMLDesc": len(self.conn.listAllDomains())}) + self.assertContains(response, "2048 MiB", count=len(VMS)) self.assertContains(response, "Active") + self.assertContains(response, "vm-a title") def test_nongrouped_index(self): AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped") - self.assert_one_info_per_vm(self.get(reverse("instances:index"))) + self.assert_read_once_a_host(reverse("instances:index")) def test_grouped_index(self): AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="grouped") - self.assert_one_info_per_vm(self.get(reverse("instances:index"))) + self.assert_read_once_a_host(reverse("instances:index")) def test_compute_instances(self): - # this page shows the maximum memory, as before - # the VM's current memory, as on the Instances page, not its maximum - self.assert_one_info_per_vm(self.get(reverse("instances", args=[self.compute.id]))) + self.assert_read_once_a_host(reverse("instances", args=[self.compute.id])) diff --git a/instances/tests/test_list_unreachable.py b/instances/tests/test_list_unreachable.py index f0366818..6d831ac2 100644 --- a/instances/tests/test_list_unreachable.py +++ b/instances/tests/test_list_unreachable.py @@ -17,19 +17,6 @@ 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( @@ -39,7 +26,15 @@ class UnreachableListTests(TestCase): 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 get(self, url, rows=None): + """The page with the host down, or up with these VM rows (get_domain_rows), + or up with the error its listing raises.""" + up = rows is not None + + def domain_rows(): + if isinstance(rows, Exception): + raise rows + return rows def no_live_host_data(_self): raise AssertionError("host data read for a host that is down") @@ -53,7 +48,8 @@ class UnreachableListTests(TestCase): patches += [ patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), - patch("instances.models.wvmInstance", side_effect=wvm_instance), + patch.object(Compute, "proxy", new_callable=PropertyMock, + return_value=SimpleNamespace(get_domain_rows=domain_rows)), ] for p in patches: p.start() @@ -75,35 +71,36 @@ class UnreachableListTests(TestCase): 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) + response = self.get(reverse("instances:index")) 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) + response = self.get(reverse("instances:index")) 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) + # vm-b is gone from its host + response = self.get(reverse("instances:index"), {self.vm_a.uuid: (UP_INFO, "")}) self.assert_unknown(response, [self.vm_b]) self.assertContains(response, reverse("instances:poweroff", args=[self.vm_a.id])) + def test_a_host_whose_listing_fails_shows_its_vms_unknown(self): + AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped") + response = self.get(reverse("instances:index"), OperationError(ERROR)) + self.assert_unknown(response, [self.vm_a, self.vm_b]) + def test_compute_instances_page_of_a_down_host(self): - response = self.get(reverse("instances", args=[self.compute.id]), unreachable) + response = self.get(reverse("instances", args=[self.compute.id])) 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) + response = self.get(reverse("instances", args=[self.compute.id])) self.assertNotContains(response, "have any Instances") self.assertContains(response, ERROR) diff --git a/instances/tests/test_states.py b/instances/tests/test_states.py index 2164fba8..41781530 100644 --- a/instances/tests/test_states.py +++ b/instances/tests/test_states.py @@ -54,16 +54,13 @@ class StateTests(TestCase): AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped") def get(self, user, url): - by_uuid = {vm.uuid: state for state, vm in self.vms.items()} - - def wvm(*args, uuid=None, **kwargs): - return SimpleNamespace(instance=Domain(by_uuid[uuid]), get_title=lambda: "", get_uuid=lambda: uuid) + rows = {vm.uuid: (Domain(state).info(), "") for state, vm in self.vms.items()} self.client.force_login(user) - props = {"status": True, "connection_error": None, "cpu_count": 1, "ram_size": 1, "ram_usage": 0} + props = {"status": True, "connection_error": None, "cpu_count": 1, "ram_size": 1, "ram_usage": 0, + "proxy": SimpleNamespace(get_domain_rows=lambda: rows)} patches = [patch.object(Compute, k, new_callable=PropertyMock, return_value=v) for k, v in props.items()] - patches += [patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), - patch("instances.models.wvmInstance", side_effect=wvm)] + patches += [patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database")] for p in patches: p.start() try: diff --git a/instances/tests/test_vm_states.py b/instances/tests/test_vm_states.py index 6f54e35f..41771b24 100644 --- a/instances/tests/test_vm_states.py +++ b/instances/tests/test_vm_states.py @@ -79,9 +79,9 @@ class VmStatesTestCase(TestCase): def test_the_lists_poll_it(self): self.client.force_login(self.admin) with patch.object(Compute, "status", property(lambda c: c.pk == self.up.pk)), \ - patch.object(Compute, "proxy", new_callable=PropertyMock, return_value=MagicMock()), \ - patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), \ - patch.object(Instance, "proxy", new_callable=PropertyMock, return_value=MagicMock()): + patch.object(Compute, "proxy", new_callable=PropertyMock, + return_value=MagicMock(**{"get_domain_rows.return_value": {}})), \ + patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"): for url, states_url in ((reverse("instances:index"), reverse("instances:states")), (reverse("instances", args=[self.up.pk]), reverse("instances:states") + f"?compute={self.up.pk}")): diff --git a/instances/utils.py b/instances/utils.py index ee1ce19a..2ce3f5af 100644 --- a/instances/utils.py +++ b/instances/utils.py @@ -352,6 +352,20 @@ def record_migration(instance, new_compute): instance.save() +def read_list_rows(instances): + """Each VM's info and title for a VM list, read once a host + (get_domain_rows), not three calls a VM; a VM whose host or whose + domain cannot be read has no info: the list shows it unknown.""" + rows = {} + for compute in {inst.compute_id: inst.compute for inst in instances}.values(): + try: + rows[compute.pk] = compute.proxy.get_domain_rows() if compute.status is True else {} + except libvirtError: + rows[compute.pk] = {} + for inst in instances: + inst.info, inst.title = rows[inst.compute_id].get(inst.uuid, (None, "")) + + def vm_states(user, computes): """{compute pk: {"ok": True, "vms": {uuid: [name, state]}}, or {"ok": False} for a host that cannot be read}: what the VM lists poll, read from libvirt; diff --git a/instances/views.py b/instances/views.py index ddd9efed..a039af04 100755 --- a/instances/views.py +++ b/instances/views.py @@ -81,22 +81,25 @@ def index(request): computes = list(Compute.objects.all().order_by("name")) for compute in computes: utils.refr(compute) - # each host's VMs by name: the database keeps no order of its own - prefetch_related_objects(computes, Prefetch("instance_set", queryset=Instance.objects.order_by("name")), - "instance_set__userinstance_set__user") show_hosts = request.user.is_superuser or request.user.has_perm("instances.view_instances") own_hosts_down = False - if show_hosts: - instances = Instance.objects.order_by("name").prefetch_related("userinstance_set__user") + if request.user.is_superuser and app_settings.VIEW_INSTANCES_LIST_STYLE == "grouped": + # each host's VMs by name: the database keeps no order of its own + prefetch_related_objects(computes, Prefetch("instance_set", queryset=Instance.objects.order_by("name")), + "instance_set__userinstance_set__user") + instances = [inst for compute in computes for inst in compute.instance_set.all()] + elif show_hosts: + instances = list(Instance.objects.order_by("name").prefetch_related("userinstance_set__user")) else: - instances = Instance.objects.filter( + instances = list(Instance.objects.filter( userinstance__user=request.user - ).order_by("name").prefetch_related("userinstance_set__user") + ).order_by("name").prefetch_related("userinstance_set__user")) # other users get no host names or connection errors, only whether # a host of their own VMs is down - own = set(instances.values_list("compute_id", flat=True)) + own = {inst.compute_id for inst in instances} own_hosts_down = any(c.status is not True for c in computes if c.pk in own) + utils.read_list_rows(instances) return render( request, diff --git a/vrtManager/connection.py b/vrtManager/connection.py index 56ece172..746cb56e 100644 --- a/vrtManager/connection.py +++ b/vrtManager/connection.py @@ -1098,6 +1098,23 @@ class wvmConnect(object): return {dom.UUIDString(): [dom.name(), stats["state.state"]] for dom, stats in self.wvm.getAllDomainStats(libvirt.VIR_DOMAIN_STATS_STATE)} + def get_domain_rows(self): + """{uuid: (info, title)} of every domain, as the VM lists show them: + info in virDomainGetInfo's shape (state, max memory KiB, memory KiB, + vCPUs), the states in one call and the rest from each domain's live + XML, which the title needs anyway. A domain gone meanwhile is left out.""" + rows = {} + for dom, stats in self.wvm.getAllDomainStats(libvirt.VIR_DOMAIN_STATS_STATE): + try: + doc = etree.fromstring(dom.XMLDesc(0)) + except libvirtError: + continue + vcpu = doc.find("vcpu") + info = [stats["state.state"], int(doc.findtext("memory")), int(doc.findtext("currentMemory")), + int(vcpu.get("current", vcpu.text))] + rows[dom.UUIDString()] = (info, doc.findtext("title") or "") + return rows + def domain_trees(self, running_only=False): """Each domain's name with its definitions, parsed: the live one, and for a persistent domain the one saved for its next boot too, read