1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00

fix(instances): disk edits keep their XML; changes lock per VM

Disk edits:
- edit_disk built a new <disk> from the form and replaced the old one,
  losing what the form does not show (e.g. encryption and virtio
  driver options); a bus change detached and re-attached the disk.
  The disk of the persistent definition is now edited in place and the
  domain defined once; a bus change picks a free device name and drops
  the old address and virtio-only options. Pending persistent changes of
  a running VM are kept.
- Clone omits empty disk devices, refuses a disk without a supported
  source and destination, and checks the name before allocating storage.

Locks:
- Every VM change held its compute's exclusive lock, so one slow
  operation blocked all VMs on that compute. A VM change now holds its
  own VM lock plus a shared compute lock; reconciliation, migration and
  cloning hold the compute exclusively (two clones of different VMs
  must not pass the name check together).
- Quota-checked changes of one user (CPU, memory and disk resize,
  clone) run one at a time under a per-user lock, also across computes,
  so concurrent requests cannot all pass the check against the old
  allocation. It is the outermost lock; superusers, who have no quota,
  skip it.
- A busy lock answers 409 to the API and a message in the web UI. API
  power and destroy actions return the view's error instead of success.
This commit is contained in:
catborise 2026-10-03 19:11:44 +03:00
parent 158d4db6c4
commit 51dedb7b8f
12 changed files with 510 additions and 269 deletions

View file

@ -2,6 +2,7 @@ import contextlib
import json
import logging
import os.path
import string
import time
logger = logging.getLogger(__name__)
@ -13,6 +14,7 @@ try:
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT,
VIR_DOMAIN_RUNNING,
VIR_DOMAIN_XML_SECURE,
VIR_DOMAIN_XML_INACTIVE,
VIR_MIGRATE_AUTO_CONVERGE,
VIR_MIGRATE_COMPRESSED,
VIR_MIGRATE_LIVE,
@ -450,7 +452,7 @@ class wvmInstance(wvmConnect):
return util.get_xml_path(self._XMLDesc(0), func=networks)
def get_disk_devices(self):
def get_disk_devices(self, config=False):
def disks(doc):
result = []
@ -537,7 +539,8 @@ class wvmInstance(wvmConnect):
)
return result
return util.get_xml_path(self._XMLDesc(0), func=disks)
flags = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE if config else 0
return util.get_xml_path(self._XMLDesc(flags), func=disks)
def get_media_devices(self):
def disks(doc):
@ -844,32 +847,6 @@ class wvmInstance(wvmConnect):
if self.get_status() == 5:
self.instance.detachDeviceFlags(xml_disk, VIR_DOMAIN_AFFECT_CONFIG)
def change_disk_bus(self, target_dev, new_target_dev, new_bus):
"""
Move a disk to another bus in the persistent config. The disk element
is reused as is (type, source, auth, driver), only <target> changes
and the old bus address is dropped. If attaching fails, the original
disk is attached again.
"""
tree = etree.fromstring(self._XMLDesc(0))
disk_el = tree.xpath("./devices/disk/target[@dev=$dev]", dev=target_dev)[
0
].getparent()
old_xml = etree.tostring(disk_el).decode()
disk_el.find("target").set("dev", new_target_dev)
disk_el.find("target").set("bus", new_bus)
for address in disk_el.findall("address"):
disk_el.remove(address)
new_xml = etree.tostring(disk_el).decode()
self.instance.detachDeviceFlags(old_xml, VIR_DOMAIN_AFFECT_CONFIG)
try:
self.instance.attachDeviceFlags(new_xml, VIR_DOMAIN_AFFECT_CONFIG)
except libvirtError:
self.instance.attachDeviceFlags(old_xml, VIR_DOMAIN_AFFECT_CONFIG)
raise
def edit_disk(
self,
target_dev,
@ -884,61 +861,70 @@ class wvmInstance(wvmConnect):
discard_mode,
detect_zeroes_mode,
):
tree = etree.fromstring(self._XMLDesc(0))
disk_el = tree.xpath("./devices/disk/target[@dev=$dev]", dev=target_dev)[
0
].getparent()
old_disk_type = disk_el.get("type")
old_disk_device = disk_el.get("device")
old_driver_name = disk_el.xpath("driver/@name")[0]
old_target_bus = disk_el.xpath("target/@bus")[0]
"""Edit the persistent disk in place and commit its domain XML once."""
tree = etree.fromstring(
self._XMLDesc(VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE)
)
matches = tree.xpath("./devices/disk/target[@dev=$dev]", dev=target_dev)
if not matches:
raise ValueError(f"Disk {target_dev} is not in the persistent configuration")
target = matches[0]
disk = target.getparent()
# Values below are interpolated into disk XML; escape them all.
esc = util.xml_escape
target_dev, source, target_bus = esc(target_dev), esc(source), esc(target_bus)
serial, format = esc(serial), esc(format)
cache_mode, io_mode = esc(cache_mode), esc(io_mode)
discard_mode, detect_zeroes_mode = esc(discard_mode), esc(detect_zeroes_mode)
old_disk_type, old_disk_device = esc(old_disk_type), esc(old_disk_device)
old_driver_name = esc(old_driver_name)
if target.get("bus") != target_bus:
prefix = {"virtio": "vd", "ide": "hd", "fdc": "fd"}.get(target_bus, "sd")
used = set(tree.xpath("./devices/disk/target/@dev")) - {target_dev}
new_dev = target_dev if target_dev.startswith(prefix) else next(
(prefix + letter for letter in string.ascii_lowercase if prefix + letter not in used),
None,
)
if new_dev is None:
raise ValueError("No available device name for the requested disk bus")
target.set("dev", new_dev)
target.set("bus", target_bus)
for address in disk.findall("address"):
disk.remove(address)
additionals = ""
if cache_mode is not None and cache_mode != "default":
additionals += f"cache='{cache_mode}' "
if io_mode is not None and io_mode != "default":
additionals += f"io='{io_mode}' "
if discard_mode is not None and discard_mode != "default":
additionals += f"discard='{discard_mode}' "
if detect_zeroes_mode is not None and detect_zeroes_mode != "default":
additionals += f"detect_zeroes='{detect_zeroes_mode}' "
driver = disk.find("driver")
if driver is None:
driver = etree.Element("driver", name="qemu")
disk.insert(0, driver)
if target_bus != "virtio":
for key in ("queues", "queue_size", "iothread", "iommu", "ats", "packed", "page_per_vq"):
driver.attrib.pop(key, None)
for iothreads in driver.findall("iothreads"):
driver.remove(iothreads)
disk.attrib.pop("model", None)
for key, value in (
("type", format), ("cache", cache_mode), ("io", io_mode),
("discard", discard_mode), ("detect_zeroes", detect_zeroes_mode),
):
if value and value != "default":
driver.set(key, value)
else:
driver.attrib.pop(key, None)
xml_disk = f"<disk type='{old_disk_type}' device='{old_disk_device}'>"
# A disk without a driver type keeps having none.
type_attr = f"type='{format}' " if format else ""
if old_disk_device == "cdrom":
xml_disk += f"<driver name='{old_driver_name}' {type_attr}/>"
elif old_disk_device == "disk":
xml_disk += f"<driver name='{old_driver_name}' {type_attr}{additionals}/>"
if disk.get("type") == "file":
source_el = disk.find("source")
if source_el is None:
source_el = etree.SubElement(disk, "source")
source_el.set("file", source)
if disk_el.get("type") == "file":
xml_disk += f"<source file='{source}'/>"
else:
# Block/network disks: keep their source (and auth) untouched;
# the form only edits the path of file disks.
for name in ("auth", "source"):
el = disk_el.find(name)
if el is not None:
xml_disk += etree.tostring(el).decode()
xml_disk += f"<target dev='{target_dev}' bus='{target_bus}'/>"
if readonly:
xml_disk += """<readonly/>"""
if shareable:
xml_disk += """<shareable/>"""
if serial is not None and serial != "None" and serial != "":
xml_disk += f"""<serial>{serial}</serial>"""
xml_disk += """</disk>"""
for tag, enabled in (("readonly", readonly), ("shareable", shareable)):
element = disk.find(tag)
if enabled and element is None:
etree.SubElement(disk, tag)
elif not enabled and element is not None:
disk.remove(element)
serial_el = disk.find("serial")
if serial and serial != "None":
if serial_el is None:
serial_el = etree.SubElement(disk, "serial")
serial_el.text = serial
elif serial_el is not None:
disk.remove(serial_el)
self.instance.updateDeviceFlags(xml_disk, VIR_DOMAIN_AFFECT_CONFIG)
self._defineXML(etree.tostring(tree).decode())
def cpu_usage(self):
cpu_usage = {}
@ -1510,12 +1496,17 @@ class wvmInstance(wvmConnect):
def clone_instance(self, clone_data):
clone_dev_path = []
if clone_data["name"] in self.get_instances():
raise ValueError("An instance with the clone name already exists")
xml = self._XMLDesc(VIR_DOMAIN_XML_SECURE)
tree = etree.fromstring(xml)
name = tree.find("name")
name.text = clone_data["name"]
uuid = tree.find("uuid")
tree.remove(uuid)
if uuid is None:
uuid = etree.SubElement(tree, "uuid")
uuid.text = util.randomUUID()
options = {
"title": clone_data.get("clone-title", ""),
@ -1523,6 +1514,21 @@ class wvmInstance(wvmConnect):
}
self._set_options(tree, options)
for disk in list(tree.findall("devices/disk")):
if disk.get("device") != "disk":
continue
source = disk.find("source")
if source is None or not any(source.get(key) for key in ("file", "dev", "name", "volume")):
disk.getparent().remove(disk)
continue
supported = source.get("file") or source.get("dev") or (
source.get("protocol") == "rbd" and source.get("name")
)
target = disk.find("target")
dev = target.get("dev") if target is not None else None
if not supported or not dev or not clone_data.get("disk-" + dev):
raise ValueError("Cannot clone a disk without a supported source and destination")
src_nvram_path = self.get_nvram()
if src_nvram_path:
# Change XML for nvram

View file

@ -185,6 +185,7 @@ class TestDiskXmlEscaping(unittest.TestCase):
inst = wvmInstance.__new__(wvmInstance)
inst.get_status = MagicMock(return_value=5) # shutoff
inst.instance = MagicMock()
inst._defineXML = MagicMock()
return inst
def test_attach_disk_escapes_source_and_serial(self):
@ -225,8 +226,9 @@ class TestDiskXmlEscaping(unittest.TestCase):
"default",
"default",
)
xml = inst.instance.updateDeviceFlags.call_args[0][0]
self.assertIn("file='/images/b&apos;/&gt;&lt;x y=&apos;'", xml)
xml = inst._defineXML.call_args.args[0]
from lxml import etree
self.assertEqual(etree.fromstring(xml).find("devices/disk/source").get("file"), "/images/b'/><x y='")
self.assertNotIn("<evil/>", xml)
def test_detach_disk_does_not_evaluate_dev_as_xpath(self):
@ -244,82 +246,148 @@ class TestDiskXmlEscaping(unittest.TestCase):
inst.instance.detachDeviceFlags.assert_not_called()
class TestChangeDiskBus(unittest.TestCase):
RBD_DOMAIN = (
"<domain><devices>"
"<disk type='network' device='disk'>"
"<driver name='qemu' type='raw'/>"
"<auth username='libvirt'><secret type='ceph' uuid='aaaa'/></auth>"
"<source protocol='rbd' name='pool/vm-disk'><host name='mon1' port='6789'/></source>"
"<target dev='vda' bus='virtio'/>"
"<address type='pci' domain='0x0000' bus='0x00' slot='0x05' function='0x0'/>"
"</disk></devices></domain>"
)
def _instance(self):
inst = wvmInstance.__new__(wvmInstance)
inst.instance = MagicMock()
inst._XMLDesc = MagicMock(return_value=self.RBD_DOMAIN)
return inst
def test_keeps_disk_source_and_auth_and_only_changes_target(self):
class TestPersistentDiskEditing(unittest.TestCase):
def setUp(self):
import libvirt
from lxml import etree
inst = self._instance()
inst.change_disk_bus("vda", "sda", "sata")
self.conn = libvirt.open("test:///default")
self.addCleanup(self.conn.close)
self.domain = self.conn.lookupByName("test")
original = self.domain.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE | libvirt.VIR_DOMAIN_XML_SECURE)
self.addCleanup(self.conn.defineXML, original)
config = etree.fromstring(original)
disk = config.find("devices/disk")
disk.set("type", "file")
driver = disk.find("driver")
if driver is None:
driver = etree.SubElement(disk, "driver")
driver.set("name", "qemu")
driver.set("type", "qcow2")
driver.set("queues", "4")
etree.SubElement(disk, "address", type="pci", slot="0x05")
encryption = etree.SubElement(disk, "encryption", format="luks")
etree.SubElement(encryption, "secret", type="passphrase", uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
etree.SubElement(etree.SubElement(disk, "iotune"), "total_bytes_sec").text = "1024"
# A pending change and a VNC password outside the edited disk must survive.
config.find("memory").text = "123456"
etree.SubElement(config.find("devices"), "graphics", type="vnc", passwd="keep-secret")
self.conn.defineXML(etree.tostring(config).decode())
self.inst = wvmInstance.__new__(wvmInstance)
self.inst.wvm = self.conn
self.inst.instance = self.domain
self.target = disk.find("target").get("dev")
new_xml = inst.instance.attachDeviceFlags.call_args[0][0]
disk = etree.fromstring(new_xml)
self.assertEqual(disk.get("type"), "network")
self.assertEqual(disk.find("source").get("name"), "pool/vm-disk")
self.assertEqual(disk.find("auth/secret").get("uuid"), "aaaa")
self.assertEqual(disk.find("target").get("dev"), "sda")
def config(self):
import libvirt
from lxml import etree
return etree.fromstring(self.domain.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE | libvirt.VIR_DOMAIN_XML_SECURE))
def edit(self, bus="virtio", format="qcow2"):
self.inst.edit_disk(
self.target, "/images/a'<new>&.qcow2", False, False, bus,
"serial<&", format, "none", "native", "unmap", "on",
)
def test_running_bus_change_preserves_disk_and_pending_domain_configuration(self):
from lxml import etree
self.assertTrue(self.domain.isActive())
live_before = self.domain.XMLDesc(0)
self.edit(bus="sata")
config = self.config()
disk = config.find("devices/disk")
self.assertEqual(disk.find("target").get("bus"), "sata")
self.assertIsNone(disk.find("address"))
self.assertTrue(disk.find("target").get("dev").startswith("sd"))
self.assertNotEqual(disk.find("address").get("type"), "pci")
self.assertIsNone(disk.find("driver").get("queues"))
self.assertEqual(disk.find("driver").get("cache"), "none")
self.assertEqual(disk.findtext("iotune/total_bytes_sec"), "1024")
self.assertEqual(disk.find("encryption/secret").get("uuid"), "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
self.assertEqual(disk.find("source").get("file"), "/images/a'<new>&.qcow2")
self.assertEqual(disk.findtext("serial"), "serial<&")
self.assertEqual(config.findtext("memory"), "123456")
self.assertEqual(config.find("devices/graphics").get("passwd"), "keep-secret")
self.assertEqual(self.domain.XMLDesc(0), live_before)
# The resulting disk can be edited again before the VM restarts.
self.target = disk.find("target").get("dev")
self.edit(bus="sata")
self.assertEqual(self.config().find("devices/disk/target").get("dev"), self.target)
def test_reattaches_the_old_disk_when_attach_fails(self):
def test_failed_definition_leaves_the_original_configuration_intact(self):
from libvirt import libvirtError
from unittest.mock import patch
before = self.domain.XMLDesc(2)
with patch.object(self.inst, "_defineXML", side_effect=libvirtError("definition failed")):
with self.assertRaises(libvirtError):
self.edit(bus="sata")
self.assertEqual(self.domain.XMLDesc(2), before)
inst = self._instance()
inst.instance.attachDeviceFlags.side_effect = [libvirtError("boom"), None]
def test_network_disk_keeps_auth_source_and_encryption(self):
from lxml import etree
config = self.config()
disk = config.find("devices/disk")
disk.set("type", "network")
disk.remove(disk.find("source"))
source = etree.SubElement(disk, "source", protocol="rbd", name="pool/vm-disk")
etree.SubElement(source, "host", name="mon1", port="6789")
auth = etree.SubElement(disk, "auth", username="libvirt")
etree.SubElement(auth, "secret", type="ceph", uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
self.conn.defineXML(etree.tostring(config).decode())
self.edit(bus="sata")
result = self.config().find("devices/disk")
self.assertEqual(result.find("source").get("name"), "pool/vm-disk")
self.assertEqual(result.find("source/host").get("name"), "mon1")
self.assertEqual(result.find("auth").get("username"), "libvirt")
self.assertIsNotNone(result.find("encryption"))
with self.assertRaises(libvirtError):
inst.change_disk_bus("vda", "sda", "sata")
def test_empty_format_omits_driver_type(self):
self.edit(format="")
self.assertIsNone(self.config().find("devices/disk/driver").get("type"))
detached_xml = inst.instance.detachDeviceFlags.call_args[0][0]
restored_xml = inst.instance.attachDeviceFlags.call_args_list[1][0][0]
self.assertEqual(restored_xml, detached_xml)
def test_virtio_options_survive_editing_on_the_same_bus(self):
self.edit()
self.assertEqual(self.config().find("devices/disk/driver").get("queues"), "4")
def test_edit_disk_keeps_source_and_auth_of_a_network_disk(self):
from lxml import etree
inst = self._instance()
inst.edit_disk(
"vda", "ignored-path", False, False, "virtio", "", "raw",
"default", "default", "default", "default",
)
disk = etree.fromstring(inst.instance.updateDeviceFlags.call_args[0][0])
self.assertEqual(disk.get("type"), "network")
self.assertEqual(disk.find("source").get("name"), "pool/vm-disk")
self.assertEqual(disk.find("source/host").get("name"), "mon1")
self.assertEqual(disk.find("auth/secret").get("uuid"), "aaaa")
def test_edit_disk_without_format_omits_the_driver_type(self):
class TestEmptyDiskClone(unittest.TestCase):
def test_clone_omits_empty_disks_before_real_libvirt_definition(self):
import libvirt
from lxml import etree
conn = libvirt.open("test:///default")
self.addCleanup(conn.close)
domain = conn.lookupByName("test")
config = etree.fromstring(domain.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE))
devices = config.find("devices")
for disk in devices.findall("disk"):
source = disk.find("source")
if source is not None:
disk.remove(source)
for interface in devices.findall("interface"):
devices.remove(interface)
inst = wvmInstance.__new__(wvmInstance)
inst.instance = MagicMock()
inst._XMLDesc = MagicMock(
return_value=(
"<domain><devices><disk type='file' device='disk'>"
"<driver name='qemu'/><source file='/images/vm.img'/>"
"<target dev='vda' bus='virtio'/></disk></devices></domain>"
)
)
inst.edit_disk(
"vda", "/images/vm.img", False, False, "virtio", "", "",
"default", "default", "default", "default",
)
disk = etree.fromstring(inst.instance.updateDeviceFlags.call_args[0][0])
self.assertIsNone(disk.find("driver").get("type"))
inst.wvm = conn
inst.instance = domain
# Exercise defensive handling of incomplete disk metadata, while the
# clone is still defined and looked up through the real test driver.
inst._XMLDesc = MagicMock(return_value=etree.tostring(config).decode())
clone_uuid = inst.clone_instance({"name": "empty-disk-clone"})
clone = conn.lookupByUUIDString(clone_uuid)
self.addCleanup(clone.undefine)
clone_config = etree.fromstring(clone.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE))
self.assertEqual(clone_config.findtext("name"), "empty-disk-clone")
self.assertEqual(clone_config.findall("devices/disk"), [])
self.assertTrue(config.findall("devices/disk"))
def test_clone_rejects_existing_name_before_allocating_storage(self):
import libvirt
conn = libvirt.open("test:///default")
self.addCleanup(conn.close)
inst = wvmInstance.__new__(wvmInstance)
inst.wvm = conn
inst.instance = conn.lookupByName("test")
inst._XMLDesc = MagicMock()
with self.assertRaisesRegex(ValueError, "already exists"):
inst.clone_instance({"name": "test"})
inst._XMLDesc.assert_not_called()