mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
Two disks of a VM with the same file name in different directories were both offered the same destination pool by default. The form was sent, and the migration job refused it only then: both copies would be one file. The plan now gives each such disk, by default, a pool no earlier disk of that name is copied into, and says under its choice that another disk has the file name. A choice that still puts them in one pool is refused by the job, as before.
3139 lines
146 KiB
Python
3139 lines
146 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, also when the request
|
|
was made while it ran (live). An offline request for a running or
|
|
paused VM 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:
|
|
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")
|
|
# a shut-off VM moves as its definition or started paused: either loses the saved memory
|
|
if state == 5 and dom.hasManagedSaveImage(0):
|
|
raise util.OperationError("A VM with a saved state cannot be migrated")
|
|
# 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"
|
|
)
|
|
|
|
# the TPM state is a file of the source host: a live migration carries it
|
|
tpm_state = etree.fromstring(definitions[0]).xpath("devices/tpm/backend[@type='emulator']")
|
|
if copies or (state == 5 and tpm_state):
|
|
note = self.migrate_running(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_running(self, conn, state, flags, uri, copies):
|
|
"""Migrates the VM of conn as a running VM, which carries its TPM
|
|
state, while libvirt copies the disks of copies into their pools here
|
|
(NON_SHARED_DISK), the definitions naming the new paths. 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_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 copies:
|
|
params[VIR_MIGRATE_PARAM_MIGRATE_DISKS] = [copy["target"] for copy in copies]
|
|
flags |= VIR_MIGRATE_NON_SHARED_DISK
|
|
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)
|
|
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
|
|
|
|
def migration_plan(self, conn, live_xml, next_xml):
|
|
"""The disks of the Migrate tab for a VM coming here (disk_plan of
|
|
live_xml), each with the pools here it can be copied into, as
|
|
disk_copies takes them: {free_pools, default} added, and the file
|
|
disks of next_xml (the next start's definition) that the migration
|
|
does not carry, which must be on shared storage. Disks of one file
|
|
name ({same_name} added) are copied into different pools by default."""
|
|
here = self.file_pools()
|
|
for pool, directory in here.values():
|
|
pool.refresh(0)
|
|
disks = self.disk_plan(conn, live_xml)
|
|
files = [os.path.basename(disk["path"]) for disk in disks]
|
|
taken = {} # file name: the pools the disks before of that name are copied into by default
|
|
for disk, file in zip(disks, files):
|
|
# a copy never replaces a file: a pool that has one of its name cannot take it
|
|
disk["free_pools"] = [name for name, (pool, directory) in sorted(here.items())
|
|
if self._find_volume(pool, file) is None]
|
|
# a copy goes by default to the pool on the same directory, else the first,
|
|
# but not where another disk of its name goes
|
|
free = [name for name in disk["free_pools"] if name not in taken.get(file, [])]
|
|
names = [name for name in free if here[name][1] == os.path.dirname(disk["path"])] or free
|
|
disk["default"] = names[0] if names else None
|
|
disk["same_name"] = files.count(file) > 1
|
|
if not disk["shared"]:
|
|
taken.setdefault(file, []).append(disk["default"])
|
|
carried = {disk["path"] for disk in disks}
|
|
pending = [row for row in self.disk_plan(conn, next_xml) if row["path"] not in carried]
|
|
return disks, pending
|
|
|
|
@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
|
|
|
|
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; a VM with TPM state migrates paused: migrate_running). 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."""
|
|
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_pcie_slots(self, config=False):
|
|
"""(PCIe root ports, free ones) of the running or the persistent
|
|
definition, None when it has none (not a PCIe machine): each PCIe
|
|
device takes a port, the one whose index is its address's bus."""
|
|
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML if config else 0))
|
|
ports = tree.xpath("./devices/controller[@type='pci'][@model='pcie-root-port']/@index")
|
|
if not ports:
|
|
return None
|
|
used = {int(bus, 16) for bus in tree.xpath("./devices/*/address[@type='pci']/@bus")}
|
|
return len(ports), sum(int(index) not in used for index in ports)
|
|
|
|
def add_pcie_slots(self, count):
|
|
"""Add count PCIe root ports to the persistent definition, which libvirt
|
|
numbers and places; a running VM gets them at its next start."""
|
|
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
|
|
devices = tree.find("devices")
|
|
for _ in range(count):
|
|
etree.SubElement(devices, "controller", type="pci", model="pcie-root-port")
|
|
self._defineXML(etree.tostring(tree).decode())
|
|
|
|
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, config=False):
|
|
mem = util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 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 get_iso_path(self, image):
|
|
"""The path of the ISO image of that name, in one of the active pools."""
|
|
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")
|
|
return paths[0]
|
|
|
|
def mount_iso(self, dev, image):
|
|
self._set_cdrom_media(dev, self.get_iso_path(image))
|
|
|
|
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}' "
|
|
|
|
# a USB CD-ROM is a CD drive on usb-bot only: usb-storage, which libvirt
|
|
# takes where QEMU has no usb-bot (RHEL 9), shows the guest a hard disk
|
|
usb_cd = " model='usb-bot'" if disk_device == "cdrom" and target_bus == "usb" else ""
|
|
xml_disk = f"<disk type='{disk_type}' device='{disk_device}'{usb_cd}>"
|
|
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, known by their MAC; 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": [{"mac": mac, "rx": 0, "tx": 0} for mac, target in 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": [
|
|
{"mac": mac, "rx": _rate(old[0], new[0], seconds, 8), "tx": _rate(old[1], new[1], seconds, 8)}
|
|
for (mac, target), old, new in zip(nics, 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": [{"mac": mac, "rx": None, "tx": None} for mac, target in 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 refresh_iso_pools(self):
|
|
"""Refresh the active pools, so that an image copied to one by hand
|
|
is listed by get_iso_media. A pool that fails does not stop the
|
|
others; returns the errors, by pool name."""
|
|
failed = {}
|
|
for pool in self.wvm.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
|
|
try:
|
|
pool.refresh(0)
|
|
except libvirtError as err:
|
|
failed[pool.name()] = err
|
|
return failed
|
|
|
|
def get_iso_media(self):
|
|
"""The .iso volumes libvirt knows in the active pools; one copied in
|
|
by hand is known after refresh_iso_pools."""
|
|
iso = []
|
|
for pool in self.wvm.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
|
|
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, elsewhere=None):
|
|
"""This VM's disk paths, split into (only this VM, also used by another
|
|
domain). Its disks are those it runs with and those of its next start.
|
|
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. elsewhere(own): the
|
|
paths of these that other hosts use."""
|
|
self.refresh_instance_pools()
|
|
own = []
|
|
for disk in self.get_disk_devices() + self.get_disk_devices(config=True):
|
|
if disk["path"] and disk["path"] not in own:
|
|
own.append(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)
|
|
if elsewhere:
|
|
used_elsewhere |= elsewhere(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)
|
|
def below(path):
|
|
"""How many files the path grows from: an overlay is deleted before
|
|
the files it needs, so one that cannot be deleted keeps them."""
|
|
seen = set()
|
|
while (path := self.backing_path(path)) and path not in seen:
|
|
seen.add(path)
|
|
return len(seen)
|
|
|
|
return (
|
|
sorted((path for path in own if path not in used_elsewhere), key=below, reverse=True),
|
|
[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):
|
|
self._check_snapshot_state()
|
|
if self.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
|
|
raise util.OperationError("The VM has external snapshots; internal snapshots cannot be mixed with them")
|
|
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)),
|
|
"children": snap.numChildren(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 snapshot create, revert and delete, internal and
|
|
external. The internal/external mix is checked only on create, so a
|
|
VM that is already mixed can still be cleaned up."""
|
|
if self.instance.hasManagedSaveImage(0):
|
|
raise util.OperationError("The VM has a saved state; start it or remove the saved state first")
|
|
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._check_snapshot_state()
|
|
self._snapshot(snapshot, external=False).delete(0)
|
|
|
|
def snapshot_revert(self, snapshot):
|
|
"""An internal snapshot is reverted to on a shut-off VM; one of a
|
|
running VM starts it again as it was then."""
|
|
if self.get_status() != 5:
|
|
raise util.OperationError("Shut the VM down to revert to an internal snapshot")
|
|
self._check_snapshot_state()
|
|
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, and so does the source when source is None. 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")
|
|
|
|
if source is not None:
|
|
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_tpm(self):
|
|
"""The persistent definition's TPM: "emulator" for a plain emulated
|
|
TPM 2.0, the panel's own, "custom" for another one, None without one."""
|
|
tpms = etree.fromstring(self._XMLDesc(PERSISTENT_XML)).xpath("devices/tpm")
|
|
if not tpms:
|
|
return None
|
|
# libvirt adds only an alias; encryption, a state path or another
|
|
# backend setting is kept by the XML editor, not dropped by the switch
|
|
own = ("backend[@type='emulator' and (not(@version) or @version='2.0') and not(*)"
|
|
" and not(@*[name()!='type' and name()!='version'])] and not(*[not(self::backend or self::alias)])")
|
|
if len(tpms) == 1 and tpms[0].xpath(own):
|
|
return "emulator"
|
|
return "custom"
|
|
|
|
def set_tpm(self, enabled):
|
|
"""Adds an emulated TPM 2.0 to the persistent definition, or removes it;
|
|
another TPM is left to the XML editor. Its state stays on the host until
|
|
the VM is undefined, so a TPM added again has it back."""
|
|
if self.get_tpm() == "custom":
|
|
raise util.OperationError("The VM has its own TPM: change it in the XML editor")
|
|
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
|
|
for tpm in tree.xpath("devices/tpm"):
|
|
tpm.getparent().remove(tpm)
|
|
if enabled:
|
|
model = self.get_tpm_model(self.get_arch(), self.get_machine_type())
|
|
if not model:
|
|
raise util.OperationError("This host offers no TPM 2.0: install swtpm on it")
|
|
tree.find("devices").append(
|
|
etree.fromstring(f"<tpm model='{model}'><backend type='emulator' version='2.0'/></tpm>"))
|
|
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
|