diff --git a/README.md b/README.md index b8c1a8a6..86eef6c2 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,7 @@ A version from before v3.0.0 has no worker: `systemctl disable --now webvirt-wor - A running or paused VM can be cloned: libvirt copies its disks from one instant while it keeps running (file systems are frozen for a moment when the guest agent is ready). It needs libvirt 11.10 or later on the host, local file disks and no TPM; the VM cannot be changed from the panel until the copy ends, and the copy can be cancelled. Cancelling a clone of a shut-off VM has no effect: it shows as cancelling until the copy ends. A target pool that fills up during the copy can pause the source VM; a clone that ends uncertain keeps the VM locked until a superuser resolves the job. - A shut-off UEFI VM can be migrated: its NVRAM file is copied to the destination host (unless both hosts share the NVRAM directory). A shut-off VM with TPM state still has to be started and migrated live. - Manual installs that keep their own `settings.py` copy the SQLite `OPTIONS` and `TEST` entries from `settings.py.template` and add `"operations"` to `INSTALLED_APPS`. +- A host's Devices page lists its PCI devices by IOMMU group and its USB devices, with what the host itself has on each (network interfaces, disks, its graphics card) and the VMs that name it. Manual installs that keep their own `settings.py` add `"nodedevs"` to `INSTALLED_APPS`. - The SQLite database must be on a local file system (WAL). - Outside compose, stop the Docker container with `docker stop -t 95` so the worker can finish running jobs. - Installer and manual installations run the panel as systemd units (`webvirt-cloud`, `webvirt-novncd`, `webvirt-socketiod`, `webvirt-worker`) and computes run `webvirt-gstfsd`, instead of supervisor; see [Moving from supervisor to systemd](#moving-from-supervisor-to-systemd). Service settings go into `/etc/default/webvirtcloud`, service output into the journal (`journalctl -u webvirt-cloud`); the app log stays in `data/`. diff --git a/computes/templates/computes/host_nav.html b/computes/templates/computes/host_nav.html index a1116c10..5e1bf278 100644 --- a/computes/templates/computes/host_nav.html +++ b/computes/templates/computes/host_nav.html @@ -31,5 +31,9 @@ {% if section == "virtsecrets" and not detail %}{% bs_icon 'key' %} {% trans "Secrets" %} {% else %}{% bs_icon 'key' %} {% trans "Secrets" %}{% endif %} + diff --git a/computes/tests/test_host_nav.py b/computes/tests/test_host_nav.py index 34caaf2e..057adce6 100644 --- a/computes/tests/test_host_nav.py +++ b/computes/tests/test_host_nav.py @@ -17,7 +17,7 @@ class HostNavTestCase(TestCase): def test_the_section_s_own_page_is_the_current_page(self): items = self.items(section="storages") - self.assertEqual(len(items), 7) + self.assertEqual(len(items), 8) self.assertEqual(items["Storages"], ("span", "page")) self.assertEqual(items["Overview"], ("a", None)) diff --git a/computes/urls.py b/computes/urls.py index 24657e7c..36b8ce80 100644 --- a/computes/urls.py +++ b/computes/urls.py @@ -4,6 +4,7 @@ from admin.decorators import superuser_only from django.urls import URLPattern, URLResolver, include, path from interfaces.views import interface, interfaces from networks.views import network, networks +from nodedevs.views import nodedevs from nwfilters.views import nwfilter, nwfilters from storages.views import create_volume, get_volumes, storage, storages @@ -61,6 +62,7 @@ urlpatterns = [ path("statistics", views.compute_graph, name="compute_graph"), path("capacity/", views.capacity, name="compute_capacity"), path("instances/", views.instances, name="instances"), + path("nodedevs/", nodedevs, name="nodedevs"), path("storages/", storages, name="storages"), path("storage//volumes/", get_volumes, name="volumes"), path("storage//", storage, name="storage"), diff --git a/nodedevs/__init__.py b/nodedevs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nodedevs/apps.py b/nodedevs/apps.py new file mode 100644 index 00000000..5c01fce1 --- /dev/null +++ b/nodedevs/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NodedevsConfig(AppConfig): + name = "nodedevs" diff --git a/nodedevs/templates/nodedev_resources.html b/nodedevs/templates/nodedev_resources.html new file mode 100644 index 00000000..953f7583 --- /dev/null +++ b/nodedevs/templates/nodedev_resources.html @@ -0,0 +1,3 @@ +{% load i18n %} +{# what the host has on a device (its network interfaces, disks, graphics card): giving it to a VM takes them from the host #} +{% for resource in device.resources %}{{ resource }}{% empty %}–{% endfor %} diff --git a/nodedevs/templates/nodedev_users.html b/nodedevs/templates/nodedev_users.html new file mode 100644 index 00000000..5f812fac --- /dev/null +++ b/nodedevs/templates/nodedev_users.html @@ -0,0 +1,2 @@ +{# the VMs whose definitions name a device, linked when the panel has them #} +{% for vm in device.vms %}{% if vm.id %}{{ vm.name }}{% else %}{{ vm.name }}{% endif %}{% if not forloop.last %}, {% endif %}{% empty %}–{% endfor %} diff --git a/nodedevs/templates/nodedevs.html b/nodedevs/templates/nodedevs.html new file mode 100644 index 00000000..811c0e35 --- /dev/null +++ b/nodedevs/templates/nodedevs.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load bootstrap_icons %} + +{% block title %}{% trans "Devices" %} - {{ compute.name }}{% endblock %} + +{% block page_heading %}{{ compute.name }} - {% trans "Devices" %}{% endblock page_heading %} + +{% block content %} + {% include "computes/host_nav.html" with section="nodedevs" %} + + {% if devices_read %} + {% if pci_count and not iommu %} +
+ {% bs_icon 'exclamation-triangle' %} + {% blocktrans %}No IOMMU groups detected: a PCI device cannot be given to a VM. The host needs its IOMMU (Intel VT-d, AMD-Vi) turned on in the firmware and the kernel; on Intel, boot it with intel_iommu=on.{% endblocktrans %} +
+ {% endif %} + +
+ +
+ +
+
{% trans "PCI devices" %} {{ pci_count }}
+ {% if pci_count %} +
+ + + + + + + + + + + + {% for group, devices in groups %} + + + + + {% for device in devices %} + + + + + + + + + {% endfor %} + + {% endfor %} +
{% trans "Address" %}{% trans "Device" %}{% trans "Type" %}{% trans "Driver" %}{% trans "Host resources" %}{% trans "Used by" %}
+ {% if group is None %}{% trans "Without an IOMMU group" %}{% else %}{% blocktrans %}IOMMU group {{ group }}{% endblocktrans %}{% endif %} +
{{ device.address }} +
{{ device.product }}
+
{{ device.vendor }} {{ device.ids }}
+ {% if device.physfn %}
{% blocktrans with address=device.physfn %}Virtual function of {{ address }}{% endblocktrans %}
{% endif %} + {% if device.vfs %}
{% blocktrans count counter=device.vfs %}{{ counter }} virtual function{% plural %}{{ counter }} virtual functions{% endblocktrans %}
{% endif %} +
{{ device.kind }}{{ device.driver|default:"–" }}{% include "nodedev_resources.html" %}{% include "nodedev_users.html" %}
+
+ {% else %} +
{% trans "The host reports no PCI devices." %}
+ {% endif %} +
+ +
+
{% trans "USB devices" %} {{ usb|length }}
+ {% if usb %} +
+ + + + + + + + + + + + {% for device in usb %} + + + + + + + + {% endfor %} + +
{% trans "Bus / device" %}{% trans "Device" %}{% trans "Type" %}{% trans "Host resources" %}{% trans "Used by" %}
{{ device.bus }} / {{ device.device }} +
{{ device.product }}
+
{{ device.vendor }} {{ device.ids }}
+ {% if device.same_model %}
{% trans "One of several of this model: a VM that names the model alone may take any of them." %}
{% endif %} +
{{ device.kinds|join:", "|default:"–" }}{% include "nodedev_resources.html" %} + {% if device.vms or not device.by_model %}{% include "nodedev_users.html" %}{% endif %} + {% if device.by_model %}
{% blocktrans with vms=device.by_model|join:", " %}By model: {{ vms }}{% endblocktrans %}
{% endif %} +
+
+ {% else %} +
{% trans "The host reports no USB devices." %}
+ {% endif %} +
+
+ +
+ {% endif %} +{% endblock %} + +{% block script %} +{% if devices_read %}{% endif %} +{% endblock %} diff --git a/nodedevs/tests/__init__.py b/nodedevs/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nodedevs/tests/test_nodedevs.py b/nodedevs/tests/test_nodedevs.py new file mode 100644 index 00000000..edaa120e --- /dev/null +++ b/nodedevs/tests/test_nodedevs.py @@ -0,0 +1,201 @@ +"""The host's devices page: PCI devices by IOMMU group and USB devices, each +with what the host has on it (network interfaces, disks, its graphics card) +and the VMs that name it, by PCI address, by USB address or by USB model +only. Node device XML as libvirt 11.10 writes it; the connection is a mock, +libvirt's test driver has no node devices.""" + +from unittest.mock import MagicMock, patch + +import libvirt +from computes.models import Compute +from django.contrib.auth import get_user_model +from django.test import SimpleTestCase, TestCase +from django.urls import reverse +from instances.models import Instance +from lxml import html +from vrtManager.nodedevs import pci_address, wvmNodeDevices + + +def pci(slot, product, code, driver=None, group=None): + iommu = f"
" \ + if group is not None else "" + driver = f"{driver}" if driver else "" + return (f"pci_0000_00_{int(slot, 16):02x}_0", f"""pci_0000_00_{int(slot, 16):02x}_0computer{driver} + {code}00{int(slot, 16)}0 + {product}Red Hat, Inc.{iommu}""") + + +def child(name, parent, capability): + return name, f"{name}{parent}{capability}" + + +def usb(name, parent, bus, device, product="QEMU Tablet", vendor_id="0x0627", product_id="0x0001"): + return name, f"""{name}{parent}{bus} + {device}{product}Adomax + """ + + +def interface(name, parent, code): + return child(name, parent, f"0{code}") + + +NODES = [ + pci("0x06", "Virtio network device", "0x020000", "virtio-pci", group=3), + child("net_eth0", "pci_0000_00_06_0", "eth0"), + pci("0x03", "Virtio block device", "0x010000", "virtio-pci", group=2), + child("virtio1", "pci_0000_00_03_0", ""), # a level between, not a resource + child("block_vda", "virtio1", "/dev/vda"), + pci("0x02", "Virtio 1.0 GPU", "0x030000", "virtio-pci", group=2), + child("drm_card0", "pci_0000_00_02_0", "/dev/dri/card0primary"), + child("drm_render", "pci_0000_00_02_0", "/dev/dri/renderD128render"), + pci("0x0a", "Ethernet controller", "0x020000", "vfio-pci", group=4), + pci("0x01", "PIIX3 USB", "0x0c0300", "uhci_hcd"), + usb("usb_usb1", "pci_0000_00_01_0", 1, 1, "1.1 root hub", "0x1d6b", "0x0001"), + interface("usb_1_0_1_0", "usb_usb1", 9), + usb("usb_1_1", "usb_usb1", 1, 2), + interface("usb_1_1_1_0", "usb_1_1", 3), + usb("usb_1_2", "usb_usb1", 1, 3), + interface("usb_1_2_1_0", "usb_1_2", 3), +] + + +def node(name, xml): + return MagicMock(**{"name.return_value": name, "XMLDesc.return_value": xml}) + + +def vm(name, hostdevs, saved=None): + """A VM with hostdevs as it runs and, given saved, persistent with those for its next start.""" + def describe(flags=0): + return f"{saved if flags else hostdevs}" + return MagicMock(**{"name.return_value": name, "XMLDesc.side_effect": describe, "isPersistent.return_value": saved is not None}) + + +PCI_0A = "
" +USB_1_3 = "
" +USB_MODEL = "" + + +def devices(nodes=NODES, domains=()): + conn = wvmNodeDevices.__new__(wvmNodeDevices) + conn.wvm = MagicMock(**{"listAllDevices.return_value": [node(*n) for n in nodes], + "listAllDomains.return_value": list(domains)}) + return conn.get_node_devices() + + +class NodeDevicesTests(SimpleTestCase): + def test_pci_devices_by_group_with_what_the_host_has_on_them(self): + pci_devices, _ = devices() + self.assertEqual([(d["address"], d["group"]) for d in pci_devices], + [("0000:00:02.0", 2), ("0000:00:03.0", 2), ("0000:00:06.0", 3), ("0000:00:0a.0", 4), ("0000:00:01.0", None)]) + gpu, disk, nic, spare, _ = pci_devices + # a graphics card's primary node is the host's; its render node is not a resource of its own + self.assertEqual((gpu["resources"], disk["resources"], nic["resources"], spare["resources"]), + (["/dev/dri/card0"], ["/dev/vda"], ["eth0"], [])) + self.assertEqual((nic["kind"], nic["driver"], nic["ids"]), ("Network controller", "virtio-pci", "1af4:1000")) + + def test_usb_devices_with_their_classes_hubs_and_same_models(self): + _, (hub, tablet_2, tablet_3) = devices() + self.assertEqual((hub["kinds"], hub["same_model"]), (["Hub"], False)) + self.assertEqual((tablet_2["kinds"], tablet_2["same_model"], tablet_3["same_model"]), (["Human interface"], True, True)) + + def test_the_vms_that_name_a_device(self): + pci_devices, (_, tablet_2, tablet_3) = devices(domains=[vm("db-1", PCI_0A + USB_1_3), vm("kiosk-1", USB_MODEL)]) + self.assertEqual([d["vms"] for d in pci_devices], [[], [], [], ["db-1"], []]) + # by address: that device; by model only: any device of the model + self.assertEqual((tablet_3["vms"], tablet_3["by_model"]), (["db-1"], ["kiosk-1"])) + self.assertEqual((tablet_2["vms"], tablet_2["by_model"]), ([], ["kiosk-1"])) + + def test_a_usb_device_is_named_as_libvirt_picks_one(self): + def named(hostdev): + _, (_, tablet_2, tablet_3) = devices(domains=[vm("v", hostdev)]) + return [(d["vms"], d["by_model"]) for d in (tablet_2, tablet_3)] + # an address with other IDs: another device now at that address + other = USB_1_3.replace("0x0627", "0x046d") + address_only = "
" + self.assertEqual(named(other), [([], []), ([], [])]) + self.assertEqual(named(address_only), [(["v"], []), ([], [])]) + self.assertEqual(named(USB_MODEL), [([], ["v"]), ([], ["v"])]) + + def test_a_pci_device_named_as_a_nic_or_only_for_the_next_start(self): + nic = ("
" + "") + pci_devices, _ = devices(domains=[vm("nic-1", nic), vm("next-1", "", saved=PCI_0A)]) + self.assertEqual({d["address"]: d["vms"] for d in pci_devices if d["vms"]}, + {"0000:00:06.0": ["nic-1"], "0000:00:0a.0": ["next-1"]}) + + def test_sr_iov_functions_and_unnamed_hardware(self): + vf = pci("0x0b", "", "0x020000", group=5)[1].replace( + "", "").replace( + "", "
") + pf = NODES[8][1].replace("", "
") + pci_devices, _ = devices(nodes=[("pci_0000_00_0a_0", pf), ("pci_0000_00_0b_0", vf)]) + self.assertEqual([(d["physfn"], d["vfs"], d["product"]) for d in pci_devices], + [("", 1, "Ethernet controller"), ("0000:00:0a.0", 0, "0x10ed")]) + + def test_a_device_gone_meanwhile_is_left_out_other_errors_are_raised(self): + for code, raised in ((libvirt.VIR_ERR_NO_NODE_DEVICE, False), (libvirt.VIR_ERR_OPERATION_FAILED, True)): + err = libvirt.libvirtError("gone") + err.get_error_code = lambda code=code: code + gone = node("pci_0000_00_0b_0", "") + gone.XMLDesc.side_effect = err + conn = wvmNodeDevices.__new__(wvmNodeDevices) + conn.wvm = MagicMock(**{"listAllDevices.return_value": [gone, node(*NODES[0])], "listAllDomains.return_value": []}) + with self.subTest(code=code): + if raised: + self.assertRaises(libvirt.libvirtError, conn.get_node_devices) + else: + self.assertEqual([d["address"] for d in conn.get_node_devices()[0]], ["0000:00:06.0"]) + + def test_a_pci_address_from_libvirts_hex_or_numbers(self): + self.assertEqual(pci_address("0x0000", "0x00", "0x1f", "0x3"), pci_address(0, 0, 31, 3)) + self.assertEqual(pci_address(0, 0, 31, 3), "0000:00:1f.3") + + +class NodeDevicesPageTests(TestCase): + def setUp(self): + self.compute = Compute.objects.create(name="nd", hostname="192.0.2.30", login="root", password="", type=1) + self.client.force_login(get_user_model().objects.create_superuser("nd", "nd@example.com", "pw")) + self.url = reverse("nodedevs", args=[self.compute.id]) + + def page(self, nodes=NODES, domains=()): + with patch("vrtManager.connection.connection_manager.get_connection") as get: + get.return_value.listAllDevices.return_value = [node(*n) for n in nodes] + get.return_value.listAllDomains.return_value = list(domains) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + return html.fromstring(response.content) + + def test_the_page_groups_pci_devices_and_links_their_vms(self): + instance = Instance.objects.create(compute=self.compute, name="db-1", uuid="00000000-0000-4000-8000-0000000000d1") + page = self.page(domains=[vm("db-1", PCI_0A), vm("outside-1", USB_1_3)]) + headers = [" ".join(h.text_content().split()) for h in page.xpath("//tr[@data-group-header]")] + self.assertEqual(headers, ["IOMMU group 2", "IOMMU group 3", "IOMMU group 4", "Without an IOMMU group"]) + self.assertEqual(page.xpath("//a[text()='db-1']/@href"), [reverse("instances:instance", args=[instance.id])]) + self.assertIn("outside-1", page.text_content()) # a VM the panel does not have: named, not linked + self.assertNotIn("No IOMMU groups detected", page.text_content()) + + def test_a_host_without_iommu_groups_says_so(self): + page = self.page(nodes=[n for n in NODES if "iommuGroup" not in n[1]]) + self.assertIn("No IOMMU groups detected", page.text_content()) + + def test_a_host_without_pci_or_usb_devices_says_so(self): + page = self.page(nodes=[n for n in NODES if "usb" in n[0]]) + self.assertIn("The host reports no PCI devices.", page.text_content()) + self.assertNotIn("No IOMMU groups detected", page.text_content()) # nothing to give + self.assertIn("The host reports no USB devices.", self.page(nodes=NODES[:2]).text_content()) + + def test_only_superusers(self): + self.client.force_login(get_user_model().objects.create_user("nd-user", "u@example.com", "pw")) + self.assertEqual(self.client.get(self.url).status_code, 403) + + def test_a_host_not_read_shows_no_device(self): + # without the VMs' definitions a device would look free + with patch("vrtManager.connection.connection_manager.get_connection") as get: + get.return_value.listAllDevices.return_value = [node(*n) for n in NODES] + get.return_value.listAllDomains.side_effect = libvirt.libvirtError("unreachable") + response = self.client.get(self.url) + page = html.fromstring(response.content) + self.assertEqual(page.xpath("//table"), []) + self.assertIn("unreachable", page.text_content()) diff --git a/nodedevs/views.py b/nodedevs/views.py new file mode 100644 index 00000000..c4ba333b --- /dev/null +++ b/nodedevs/views.py @@ -0,0 +1,29 @@ +from admin.decorators import superuser_only +from computes.models import Compute +from django.contrib import messages +from django.shortcuts import get_object_or_404, render +from instances.models import Instance +from libvirt import libvirtError +from vrtManager.nodedevs import wvmNodeDevices + + +@superuser_only +def nodedevs(request, compute_id): + """The host's PCI and USB devices: what each is, what the host has on it + and the VMs that name it. Read only.""" + compute = get_object_or_404(Compute, pk=compute_id) + context = {"compute": compute} + try: + conn = wvmNodeDevices(compute.hostname, compute.login, compute.password, compute.type) + pci, usb = conn.get_node_devices() + vm_ids = dict(Instance.objects.filter(compute=compute).values_list("name", "id")) + for device in pci + usb: + device["vms"] = [{"name": name, "id": vm_ids.get(name)} for name in device["vms"]] + groups = {} + for device in pci: + groups.setdefault(device["group"], []).append(device) + context.update(devices_read=True, groups=list(groups.items()), pci_count=len(pci), usb=usb, + iommu=any(group is not None for group in groups)) + except libvirtError as err: # nothing is shown without the VMs: a device would look free + messages.error(request, err) + return render(request, "nodedevs.html", context) diff --git a/static/icon_cache/usb-symbol_None_None_None.svg b/static/icon_cache/usb-symbol_None_None_None.svg new file mode 100644 index 00000000..54aa0860 --- /dev/null +++ b/static/icon_cache/usb-symbol_None_None_None.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/vrtManager/nodedevs.py b/vrtManager/nodedevs.py new file mode 100644 index 00000000..8cfb35b9 --- /dev/null +++ b/vrtManager/nodedevs.py @@ -0,0 +1,164 @@ +from django.utils.translation import gettext_lazy as _ +from libvirt import VIR_ERR_NO_NODE_DEVICE, libvirtError +from lxml import etree + +from vrtManager.connection import wvmConnect + +# PCI base classes by their code (the class's first byte) +PCI_CLASSES = { + 0x01: _("Storage controller"), + 0x02: _("Network controller"), + 0x03: _("Display controller"), + 0x04: _("Multimedia controller"), + 0x06: _("Bridge"), + 0x0C: _("Serial bus controller"), +} + +# USB interface classes by their code +USB_CLASSES = { + 0x01: _("Audio"), + 0x02: _("Communications"), + 0x03: _("Human interface"), + 0x06: _("Imaging"), + 0x07: _("Printer"), + 0x08: _("Mass storage"), + 0x09: _("Hub"), + 0x0A: _("Communications data"), + 0x0B: _("Smart card"), + 0x0E: _("Video"), + 0xE0: _("Wireless"), + 0xFF: _("Vendor specific"), +} + + +def pci_address(domain, bus, slot, function): + """A PCI address as lspci writes it, from its parts as integers or as + libvirt's hex text (0x0000).""" + domain, bus, slot, function = (int(str(part), 0) for part in (domain, bus, slot, function)) + return f"{domain:04x}:{bus:02x}:{slot:02x}.{function:x}" + + +def host_resources(name, nodes, children): + """What the host itself has on the device named name or below it: its + network interfaces, block devices and graphics cards, by name. Giving + the device to a VM takes them from the host.""" + found = [] + for child in children.get(name, []): + cap = nodes[child].find("capability") + if cap.get("type") == "net": + found.append(cap.findtext("interface")) + elif cap.get("type") == "storage": + found.append(cap.findtext("block")) + elif cap.get("type") == "drm" and cap.findtext("type") == "primary": + found.append(nodes[child].xpath("string(devnode[@type='dev'])")) + found.extend(host_resources(child, nodes, children)) + return found + + +def hostdev_users(trees): + """What a VM's definitions give it of the host: PCI addresses (of a + , or of a NIC that is a host device) and USB selectors + (vendor, product, (bus, device) or None), as libvirt picks a USB device: + by the parts given, all of them.""" + pci, usb = set(), [] + for tree in trees: + for address in tree.xpath("devices/hostdev[@mode='subsystem'][@type='pci']/source/address" + " | devices/interface[@type='hostdev']/source/address" + " | devices/interface/actual[@type='hostdev']/source/address"): + pci.add(pci_address(address.get("domain", "0"), address.get("bus"), address.get("slot"), address.get("function"))) + for source in tree.xpath("devices/hostdev[@mode='subsystem'][@type='usb']/source"): + address = source.find("address") + usb.append((source.xpath("string(vendor/@id)").lower(), source.xpath("string(product/@id)").lower(), + (int(address.get("bus")), int(address.get("device"))) if address is not None else None)) + return pci, usb + + +def usb_named(selector, model, address): + """How a USB selector names the device of model at address: "address" + (its address, and its IDs where given), "model" (its IDs, no address: + any device of the model may be taken) or None.""" + vendor, product, selected = selector + ids_match = (not vendor or vendor == model[0]) and (not product or product == model[1]) + if not ids_match: + return None + if selected == address: + return "address" + return "model" if selected is None else None + + +class wvmNodeDevices(wvmConnect): + def get_node_devices(self): + """The host's PCI and USB devices, each with what the host has on it + (host_resources) and the VMs whose definitions name it: one listing + of every node device, one XML read each (a device gone meanwhile is + left out), and the VMs' definitions read once (domain_trees).""" + nodes = {} + for device in self.wvm.listAllDevices(0): + try: + nodes[device.name()] = etree.fromstring(device.XMLDesc(0)) + except libvirtError as err: + if err.get_error_code() != VIR_ERR_NO_NODE_DEVICE: + raise + children = {} + for name, node in nodes.items(): + children.setdefault(node.findtext("parent"), []).append(name) + + vms = [(vm, *hostdev_users(trees)) for vm, trees in self.domain_trees()] + pci = [self._pci(name, node, nodes, children, vms) for name, node in nodes.items() + if node.find("capability").get("type") == "pci"] + usb = [self._usb(name, node, nodes, children, vms) for name, node in nodes.items() + if node.find("capability").get("type") == "usb_device"] + # a model the host has more than one of: a VM that names it by model alone may take any of them + models = [device["model"] for device in usb] + for device in usb: + device["same_model"] = models.count(device["model"]) > 1 + pci.sort(key=lambda device: (device["group"] is None, device["group"] or 0, device["address"])) + usb.sort(key=lambda device: (device["bus"], device["device"])) + return pci, usb + + @staticmethod + def _pci(name, node, nodes, children, vms): + cap = node.find("capability") + address = pci_address(cap.findtext("domain"), cap.findtext("bus"), cap.findtext("slot"), cap.findtext("function")) + code = int(cap.findtext("class") or "0", 16) + group = cap.xpath("string(iommuGroup/@number)") + # SR-IOV: a virtual function's physical one + physfn = cap.find("capability[@type='phys_function']/address") + return { + "address": address, + "vendor": cap.findtext("vendor") or cap.xpath("string(vendor/@id)"), + "product": cap.findtext("product") or cap.xpath("string(product/@id)"), + "ids": f"{cap.xpath('string(vendor/@id)')[2:]}:{cap.xpath('string(product/@id)')[2:]}", + "kind": PCI_CLASSES.get(code >> 16, _("Other")), + "driver": node.findtext("driver/name") or "", + "group": int(group) if group else None, + "resources": host_resources(name, nodes, children), + "vms": [vm for vm, pci, _ in vms if address in pci], + "physfn": pci_address(physfn.get("domain"), physfn.get("bus"), physfn.get("slot"), physfn.get("function")) + if physfn is not None else "", + "vfs": len(cap.xpath("capability[@type='virt_functions']/address")), + } + + @staticmethod + def _usb(name, node, nodes, children, vms): + cap = node.find("capability") + bus, device = int(cap.findtext("bus")), int(cap.findtext("device")) + model = (cap.xpath("string(vendor/@id)").lower(), cap.xpath("string(product/@id)").lower()) + # a USB device's classes are its interfaces' + codes = [int(nodes[child].findtext("capability/class") or "0") for child in children.get(name, []) + if nodes[child].find("capability").get("type") == "usb"] + named = [(vm, usb_named(selector, model, (bus, device))) for vm, _, selectors in vms for selector in selectors] + by_address = sorted({vm for vm, how in named if how == "address"}) + return { + "bus": bus, + "device": device, + "vendor": cap.findtext("vendor") or model[0], + "product": cap.findtext("product") or model[1], + "model": model, + "ids": f"{model[0][2:]}:{model[1][2:]}", + "kinds": sorted({str(USB_CLASSES.get(code, _("Other"))) for code in codes}), + "resources": host_resources(name, nodes, children), + "vms": by_address, + # VMs that name its model only: which device of the model they take is known only when they start + "by_model": sorted({vm for vm, how in named if how == "model"} - set(by_address)), + } diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index a65c1082..cd304c96 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -77,6 +77,7 @@ INSTALLED_APPS = [ "instances", "interfaces", "nwfilters", + "nodedevs", "storages", "virtsecrets", "logs",