1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/vrtManager/instance.py
catborise f8e30d7668 feat(instances): a VM's resize tab as a ledger, its vCPU changes kept
The Resize tab's CPU, Memory and Disk panes were horizontal forms with
bold host figures. The owner chose the ledger layout from three
rendered options: each section with its heading, the VM's figures and
the host's on the left, its fields on the right, and one line on what
applies now and what at the next start.

Codex reviewed resizing end to end; these are fixed:

- A vCPU turned on or off while the VM runs is kept at its next start:
  libvirt was asked without flags, which changes the running VM only.
- A paused VM offers to turn vCPUs on only: turning one off needs the
  guest, which does not run.
- A smaller disk size is refused, and so is the rest of the form,
  before anything grows; it was skipped without a word.
- vCPUs and memory below 1 are refused.
- The memory fields are numbers filled with the VM's values: the
  "Custom value" box, its script and the preset list are gone; an empty
  custom field silently used the preset.
- Units and words: MiB, not MB; a disk's field is its new size, with
  its size now above it; the messages say what the VM gets.
2026-10-30 18:16:11 +03:00

3026 lines
140 KiB
Python

import base64
import contextlib
import ipaddress
import json
import logging
import os.path
import re
import string
import time
logger = logging.getLogger(__name__)
try:
from libvirt import (
VIR_DOMAIN_AFFECT_CONFIG,
VIR_DOMAIN_AFFECT_LIVE,
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,
VIR_DOMAIN_PAUSED,
VIR_DOMAIN_RUNNING,
VIR_DOMAIN_SHUTOFF,
VIR_DOMAIN_START_PAUSED,
VIR_DOMAIN_XML_SECURE,
VIR_DOMAIN_XML_INACTIVE,
VIR_DOMAIN_XML_MIGRATABLE,
VIR_DOMAIN_UNDEFINE_KEEP_NVRAM,
VIR_ERR_NO_DOMAIN,
VIR_ERR_NO_STORAGE_POOL,
VIR_ERR_NO_STORAGE_VOL,
VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE,
VIR_DOMAIN_BLOCK_COPY_TRANSIENT_JOB,
VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT,
VIR_DOMAIN_BLOCK_RESIZE_BYTES,
VIR_STORAGE_VOL_FILE,
VIR_MIGRATE_AUTO_CONVERGE,
VIR_MIGRATE_COMPRESSED,
VIR_MIGRATE_LIVE,
VIR_MIGRATE_NON_SHARED_DISK,
VIR_MIGRATE_OFFLINE,
VIR_MIGRATE_PARAM_DEST_XML,
VIR_MIGRATE_PARAM_MIGRATE_DISKS,
VIR_MIGRATE_PARAM_PERSIST_XML,
VIR_MIGRATE_PARAM_URI,
VIR_MIGRATE_PERSIST_DEST,
VIR_MIGRATE_UNDEFINE_SOURCE,
VIR_MIGRATE_UNSAFE,
VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC,
VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY,
VIR_DOMAIN_SNAPSHOT_CREATE_QUIESCE,
VIR_DOMAIN_SNAPSHOT_REVERT_PAUSED,
VIR_DOMAIN_SNAPSHOT_REVERT_RUNNING,
VIR_ERR_CONFIG_UNSUPPORTED,
VIR_ERR_OPERATION_INVALID,
VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL,
VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL,
VIR_DOMAIN_BACKUP_BEGIN_REUSE_EXTERNAL,
VIR_DOMAIN_JOB_NONE,
VIR_DOMAIN_JOB_OPERATION_BACKUP,
VIR_DOMAIN_JOB_STATS_COMPLETED,
VIR_DOMAIN_JOB_STATS_KEEP_COMPLETED,
VIR_ERR_NO_DOMAIN_BACKUP,
libvirtError,
)
from libvirt_qemu import qemuAgentCommand
except Exception:
from libvirt import libvirtError, VIR_DOMAIN_XML_SECURE, VIR_DOMAIN_XML_INACTIVE, VIR_MIGRATE_LIVE
from collections import OrderedDict
from datetime import datetime
from xml.etree import ElementTree
from lxml import etree
from vrtManager import util
from vrtManager.connection import NVRAM_DIR, wvmConnect
from vrtManager.nodedevs import pci_address, usb_selector
from vrtManager.storage import wvmStorage, wvmStorages
# Edits that redefine the domain start from this: the persistent definition,
# with secrets. Live XML would drop pending changes, non-secure XML the VNC
# password.
PERSISTENT_XML = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE
USB_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='usb']"
PCI_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='pci']"
# what of the host a VM holds: host devices, and NICs that are host devices
RNG_DEVICE = "<rng model='virtio'><backend model='random'>/dev/urandom</backend></rng>"
GUEST_AGENT_CHANNEL = "/domain/devices/channel[@type='unix']/target[@name='org.qemu.guest_agent.0']"
HOST_DEVICES = "devices/hostdev | devices/interface[@type='hostdev'] | devices/interface/actual[@type='hostdev']"
# External snapshot names become part of overlay and memory file names.
SNAPSHOT_NAME = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}")
# the longest window wvmInstance.usage() averages over
USAGE_MAX_WINDOW = 30
# seconds the guest agent has to set a password or add a key
AGENT_TIMEOUT = 10
# A running VM is cloned with a push backup that keeps the VM alive when the
# guest shuts down during the copy: libvirt 11.10.
LIVE_CLONE_MIN_LIBVIRT = 11_010_000
# libvirt 11.10's value; bindings built against older libvirt lack the name,
# and the flag is sent to the host, whose version is checked.
VIR_DOMAIN_BACKUP_BEGIN_PRESERVE_SHUTDOWN_DOMAIN = 2
def _rate(before, after, seconds, scale=1):
"""Growth per second of a counter, or None when a reading is unsupported
(-1) or the counter went back."""
if before < 0 or after < before:
return None
return scale * (after - before) / seconds
class wvmInstances(wvmConnect):
def moveto(self, conn, name, live, unsafe, offline, autoconverge=False, compress=False, uri=None,
pools=None):
"""Migrates the VM of conn (a wvmInstance on the source) here.
The mode follows the VM's state, read once: a running VM migrates
live or, without live, paused for the transfer (non-live); a paused
VM stays paused; a shut-off VM moves offline. A request that does not
fit the state is refused. unsafe, autoconverge (live only) and
compress apply to a running VM. uri: the native migration URI
(tcp://address) on the destination; None lets libvirt use the
destination's own hostname. pools: {target: pool here} of the disks
libvirt copies into those pools during the migration (disk_copies);
the other disks must be on storage both hosts share. Returns the mode
and options used, for the log, and the paths of the copied disks."""
state = conn.get_status()
if state == 5:
if live:
raise util.OperationError("A shut-off VM cannot be migrated live")
mode = ["offline"]
elif state in (1, 3):
if offline:
raise util.OperationError("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")
# The source definition always goes: one left behind points at the
# same (shared) disks as the migrated VM and could be started again.
flags = VIR_MIGRATE_PERSIST_DEST | VIR_MIGRATE_UNDEFINE_SOURCE
if state == 5:
flags |= VIR_MIGRATE_OFFLINE
elif live:
flags |= VIR_MIGRATE_LIVE
if state == 1:
for wanted, flag, label in (
(unsafe, VIR_MIGRATE_UNSAFE, "unsafe"),
(autoconverge and live, VIR_MIGRATE_AUTO_CONVERGE, "auto converge"),
(compress, VIR_MIGRATE_COMPRESSED, "compressed"),
):
if wanted:
flags |= flag
mode.append(label)
dom = conn.instance
# 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")
if state == 5:
self.refuse_local_state(dom.XMLDesc(0))
# 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")
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)
)
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"
)
if copies:
note = self.migrate_copying(conn, state, flags, uri, copies)
if note:
mode.append(note)
elif state == 5:
# Workaround for a libvirt bug (RHEL-156800), see move_definition.
# Once supported hosts all run a fixed libvirt, this branch and
# move_definition can go: the flags above are those for
# dom.migrate.
self.move_definition(dom, conn)
else:
dom.migrate(self.wvm, flags, None, uri, 0)
return ", ".join(mode), [copy["path"] for copy in copies]
def migrate_copying(self, conn, state, flags, uri, copies):
"""Migrates the VM of conn while libvirt copies the disks of copies
into their pools here (NON_SHARED_DISK), the definitions naming the
new paths. libvirt copies the disks of a running or paused VM only: a
shut-off VM is started paused for it, its CPUs never run, and is shut
off here after it; its UEFI variables are copied first, as this host
would start from the firmware's template. Returns a note for the log
when the VM could not be shut off here."""
dom = conn.instance
nvram = None
xml_flags = VIR_DOMAIN_XML_SECURE | VIR_DOMAIN_XML_MIGRATABLE
params = {
VIR_MIGRATE_PARAM_MIGRATE_DISKS: [copy["target"] for copy in copies],
VIR_MIGRATE_PARAM_DEST_XML: self.moved_disks(dom.XMLDesc(xml_flags), copies),
VIR_MIGRATE_PARAM_PERSIST_XML: self.moved_disks(dom.XMLDesc(xml_flags | VIR_DOMAIN_XML_INACTIVE), copies),
}
if uri:
params[VIR_MIGRATE_PARAM_URI] = uri
try:
if state == 5:
flags &= ~VIR_MIGRATE_OFFLINE
nvram = self.copy_nvram(conn)
dom.createWithFlags(VIR_DOMAIN_START_PAUSED)
dom.migrate3(self.wvm, params, flags | VIR_MIGRATE_NON_SHARED_DISK)
except libvirtError as error:
if state == 5:
with contextlib.suppress(libvirtError):
dom.destroy() # shut off on the source again
self.remove_partial_copies(dom.UUIDString(), copies, error, nvram)
raise
if state == 5:
try:
self.wvm.lookupByUUIDString(dom.UUIDString()).destroy()
except libvirtError as error:
return f"left paused here, shut it off: {error}"
return None
@staticmethod
def moved_disks(xml, copies):
"""xml with each copied disk's source at its path here; a disk is
found by its target and its source path before the copy."""
tree = etree.fromstring(xml)
sources = [(source, copy["dest"]) for copy in copies for source in tree.xpath(
"devices/disk[target/@dev=$target]/source[@file=$path]", target=copy["target"], path=copy["path"])]
for source, dest in sources:
source.set("file", dest)
return etree.tostring(tree, encoding="unicode")
def disk_plan(self, conn, xml):
"""How the file disks of the VM of conn (on the source) in xml, the
definition the migration carries, can reach this host: a list of
{target, path, capacity, shared, refusal}. shared: both hosts see the
file at its path, as the directory both have as a storage pool is
one (same_directory); False when not, or when it cannot be told.
refusal: why the disk cannot be copied, or None."""
rows, shared = [], {}
for disk in ElementTree.fromstring(xml).iter("disk"):
source = disk.find("source")
if disk.get("type") != "file" or source is None or not source.get("file"):
continue
path = source.get("file")
directory = os.path.dirname(path)
if directory not in shared:
here, there = self.pool_at(directory), conn.pool_at(directory)
try:
shared[directory] = here is not None and there is not None and self.same_directory(there, here)
except libvirtError:
shared[directory] = False # not known to be shared: copied, never assumed
try:
volume = conn.wvm.storageVolLookupByPath(path)
except libvirtError:
volume = None
rows.append({"target": disk.find("target").get("dev"), "path": path, "shared": shared[directory],
"capacity": volume.info()[1] if volume else None,
"refusal": self.copy_refusal(disk, volume)})
return rows
@staticmethod
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"
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"
driver = disk.find("driver")
if driver is None or driver.get("type") not in ("raw", "qcow2"):
return "only raw and qcow2 disks are copied"
if volume is None:
return "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 None
def disk_copies(self, conn, definitions, pools):
"""The copies of the disks pools names a pool here for: {target, path,
dest, pool}, from the first of definitions (XML, the one the migration
carries). Each other file disk (see disk_plan), also of the next
start's definition, must be on shared storage. A copy never replaces a file: one
of the same name in the pool refuses it (libvirt would write into it).
Every problem is named in one refusal."""
here = self.file_pools() if pools else {}
copies, problems, targets, carried = [], [], set(), set()
for row in self.disk_plan(conn, definitions[0]):
targets.add(row["target"])
carried.add(row["path"])
name = pools.get(row["target"])
label = f"{row['target']} ({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")
continue
if row["refusal"]:
problems.append(f"{label}: {row['refusal']}")
continue
if name not in here:
problems.append(f"{label}: the destination host has no pool {name} for disk files")
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}")
continue
if any(copy["dest"] == f"{directory}/{file}" for copy in copies):
problems.append(f"{label}: another disk is copied to {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]]
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"]]
if problems:
raise util.OperationError("The disks cannot be migrated: " + "; ".join(problems))
return copies
def remove_partial_copies(self, uuid, copies, error, nvram=None):
"""Deletes the copies libvirt made here for a migration that failed,
and the copy of its NVRAM file, while this host has no VM of uuid (a
VM here has them as its disks)."""
try:
self.wvm.lookupByUUIDString(uuid)
return
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
left = []
for copy in copies:
copy["pool"].refresh(0)
volume = self._find_volume(copy["pool"], os.path.basename(copy["dest"]))
try:
if volume is not None:
volume.delete(0)
except libvirtError:
left.append(copy["dest"])
try:
self.delete_nvram(nvram)
except libvirtError:
left.append(f"{NVRAM_DIR}/{nvram}")
if left:
raise util.OperationError(
f"{error}; remove the partial disk copies from the destination host: {', '.join(left)}"
) from error
@staticmethod
def refuse_local_state(xml):
"""A shut-off VM moves without its TPM state, a file of the source
host: on the destination it would get a new TPM that no longer
unlocks its disks. A live migration carries it. (The UEFI variables
are copied: copy_nvram.)"""
if ElementTree.fromstring(xml).find("devices/tpm/backend[@type='emulator']") is not None:
raise util.OperationError(
"A shut-off VM with TPM state cannot be migrated: the state stays on the source host. "
"Start the VM and migrate it live."
)
def copy_nvram(self, conn):
"""Copies the UEFI variables file of the shut-off VM of conn (on the
source) from libvirt's NVRAM directory there to the same path here,
unless both hosts share that directory. Returns the copy's name."""
path = conn.get_nvram()
if not path or os.path.dirname(path) != NVRAM_DIR:
return None
name = os.path.basename(path)
with conn._nvram_pool() as source, self._nvram_pool() as target:
vol = self._find_volume(source, name)
if vol is None:
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}")
return None
chunks = []
stream = conn.wvm.newStream(0)
vol.download(stream, 0, 0, 0)
stream.recvAll(lambda _stream, data, out: out.append(data), chunks)
stream.finish()
self.upload_volume(target, name, b"".join(chunks))
return name
def delete_nvram(self, name):
if name:
with self._nvram_pool() as pool:
pool.storageVolLookupByName(name).delete(0)
def missing_disks(self, definitions, copied=()):
"""(target, source) of the disks in the VM definitions (XML) that are
not volumes of this host's active storage pools: the VM's own files,
their backing files (as the definition or this host's volume lists
them), CD-ROM images, block devices and pool volumes. Network disks
(RBD, iSCSI, ...) are reached the same way from every host and are not
checked; other types (dir, nvme, ...) cannot be and count as missing.
Pools are refreshed before a disk counts as missing, as a file another
host made on shared storage is listed after that."""
disks = {}
for xml in definitions:
for disk in ElementTree.fromstring(xml).iter("disk"):
target = disk.find("target")
target = target.get("dev") if target is not None else "?"
for element in (disk, *disk.iter("backingStore")):
source = element.find("source")
kind = element.get("type")
if source is None or kind == "network":
continue
if kind == "volume":
disks[(source.get("pool"), source.get("volume"))] = target
elif kind in ("file", "block") and (source.get("file") or source.get("dev")):
if element is disk and source.get("file") in copied:
continue
disks[(None, source.get("file") or source.get("dev"))] = target
elif kind not in ("file", "block"):
disks[(kind, None)] = target
def missing():
checked, unseen, queue = set(), [], list(disks.items())
while queue:
key, target = queue.pop()
if key in checked:
continue
checked.add(key)
volume = self.find_volume(*key)
if volume is None:
unseen.append((target, key))
continue
backing = ElementTree.fromstring(volume.XMLDesc(0)).findtext("backingStore/path")
if backing and "://" not in backing: # a URI is a network backing image
queue.append(((None, backing), target))
return unseen
if missing():
for pool in self.wvm.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
with contextlib.suppress(libvirtError):
pool.refresh(0)
return [(target, self.disk_label(*key)) for target, key in missing()]
@staticmethod
def disk_label(pool, name):
if name is None:
return f"{pool} disk, cannot be checked"
return name if pool is None else f"{pool}/{name}"
def find_volume(self, pool, name):
"""The volume name of pool, or with pool None the volume at path name;
None when this host has none (or name is None: a disk type that
cannot be checked)."""
if name is None:
return None
try:
if pool is None:
return self.wvm.storageVolLookupByPath(name)
pool = self.wvm.storagePoolLookupByName(pool)
return pool.storageVolLookupByName(name) if pool.isActive() else None
except libvirtError as error:
if error.get_error_code() in (VIR_ERR_NO_STORAGE_VOL, VIR_ERR_NO_STORAGE_POOL):
return None
raise
def move_definition(self, dom, conn):
"""Migrates a shut-off VM: that is only its definition, as the disks
are on storage both hosts share, and its UEFI variables file
(copy_nvram; moveto refuses a VM with TPM state). The destination's
qemu migration hook does not run for it.
Workaround, to be removed in a later version: libvirt's own offline
migration (VIR_MIGRATE_OFFLINE) fails with "operation failed: domain
is no longer running" on libvirt builds that have upstream commit
a4f610ff ("Always offer block dirty bitmaps during migration") but not
its fix 59fde80f, which is in libvirt 12.2.0. RHEL backported the
first (RHEL-145770) and tracks the fix as RHEL-156800; RHEL/Rocky 9.8
with libvirt 11.10.0-12.el9_8 is affected. When supported hosts all
have the fix, offline migration can go back to dom.migrate."""
if dom.hasManagedSaveImage(0):
raise util.OperationError("A VM with a saved state cannot be migrated")
for lookup, key in ((self.wvm.lookupByUUIDString, dom.UUIDString()), (self.wvm.lookupByName, dom.name())):
try:
lookup(key)
except libvirtError as error:
if error.get_error_code() != VIR_ERR_NO_DOMAIN:
raise
else:
raise util.OperationError(f"The destination host already has a VM {key}")
xml = dom.XMLDesc(VIR_DOMAIN_XML_SECURE | VIR_DOMAIN_XML_MIGRATABLE)
autostart = dom.autostart()
nvram = self.copy_nvram(conn)
try:
new = self.wvm.defineXML(xml)
except Exception:
self.delete_nvram(nvram)
raise
try:
# libvirt refuses to undefine a VM with NVRAM without this; the file stays for the cleanup
dom.undefineFlags(VIR_DOMAIN_UNDEFINE_KEEP_NVRAM)
try:
started = dom.isActive()
except libvirtError as error:
if error.get_error_code() != VIR_ERR_NO_DOMAIN:
raise
started = False # gone from the source: the move is done
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")
except Exception as error:
self.undo_definition(dom, new, error) # raises when the destination keeps the VM
self.delete_nvram(nvram)
raise
@staticmethod
def undo_definition(dom, new, error):
"""After a failed move, the VM is defined on one host only: the
destination definition goes only while the source still has its own."""
try:
on_source = dom.isPersistent()
except libvirtError:
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"
) 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}"
) 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 '.'")
def agent_command(dom, command, arguments=None):
"""The answer of one guest agent command that waits AGENT_TIMEOUT seconds
at most: libvirt's own calls for it wait for a hung agent indefinitely,
and the domain's agent timeout is shared by every agent call."""
request = {"execute": command}
if arguments is not None:
request["arguments"] = arguments
return json.loads(qemuAgentCommand(dom, json.dumps(request), AGENT_TIMEOUT, 0))["return"]
def agent_interfaces(dom):
"""The guest's interfaces from its agent, in interfaceAddresses()'s form,
which waits for a hung agent indefinitely."""
interfaces = {}
for iface in agent_command(dom, "guest-network-get-interfaces"):
addrs = []
for address in iface.get("ip-addresses", []):
kind = 0 if address["ip-address-type"] == "ipv4" else 1
addrs.append({"type": kind, "addr": address["ip-address"], "prefix": address["prefix"]})
interfaces[iface["name"]] = {"hwaddr": iface.get("hardware-address"), "addrs": addrs}
return interfaces
class wvmInstance(wvmConnect):
# set by cached_reads(): libvirt answers reused within the block
_read_cache = None
def __init__(self, host, login, passwd, conn, vname, uuid=None):
wvmConnect.__init__(self, host, login, passwd, conn)
self._ip_cache = None
# A known VM is found by UUID: a name can be renamed or swapped
# outside WebVirtCloud. No fallback to the name.
self.instance = self.wvm.lookupByUUIDString(uuid) if uuid else self.get_instance(vname)
def osinfo(self):
info = agent_command(self.instance, "guest-get-osinfo")
# an agent without one of these commands still gives the rest
for command in ("guest-get-timezone", "guest-get-host-name"):
with contextlib.suppress(libvirtError):
info.update(agent_command(self.instance, command))
return info
def start(self, flags=0):
self.instance.createWithFlags(flags)
def shutdown(self):
self.instance.shutdown()
def force_shutdown(self):
self.instance.destroy()
def suspend(self):
self.instance.suspend()
def resume(self):
self.instance.resume()
def delete(self, flags=0):
self.instance.undefineFlags(flags)
@contextlib.contextmanager
def cached_reads(self):
"""
Within the block the domain XML (per flags value), the VM's state and
each disk's volume are read from libvirt once (the host's info and
capabilities are read once per object anyway), for pages that only
read: a VM page reads the XML in some 30 getters and each disk in two
lists. Code that changes the VM must not run inside: most changes
(devices, QoS, memory) do not go through _defineXML, the only call
that drops the cache.
"""
if self._read_cache is not None:
raise RuntimeError("cached_reads blocks do not nest")
self._read_cache = {}
try:
yield self
finally:
self._read_cache = None
def _cached(self, key, read):
cache = self._read_cache
if cache is None:
return read()
if key not in cache:
cache[key] = read()
return cache[key]
def _XMLDesc(self, flag):
return self._cached(("xml", flag), lambda: self.instance.XMLDesc(flag))
def _defineXML(self, xml):
if self._read_cache is not None:
self._read_cache.clear()
return self.wvm.defineXML(xml)
def get_status(self):
"""
VIR_DOMAIN_NOSTATE = 0
VIR_DOMAIN_RUNNING = 1
VIR_DOMAIN_PAUSED = 3
VIR_DOMAIN_SHUTOFF = 5
"""
return self._cached("status", lambda: self.instance.info()[0])
def get_autostart(self):
return self.instance.autostart()
def set_autostart(self, flag):
return self.instance.setAutostart(flag)
def get_uuid(self):
return self.instance.UUIDString()
def get_vcpu(self, config=False):
vcpu = util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), "/domain/vcpu")
return int(vcpu)
def get_cur_vcpu(self, config=False):
cur_vcpu = util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), "/domain/vcpu/@current")
return int(cur_vcpu) if cur_vcpu else self.get_vcpu(config)
def get_arch(self):
return util.get_xml_path(self._XMLDesc(0), "/domain/os/type/@arch")
def get_machine_type(self):
return util.get_xml_path(self._XMLDesc(0), "/domain/os/type/@machine")
def get_dom_emulator(self):
return util.get_xml_path(self._XMLDesc(0), "/domain/devices/emulator")
def get_nvram(self):
xml = self._XMLDesc(0) # a path as text, or a <source file=...> since libvirt 8.5
return util.get_xml_path(xml, "/domain/os/nvram/source/@file") or util.get_xml_path(xml, "/domain/os/nvram")
def get_loader(self):
xml = self._XMLDesc(0)
loader = util.get_xml_path(xml, "/domain/os/loader")
loader_type = util.get_xml_path(xml, "/domain/os/loader/@type")
readonly = util.get_xml_path(xml, "/domain/os/loader/@readonly")
return {"loader": loader, "type": loader_type, "readonly": readonly}
def get_vcpus(self, config=False):
vcpus = OrderedDict()
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML if config else 0))
for vcpu in tree.xpath("/domain/vcpus/vcpu"):
vcpu_id = vcpu.get("id")
enabled = vcpu.get("enabled")
hotplug = vcpu.get("hotpluggable")
order = vcpu.get("order")
vcpus[vcpu_id] = {
"enabled": enabled,
"hotpluggable": hotplug,
"order": order,
}
return vcpus
def get_memory(self):
mem = util.get_xml_path(self._XMLDesc(0), "/domain/memory")
return int(mem) // 1024
def get_cur_memory(self):
mem = util.get_xml_path(self._XMLDesc(0), "/domain/currentMemory")
return int(mem) // 1024
def get_title(self, config=False):
title = util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), "/domain/title")
return title or ""
def get_description(self, config=False):
description = util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), "/domain/description")
return description or ""
def get_max_memory(self):
return self.host_info[1] * 1048576
def get_max_cpus(self):
"""Get number of physical CPUs."""
hostinfo = self.host_info
pcpus = hostinfo[4] * hostinfo[5] * hostinfo[6] * hostinfo[7]
return range(1, int(pcpus + 1))
def get_interface_addresses(self, iface_mac):
if self._ip_cache is None:
self.refresh_interface_addresses()
qemuga = self._ip_cache["qemuga"]
arp = self._ip_cache["arp"]
leases = []
def extract_dom(info):
ipv4 = []
ipv6 = []
for addrs in info.values():
if addrs["hwaddr"] != iface_mac:
continue
if not addrs["addrs"]:
continue
for addr in addrs["addrs"]:
if addr["type"] == 0:
ipv4.append(addr["addr"])
elif addr["type"] == 1 and not str(addr["addr"]).startswith("fe80"):
ipv6.append(addr["addr"] + "/" + str(addr["prefix"]))
return ipv4, ipv6
def extract_lease(info):
ipv4 = []
ipv6 = []
if info["mac"] == iface_mac:
if info["type"] == 0:
ipv4.append(info["ipaddr"])
elif info["type"] == 1:
ipv6.append(info["ipaddr"])
return ipv4, ipv6
for ips in [qemuga] + leases + [arp]:
ipv4, ipv6 = extract_lease(ips) if "expirytime" in ips else extract_dom(ips)
if ipv4 or ipv6:
return ipv4, ipv6
return None, None
def _get_interface_addresses(self, source):
# ("Calling interfaceAddresses source=%s", source)
with contextlib.suppress(libvirtError):
return self.instance.interfaceAddresses(source)
return {}
def refresh_interface_addresses(self):
self._ip_cache = {"qemuga": {}, "arp": {}}
if self.get_status() != 1:
return
if self.is_agent_ready():
with contextlib.suppress(libvirtError):
self._ip_cache["qemuga"] = agent_interfaces(self.instance)
self._ip_cache["arp"] = self._get_interface_addresses(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP)
def get_net_devices(self, config=False):
def networks(ctx):
result = []
for net in ctx.xpath("/domain/devices/interface"):
inbound = outbound = []
interface_type = net.xpath("@type")[0]
mac_inst = net.xpath("mac/@address")[0]
source = net.xpath("source/@network|source/@bridge|source/@dev")
nic_inst = source[0] if source else ""
target_inst = net.xpath("target/@dev")[0] if net.xpath("target/@dev") else ""
link_state = net.xpath("link/@state")[0] if net.xpath("link") else "up"
filterref_inst = net.xpath("filterref/@filter")[0] if net.xpath("filterref/@filter") else ""
model_type = net.xpath("model/@type")[0] if net.xpath("model/@type") else ""
if net.xpath("bandwidth/inbound"):
in_attr = net.xpath("bandwidth/inbound")[0]
in_av = in_attr.get("average")
in_peak = in_attr.get("peak")
in_burst = in_attr.get("burst")
inbound = {"average": in_av, "peak": in_peak, "burst": in_burst}
if net.xpath("bandwidth/outbound"):
out_attr = net.xpath("bandwidth/outbound")[0]
out_av = out_attr.get("average")
out_peak = out_attr.get("peak")
out_burst = out_attr.get("burst")
outbound = {"average": out_av, "peak": out_peak, "burst": out_burst}
try:
ipv4, ipv6 = self.get_interface_addresses(mac_inst)
except libvirtError:
ipv4, ipv6 = None, None
result.append(
{
"type": interface_type,
"mac": mac_inst,
"nic": nic_inst,
"target": target_inst,
"state": link_state,
"model": model_type,
"ipv4": ipv4,
"ipv6": ipv6,
"filterref": filterref_inst,
"inbound": inbound,
"outbound": outbound,
}
)
return result
return util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), func=networks)
def get_disk_devices(self, config=False):
def disks(doc):
result = []
for disk in doc.xpath("/domain/devices/disk"):
dev = volume = storage = src_file = bus = None
disk_format = used_size = disk_size = None
disk_cache = disk_io = disk_discard = disk_zeroes = "default"
readonly = shareable = serial = None
backing_file = None
device = disk.xpath("@device")[0]
if device == "disk":
try:
dev = disk.xpath("target/@dev")[0]
bus = disk.xpath("target/@bus")[0]
try:
src_file = disk.xpath(
"source/@file|source/@dev|source/@name"
)[0]
except Exception:
v = disk.xpath("source/@volume")[0]
s_name = disk.xpath("source/@pool")[0]
s = self.wvm.storagePoolLookupByName(s_name)
src_file = s.storageVolLookupByName(v).path()
with contextlib.suppress(Exception):
disk_format = disk.xpath("driver/@type")[0]
with contextlib.suppress(Exception):
disk_cache = disk.xpath("driver/@cache")[0]
with contextlib.suppress(Exception):
disk_io = disk.xpath("driver/@io")[0]
with contextlib.suppress(Exception):
disk_discard = disk.xpath("driver/@discard")[0]
with contextlib.suppress(Exception):
disk_zeroes = disk.xpath("driver/@detect_zeroes")[0]
with contextlib.suppress(Exception):
backing_file = disk.xpath("backingStore/source/@file")[0]
readonly = bool(disk.xpath("readonly"))
shareable = bool(disk.xpath("shareable"))
serial = (
disk.xpath("serial")[0].text
if disk.xpath("serial")
else None
)
details = self._volume_details(src_file)
if details:
volume, disk_size, used_size, storage = details
else:
volume = src_file
except Exception as e:
logger.warning("Disk %s of %s not fully read: %s", dev, self.instance.name(), e)
finally:
result.append(
{
"dev": dev,
"bus": bus,
"image": volume,
"storage": storage,
"path": src_file,
"format": disk_format,
"backing_file": backing_file,
"size": disk_size,
"used": used_size,
"cache": disk_cache,
"io": disk_io,
"discard": disk_discard,
"detect_zeroes": disk_zeroes,
"readonly": readonly,
"shareable": shareable,
"serial": serial,
}
)
return result
flags = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE if config else 0
return util.get_xml_path(self._XMLDesc(flags), func=disks)
def _volume_details(self, path):
"""(name, capacity, allocation, pool name) of the volume at path, or
None when no pool holds it."""
def read():
try:
vol = self.get_volume_by_path(path)
info = vol.info()
return vol.name(), info[1], info[2], vol.storagePoolLookupByVolume().name()
except libvirtError:
return None
return self._cached(("volume", path), read)
def get_media_devices(self, config=False):
def disks(doc):
result = []
for media in doc.xpath("/domain/devices/disk[@device='cdrom']"):
dev = media.xpath("target/@dev")
bus = media.xpath("target/@bus")
src_file = media.xpath("source/@file")
src_file = src_file[0] if src_file else None
details = self._volume_details(src_file) if src_file else None
volume, _, _, storage = details or (None, None, None, None)
result.append(
{
"dev": dev[0] if dev else None,
"image": volume,
"storage": storage,
"path": src_file,
"bus": bus[0] if bus else None,
}
)
return result
return util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), func=disks)
def get_bootmenu(self, config=False):
menu = util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), "/domain/os/bootmenu/@enable")
return menu == "yes"
def set_bootmenu(self, flag):
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
os = tree.find("os")
menu = os.find("bootmenu")
if menu is None:
bootmenu = etree.fromstring("<bootmenu enable='yes'/>")
os.append(bootmenu)
menu = os.find("bootmenu")
if flag == 0: # Disable
menu.attrib["enable"] = "no"
elif flag == 1: # Enable
menu.attrib["enable"] = "yes"
elif flag == -1: # Remove
os.remove(menu)
else:
raise Exception(
"Unknown boot menu option, please choose one of 0:disable, 1:enable, -1:remove"
)
xmldom = etree.tostring(tree).decode()
self._defineXML(xmldom)
def get_bootorder(self, config=False):
boot_order = {}
dev_type = target = None
tree = ElementTree.fromstring(self._XMLDesc(PERSISTENT_XML if config else 0))
os = tree.find("os")
boot = os.findall("boot")
for idx, b in enumerate(boot):
dev = b.get("dev")
if dev == "cdrom":
target = "cdrom"
dev_type = "file"
elif dev == "fd":
target = "floppy"
dev_type = "file"
elif dev == "hd":
target = "disk"
dev_type = "file"
elif dev == "network":
target = "network"
dev_type = "network"
boot_order[idx] = {"type": dev_type, "dev": dev, "target": target}
devices = tree.find("devices")
for dev in devices:
dev_target = None
boot_dev = dev.find("boot")
if boot_dev is not None:
idx = boot_dev.get("order")
dev_type = dev.get("type")
dev_device = dev.get("device")
if dev.tag == "disk": # file, block, volume or network
dev_target = dev.find("target").get("dev")
elif dev.tag == "interface": # network, bridge or direct
dev_device = "network"
dev_target = dev.find("mac").get("address")
# pass dev_type usb
boot_order[int(idx) - 1] = {
"type": dev_type,
"dev": dev_device,
"target": dev_target,
}
return boot_order
def set_bootorder(self, devorder):
if not devorder:
return
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")
# 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)
for idx, dev in devorder.items():
order = etree.fromstring("<boot order='{}'/>".format(idx + 1))
if dev["type"] in ("disk", "cdrom"):
devices = tree.xpath("./devices/disk[@device=$device][target/@dev=$dev]", device=dev["type"], dev=dev["dev"])
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")
# 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")
for d in devices:
d.append(order)
self._defineXML(etree.tostring(tree).decode())
def _set_cdrom_media(self, dev, path):
"""Insert path into, or with None eject, CD-ROM dev.
The live and the persistent definition are updated separately from
their own device XML, so neither is rewritten from the other.
"""
definitions = [
("running" if affect == VIR_DOMAIN_AFFECT_LIVE else "persistent", xml_flags, affect)
for xml_flags, affect in self._definitions()
]
devices = []
for label, xml_flags, _ in definitions:
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")
devices.append(found[0])
for (label, _, affect), disk in zip(definitions, devices):
for source in disk.findall("source"):
disk.remove(source)
if path:
disk.insert(1, etree.Element("source", file=path))
try:
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
def mount_iso(self, dev, image):
paths = []
for storage in self.get_storages(only_actives=True):
stg = self.get_storage(storage)
if image in stg.listVolumes():
paths.append(stg.storageVolLookupByName(image).path())
if not paths:
raise util.OperationError(f"ISO image {image} was not found")
if len(paths) > 1:
raise util.OperationError(f"ISO image {image} is in more than one storage pool")
self._set_cdrom_media(dev, paths[0])
def umount_iso(self, dev, image=None):
self._set_cdrom_media(dev, None)
def attach_disk(
self,
target_dev,
source,
source_info=None,
pool_type="dir",
target_bus="ide",
disk_type="file",
disk_device="disk",
driver_name="qemu",
format_type="raw",
readonly=False,
shareable=False,
serial=None,
cache_mode=None,
io_mode=None,
discard_mode=None,
detect_zeroes_mode=None,
):
# Every value below is interpolated into disk XML; escape them all so
# a quote in a path, serial or option cannot inject XML.
esc = util.xml_escape
target_dev, source, target_bus = esc(target_dev), esc(source), esc(target_bus)
disk_type, disk_device = esc(disk_type), esc(disk_device)
driver_name, format_type = esc(driver_name), esc(format_type)
serial = esc(serial)
cache_mode, io_mode = esc(cache_mode), esc(io_mode)
discard_mode, detect_zeroes_mode = esc(discard_mode), esc(detect_zeroes_mode)
additionals = ""
if (
cache_mode is not None
and cache_mode != "default"
and disk_device != "cdrom"
):
additionals += f"cache='{cache_mode}' "
if io_mode is not None and io_mode != "default":
additionals += f"io='{io_mode}' "
if discard_mode is not None and discard_mode != "default":
additionals += f"discard='{discard_mode}' "
if detect_zeroes_mode is not None and detect_zeroes_mode != "default":
additionals += f"detect_zeroes='{detect_zeroes_mode}' "
xml_disk = f"<disk type='{disk_type}' device='{disk_device}'>"
if disk_device == "cdrom":
xml_disk += f"<driver name='{driver_name}' type='{format_type}'/>"
elif disk_device == "disk":
xml_disk += (
f"<driver name='{driver_name}' type='{format_type}' {additionals}/>"
)
if disk_type == "file":
xml_disk += f"<source file='{source}'/>"
elif disk_type == "block": # a volume of an LVM, disk or iSCSI pool
xml_disk += f"<source dev='{source}'/>"
elif disk_type == "network":
if pool_type == "rbd":
auth_type = source_info.get("auth_type")
auth_user = source_info.get("auth_user")
auth_uuid = source_info.get("auth_uuid")
xml_disk += f"""<auth username='{auth_user}'>
<secret type='{auth_type}' uuid='{auth_uuid}'/>
</auth>"""
xml_disk += f"""<source protocol='{pool_type}' name='{source}'>"""
for host in source_info.get("hosts"):
if host.get("hostport"):
xml_disk += f"""<host name="{host.get('hostname')}" port='{host.get('hostport')}'/>"""
else:
xml_disk += f"""<host name="{host.get('hostname')}"/>"""
xml_disk += """</source>"""
else:
raise Exception("Not implemented disk type")
else:
raise Exception("Not implemented disk type")
xml_disk += f"<target dev='{target_dev}' bus='{target_bus}'/>"
if readonly or disk_device == "cdrom":
xml_disk += """<readonly/>"""
if shareable:
xml_disk += """<shareable/>"""
if serial is not None and serial != "None" and serial != "":
xml_disk += f"""<serial>{serial}</serial>"""
xml_disk += """</disk>"""
self._attach_device(xml_disk)
def detach_disk(self, target_dev):
"""Detach target_dev from each definition that has it, as that definition describes it."""
found = False
for xml_flags, affect in self._definitions():
tree = etree.fromstring(self._XMLDesc(xml_flags))
disks = tree.xpath("./devices/disk[target/@dev=$dev]", dev=target_dev)
if disks:
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")
def has_disk(self, target_dev):
"""True while the live or the persistent definition still has target_dev."""
for flags in (0, VIR_DOMAIN_XML_INACTIVE):
tree = etree.fromstring(self._XMLDesc(flags))
if tree.xpath("./devices/disk/target[@dev=$dev]", dev=target_dev):
return True
return False
def wait_disk_detached(self, target_dev, timeout=10):
"""A live detach completes only when the guest releases the disk."""
deadline = time.monotonic() + timeout
while self.has_disk(target_dev):
if time.monotonic() >= deadline:
return False
time.sleep(0.5)
return True
def edit_disk(
self,
target_dev,
source,
readonly,
shareable,
target_bus,
serial,
format,
cache_mode,
io_mode,
discard_mode,
detect_zeroes_mode,
):
"""Edit the persistent disk in place and commit its domain XML once."""
tree = etree.fromstring(
self._XMLDesc(VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE)
)
matches = tree.xpath("./devices/disk/target[@dev=$dev]", dev=target_dev)
if not matches:
raise ValueError(f"Disk {target_dev} is not in the persistent configuration")
target = matches[0]
disk = target.getparent()
if target.get("bus") != target_bus:
prefix = util.vol_dev_type(target_bus) or "sd"
used = set(tree.xpath("./devices/disk/target/@dev")) - {target_dev}
new_dev = target_dev if target_dev.startswith(prefix) else next(
(prefix + letter for letter in string.ascii_lowercase if prefix + letter not in used),
None,
)
if new_dev is None:
raise ValueError("No available device name for the requested disk bus")
target.set("dev", new_dev)
target.set("bus", target_bus)
for address in disk.findall("address"):
disk.remove(address)
driver = disk.find("driver")
if driver is None:
driver = etree.Element("driver", name="qemu")
disk.insert(0, driver)
if target_bus != "virtio":
for key in ("queues", "queue_size", "iothread", "iommu", "ats", "packed", "page_per_vq"):
driver.attrib.pop(key, None)
for iothreads in driver.findall("iothreads"):
driver.remove(iothreads)
disk.attrib.pop("model", None)
for key, value in (
("type", format), ("cache", cache_mode), ("io", io_mode),
("discard", discard_mode), ("detect_zeroes", detect_zeroes_mode),
):
if value and value != "default":
driver.set(key, value)
else:
driver.attrib.pop(key, None)
if disk.get("type") == "file":
source_el = disk.find("source")
if source_el is None:
source_el = etree.SubElement(disk, "source")
source_el.set("file", source)
for tag, enabled in (("readonly", readonly), ("shareable", shareable)):
element = disk.find(tag)
if enabled and element is None:
etree.SubElement(disk, tag)
elif not enabled and element is not None:
disk.remove(element)
serial_el = disk.find("serial")
if serial and serial != "None":
if serial_el is None:
serial_el = etree.SubElement(disk, "serial")
serial_el.text = serial
elif serial_el is not None:
disk.remove(serial_el)
self._defineXML(etree.tostring(tree).decode())
def set_vcpu(self, cpu_id, enabled):
"""Turns a hotpluggable vCPU of the active VM on or off, now and for its next start."""
self.instance.setVcpu(str(cpu_id), enabled, VIR_DOMAIN_AFFECT_LIVE | VIR_DOMAIN_AFFECT_CONFIG)
def set_vcpu_hotplug(self, status, vcpus_hotplug=0):
"""vcpus_hotplug: the vCPUs enabled at boot, 0 keeps the current ones;
libvirt adds the rest up to the maximum as disabled hotpluggable vCPUs"""
vcpus_hotplug = self.get_cur_vcpu() if vcpus_hotplug == 0 else vcpus_hotplug
if self.get_status() == 5: # shutoff
if status:
xml = """ <vcpus>"""
xml += """<vcpu id='0' enabled='yes' hotpluggable='no' order='1'/>"""
for i in range(1, vcpus_hotplug):
xml += f"""<vcpu id='{i}' enabled='yes' hotpluggable='yes' order='{i+1}'/>"""
xml += """</vcpus>"""
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
vcpus = tree.xpath("/domain/vcpus")
if not vcpus:
tree.append(etree.fromstring(xml))
self._defineXML(etree.tostring(tree).decode())
else:
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
vcpus = tree.xpath("/domain/vcpus")
for vcpu in vcpus:
parent = vcpu.getparent()
parent.remove(vcpu)
self._defineXML(etree.tostring(tree).decode())
else:
raise util.OperationError(
"Please shutdown the instance then try to enable vCPU hotplug"
)
def mem_usage(self):
mem_usage = {}
if self.get_status() == 1:
mem_stats = self.instance.memoryStats()
rss = mem_stats["rss"] if "rss" in mem_stats else 0
total = mem_stats["actual"] if "actual" in mem_stats else 0
available = total - rss
if available < 0:
available = 0
mem_usage["used"] = rss
mem_usage["total"] = total
else:
mem_usage["used"] = 0
mem_usage["total"] = 0
return mem_usage
def usage(self, previous=None):
"""
CPU (percent of the host), disk read/write bytes and network rx/tx
bits per second, for the stats view. With previous, the "sample" of an
earlier answer, they are averages since then and nothing waits; without
a usable one, over one second. A sample is usable for the same run of
the VM (domain ID) on a host with the same CPUs and devices, at most
USAGE_MAX_WINDOW seconds old. A value the host does not report (-1) or
whose counter went back is None.
"""
tree = ElementTree.fromstring(self._XMLDesc(0))
disks = []
for disk in tree.findall("devices/disk"):
source, target = disk.find("source"), disk.find("target")
if (
disk.get("device") == "disk"
and source is not None
and any(source.get(name) for name in ("protocol", "file", "dev", "volume"))
and target is not None
and target.get("dev")
):
disks.append(target.get("dev")) # blockStats takes the target name
# all NICs, in the order of the page's charts; one without a target is not measured
nics = []
for nic in tree.findall("devices/interface"):
mac, target = nic.find("mac"), nic.find("target")
nics.append([
"" if mac is None else mac.get("address", ""),
"" if target is None else target.get("dev", ""),
])
if self.get_status() != 1:
return {
"cpu": 0,
"disks": [{"dev": dev, "rd": 0, "wr": 0} for dev in disks],
"nics": [{"dev": i, "rx": 0, "tx": 0} for i in range(len(nics))],
}
key = [self.instance.ID(), self.host_info[2], disks, nics]
unread = (-1,) * 8
def read():
blocks = [self.instance.blockStats(dev) for dev in disks]
links = [self.instance.interfaceStats(target) if target else unread for _, target in nics]
return [
self.instance.info()[4],
[[stats[1], stats[3]] for stats in blocks],
[[stats[0], stats[4]] for stats in links],
time.monotonic(),
]
def rates(before, after):
seconds = after[3] - before[3]
if before[0] > after[0] or not 0 < seconds <= USAGE_MAX_WINDOW:
return None
return {
"cpu": 100 * (after[0] - before[0]) / (seconds * key[1] * 10**9),
"disks": [
{"dev": dev, "rd": _rate(old[0], new[0], seconds), "wr": _rate(old[1], new[1], seconds)}
for dev, old, new in zip(disks, before[1], after[1])
],
"nics": [
{"dev": i, "rx": _rate(old[0], new[0], seconds, 8), "tx": _rate(old[1], new[1], seconds, 8)}
for i, (old, new) in enumerate(zip(before[2], after[2]))
],
"window": seconds,
}
counters = read()
usage = None
if previous is not None and previous.get("key") == key:
usage = rates(previous["counters"], counters)
if usage is None:
before = counters
time.sleep(1)
counters = read()
# None everywhere if the VM restarted meanwhile
usage = rates(before, counters) or {
"cpu": None,
"disks": [{"dev": dev, "rd": None, "wr": None} for dev in disks],
"nics": [{"dev": i, "rx": None, "tx": None} for i in range(len(nics))],
"window": None,
}
usage["sample"] = {"key": key, "counters": counters}
return usage
def get_telnet_port(self):
telnet_port = None
service_port = None
tree = ElementTree.fromstring(self._XMLDesc(0))
for console in tree.findall("devices/console"):
if console.get("type") == "tcp":
for elm in console:
if elm.tag == "source":
if elm.get("service"):
service_port = elm.get("service")
if elm.tag == "protocol":
if elm.get("type") == "telnet":
if service_port is not None:
telnet_port = service_port
return telnet_port
def get_console_listener_addr(self, config=False):
listener_addr = util.get_xml_path(
self._XMLDesc(PERSISTENT_XML if config else 0), "/domain/devices/graphics/@listen"
)
if listener_addr is None:
listener_addr = util.get_xml_path(
self._XMLDesc(PERSISTENT_XML if config else 0), "/domain/devices/graphics/listen/@address"
)
if listener_addr is None:
return "127.0.0.1"
return listener_addr
def console_listens_locally(self):
"""True if the persistent definition binds the console to loopback
addresses or a Unix socket only. Anything the XML does not pin down
counts as not local: listen type network, or no address at all (the
host's default in qemu.conf)."""
graphic = etree.fromstring(self._XMLDesc(PERSISTENT_XML)).find("devices/graphics")
if graphic is None:
return True # no console
addresses = [graphic.get("listen")]
local_types = 0
for listen in graphic.findall("listen"):
if listen.get("type") == "address":
addresses.append(listen.get("address"))
elif listen.get("type") in ("socket", "none"):
local_types += 1
else:
return False
addresses = [a for a in addresses if a]
if not addresses:
return local_types > 0
try:
return all(ipaddress.ip_address(a).is_loopback for a in addresses)
except ValueError: # a host name
return False
def set_console_listener_addr(self, listener_addr):
root = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
# The console is the first graphics device of the definition being
# edited; the running VM may still have another type.
graphic = root.find("devices/graphics")
if graphic is None:
return False
listen = graphic.find("listen[@type='address']")
if listen is None:
return False
if listener_addr:
graphic.set("listen", listener_addr)
listen.set("address", listener_addr)
else:
with contextlib.suppress(Exception):
graphic.attrib.pop("listen")
listen.attrib.pop("address")
newxml = etree.tostring(root).decode()
return self._defineXML(newxml)
def set_console_type(self, console_type):
"""VNC: a VNC graphics device on localhost; "pty": no graphics, so the
serial console opens (libvirt adds the serial port for the console)."""
root = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
devices = root.find("devices")
for graphic in devices.findall("graphics"):
devices.remove(graphic)
if console_type == "vnc":
etree.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1")
elif devices.find("console") is None:
etree.SubElement(devices, "console", type="pty")
return self._defineXML(etree.tostring(root).decode())
def get_console_socket(self):
socket = util.get_xml_path(self._XMLDesc(0), "/domain/devices/graphics/@socket")
return socket
def get_console_type(self, config=False):
xml = self._XMLDesc(PERSISTENT_XML if config else 0)
console_type = util.get_xml_path(xml, "/domain/devices/graphics/@type")
if console_type is None:
console_type = util.get_xml_path(xml, "/domain/devices/console/@type")
return console_type
def get_console_port(self, console_type=None):
if console_type is None:
console_type = self.get_console_type()
port = util.get_xml_path(
self._XMLDesc(0),
"/domain/devices/graphics[@type='%s']/@port" % console_type,
)
return port
def get_console_passwd(self, config=False):
return util.get_xml_path(
self._XMLDesc(PERSISTENT_XML if config else VIR_DOMAIN_XML_SECURE), "/domain/devices/graphics/@passwd"
)
def set_console_passwd(self, passwd):
root = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
graphic = root.find("devices/graphics")
if graphic is None:
return False
if passwd:
graphic.set("passwd", passwd)
else:
with contextlib.suppress(Exception):
graphic.attrib.pop("passwd")
newxml = etree.tostring(root).decode()
return self._defineXML(newxml)
def set_console_keymap(self, keymap):
root = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
graphic = root.find("devices/graphics")
if graphic is None:
return False
if keymap != "auto":
graphic.set("keymap", keymap)
else:
with contextlib.suppress(Exception):
graphic.attrib.pop("keymap")
newxml = etree.tostring(root).decode()
self._defineXML(newxml)
def get_console_keymap(self, config=False):
return (
util.get_xml_path(
self._XMLDesc(PERSISTENT_XML if config else VIR_DOMAIN_XML_SECURE), "/domain/devices/graphics/@keymap"
)
or ""
)
def get_video_model(self, config=False):
""":return only primary video card"""
xml = self._XMLDesc(PERSISTENT_XML if config else VIR_DOMAIN_XML_SECURE)
tree = etree.fromstring(xml)
video_models = tree.xpath("/domain/devices/video/model")
for model in video_models:
if model.get("primary") == "yes" or len(video_models) == 1:
return model.get("type")
def set_video_model(self, model):
"""Changes only the primary video card, which stays the primary one;
the old model's memory and head settings go with it."""
xml = self._XMLDesc(PERSISTENT_XML)
tree = etree.fromstring(xml)
video_models = tree.xpath("/domain/devices/video/model")
for video_model in video_models:
if video_model.get("primary") == "yes" or len(video_models) == 1:
parent = video_model.getparent()
parent.remove(video_model)
new_model = etree.SubElement(parent, "model", type=model)
if video_model.get("primary"):
new_model.set("primary", video_model.get("primary"))
self._defineXML(etree.tostring(tree).decode())
return
def resize_cpu(self, cur_vcpu, vcpu):
"""
Function change ram and cpu on instance.
"""
is_vcpus_enabled = self.get_vcpus()
if is_vcpus_enabled:
self.set_vcpu_hotplug(False)
xml = self._XMLDesc(PERSISTENT_XML)
tree = etree.fromstring(xml)
vcpu_elem = tree.find("vcpu")
vcpu_elem.text = vcpu
vcpu_elem.set("current", cur_vcpu)
new_xml = etree.tostring(tree).decode()
self._defineXML(new_xml)
if is_vcpus_enabled:
self.set_vcpu_hotplug(True, int(cur_vcpu))
def resize_mem(self, cur_memory, memory):
"""
Function change ram and cpu on vds.
"""
memory = int(memory) * 1024
cur_memory = int(cur_memory) * 1024
# an active VM (running, paused, ...) changes only its current memory;
# a paused guest's balloon driver applies it once it is resumed
if self.instance.isActive():
self.set_memory(cur_memory, VIR_DOMAIN_AFFECT_LIVE)
self.set_memory(cur_memory, VIR_DOMAIN_AFFECT_CONFIG)
return
xml = self._XMLDesc(PERSISTENT_XML)
tree = etree.fromstring(xml)
mem_elem = tree.find("memory")
mem_elem.text = str(memory)
cur_mem_elem = tree.find("currentMemory")
cur_mem_elem.text = str(cur_memory)
new_xml = etree.tostring(tree).decode()
self._defineXML(new_xml)
def resize_disk(self, disks):
"""Grow disks. QEMU holds the lock of a running domain's image files and
grows them itself; other volumes are grown by the storage pool first,
then QEMU is told the new size. A pool that cannot resize volumes (LVM
for one) raises VIR_ERR_NO_SUPPORT before anything changes."""
active = self.instance.isActive()
for disk in disks:
vol = self.get_volume_by_path(disk["path"])
if not active or vol.info()[0] != VIR_STORAGE_VOL_FILE:
vol.resize(disk["size_new"])
if active:
self.instance.blockResize(disk["path"], disk["size_new"], VIR_DOMAIN_BLOCK_RESIZE_BYTES)
def get_iso_media(self):
iso = []
for pool in self.wvm.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
# refreshed so that an image copied to the pool by hand shows up
with contextlib.suppress(libvirtError):
pool.refresh(0)
try:
names = pool.listVolumes()
except libvirtError: # stopped or removed meanwhile
continue
iso.extend(name for name in names if name.lower().endswith(".iso"))
return iso
def paths_used_by_other_domains(self, own=()):
"""Paths another domain uses (see paths_in_use). The volumes in own
are this VM's: their backing files do not count."""
return self.paths_in_use(self.instance.UUIDString(), own)
def split_disk_paths_by_use(self):
"""This VM's disk paths, split into (only this VM, also used by another
domain). With external snapshots, the files below a disk that the
snapshots were taken on, and their memory files, are the VM's too, and
so is its cloud-init seed, in a CD-ROM drive or ejected."""
self.refresh_instance_pools()
own = [disk["path"] for disk in self.get_disk_devices() if disk["path"]]
own += self._snapshot_chain_files(own)
seed = self._seed_path()
if seed:
own.append(seed)
used_elsewhere = self.paths_used_by_other_domains(own)
# a kept file keeps the files below it
for path in [path for path in own if path in used_elsewhere]:
while (path := self._backing_path(path)) and path not in used_elsewhere:
used_elsewhere.add(path)
return (
[path for path in own if path not in used_elsewhere],
[path for path in own if path in used_elsewhere],
)
def _seed_path(self):
"""The path of this VM's cloud-init seed (cidata-<uuid>.iso) in an active pool, or None."""
name = f"cidata-{self.get_uuid()}.iso"
for pool in self.wvm.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
vol = self._find_volume(pool, name)
if vol is not None:
return vol.path()
return None
def _snapshot_chain_files(self, tops):
"""The files of this VM's external snapshots that these disk images
grow from or that grow from them: the backing files snapshots were
taken on, down the chain, the overlays of snapshots off the chain
(newer ones kept after a revert), and the snapshots' memory files. A
backing file the VM started from (a template) is no snapshot point
and ends the chain; an overlay that does not grow from the chain (a
disk detached since) stays."""
points, overlays, memory = set(), set(), []
for snap in self.instance.listAllSnapshots(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
tree = etree.fromstring(snap.getXMLDesc(0))
memory += tree.xpath("memory/@file")
overlays.update(tree.xpath("disks/disk/source/@file"))
saved = self._saved_definition(tree)
if saved is not None:
points.update(self._disk_sources(saved).values())
files = list(tops)
for path in tops:
while (path := self._backing_path(path)) in points and path not in files:
files.append(path)
grew = True
while grew:
grew = False
for top in overlays.difference(files):
if top in files: # reached by a branch in this pass
continue
branch, path = [top], top
while (path := self._backing_path(path)) in points | overlays and path not in files:
branch.append(path)
if path in files:
files += branch
grew = True
return files[len(tops):] + memory
def _backing_path(self, path):
"""The backing file of a volume; None if it has none or is in no pool.
Any other error is raised: a chain cut short would let a file a kept
one needs count as deletable."""
try:
vol = self.get_volume_by_path(path)
except libvirtError as err:
if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL:
raise
return None
return util.get_xml_path(vol.XMLDesc(0), "/volume/backingStore/path")
def _snapshotCreateXML(self, xml, flag):
self.instance.snapshotCreateXML(xml, flag)
def create_snapshot(self, name, desc=None):
if self.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
raise util.OperationError("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 ""
xml = """<domainsnapshot>
<name>%s</name>
<description>%s</description>
<state>%s</state>
<creationTime>%d</creationTime>""" % (
name_esc,
desc_esc,
state,
time.time(),
)
# libvirt decides whether this firmware/NVRAM supports internal
# snapshots; the domain definition is never rewritten for it.
xml += self._XMLDesc(VIR_DOMAIN_XML_SECURE)
xml += """<active>0</active>
</domainsnapshot>"""
self._snapshotCreateXML(xml, 0)
def create_external_snapshot(self, name, desc="", memory=False, quiesce=False, free_percent=10, free_min_gib=1):
"""A disk-only snapshot, or with memory too (running VMs only). Every
disk gets a libvirt-named overlay next to it; the memory file goes
next to the first disk. quiesce: the guest agent freezes the guest's
file systems for a disk-only snapshot of a running VM (libvirt
accepts it only without memory, which keeps the whole state anyway).
free_percent, free_min_gib: the free space each pool keeps (see
_check_snapshot_space)."""
check_snapshot_name(name)
if quiesce:
if memory:
raise util.OperationError("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")
if not self.is_agent_ready():
raise util.OperationError("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")
if name in self.instance.snapshotListNames(0):
raise util.OperationError(f"A snapshot named {name} already exists")
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")
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")
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")
elif disk.get("type") != "file":
raise util.OperationError(f"Disk {dev} is not a file; external snapshots need file disks")
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
try:
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}"
) 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)
xml = (
f"<domainsnapshot><name>{name}</name>"
f"<description>{util.xml_escape(desc)}</description>{memory_xml}</domainsnapshot>"
)
flags = VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC
if not memory:
flags |= VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY
if quiesce:
self.ping_agent()
flags |= VIR_DOMAIN_SNAPSHOT_CREATE_QUIESCE
self._snapshotCreateXML(xml, flags)
self._refresh_pools_after_snapshot()
def _snapshot_memory(self, name, disks):
"""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")
if not disks:
raise util.OperationError("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
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()
def _check_snapshot_space(self, disks, memory_pool, free_percent, free_min_gib):
"""Each storage pool with a disk of the VM keeps free_percent of the
virtual size of those disks free, at least free_min_gib GiB, and the
pool of the memory file the VM's memory on top. The
overlays grow as the guest writes, and a delete merges them down.
This is checked before the snapshot only; nothing is reserved. A
disk in no pool is not checked."""
needed, pools = {}, {}
for path in disks:
try:
vol = self.get_volume_by_path(path)
except libvirtError as err:
if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL:
raise
continue
pool = vol.storagePoolLookupByVolume()
pools[pool.name()] = pool
needed[pool.name()] = needed.get(pool.name(), 0) + vol.info()[1] * free_percent / 100
for name in needed:
needed[name] = max(needed[name], free_min_gib * 1024**3)
if memory_pool:
needed[memory_pool] += self.get_cur_memory() * 1024 * 1024
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)"
)
def check_external_revert(self, name):
"""Refuses a revert that cannot work, before anything changes; returns
the snapshot."""
snap = self._snapshot(name, external=True)
self._check_snapshot_state()
tree = etree.fromstring(snap.getXMLDesc(0))
# Snapshots of UEFI VMs taken before WebVirtCloud used libvirt's
# snapshot API saved the loader switched to rom; a revert would
# restore that.
saved = self._saved_definition(tree)
saved_loader = saved.xpath("string(os/loader/@type)") if saved is not None else ""
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"
)
return snap
def revert_external_snapshot(self, name):
"""libvirt returns the disks and the definition to the snapshot's. A
disk-only snapshot keeps the VM running, paused or shut off as it is;
a snapshot with memory resumes the VM as it was saved. Returns the
files libvirt left behind (see _left_behind)."""
snap = self.check_external_revert(name)
tree = etree.fromstring(snap.getXMLDesc(0))
if tree.find("memory[@snapshot='external']") is not None:
flags = 0
else:
flags = {1: VIR_DOMAIN_SNAPSHOT_REVERT_RUNNING, 3: VIR_DOMAIN_SNAPSHOT_REVERT_PAUSED}.get(
self.get_status(), 0
)
before = self._snapshot_files()
# libvirt names the new overlays after the second, so a revert within
# the second of the previous one finds them taken, after it has
# already stopped a running VM; one retry a second later finishes it.
try:
self.instance.revertToSnapshot(snap, flags)
except libvirtError as err:
if err.get_error_code() != VIR_ERR_CONFIG_UNSUPPORTED or "already exists" not in str(err):
raise
time.sleep(1)
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
return self._left_behind(before)
def delete_external_snapshot(self, name):
"""libvirt merges the snapshot's overlays and removes its files.
Returns the files it left behind (see _left_behind)."""
snap = self._snapshot(name, external=True)
self._check_snapshot_state()
before = self._snapshot_files()
snap.delete(0)
return self._left_behind(before)
def _snapshot_files(self):
"""(disks, snapshot files, snapshot points): the files each disk uses
by target, the external snapshots' own files (overlays, memory), and
the files snapshots were taken on, which libvirt's overlays sit on."""
disks = {
disk.xpath("string(target/@dev)"): set(disk.xpath(".//source/@file"))
for disk in etree.fromstring(self.instance.XMLDesc(0)).xpath("devices/disk[@device='disk']")
}
own, points = set(), set()
for snap in self.instance.listAllSnapshots(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
tree = etree.fromstring(snap.getXMLDesc(0))
own.update(tree.xpath("disks/disk/source/@file|memory/@file"))
saved = self._saved_definition(tree)
if saved is not None:
points.update(self._disk_sources(saved).values())
return disks, own, points | own
def snapshot_files_state(self):
"""_snapshot_files as plain lists, recorded before a snapshot change."""
disks, own, points = self._snapshot_files()
return {"disks": {dev: sorted(files) for dev, files in disks.items()},
"own": sorted(own), "points": sorted(points)}
def left_behind_since(self, state):
"""_left_behind from a state recorded by snapshot_files_state."""
before = ({dev: set(files) for dev, files in state["disks"].items()}, set(state["own"]), set(state["points"]))
return self._left_behind(before)
def _refresh_pools_after_snapshot(self):
"""Refresh the pools once libvirt has changed the snapshot files. The
snapshot operation is done by then, so an error is only logged;
returns whether the pools are current."""
try:
self.refresh_instance_pools()
except libvirtError as err:
logger.warning("Storage pools of %s were not refreshed after a snapshot change: %s", self.instance.name(), err)
return False
return True
def _left_behind(self, before):
"""Files libvirt merged or discarded but could not remove: it only
logs a warning then (on NFS it removes them as the qemu user, who
needs write access to their directory). Only a snapshot's own file or
an overlay on a snapshot point counts; a disk a revert detached is
kept on purpose, and a file another VM on this host uses (or one
below it) is not reported. A file outside every storage pool is not
checked, nor is anything when the pools could not be refreshed: a
stale pool still lists the removed files."""
disks, own, points = before
if not self._refresh_pools_after_snapshot():
return []
now_disks, now_own, _ = self._snapshot_files()
# only disks still attached: one a revert detached keeps its files,
# whatever they sit on
attached = set().union(*(files for dev, files in disks.items() if dev in now_disks))
left = []
for path in sorted((attached | own) - set().union(now_own, *now_disks.values())):
try:
vol = self.get_volume_by_path(path)
if path not in own and util.get_xml_path(vol.XMLDesc(0), "/volume/backingStore/path") not in points:
continue
except libvirtError: # not in a pool, or removed meanwhile
continue
left.append(path)
if left:
# another VM on this host may use one; their own backing files
# do not keep each other, but a file in use keeps the files below it
in_use = self.paths_in_use(own=left)
for path in [path for path in left if path in in_use]:
while (path := self._backing_path(path)) and path not in in_use:
in_use.add(path)
left = [path for path in left if path not in in_use]
return left
@staticmethod
def _disk_sources(xml):
"""Target dev -> source file of the disks (not CD-ROMs) in a domain definition."""
tree = etree.fromstring(xml) if isinstance(xml, str) else xml
return {
disk.xpath("string(target/@dev)"): disk.xpath("string(source/@file|source/@dev|source/@name)")
for disk in tree.xpath("devices/disk[@device='disk']")
}
@staticmethod
def _saved_definition(tree):
"""The definition a revert to this snapshot restores as the persistent one."""
definition = tree.find("inactiveDomain")
return tree.find("domain") if definition is None else definition
def get_snapshot(self, flag):
"""Snapshots of one kind (VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL or
_EXTERNAL), with whether each saved memory, is the current one, and
which disks a revert to it detaches: from the persistent definition,
and on a running VM from the live one too (a disk hot-plugged only)."""
persistent_disks = self._disk_sources(self._XMLDesc(PERSISTENT_XML))
live_disks = self._disk_sources(self._XMLDesc(0)) if self.instance.isActive() else {}
snapshots = []
for snap in self.instance.listAllSnapshots(flag):
tree = etree.fromstring(snap.getXMLDesc(0))
definition = self._saved_definition(tree)
saved_disks = self._disk_sources(definition) if definition is not None else {}
# a revert of a running VM restores what ran when the snapshot was taken
ran = tree.find("domain")
saved_live_disks = self._disk_sources(ran) if ran is not None else saved_disks
detaches = {dev: path for dev, path in persistent_disks.items() if dev not in saved_disks}
for dev, path in live_disks.items():
if dev not in saved_live_disks:
detaches.setdefault(dev, path)
snapshots.append(
{
"date": datetime.fromtimestamp(int(tree.findtext("creationTime"))),
"name": snap.getName(),
"description": tree.findtext("description") or "",
"memory": tree.find("memory[@snapshot='external']") is not None,
"current": bool(snap.isCurrent(0)),
"detaches": [{"dev": dev, "path": path} for dev, path in detaches.items()],
}
)
return snapshots
def get_snapshot_jobs(self):
"""Block jobs (snapshot merges) running on the VM's disks. libvirt
merges a shut-off VM's snapshot as an offline block job; a libvirt
without offline jobs refuses the query for an inactive VM, which
means no job."""
active = self.instance.isActive()
jobs = []
for dev in self._disk_sources(self._XMLDesc(0 if active else PERSISTENT_XML)):
try:
info = self.instance.blockJobInfo(dev, 0)
except libvirtError as err:
if active or err.get_error_code() != VIR_ERR_OPERATION_INVALID:
raise
info = None
if info:
jobs.append({"dev": dev, "cur": info["cur"], "end": info["end"]})
return jobs
def disk_move_plan(self, dev, pool_name):
"""Where the disk dev of the VM goes in the pool pool_name of this
host: {dev, path, dest, format, pool, volume, running}. Refused with
the reason unless the VM is persistent, running, paused or shut off,
without snapshots, a saved state or a block job; the disk a raw or
qcow2 file of a storage pool, without a backing file, not read-only,
shareable or encrypted, the same file in both definitions; the pool
one of disk files, another directory, without a file of that name."""
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")
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")
# 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))]
sources = [disk.find("source") if disk is not None else None for disk in disks]
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")
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")
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
if ElementTree.fromstring(volume.XMLDesc(0)).findtext("backingStore/path"):
raise util.OperationError(f"{dev} does not move: it has a backing file")
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")
pool, directory = pools[pool_name]
if directory == os.path.dirname(path):
raise util.OperationError(f"{dev} is in pool {pool_name} already")
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}")
return {"dev": dev, "path": path, "dest": f"{directory}/{name}", "format": fmt, "pool": pool,
"volume": volume, "running": state != VIR_DOMAIN_SHUTOFF}
def move_disk(self, move):
"""Moves the disk as disk_move_plan planned it. A running or paused
VM's is copied by a block job (TRANSIENT_JOB: the VM is persistent)
into a file libvirt creates; at the pivot libvirt names the copy in
both definitions. A shut-off VM's volume is copied into the pool and
the copy named in the definition. The source file stays (see
MoveDisk). A failed move deletes the copy it made, unless a
definition names it; a file there before the copy began is never
ours."""
dom, dev, dest = self.instance, move["dev"], move["dest"]
made = False
try:
if move["running"]:
xml = (f"<disk type='file'><driver type='{move['format']}'/>"
f"<source file='{util.xml_escape(dest)}'/></disk>")
dom.blockCopy(dev, xml, None, VIR_DOMAIN_BLOCK_COPY_TRANSIENT_JOB)
made = True
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")
if info["end"] and info["cur"] == info["end"]:
break
time.sleep(1)
dom.blockJobAbort(dev, VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT)
move["pool"].refresh(0) # libvirt made the file past the pool: it lists it now
else:
move["pool"].createXMLFrom(
f"<volume><name>{util.xml_escape(os.path.basename(dest))}</name>"
f"<capacity>{move['volume'].info()[1]}</capacity>"
f"<target><format type='{move['format']}'/></target></volume>", move["volume"], 0)
made = True
tree = ElementTree.fromstring(self._XMLDesc(PERSISTENT_XML))
tree.find(f"devices/disk/target[@dev='{dev}']/../source").set("file", dest)
self._defineXML(ElementTree.tostring(tree, encoding="unicode"))
except libvirtError as error:
if made:
self.remove_disk_copy(move, error)
raise
def disk_named(self, dev):
"""The files the running (if active) and next start's definitions name for dev."""
flags = [0, PERSISTENT_XML] if self.instance.isActive() else [PERSISTENT_XML]
files = set()
for flag in flags:
disk = ElementTree.fromstring(self.instance.XMLDesc(flag)).find(f"devices/disk/target[@dev='{dev}']/..")
source = disk.find("source") if disk is not None else None
files.add(source.get("file") if source is not None else None)
return files
def remove_disk_copy(self, move, error):
"""Deletes the copy of a failed move once nothing uses it: no block
job runs on the disk and no definition names it. Otherwise it stays,
and the error says so."""
dev, dest = move["dev"], move["dest"]
try:
if move["running"] and self.instance.blockJobInfo(dev, 0):
self.instance.blockJobAbort(dev, 0)
named = dest in self.disk_named(dev)
if not named:
move["pool"].refresh(0)
copy = self._find_volume(move["pool"], os.path.basename(dest))
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
if named:
raise util.OperationError(f"{error}; the VM may use {dest} now: check its disk {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}")
return self.instance.snapshotLookupByName(name, 0)
def _check_snapshot_state(self):
"""Refusals shared by external snapshot create, revert and delete. 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")
if self.get_snapshot_jobs():
raise util.OperationError("A snapshot job is still running on this VM; try again when it ends")
def snapshot_delete(self, snapshot):
self._snapshot(snapshot, external=False).delete(0)
def snapshot_revert(self, snapshot):
self.instance.revertToSnapshot(self._snapshot(snapshot, external=False), 0)
def get_managed_save_image(self):
return self.instance.hasManagedSaveImage(0)
def get_wvmStorage(self, pool):
return wvmStorage(self.host, self.login, self.passwd, self.conn, pool)
def get_wvmStorages(self):
return wvmStorages(self.host, self.login, self.passwd, self.conn)
def refresh_instance_pools(self):
"""Refresh the pools whose target directory holds one of the VM's
disks; a disk outside every pool has none to refresh."""
target_paths = {os.path.dirname(disk["path"]) for disk in self.get_disk_devices() if disk.get("path")}
storages = self.get_wvmStorages()
for target_path in target_paths:
pool = storages.get_pool_by_target(target_path)
if pool is not None:
pool.refresh(0)
def fix_mac(self, mac):
if ":" in mac:
return mac
# if mac does not contain ":", try to split into tuples and join with ":"
n = 2
mac_tuples = [mac[i : i + n] for i in range(0, len(mac), n)]
return ":".join(mac_tuples)
def _clone_definition(self, clone_data, uuid):
"""The VM's persistent definition renamed to the clone, with its UUID,
title, description and MACs; returns it and the (source, dev) of each
disk to copy. Disks without a source are dropped. Raises before
anything is allocated."""
if clone_data["name"] in self.get_instances():
raise ValueError("An instance with the clone name already exists")
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
# the cloud-init seed holds the VM's own instance-id and hostname: the clone's drive stays empty
seed = f"cidata-{tree.findtext('uuid')}.iso"
for source in tree.xpath("devices/disk[@device='cdrom']/source"):
if os.path.basename(source.get("file") or "") == seed:
source.getparent().remove(source)
tree.find("name").text = clone_data["name"]
uuid_el = tree.find("uuid")
if uuid_el is None:
uuid_el = etree.SubElement(tree, "uuid")
uuid_el.text = uuid
self._set_options(
tree,
{
"title": clone_data.get("clone-title", ""),
"description": clone_data.get("clone-description", ""),
},
)
disks = []
for disk in list(tree.findall("devices/disk")):
if disk.get("device") != "disk":
continue
source = disk.find("source")
if source is None or not any(source.get(key) for key in ("file", "dev", "name", "volume")):
disk.getparent().remove(disk)
continue
supported = source.get("file") or source.get("dev") or (
source.get("protocol") == "rbd" and source.get("name")
)
target = disk.find("target")
dev = target.get("dev") if target is not None else None
if not supported or not dev or not clone_data.get("disk-" + dev):
raise ValueError("Cannot clone a disk without a supported source and destination")
disks.append((source, dev))
# a host device is one VM's: the clone gets none (the clone form says so)
for hostdev in tree.findall("devices/hostdev"):
hostdev.getparent().remove(hostdev)
for num, net in enumerate(tree.findall("devices/interface")):
if net.get("type") == "hostdev": # numbered as the form numbers the VM's NICs
net.getparent().remove(net)
continue
mac = clone_data.get(f"clone-net-mac-{num}")
if not mac:
raise ValueError(f"No MAC address for network interface {num}")
net.find("mac").set("address", self.fix_mac(mac))
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")
return tree, disks
def _clone_volume_xml(self, name, capacity, vol_format, owner_uid, owner_gid):
return f"""<volume>
<name>{util.xml_escape(name)}</name>
<capacity>{capacity}</capacity>
<allocation>0</allocation>
<target>
<format type='{vol_format}'/>
<permissions>
<owner>{owner_uid}</owner>
<group>{owner_gid}</group>
<mode>0644</mode>
<label>virt_image_t</label>
</permissions>
<compat>1.1</compat>
<features><lazy_refcounts/></features>
</target>
</volume>"""
def define_clone(self, clone_data, uuid, paths):
"""Defines the clone with each copied disk's source set from paths
({dev: new path}) and its own copy of the VM's UEFI variables (NVRAM).
The NVRAM copy is removed if the definition fails. Returns the
clone's UUID."""
tree, disks = self._clone_definition(clone_data, uuid)
for source, dev in disks:
source.set("file" if source.get("file") else "dev" if source.get("dev") else "name", paths[dev])
# the UEFI variables: a path as text, or a <source file=...> since libvirt 8.5
nvram = tree.find("os/nvram")
nvram_source = nvram.find("source") if nvram is not None else None
nvram_path = None
if nvram is not None:
nvram_path = nvram_source.get("file") if nvram_source is not None else (nvram.text or "").strip()
if not nvram_path:
return self._defineXML(etree.tostring(tree).decode()).UUIDString()
# the clone starts with the VM's UEFI variables: boot entries, enrolled keys
if os.path.dirname(nvram_path) == NVRAM_DIR:
pools = self._nvram_pool()
else: # a volume of a pool; libvirtError when no pool holds it
pools = contextlib.nullcontext(self.get_volume_by_path(nvram_path).storagePoolLookupByVolume())
with pools as pool:
original = pool.storageVolLookupByName(os.path.basename(nvram_path))
# same format, owner and mode as the original: the domain names the format,
# and qemu may run without libvirt changing the file's owner
target = etree.fromstring(original.XMLDesc(0)).find("target")
volume = etree.Element("volume")
etree.SubElement(volume, "name").text = clone_data["name"] + "_VARS" + os.path.splitext(nvram_path)[1]
etree.SubElement(volume, "capacity").text = "0"
copy_target = etree.SubElement(volume, "target")
for tag in ("format", "permissions"):
if target.find(tag) is not None:
copy_target.append(target.find(tag))
copy = pool.createXMLFrom(etree.tostring(volume).decode(), original, 0)
if nvram_source is not None:
nvram_source.set("file", copy.path())
else:
nvram.text = copy.path()
try:
return self._defineXML(etree.tostring(tree).decode()).UUIDString()
except Exception as err:
try:
copy.delete(0)
except libvirtError:
raise util.OperationError(
f"Clone failed ({err}); remove these copies by hand: {copy.path()}"
) from err
raise
def clone_instance(self, clone_data, uuid):
"""Copy a shut-off VM, with its disks and UEFI variables (NVRAM).
Inputs are checked before anything is allocated, and on failure only
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")
_, disks = self._clone_definition(clone_data, uuid)
paths = {} # dev: path of its copy
created = [] # paths of volumes this call created
try:
for source, dev in disks:
target_file = clone_data["disk-" + dev]
meta_prealloc = bool(clone_data.get("meta-" + dev))
if source.get("file"):
vol = self.get_volume_by_path(source.get("file"))
vol_format = util.get_xml_path(vol.XMLDesc(0), "/volume/target/format/@type")
new_vol = vol.storagePoolLookupByVolume().createXMLFrom(
self._clone_volume_xml(
target_file, 0, vol_format, clone_data["disk_owner_uid"], clone_data["disk_owner_gid"]
),
vol,
meta_prealloc and vol_format == "qcow2",
)
created.append(new_vol.path())
paths[dev] = new_vol.path()
elif source.get("protocol") == "rbd":
vol = self.get_volume_by_path(source.get("name"))
vol_format = util.get_xml_path(vol.XMLDesc(0), "/volume/target/format/@type")
new_vol = vol.storagePoolLookupByVolume().createXMLFrom(
f"""<volume type='network'>
<name>{target_file}</name>
<capacity>0</capacity>
<allocation>0</allocation>
<target><format type='{vol_format}'/></target>
</volume>""",
vol,
meta_prealloc,
)
created.append(new_vol.path())
paths[dev] = f"{os.path.dirname(source.get('name'))}/{target_file}"
else:
vol = self.get_volume_by_path(source.get("dev"))
pool_name = util.get_xml_path(vol.storagePoolLookupByVolume().XMLDesc(0), "/pool/name")
storage = self.get_wvmStorage(pool_name)
name = storage.clone_volume(vol.name(), target_file)
new_path = storage.get_volume(name).path()
created.append(new_path)
paths[dev] = new_path
return self.define_clone(clone_data, uuid, paths)
except Exception as err:
leftovers = []
for path in reversed(created):
try:
self.get_volume_by_path(path).delete(0)
except libvirtError:
leftovers.append(path)
if leftovers:
raise util.OperationError(f"Clone failed ({err}); remove these copies by hand: {', '.join(leftovers)}") from err
raise
def live_clone_targets(self, clone_data, free_percent=10, free_min_gib=1):
"""Refuses a live clone that cannot work, before anything is
allocated; returns the new volume of each copied disk, in disk order:
in the source volume's pool, at its virtual capacity and format. Each
pool keeps the copies plus free_percent of them, at least
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")
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")
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")
if source.getparent().find("shareable") is not None:
raise util.OperationError(f"Disk {dev} is shareable; shut the VM down to clone it")
# 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")
if self.instance.jobInfo()[0] != VIR_DOMAIN_JOB_NONE:
raise util.OperationError("Another job is running on the VM; clone it when that ends")
targets, pools, copied = [], {}, {}
for source, dev in disks:
vol = self.get_volume_by_path(source.get("file"))
pool = vol.storagePoolLookupByVolume()
name = clone_data["disk-" + dev]
try:
pool.storageVolLookupByName(name)
except libvirtError as err:
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()}")
capacity = vol.info()[1]
pools[pool.name()] = pool
copied[pool.name()] = copied.get(pool.name(), 0) + capacity
targets.append(
{
"dev": dev,
"pool": pool.name(),
"name": name,
"path": os.path.join(util.get_xml_path(pool.XMLDesc(0), "/pool/target/path"), name),
"capacity": capacity,
"format": util.get_xml_path(vol.XMLDesc(0), "/volume/target/format/@type"),
}
)
for name, size in copied.items():
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)"
)
return targets
def create_clone_target(self, target, owner_uid, owner_gid):
"""An empty volume for a copied disk; libvirt refuses one that exists.
Returns its path."""
pool = self.wvm.storagePoolLookupByName(target["pool"])
xml = self._clone_volume_xml(target["name"], target["capacity"], target["format"], owner_uid, owner_gid)
return pool.createXML(xml, 0).path()
def begin_clone_backup(self, targets):
"""Starts libvirt's push backup of the targets' disks into their
volumes. The VM keeps running when the guest shuts down meanwhile."""
disks = "".join(
f"<disk name='{util.xml_escape(t['dev'])}' backup='yes' type='file'>"
f"<target file='{util.xml_escape(t['path'])}'/><driver type='{util.xml_escape(t['format'])}'/></disk>"
for t in targets
)
self.instance.backupBegin(
f"<domainbackup mode='push'><disks>{disks}</disks></domainbackup>",
None,
VIR_DOMAIN_BACKUP_BEGIN_REUSE_EXTERNAL | VIR_DOMAIN_BACKUP_BEGIN_PRESERVE_SHUTDOWN_DOMAIN,
)
def backup_targets(self):
"""The target paths of the running backup; None when none runs."""
try:
xml = self.instance.backupGetXMLDesc(0)
except libvirtError as err:
if err.get_error_code() == VIR_ERR_NO_DOMAIN_BACKUP:
return None
raise
return set(etree.fromstring(xml).xpath("disks/disk/target/@file"))
def backup_progress(self):
"""(bytes copied, bytes to copy) of the running backup; None when
none runs."""
stats = self.instance.jobStats(0)
if (
stats.get("type", VIR_DOMAIN_JOB_NONE) == VIR_DOMAIN_JOB_NONE
or stats.get("operation") != VIR_DOMAIN_JOB_OPERATION_BACKUP
):
return None
return stats.get("disk_processed", 0), stats.get("disk_total", 0)
def backup_result(self):
"""(success, libvirt's error) of the completed backup, kept for later
reads; None when libvirt has no completed backup."""
stats = self.instance.jobStats(VIR_DOMAIN_JOB_STATS_COMPLETED | VIR_DOMAIN_JOB_STATS_KEEP_COMPLETED)
if stats.get("operation") != VIR_DOMAIN_JOB_OPERATION_BACKUP or "success" not in stats:
return None
return bool(stats["success"]), stats.get("errmsg") or ""
def discard_backup_result(self):
"""Drops the result libvirt keeps of the VM's last completed job, so
a later backup_result() can only be of a backup started after this."""
self.instance.jobStats(VIR_DOMAIN_JOB_STATS_COMPLETED)
def abort_backup(self):
self.instance.abortJob()
def ping_agent(self):
"""A frozen or dead agent fails here in AGENT_TIMEOUT seconds. The
freeze that follows is libvirt's, without a timeout: a timed freeze
could still end in the guest after its thaw failed, leaving the guest
frozen."""
agent_command(self.instance, "guest-ping")
def fs_freeze(self):
self.ping_agent()
self.instance.fsFreeze(None, 0)
def fs_thaw(self):
self.instance.fsThaw(None, 0)
def add_network(
self, mac_address, source, source_type="net", model="virtio", nwfilter=None
):
if source_type == "net":
interface_type = "network"
elif source_type == "bridge":
interface_type = "bridge"
else:
interface_type = "direct"
# Built with lxml so posted values are escaped, never parsed as XML.
iface = etree.Element("interface", type=interface_type)
if mac_address: # otherwise libvirt generates one
etree.SubElement(iface, "mac", address=mac_address)
if interface_type == "network":
etree.SubElement(iface, "source", network=source)
elif interface_type == "direct":
etree.SubElement(iface, "source", dev=source, mode="bridge")
else:
etree.SubElement(iface, "source", bridge=source)
if model and model != "default": # default: no model, libvirt chooses
etree.SubElement(iface, "model", type=model)
if nwfilter:
etree.SubElement(iface, "filterref", filter=nwfilter)
self._attach_device(etree.tostring(iface).decode())
def _attach_device(self, xml):
"""Attach to the persistent definition and, when the VM is active, to
the running one, in one call. QEMU checks a copy of the persistent
definition before the hotplug, so a conflict there (a disk target or
an agent channel already pending) changes neither, and a NIC without
a MAC gets the same one in both. Still possible: a failure after a
successful hotplug leaves the device in the running VM only, and a
SCSI controller added for a disk that then fails to plug stays. An
error never proves that a new volume is unused."""
flags = VIR_DOMAIN_AFFECT_CONFIG
if self.instance.isActive():
flags |= VIR_DOMAIN_AFFECT_LIVE
self.instance.attachDeviceFlags(xml, flags)
def _definitions(self):
"""(XMLDesc flags, affect flag) of the definitions a device edit applies to:
the running one when the VM is active (running or paused), and the
persistent one. Each is edited from its own XML."""
definitions = [(PERSISTENT_XML, VIR_DOMAIN_AFFECT_CONFIG)]
if self.instance.isActive():
definitions.insert(0, (VIR_DOMAIN_XML_SECURE, VIR_DOMAIN_AFFECT_LIVE))
return definitions
def _nic(self, xml_flags, mac_address):
found = etree.fromstring(self._XMLDesc(xml_flags)).xpath(
"./devices/interface[mac/@address=$mac]", mac=mac_address
)
return found[0] if found else None
def delete_network(self, mac_address):
"""Detach the NIC; returns its persistent XML, or None if there is none."""
removed = None
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")
if nic is not None:
removed = etree.tostring(nic).decode()
self.instance.detachDeviceFlags(removed, affect)
return removed
def change_network(self, old_mac, mac, source, source_type, model, nwfilter):
"""Edit the NIC with old_mac in the persistent definition, in place.
Settings this form does not edit (QoS, link state, driver, PCI address)
stay. A running VM gets the change at its next start.
"""
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}")
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")
iface_type = {"net": "network", "bridge": "bridge"}.get(source_type, "direct")
iface.set("type", iface_type)
for old_source in iface.findall("source"):
iface.remove(old_source)
source_attrs = {"network": {"network": source}, "bridge": {"bridge": source}}.get(
iface_type, {"dev": source, "mode": "bridge"}
)
iface.insert(1, etree.Element("source", **source_attrs))
iface.find("mac").set("address", mac)
for old_model in iface.findall("model"):
iface.remove(old_model)
if model and model != "default": # default: no model, libvirt chooses
etree.SubElement(iface, "model", type=model)
for old_filter in iface.findall("filterref"):
iface.remove(old_filter)
if nwfilter:
etree.SubElement(iface, "filterref", filter=nwfilter)
self._defineXML(etree.tostring(tree).decode())
def _usb_hostdevs(self, xml_flags):
"""(element, selector) of each USB host device of a definition. The
selector (usb_selector) is read from the migratable XML, the only one
that marks the addresses libvirt resolved for a running VM."""
elements = etree.fromstring(self._XMLDesc(xml_flags)).xpath(USB_HOSTDEVS)
marked = etree.fromstring(self._XMLDesc(xml_flags | VIR_DOMAIN_XML_MIGRATABLE)).xpath(USB_HOSTDEVS)
return [(element, usb_selector(mark.find("source"))) for element, mark in zip(elements, marked)]
def get_hostdevs(self):
"""The VM's host devices, each once, with whether the running VM has
it ("now") and whether its next start has it ("next"): USB devices
by selector (vendor, product, address) and the device the running
VM holds; PCI devices by address, and whether libvirt manages them
(takes them from their host drivers); the others (SCSI, mediated, NICs
that are host devices) by type and address, which the panel does
not change."""
usb, pci, others = {}, {}, {}
for xml_flags, affect in self._definitions():
where = "now" if affect == VIR_DOMAIN_AFFECT_LIVE else "next"
for element, selector in self._usb_hostdevs(xml_flags):
row = usb.setdefault(selector, {"vendor": selector[0], "product": selector[1], "address": selector[2],
"now": None, "next": False})
if where == "now":
held = element.find("source/address")
row["now"] = (int(held.get("bus")), int(held.get("device"))) if held is not None else selector[2]
else:
row["next"] = True
tree = etree.fromstring(self._XMLDesc(xml_flags))
for address in tree.xpath(PCI_HOSTDEVS + "/source/address"):
key = pci_address(address.get("domain", "0"), address.get("bus"), address.get("slot"), address.get("function"))
row = pci.setdefault(key, {"address": key, "now": False, "next": False, "managed": False})
row[where] = True
row["managed"] = row["managed"] or address.getparent().getparent().get("managed") == "yes"
for device in tree.xpath("devices/hostdev[not(@type='usb' or @type='pci')]"
" | devices/interface[@type='hostdev' or actual/@type='hostdev']"):
if device.tag == "interface" and device.get("type") != "hostdev":
source = device.find("actual/source") # a NIC of a network that gives host devices
else:
source = device.find("source")
address = source.find("address") if source is not None else None
if address is not None and address.get("slot") is not None:
label = pci_address(address.get("domain", "0"), address.get("bus"), address.get("slot"),
address.get("function"))
else: # a mediated device's UUID; a SCSI device as its source
label = address.get("uuid", "") if address is not None else ""
kind = "nic" if device.tag == "interface" else device.get("type", device.get("mode"))
source_text = " ".join(etree.tostring(source, encoding="unicode").split()) if source is not None else ""
row = others.setdefault((kind, label or source_text),
{"type": kind, "address": label, "now": False, "next": False})
row[where] = True
return list(usb.values()), list(pci.values()), list(others.values())
def _pci_hostdev(self, address, xml_flags=PERSISTENT_XML):
"""A definition's <hostdev> of the PCI device at address, or None."""
tree = etree.fromstring(self._XMLDesc(xml_flags))
for found in tree.xpath(PCI_HOSTDEVS + "/source/address"):
if pci_address(found.get("domain", "0"), found.get("bus"), found.get("slot"), found.get("function")) == address:
return found.getparent().getparent()
return None
def _pci_running(self):
"""Whether a PCI change goes to the running VM too: True while it
runs, False while it is shut off. A paused VM, or one with a saved
state (managed save, which would start without the change), is
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")
return status == VIR_DOMAIN_RUNNING
def _pci_outcome(self, address, err):
"""An OperationError with err and where the PCI device at address is
now: in the running VM, in its next start; the outcome is unknown
when the VM cannot be read."""
try:
running = self.instance.isActive()
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}")
def add_pci_hostdev(self, address):
"""Gives the VM the host's PCI device at address (0000:05:00.0), for
its next start and, while it runs, at once: libvirt binds it to
vfio-pci, and gives it back when the VM stops or lets it go. A hot
plug that fails says where the device is (the two are not changed
together)."""
running = self._pci_running()
domain, bus, slot_function = address.split(":")
slot, function = slot_function.split(".")
hostdev = etree.Element("hostdev", mode="subsystem", type="pci", managed="yes")
etree.SubElement(etree.SubElement(hostdev, "source"), "address", domain=f"0x{domain}", bus=f"0x{bus}",
slot=f"0x{slot}", function=f"0x{function}")
flags = VIR_DOMAIN_AFFECT_CONFIG | (VIR_DOMAIN_AFFECT_LIVE if running else 0)
try:
self.instance.attachDeviceFlags(etree.tostring(hostdev, encoding="unicode"), flags)
except libvirtError as err:
if not running:
raise
raise self._pci_outcome(address, err) from None
def delete_pci_hostdev(self, address):
"""Takes the PCI device at address from the VM: from the running VM
(the guest has to let it go; libvirt waits a few seconds for it) and
from its next start, each as it has it. Returns True when the guest
has not let it go yet: it stays in the running VM until it does or
the VM stops. A failure on a running VM says where the device is."""
running = self._pci_running()
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}")
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)
if saved is not None:
self.instance.detachDeviceFlags(etree.tostring(saved, encoding="unicode"), VIR_DOMAIN_AFFECT_CONFIG)
return live is not None and self._pci_hostdev(address, VIR_DOMAIN_XML_SECURE) is not None
except libvirtError as err:
if not running:
raise
raise self._pci_outcome(address, err) from None
def add_usb_hostdev(self, vendor, product, address=None):
"""Gives the VM the host's USB device of vendor and product, at
address (bus, device) when given: the host has more than one of the
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")
hostdev = etree.Element("hostdev", mode="subsystem", type="usb", managed="yes")
source = etree.SubElement(hostdev, "source")
etree.SubElement(source, "vendor", id=vendor)
etree.SubElement(source, "product", id=product)
if address:
etree.SubElement(source, "address", bus=str(address[0]), device=str(address[1]))
self._attach_device(etree.tostring(hostdev, encoding="unicode"))
def delete_usb_hostdev(self, selector):
"""Takes the USB host device of selector (usb_selector) from each
definition that has it, as that definition describes it."""
found = False
for xml_flags, affect in self._definitions():
for element, own in self._usb_hostdevs(xml_flags):
if own == selector:
found = True
self.instance.detachDeviceFlags(etree.tostring(element, encoding="unicode"), affect)
if not found:
raise util.OperationError("The VM has no such USB device")
def set_link_state(self, mac_address, state):
for xml_flags, affect in self._definitions():
nic = self._nic(xml_flags, mac_address)
if nic is None:
continue
for link in nic.findall("link"):
nic.remove(link)
etree.SubElement(nic, "link", state=state)
self.instance.updateDeviceFlags(etree.tostring(nic).decode(), affect)
def _set_options(self, tree, options):
for o in ["title", "description"]:
option = tree.find(o)
option_value = options.get(o, "").strip()
if not option_value:
if option is not None:
tree.remove(option)
else:
if option is None:
option = etree.SubElement(tree, o)
option.text = option_value
def get_rng(self):
"""The persistent definition's RNG device: "virtio" for the panel's
own (virtio, from the host's /dev/urandom), "custom" for another one,
None without one."""
rngs = etree.fromstring(self._XMLDesc(PERSISTENT_XML)).xpath("devices/rng")
if not rngs:
return None
# libvirt adds only its address and alias to the panel's device; a rate or driver makes it another one
own = "@model='virtio' and backend[@model='random']='/dev/urandom' and not(*[not(self::backend or self::address or self::alias)])"
if len(rngs) == 1 and rngs[0].xpath(own):
return "virtio"
return "custom"
def set_rng(self, enabled):
"""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")
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
for rng in tree.xpath("devices/rng"):
rng.getparent().remove(rng)
if enabled:
tree.find("devices").append(etree.fromstring(RNG_DEVICE))
self._defineXML(etree.tostring(tree).decode())
def get_on_crash(self):
"""What the next start does when the guest crashes; libvirt's default is destroy."""
return etree.fromstring(self._XMLDesc(PERSISTENT_XML)).findtext("on_crash") or "destroy"
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}")
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
on_crash = tree.find("on_crash")
if on_crash is None:
on_crash = etree.SubElement(tree, "on_crash")
on_crash.text = action
self._defineXML(etree.tostring(tree).decode())
def set_options(self, options):
"""
Function change description, title
"""
xml = self._XMLDesc(PERSISTENT_XML)
tree = etree.fromstring(xml)
self._set_options(tree, options)
new_xml = etree.tostring(tree).decode()
self._defineXML(new_xml)
def set_memory(self, size, flags=0):
self.instance.setMemoryFlags(size, flags)
def set_qos(self, mac, direction, average, peak, burst):
if direction not in ("inbound", "outbound"):
raise ValueError("Direction must be inbound or outbound")
average = int(average)
peak = int(peak)
burst = int(burst)
xml = f"<{direction} average='{average}' peak='{peak}' burst='{burst}'/>"
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
macs = tree.xpath("/domain/devices/interface/mac")
for cur_mac in macs:
if cur_mac.get("address") == mac:
interface = cur_mac.getparent()
band = interface.find("bandwidth")
if band is None:
xml = "<bandwidth>" + xml + "</bandwidth>"
interface.append(etree.fromstring(xml))
else:
direct = band.find(direction)
if direct is not None:
# keep what the form does not post (an inbound floor)
direct.attrib.update(etree.fromstring(xml).attrib)
else:
band.append(etree.fromstring(xml))
new_xml = etree.tostring(tree).decode()
self._defineXML(new_xml)
def unset_qos(self, mac, direction):
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
for direct in tree.xpath(
"/domain/devices/interface/bandwidth/{}".format(direction)
):
band_el = direct.getparent()
interface_el = (
band_el.getparent()
) # parent bandwidth,its parent is interface
parent_mac = interface_el.xpath("mac/@address")
if parent_mac[0] == mac:
band_el.remove(direct)
self._defineXML(etree.tostring(tree).decode())
def add_guest_agent(self):
"""Adds the agent channel to the definitions that do not have it."""
flags = 0
for xml_flags, affect in self._definitions():
if not etree.fromstring(self._XMLDesc(xml_flags)).xpath(GUEST_AGENT_CHANNEL):
flags |= affect
if flags:
channel_xml = "<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>"
self.instance.attachDeviceFlags(channel_xml, flags)
def remove_guest_agent(self):
for xml_flags, affect in self._definitions():
tree = etree.fromstring(self._XMLDesc(xml_flags))
for target in tree.xpath(GUEST_AGENT_CHANNEL):
channel_xml = etree.tostring(target.getparent()).decode()
self.instance.detachDeviceFlags(channel_xml, affect)
def get_guest_agent(self, config=False):
def _get_agent(doc):
"""
Return agent channel object if it is defined.
"""
for channel in doc.xpath("/domain/devices/channel"):
ch_type = channel.get("type")
target = channel.find("target")
target_name = target.get("name")
if ch_type == "unix" and target_name == "org.qemu.guest_agent.0":
return channel
return None
return util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), func=_get_agent)
def set_user_password(self, user, crypted):
"""Set a user's password in the running guest through its agent;
crypted is a crypt(3) hash."""
password = base64.b64encode(crypted.encode()).decode()
agent_command(self.instance, "guest-set-user-password", {"username": user, "password": password, "crypted": True})
def add_authorized_key(self, user, key):
"""Add an SSH public key to the user's authorized keys in the running
guest through its agent."""
agent_command(self.instance, "guest-ssh-add-authorized-keys", {"username": user, "keys": [key], "reset": False})
def is_agent_ready(self):
"""
Return connected state of an agent.
"""
# we need to get a fresh agent channel object on each call so it
# reflects the current state
dev = self.get_guest_agent()
if dev is not None:
states = dev.xpath("target/@state")
state = states[0] if len(states) > 0 else ""
if state == "connected":
return True
return False