mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +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:
parent
0977aef587
commit
ac8ff62e7e
20 changed files with 679 additions and 94 deletions
|
|
@ -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"]))
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
15
instances/templates/instances/host_usb_form.html
Normal file
15
instances/templates/instances/host_usb_form.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
230
instances/tests/test_host_devices.py
Normal file
230
instances/tests/test_host_devices.py
Normal 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()
|
||||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(" ", ""))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue