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:
parent
ac8ff62e7e
commit
80dd145bcf
19 changed files with 509 additions and 103 deletions
|
|
@ -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 }}";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
29
instances/templates/instances/host_devices_form.html
Normal file
29
instances/templates/instances/host_devices_form.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(" ", ""))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
|
|
|
|||
134
nodedevs/tests/test_pci_rules.py
Normal file
134
nodedevs/tests/test_pci_rules.py
Normal 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")])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue