1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

feat(instances): PCI devices of the host given to a shut-off VM

- The Host devices tab gives a shut-off VM a PCI device of its host and
  takes it back; libvirt binds the device to vfio-pci when the VM starts
  and gives it back when it stops (managed). The VM's PCI devices are
  listed with whether the running VM and its next start have them.
- A device is offered, and accepted on the server under the compute's
  claim, only when it is no bridge of any kind (bridge header, PCIe port,
  or of the bridge class: host bridge, LPC), the host has none of its own
  network interfaces, disks or graphics card on it (else: bind it to
  vfio-pci on the host first), no other VM names it, and every other
  endpoint of its IOMMU group is the VM's too or has no driver or
  vfio-pci and no other VM; bridges in the group stay with pcieport, and
  a member that cannot be read keeps the group. Nothing in the group is
  taken implicitly.
- A snapshot revert checks the PCI devices of the snapshot with the same
  rules; migration and clones already leave host devices out.
- The add forms of the tab load together, when the tab opens.
This commit is contained in:
catborise 2026-10-29 13:45:27 +03:00
parent ac8ff62e7e
commit 80dd145bcf
19 changed files with 509 additions and 103 deletions

View file

@ -428,19 +428,19 @@
});
</script>
<script>
// the Host devices tab's add form: loaded once, when the tab first opens
var hostUsb = document.getElementById("host-usb");
if (hostUsb) {
// the Host devices tab's add forms: loaded once, when the tab first opens
var hostDevices = document.getElementById("host-devices");
if (hostDevices) {
document.querySelector('[data-bs-target="#hostdevs"]').addEventListener("shown.bs.tab", function () {
if (hostUsb.dataset.loaded) return;
hostUsb.dataset.loaded = "true";
fetch(hostUsb.dataset.url).then(function (response) {
if (hostDevices.dataset.loaded) return;
hostDevices.dataset.loaded = "true";
fetch(hostDevices.dataset.url).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.text();
}).then(function (html) {
hostUsb.innerHTML = html;
hostDevices.innerHTML = html;
}).catch(function () {
hostUsb.textContent = "{{ _("The host's USB devices could not be read.")|escapejs }}";
hostDevices.textContent = "{{ _("The host's devices could not be read.")|escapejs }}";
});
});
}

View file

@ -0,0 +1,29 @@
{% load i18n %}
{% load bootstrap_icons %}
{# the add forms of the Host devices tab: each choice disabled with the reasons it cannot be given #}
{% if error %}
<div class="alert alert-warning mb-0">{% blocktrans %}The host's devices could not be read: {{ error }}{% endblocktrans %}</div>
{% else %}
<form method="post" action="{% url 'instances:add_usb_hostdev' instance.id %}" class="d-flex flex-wrap gap-2 mb-2">{% csrf_token %}
<select name="usb_device" class="form-select w-auto mw-100" aria-label="{% trans "USB device of the host" %}" required>
<option value="" selected disabled>{% trans "Choose a USB device of the host…" %}</option>
{% for device in host_usb %}
<option value="{{ device.bus }}/{{ device.device }}"{% if device.problems %} disabled{% endif %}>{{ device.product }} ({{ device.ids }}, {{ device.bus }}/{{ device.device }}){% if device.problems %}: {{ device.problems|join:"; " }}{% endif %}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">{% bs_icon 'plus-lg' %} {% trans "Add USB device" %}</button>
</form>
<form method="post" action="{% url 'instances:add_pci_hostdev' instance.id %}" class="d-flex flex-wrap gap-2">{% csrf_token %}
<select name="pci_device" class="form-select w-auto mw-100" aria-label="{% trans "PCI device of the host" %}" aria-describedby="pci_help" required{% if running %} disabled{% endif %}>
<option value="" selected disabled>{% trans "Choose a PCI device of the host…" %}</option>
{% for device in host_pci %}
<option value="{{ device.address }}"{% if device.problems %} disabled{% endif %}>{{ device.product }} ({{ device.address }}, {{ device.driver|default:_("no driver") }}){% if device.problems %}: {{ device.problems|join:"; " }}{% endif %}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary"{% if running %} disabled{% endif %}>{% bs_icon 'plus-lg' %} {% trans "Add PCI device" %}</button>
<div class="form-text w-100" id="pci_help">
{% if running %}{% trans "Shut the VM down, without a saved state, to change its PCI devices." %}
{% else %}{% trans "libvirt binds the device to vfio-pci when the VM starts, and gives it back to the host when the VM stops; a device bound to vfio-pci before stays bound to it." %}{% endif %}
</div>
</form>
{% endif %}

View file

@ -1,15 +0,0 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if error %}
<div class="alert alert-warning mb-0">{% blocktrans %}The host's devices could not be read: {{ error }}{% endblocktrans %}</div>
{% else %}
<form method="post" action="{% url 'instances:add_usb_hostdev' instance.id %}" class="d-flex flex-wrap gap-2">{% csrf_token %}
<select name="usb_device" class="form-select w-auto mw-100" aria-label="{% trans "USB device of the host" %}" required>
<option value="" selected disabled>{% trans "Choose a USB device of the host…" %}</option>
{% for device in host_usb %}
<option value="{{ device.bus }}/{{ device.device }}"{% if device.problems %} disabled{% endif %}>{{ device.product }} ({{ device.ids }}, {{ device.bus }}/{{ device.device }}){% if device.problems %}: {{ device.problems|join:"; " }}{% endif %}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">{% bs_icon 'plus-lg' %} {% trans "Add USB device" %}</button>
</form>
{% endif %}

View file

@ -598,9 +598,9 @@
<div role="tabpanel" class="tab-pane border border-top-0 p-3" id="hostdevs">
<p class="small text-body-secondary">{% trans "Devices of the host given to the VM. The VM starts only on a host that has them; it is not migrated while it has them, and its clones get none." %}</p>
{% if request.user.is_superuser %}
{# the add form comes when the tab opens: its choices scan every VM of the host #}
<div id="host-usb" class="mb-3" data-url="{% url 'instances:host_usb' instance.id %}">
<span class="small text-body-secondary">{% trans "Reading the host's USB devices…" %}</span>
{# the add forms come when the tab opens: their choices scan every VM of the host #}
<div id="host-devices" class="mb-3" data-url="{% url 'instances:host_devices' instance.id %}">
<span class="small text-body-secondary">{% trans "Reading the host's devices…" %}</span>
</div>
{% endif %}
{% if usb_hostdevs %}
@ -640,13 +640,46 @@
</table>
</div>
{% endif %}
{% if pci_hostdevs %}
<div class="table-responsive position-relative">
<table class="table align-middle">
<thead>
<tr>
<th scope="col">{% trans "PCI device" %}</th>
<th scope="col">{% trans "Running VM" %}</th>
<th scope="col">{% trans "Next start" %}</th>
{% if request.user.is_superuser %}<th scope="col"><span class="visually-hidden">{% trans "Actions" %}</span></th>{% endif %}
</tr>
</thead>
<tbody>
{% for device in pci_hostdevs %}
<tr>
<td>{{ device.name|default:"–" }} <span class="small text-body-secondary font-monospace">{{ device.address }}</span></td>
<td>{% if device.now %}{% trans "Has it" %}{% else %}–{% endif %}</td>
<td>{% if device.next %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
{% if request.user.is_superuser %}
<td class="text-end">
<form method="post" action="{% url 'instances:delete_pci_hostdev' instance.id %}">{% csrf_token %}
<input type="hidden" name="address" value="{{ device.address }}">
<button type="submit" class="btn btn-sm btn-outline-danger" aria-label="{% trans "Remove the PCI device" %}"
{% if instance.status != 5 or not device.next %}disabled title="{% trans "Shut the VM down to change its PCI devices" %}"{% endif %}
data-confirm="{% trans "Remove this PCI device from the VM?" %}" onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %}</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% for device in other_hostdevs %}
<p class="mb-1">
{% if device.type == "nic" %}{% trans "Network interface" %}{% else %}{{ device.type|upper }}{% endif %} <span class="font-monospace">{{ device.address }}</span>
<span class="small text-body-secondary">{% if device.now and device.next %}{% trans "running VM and next start" %}{% elif device.now %}{% trans "running VM only" %}{% else %}{% trans "next start only" %}{% endif %}; {% trans "changed in the XML editor" %}</span>
</p>
{% endfor %}
{% if not usb_hostdevs and not other_hostdevs %}
{% if not usb_hostdevs and not pci_hostdevs and not other_hostdevs %}
<p class="small text-body-secondary mb-0">{% trans "The VM has no host devices." %}</p>
{% endif %}
</div>
@ -808,7 +841,7 @@
{% if can_clone %}
<div role="tabpanel" class="tab-pane border border-top-0 p-3" id="clone">
<p class="fw-bold">{% trans "Create a clone" %}</p>
{% if usb_hostdevs or other_hostdevs %}<p class="small text-body-secondary">{% trans "The clone gets none of the VM's host devices: one VM holds a device of the host." %}</p>{% endif %}
{% if usb_hostdevs or pci_hostdevs or other_hostdevs %}<p class="small text-body-secondary">{% trans "The clone gets none of the VM's host devices: one VM holds a device of the host." %}</p>{% endif %}
<form class="form" action="{% url 'instances:clone' instance.id %}" method="post" role="form" aria-label="Create clone form">{% csrf_token %}{% submission_key %}
<div class="row">
<label class="col-sm-3 col-form-label fw-normal">{% trans "Clone Name" %}</label>

View file

@ -69,7 +69,7 @@ class ConsoleListenerTests(TestCase):
proxy.get_console_listener_addr.return_value = "127.0.0.1"
for name in ("get_networks", "get_ifaces", "get_storages", "get_disk_devices", "get_media_devices", "get_net_devices"):
getattr(proxy, name).return_value = []
proxy.get_hostdevs.return_value = ([], [])
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_status.return_value = 5
response = self.client.get(reverse("instances:instance", args=[self.vm.id]))
form = response.context["console_form"]

View file

@ -61,7 +61,7 @@ class ConsoleSettingsAccessTestCase(TestCase):
proxy.get_max_memory.return_value = 4096
proxy.get_networks.return_value = []
proxy.get_ifaces.return_value = []
proxy.get_hostdevs.return_value = ([], [])
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_storages.return_value = []
proxy.get_console_passwd.return_value = VNC_PASSWORD
proxy.get_console_type.return_value = "vnc"

View file

@ -38,7 +38,7 @@ def vm(running, saved, active=True, migratable=None):
the migratable XML marks it), and whose next start is saved."""
proxy = wvmInstance.__new__(wvmInstance)
proxy._read_cache = None
proxy.instance = MagicMock(**{"isActive.return_value": active})
proxy.instance = MagicMock(**{"isActive.return_value": active, "hasManagedSaveImage.return_value": 0})
def describe(flags=0):
if flags & libvirt.VIR_DOMAIN_XML_INACTIVE:
@ -53,9 +53,9 @@ class UsbHostdevTests(SimpleTestCase):
# the running VM writes the address libvirt resolved; only its migratable XML marks it
proxy = vm(domain(CONTROLLER, by_model("<address bus='1' device='3'/>")), domain(CONTROLLER, by_model()),
migratable=domain(CONTROLLER, by_model("<address bus='1' device='3'/>", " autoAddress='yes'")))
usb, others = proxy.get_hostdevs()
usb, pci, others = proxy.get_hostdevs()
self.assertEqual(usb, [{"vendor": "0x0627", "product": "0x0001", "address": None, "now": (1, 3), "next": True}])
self.assertEqual(others, [])
self.assertEqual((pci, others), ([], []))
def test_a_device_is_taken_from_each_definition_as_it_describes_it(self):
proxy = vm(domain(CONTROLLER, by_model("<address bus='1' device='3'/>")), domain(CONTROLLER, by_model()),
@ -93,13 +93,38 @@ class UsbHostdevTests(SimpleTestCase):
"<address type='pci' domain='0x0000' bus='0x00' slot='0x0c' function='0x1'/></source></actual></interface>")
mdev = "<hostdev mode='subsystem' type='mdev'><source><address uuid='{}'/></source></hostdev>"
running = domain(pci, nic, network_nic, mdev.format("00000000-0000-4000-8000-00000000a001"))
_, others = vm(running, domain(nic, mdev.format("00000000-0000-4000-8000-00000000a002"))).get_hostdevs()
self.assertEqual(others, [{"type": "pci", "address": "0000:00:0a.0", "now": True, "next": False},
{"type": "nic", "address": "0000:00:0b.0", "now": True, "next": True},
_, pci_rows, others = vm(running, domain(nic, mdev.format("00000000-0000-4000-8000-00000000a002"))).get_hostdevs()
self.assertEqual(pci_rows, [{"address": "0000:00:0a.0", "now": True, "next": False, "managed": False}])
self.assertEqual(others, [{"type": "nic", "address": "0000:00:0b.0", "now": True, "next": True},
{"type": "nic", "address": "0000:00:0c.1", "now": True, "next": False},
{"type": "mdev", "address": "00000000-0000-4000-8000-00000000a001", "now": True, "next": False},
{"type": "mdev", "address": "00000000-0000-4000-8000-00000000a002", "now": False, "next": True}])
def test_a_pci_device_is_given_and_taken_back_only_while_the_vm_is_shut_off(self):
proxy = vm("", domain(), active=False)
proxy.add_pci_hostdev("0000:05:00.0")
xml, flags = proxy.instance.attachDeviceFlags.call_args.args
self.assertEqual(flags, libvirt.VIR_DOMAIN_AFFECT_CONFIG)
self.assertEqual((etree.fromstring(xml).get("managed"), dict(etree.fromstring(xml).find("source/address").attrib)),
("yes", {"domain": "0x0000", "bus": "0x05", "slot": "0x00", "function": "0x0"}))
pci = "<hostdev mode='subsystem' type='pci' managed='yes'><source><address domain='0x0000' bus='0x05' slot='0x00' function='0x0'/></source></hostdev>"
proxy = vm("", domain(pci), active=False)
proxy.delete_pci_hostdev("0000:05:00.0")
self.assertEqual(proxy.instance.detachDeviceFlags.call_args.args[1], libvirt.VIR_DOMAIN_AFFECT_CONFIG)
with self.assertRaisesRegex(OperationError, "no PCI device 0000:06:00.0"):
proxy.delete_pci_hostdev("0000:06:00.0")
running = vm(domain(pci), domain(pci))
for change in (running.add_pci_hostdev, running.delete_pci_hostdev):
with self.assertRaisesRegex(OperationError, "Shut the VM down"):
change("0000:05:00.0")
running.instance.attachDeviceFlags.assert_not_called()
running.instance.detachDeviceFlags.assert_not_called()
# a saved state would start the VM without the change
saved = vm("", domain(pci), active=False)
saved.instance.hasManagedSaveImage.return_value = 1
with self.assertRaisesRegex(OperationError, "without a saved state"):
saved.add_pci_hostdev("0000:06:00.0")
def test_a_nic_that_is_a_host_device_is_left_to_the_xml_editor(self):
nic = "<interface type='hostdev'><mac address='52:54:00:00:00:0a'/><source><address type='pci' bus='0x00' slot='0x0a'/></source></interface>"
proxy = vm(domain(nic), domain(nic))
@ -142,17 +167,18 @@ class HostUsbViewTests(TestCase):
"XMLDesc.return_value": domain(by_model("<address bus='1' device='3'/>"))})
self.proxy.wvm.listAllDevices.return_value = [node(*n) for n in NODES]
self.proxy.domain_trees.side_effect = lambda: [("db-1", [etree.fromstring(other.XMLDesc())])]
self.proxy.get_hostdevs.return_value = ([], [], [])
def test_the_form_offers_what_can_be_given_and_says_why_not(self):
page = html.fromstring(self.client.get(reverse("instances:host_usb", args=[self.instance.id])).content)
options = {o.get("value"): (o.get("disabled") is not None, o.text_content()) for o in page.xpath("//option[@value!='']")}
page = html.fromstring(self.client.get(reverse("instances:host_devices", args=[self.instance.id])).content)
options = {o.get("value"): (o.get("disabled") is not None, o.text_content()) for o in page.xpath("//select[@name='usb_device']/option[@value!='']")}
self.assertEqual(sorted(options), ["1/1", "1/2", "1/3"])
self.assertTrue(options["1/1"][0] and "is a hub" in options["1/1"][1])
self.assertTrue(options["1/3"][0] and "is given to db-1" in options["1/3"][1])
self.assertFalse(options["1/2"][0])
# a device the VM has already
self.proxy.domain_trees.side_effect = lambda: [("kiosk-1", [etree.fromstring(domain(by_model("<address bus='1' device='2'/>")))])]
page = html.fromstring(self.client.get(reverse("instances:host_usb", args=[self.instance.id])).content)
page = html.fromstring(self.client.get(reverse("instances:host_devices", args=[self.instance.id])).content)
self.assertEqual(page.xpath("//option[@value='1/2']/@disabled"), ["disabled"])
def test_a_device_is_given_by_address_when_the_host_has_several_of_its_model(self):
@ -172,6 +198,23 @@ class HostUsbViewTests(TestCase):
self.client.post(reverse("instances:add_usb_hostdev", args=[self.instance.id]), {"usb_device": "1/2"})
self.proxy.add_usb_hostdev.assert_called_once_with("0x0627", "0x0001", None)
def test_a_pci_device_is_given_when_it_can_be(self):
url = reverse("instances:add_pci_hostdev", args=[self.instance.id])
# the host's own NIC: refused with the reason
response = self.client.post(url, {"pci_device": "0000:00:06.0"})
self.assertIn("holds the host's eth0", " ".join(str(m) for m in get_messages(response.wsgi_request)))
self.proxy.add_pci_hostdev.assert_not_called()
self.client.post(url, {"pci_device": "0000:00:0a.0"}) # bound to vfio-pci, alone in its group
self.proxy.add_pci_hostdev.assert_called_once_with("0000:00:0a.0")
self.client.post(reverse("instances:delete_pci_hostdev", args=[self.instance.id]), {"address": "0000:00:0a.0"})
self.proxy.delete_pci_hostdev.assert_called_once_with("0000:00:0a.0")
def test_the_pci_form_waits_for_a_shut_off_vm(self):
self.proxy.instance.isActive.return_value = True
page = html.fromstring(self.client.get(reverse("instances:host_devices", args=[self.instance.id])).content)
self.assertEqual(page.xpath("//select[@name='pci_device']/@disabled"), ["disabled"])
self.assertIn("Shut the VM down", page.text_content())
def test_a_device_is_taken_back_by_its_selector(self):
url = reverse("instances:delete_usb_hostdev", args=[self.instance.id])
self.client.post(url, {"vendor": "0x0627", "product": "0x0001", "address": ""})
@ -181,9 +224,13 @@ class HostUsbViewTests(TestCase):
def test_only_superusers(self):
self.client.force_login(get_user_model().objects.create_user("hd-user", "u@example.com", "pw"))
self.assertEqual(self.client.get(reverse("instances:host_usb", args=[self.instance.id])).status_code, 403)
self.assertEqual(self.client.get(reverse("instances:host_devices", args=[self.instance.id])).status_code, 403)
self.client.post(reverse("instances:add_usb_hostdev", args=[self.instance.id]), {"usb_device": "1/2"})
self.client.post(reverse("instances:add_pci_hostdev", args=[self.instance.id]), {"pci_device": "0000:00:0a.0"})
self.client.post(reverse("instances:delete_pci_hostdev", args=[self.instance.id]), {"address": "0000:00:0a.0"})
self.proxy.add_usb_hostdev.assert_not_called()
self.proxy.add_pci_hostdev.assert_not_called()
self.proxy.delete_pci_hostdev.assert_not_called()
class RevertTests(TestCase):

View file

@ -72,7 +72,7 @@ class PageControls:
"get_media_devices", "get_net_devices", "get_snapshot",
"get_snapshot_jobs"):
getattr(proxy, getter).return_value = []
proxy.get_hostdevs.return_value = ([], [])
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_console_type.return_value = "vnc"
proxy.get_status.return_value = status
response = self.client.get(reverse("instances:instance", args=[self.instance.id]))

View file

@ -47,7 +47,7 @@ class PendingEditsTestCase(TestCase):
for getter in ("get_networks", "get_ifaces", "get_storages", "get_media_devices", "get_net_devices",
"get_snapshot", "get_snapshot_jobs"):
getattr(proxy, getter).return_value = []
proxy.get_hostdevs.return_value = ([], [])
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_disk_devices.side_effect = lambda **kw: config if kw.get("config") else live
proxy.get_all_qos.side_effect = lambda **kw: qos[1] if kw.get("config") else qos[0]
proxy.get_console_type.return_value = "vnc"

View file

@ -205,7 +205,7 @@ class DetailPageQuotaTests(TestCase):
proxy.get_max_memory.return_value = 4096
for name in ("get_networks", "get_ifaces", "get_storages", "get_disk_devices", "get_media_devices", "get_net_devices"):
getattr(proxy, name).return_value = []
proxy.get_hostdevs.return_value = ([], [])
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_status.return_value = 5
response = self.client.get(reverse("instances:instance", args=[vm.id]))
self.assertEqual(response.status_code, 200)

View file

@ -101,7 +101,7 @@ class SnapshotTabTestCase(TestCase):
for getter in ("get_networks", "get_ifaces", "get_storages", "get_disk_devices",
"get_media_devices", "get_net_devices"):
getattr(proxy, getter).return_value = []
proxy.get_hostdevs.return_value = ([], [])
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_snapshot.side_effect = lambda flag: {
libvirt.VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL: list(internal),
libvirt.VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL: list(external),

View file

@ -221,7 +221,7 @@ class DetailStateTests(TestCase):
proxy.get_max_memory.return_value = 4096
for name in ("get_networks", "get_ifaces", "get_storages", "get_disk_devices", "get_media_devices", "get_net_devices"):
getattr(proxy, name).return_value = []
proxy.get_hostdevs.return_value = ([], [])
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_status.return_value = state
page = self.client.get(reverse("instances:instance", args=[vm.id])).content.decode()
self.assertIn(f'">{label}</span>', page.replace("\n", "").replace(" ", ""))

View file

@ -137,7 +137,7 @@ ALLOWED_HTML_SINKS = {
("accounts/templates/login.html", "$btn.html("): "static spinner markup",
("accounts/templates/accounts/otp_login.html", "$btn.html("): "static spinner markup",
("instances/templates/instance.html", "//sto_input.innerHTML = pool;"): "comment",
("instances/templates/instance.html", "hostUsb.innerHTML = html;"): "the panel's own fragment, host_usb_form.html, escaped by Django",
("instances/templates/instance.html", "hostDevices.innerHTML = html;"): "the panel's own fragment, host_devices_form.html, escaped by Django",
}

View file

@ -57,9 +57,11 @@ urlpatterns = [
path("<int:pk>/change_network/", views.change_network, name="change_network"),
path("<int:pk>/add_network/", views.add_network, name="add_network"),
path("<int:pk>/delete_network/", views.delete_network, name="delete_network"),
path("<int:pk>/host_usb/", views.host_usb, name="host_usb"),
path("<int:pk>/host_devices/", views.host_devices, name="host_devices"),
path("<int:pk>/add_usb_hostdev/", views.add_usb_hostdev, name="add_usb_hostdev"),
path("<int:pk>/delete_usb_hostdev/", views.delete_usb_hostdev, name="delete_usb_hostdev"),
path("<int:pk>/add_pci_hostdev/", views.add_pci_hostdev, name="add_pci_hostdev"),
path("<int:pk>/delete_pci_hostdev/", views.delete_pci_hostdev, name="delete_pci_hostdev"),
path("<int:pk>/set_link_state/", views.set_link_state, name="set_link_state"),
path("<int:pk>/set_qos/", views.set_qos, name="set_qos"),
path("<int:pk>/unset_qos/", views.unset_qos, name="unset_qos"),

View file

@ -49,7 +49,7 @@ from vrtManager import util
from vrtManager.create import cpu_choices, wvmCreate
from vrtManager.instance import USAGE_MAX_WINDOW
from vrtManager.interface import wvmInterface
from vrtManager.nodedevs import hostdev_problems, node_devices, usb_names
from vrtManager.nodedevs import device_names, hostdev_problems, node_devices, pci_problems
from vrtManager.storage import wvmStorage
from vrtManager.util import randomPasswd
@ -309,13 +309,15 @@ def _instance_page(request, pk, instance, compute):
instance.drbd = drbd_status(request, pk)
instance.save()
usb_hostdevs, other_hostdevs = instance.proxy.get_hostdevs()
if usb_hostdevs:
names = usb_names(instance.proxy)
usb_hostdevs, pci_hostdevs, other_hostdevs = instance.proxy.get_hostdevs()
if usb_hostdevs or pci_hostdevs:
names = device_names(instance.proxy)
for row in usb_hostdevs:
row["name"] = names.get((row["vendor"], row["product"]), "")
for row in pci_hostdevs:
row["name"] = names.get(row["address"], "")
# owners see the devices their VM has; only superusers change them
show_hostdevs = request.user.is_superuser or bool(usb_hostdevs or other_hostdevs)
show_hostdevs = request.user.is_superuser or bool(usb_hostdevs or pci_hostdevs or other_hostdevs)
show_settings = show_settings or show_hostdevs
job_resource = claims.instance_key(instance.uuid)
@ -1795,30 +1797,42 @@ def delete_network(request, pk):
return _back(request, pk, "network")
def _host_usb(instance):
"""The host's USB devices, each with why it cannot be given to the VM
(hostdev_problems; none: it can) and how the VM would name it: by its
address when the host has more than one of its model, else by model."""
_pci, usb = node_devices(instance.proxy)
def _host_devices(instance):
"""The host's PCI and USB devices, each with why it cannot be given to
the VM (pci_problems, hostdev_problems; none: it can). A USB device is
named by its address when the host has more than one of its model, else
by model; a PCI device is checked with the managed PCI devices of the
VM's next start, as libvirt takes them from the host along with it."""
pci, usb = node_devices(instance.proxy)
own_pci = instance.proxy.get_hostdevs()[1]
managed = {row["address"] for row in own_pci if row["managed"] and row["next"]}
by_address = {device["address"]: device for device in pci}
for device in pci:
device["problems"] = pci_problems(instance.name, device, by_address, managed | {device["address"]})
if device["address"] in {row["address"] for row in own_pci}:
device["problems"].append(_("The VM has it"))
for device in usb:
address = (device["bus"], device["device"]) if device["same_model"] else None
device["selector"] = (*device["model"], address)
device["problems"] = hostdev_problems(instance.name, [], [device], set(), [device["selector"]])
device["problems"] = hostdev_problems(instance.name, pci, [device], set(), [device["selector"]])
if instance.name in device["vms"]:
device["problems"].append(_("The VM has it"))
return usb
return pci, usb
@superuser_only
def host_usb(request, pk):
"""The add form of the Host devices tab, loaded when the tab opens: its
choices scan every VM of the host."""
def host_devices(request, pk):
"""The add forms of the Host devices tab, loaded when the tab opens:
their choices scan every VM of the host."""
instance = get_instance(request.user, pk)
try:
context = {"instance": instance, "host_usb": _host_usb(instance)}
pci, usb = _host_devices(instance)
domain = instance.proxy.instance
context = {"instance": instance, "host_pci": pci, "host_usb": usb,
"running": domain.isActive() or domain.hasManagedSaveImage(0)}
except libvirtError as err:
context = {"instance": instance, "error": err}
return render(request, "instances/host_usb_form.html", context)
return render(request, "instances/host_devices_form.html", context)
@require_POST
@ -1829,7 +1843,7 @@ def add_usb_hostdev(request, pk):
claim, so no other VM gets it meanwhile."""
instance = get_instance(request.user, pk)
chosen = request.POST.get("usb_device", "")
device = next((d for d in _host_usb(instance) if f"{d['bus']}/{d['device']}" == chosen), None)
device = next((d for d in _host_devices(instance)[1] if f"{d['bus']}/{d['device']}" == chosen), None)
if device is None:
messages.error(request, _("The host has no USB device %(device)s") % {"device": chosen})
elif device["problems"]:
@ -1841,6 +1855,36 @@ def add_usb_hostdev(request, pk):
return _back(request, pk, "hostdevs")
@require_POST
@superuser_only
@serialize_compute_mutation
def add_pci_hostdev(request, pk):
"""Gives the shut-off VM a PCI device of its host; checked under the
compute's claim, so no other VM gets it or its IOMMU group meanwhile."""
instance = get_instance(request.user, pk)
chosen = request.POST.get("pci_device", "")
device = next((d for d in _host_devices(instance)[0] if d["address"] == chosen), None)
if device is None:
messages.error(request, _("The host has no PCI device %(device)s") % {"device": chosen})
elif device["problems"]:
messages.error(request, "; ".join(device["problems"]))
else:
with audit_vm(request, instance, f"add PCI device {device['address']} ({device['product']})"):
instance.proxy.add_pci_hostdev(device["address"])
return _back(request, pk, "hostdevs")
@require_POST
@superuser_only
@serialize_instance_mutation
def delete_pci_hostdev(request, pk):
instance = get_instance(request.user, pk)
address = request.POST.get("address", "")
with audit_vm(request, instance, f"remove PCI device {address}"):
instance.proxy.delete_pci_hostdev(address)
return _back(request, pk, "hostdevs")
@require_POST
@superuser_only
@serialize_instance_mutation

View file

@ -14,7 +14,7 @@ from django.urls import reverse
from instances.models import Instance
from lxml import html
from vrtManager.connection import wvmConnect
from vrtManager.nodedevs import node_devices, pci_address
from vrtManager.nodedevs import hostdev_problems, node_devices, pci_address
def pci(slot, product, code, driver=None, group=None):
@ -71,6 +71,7 @@ def vm(name, hostdevs, saved=None):
return MagicMock(**{"name.return_value": name, "XMLDesc.side_effect": describe, "isPersistent.return_value": saved is not None})
PCI_01 = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x01' function='0x0'/></source></hostdev>"
PCI_0A = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x0a' function='0x0'/></source></hostdev>"
USB_1_3 = "<hostdev mode='subsystem' type='usb'><source><vendor id='0x0627'/><product id='0x0001'/><address bus='1' device='3'/></source></hostdev>"
USB_MODEL = "<hostdev mode='subsystem' type='usb'><source><vendor id='0x0627'/><product id='0x0001'/></source></hostdev>"
@ -106,6 +107,18 @@ class NodeDevicesTests(SimpleTestCase):
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_controller_holds_the_usb_devices_below_it(self):
pci_devices, (hub, tablet_2, _) = devices(domains=[vm("kiosk-1", USB_1_3)])
controller = [d for d in pci_devices if d["address"] == "0000:00:01.0"][0]
# its root hub is no device of the host's; the tablets are, one given to a VM
self.assertEqual(controller["resources"], ["USB device 1/2 (QEMU Tablet)", "USB device 1/3 (QEMU Tablet)"])
self.assertEqual((hub["controller"], tablet_2["controller"]), ("0000:00:01.0", "0000:00:01.0"))
def test_a_usb_device_on_a_controller_given_to_a_vm_is_not_given_to_another(self):
pci_devices, usb_devices = devices(domains=[vm("db-1", PCI_01)])
reasons = hostdev_problems("kiosk-1", pci_devices, usb_devices, set(), [("0x0627", "0x0001", (1, 2))])
self.assertIn("USB device 1/2 (QEMU Tablet) is on the PCI controller 0000:00:01.0, given to db-1", reasons)
def test_a_usb_device_is_named_as_libvirt_picks_one(self):
def named(hostdev):
_, (_, tablet_2, tablet_3) = devices(domains=[vm("v", hostdev)])

View file

@ -0,0 +1,134 @@
"""Which PCI devices of a host a VM can be given: an endpoint in an IOMMU
group, without the host's own network interfaces or disks on it, that no
other VM names; the other endpoints of its group are managed devices of the
same definition, or have no driver or vfio-pci, and no other VM has any of
them; bridges stay with pcieport. Each definition of a snapshot is checked
on its own. Node XML as libvirt 11.10 writes it on a q35 host with an
emulated IOMMU."""
from unittest.mock import MagicMock
from django.test import SimpleTestCase
from lxml import etree
from vrtManager.nodedevs import _pci, check_hostdevs, hostdev_problems, pci_problems
from vrtManager.util import OperationError
def device(slot, product, code, driver, group, members=None, bridge=False):
"""A PCI device on bus 0 at slot, in IOMMU group with the slots members (its own by default)."""
members = members or [slot]
addresses = "".join(f"<address domain='0x0000' bus='0x00' slot='0x{m:02x}' function='0x0'/>" for m in members)
driver_xml = f"<driver><name>{driver}</name></driver>" if driver else ""
bridge_xml = "<capability type='pci-bridge'/>" if bridge else ""
xml = (f"<device><name>pci_0000_00_{slot:02x}_0</name><parent>computer</parent>{driver_xml}"
f"<capability type='pci'><class>{code}</class><domain>0</domain><bus>0</bus><slot>{slot}</slot><function>0</function>"
f"<product id='0x0001'>{product}</product><vendor id='0x8086'>Intel</vendor>{bridge_xml}"
f"<iommuGroup number='{group}'>{addresses}</iommuGroup></capability></device>")
return f"pci_0000_00_{slot:02x}_0", etree.fromstring(xml)
NODES = dict([
device(0x02, "PCIe Root port", "0x060400", "pcieport", 2, bridge=True),
device(0x03, "SATA controller", "0x010601", "ahci", 7, [0x03, 0x04, 0x05]),
device(0x04, "SMBus controller", "0x0c0500", "i801_smbus", 7, [0x03, 0x04, 0x05]),
device(0x05, "LPC controller", "0x060100", "lpc_ich", 7, [0x03, 0x04, 0x05]), # class 0x06, not a bridge
device(0x06, "Gigabit NIC", "0x020000", "e1000e", 12),
device(0x07, "Spare NIC", "0x020000", "vfio-pci", 13),
device(0x08, "NIC port 0", "0x020000", "vfio-pci", 14, [0x08, 0x09, 0x0a]),
device(0x09, "NIC port 1", "0x020000", None, 14, [0x08, 0x09, 0x0a]),
device(0x0a, "Downstream port", "0x060400", "pcieport", 14, [0x08, 0x09, 0x0a], bridge=True),
device(0x0b, "Accelerator", "0x120000", "vfio-pci", 15, [0x0b, 0x0c]), # 0x0c cannot be read
device(0x0d, "GPU", "0x030000", "nouveau", 16, [0x0d, 0x0e]),
device(0x0e, "GPU audio", "0x040300", "snd_hda_intel", 16, [0x0d, 0x0e]),
])
# what the host has below: a disk on the SATA controller, its interface on the e1000e
NODES["block_sda"] = etree.fromstring("<device><name>block_sda</name><parent>pci_0000_00_03_0</parent>"
"<capability type='storage'><block>/dev/sda</block></capability></device>")
NODES["net_eth1"] = etree.fromstring("<device><name>net_eth1</name><parent>pci_0000_00_06_0</parent>"
"<capability type='net'><interface>eth1</interface></capability></device>")
def inventory(vms=()):
"""The host's PCI devices, as a list; vms: (VM, its PCI addresses)."""
children = {}
for name, node in NODES.items():
children.setdefault(node.findtext("parent"), []).append(name)
users = [(vm, set(addresses), []) for vm, addresses in vms]
return [_pci(name, node, NODES, children, users) for name, node in NODES.items()
if node.find("capability").get("type") == "pci"]
def problems(address, managed=(), vms=()):
devices = {d["address"]: d for d in inventory(vms)}
return " ".join(pci_problems("web-1", devices[address], devices, {address, *managed}))
def hostdev(slot, managed="yes"):
return (f"<hostdev mode='subsystem' type='pci' managed='{managed}'><source>"
f"<address domain='0x0000' bus='0x00' slot='0x{slot:02x}' function='0x0'/></source></hostdev>")
class PciRulesTests(SimpleTestCase):
def test_a_lone_endpoint_bound_to_vfio_pci_can_be_given(self):
self.assertEqual(problems("0000:00:07.0"), "")
def test_a_device_the_host_uses_can_not_and_how_to_free_it(self):
reason = problems("0000:00:06.0")
self.assertIn("holds the host's eth1", reason)
self.assertIn("bind 0000:00:06.0 to vfio-pci on the host first", reason)
def test_a_bridge_of_any_kind_can_not(self):
# a PCIe port, and of the bridge class an LPC controller (a host bridge alike)
self.assertEqual(problems("0000:00:02.0"), "PCI device 0000:00:02.0 is a bridge")
self.assertEqual(problems("0000:00:05.0"), "PCI device 0000:00:05.0 is a bridge")
def test_the_other_endpoints_of_its_group_must_be_free_of_the_host(self):
# the LPC controller is of class 0x06 but no bridge: its driver keeps the group
reason = problems("0000:00:04.0")
self.assertIn("with 0000:00:03.0 (SATA controller), which the host (driver ahci) uses", reason)
self.assertIn("with 0000:00:05.0 (LPC controller), which the host (driver lpc_ich) uses", reason)
def test_unbound_and_vfio_pci_members_and_a_pcieport_bridge_keep_nothing(self):
self.assertEqual(problems("0000:00:08.0"), "")
def test_a_member_given_as_a_managed_device_too_is_taken_with_it(self):
self.assertIn("with 0000:00:0e.0 (GPU audio), which the host (driver snd_hda_intel) uses", problems("0000:00:0d.0"))
self.assertEqual(problems("0000:00:0d.0", managed={"0000:00:0e.0"}), "")
def test_a_member_another_vm_has_or_that_cannot_be_read_keeps_the_group(self):
self.assertIn("with 0000:00:09.0 (NIC port 1), which db-1 uses",
problems("0000:00:08.0", vms=[("db-1", {"0000:00:09.0"})]))
# managed in this definition or not: another VM has it
self.assertIn("which db-1 uses", problems("0000:00:0d.0", managed={"0000:00:0e.0"}, vms=[("db-1", {"0000:00:0e.0"})]))
self.assertIn("with 0000:00:0c.0, which cannot be read", problems("0000:00:0b.0"))
def test_a_device_another_vm_has_can_not(self):
self.assertIn("is given to db-1", problems("0000:00:07.0", vms=[("db-1", {"0000:00:07.0"})]))
self.assertEqual(problems("0000:00:07.0", vms=[("web-1", {"0000:00:07.0"})]), "") # its own
def test_a_device_the_host_does_not_have_can_not(self):
reasons = hostdev_problems("web-1", inventory(), [], {"0000:00:1f.0"}, [])
self.assertEqual(reasons, ["PCI device 0000:00:1f.0 is not on the host"])
class DefinitionsTests(SimpleTestCase):
"""A revert restores two definitions; the VM starts from one of them."""
def check(self, *devices_per_definition):
conn = MagicMock()
conn.wvm.listAllDevices.return_value = [MagicMock(**{"name.return_value": name,
"XMLDesc.return_value": etree.tostring(node)})
for name, node in NODES.items()]
conn.domain_trees.return_value = []
trees = [etree.fromstring(f"<domain><devices>{''.join(devices)}</devices></domain>") for devices in devices_per_definition]
check_hostdevs(conn, "web-1", trees)
def test_each_definition_takes_its_group_on_its_own(self):
self.check([hostdev(0x0d), hostdev(0x0e)])
# the running definition has the GPU, only the saved one its audio function
with self.assertRaisesRegex(OperationError, "GPU audio"):
self.check([hostdev(0x0d)], [hostdev(0x0e)])
def test_a_member_libvirt_does_not_manage_keeps_its_host_driver(self):
with self.assertRaisesRegex(OperationError, "which the host \\(driver snd_hda_intel\\) uses"):
self.check([hostdev(0x0d), hostdev(0x0e, managed="no")])

View file

@ -70,6 +70,7 @@ from vrtManager.storage import wvmStorage, wvmStorages
PERSISTENT_XML = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE
USB_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='usb']"
PCI_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='pci']"
# what of the host a VM holds: host devices, and NICs that are host devices
HOST_DEVICES = "devices/hostdev | devices/interface[@type='hostdev'] | devices/interface/actual[@type='hostdev']"
@ -2389,25 +2390,31 @@ class wvmInstance(wvmConnect):
return [(element, usb_selector(mark.find("source"))) for element, mark in zip(elements, marked)]
def get_hostdevs(self):
"""The VM's USB host devices, each once: its selector (vendor,
product, address), whether the running VM has it and the device it
holds there, whether its next start has it. And its other host
devices (PCI, SCSI, mediated, NICs that are host devices), by type
and PCI address, likewise: the panel does not change them."""
usb = {}
"""The VM's host devices, each once, with whether the running VM has
it ("now") and whether its next start has it ("next"): USB devices
by selector (vendor, product, address) and the device the running
VM holds; PCI devices by address, and whether libvirt manages them
(takes them from their host drivers); the others (SCSI, mediated, NICs
that are host devices) by type and address, which the panel does
not change."""
usb, pci, others = {}, {}, {}
for xml_flags, affect in self._definitions():
where = "now" if affect == VIR_DOMAIN_AFFECT_LIVE else "next"
for element, selector in self._usb_hostdevs(xml_flags):
row = usb.setdefault(selector, {"vendor": selector[0], "product": selector[1], "address": selector[2],
"now": None, "next": False})
if affect == VIR_DOMAIN_AFFECT_LIVE:
if where == "now":
held = element.find("source/address")
row["now"] = (int(held.get("bus")), int(held.get("device"))) if held is not None else selector[2]
else:
row["next"] = True
others = {}
for xml_flags, affect in self._definitions():
tree = etree.fromstring(self._XMLDesc(xml_flags))
for device in tree.xpath("devices/hostdev[not(@type='usb')]"
for address in tree.xpath(PCI_HOSTDEVS + "/source/address"):
key = pci_address(address.get("domain", "0"), address.get("bus"), address.get("slot"), address.get("function"))
row = pci.setdefault(key, {"address": key, "now": False, "next": False, "managed": False})
row[where] = True
row["managed"] = row["managed"] or address.getparent().getparent().get("managed") == "yes"
for device in tree.xpath("devices/hostdev[not(@type='usb' or @type='pci')]"
" | devices/interface[@type='hostdev' or actual/@type='hostdev']"):
if device.tag == "interface" and device.get("type") != "hostdev":
source = device.find("actual/source") # a NIC of a network that gives host devices
@ -2423,8 +2430,42 @@ class wvmInstance(wvmConnect):
source_text = " ".join(etree.tostring(source, encoding="unicode").split()) if source is not None else ""
row = others.setdefault((kind, label or source_text),
{"type": kind, "address": label, "now": False, "next": False})
row["now" if affect == VIR_DOMAIN_AFFECT_LIVE else "next"] = True
return list(usb.values()), list(others.values())
row[where] = True
return list(usb.values()), list(pci.values()), list(others.values())
def _pci_hostdev(self, address):
"""The persistent definition's <hostdev> of the PCI device at address, or None."""
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
for found in tree.xpath(PCI_HOSTDEVS + "/source/address"):
if pci_address(found.get("domain", "0"), found.get("bus"), found.get("slot"), found.get("function")) == address:
return found.getparent().getparent()
return None
def _check_shut_off_for_pci(self):
"""PCI devices change in the persistent definition of a shut-off VM;
a saved state (managed save) would start the VM without the change."""
if self.instance.isActive() or self.instance.hasManagedSaveImage(0):
raise util.OperationError("Shut the VM down, without a saved state, to change its PCI devices")
def add_pci_hostdev(self, address):
"""Gives the shut-off VM the host's PCI device at address
(0000:05:00.0): libvirt binds it to vfio-pci when the VM starts and
gives it back to the host when it stops."""
self._check_shut_off_for_pci()
domain, bus, slot_function = address.split(":")
slot, function = slot_function.split(".")
hostdev = etree.Element("hostdev", mode="subsystem", type="pci", managed="yes")
etree.SubElement(etree.SubElement(hostdev, "source"), "address", domain=f"0x{domain}", bus=f"0x{bus}",
slot=f"0x{slot}", function=f"0x{function}")
self.instance.attachDeviceFlags(etree.tostring(hostdev, encoding="unicode"), VIR_DOMAIN_AFFECT_CONFIG)
def delete_pci_hostdev(self, address):
"""Takes the PCI device at address from the shut-off VM, also one the host no longer has."""
self._check_shut_off_for_pci()
hostdev = self._pci_hostdev(address)
if hostdev is None:
raise util.OperationError(f"The VM has no PCI device {address}")
self.instance.detachDeviceFlags(etree.tostring(hostdev, encoding="unicode"), VIR_DOMAIN_AFFECT_CONFIG)
def add_usb_hostdev(self, vendor, product, address=None):
"""Gives the VM the host's USB device of vendor and product, at

View file

@ -1,5 +1,6 @@
from django.utils.translation import gettext_lazy as _
from libvirt import VIR_CONNECT_LIST_NODE_DEVICES_CAP_USB_DEV, VIR_ERR_NO_NODE_DEVICE, libvirtError
from libvirt import (VIR_CONNECT_LIST_NODE_DEVICES_CAP_PCI_DEV, VIR_CONNECT_LIST_NODE_DEVICES_CAP_USB_DEV,
VIR_ERR_NO_NODE_DEVICE, libvirtError)
from lxml import etree
from vrtManager import util
@ -40,10 +41,17 @@ def pci_address(domain, bus, slot, function):
return f"{domain:04x}:{bus:02x}:{slot:02x}.{function:x}"
def usb_hub(name, nodes, children):
"""True when the USB device named name is a hub (an interface of class 9)."""
return any(nodes[child].findtext("capability/class") == str(USB_HUB) for child in children.get(name, [])
if nodes[child].find("capability").get("type") == "usb")
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."""
network interfaces, block devices, graphics cards and USB devices (a
keyboard, or one given to a VM), 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")
@ -53,6 +61,8 @@ def host_resources(name, nodes, children):
found.append(cap.findtext("block"))
elif cap.get("type") == "drm" and cap.findtext("type") == "primary":
found.append(nodes[child].xpath("string(devnode[@type='dev'])"))
elif cap.get("type") == "usb_device" and not usb_hub(child, nodes, children):
found.append(f"USB device {cap.findtext('bus')}/{cap.findtext('device')} ({cap.findtext('product')})")
found.extend(host_resources(child, nodes, children))
return found
@ -105,15 +115,58 @@ def device_problems(label, vms, resources, vm):
return problems
def hostdev_problems(vm, pci, usb, wanted_pci, wanted_usb):
"""Why the host's devices pci and usb (get_node_devices) that the VM
named vm wants (hostdev_users) cannot be given to it: another VM names
them too, the host has its own network interfaces, disks or graphics
card on them, or they are USB hubs."""
problems = []
def managed_pci(tree):
"""The PCI addresses a VM's definition gives it as managed host devices:
libvirt takes them from their host drivers when the VM starts."""
return {pci_address(a.get("domain", "0"), a.get("bus"), a.get("slot"), a.get("function"))
for a in tree.xpath("devices/hostdev[@mode='subsystem'][@type='pci'][@managed='yes']/source/address")}
def pci_problems(vm, device, by_address, managed):
"""Why the host's PCI device (get_node_devices) cannot be given to the VM
named vm, whose definition gives it the managed PCI devices at the
addresses managed: by_address holds every PCI device of the host. Its
IOMMU group goes to the VM as a whole, so each other endpoint in it must
be one of managed, or have no driver or vfio-pci; no other VM may have
any of them; bridges stay with the pcieport driver or none. Nothing in
the group is taken implicitly."""
label = f"PCI device {device['address']}"
# a bridge of any kind (a host bridge, a PCIe port) is the host's
if device["bridge"] or device["bridge_class"]:
return [f"{label} is a bridge"]
problems = device_problems(label, device["vms"], device["resources"], vm)
if device["resources"]:
problems.append(f"bind {device['address']} to vfio-pci on the host first if it is not needed there")
if device["group"] is None:
return problems + [f"{label} is in no IOMMU group"]
shares = f"{label} shares IOMMU group {device['group']} with"
for member in device["group_members"]:
if member == device["address"]:
continue
other = by_address.get(member)
if other is None:
problems.append(f"{shares} {member}, which cannot be read")
elif others := sorted(set(other["vms"]) - {vm}):
problems.append(f"{shares} {member} ({other['product']}), which {', '.join(others)} uses")
elif other["bridge"]:
if other["driver"] not in ("", "pcieport"):
problems.append(f"{shares} the bridge {member} (driver {other['driver']})")
elif member not in managed and other["driver"] not in ("", "vfio-pci"):
problems.append(f"{shares} {member} ({other['product']}), which the host (driver {other['driver']}) uses")
return problems
def hostdev_problems(vm, pci, usb, wanted_pci, wanted_usb, managed=frozenset()):
"""Why the host's devices pci and usb (get_node_devices) that a definition
of the VM named vm wants (hostdev_users; managed_pci) cannot be given to
it: a PCI device the host does not have, pci_problems; for USB ones,
another VM names them or their PCI controller too, the host has its own
network interfaces or disks on them, or they are hubs."""
by_address = {device["address"]: device for device in pci}
problems = [f"PCI device {address} is not on the host" for address in sorted(wanted_pci - by_address.keys())]
for device in pci:
if device["address"] in wanted_pci:
problems += device_problems(f"PCI device {device['address']}", device["vms"], device["resources"], vm)
problems += pci_problems(vm, device, by_address, managed)
for device in usb:
address = (device["bus"], device["device"])
if any(usb_named(selector, device["model"], address) for selector in wanted_usb):
@ -121,6 +174,9 @@ def hostdev_problems(vm, pci, usb, wanted_pci, wanted_usb):
problems += device_problems(label, device["vms"] + device["by_model"], device["resources"], vm)
if device["hub"]:
problems.append(f"{label} is a hub")
controller = by_address.get(device["controller"])
if controller and (holders := sorted(set(controller["vms"]) - {vm})):
problems.append(f"{label} is on the PCI controller {controller['address']}, given to {', '.join(holders)}")
return problems
@ -139,6 +195,13 @@ def _pci(name, node, nodes, children, vms):
"kind": PCI_CLASSES.get(code >> 16, _("Other")),
"driver": node.findtext("driver/name") or "",
"group": int(group) if group else None,
"group_members": [pci_address(a.get("domain"), a.get("bus"), a.get("slot"), a.get("function"))
for a in cap.xpath("iommuGroup/address")],
# a bridge header (a PCIe port too), as against class 0x06 functions such as an LPC controller
"bridge": cap.find("capability[@type='pci-bridge']") is not None
or cap.find("capability[@type='cardbus-bridge']") is not None or node.findtext("driver/name") == "pcieport",
# of the bridge class: a host bridge or an LPC controller too
"bridge_class": code >> 16 == 0x06,
"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"))
@ -147,7 +210,6 @@ def _pci(name, node, nodes, children, vms):
}
def _usb(name, node, nodes, children, vms):
cap = node.find("capability")
bus, device = int(cap.findtext("bus")), int(cap.findtext("device"))
@ -155,6 +217,11 @@ def _usb(name, node, nodes, children, vms):
# 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"]
# the PCI controller it is on: a VM given that controller takes it too
parent = node.findtext("parent")
while parent in nodes and nodes[parent].find("capability").get("type") != "pci":
parent = nodes[parent].findtext("parent")
controller = nodes[parent].find("capability") if parent in nodes else None
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 {
@ -166,6 +233,8 @@ def _usb(name, node, nodes, children, vms):
"ids": f"{model[0][2:]}:{model[1][2:]}",
"kinds": sorted({str(USB_CLASSES.get(code, _("Other"))) for code in codes}),
"hub": USB_HUB in codes,
"controller": pci_address(controller.findtext("domain"), controller.findtext("bus"), controller.findtext("slot"),
controller.findtext("function")) if controller is not None else "",
"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
@ -203,27 +272,36 @@ def node_devices(conn):
return pci, usb
def usb_names(conn):
"""The product names of the host's USB devices, by their (vendor, product)
IDs: one listing of the USB devices, one XML read each; none when they
cannot be read, as the names are only shown."""
def device_names(conn):
"""The product names of the host's USB devices, by their (vendor,
product) IDs, and of its PCI devices, by address: one listing of each,
one XML read per device; none when they cannot be read, as the names
are only shown."""
names = {}
try:
for device in conn.wvm.listAllDevices(VIR_CONNECT_LIST_NODE_DEVICES_CAP_USB_DEV):
cap = etree.fromstring(device.XMLDesc(0)).find("capability")
model = (cap.xpath("string(vendor/@id)").lower(), cap.xpath("string(product/@id)").lower())
names[model] = cap.findtext("product") or ""
except libvirtError: # a device gone meanwhile, or the host: the IDs are shown
for flag in (VIR_CONNECT_LIST_NODE_DEVICES_CAP_USB_DEV, VIR_CONNECT_LIST_NODE_DEVICES_CAP_PCI_DEV):
for device in conn.wvm.listAllDevices(flag):
cap = etree.fromstring(device.XMLDesc(0)).find("capability")
if cap.get("type") == "pci":
key = pci_address(cap.findtext("domain"), cap.findtext("bus"), cap.findtext("slot"), cap.findtext("function"))
else:
key = (cap.xpath("string(vendor/@id)").lower(), cap.xpath("string(product/@id)").lower())
names[key] = cap.findtext("product") or ""
except libvirtError: # a device gone meanwhile, or the host: the IDs and addresses are shown
return {}
return names
def check_hostdevs(conn, vm, trees):
"""Refuses the host devices the definitions trees give the VM named vm,
on the host of conn, when they cannot be (hostdev_problems)."""
wanted = hostdev_users(trees)
if not any(wanted):
on the host of conn, when they cannot be (hostdev_problems): each
definition on its own, as the VM starts from one of them."""
wanted = [(tree, hostdev_users([tree])) for tree in trees]
if not any(pci or usb for _, (pci, usb) in wanted):
return
pci, usb = node_devices(conn)
if problems := hostdev_problems(vm, pci, usb, *wanted):
raise util.OperationError("; ".join(problems))
problems = []
for tree, (wanted_pci, wanted_usb) in wanted:
problems += hostdev_problems(vm, pci, usb, wanted_pci, wanted_usb, managed_pci(tree))
if problems:
raise util.OperationError("; ".join(dict.fromkeys(problems)))