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:
parent
1e66367bb4
commit
491819ed64
21 changed files with 2371 additions and 420 deletions
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue