diff --git a/computes/models.py b/computes/models.py index 407f63ec..ec7c769b 100644 --- a/computes/models.py +++ b/computes/models.py @@ -91,18 +91,22 @@ class Compute(Model): @cached_property def capacity(self): """What the host has given its VMs (wvmHostDetails.get_capacity), its - vCPUs per CPU, and its active storage pools, the default one first; or - None when it cannot be read.""" + vCPUs per CPU, and its active storage pools, the default one first + (None when the pools cannot be read); or None when the host cannot be + read.""" if self.status is not True: return None try: given = self.proxy.get_capacity() - storages = wvmStorages(self.hostname, self.login, self.password, self.type) - pools = [pool for pool in storages.get_storages_info() if pool["status"]] except libvirtError: return None given["ratio"] = given["vcpus"]["all"] / given["cpus"] if given["cpus"] else 0 - given["pools"] = sorted(pools, key=lambda pool: (pool["name"] != "default", pool["name"])) + try: + storages = wvmStorages(self.hostname, self.login, self.password, self.type) + pools = [pool for pool in storages.get_storages_info() if pool["status"]] + given["pools"] = sorted(pools, key=lambda pool: (pool["name"] != "default", pool["name"])) + except libvirtError: + given["pools"] = None return given def __str__(self): diff --git a/computes/templates/computes/capacity_memory.html b/computes/templates/computes/capacity_memory.html new file mode 100644 index 00000000..ff436b5b --- /dev/null +++ b/computes/templates/computes/capacity_memory.html @@ -0,0 +1,10 @@ +{% load i18n %} +{# the memory a host has given its VMs: given, from views.shown_capacity #} +
{% blocktrans with all=given.assigned.all|filesizeformat memory=given.memory|filesizeformat %}{{ all }} given of {{ memory }}{% endblocktrans %}
+ +
{% blocktrans with running=given.assigned.running|filesizeformat %}{{ running }} of it running{% endblocktrans %}
diff --git a/computes/templates/computes/capacity_storage.html b/computes/templates/computes/capacity_storage.html new file mode 100644 index 00000000..569b5d7e --- /dev/null +++ b/computes/templates/computes/capacity_storage.html @@ -0,0 +1,16 @@ +{% load i18n %} +{# a host's storage pools: given, from views.shown_capacity #} +{% if given.pools is None %} +
{% trans "The storage pools could not be read." %}
+{% elif not given.pool %} +
{% trans "No active storage pool." %}
+{% endif %} +{% if given.pool %}{% include "computes/pool_bar.html" with pool=given.pool %}{% endif %} +{% if given.more_pools %} +
+ {% blocktrans count counter=given.more_pools|length %}{{ counter }} more pool{% plural %}{{ counter }} more pools{% endblocktrans %} + {% for pool in given.more_pools %} +
{% include "computes/pool_bar.html" %}
+ {% endfor %} +
+{% endif %} diff --git a/computes/templates/computes/capacity_vcpus.html b/computes/templates/computes/capacity_vcpus.html new file mode 100644 index 00000000..2730fdae --- /dev/null +++ b/computes/templates/computes/capacity_vcpus.html @@ -0,0 +1,5 @@ +{% load i18n %} +{# the vCPUs a host has given its VMs: given, what its list cell and its overview show #} +
{{ given.ratio|floatformat:1 }}:1
+
{% blocktrans count counter=given.vcpus.all %}{{ counter }} vCPU{% plural %}{{ counter }} vCPUs{% endblocktrans %} {% blocktrans count counter=given.cpus %}on {{ counter }} CPU{% plural %}on {{ counter }} CPUs{% endblocktrans %}
+
{% blocktrans with running=given.vcpus.running %}{{ running }} running{% endblocktrans %}
diff --git a/computes/templates/computes/list.html b/computes/templates/computes/list.html index 2bc49784..95d77942 100644 --- a/computes/templates/computes/list.html +++ b/computes/templates/computes/list.html @@ -35,7 +35,7 @@ {% for compute in computes %} - {% with given=compute.capacity %} + {% with given=compute.shown_capacity %}
@@ -55,41 +55,21 @@ {% if given %} -
{{ given.ratio|floatformat:1 }}:1
-
{% blocktrans count counter=given.vcpus.all %}{{ counter }} vCPU{% plural %}{{ counter }} vCPUs{% endblocktrans %} {% blocktrans count counter=given.cpus %}on {{ counter }} CPU{% plural %}on {{ counter }} CPUs{% endblocktrans %}
-
{% blocktrans with running=given.vcpus.running %}{{ running }} running{% endblocktrans %}
+ {% include "computes/capacity_vcpus.html" %} -
{% blocktrans with all=given.assigned.all|filesizeformat memory=given.memory|filesizeformat %}{{ all }} given of {{ memory }}{% endblocktrans %}
- -
{% blocktrans with running=given.assigned.running|filesizeformat %}{{ running }} of it running{% endblocktrans %}
+ {% include "computes/capacity_memory.html" %} - {% if given.pool %}{% include "computes/pool_bar.html" with pool=given.pool %}{% endif %} - {% if given.more_pools %} -
- {% blocktrans count counter=given.more_pools|length %}{{ counter }} more pool{% plural %}{{ counter }} more pools{% endblocktrans %} - {% for pool in given.more_pools %} -
{% include "computes/pool_bar.html" %}
- {% endfor %} -
- {% endif %} + {% include "computes/capacity_storage.html" %} {% else %} {% if compute.status is True %}{% trans "The capacity could not be read." %}{% else %}{% trans "No capacity while the host cannot be reached." %}{% endif %} {% endif %}
- {% if compute.status is True %} - {% bs_icon 'eye-fill' %} - {% else %} - {% bs_icon 'eye' %} - {% endif %} + {# a host the panel cannot reach opens too: its page says why #} + {% bs_icon 'eye-fill' %} {% bs_icon 'pencil-fill' %} {% bs_icon 'x-circle-fill' %}
diff --git a/computes/templates/overview.html b/computes/templates/overview.html index efc5a40e..fda5fcb3 100644 --- a/computes/templates/overview.html +++ b/computes/templates/overview.html @@ -5,97 +5,154 @@ {% block title %}{% trans "Overview" %} - {{ compute.name }}{% endblock %} +{% block page_heading_extra %} +{% bs_icon 'pencil' %} {% trans "Edit connection" %} +{% bs_icon 'x-circle' %} {% trans "Remove from panel" %} +{% endblock page_heading_extra %} + {% block page_heading %}{{ compute.name }}{% endblock page_heading %} {% block content %} -
- {% include "computes/host_nav.html" with section="overview" %} -
+

+ + {% if connected %} + {% trans "Connected" %} + {% else %} + {% trans "Not connected" %} + {% endif %} + + {{ compute.hostname }} + {% if compute.details %}{{ compute.details }}{% endif %} +

-
- -
-
{% trans "Hostname" %}
-
{{ hostname }}
-
{% trans "Hypervisors" %}
-
- - -
-
{% trans "Emulator" %}
-
{{ emulator }}
-
{% trans "Version" %}
-
- {% trans 'Qemu' %} - {{ version }}   - {% trans 'Libvirt' %} - {{ lib_version }}   -
-
{% trans "Memory" %}
-
{{ host_memory|filesizeformat }}
-
{% trans "Architecture" %}
-
{{ host_arch }}
-
{% trans "Logical CPUs" %}
-
{{ logical_cpu }}
-
{% trans "Processor" %}
-
{{ model_cpu }}
-
{% trans "Connection" %}
-
{{ uri_conn }}
-
{% trans "Details" %}
-
{{ compute.details }}
+{% include "computes/host_nav.html" with section="overview" %} + +{% if not connected %} + +{% else %} + +{% if read_error %} + +{% endif %} + + +{% if given %} +
+
+
+
{% trans "VMs" %}
+
{{ given.vm_count }}
+
+ {% blocktrans with running=given.vms.running shutoff=given.vms.shutoff %}{{ running }} running, {{ shutoff }} shut off{% endblocktrans %}{% if given.vms.paused %}, {% blocktrans with paused=given.vms.paused %}{{ paused }} paused{% endblocktrans %}{% endif %}{% if given.vms.other %}, {% blocktrans with other=given.vms.other %}{{ other }} other{% endblocktrans %}{% endif %} +
+ {% trans "Instances" %} → +
+
+
+
+
{% trans "vCPUs" %}
+ {% include "computes/capacity_vcpus.html" %} +
+
+
+
+
{% trans "Memory" %}
+ {% include "computes/capacity_memory.html" %} +
+
+
+
+
{% trans "Storage" %}
+ {% include "computes/capacity_storage.html" %} + {% trans "Storages" %} → +
+
+
+{% else %} +

{% trans "The capacity could not be read." %}

+{% endif %} + +{# the load is read on its own: shown also when the details could not be #} + +
+
+
+
{% trans "CPU in use" %} –
+
+
+
+
+
+
{% trans "Memory in use" %} –
+
+
+
+
+

+ {% trans "The last 10 readings, one every 5 seconds. Memory in use leaves out the host's buffers and cache." %} + {% trans "Waiting for the first reading." %} +

+ +{% if host %} +
+
+ +
+
{% trans "Hostname" %}
+
{{ host.hostname }}
+
{% trans "Architecture" %}
+
{{ host.arch }}
+
{% trans "Processor" %}
+
{{ host.processor|default:"–" }}
+
{% trans "Logical CPUs" %}
+
{{ host.topology.cpus }}
+
{% trans "Physical cores" %}
+
{{ host.topology.cores|default:"–" }}
+
{% trans "Sockets" %}
+
{{ host.topology.sockets|default:"–" }}
+
{% trans "NUMA nodes" %}
+
{{ host.topology.nodes|default:"–" }}
+
{% trans "Clock" %}
+
{{ host.topology.mhz }} MHz
+
{% trans "Memory" %}
+
{{ host.memory|filesizeformat }}
- - -
-
-
-
- {% bs_icon 'arrow-right' %} - {% trans "CPU Utilization" %} -
-
-
-
- -
-
-
- {% bs_icon 'arrow-right'%} {% trans "RAM Utilization" %} -
-
-
-
-
-
+
+
+ +
+
{% trans "Hypervisor" %}
+
{{ host.hypervisor }} {{ host.hypervisor_version }}
+
libvirt
+
{{ host.libvirt_version }}
+
{% trans "Emulator" %}
+
{{ host.emulator|default:"–" }}
+
{% trans "Guest architectures" %}
+
+ {% for arch, types in host.guest_archs.items %} +
{{ arch }} ({{ types|join:", " }})
+ {% endfor %} +
+
{% trans "Connection" %}
+
{{ host.uri }}
+
+
+
+{% endif %} +{% endif %} {% endblock %} + {% block script %} +{% if connected %} +{% endif %} {% endblock %} diff --git a/computes/tests/test_capacity.py b/computes/tests/test_capacity.py index 785a7ad3..d7cb6107 100644 --- a/computes/tests/test_capacity.py +++ b/computes/tests/test_capacity.py @@ -11,6 +11,7 @@ from libvirt import libvirtError from lxml import html GIB = 1024 ** 3 +VMS = {"running": 3, "paused": 1, "shutoff": 2, "other": 0} def pool(name, size_gib, free_gib, status=1): @@ -33,6 +34,14 @@ class CapacityTests(TestCase): "cpus": 16, "vcpus": {"all": 24, "running": 8}, "ratio": 1.5, "pools": [pool("default", 500, 120), pool("iso", 100, 90), pool("nfs", 900, 800)]}) + @patch("computes.models.wvmStorages") + @patch("computes.models.wvmHostDetails") + def test_pools_that_cannot_be_read_leave_the_cpus_and_memory(self, details, storages): + details.return_value.get_capacity.return_value = {"cpus": 16, "vcpus": {"all": 24, "running": 8}} + storages.return_value.get_storages_info.side_effect = libvirtError("cannot refresh pool") + self.assertEqual(self.compute.capacity, { + "cpus": 16, "vcpus": {"all": 24, "running": 8}, "ratio": 1.5, "pools": None}) + @patch("computes.models.wvmHostDetails") def test_a_host_whose_read_fails_has_no_capacity(self, details): # a VM that goes while the host is read, for one @@ -53,7 +62,7 @@ class ComputesPageTests(TestCase): c1 = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1) Compute.objects.create(name="host-two", hostname="192.0.2.2", login="u", password="p", type=1) found = {c1.id: {"cpus": 16, "memory": 64 * GIB, "vcpus": {"all": 40, "running": 24}, - "assigned": {"all": 48 * GIB, "running": 32 * GIB}, "ratio": 2.5, + "assigned": {"all": 48 * GIB, "running": 32 * GIB}, "ratio": 2.5, "vms": VMS, "pools": [pool("default", 500, 120)]}} with patch.object(Compute, "capacity", new=property(lambda c: found.get(c.id))), \ patch.object(Compute, "status", new=True): @@ -77,7 +86,7 @@ class ComputesPageTests(TestCase): Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1) pools = [pool("default", 500, 120), pool("backup", 900, 800), pool("iso", 100, 90), pool("nfs", 2000, 1000)] given = {"cpus": 16, "memory": 64 * GIB, "vcpus": {"all": 8, "running": 8}, "ratio": 0.5, - "assigned": {"all": 8 * GIB, "running": 8 * GIB}, "pools": pools} + "assigned": {"all": 8 * GIB, "running": 8 * GIB}, "pools": pools, "vms": VMS} with patch.object(Compute, "capacity", new=given), patch.object(Compute, "status", new=True): page = html.fromstring(self.client.get(reverse("computes")).content) (storage,) = page.xpath("//tbody/tr/td[4]") @@ -95,7 +104,7 @@ class SingularTests(TestCase): self.client.force_login(admin) c = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1) given = {"cpus": 1, "memory": 4 * GIB, "vcpus": {"all": 1, "running": 1}, - "assigned": {"all": GIB, "running": GIB}, "ratio": 1.0, "pools": []} + "assigned": {"all": GIB, "running": GIB}, "ratio": 1.0, "pools": [], "vms": VMS} with patch.object(Compute, "capacity", new=given), patch.object(Compute, "status", new=True): page = html.fromstring(self.client.get(reverse("computes")).content) line = self.client.get(reverse("compute_capacity", args=[c.id])).content.decode() diff --git a/computes/tests/test_cpu_graph.py b/computes/tests/test_cpu_graph.py index bbb509f1..30ed4d57 100644 --- a/computes/tests/test_cpu_graph.py +++ b/computes/tests/test_cpu_graph.py @@ -24,6 +24,7 @@ from vrtManager.util import ConnectionFailed # the page script runs after the shared chart helpers CHARTS_JS = settings.BASE_DIR / "static" / "js" / "charts.js" +TOPOLOGY = {"nodes": 1, "sockets": 1, "cores": 2, "threads": 1, "mhz": 2400, "cpus": 2} def host(*readings): @@ -138,17 +139,20 @@ class GraphScriptTestCase(TestCase): compute = Compute.objects.create(name="gs", hostname="192.0.2.1", login="u", password="p", type=1) with patch("computes.views.wvmHostDetails") as details, patch.object( Compute, "status", new_callable=PropertyMock, return_value=True - ): + ), patch.object(Compute, "capacity", new=None): details.return_value.get_node_info.return_value = ("h", "x86_64", 1024, 2, "cpu", "qemu+tcp://h/system") - details.return_value.get_memory_usage.return_value = {"total": 1024, "usage": 512, "percent": 50} + details.return_value.get_cpu_topology.return_value = TOPOLOGY page = self.client.get(reverse("overview", args=[compute.id])).content.decode() script = next(s for s in html.fromstring(page).xpath("//script[not(@src)]/text()") if "pollWhileVisible(" in s) js = """ const vm = require("vm"); const charts = [], requests = []; + const element = (dataset) => ({dataset, textContent: "", classList: {add: () => {}, remove: () => {}}}); + const elements = {graph_state: element({updated: "Updated at %(time)s.", stale: "Not updated since %(time)s."}), + cpu_now: element({}), mem_now: element({})}; let poll; const page = { - document: {body: {}, getElementById: () => ({})}, + document: {body: {}, getElementById: (id) => elements[id] || {}}, // Chart.js adds the lists a config leaves out getComputedStyle: () => ({getPropertyValue: () => ""}), Chart: Object.assign(function (canvas, config) { @@ -158,21 +162,23 @@ class GraphScriptTestCase(TestCase): this.update = () => {}; charts.push(this); }, {defaults: {}}), - $: {getJSON: (url, params) => { requests.push(params); return {}; }}, + $: {getJSON: (url, params) => { requests.push(params); return {fail() { return this; }}; }}, pollWhileVisible: (interval, request, onData) => { poll = {request, onData}; }, }; const context = vm.createContext(page); vm.runInContext(CHARTS, context); vm.runInContext(SCRIPT, context); const answer = (cpu, sample) => ({cpudata: cpu, window: 5.1, cpu_sample: sample, timeline: "t", - memdata: {total: 1048576, usage: 0}}); + memdata: {total: 2 * 1073741824, usage: 1073741824}}); poll.request(); poll.onData(answer(3, "token-1")); + const shown = [elements.cpu_now.textContent, elements.mem_now.textContent, elements.graph_state.textContent]; poll.request(); poll.onData({cpudata: null, memdata: {total: null, usage: null}, timeline: "t"}); poll.request(); const label = charts[0].options.plugins.tooltip.callbacks.label({dataset: {label: "Usage"}, formattedValue: "3", dataIndex: 0}); - console.log(JSON.stringify({requests, label, cpu: charts[0].data.datasets[0].data, + console.log(JSON.stringify({requests, label, shown, state: elements.graph_state.textContent, + cpu: charts[0].data.datasets[0].data, mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.y.max})); """.replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text())) out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30) @@ -181,5 +187,8 @@ class GraphScriptTestCase(TestCase): self.assertEqual(result["requests"], [{}, {"cpu_sample": "token-1"}, {}]) self.assertEqual(result["cpu"], [3, None]) # a host that could not be read leaves a gap and keeps the memory axis - self.assertEqual((result["mem"], result["memMax"]), ([0, None], 1)) + self.assertEqual((result["mem"], result["memMax"]), ([1, None], 2)) + # the readings in words, and since when they are not updated + self.assertEqual(result["shown"], ["3 %", "1 / 2 GB", "Updated at t."]) + self.assertEqual(result["state"], "Not updated since t.") self.assertTrue(result["label"].endswith(": 3 % (average over 5.1 s)"), result["label"]) diff --git a/computes/tests/test_overview.py b/computes/tests/test_overview.py new file mode 100644 index 00000000..e482efc8 --- /dev/null +++ b/computes/tests/test_overview.py @@ -0,0 +1,94 @@ +"""The host overview page: a host the panel reaches, one it cannot reach, +and one whose details cannot be read.""" + +from unittest.mock import PropertyMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from libvirt import libvirtError +from lxml import html + +from computes.models import Compute + +GIB = 1024 ** 3 +GIVEN = {"cpus": 8, "memory": 32 * GIB, "vcpus": {"all": 12, "running": 6}, + "assigned": {"all": 24 * GIB, "running": 12 * GIB}, "ratio": 1.5, + "vms": {"running": 3, "paused": 1, "shutoff": 2, "other": 0}, + "pools": [{"name": "default", "status": 1, "size": 500 * GIB, "free": 120 * GIB}]} + + +def text(page, xpath): + return " ".join(" ".join(page.xpath(xpath)[0].itertext()).split()) + + +class OverviewTestCase(TestCase): + def setUp(self): + self.client.force_login(get_user_model().objects.create_superuser("ov-admin", "ov@example.com", "pw")) + self.compute = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", + type=1, details="rack 4") + self.url = reverse("overview", args=[self.compute.id]) + + def page(self, connected=True, given=GIVEN): + with patch.object(Compute, "status", new_callable=PropertyMock, return_value=connected), \ + patch.object(Compute, "connection_error", new_callable=PropertyMock, + return_value="Connection Failed: 192.0.2.1 did not answer within 5 s"), \ + patch.object(Compute, "capacity", new=given and {**given}): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + return html.fromstring(response.content) + + @patch("computes.views.wvmHostDetails") + def test_a_host_shows_its_vms_capacity_hardware_and_software(self, details): + conn = details.return_value + conn.get_node_info.return_value = ("wvc-host", "x86_64", 32 * GIB, 8, "Xeon", "qemu+tcp://192.0.2.1/system") + conn.get_cpu_topology.return_value = {"nodes": 2, "sockets": 2, "cores": 4, "mhz": 2400, "cpus": 8} + conn.wvm.getType.return_value = "QEMU" + conn.get_version.return_value = "10.1.0" + conn.get_lib_version.return_value = "11.10.0" + conn.get_emulator.return_value = "/usr/libexec/qemu-kvm" + conn.get_hypervisors_domain_types.return_value = {"i686": ["kvm", "qemu"], "x86_64": ["kvm", "qemu"]} + page = self.page() + self.assertIn("Connected", text(page, "//p[span]")) + self.assertIn("rack 4", text(page, "//p[span]")) + cards = [" ".join(" ".join(card.itertext()).split()) for card in page.xpath("//div[@class='card-body' or contains(@class,'card-body ')]")] + self.assertIn("VMs 6 3 running, 2 shut off, 1 paused Instances →", cards) + self.assertTrue(any(card.startswith("vCPUs 1.5:1 12 vCPUs on 8 CPUs 6 running") for card in cards), cards) + self.assertTrue(any("24.0 GB given of 32.0 GB" in card for card in cards), cards) + self.assertTrue(any("120.0 GB free of 500.0 GB" in card for card in cards), cards) + hardware = text(page, "//h4[.='Hardware']/following-sibling::dl") + self.assertIn("Logical CPUs 8 Physical cores 4 Sockets 2", hardware) + self.assertIn("NUMA nodes 2", hardware) + software = text(page, "//h4[.='Software and connection']/following-sibling::dl") + self.assertIn("Hypervisor QEMU 10.1.0", software) + self.assertIn("x86_64 (kvm, qemu)", software) + # the charts poll only for a host that was read + self.assertTrue(page.xpath("//canvas[@id='cpuChart']")) + + @patch("computes.views.wvmHostDetails") + def test_a_host_the_panel_cannot_reach_keeps_its_page_with_the_reason(self, details): + page = self.page(connected=False, given=None) + details.assert_not_called() + self.assertIn("Not connected", text(page, "//p[span]")) + self.assertIn("did not answer within 5 s", text(page, "//div[@role='alert']")) + # the way back: its sections, its connection and its removal + self.assertTrue(page.xpath("//nav[@aria-label='Host sections']")) + self.assertTrue(page.xpath(f"//a[@href='{reverse('compute_update', args=[self.compute.id])}']")) + self.assertTrue(page.xpath(f"//a[@href='{reverse('compute_delete', args=[self.compute.id])}']")) + self.assertFalse(page.xpath("//canvas")) + + @patch("computes.views.wvmHostDetails") + def test_details_that_cannot_be_read_leave_the_capacity_and_the_load(self, details): + details.return_value.get_node_info.return_value = ("h", "x86_64", GIB, 2, None, "qemu+tcp://192.0.2.1/system") + details.return_value.get_lib_version.side_effect = libvirtError("cannot read the version") + page = self.page() + self.assertIn("cannot read the version", text(page, "//div[@role='alert']")) + self.assertTrue(page.xpath("//h5[.='VMs']")) + # the load is read on its own, by the charts + self.assertTrue(page.xpath("//canvas[@id='cpuChart']")) + self.assertFalse(page.xpath("//h4[.='Hardware']")) + + def test_the_list_links_the_overview_of_a_host_it_cannot_reach(self): + with patch.object(Compute, "status", new_callable=PropertyMock, return_value=False): + page = html.fromstring(self.client.get(reverse("computes")).content) + self.assertTrue(page.xpath(f"//a[@title='Overview'][@href='{self.url}']")) diff --git a/computes/tests/test_overview_status.py b/computes/tests/test_overview_status.py index 54074c20..d181cf4e 100644 --- a/computes/tests/test_overview_status.py +++ b/computes/tests/test_overview_status.py @@ -17,17 +17,6 @@ class OverviewStatusTests(TestCase): # an ssh_config alias: not a name a TCP probe can reach self.compute = Compute.objects.create(name="ov-alias", hostname="wvc-alias", login="root", password="", type=2) - @patch("vrtManager.connection.connection_manager.host_is_up", return_value=False) - @patch("computes.views.wvmHostDetails") - def test_overview_state_comes_from_the_connection(self, host_details, host_is_up): - conn = host_details.return_value - conn.get_node_info.return_value = ("host", "x86_64", 1024, 2, "cpu", "qemu+ssh://root@wvc-alias/system") - conn.get_memory_usage.return_value = {"total": 1024, "usage": 512, "percent": 50} - with patch.object(Compute, "status", new_callable=PropertyMock, return_value=True): - response = self.client.get(reverse("overview", args=[self.compute.id])) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["status"], "true") - def test_compute_list_shows_why_a_host_is_not_connected(self): with patch.object(Compute, "status", new_callable=PropertyMock, return_value=False), patch.object( Compute, "connection_error", new_callable=PropertyMock, return_value=ERROR diff --git a/computes/views.py b/computes/views.py index 62e246ef..a941875a 100644 --- a/computes/views.py +++ b/computes/views.py @@ -30,28 +30,36 @@ CPU_SAMPLE_MAX_AGE = 15 CPU_SAMPLE_SIGNER = signing.TimestampSigner(salt="computes.cpu-sample") +def shown_capacity(compute): + """The compute's capacity, with what its bars show; None when it cannot + be read.""" + given = compute.capacity + if given: + # the memory bar: running and shut-off VMs' memory, and a mark at the + # host's memory, on a scale that holds whichever is larger + assigned, memory = given["assigned"], given["memory"] + scale = max(assigned["all"], memory) or 1 + given["bar"] = {"running": assigned["running"] * 100 / scale, + "stopped": (assigned["all"] - assigned["running"]) * 100 / scale, + "host": memory * 100 / scale, "over": assigned["all"] > memory} + given["memory_percent"] = round(assigned["all"] * 100 / memory) if memory else 0 + given["vm_count"] = sum(given["vms"].values()) + # the default pool (else the first) shows; the others (a host may + # have dozens) open on a click + pools = given["pools"] or [] + for pool in pools: + pool["used_percent"] = (pool["size"] - pool["free"]) * 100 / pool["size"] if pool["size"] else 0 + given["pool"] = pools[0] if pools else None + given["more_pools"] = pools[1:] + return given + + @superuser_only def computes(request): """The computes, with what each has given its VMs.""" computes = list(Compute.objects.order_by("name")) for compute in computes: - given = compute.capacity - if given: - # the memory bar: running and shut-off VMs' memory, and a mark at the - # host's memory, on a scale that holds whichever is larger - assigned, memory = given["assigned"], given["memory"] - scale = max(assigned["all"], memory) or 1 - given["bar"] = {"running": assigned["running"] * 100 / scale, - "stopped": (assigned["all"] - assigned["running"]) * 100 / scale, - "host": memory * 100 / scale, "over": assigned["all"] > memory} - given["memory_percent"] = round(assigned["all"] * 100 / memory) if memory else 0 - for pool in given["pools"]: - pool["used_percent"] = (pool["size"] - pool["free"]) * 100 / pool["size"] if pool["size"] else 0 - # the default pool (else the first) shows; the others (a host may - # have dozens) open on a click - pools = given["pools"] - given["pool"] = pools[0] if pools else None - given["more_pools"] = pools[1:] + compute.shown_capacity = shown_capacity(compute) return render(request, "computes/list.html", {"computes": computes}) @@ -65,33 +73,29 @@ def capacity(request, compute_id): @superuser_only def overview(request, compute_id): + """The host's page: whether the panel reaches it, its VMs, what it has + given them, its load, hardware and software. A host it cannot reach + keeps the page, with the reason and its connection to edit.""" compute = get_object_or_404(Compute, pk=compute_id) # the connection itself, not a TCP probe: ssh aliases and ProxyJump hosts # are not reachable by name and port from here - status = "true" if compute.status else "false" - - conn = wvmHostDetails( - compute.hostname, - compute.login, - compute.password, - compute.type, - ) - ( - hostname, - host_arch, - host_memory, - logical_cpu, - model_cpu, - uri_conn, - ) = conn.get_node_info() - hypervisor = conn.get_hypervisors_domain_types() - mem_usage = conn.get_memory_usage() - emulator = conn.get_emulator(host_arch) - version = conn.get_version() - lib_version = conn.get_lib_version() - conn.close() - - return render(request, "overview.html", locals()) + context = {"compute": compute, "connected": compute.status is True} + if context["connected"]: + try: + conn = wvmHostDetails(compute.hostname, compute.login, compute.password, compute.type) + hostname, arch, memory, _cpus, processor, uri = conn.get_node_info() + context["host"] = { + "hostname": hostname, "arch": arch, "memory": memory, "processor": processor, "uri": uri, + "topology": conn.get_cpu_topology(), + "hypervisor": conn.wvm.getType(), "hypervisor_version": conn.get_version(), + "libvirt_version": conn.get_lib_version(), + "emulator": conn.get_emulator(arch), "guest_archs": conn.get_hypervisors_domain_types(), + } + conn.close() + except libvirtError as error: + context["read_error"] = str(error) + context["given"] = shown_capacity(compute) + return render(request, "overview.html", context) @superuser_only diff --git a/static/icon_cache/arrow-clockwise_None_None_None.svg b/static/icon_cache/arrow-clockwise_None_None_None.svg new file mode 100644 index 00000000..23dd7e18 --- /dev/null +++ b/static/icon_cache/arrow-clockwise_None_None_None.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/icon_cache/x-circle_None_None_None.svg b/static/icon_cache/x-circle_None_None_None.svg new file mode 100644 index 00000000..cb45faf9 --- /dev/null +++ b/static/icon_cache/x-circle_None_None_None.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/vrtManager/hostdetails.py b/vrtManager/hostdetails.py index 3de53687..08f30c65 100644 --- a/vrtManager/hostdetails.py +++ b/vrtManager/hostdetails.py @@ -1,11 +1,26 @@ import time -from libvirt import VIR_DOMAIN_SHUTOFF, VIR_NODE_CPU_STATS_ALL_CPUS +from libvirt import ( + libvirtError, + VIR_DOMAIN_BLOCKED, + VIR_DOMAIN_PAUSED, + VIR_DOMAIN_RUNNING, + VIR_DOMAIN_SHUTOFF, + VIR_NODE_CPU_STATS_ALL_CPUS, +) from vrtManager.connection import wvmConnect from vrtManager.util import get_xml_path +# a VM's state as the host's VM counts group it; others count as "other" +VM_STATES = { + VIR_DOMAIN_RUNNING: "running", + VIR_DOMAIN_BLOCKED: "running", # running, waiting on a resource + VIR_DOMAIN_PAUSED: "paused", + VIR_DOMAIN_SHUTOFF: "shutoff", +} + # the times of virNodeGetCPUStats that add up to the elapsed CPU time; guest is # already part of user and utilization is a percentage CPU_TIMES = ("kernel", "user", "idle", "iowait") @@ -78,23 +93,47 @@ class wvmHostDetails(wvmConnect): info.append(self.host_info[0]) # architecture info.append(self.host_info[1] * 1048576) # memory info.append(self.host_info[2]) # cpu core count - info.append( - get_xml_path(self.wvm.getSysinfo(0), func=cpu_version) - ) # cpu version + try: + info.append(get_xml_path(self.wvm.getSysinfo(0), func=cpu_version)) # cpu version + except libvirtError: # not every driver or host gives its sysinfo + info.append(None) info.append(self.wvm.getURI()) # uri return info def get_capacity(self): """ What the host has given its VMs: vCPUs and memory (bytes) of all VMs - and of the running ones, against its CPUs and memory. A shut-off VM - takes its share again when it starts. + and of the running ones (paused ones too: they keep theirs), against + its CPUs and memory. A shut-off VM takes its share again when it + starts. Also its VMs by state: running, paused, shut off and other + (crashed, suspended, shutting down). """ vcpus = {"all": 0, "running": 0} assigned = {"all": 0, "running": 0} + vms = {"running": 0, "paused": 0, "shutoff": 0, "other": 0} for vm in self.get_host_instances(raw_mem_size=True).values(): for share in ("all",) if vm["status"] == VIR_DOMAIN_SHUTOFF else ("all", "running"): vcpus[share] += int(vm["vcpu"]) assigned[share] += vm["memory"] + vms[VM_STATES.get(vm["status"], "other")] += 1 return {"cpus": self.host_info[2], "memory": self.host_info[1] * 1048576, - "vcpus": vcpus, "assigned": assigned} + "vcpus": vcpus, "assigned": assigned, "vms": vms} + + def get_cpu_topology(self): + """ + The host's logical CPUs and MHz, and from the CPUs of its NUMA cells + in its capabilities, the NUMA nodes, sockets and physical cores (None + when it does not say). Not virNodeGetInfo's topology: it makes one up + for hosts it cannot describe, such as asymmetric NUMA. + """ + + def topology(doc): + cpus = doc.xpath("/capabilities/host/topology/cells/cell/cpus/cpu[@socket_id]") + return { + "nodes": len(doc.xpath("/capabilities/host/topology/cells/cell")) or None, + "sockets": len({cpu.get("socket_id") for cpu in cpus}) or None, + # a core is one within its socket and die + "cores": len({(cpu.get("socket_id"), cpu.get("die_id"), cpu.get("core_id")) for cpu in cpus}) or None, + } + + return {**get_xml_path(self.get_cap_xml(), func=topology), "cpus": self.host_info[2], "mhz": self.host_info[3]} diff --git a/vrtManager/tests/test_host_reads.py b/vrtManager/tests/test_host_reads.py index 9ada59de..f544fb28 100644 --- a/vrtManager/tests/test_host_reads.py +++ b/vrtManager/tests/test_host_reads.py @@ -158,10 +158,35 @@ class CapacityTestCase(unittest.TestCase): "cpus": 4, "memory": 2 * gib, "vcpus": {"all": 14, "running": 6}, "assigned": {"all": 14 * gib, "running": 6 * gib}, + "vms": {"running": 1, "paused": 1, "shutoff": 1, "other": 0}, }) conn.get_host_instances.assert_called_once_with(raw_mem_size=True) +class CpuTopologyTestCase(unittest.TestCase): + """The topology comes from the capabilities' NUMA cells: getInfo makes + one up (one node, one socket, every CPU a core) for hosts it cannot + describe.""" + + def topology(self, cells): + conn = connection(wvmHostDetails) + conn.__dict__["host_info"] = ["x86_64", 65536, 16, 2400, 1, 1, 16, 1] # getInfo's made-up one + conn.get_cap_xml = lambda: f"{cells}" + return conn.get_cpu_topology() + + def test_sockets_and_cores_are_counted_from_the_cpus_of_the_numa_cells(self): + # two nodes, two sockets, four cores of two threads each + cells = "".join( + f"" + + "".join(f"" for i in range(4)) + + "" for node in range(2)) + self.assertEqual(self.topology(cells), {"nodes": 2, "sockets": 2, "cores": 4, "cpus": 16, "mhz": 2400}) + + def test_a_host_that_does_not_say_has_no_sockets_or_cores(self): + cells = "" + self.assertEqual(self.topology(cells), {"nodes": 1, "sockets": None, "cores": None, "cpus": 16, "mhz": 2400}) + + class StoragesInfoTestCase(unittest.TestCase): def test_each_pool_tells_its_free_space(self): conn = connection(wvmStorages)