From ac8ff62e7e03aded6c376ca5cbcc58869d8bd325 Mon Sep 17 00:00:00 2001 From: catborise Date: Thu, 29 Oct 2026 13:16:45 +0300 Subject: [PATCH] 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. --- instances/jobs.py | 24 +- instances/templates/instance.html | 16 ++ .../templates/instances/host_usb_form.html | 15 ++ .../templates/instances/settings_tab.html | 70 ++++++ instances/tests/test_console_listener.py | 1 + .../tests/test_console_settings_access.py | 1 + instances/tests/test_host_devices.py | 230 ++++++++++++++++++ instances/tests/test_page_controls.py | 1 + instances/tests/test_pending_edits.py | 1 + instances/tests/test_quota.py | 1 + instances/tests/test_snapshot_jobs.py | 1 + instances/tests/test_snapshot_views.py | 1 + instances/tests/test_states.py | 1 + instances/tests/test_xss.py | 1 + instances/urls.py | 3 + instances/views.py | 69 ++++++ nodedevs/tests/test_nodedevs.py | 13 +- nodedevs/views.py | 6 +- vrtManager/instance.py | 91 +++++++ vrtManager/nodedevs.py | 227 +++++++++++------ 20 files changed, 679 insertions(+), 94 deletions(-) create mode 100644 instances/templates/instances/host_usb_form.html create mode 100644 instances/tests/test_host_devices.py diff --git a/instances/jobs.py b/instances/jobs.py index 7bb72489..21d2df8b 100644 --- a/instances/jobs.py +++ b/instances/jobs.py @@ -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"])) diff --git a/instances/templates/instance.html b/instances/templates/instance.html index f2c14567..bba4f00e 100644 --- a/instances/templates/instance.html +++ b/instances/templates/instance.html @@ -428,6 +428,22 @@ });