1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

feat(vrtManager): the libvirt layer's messages show in the reader's language

vrtManager's errors (some 140: a disk that does not move, a network
address outside its subnet, a host device given to another VM, the
disks a migration cannot copy) were English f-strings, so no catalog
could translate them, on pages or in jobs.

They are marked texts with their values now; a variant that differs by
a word (internal or external, running or persistent, with a subnet or
without) is a whole message of its own, and a list of problems is a
list of messages. OperationError reads in the active language, so the
pages that show one translate it as they are; its args stay English, and
so do the logs and the audit log. Host device problems and the Migrate
tab's reasons a disk is not copied are messages too.
This commit is contained in:
catborise 2026-11-03 00:05:34 +03:00
parent 1e66367bb4
commit 491819ed64
21 changed files with 2371 additions and 420 deletions

View file

@ -25,7 +25,8 @@
</select>
{% if disk.same_name %}<div class="form-text">{% trans "Another disk of the VM has this file name: copy them to different pools." %}</div>{% endif %}
{% elif disk.refusal %}
<p class="small text-danger mb-0" data-refused>{% blocktrans with reason=disk.refusal %}Cannot be copied: {{ reason }}{% endblocktrans %}</p>
{% trans disk.refusal as reason %}
<p class="small text-danger mb-0" data-refused>{% blocktrans %}Cannot be copied: {{ reason }}{% endblocktrans %}</p>
{% elif pools %}
<p class="small text-danger mb-0" data-refused>{% trans "Every pool of the destination host has a file of this name" %}</p>
{% else %}

View file

@ -2074,14 +2074,18 @@ def _host_devices(instance):
else:
managed = {row["address"] for row in own_pci if row["managed"] and row["next"]}
by_address = {device["address"]: device for device in pci}
# the problems in the page's language (vrtManager.nodedevs gives messages)
def shown(problems):
return [util.render(m["text"], m["values"], _) for m in problems]
for device in pci:
device["problems"] = pci_problems(instance.name, device, by_address, managed | {device["address"]})
device["problems"] = shown(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, pci, [device], set(), [device["selector"]])
device["problems"] = shown(hostdev_problems(instance.name, pci, [device], set(), [device["selector"]]))
if instance.name in device["vms"]:
device["problems"].append(_("The VM has it"))
return pci, usb

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -64,7 +64,7 @@ def reason(exc, user):
if isinstance(exc, claims.Busy):
return busy_message(exc, user)
if isinstance(exc, OperationError):
return str(exc)
return exc.args[0] # English: str() is the reader's language
if isinstance(exc, libvirt.libvirtError):
return safe_text(exc.get_error_message() or str(exc))[:500]
return type(exc).__name__
@ -90,6 +90,10 @@ def audit(request, compute, subject, action, instance="", uuid=""):
def failed(request, compute, subject, action, why, instance="", uuid=""):
"""Write the failure row of an action that was refused before it started."""
from vrtManager.util import OperationError
if isinstance(why, OperationError):
why = why.args[0] # English: str() is the reader's language
write(request.user.get_username(), host_of(compute), instance, f"{subject}: {action}: {why}", False,
request_ip(request), uuid)

View file

@ -14,6 +14,7 @@ from django.urls import reverse
from instances.models import Instance
from lxml import html
from vrtManager.connection import wvmConnect
from vrtManager import util
from vrtManager.nodedevs import hostdev_problems, node_devices, pci_address
@ -116,7 +117,8 @@ class NodeDevicesTests(SimpleTestCase):
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))])
reasons = [util.render(m["text"], m["values"], str) for m in 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):

View file

@ -10,6 +10,7 @@ from unittest.mock import MagicMock
from django.test import SimpleTestCase
from lxml import etree
from vrtManager import util
from vrtManager.nodedevs import _pci, check_hostdevs, hostdev_problems, pci_problems
from vrtManager.util import OperationError
@ -60,7 +61,8 @@ def inventory(vms=()):
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}))
return " ".join(util.render(m["text"], m["values"], str)
for m in pci_problems("web-1", devices[address], devices, {address, *managed}))
def hostdev(slot, managed="yes"):
@ -107,7 +109,8 @@ class PciRulesTests(SimpleTestCase):
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"}, [])
reasons = [util.render(m["text"], m["values"], str)
for m in hostdev_problems("web-1", inventory(), [], {"0000:00:1f.0"}, [])]
self.assertEqual(reasons, ["PCI device 0000:00:1f.0 is not on the host"])

View file

@ -19,7 +19,7 @@ from libvirt import libvirtError
from accounts.models import UserInstance
from appsettings.models import AppSettings
from instances.models import Instance
from vrtManager.util import ConnectionFailed, OperationError
from vrtManager.util import ConnectionFailed, OperationError, render
from . import jobs
@ -66,7 +66,18 @@ def safe_text(text):
# words() puts them in the language of whoever reads them. A text that is not
# ours (libvirt's) is a message without values and stays as it is.
def say(text, **values):
return [{"text": safe_text(text), "values": {key: safe_text(value) for key, value in values.items()}}]
return [{"text": safe_text(text), "values": {key: _stored(value) for key, value in values.items()}}]
def _stored(value):
"""A value as a job keeps it: messages stay messages (vrtManager.util.message)."""
if isinstance(value, list):
return [say(m["text"], **m["values"])[0] for m in value]
if isinstance(value, OperationError):
return [say(value.text, **value.values)[0]]
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value # for %(x).1f and the like
return safe_text(value)
def said(exception):
@ -78,7 +89,7 @@ def said(exception):
def words(messages):
"""messages in the active language, each a sentence of its own."""
parts = [_(m["text"]) % m["values"] if m["values"] else _(m["text"]) for m in messages]
parts = [render(m["text"], m["values"], _) for m in messages]
return " ".join(part if last or part.endswith((".", "。", "!", "?")) else part + "."
for last, part in ((i == len(parts) - 1, part) for i, part in enumerate(parts)))

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import contextlib
import io
import os
@ -196,7 +197,7 @@ class wvmConnection(object):
try:
connection = self._open_transport()
if not connection.isAlive(): # closed again before it was published
raise util.OperationError("the connection closed right after opening")
raise util.OperationError(gettext_noop("the connection closed right after opening"))
# Drivers without keepalive or close callbacks work without them
try:
connection.setKeepAlive(connection_manager.keepalive_interval, connection_manager.keepalive_count)

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import base64
import contextlib
import ipaddress
@ -130,10 +131,10 @@ class wvmInstances(wvmConnect):
mode = ["offline"]
elif state in (1, 3):
if offline:
raise util.OperationError("A running or paused VM cannot be migrated offline")
raise util.OperationError(gettext_noop("A running or paused VM cannot be migrated offline"))
mode = ["live" if live else "non-live"]
else:
raise util.OperationError("The VM can be migrated when it is running, paused or shut off")
raise util.OperationError(gettext_noop("The VM can be migrated when it is running, paused or shut off"))
# The source definition always goes: one left behind points at the
# same (shared) disks as the migrated VM and could be started again.
@ -156,33 +157,32 @@ class wvmInstances(wvmConnect):
# libvirt keeps snapshot metadata on the source host: shared disks do
# not carry it to the destination.
if dom.snapshotNum(0):
raise util.OperationError("A VM with snapshots cannot be migrated")
raise util.OperationError(gettext_noop("A VM with snapshots cannot be migrated"))
# a shut-off VM moves as its definition or started paused: either loses the saved memory
if state == 5 and dom.hasManagedSaveImage(0):
raise util.OperationError("A VM with a saved state cannot be migrated")
raise util.OperationError(gettext_noop("A VM with a saved state cannot be migrated"))
# the definition the migration carries first: it has the disks a migration copies
definitions = [dom.XMLDesc(0)]
if state != 5:
definitions.append(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE))
# a host device is of the source host: the destination may have none, or another one
if any(etree.fromstring(xml).xpath(HOST_DEVICES) for xml in definitions):
raise util.OperationError("A VM with host devices cannot be migrated; remove them first")
raise util.OperationError(gettext_noop("A VM with host devices cannot be migrated; remove them first"))
copies = self.disk_copies(conn, definitions, pools or {})
if copies:
mode.append("disks copied: " + ", ".join(f"{copy['target']} to {copy['pool'].name()}" for copy in copies))
missing = self.missing_disks(definitions, copied={copy["path"] for copy in copies})
if missing:
raise util.OperationError(
"The destination host does not have these disks of the VM: "
+ ", ".join(f"{target} ({source})" for target, source in missing)
)
raise util.OperationError(gettext_noop("The destination host does not have these disks of the VM: %(disks)s"),
disks=[util.message(gettext_noop("%(target)s (%(source)s)"), target=target,
source=source) for target, source in missing])
dom_arch = conn.get_arch()
dom_emulator = conn.get_dom_emulator()
if dom_emulator != self.get_emulator(dom_arch):
raise util.OperationError(
"Destination host emulator is different. Cannot be migrated"
gettext_noop("Destination host emulator is different. Cannot be migrated")
)
# the TPM state is a file of the source host: a live migration carries it
@ -313,17 +313,17 @@ class wvmInstances(wvmConnect):
def copy_refusal(disk, volume):
"""Why the disk (its volume on the source, None if no pool has it) cannot be copied, or None."""
if disk.get("device") != "disk":
return "an image is not copied; eject it first"
return gettext_noop("an image is not copied; eject it first")
if disk.find("readonly") is not None or disk.find("encryption") is not None \
or disk.find("source/encryption") is not None:
return "a read-only or encrypted disk is not copied"
return gettext_noop("a read-only or encrypted disk is not copied")
driver = disk.find("driver")
if driver is None or driver.get("type") not in ("raw", "qcow2"):
return "only raw and qcow2 disks are copied"
return gettext_noop("only raw and qcow2 disks are copied")
if volume is None:
return "no storage pool of the source host has it"
return gettext_noop("no storage pool of the source host has it")
if ElementTree.fromstring(volume.XMLDesc(0)).findtext("backingStore/path"):
return "a disk with a backing file is not copied"
return gettext_noop("a disk with a backing file is not copied")
return None
def disk_copies(self, conn, definitions, pools):
@ -339,34 +339,44 @@ class wvmInstances(wvmConnect):
targets.add(row["target"])
carried.add(row["path"])
name = pools.get(row["target"])
label = f"{row['target']} ({row['path']})"
disk = {"target": row["target"], "path": row["path"]}
if not name:
if not row["shared"]:
problems.append(f"{label}: the destination host does not share it; choose a pool to copy it to")
problems.append(util.message(gettext_noop(
"%(target)s (%(path)s): the destination host does not share it; choose a pool to copy it to"),
**disk))
continue
if row["refusal"]:
problems.append(f"{label}: {row['refusal']}")
problems.append(util.message(gettext_noop("%(target)s (%(path)s): %(refusal)s"), **disk,
refusal=[util.message(row["refusal"])]))
continue
if name not in here:
problems.append(f"{label}: the destination host has no pool {name} for disk files")
problems.append(util.message(gettext_noop(
"%(target)s (%(path)s): the destination host has no pool %(pool)s for disk files"), **disk, pool=name))
continue
pool, directory = here[name]
pool.refresh(0)
file = os.path.basename(row["path"])
if self._find_volume(pool, file) is not None:
problems.append(f"{label}: pool {name} of the destination host already has a file {file}")
problems.append(util.message(gettext_noop(
"%(target)s (%(path)s): pool %(pool)s of the destination host already has a file %(file)s"),
**disk, pool=name, file=file))
continue
if any(copy["dest"] == f"{directory}/{file}" for copy in copies):
problems.append(f"{label}: another disk is copied to {directory}/{file}")
problems.append(util.message(gettext_noop("%(target)s (%(path)s): another disk is copied to %(dest)s"),
**disk, dest=f"{directory}/{file}"))
continue
copies.append({"target": row["target"], "path": row["path"], "dest": f"{directory}/{file}",
"pool": pool})
problems += [f"{target}: the VM has no disk file to copy" for target in pools.keys() - targets if pools[target]]
problems += [util.message(gettext_noop("%(target)s: the VM has no disk file to copy"), target=target)
for target in pools.keys() - targets if pools[target]]
for xml in definitions[1:]:
problems += [f"{row['target']} ({row['path']}) of the next start: the destination host does not share it"
for row in self.disk_plan(conn, xml) if row["path"] not in carried and not row["shared"]]
problems += [util.message(gettext_noop(
"%(target)s (%(path)s) of the next start: the destination host does not share it"),
target=row["target"], path=row["path"])
for row in self.disk_plan(conn, xml) if row["path"] not in carried and not row["shared"]]
if problems:
raise util.OperationError("The disks cannot be migrated: " + "; ".join(problems))
raise util.OperationError(gettext_noop("The disks cannot be migrated: %(problems)s"), problems=problems)
return copies
def remove_partial_copies(self, uuid, copies, error, nvram=None):
@ -379,9 +389,8 @@ class wvmInstances(wvmConnect):
except libvirtError as lookup_error:
if lookup_error.get_error_code() != VIR_ERR_NO_DOMAIN:
raise util.OperationError(
f"{error}; remove the partial disk copies from the destination host: "
+ ", ".join(copy["dest"] for copy in copies)
) from error
gettext_noop("%(error)s; remove the partial disk copies from the destination host: %(copies)s"),
error=error, copies=", ".join(copy["dest"] for copy in copies)) from error
left = []
for copy in copies:
copy["pool"].refresh(0)
@ -397,7 +406,7 @@ class wvmInstances(wvmConnect):
left.append(f"{NVRAM_DIR}/{nvram}")
if left:
raise util.OperationError(
f"{error}; remove the partial disk copies from the destination host: {', '.join(left)}"
gettext_noop("%(error)s; remove the partial disk copies from the destination host: %(left)s"), error=error, left=', '.join(left)
) from error
def copy_nvram(self, conn):
@ -414,7 +423,7 @@ class wvmInstances(wvmConnect):
return None # never started: libvirt creates it at the first start
if self.same_directory(source, target):
if self._find_volume(target, name) is None:
raise util.OperationError(f"The destination host cannot see the NVRAM file {path}")
raise util.OperationError(gettext_noop("The destination host cannot see the NVRAM file %(path)s"), path=path)
return None
chunks = []
stream = conn.wvm.newStream(0)
@ -482,7 +491,7 @@ class wvmInstances(wvmConnect):
@staticmethod
def disk_label(pool, name):
if name is None:
return f"{pool} disk, cannot be checked"
return [util.message(gettext_noop("%(type)s disk, cannot be checked"), type=pool)]
return name if pool is None else f"{pool}/{name}"
def find_volume(self, pool, name):
@ -522,7 +531,7 @@ class wvmInstances(wvmConnect):
if error.get_error_code() != VIR_ERR_NO_DOMAIN:
raise
else:
raise util.OperationError(f"The destination host already has a VM {key}")
raise util.OperationError(gettext_noop("The destination host already has a VM %(key)s"), key=key)
xml = dom.XMLDesc(VIR_DOMAIN_XML_SECURE | VIR_DOMAIN_XML_MIGRATABLE)
autostart = dom.autostart()
@ -544,7 +553,7 @@ class wvmInstances(wvmConnect):
if started:
# started meanwhile by another client: it stays on the source
dom.connect().defineXML(xml).setAutostart(autostart)
raise util.OperationError("The VM was started on the source host during the migration")
raise util.OperationError(gettext_noop("The VM was started on the source host during the migration"))
except Exception as error:
self.undo_definition(dom, new, error) # raises when the destination keeps the VM
self.delete_nvram(nvram)
@ -560,19 +569,19 @@ class wvmInstances(wvmConnect):
on_source = False
if not on_source:
raise util.OperationError(
f"{error}; the VM may now be defined only on the destination host: check both hosts"
gettext_noop("%(error)s; the VM may now be defined only on the destination host: check both hosts"), error=error
) from error
try:
new.undefineFlags(VIR_DOMAIN_UNDEFINE_KEEP_NVRAM)
except libvirtError as undo_error:
raise util.OperationError(
f"{error}; the VM is now defined on both hosts, remove it from the destination: {undo_error}"
gettext_noop("%(error)s; the VM is now defined on both hosts, remove it from the destination: %(undo_error)s"), error=error, undo_error=undo_error
) from error
def check_snapshot_name(name):
if not SNAPSHOT_NAME.fullmatch(name):
raise util.OperationError("Snapshot names are 1-64 letters, digits, '.', '_' or '-' and do not start with '.'")
raise util.OperationError(gettext_noop("Snapshot names are 1-64 letters, digits, '.', '_' or '-' and do not start with '.'"))
def agent_command(dom, command, arguments=None):
@ -1088,7 +1097,7 @@ class wvmInstance(wvmConnect):
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
# the order lists disks, CD-ROM drives and NICs: it does not drop another device's place
if tree.xpath("./devices/*[not(self::disk[@device='disk' or @device='cdrom'] or self::interface)]/boot"):
raise util.OperationError("A device other than a disk or NIC is in the boot order: change it in the XML editor")
raise util.OperationError(gettext_noop("A device other than a disk or NIC is in the boot order: change it in the XML editor"))
# it replaces the order by device type and every device's place
for boot in tree.xpath("./os/boot | ./devices/*/boot"):
boot.getparent().remove(boot)
@ -1100,10 +1109,10 @@ class wvmInstance(wvmConnect):
elif dev["type"] == "network": # any NIC type: network, bridge, direct
devices = tree.xpath("./devices/interface[mac/@address=$mac]", mac=dev["dev"])
else:
raise util.OperationError("Invalid Device Type for boot order")
raise util.OperationError(gettext_noop("Invalid Device Type for boot order"))
# nothing is defined until every choice is found: the order above is cleared
if not devices:
raise util.OperationError(f"Boot device {dev['dev']} is not in the definition")
raise util.OperationError(gettext_noop("Boot device %(dev)s is not in the definition"), dev=dev['dev'])
for d in devices:
d.append(order)
self._defineXML(etree.tostring(tree).decode())
@ -1123,7 +1132,9 @@ class wvmInstance(wvmConnect):
tree = etree.fromstring(self._XMLDesc(xml_flags))
found = tree.xpath("./devices/disk[@device='cdrom'][target/@dev=$dev]", dev=dev)
if not found:
raise util.OperationError(f"CD-ROM {dev} is not in the {label} definition")
missing = (gettext_noop("CD-ROM %(dev)s is not in the running definition") if label == "running"
else gettext_noop("CD-ROM %(dev)s is not in the persistent definition"))
raise util.OperationError(missing, dev=dev)
devices.append(found[0])
for (label, _, affect), disk in zip(definitions, devices):
for source in disk.findall("source"):
@ -1134,7 +1145,7 @@ class wvmInstance(wvmConnect):
self.instance.updateDeviceFlags(etree.tostring(disk).decode(), affect)
except libvirtError as err:
if label == "persistent" and len(definitions) == 2:
raise util.OperationError(f"Media of {dev} changed in the running VM only: {err}") from err
raise util.OperationError(gettext_noop("Media of %(dev)s changed in the running VM only: %(err)s"), dev=dev, err=err) from err
raise
def get_iso_path(self, image):
@ -1145,9 +1156,9 @@ class wvmInstance(wvmConnect):
if image in stg.listVolumes():
paths.append(stg.storageVolLookupByName(image).path())
if not paths:
raise util.OperationError(f"ISO image {image} was not found")
raise util.OperationError(gettext_noop("ISO image %(image)s was not found"), image=image)
if len(paths) > 1:
raise util.OperationError(f"ISO image {image} is in more than one storage pool")
raise util.OperationError(gettext_noop("ISO image %(image)s is in more than one storage pool"), image=image)
return paths[0]
def mount_iso(self, dev, image):
@ -1254,7 +1265,7 @@ class wvmInstance(wvmConnect):
found = True
self.instance.detachDeviceFlags(etree.tostring(disks[0]).decode(), affect)
if not found:
raise util.OperationError(f"Disk {target_dev} is not in the VM's definition")
raise util.OperationError(gettext_noop("Disk %(target_dev)s is not in the VM's definition"), target_dev=target_dev)
def has_disk(self, target_dev):
"""True while the live or the persistent definition still has target_dev."""
@ -1382,7 +1393,7 @@ class wvmInstance(wvmConnect):
self._defineXML(etree.tostring(tree).decode())
else:
raise util.OperationError(
"Please shutdown the instance then try to enable vCPU hotplug"
gettext_noop("Please shutdown the instance then try to enable vCPU hotplug")
)
def mem_usage(self):
@ -1845,7 +1856,7 @@ class wvmInstance(wvmConnect):
def create_snapshot(self, name, desc=None):
self._check_snapshot_state()
if self.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
raise util.OperationError("The VM has external snapshots; internal snapshots cannot be mixed with them")
raise util.OperationError(gettext_noop("The VM has external snapshots; internal snapshots cannot be mixed with them"))
state = "shutoff" if self.get_status() == 5 else "running"
name_esc = util.xml_escape(name)
desc_esc = util.xml_escape(desc) if desc else ""
@ -1877,30 +1888,30 @@ class wvmInstance(wvmConnect):
check_snapshot_name(name)
if quiesce:
if memory:
raise util.OperationError("A snapshot with memory is not quiesced; it keeps the whole guest state")
raise util.OperationError(gettext_noop("A snapshot with memory is not quiesced; it keeps the whole guest state"))
if self.get_status() != 1:
raise util.OperationError("Only a running VM's file systems can be frozen")
raise util.OperationError(gettext_noop("Only a running VM's file systems can be frozen"))
if not self.is_agent_ready():
raise util.OperationError("The QEMU guest agent is not connected; it freezes the file systems")
raise util.OperationError(gettext_noop("The QEMU guest agent is not connected; it freezes the file systems"))
self._check_snapshot_state()
if self.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL):
raise util.OperationError("The VM has internal snapshots; external snapshots cannot be mixed with them")
raise util.OperationError(gettext_noop("The VM has internal snapshots; external snapshots cannot be mixed with them"))
if name in self.instance.snapshotListNames(0):
raise util.OperationError(f"A snapshot named {name} already exists")
raise util.OperationError(gettext_noop("A snapshot named %(name)s already exists"), name=name)
if self.instance.hasCurrentSnapshot(0):
if self.instance.snapshotCurrent(0).numChildren(0):
raise util.OperationError("Newer snapshots exist after the last revert; delete them first")
raise util.OperationError(gettext_noop("Newer snapshots exist after the last revert; delete them first"))
elif self.instance.snapshotListNames(0):
# only after changes outside WebVirtCloud; a create would start a second root
raise util.OperationError("No snapshot is current; revert to a snapshot first")
raise util.OperationError(gettext_noop("No snapshot is current; revert to a snapshot first"))
tree = etree.fromstring(self._XMLDesc(0))
for disk in tree.xpath("devices/disk"):
dev = disk.xpath("string(target/@dev)")
if disk.get("device") in ("cdrom", "floppy"):
if disk.xpath("source/@file|source/@dev|source/@name"):
raise util.OperationError(f"Eject the media from {dev} first")
raise util.OperationError(gettext_noop("Eject the media from %(dev)s first"), dev=dev)
elif disk.get("type") != "file":
raise util.OperationError(f"Disk {dev} is not a file; external snapshots need file disks")
raise util.OperationError(gettext_noop("Disk %(dev)s is not a file; external snapshots need file disks"), dev=dev)
disks = list(self._disk_sources(tree).values())
# free space, and the top overlays, are known to the pools only after
# a refresh; nothing has changed yet when it fails
@ -1908,7 +1919,7 @@ class wvmInstance(wvmConnect):
self.refresh_instance_pools()
except libvirtError as err:
raise util.OperationError(
f"The storage pools could not be refreshed, so their free space is unknown: {err}"
gettext_noop("The storage pools could not be refreshed, so their free space is unknown: %(err)s"), err=err
) from None
memory_xml, memory_pool = self._snapshot_memory(name, disks) if memory else ("", None)
self._check_snapshot_space(disks, memory_pool, free_percent, free_min_gib)
@ -1929,13 +1940,14 @@ class wvmInstance(wvmConnect):
"""The <memory> element of a snapshot that saves memory, and the
storage pool its file goes into: the pool of the first disk."""
if self.get_status() != 1:
raise util.OperationError("Memory is saved only from a running VM")
raise util.OperationError(gettext_noop("Memory is saved only from a running VM"))
if not disks:
raise util.OperationError("The VM has no disk to keep its memory next to")
raise util.OperationError(gettext_noop("The VM has no disk to keep its memory next to"))
try:
pool = self.get_volume_by_path(disks[0]).storagePoolLookupByVolume()
except libvirtError:
raise util.OperationError(f"{disks[0]} is not in a storage pool; memory is saved only into one") from None
raise util.OperationError(gettext_noop("%(disk)s is not in a storage pool; memory is saved only into one"),
disk=disks[0]) from None
path = os.path.join(os.path.dirname(disks[0]), f"{self.get_uuid()}.{name}.mem")
return f"<memory snapshot='external' file='{util.xml_escape(path)}'/>", pool.name()
@ -1964,11 +1976,10 @@ class wvmInstance(wvmConnect):
for name, size in needed.items():
free = pools[name].info()[3]
if free < size:
raise util.OperationError(
f"Storage pool {name} has {free / 1024**3:.1f} GiB free; the snapshot needs {size / 1024**3:.1f} GiB "
f"({free_percent:g}% of the VM's disks in it, at least {free_min_gib:g} GiB, "
"and the memory if it is saved)"
)
raise util.OperationError(gettext_noop(
"Storage pool %(pool)s has %(free).1f GiB free; the snapshot needs %(size).1f GiB (%(percent)g%% "
"of the VM's disks in it, at least %(minimum)g GiB, and the memory if it is saved)"),
pool=name, free=free / 1024**3, size=size / 1024**3, percent=free_percent, minimum=free_min_gib)
def check_external_revert(self, name):
"""Refuses a revert that cannot work, before anything changes; returns
@ -1984,7 +1995,7 @@ class wvmInstance(wvmConnect):
current_loader = util.get_xml_path(self._XMLDesc(PERSISTENT_XML), "/domain/os/loader/@type")
if saved_loader == "rom" and current_loader == "pflash":
raise util.OperationError(
"This snapshot was taken by an older version on a UEFI VM and cannot be reverted; delete it instead"
gettext_noop("This snapshot was taken by an older version on a UEFI VM and cannot be reverted; delete it instead")
)
return snap
@ -2014,10 +2025,10 @@ class wvmInstance(wvmConnect):
try:
self.instance.revertToSnapshot(snap, flags)
except libvirtError as retry_err:
state = "running" if self.instance.isActive() else "shut off"
raise util.OperationError(
f"The revert failed twice; try again in a moment ({retry_err}). The VM is {state}."
) from None
failed = (gettext_noop("The revert failed twice; try again in a moment (%(error)s). The VM is running.")
if self.instance.isActive() else
gettext_noop("The revert failed twice; try again in a moment (%(error)s). The VM is shut off."))
raise util.OperationError(failed, error=retry_err) from None
return self._left_behind(before)
def delete_external_snapshot(self, name):
@ -2179,9 +2190,9 @@ class wvmInstance(wvmConnect):
dom = self.instance
state = dom.info()[0]
if not dom.isPersistent() or state not in (VIR_DOMAIN_RUNNING, VIR_DOMAIN_PAUSED, VIR_DOMAIN_SHUTOFF):
raise util.OperationError("A disk moves when its VM is defined and running, paused or shut off")
raise util.OperationError(gettext_noop("A disk moves when its VM is defined and running, paused or shut off"))
if dom.snapshotNum(0) or dom.hasManagedSaveImage(0) or self.get_snapshot_jobs():
raise util.OperationError("A disk of a VM with snapshots, a saved state or a running block job does not move")
raise util.OperationError(gettext_noop("A disk of a VM with snapshots, a saved state or a running block job does not move"))
# the running definition (the persistent one of a shut-off VM) first
disks = [ElementTree.fromstring(xml).find(f"devices/disk/target[@dev='{dev}']/..")
for xml in (self._XMLDesc(0), self._XMLDesc(PERSISTENT_XML))]
@ -2189,32 +2200,31 @@ class wvmInstance(wvmConnect):
disk, source = disks[0], sources[0]
if disk is None or disk.get("type") != "file" or disk.get("device") != "disk" or None in sources \
or len({s.get("file") for s in sources}) != 1:
raise util.OperationError(f"{dev} is not a disk file of the VM, the same in its running and next start's "
"definition")
raise util.OperationError(gettext_noop("%(dev)s is not a disk file of the VM, the same in its running and next start's definition"), dev=dev)
driver = disk.find("driver")
fmt = driver.get("type") if driver is not None else None
if fmt not in ("raw", "qcow2") or disk.find("readonly") is not None or disk.find("shareable") is not None \
or disk.find("encryption") is not None or source.find("encryption") is not None:
raise util.OperationError(f"{dev} does not move: only writable raw and qcow2 disks do")
raise util.OperationError(gettext_noop("%(dev)s does not move: only writable raw and qcow2 disks do"), dev=dev)
path = source.get("file")
try:
volume = self.wvm.storageVolLookupByPath(path)
except libvirtError as err:
if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL:
raise
raise util.OperationError(f"{dev} does not move: no storage pool has {path}") from err
raise util.OperationError(gettext_noop("%(dev)s does not move: no storage pool has %(path)s"), dev=dev, path=path) from err
if ElementTree.fromstring(volume.XMLDesc(0)).findtext("backingStore/path"):
raise util.OperationError(f"{dev} does not move: it has a backing file")
raise util.OperationError(gettext_noop("%(dev)s does not move: it has a backing file"), dev=dev)
pools = self.file_pools()
if pool_name not in pools:
raise util.OperationError(f"{pool_name} is not an active pool of disk files of this host")
raise util.OperationError(gettext_noop("%(pool_name)s is not an active pool of disk files of this host"), pool_name=pool_name)
pool, directory = pools[pool_name]
if directory == os.path.dirname(path):
raise util.OperationError(f"{dev} is in pool {pool_name} already")
raise util.OperationError(gettext_noop("%(dev)s is in pool %(pool_name)s already"), dev=dev, pool_name=pool_name)
pool.refresh(0)
name = os.path.basename(path)
if self._find_volume(pool, name) is not None:
raise util.OperationError(f"Pool {pool_name} already has a file {name}")
raise util.OperationError(gettext_noop("Pool %(pool_name)s already has a file %(name)s"), pool_name=pool_name, name=name)
return {"dev": dev, "path": path, "dest": f"{directory}/{name}", "format": fmt, "pool": pool,
"volume": volume, "running": state != VIR_DOMAIN_SHUTOFF}
@ -2238,7 +2248,7 @@ class wvmInstance(wvmConnect):
while True: # mirrored once the copy caught up: cur == end
info = dom.blockJobInfo(dev, 0)
if not info:
raise util.OperationError(f"The copy of {dev} ended before it was complete")
raise util.OperationError(gettext_noop("The copy of %(dev)s ended before it was complete"), dev=dev)
if info["end"] and info["cur"] == info["end"]:
break
time.sleep(1)
@ -2283,16 +2293,17 @@ class wvmInstance(wvmConnect):
if copy is not None:
copy.delete(0)
except libvirtError as cleanup_error:
raise util.OperationError(f"{error}; remove {dest} if it is there: {cleanup_error}") from error
raise util.OperationError(gettext_noop("%(error)s; remove %(dest)s if it is there: %(cleanup_error)s"), error=error, dest=dest, cleanup_error=cleanup_error) from error
if named:
raise util.OperationError(f"{error}; the VM may use {dest} now: check its disk {dev}") from error
raise util.OperationError(gettext_noop("%(error)s; the VM may use %(dest)s now: check its disk %(dev)s"), error=error, dest=dest, dev=dev) from error
def _snapshot(self, name, external):
"""The named snapshot; a snapshot of the other kind is refused."""
flag = VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL if external else VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL
if name not in self.instance.snapshotListNames(flag):
kind = "external" if external else "internal"
raise util.OperationError(f"The VM has no {kind} snapshot named {name}")
missing = (gettext_noop("The VM has no external snapshot named %(name)s") if external
else gettext_noop("The VM has no internal snapshot named %(name)s"))
raise util.OperationError(missing, name=name)
return self.instance.snapshotLookupByName(name, 0)
def _check_snapshot_state(self):
@ -2300,9 +2311,9 @@ class wvmInstance(wvmConnect):
external. The internal/external mix is checked only on create, so a
VM that is already mixed can still be cleaned up."""
if self.instance.hasManagedSaveImage(0):
raise util.OperationError("The VM has a saved state; start it or remove the saved state first")
raise util.OperationError(gettext_noop("The VM has a saved state; start it or remove the saved state first"))
if self.get_snapshot_jobs():
raise util.OperationError("A snapshot job is still running on this VM; try again when it ends")
raise util.OperationError(gettext_noop("A snapshot job is still running on this VM; try again when it ends"))
def snapshot_delete(self, snapshot):
self._check_snapshot_state()
@ -2312,7 +2323,7 @@ class wvmInstance(wvmConnect):
"""An internal snapshot is reverted to on a shut-off VM; one of a
running VM starts it again as it was then."""
if self.get_status() != 5:
raise util.OperationError("Shut the VM down to revert to an internal snapshot")
raise util.OperationError(gettext_noop("Shut the VM down to revert to an internal snapshot"))
self._check_snapshot_state()
self.instance.revertToSnapshot(self._snapshot(snapshot, external=False), 0)
@ -2401,7 +2412,7 @@ class wvmInstance(wvmConnect):
nvram = tree.find("os/nvram")
if nvram is not None and nvram.get("type", "file") != "file":
raise util.OperationError("Cannot clone a VM whose NVRAM is not a file")
raise util.OperationError(gettext_noop("Cannot clone a VM whose NVRAM is not a file"))
return tree, disks
def _clone_volume_xml(self, name, capacity, vol_format, owner_uid, owner_gid):
@ -2469,7 +2480,7 @@ class wvmInstance(wvmConnect):
copy.delete(0)
except libvirtError:
raise util.OperationError(
f"Clone failed ({err}); remove these copies by hand: {copy.path()}"
gettext_noop("Clone failed (%(err)s); remove these copies by hand: %(path)s"), err=err, path=copy.path()
) from err
raise
@ -2479,7 +2490,7 @@ class wvmInstance(wvmConnect):
the volumes this call created are removed. Returns the clone's UUID."""
if self.get_status() != 5:
# A running guest keeps writing while its disks are copied.
raise util.OperationError("Shut the VM down before cloning it")
raise util.OperationError(gettext_noop("Shut the VM down before cloning it"))
_, disks = self._clone_definition(clone_data, uuid)
paths = {} # dev: path of its copy
@ -2533,7 +2544,7 @@ class wvmInstance(wvmConnect):
except libvirtError:
leftovers.append(path)
if leftovers:
raise util.OperationError(f"Clone failed ({err}); remove these copies by hand: {', '.join(leftovers)}") from err
raise util.OperationError(gettext_noop("Clone failed (%(err)s); remove these copies by hand: %(leftovers)s"), err=err, leftovers=', '.join(leftovers)) from err
raise
def live_clone_targets(self, clone_data, free_percent=10, free_min_gib=1):
@ -2544,22 +2555,22 @@ class wvmInstance(wvmConnect):
free_min_gib GiB, free (the snapshots' rule); checked only, nothing
is reserved."""
if self.wvm.getLibVersion() < LIVE_CLONE_MIN_LIBVIRT:
raise util.OperationError("Live clone needs libvirt 11.10 or later; shut the VM down to clone it")
raise util.OperationError(gettext_noop("Live clone needs libvirt 11.10 or later; shut the VM down to clone it"))
tree, disks = self._clone_definition(clone_data, "")
if tree.find("devices/tpm") is not None:
raise util.OperationError("A VM with a TPM is cloned only shut down")
raise util.OperationError(gettext_noop("A VM with a TPM is cloned only shut down"))
for source, dev in disks:
if not source.get("file"):
raise util.OperationError(f"Disk {dev} is not a local file; shut the VM down to clone it")
raise util.OperationError(gettext_noop("Disk %(dev)s is not a local file; shut the VM down to clone it"), dev=dev)
if source.getparent().find("shareable") is not None:
raise util.OperationError(f"Disk {dev} is shareable; shut the VM down to clone it")
raise util.OperationError(gettext_noop("Disk %(dev)s is shareable; shut the VM down to clone it"), dev=dev)
# the backup copies the running disks: one the next start changes would give the clone the wrong data
live = etree.fromstring(self._XMLDesc(0))
for source, dev in disks:
if live.xpath("devices/disk[target/@dev=$dev]/source/@file", dev=dev) != [source.get("file")]:
raise util.OperationError(f"Disk {dev} changes at the next start; shut the VM down to clone it")
raise util.OperationError(gettext_noop("Disk %(dev)s changes at the next start; shut the VM down to clone it"), dev=dev)
if self.instance.jobInfo()[0] != VIR_DOMAIN_JOB_NONE:
raise util.OperationError("Another job is running on the VM; clone it when that ends")
raise util.OperationError(gettext_noop("Another job is running on the VM; clone it when that ends"))
targets, pools, copied = [], {}, {}
for source, dev in disks:
@ -2572,7 +2583,7 @@ class wvmInstance(wvmConnect):
if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL:
raise
else:
raise util.OperationError(f"Volume {name} already exists in storage pool {pool.name()}")
raise util.OperationError(gettext_noop("Volume %(name)s already exists in storage pool %(pool)s"), name=name, pool=pool.name())
capacity = vol.info()[1]
pools[pool.name()] = pool
copied[pool.name()] = copied.get(pool.name(), 0) + capacity
@ -2590,10 +2601,10 @@ class wvmInstance(wvmConnect):
needed = size + max(size * free_percent / 100, free_min_gib * 1024**3)
free = pools[name].info()[3]
if free < needed:
raise util.OperationError(
f"Storage pool {name} has {free / 1024**3:.1f} GiB free; the clone needs {needed / 1024**3:.1f} GiB "
f"(the copied disks, plus {free_percent:g}% of them, at least {free_min_gib:g} GiB)"
)
raise util.OperationError(gettext_noop(
"Storage pool %(pool)s has %(free).1f GiB free; the clone needs %(size).1f GiB (the copied disks, "
"plus %(percent)g%% of them, at least %(minimum)g GiB)"),
pool=name, free=free / 1024**3, size=needed / 1024**3, percent=free_percent, minimum=free_min_gib)
return targets
def create_clone_target(self, target, owner_uid, owner_gid):
@ -2730,7 +2741,7 @@ class wvmInstance(wvmConnect):
for xml_flags, affect in self._definitions():
nic = self._nic(xml_flags, mac_address)
if nic is not None and nic.get("type") == "hostdev":
raise util.OperationError("A network interface that is a host device is removed in the XML editor")
raise util.OperationError(gettext_noop("A network interface that is a host device is removed in the XML editor"))
if nic is not None:
removed = etree.tostring(nic).decode()
self.instance.detachDeviceFlags(removed, affect)
@ -2746,10 +2757,10 @@ class wvmInstance(wvmConnect):
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
found = tree.xpath("./devices/interface[mac/@address=$mac]", mac=old_mac)
if not found:
raise util.OperationError(f"No network interface with MAC {old_mac}")
raise util.OperationError(gettext_noop("No network interface with MAC %(old_mac)s"), old_mac=old_mac)
iface = found[0]
if iface.get("type") == "hostdev":
raise util.OperationError("A network interface that is a host device is changed in the XML editor")
raise util.OperationError(gettext_noop("A network interface that is a host device is changed in the XML editor"))
if source is not None:
iface_type = {"net": "network", "bridge": "bridge"}.get(source_type, "direct")
@ -2840,7 +2851,7 @@ class wvmInstance(wvmConnect):
refused."""
status = self.instance.info()[0]
if self.instance.hasManagedSaveImage(0) or status not in (VIR_DOMAIN_RUNNING, VIR_DOMAIN_SHUTOFF):
raise util.OperationError("A VM changes its PCI devices while it runs, or is shut off without a saved state")
raise util.OperationError(gettext_noop("A VM changes its PCI devices while it runs, or is shut off without a saved state"))
return status == VIR_DOMAIN_RUNNING
def _pci_outcome(self, address, err):
@ -2852,12 +2863,20 @@ class wvmInstance(wvmConnect):
now = running and self._pci_hostdev(address, VIR_DOMAIN_XML_SECURE) is not None
later = self._pci_hostdev(address) is not None
except libvirtError:
return util.OperationError(f"{err}; whether the VM has PCI device {address} now is not known")
next_start = "its next start has it" if later else "its next start does not have it"
if not running:
return util.OperationError(f"{err}; PCI device {address}: the VM is not running, {next_start}")
now_text = "the running VM has it" if now else "the running VM does not have it"
return util.OperationError(f"{err}; PCI device {address}: {now_text}, {next_start}")
return util.OperationError(gettext_noop("%(err)s; whether the VM has PCI device %(address)s now is not known"), err=err, address=address)
outcome = {
(False, True): gettext_noop("%(err)s; PCI device %(address)s: the VM is not running, its next start has it"),
(False, False): gettext_noop(
"%(err)s; PCI device %(address)s: the VM is not running, its next start does not have it"),
(True, True): gettext_noop("%(err)s; PCI device %(address)s: the running VM has it, its next start has it"),
(True, False): gettext_noop(
"%(err)s; PCI device %(address)s: the running VM has it, its next start does not have it"),
(None, True): gettext_noop(
"%(err)s; PCI device %(address)s: the running VM does not have it, its next start has it"),
(None, False): gettext_noop(
"%(err)s; PCI device %(address)s: the running VM does not have it, its next start does not have it"),
}[(now or None) if running else False, later]
return util.OperationError(outcome, err=err, address=address)
def add_pci_hostdev(self, address):
"""Gives the VM the host's PCI device at address (0000:05:00.0), for
@ -2889,7 +2908,7 @@ class wvmInstance(wvmConnect):
live = self._pci_hostdev(address, VIR_DOMAIN_XML_SECURE) if running else None
saved = self._pci_hostdev(address)
if live is None and saved is None:
raise util.OperationError(f"The VM has no PCI device {address}")
raise util.OperationError(gettext_noop("The VM has no PCI device %(address)s"), address=address)
try:
if live is not None: # a refusal stops here; a request the guest has not answered goes on
self.instance.detachDeviceFlags(etree.tostring(live, encoding="unicode"), VIR_DOMAIN_AFFECT_LIVE)
@ -2907,7 +2926,7 @@ class wvmInstance(wvmConnect):
model. Refused without a USB controller."""
for xml_flags, _ in self._definitions():
if not etree.fromstring(self._XMLDesc(xml_flags)).xpath("devices/controller[@type='usb'][not(@model='none')]"):
raise util.OperationError("The VM has no USB controller")
raise util.OperationError(gettext_noop("The VM has no USB controller"))
hostdev = etree.Element("hostdev", mode="subsystem", type="usb", managed="yes")
source = etree.SubElement(hostdev, "source")
etree.SubElement(source, "vendor", id=vendor)
@ -2926,7 +2945,7 @@ class wvmInstance(wvmConnect):
found = True
self.instance.detachDeviceFlags(etree.tostring(element, encoding="unicode"), affect)
if not found:
raise util.OperationError("The VM has no such USB device")
raise util.OperationError(gettext_noop("The VM has no such USB device"))
def set_link_state(self, mac_address, state):
for xml_flags, affect in self._definitions():
@ -2967,7 +2986,7 @@ class wvmInstance(wvmConnect):
"""Adds the panel's virtio RNG device to the persistent definition, or
removes it; another RNG device is left to the XML editor."""
if self.get_rng() == "custom":
raise util.OperationError("The VM has its own RNG device: change it in the XML editor")
raise util.OperationError(gettext_noop("The VM has its own RNG device: change it in the XML editor"))
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
for rng in tree.xpath("devices/rng"):
rng.getparent().remove(rng)
@ -2994,14 +3013,14 @@ class wvmInstance(wvmConnect):
another TPM is left to the XML editor. Its state stays on the host until
the VM is undefined, so a TPM added again has it back."""
if self.get_tpm() == "custom":
raise util.OperationError("The VM has its own TPM: change it in the XML editor")
raise util.OperationError(gettext_noop("The VM has its own TPM: change it in the XML editor"))
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
for tpm in tree.xpath("devices/tpm"):
tpm.getparent().remove(tpm)
if enabled:
model = self.get_tpm_model(self.get_arch(), self.get_machine_type())
if not model:
raise util.OperationError("This host offers no TPM 2.0: install swtpm on it")
raise util.OperationError(gettext_noop("This host offers no TPM 2.0: install swtpm on it"))
tree.find("devices").append(
etree.fromstring(f"<tpm model='{model}'><backend type='emulator' version='2.0'/></tpm>"))
self._defineXML(etree.tostring(tree).decode())
@ -3013,7 +3032,7 @@ class wvmInstance(wvmConnect):
def set_on_crash(self, action):
"""restart or destroy (stop) the VM when the guest crashes, from the next start."""
if action not in ("restart", "destroy"):
raise util.OperationError(f"Unknown crash action {action}")
raise util.OperationError(gettext_noop("Unknown crash action %(action)s"), action=action)
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
on_crash = tree.find("on_crash")
if on_crash is None:

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import contextlib
from functools import cached_property
from xml.etree import ElementTree
@ -31,8 +32,7 @@ def changes(conn):
try:
conn.changeRollback(0)
except libvirtError as rollback_err:
raise util.OperationError(f"{err}; rolling the change back failed too, "
f"the host's configuration may be changed: {rollback_err}") from err
raise util.OperationError(gettext_noop("%(err)s; rolling the change back failed too, the host's configuration may be changed: %(rollback_err)s"), err=err, rollback_err=rollback_err) from err
raise
@ -266,12 +266,11 @@ class wvmInterface(wvmConnect):
refused, which of them takes the addresses being the user's choice.
All of it is one change of the host's configuration."""
if self.iface.isActive():
raise util.OperationError(f"Stop interface {self.iface.name()} before deleting it")
raise util.OperationError(gettext_noop("Stop interface %(iface)s before deleting it"), iface=self.iface.name())
tree = ElementTree.fromstring(self.iface.XMLDesc(VIR_INTERFACE_XML_INACTIVE))
members = tree.findall("bridge/interface")
if len(members) > 1 and tree.findall("protocol"):
raise util.OperationError(f"Bridge {self.iface.name()} has addresses and several members: "
"give the addresses to one of them on the host first")
raise util.OperationError(gettext_noop("Bridge %(iface)s has addresses and several members: give the addresses to one of them on the host first"), iface=self.iface.name())
with changes(self.wvm):
self.iface.undefine()
for member in members:

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import copy
import ipaddress
from datetime import datetime, timezone
@ -295,15 +296,13 @@ class wvmNetwork(wvmConnect):
def stop(self):
"""Refused while a running VM is on it: that VM would be cut off."""
if vms := self._vms_on_it(running_only=True):
raise util.OperationError(f"Network {self.net.name()} is in use by the running VMs {', '.join(vms)}; "
"shut them down or remove their network cards first")
raise util.OperationError(gettext_noop("Network %(net)s is in use by the running VMs %(vms)s; shut them down or remove their network cards first"), net=self.net.name(), vms=', '.join(vms))
self.net.destroy()
def delete(self):
"""Refused while a VM's definition names it: that VM would no longer start."""
if vms := self._vms_on_it(running_only=False):
raise util.OperationError(f"VMs {', '.join(vms)} use network {self.net.name()} and would no longer "
"start; remove their network cards first")
raise util.OperationError(gettext_noop("VMs %(vms)s use network %(net)s and would no longer start; remove their network cards first"), vms=', '.join(vms), net=self.net.name())
self.net.undefine()
def update(self, command, section, xml, parentIndex):
@ -312,8 +311,7 @@ class wvmNetwork(wvmConnect):
active = self.is_active()
if active and self._addresses(self.tree) != self._addresses(self.config()):
# parentIndex names the same <ip> in both only when they list the same addresses
raise util.OperationError(f"Network {self.net.name()} runs with other addresses than it has saved; "
"stop and start it first")
raise util.OperationError(gettext_noop("Network %(net)s runs with other addresses than it has saved; stop and start it first"), net=self.net.name())
flags = VIR_NETWORK_UPDATE_AFFECT_CONFIG | (VIR_NETWORK_UPDATE_AFFECT_LIVE if active else 0)
self.net.update(command, section, parentIndex, xml, flags)
self._forget()
@ -338,36 +336,47 @@ class wvmNetwork(wvmConnect):
tree = self.config()
ips = tree.findall("ip")
if not 0 <= index < len(ips):
raise util.OperationError(f"Network {self.net.name()} has no such address")
raise util.OperationError(gettext_noop("Network %(net)s has no such address"), net=self.net.name())
return tree, ips[index], ip_networks(tree)[index]
# an address outside the network: (with its subnet, without one)
OUTSIDE = {
"range start": (gettext_noop("The range start %(address)s is not in %(subnet)s"),
gettext_noop("The range start %(address)s is not in the network")),
"range end": (gettext_noop("The range end %(address)s is not in %(subnet)s"),
gettext_noop("The range end %(address)s is not in the network")),
"fixed address": (gettext_noop("The fixed address %(address)s is not in %(subnet)s"),
gettext_noop("The fixed address %(address)s is not in the network")),
}
def _checked_address(self, ip, address, what):
"""address as an address of the <ip> ip (ip_networks): of its family and
subnet, and not the host's own."""
try:
address = ipaddress.ip_address(address)
except ValueError:
raise util.OperationError(f"{address} is not an IP address") from None
raise util.OperationError(gettext_noop("%(address)s is not an IP address"), address=address) from None
if ip["subnet"] is None or address not in ip["subnet"]:
raise util.OperationError(f"The {what} {address} is not in {ip['subnet'] or 'the network'}")
outside = self.OUTSIDE[what][ip["subnet"] is None]
raise util.OperationError(outside, address=address, subnet=ip["subnet"])
if address == ipaddress.ip_address(ip["address"]):
raise util.OperationError(f"{address} is the host's own address on the network")
raise util.OperationError(gettext_noop("%(address)s is the host's own address on the network"), address=address)
return address
def modify_dhcp_range(self, index, range_start, range_end):
"""Refused while the network runs: the running network would keep the old range."""
if self.is_active():
raise util.OperationError(f"Stop network {self.net.name()} to change its DHCP range")
raise util.OperationError(gettext_noop("Stop network %(net)s to change its DHCP range"), net=self.net.name())
tree, ip, info = self._ip(index)
dhcp_range = ip.find("dhcp/range")
if dhcp_range is None:
raise util.OperationError(f"This address of network {self.net.name()} has no DHCP range")
raise util.OperationError(gettext_noop("This address of network %(net)s has no DHCP range"), net=self.net.name())
start = self._checked_address(info, range_start, "range start")
end = self._checked_address(info, range_end, "range end")
if start > end:
raise util.OperationError(f"The range starts at {start}, after its end {end}")
raise util.OperationError(gettext_noop("The range starts at %(start)s, after its end %(end)s"), start=start, end=end)
if start <= ipaddress.ip_address(info["address"]) <= end:
raise util.OperationError(f"The range takes in {info['address']}, the host's own address")
raise util.OperationError(gettext_noop("The range takes in %(address)s, the host's own address"), address=info['address'])
dhcp_range.set("start", str(start))
dhcp_range.set("end", str(end))
self._define(tree)
@ -376,7 +385,7 @@ class wvmNetwork(wvmConnect):
_tree, ip, _info = self._ip(index)
host = next((h for h in ip.findall("dhcp/host") if h.get("ip") == address), None)
if host is None:
raise util.OperationError(f"Network {self.net.name()} has no fixed address {address}")
raise util.OperationError(gettext_noop("Network %(net)s has no fixed address %(address)s"), net=self.net.name(), address=address)
# the host as it is: a host may have only a name and an address
self.update(VIR_NETWORK_UPDATE_COMMAND_DELETE, VIR_NETWORK_SECTION_IP_DHCP_HOST,
etree.tostring(host, with_tail=False).decode(), index)
@ -398,16 +407,16 @@ class wvmNetwork(wvmConnect):
if original:
old = next((h for h in hosts if h.get("ip") == original), None)
if old is None:
raise util.OperationError(f"The fixed address {original} is gone meanwhile; reload the page")
raise util.OperationError(gettext_noop("The fixed address %(original)s is gone meanwhile; reload the page"), original=original)
for other in hosts:
if other is old:
continue
if other.get("ip") == str(address):
raise util.OperationError(f"{address} is another entry's fixed address")
raise util.OperationError(gettext_noop("%(address)s is another entry's fixed address"), address=address)
if mac_duid and other.get(key, "").lower() == mac_duid.lower():
raise util.OperationError(f"{mac_duid} has another fixed address, {other.get('ip')}")
raise util.OperationError(gettext_noop("%(mac_duid)s has another fixed address, %(other)s"), mac_duid=mac_duid, other=other.get('ip'))
if name and other.get("name") == name:
raise util.OperationError(f"The name {name} has another fixed address, {other.get('ip')}")
raise util.OperationError(gettext_noop("The name %(name)s has another fixed address, %(other)s"), name=name, other=other.get('ip'))
new = copy.deepcopy(old) if old is not None else etree.Element("host")
new.tail = None
for attribute, value in ((key, mac_duid), ("name", name), ("ip", str(address))):
@ -443,11 +452,11 @@ class wvmNetwork(wvmConnect):
def set_qos(self, direction, average, peak, burst):
if direction not in ("inbound", "outbound"):
raise ValueError("Direction must be inbound or outbound")
raise util.OperationError(gettext_noop("Direction must be inbound or outbound"))
try:
average, peak, burst = int(average), int(peak), int(burst)
except ValueError:
raise ValueError("QoS rates must be whole numbers")
raise util.OperationError(gettext_noop("QoS rates must be whole numbers")) from None
tree = self.config()
band = tree.find("bandwidth")
if band is None:
@ -470,8 +479,8 @@ class wvmNetwork(wvmConnect):
names one, the same UUID, not another network."""
tree = util.parse_xml(new_xml)
if tree.tag != "network" or tree.findtext("name") != self.net.name():
raise util.OperationError(f"The XML must define network {self.net.name()}")
raise util.OperationError(gettext_noop("The XML must define network %(net)s"), net=self.net.name())
if tree.findtext("uuid") not in (None, self.net.UUIDString()):
raise util.OperationError("The XML must keep the network's UUID")
raise util.OperationError(gettext_noop("The XML must keep the network's UUID"))
self.wvm.networkDefineXML(new_xml)
self._forget()

View file

@ -1,4 +1,5 @@
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop
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
@ -104,14 +105,26 @@ def usb_named(selector, model, address):
return "model" if selected is None else None
# Problems are messages (util.message), shown in the reader's language; a
# device's label is one too, put in each problem of it
def pci_label(address):
return [util.message(gettext_noop("PCI device %(address)s"), address=address)]
def usb_label(device):
return [util.message(gettext_noop("USB device %(bus)s/%(device)s (%(product)s)"),
bus=device["bus"], device=device["device"], product=device["product"])]
def device_problems(label, vms, resources, vm):
"""Why a host device cannot be given to the VM named vm: the other VMs
that name it, and what the host has on it."""
problems = []
if others := sorted(set(vms) - {vm}):
problems.append(f"{label} is given to {', '.join(others)}")
problems.append(util.message(gettext_noop("%(device)s is given to %(vms)s"), device=label, vms=", ".join(others)))
if resources:
problems.append(f"{label} holds the host's {', '.join(resources)}")
problems.append(util.message(gettext_noop("%(device)s holds the host's %(resources)s"), device=label,
resources=", ".join(resources)))
return problems
@ -130,29 +143,37 @@ def pci_problems(vm, device, by_address, managed):
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']}"
label = pci_label(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"]
return [util.message(gettext_noop("%(device)s is a bridge"), device=label)]
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")
problems.append(util.message(gettext_noop(
"bind %(address)s to vfio-pci on the host first if it is not needed there"), address=device["address"]))
if device["group"] is None:
return problems + [f"{label} is in no IOMMU group"]
shares = f"{label} shares IOMMU group {device['group']} with"
return problems + [util.message(gettext_noop("%(device)s is in no IOMMU group"), device=label)]
group = {"device": label, "group": device["group"]}
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")
problems.append(util.message(gettext_noop(
"%(device)s shares IOMMU group %(group)s with %(member)s, which cannot be read"), **group, member=member))
elif others := sorted(set(other["vms"]) - {vm}):
problems.append(f"{shares} {member} ({other['product']}), which {', '.join(others)} uses")
problems.append(util.message(gettext_noop(
"%(device)s shares IOMMU group %(group)s with %(member)s (%(product)s), which %(vms)s uses"),
**group, member=member, product=other["product"], vms=", ".join(others)))
elif other["bridge"]:
if other["driver"] not in ("", "pcieport"):
problems.append(f"{shares} the bridge {member} (driver {other['driver']})")
problems.append(util.message(gettext_noop(
"%(device)s shares IOMMU group %(group)s with the bridge %(member)s (driver %(driver)s)"),
**group, member=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")
problems.append(util.message(gettext_noop(
"%(device)s shares IOMMU group %(group)s with %(member)s (%(product)s), which the host "
"(driver %(driver)s) uses"), **group, member=member, product=other["product"], driver=other["driver"]))
return problems
@ -163,20 +184,23 @@ def hostdev_problems(vm, pci, usb, wanted_pci, wanted_usb, managed=frozenset()):
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())]
problems = [util.message(gettext_noop("%(device)s is not on the host"), device=pci_label(address))
for address in sorted(wanted_pci - by_address.keys())]
for device in pci:
if device["address"] in wanted_pci:
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):
label = f"USB device {device['bus']}/{device['device']} ({device['product']})"
label = usb_label(device)
problems += device_problems(label, device["vms"] + device["by_model"], device["resources"], vm)
if device["hub"]:
problems.append(f"{label} is a hub")
problems.append(util.message(gettext_noop("%(device)s is a hub"), device=label))
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)}")
problems.append(util.message(gettext_noop(
"%(device)s is on the PCI controller %(controller)s, given to %(vms)s"),
device=label, controller=controller["address"], vms=", ".join(holders)))
return problems
@ -304,4 +328,5 @@ def check_hostdevs(conn, vm, trees):
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)))
unique = list({util.render(m["text"], m["values"], str): m for m in problems}.values())
raise util.OperationError(gettext_noop("%(problems)s"), problems=unique)

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import copy
import re
from functools import cached_property
@ -58,23 +59,23 @@ class wvmNWFilters(wvmConnect):
host has is refused, as it would replace that filter."""
tree = util.parse_xml(xml)
if tree.tag != "filter":
raise util.OperationError("Enter one <filter> element")
raise util.OperationError(gettext_noop("Enter one <filter> element"))
if not NAME.fullmatch(tree.get("name") or ""):
raise util.OperationError("The filter's name may have letters, digits, dots, dashes and underscores")
raise util.OperationError(gettext_noop("The filter's name may have letters, digits, dots, dashes and underscores"))
for nwfilter in self.wvm.listAllNWFilters(0):
if nwfilter.name() == tree.get("name"):
raise util.OperationError("A network filter with this name already exists")
raise util.OperationError(gettext_noop("A network filter with this name already exists"))
if nwfilter.UUIDString() == tree.findtext("uuid"):
raise util.OperationError("A network filter with this UUID already exists")
raise util.OperationError(gettext_noop("A network filter with this UUID already exists"))
self.wvm.nwfilterDefineXML(xml)
def clone_nwfilter(self, name, clone_name):
"""Define a copy of filter name as clone_name, with its rules,
references and their parameters, and a UUID of its own."""
if not NAME.fullmatch(clone_name):
raise util.OperationError("The filter's name may have letters, digits, dots, dashes and underscores")
raise util.OperationError(gettext_noop("The filter's name may have letters, digits, dots, dashes and underscores"))
if clone_name in self.get_nwfilters():
raise util.OperationError(f"A network filter named {clone_name} already exists")
raise util.OperationError(gettext_noop("A network filter named %(clone_name)s already exists"), clone_name=clone_name)
tree = etree.fromstring(self.get_nwfilter(name).XMLDesc(0))
tree.set("name", clone_name)
tree.remove(tree.find("uuid"))
@ -86,10 +87,10 @@ class wvmNWFilters(wvmConnect):
or a VM using a filter that references it, would not start."""
vms = self.get_users().get(name, [])
if vms:
raise util.OperationError(f"Filter {name} is used by VM {', '.join(vms)}")
raise util.OperationError(gettext_noop("Filter %(name)s is used by VM %(vms)s"), name=name, vms=', '.join(vms))
referencing = [nwfilter["name"] for nwfilter in self.get_nwfilters_info() if name in nwfilter["refs"]]
if referencing:
raise util.OperationError(f"Filter {name} is referenced by filter {', '.join(referencing)}")
raise util.OperationError(gettext_noop("Filter %(name)s is referenced by filter %(referencing)s"), name=name, referencing=', '.join(referencing))
self.get_nwfilter(name).undefine()
@ -155,7 +156,7 @@ class wvmNWFilter(wvmNWFilters):
except ValueError:
index = -1
if not 0 <= index < len(elements) or element_text(elements[index]) != same_text(text):
raise util.OperationError("The filter has changed; reload the page and try again")
raise util.OperationError(gettext_noop("The filter has changed; reload the page and try again"))
tree.remove(elements[index])
self._define(tree)
return elements[index]
@ -165,9 +166,9 @@ class wvmNWFilter(wvmNWFilters):
its name and UUID."""
tree = util.parse_xml(xml)
if tree.tag != "filter" or tree.get("name") != self.get_name():
raise util.OperationError(f"The XML must define filter {self.get_name()}")
raise util.OperationError(gettext_noop("The XML must define filter %(name)s"), name=self.get_name())
if tree.findtext("uuid") != self.get_uuid():
raise util.OperationError("The XML must keep the filter's UUID")
raise util.OperationError(gettext_noop("The XML must keep the filter's UUID"))
self.wvm.nwfilterDefineXML(xml)
def delete_rule(self, index, text):
@ -179,7 +180,7 @@ class wvmNWFilter(wvmNWFilters):
def add_ref(self, name):
if name not in self.get_ref_choices():
raise util.OperationError(f"Filter {name} cannot be referenced: it is this one, referenced already or not on the host")
raise util.OperationError(gettext_noop("Filter %(name)s cannot be referenced: it is this one, referenced already or not on the host"), name=name)
tree = copy.deepcopy(self.tree)
etree.SubElement(tree, "filterref", filter=name)
self._define(tree)
@ -189,7 +190,7 @@ class wvmNWFilter(wvmNWFilters):
protocol per rule, so it is never merged into another rule."""
rule = util.parse_xml(xml)
if rule.tag != "rule":
raise util.OperationError("Enter one <rule> element")
raise util.OperationError(gettext_noop("Enter one <rule> element"))
tree = copy.deepcopy(self.tree)
tree.append(rule)
self._define(tree)

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import contextlib
import libvirt
@ -188,8 +189,7 @@ class wvmStorage(wvmConnect):
running_only=True,
)
if vms:
raise util.OperationError(f"Pool {name} holds disks of the running VMs {', '.join(vms)}; "
"shut them down first")
raise util.OperationError(gettext_noop("Pool %(name)s holds disks of the running VMs %(vms)s; shut them down first"), name=name, vms=', '.join(vms))
return self.pool.destroy()
def delete(self):
@ -197,8 +197,7 @@ class wvmStorage(wvmConnect):
no longer start. A disk given by its file path does not need it."""
name = self.pool.name()
if vms := self.domains_using(lambda domain: domain.xpath("devices/disk/source[@pool=$name]", name=name)):
raise util.OperationError(f"VMs {', '.join(vms)} have disks in pool {name} by its name and would no "
"longer start; detach those disks first")
raise util.OperationError(gettext_noop("VMs %(vms)s have disks in pool %(name)s by its name and would no longer start; detach those disks first"), vms=', '.join(vms), name=name)
self.pool.undefine()
def get_autostart(self):
@ -277,7 +276,7 @@ class wvmStorage(wvmConnect):
"""libvirt deletes any volume; one a VM still needs is refused."""
vol = self.pool.storageVolLookupByName(name)
if vol.path() in self.paths_in_use():
raise util.OperationError(f"Volume {name} is in use: a VM's disk, a backing file or a snapshot's file")
raise util.OperationError(gettext_noop("Volume %(name)s is in use: a VM's disk, a backing file or a snapshot's file"), name=name)
vol.delete(0)
def get_volume_format_type(self, name):

View file

@ -161,6 +161,32 @@ class OperationErrorTestCase(unittest.TestCase):
# a text without values is not formatted: libvirt's may hold a %
self.assertEqual(str(OperationError("100% used")), "100% used")
def test_reads_in_the_active_language_and_keeps_english_in_its_args(self):
from django.utils import translation
from vrtManager.util import OperationError, message
error = OperationError("The disks cannot be migrated: %(problems)s", problems=[
message("%(target)s: the VM has no disk file to copy", target="vda"),
message("%(target)s (%(path)s): %(refusal)s", target="sda", path="/i/os.iso",
refusal=[message("an image is not copied; eject it first")])])
english = ("The disks cannot be migrated: vda: the VM has no disk file to copy; "
"sda (/i/os.iso): an image is not copied; eject it first")
self.assertEqual(error.args[0], english)
with translation.override("tr"):
self.assertNotEqual(str(error), english)
self.assertEqual(error.args[0], english) # logs and the audit log stay English
self.assertIn("vda", str(error))
def test_a_job_keeps_number_values_for_their_formats(self):
from operations.utils import said, words
from vrtManager.util import OperationError
error = OperationError("Storage pool %(pool)s has %(free).1f GiB free; the clone needs %(size).1f GiB "
"(the copied disks, plus %(percent)g%% of them, at least %(minimum)g GiB)",
pool="default", free=1.25, size=3.0, percent=10, minimum=2)
self.assertEqual(words(said(error)), "Storage pool default has 1.2 GiB free; the clone needs 3.0 GiB "
"(the copied disks, plus 10% of them, at least 2 GiB)")
if __name__ == "__main__":
unittest.main()

View file

@ -48,11 +48,11 @@ class TestXmlSafety(unittest.TestCase):
wn.wvm = MagicMock()
# Invalid direction
with self.assertRaises(ValueError):
with self.assertRaises(util.OperationError):
wn.set_qos("lateral", 100, 200, 300)
# Invalid numeric strings
with self.assertRaises(ValueError):
with self.assertRaises(util.OperationError):
wn.set_qos("inbound", "not_a_number", 200, 300)
def test_snapshot_xml_payload_is_escaped(self):

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import ipaddress
import random
import secrets
@ -8,23 +9,47 @@ import libvirt
import lxml.etree as etree
from django.conf import settings
from django.utils.translation import gettext
def message(text, **values):
"""Our text, marked with gettext_noop, with %(name)s for each of values.
A value may be a list of messages: they are put in its place one after
another."""
return {"text": str(text), "values": values}
def render(text, values, translate):
"""text with its values, through translate (gettext, or str for English)."""
def shown(value):
if isinstance(value, list):
return "; ".join(render(m["text"], m["values"], translate) for m in value)
if isinstance(value, OperationError):
return render(value.text, value.values, translate)
return value
text = translate(text)
return text % {key: shown(value) for key, value in values.items()} if values else text
class OperationError(libvirt.libvirtError):
"""An error with WebVirtCloud's own message: text, with %(name)s for each
of values. str() is the English message; a page or a job shows text
translated, with the values (operations.utils.said).
"""An error with WebVirtCloud's own message (see message()). Its args are
the English message, for logs and the audit log; str() is the message in
the active language, English outside a request. A job keeps the text and
values (operations.utils.said) and shows them in its reader's language.
libvirtError(msg) reports the thread's last libvirt error instead of msg
when there is one, so an earlier, unrelated failure would be shown.
"""
def __init__(self, text, **values):
Exception.__init__(self, text % values if values else text)
self.text, self.values = str(text), values
Exception.__init__(self, render(self.text, values, str))
self.err = None
def __str__(self):
return render(self.text, self.values, gettext)
class ConnectionFailed(OperationError):
"""The host could not be connected to; the message may name it and how it
@ -112,9 +137,9 @@ def parse_xml(xml):
try:
tree = etree.fromstring(xml.encode(), parser) # bytes: the XML may declare its encoding
except etree.XMLSyntaxError as err:
raise OperationError(f"The XML is not valid: {err}") from None
raise OperationError(gettext_noop("The XML is not valid: %(err)s"), err=err) from None
if tree.getroottree().docinfo.internalDTD is not None:
raise OperationError("The XML may not declare a DTD")
raise OperationError(gettext_noop("The XML may not declare a DTD"))
return tree

View file

@ -1,3 +1,4 @@
from django.utils.translation import gettext_noop
import base64
from libvirt import VIR_ERR_NO_STORAGE_POOL, VIR_STORAGE_XML_INACTIVE, libvirtError
@ -22,7 +23,7 @@ def value_bytes(usage_type, value):
try:
return base64.b64decode(value, validate=True)
except ValueError: # not base64, or not ASCII; the message never holds the value
raise util.OperationError("A Ceph key is base64 text, as `ceph auth get-key` prints it") from None
raise util.OperationError(gettext_noop("A Ceph key is base64 text, as `ceph auth get-key` prints it")) from None
def secrets_named(tree, path, by_usage):
@ -111,8 +112,8 @@ class wvmSecrets(wvmConnect):
if users := self.get_users(self.get_secrets_info()).get(uuid):
named = []
if users["vms"]:
named.append(f"VMs {', '.join(users['vms'])}")
named.append(util.message(gettext_noop("VMs %(names)s"), names=", ".join(users["vms"])))
if users["pools"]:
named.append(f"pools {', '.join(users['pools'])}")
raise util.OperationError(f"Secret {uuid} is used by {'; '.join(named)}; remove it from them first")
named.append(util.message(gettext_noop("pools %(names)s"), names=", ".join(users["pools"])))
raise util.OperationError(gettext_noop("Secret %(uuid)s is used by %(named)s; remove it from them first"), uuid=uuid, named=named)
secret.undefine()