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

feat(instances): USB devices of the host given to a VM

- A Host devices tab on the VM page lists the VM's USB host devices, each
  once: how it is chosen (its address, or any device of its model), the
  device the running VM holds and whether its next start has it; its other
  host devices (PCI, SCSI, mediated) are listed for the XML editor.
- Superusers add a USB device of the host and take it back, on a running
  VM too. The add form is loaded when the tab opens, as its choices read
  every VM of the host; a hub, a device the host has its own disks or
  network interfaces on, or one another VM names is not offered, with the
  reason, and refused on the server under the compute's claim. A device is
  chosen by model when the host has one of it, else by its address.
- A VM without a USB controller is refused; a device is taken from each
  definition as that definition describes it.
- A clone gets none of the VM's host devices (the clone form says so), and
  a VM with host devices is not migrated. A snapshot revert is refused when
  it would give the VM a device another VM names or the host uses.
- A NIC that is a host device is no longer edited or removed from the
  Network tab: the XML editor changes it.
This commit is contained in:
catborise 2026-10-29 13:16:45 +03:00
parent 0977aef587
commit ac8ff62e7e
20 changed files with 679 additions and 94 deletions

View file

@ -11,10 +11,12 @@ from django.utils.translation import gettext_noop
from django.utils.translation import gettext_noop as _
from libvirt import (VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, VIR_DOMAIN_JOB_NONE, VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL,
VIR_ERR_NO_DOMAIN, VIR_ERR_NO_STORAGE_VOL, libvirtError)
from lxml import etree
from vrtManager import util
from vrtManager.connection import wvmConnect
from vrtManager.create import cpu_choices, wvmCreate
from vrtManager.instance import check_snapshot_name, wvmInstance
from vrtManager.instance import HOST_DEVICES, check_snapshot_name, wvmInstance
from vrtManager.nodedevs import check_hostdevs
from accounts.models import UserInstance, UserSSHKey
from appsettings.settings import app_settings
@ -349,6 +351,14 @@ def _current(proxy):
return dom.snapshotCurrent(0).getName() if dom.hasCurrentSnapshot(0) else None
def _snapshot_definitions(proxy, params):
"""The VM's definitions in the snapshot a revert restores (as it runs,
and as saved), and whether they give it host devices."""
tree = etree.fromstring(proxy._snapshot(params["name"], params["external"]).getXMLDesc(0))
definitions = tree.xpath("domain | inactiveDomain")
return any(definition.xpath(HOST_DEVICES) for definition in definitions), definitions
@register
class RevertSnapshot(Kind):
name = "snapshot.revert"
@ -363,14 +373,20 @@ class RevertSnapshot(Kind):
_proxy(_authorized(user, params["instance"]))._snapshot(params["name"], params["external"])
def plan(self, user, params):
return _vm_plan(params)
plan = _vm_plan(params)
# a revert that gives the VM host devices holds the whole compute: no other VM takes them meanwhile
if _snapshot_definitions(_proxy(_instance(params["instance"])), params)[0]:
plan.claims[claims.compute_key(plan.intent["compute"])] = Claim.EXCLUSIVE
return plan
def precheck(self, op, ctx):
proxy = _checked_proxy(op)
if op.params["external"]:
proxy.check_external_revert(op.params["name"])
else:
proxy._snapshot(op.params["name"], False)
# a revert gives the VM the host devices it had then: not one given since to another VM
has_hostdevs, definitions = _snapshot_definitions(proxy, op.params)
if has_hostdevs:
check_hostdevs(proxy, proxy.instance.name(), definitions)
def run(self, op, ctx):
proxy = _proxy(_instance(op.params["instance"]))

View file

@ -428,6 +428,22 @@
});
</script>
<script>
// the Host devices tab's add form: loaded once, when the tab first opens
var hostUsb = document.getElementById("host-usb");
if (hostUsb) {
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 (!response.ok) throw new Error(response.statusText);
return response.text();
}).then(function (html) {
hostUsb.innerHTML = html;
}).catch(function () {
hostUsb.textContent = "{{ _("The host's USB devices could not be read.")|escapejs }}";
});
});
}
function get_osinfo() {
show_tab('#settings');
show_tab('#osinfo');

View file

@ -0,0 +1,15 @@
{% 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

@ -38,6 +38,13 @@
</button>
</li>
{% endif %}
{% if show_hostdevs %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#hostdevs" type="button" role="tab" aria-controls="hostdevs" aria-selected="false">
{% trans "Host devices" %}
</button>
</li>
{% endif %}
{% if can_clone %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#clone" type="button" role="tab" aria-controls="clone" aria-selected="false">
@ -395,6 +402,8 @@
</div>
</td>
<td>
{# a NIC that is a host device is changed in the XML editor #}
{% if network.type != "hostdev" %}
<form action="{% url 'instances:change_network' instance.id %}" method="post" name="edit_network{{ forloop.counter0 }}" role="form">{% csrf_token %}
<button class="btn btn-sm btn-primary"
type="button"
@ -467,6 +476,7 @@
</div>
</div>
</form>
{% endif %}
</td>
<td>
<form action="{% url 'instances:set_qos' instance.id %}" method="post" name="add_qos{{ forloop.counter0 }}" role="form" aria-label="Add network qos form">
@ -476,12 +486,14 @@
</form>
</td>
<td>
{% if network.type != "hostdev" %}
<form action="{% url 'instances:delete_network' instance.id %}" method="post" name="delete_network" role="form">{% csrf_token %}
<button class="btn btn-sm btn-danger" value="{{ network.mac }}" name="delete_network" title="{% trans "Delete Device" %}"
data-confirm="{% blocktrans with mac=network.mac %}Remove network card {{ mac }}? A running VM loses its connection through it at once.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
</form>
{% endif %}
</td>
</tr>
<tr>
@ -582,6 +594,63 @@
{% endif %}
<div class="clearfix"></div>
</div>
{% if show_hostdevs %}
<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>
</div>
{% endif %}
{% if usb_hostdevs %}
<div class="table-responsive position-relative">
<table class="table align-middle">
<thead>
<tr>
<th scope="col">{% trans "USB device" %}</th>
<th scope="col">{% trans "Chosen by" %}</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 usb_hostdevs %}
<tr>
<td>{{ device.name|default:"–" }} <span class="small text-body-secondary font-monospace">{{ device.vendor|slice:"2:" }}:{{ device.product|slice:"2:" }}</span></td>
<td>{% if device.address %}{% blocktrans with bus=device.address.0 device=device.address.1 %}Its address {{ bus }}/{{ device }}{% endblocktrans %}{% else %}{% trans "Its model: any device of it" %}{% endif %}</td>
<td>{% if device.now %}{% blocktrans with bus=device.now.0 device=device.now.1 %}Has {{ bus }}/{{ device }}{% endblocktrans %}{% 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_usb_hostdev' instance.id %}">{% csrf_token %}
<input type="hidden" name="vendor" value="{{ device.vendor }}">
<input type="hidden" name="product" value="{{ device.product }}">
<input type="hidden" name="address" value="{% if device.address %}{{ device.address.0 }}/{{ device.address.1 }}{% endif %}">
<button type="submit" class="btn btn-sm btn-outline-danger" aria-label="{% trans "Remove the USB device" %}"
data-confirm="{% trans "Remove this USB device from the VM? A running VM loses it at once." %}"
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 %}
<p class="small text-body-secondary mb-0">{% trans "The VM has no host devices." %}</p>
{% endif %}
</div>
{% endif %}
<div role="tabpanel" class="tab-pane border border-top-0 p-3" id="migrate">
<p>{% trans "For migration both host servers must have equal settings and OS type" %}</p>
<form action="{% url 'instances:migrate' instance.id %}" class="ms-3 form" method="post" role="form" aria-label="Migrate instance form">
@ -739,6 +808,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 %}
<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,6 +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_status.return_value = 5
response = self.client.get(reverse("instances:instance", args=[self.vm.id]))
form = response.context["console_form"]

View file

@ -61,6 +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_storages.return_value = []
proxy.get_console_passwd.return_value = VNC_PASSWORD
proxy.get_console_type.return_value = "vnc"

View file

@ -0,0 +1,230 @@
"""A VM's host devices: USB devices of its host given and taken back, each
named once whether the running VM or its next start has it; the checks that
keep a device to one VM and away from what the host uses itself, also for a
snapshot revert; clones get none, and a VM with any is not migrated."""
import unittest
from unittest.mock import MagicMock, patch
import libvirt
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
from instances.models import Instance
from lxml import etree, html
from nodedevs.tests.test_nodedevs import NODES, node
from vrtManager.instance import wvmInstance
from vrtManager.util import OperationError
from vrtManager.tests.test_migration import FakeSource, destination, with_disks
MIGRATABLE = libvirt.VIR_DOMAIN_XML_MIGRATABLE
CONTROLLER = "<controller type='usb' model='qemu-xhci'/>"
BY_MODEL = "<hostdev mode='subsystem' type='usb' managed='yes'><source{auto}><vendor id='0x0627'/><product id='0x0001'/>{address}</source></hostdev>"
def by_model(address="", auto=""):
return BY_MODEL.format(address=address, auto=auto)
def domain(*devices):
return f"<domain><name>kiosk-1</name><devices>{''.join(devices)}</devices></domain>"
def vm(running, saved, active=True, migratable=None):
"""A wvmInstance whose running VM is running, as XMLDesc gives it (and as
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})
def describe(flags=0):
if flags & libvirt.VIR_DOMAIN_XML_INACTIVE:
return saved
return migratable if flags & MIGRATABLE and migratable else running
proxy.instance.XMLDesc.side_effect = describe
return proxy
class UsbHostdevTests(SimpleTestCase):
def test_a_device_chosen_by_model_is_one_row_with_the_device_it_holds(self):
# 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()
self.assertEqual(usb, [{"vendor": "0x0627", "product": "0x0001", "address": None, "now": (1, 3), "next": True}])
self.assertEqual(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()),
migratable=domain(CONTROLLER, by_model("<address bus='1' device='3'/>", " autoAddress='yes'")))
proxy.delete_usb_hostdev(("0x0627", "0x0001", None))
live, config = proxy.instance.detachDeviceFlags.call_args_list
self.assertEqual(live.args[1], libvirt.VIR_DOMAIN_AFFECT_LIVE)
self.assertEqual(etree.fromstring(live.args[0]).xpath("string(source/address/@device)"), "3")
self.assertEqual((config.args[1], etree.fromstring(config.args[0]).find("source/address")),
(libvirt.VIR_DOMAIN_AFFECT_CONFIG, None))
with self.assertRaisesRegex(OperationError, "no such USB device"):
proxy.delete_usb_hostdev(("0x0627", "0x0001", (1, 2)))
def test_a_device_is_given_by_model_or_by_address_with_a_usb_controller(self):
proxy = vm(domain(CONTROLLER), domain(CONTROLLER))
proxy.add_usb_hostdev("0x0627", "0x0001", (1, 3))
added = etree.fromstring(proxy.instance.attachDeviceFlags.call_args.args[0])
self.assertEqual((added.get("type"), added.xpath("string(source/vendor/@id)"), added.xpath("string(source/address/@device)")),
("usb", "0x0627", "3"))
for xml in (domain(), domain("<controller type='usb' model='none'/>")):
proxy = vm(xml, xml, active=False)
with self.subTest(xml=xml), self.assertRaisesRegex(OperationError, "no USB controller"):
proxy.add_usb_hostdev("0x0627", "0x0001")
proxy.instance.attachDeviceFlags.assert_not_called()
def test_a_device_the_host_has_one_of_is_given_by_model(self):
proxy = vm(domain(CONTROLLER), domain(CONTROLLER))
proxy.add_usb_hostdev("0x0627", "0x0001")
self.assertIsNone(etree.fromstring(proxy.instance.attachDeviceFlags.call_args.args[0]).find("source/address"))
def test_other_host_devices_as_the_running_vm_and_its_next_start_have_them(self):
pci = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x0a' function='0x0'/></source></hostdev>"
nic = "<interface type='hostdev'><source><address type='pci' domain='0x0000' bus='0x00' slot='0x0b' function='0x0'/></source></interface>"
network_nic = ("<interface type='network'><source network='sriov'/><actual type='hostdev'><source>"
"<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},
{"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_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))
with self.assertRaisesRegex(OperationError, "XML editor"):
proxy.delete_network("52:54:00:00:00:0a")
with self.assertRaisesRegex(OperationError, "XML editor"):
proxy.change_network("52:54:00:00:00:0a", "52:54:00:00:00:0b", "default", "net", "virtio", "")
proxy.instance.detachDeviceFlags.assert_not_called()
def test_a_clone_gets_no_host_device_and_its_nics_keep_their_numbers(self):
proxy = vm("", domain(by_model(),
"<interface type='hostdev'><mac address='52:54:00:00:00:0a'/></interface>",
"<interface type='network'><mac address='52:54:00:00:00:0b'/><source network='default'/></interface>"))
proxy.get_instances = lambda: []
tree, _ = proxy._clone_definition({"name": "kiosk-2", "clone-net-mac-1": "52:54:00:00:00:1b"},
"99999999-2222-3333-4444-555555555555")
self.assertEqual(tree.xpath("devices/hostdev"), [])
self.assertEqual(tree.xpath("devices/interface/mac/@address"), ["52:54:00:00:00:1b"])
class MigrationTests(unittest.TestCase):
def test_a_vm_with_host_devices_is_not_migrated(self):
for devices, state in ((by_model(), 1), (by_model(), 5), ("<hostdev mode='subsystem' type='pci'/>", 1)):
source = FakeSource(state, xml=with_disks(devices))
with self.subTest(state=state), self.assertRaisesRegex(OperationError, "host devices cannot be migrated"):
destination().moveto(source, "vm", live=state == 1, unsafe=False, offline=state == 5)
self.assertEqual(source.instance.calls, [])
class HostUsbViewTests(TestCase):
def setUp(self):
self.compute = Compute.objects.create(name="hd", hostname="192.0.2.31", login="root", password="", type=1)
self.instance = Instance.objects.create(compute=self.compute, name="kiosk-1", uuid="00000000-0000-4000-8000-0000000000e1")
self.client.force_login(get_user_model().objects.create_superuser("hd", "hd@example.com", "pw"))
patcher = patch("instances.models.wvmInstance")
self.proxy = patcher.start().return_value
self.addCleanup(patcher.stop)
# the host of the node device fixture; another VM holds tablet 1/3 by address
other = MagicMock(**{"name.return_value": "db-1", "isPersistent.return_value": False,
"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())])]
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!='']")}
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)
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):
url = reverse("instances:add_usb_hostdev", args=[self.instance.id])
response = self.client.post(url, {"usb_device": "1/2"})
self.assertEqual(response.status_code, 302)
self.proxy.add_usb_hostdev.assert_called_once_with("0x0627", "0x0001", (1, 2))
# refused on the server too: another VM's device, a hub
for chosen, why in (("1/3", "is given to db-1"), ("1/1", "is a hub"), ("9/9", "has no USB device")):
response = self.client.post(url, {"usb_device": chosen})
with self.subTest(chosen=chosen):
self.assertIn(why, " ".join(str(m) for m in get_messages(response.wsgi_request)))
self.assertEqual(self.proxy.add_usb_hostdev.call_count, 1)
def test_a_device_is_given_by_model_when_the_host_has_one_of_it(self):
self.proxy.wvm.listAllDevices.return_value = [node(*n) for n in NODES if n[0] not in ("usb_1_2", "usb_1_2_1_0")]
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_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": ""})
self.client.post(url, {"vendor": "0x0627", "product": "0x0001", "address": "1/3"})
self.assertEqual([c.args[0] for c in self.proxy.delete_usb_hostdev.call_args_list],
[("0x0627", "0x0001", None), ("0x0627", "0x0001", (1, 3))])
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.client.post(reverse("instances:add_usb_hostdev", args=[self.instance.id]), {"usb_device": "1/2"})
self.proxy.add_usb_hostdev.assert_not_called()
class RevertTests(TestCase):
"""A revert gives the VM the host devices of the snapshot, as it ran and
as saved: not one another VM names; and it holds the compute meanwhile."""
def setUp(self):
from instances.jobs import RevertSnapshot
self.kind = RevertSnapshot()
compute = Compute.objects.create(name="rv", hostname="192.0.2.32", login="root", password="", type=1)
self.instance = Instance.objects.create(compute=compute, name="kiosk-1", uuid="00000000-0000-4000-8000-0000000000e2")
patcher = patch("instances.jobs.wvmInstance")
self.proxy = patcher.start().return_value
self.addCleanup(patcher.stop)
self.proxy.instance.name.return_value = "kiosk-1"
self.proxy.wvm.listAllDevices.return_value = [node(*n) for n in NODES]
self.proxy.domain_trees.side_effect = lambda: [("db-1", [etree.fromstring(domain(by_model("<address bus='1' device='3'/>")))])]
self.params = {"instance": self.instance.id, "name": "s1", "external": False}
def snapshot(self, running, saved=None):
saved = f"<inactiveDomain><devices>{saved}</devices></inactiveDomain>" if saved is not None else ""
self.proxy._snapshot.return_value.getXMLDesc.return_value = \
f"<domainsnapshot><domain><devices>{running}</devices></domain>{saved}</domainsnapshot>"
def precheck(self):
op = MagicMock(params=self.params, intent={"compute": self.instance.compute_id}, user=MagicMock(is_superuser=True))
with patch("instances.jobs._authorized", return_value=self.instance):
self.kind.precheck(op, None)
def test_a_device_another_vm_names_is_not_given_back(self):
for running, saved in ((by_model(), None), ("", by_model())): # the saved definition counts too
self.snapshot(running, saved)
with self.subTest(saved=saved), self.assertRaisesRegex(OperationError, "is given to db-1"):
self.precheck()
def test_the_compute_is_held_only_for_a_revert_with_host_devices(self):
compute = f"compute:{self.instance.compute_id}"
self.snapshot(by_model())
self.assertEqual(self.kind.plan(None, self.params).claims[compute], "exclusive")
self.snapshot("")
self.assertEqual(self.kind.plan(None, self.params).claims[compute], "shared")
self.precheck() # no host device: the host's devices are not read
self.proxy.wvm.listAllDevices.assert_not_called()

View file

@ -72,6 +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_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,6 +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_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,6 +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_status.return_value = 5
response = self.client.get(reverse("instances:instance", args=[vm.id]))
self.assertEqual(response.status_code, 200)

View file

@ -27,6 +27,7 @@ class SnapshotJobsTestCase(TransactionTestCase):
self.proxy = patcher.start().return_value
self.addCleanup(patcher.stop)
self.proxy.instance.snapshotListNames.return_value = []
self.proxy._snapshot.return_value.getXMLDesc.return_value = "<domainsnapshot><domain/></domainsnapshot>"
self.proxy.instance.isActive.return_value = False
self.proxy.instance.hasCurrentSnapshot.return_value = 0

View file

@ -101,6 +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_snapshot.side_effect = lambda flag: {
libvirt.VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL: list(internal),
libvirt.VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL: list(external),

View file

@ -221,6 +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_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,6 +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",
}

View file

@ -57,6 +57,9 @@ 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>/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>/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,6 +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.storage import wvmStorage
from vrtManager.util import randomPasswd
@ -308,6 +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)
for row in usb_hostdevs:
row["name"] = names.get((row["vendor"], row["product"]), "")
# 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_settings = show_settings or show_hostdevs
job_resource = claims.instance_key(instance.uuid)
return render(request, "instance.html", locals(),)
@ -1785,6 +1795,65 @@ 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)
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"]])
if instance.name in device["vms"]:
device["problems"].append(_("The VM has it"))
return 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."""
instance = get_instance(request.user, pk)
try:
context = {"instance": instance, "host_usb": _host_usb(instance)}
except libvirtError as err:
context = {"instance": instance, "error": err}
return render(request, "instances/host_usb_form.html", context)
@require_POST
@superuser_only
@serialize_compute_mutation
def add_usb_hostdev(request, pk):
"""Gives the VM a USB device of its host; checked under the compute's
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)
if device is None:
messages.error(request, _("The host has no USB device %(device)s") % {"device": chosen})
elif device["problems"]:
messages.error(request, "; ".join(device["problems"]))
else:
vendor, product, address = device["selector"]
with audit_vm(request, instance, f"add USB device {device['ids']} ({device['product']})"):
instance.proxy.add_usb_hostdev(vendor, product, address)
return _back(request, pk, "hostdevs")
@require_POST
@superuser_only
@serialize_instance_mutation
def delete_usb_hostdev(request, pk):
instance = get_instance(request.user, pk)
vendor, product = request.POST.get("vendor", ""), request.POST.get("product", "")
bus, _sep, device = request.POST.get("address", "").partition("/")
address = (int(bus), int(device)) if bus.isdigit() and device.isdigit() else None
with audit_vm(request, instance, f"remove USB device {vendor[2:]}:{product[2:]}"):
instance.proxy.delete_usb_hostdev((vendor, product, address))
return _back(request, pk, "hostdevs")
@require_POST
@superuser_only
@serialize_instance_mutation

View file

@ -13,7 +13,8 @@ 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
from vrtManager.connection import wvmConnect
from vrtManager.nodedevs import node_devices, pci_address
def pci(slot, product, code, driver=None, group=None):
@ -76,10 +77,10 @@ USB_MODEL = "<hostdev mode='subsystem' type='usb'><source><vendor id='0x0627'/><
def devices(nodes=NODES, domains=()):
conn = wvmNodeDevices.__new__(wvmNodeDevices)
conn = wvmConnect.__new__(wvmConnect)
conn.wvm = MagicMock(**{"listAllDevices.return_value": [node(*n) for n in nodes],
"listAllDomains.return_value": list(domains)})
return conn.get_node_devices()
return node_devices(conn)
class NodeDevicesTests(SimpleTestCase):
@ -140,13 +141,13 @@ class NodeDevicesTests(SimpleTestCase):
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 = wvmConnect.__new__(wvmConnect)
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)
self.assertRaises(libvirt.libvirtError, node_devices, conn)
else:
self.assertEqual([d["address"] for d in conn.get_node_devices()[0]], ["0000:00:06.0"])
self.assertEqual([d["address"] for d in node_devices(conn)[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))

View file

@ -4,7 +4,8 @@ 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
from vrtManager.connection import wvmConnect
from vrtManager.nodedevs import node_devices
@superuser_only
@ -14,8 +15,7 @@ def nodedevs(request, compute_id):
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()
pci, usb = node_devices(wvmConnect(compute.hostname, compute.login, compute.password, compute.type))
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"]]

View file

@ -61,6 +61,7 @@ from lxml import etree
from vrtManager import util
from vrtManager.connection import NVRAM_DIR, wvmConnect
from vrtManager.nodedevs import pci_address, usb_selector
from vrtManager.storage import wvmStorage, wvmStorages
# Edits that redefine the domain start from this: the persistent definition,
@ -68,6 +69,10 @@ from vrtManager.storage import wvmStorage, wvmStorages
# password.
PERSISTENT_XML = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE
USB_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='usb']"
# 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']"
# External snapshot names become part of overlay and memory file names.
SNAPSHOT_NAME = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}")
# the longest window wvmInstance.usage() averages over
@ -141,6 +146,9 @@ class wvmInstances(wvmConnect):
definitions = {dom.XMLDesc(0)}
if state != 5:
definitions.add(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE))
# a host device is of the source host: the destination may have none, or another one
if any(etree.fromstring(xml).xpath(HOST_DEVICES) for xml in definitions):
raise util.OperationError("A VM with host devices cannot be migrated; remove them first")
missing = self.missing_disks(definitions)
if missing:
raise util.OperationError(
@ -1993,7 +2001,13 @@ class wvmInstance(wvmConnect):
raise ValueError("Cannot clone a disk without a supported source and destination")
disks.append((source, dev))
# a host device is one VM's: the clone gets none (the clone form says so)
for hostdev in tree.findall("devices/hostdev"):
hostdev.getparent().remove(hostdev)
for num, net in enumerate(tree.findall("devices/interface")):
if net.get("type") == "hostdev": # numbered as the form numbers the VM's NICs
net.getparent().remove(net)
continue
mac = clone_data.get(f"clone-net-mac-{num}")
if not mac:
raise ValueError(f"No MAC address for network interface {num}")
@ -2324,6 +2338,8 @@ class wvmInstance(wvmConnect):
removed = None
for xml_flags, affect in self._definitions():
nic = self._nic(xml_flags, mac_address)
if nic is not None and nic.get("type") == "hostdev":
raise util.OperationError("A network interface that is a host device is removed in the XML editor")
if nic is not None:
removed = etree.tostring(nic).decode()
self.instance.detachDeviceFlags(removed, affect)
@ -2340,6 +2356,8 @@ class wvmInstance(wvmConnect):
if not found:
raise util.OperationError(f"No network interface with MAC {old_mac}")
iface = found[0]
if iface.get("type") == "hostdev":
raise util.OperationError("A network interface that is a host device is changed in the XML editor")
iface_type = {"net": "network", "bridge": "bridge"}.get(source_type, "direct")
iface.set("type", iface_type)
@ -2362,6 +2380,79 @@ class wvmInstance(wvmConnect):
self._defineXML(etree.tostring(tree).decode())
def _usb_hostdevs(self, xml_flags):
"""(element, selector) of each USB host device of a definition. The
selector (usb_selector) is read from the migratable XML, the only one
that marks the addresses libvirt resolved for a running VM."""
elements = etree.fromstring(self._XMLDesc(xml_flags)).xpath(USB_HOSTDEVS)
marked = etree.fromstring(self._XMLDesc(xml_flags | VIR_DOMAIN_XML_MIGRATABLE)).xpath(USB_HOSTDEVS)
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 = {}
for xml_flags, affect in self._definitions():
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:
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')]"
" | 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
else:
source = device.find("source")
address = source.find("address") if source is not None else None
if address is not None and address.get("slot") is not None:
label = pci_address(address.get("domain", "0"), address.get("bus"), address.get("slot"),
address.get("function"))
else: # a mediated device's UUID; a SCSI device as its source
label = address.get("uuid", "") if address is not None else ""
kind = "nic" if device.tag == "interface" else device.get("type", device.get("mode"))
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())
def add_usb_hostdev(self, vendor, product, address=None):
"""Gives the VM the host's USB device of vendor and product, at
address (bus, device) when given: the host has more than one of the
model. Refused without a USB controller."""
for xml_flags, _ in self._definitions():
if not etree.fromstring(self._XMLDesc(xml_flags)).xpath("devices/controller[@type='usb'][not(@model='none')]"):
raise util.OperationError("The VM has no USB controller")
hostdev = etree.Element("hostdev", mode="subsystem", type="usb", managed="yes")
source = etree.SubElement(hostdev, "source")
etree.SubElement(source, "vendor", id=vendor)
etree.SubElement(source, "product", id=product)
if address:
etree.SubElement(source, "address", bus=str(address[0]), device=str(address[1]))
self._attach_device(etree.tostring(hostdev, encoding="unicode"))
def delete_usb_hostdev(self, selector):
"""Takes the USB host device of selector (usb_selector) from each
definition that has it, as that definition describes it."""
found = False
for xml_flags, affect in self._definitions():
for element, own in self._usb_hostdevs(xml_flags):
if own == selector:
found = True
self.instance.detachDeviceFlags(etree.tostring(element, encoding="unicode"), affect)
if not found:
raise util.OperationError("The VM has no such USB device")
def set_link_state(self, mac_address, state):
for xml_flags, affect in self._definitions():
nic = self._nic(xml_flags, mac_address)

View file

@ -1,8 +1,8 @@
from django.utils.translation import gettext_lazy as _
from libvirt import VIR_ERR_NO_NODE_DEVICE, libvirtError
from libvirt import VIR_CONNECT_LIST_NODE_DEVICES_CAP_USB_DEV, VIR_ERR_NO_NODE_DEVICE, libvirtError
from lxml import etree
from vrtManager.connection import wvmConnect
from vrtManager import util
# PCI base classes by their code (the class's first byte)
PCI_CLASSES = {
@ -30,6 +30,8 @@ USB_CLASSES = {
0xFF: _("Vendor specific"),
}
USB_HUB = 0x09
def pci_address(domain, bus, slot, function):
"""A PCI address as lspci writes it, from its parts as integers or as
@ -55,21 +57,27 @@ def host_resources(name, nodes, children):
return found
def usb_selector(source):
"""(vendor, product, (bus, device) or None) of a USB <hostdev>'s <source>,
the parts libvirt picks a device by. An address libvirt resolved for a
running VM (autoAddress, in its migratable XML) is not one of them."""
address = source.find("address")
given = address is not None and source.get("autoAddress") != "yes"
return (source.xpath("string(vendor/@id)").lower(), source.xpath("string(product/@id)").lower(),
(int(address.get("bus")), int(address.get("device"))) if given else None)
def hostdev_users(trees):
"""What a VM's definitions give it of the host: PCI addresses (of a
<hostdev>, 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."""
(usb_selector)."""
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))
usb.extend(usb_selector(source) for source in tree.xpath("devices/hostdev[@mode='subsystem'][@type='usb']/source"))
return pci, usb
@ -86,79 +94,136 @@ def usb_named(selector, model, 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)
def device_problems(label, vms, resources, vm):
"""Why a host device cannot be given to the VM named vm: the other VMs
that name it, and what the host has on it."""
problems = []
if others := sorted(set(vms) - {vm}):
problems.append(f"{label} is given to {', '.join(others)}")
if resources:
problems.append(f"{label} holds the host's {', '.join(resources)}")
return problems
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")),
}
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 = []
for device in pci:
if device["address"] in wanted_pci:
problems += device_problems(f"PCI device {device['address']}", device["vms"], device["resources"], vm)
for device in usb:
address = (device["bus"], device["device"])
if any(usb_named(selector, device["model"], address) for selector in wanted_usb):
label = f"USB device {device['bus']}/{device['device']} ({device['product']})"
problems += device_problems(label, device["vms"] + device["by_model"], device["resources"], vm)
if device["hub"]:
problems.append(f"{label} is a hub")
return problems
@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)),
}
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")),
}
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}),
"hub": USB_HUB 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)),
}
def node_devices(conn):
"""The PCI and USB devices of the host of conn (a wvmConnect), 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 conn.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 conn.domain_trees()]
pci = [_pci(name, node, nodes, children, vms) for name, node in nodes.items()
if node.find("capability").get("type") == "pci"]
usb = [_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
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."""
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
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):
return
pci, usb = node_devices(conn)
if problems := hostdev_problems(vm, pci, usb, *wanted):
raise util.OperationError("; ".join(problems))