Import QEMU upstream snapshot d2e570c
Upstream: https://gitlab.com/qemu-project/qemu.git Upstream-Commit: d2e570cc0f97b936902a5b1b86b73c0f5998b475
This commit is contained in:
commit
cf256aa081
11315 files changed
+3598369
No files matched your search
@@ -0,0 +1,7 @@
|
||||
qemu.utils package
|
||||
==================
|
||||
|
||||
This package provides miscellaneous utilities used for testing and
|
||||
debugging QEMU. It is used primarily by the vm and functional tests.
|
||||
|
||||
See the documentation in ``__init__.py`` for more information.
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
QEMU development and testing utilities
|
||||
|
||||
This package provides a small handful of utilities for performing
|
||||
various tasks not directly related to the launching of a VM.
|
||||
"""
|
||||
|
||||
# Copyright (C) 2021 Red Hat Inc.
|
||||
#
|
||||
# Authors:
|
||||
# John Snow <[email protected]>
|
||||
# Cleber Rosa <[email protected]>
|
||||
#
|
||||
# This work is licensed under the terms of the GNU GPL, version 2. See
|
||||
# the COPYING file in the top-level directory.
|
||||
#
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from subprocess import CalledProcessError
|
||||
import textwrap
|
||||
from typing import Optional
|
||||
|
||||
# pylint: disable=import-error
|
||||
from .accel import (
|
||||
hvf_available,
|
||||
kvm_available,
|
||||
list_accel,
|
||||
tcg_available,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
'VerboseProcessError',
|
||||
'add_visual_margin',
|
||||
'get_info_usernet_hostfwd_port',
|
||||
'hvf_available',
|
||||
'kvm_available',
|
||||
'list_accel',
|
||||
'tcg_available',
|
||||
)
|
||||
|
||||
|
||||
def get_info_usernet_hostfwd_port(info_usernet_output: str) -> Optional[int]:
|
||||
"""
|
||||
Returns the port given to the hostfwd parameter via info usernet
|
||||
|
||||
:param info_usernet_output: output generated by hmp command "info usernet"
|
||||
:return: the port number allocated by the hostfwd option
|
||||
"""
|
||||
for line in info_usernet_output.split('\r\n'):
|
||||
regex = r'TCP.HOST_FORWARD.*127\.0\.0\.1\s+(\d+)\s+10\.'
|
||||
match = re.search(regex, line)
|
||||
if match is not None:
|
||||
return int(match[1])
|
||||
return None
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def add_visual_margin(
|
||||
content: str = '',
|
||||
width: Optional[int] = None,
|
||||
name: Optional[str] = None,
|
||||
padding: int = 1,
|
||||
upper_left: str = '┏',
|
||||
lower_left: str = '┗',
|
||||
horizontal: str = '━',
|
||||
vertical: str = '┃',
|
||||
) -> str:
|
||||
"""
|
||||
Decorate and wrap some text with a visual decoration around it.
|
||||
|
||||
This function assumes that the text decoration characters are single
|
||||
characters that display using a single monospace column.
|
||||
|
||||
┏━ Example ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
┃ This is what this function looks like with text content that's
|
||||
┃ wrapped to 66 characters. The right-hand margin is left open to
|
||||
┃ accommodate the occasional unicode character that might make
|
||||
┃ predicting the total "visual" width of a line difficult. This
|
||||
┃ provides a visual distinction that's good-enough, though.
|
||||
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
:param content: The text to wrap and decorate.
|
||||
:param width:
|
||||
The number of columns to use, including for the decoration
|
||||
itself. The default (None) uses the available width of the
|
||||
current terminal, or a fallback of 72 lines. A negative number
|
||||
subtracts a fixed-width from the default size. The default obeys
|
||||
the COLUMNS environment variable, if set.
|
||||
:param name: A label to apply to the upper-left of the box.
|
||||
:param padding: How many columns of padding to apply inside.
|
||||
:param upper_left: Upper-left single-width text decoration character.
|
||||
:param lower_left: Lower-left single-width text decoration character.
|
||||
:param horizontal: Horizontal single-width text decoration character.
|
||||
:param vertical: Vertical single-width text decoration character.
|
||||
"""
|
||||
if width is None or width < 0:
|
||||
avail = shutil.get_terminal_size(fallback=(72, 24))[0]
|
||||
if width is None:
|
||||
_width = avail
|
||||
else:
|
||||
_width = avail + width
|
||||
else:
|
||||
_width = width
|
||||
|
||||
prefix = vertical + (' ' * padding)
|
||||
|
||||
def _bar(name: Optional[str], top: bool = True) -> str:
|
||||
ret = upper_left if top else lower_left
|
||||
if name is not None:
|
||||
ret += f"{horizontal} {name} "
|
||||
|
||||
filler_len = _width - len(ret)
|
||||
ret += f"{horizontal * filler_len}"
|
||||
return ret
|
||||
|
||||
def _wrap(line: str) -> str:
|
||||
return os.linesep.join(
|
||||
textwrap.wrap(
|
||||
line, width=_width - padding, initial_indent=prefix,
|
||||
subsequent_indent=prefix, replace_whitespace=False,
|
||||
drop_whitespace=True, break_on_hyphens=False)
|
||||
)
|
||||
|
||||
return os.linesep.join((
|
||||
_bar(name, top=True),
|
||||
os.linesep.join(_wrap(line) for line in content.splitlines()),
|
||||
_bar(None, top=False),
|
||||
))
|
||||
|
||||
|
||||
class VerboseProcessError(CalledProcessError):
|
||||
"""
|
||||
The same as CalledProcessError, but more verbose.
|
||||
|
||||
This is useful for debugging failed calls during test executions.
|
||||
The return code, signal (if any), and terminal output will be displayed
|
||||
on unhandled exceptions.
|
||||
"""
|
||||
def summary(self) -> str:
|
||||
"""Return the normal CalledProcessError str() output."""
|
||||
return super().__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
lmargin = ' '
|
||||
width = -len(lmargin)
|
||||
sections = []
|
||||
|
||||
# Does self.stdout contain both stdout and stderr?
|
||||
has_combined_output = self.stderr is None
|
||||
|
||||
name = 'output' if has_combined_output else 'stdout'
|
||||
if self.stdout:
|
||||
sections.append(add_visual_margin(self.stdout, width, name))
|
||||
else:
|
||||
sections.append(f"{name}: N/A")
|
||||
|
||||
if self.stderr:
|
||||
sections.append(add_visual_margin(self.stderr, width, 'stderr'))
|
||||
elif not has_combined_output:
|
||||
sections.append("stderr: N/A")
|
||||
|
||||
return os.linesep.join((
|
||||
self.summary(),
|
||||
textwrap.indent(os.linesep.join(sections), prefix=lmargin),
|
||||
))
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
QEMU accel module:
|
||||
|
||||
This module provides utilities for discover and check the availability of
|
||||
accelerators.
|
||||
"""
|
||||
# Copyright (C) 2015-2016 Red Hat Inc.
|
||||
# Copyright (C) 2012 IBM Corp.
|
||||
#
|
||||
# Authors:
|
||||
# Fam Zheng <[email protected]>
|
||||
#
|
||||
# This work is licensed under the terms of the GNU GPL, version 2. See
|
||||
# the COPYING file in the top-level directory.
|
||||
#
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# Mapping host architecture to any additional architectures it can
|
||||
# support which often includes its 32 bit cousin.
|
||||
ADDITIONAL_ARCHES = {
|
||||
"x86_64": "i386",
|
||||
"aarch64": "armhf",
|
||||
"ppc64le": "ppc64",
|
||||
}
|
||||
|
||||
|
||||
def list_accel(qemu_bin: str) -> List[str]:
|
||||
"""
|
||||
List accelerators enabled in the QEMU binary.
|
||||
|
||||
@param qemu_bin (str): path to the QEMU binary.
|
||||
@raise Exception: if failed to run ``qemu -accel help``
|
||||
@return a list of accelerator names.
|
||||
"""
|
||||
if not qemu_bin:
|
||||
return []
|
||||
try:
|
||||
out = subprocess.check_output([qemu_bin, '-accel', 'help'],
|
||||
universal_newlines=True)
|
||||
except:
|
||||
LOG.debug("Failed to get the list of accelerators in %s", qemu_bin)
|
||||
raise
|
||||
# Skip the first line which is the header.
|
||||
return [acc.strip() for acc in out.splitlines()[1:]]
|
||||
|
||||
|
||||
def kvm_available(target_arch: Optional[str] = None,
|
||||
qemu_bin: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if KVM is available using the following heuristic:
|
||||
- Kernel module is present in the host;
|
||||
- Target and host arches don't mismatch;
|
||||
- KVM is enabled in the QEMU binary.
|
||||
|
||||
@param target_arch (str): target architecture
|
||||
@param qemu_bin (str): path to the QEMU binary
|
||||
@return True if kvm is available, otherwise False.
|
||||
"""
|
||||
if not os.access("/dev/kvm", os.R_OK | os.W_OK):
|
||||
return False
|
||||
if target_arch:
|
||||
host_arch = os.uname()[4]
|
||||
if target_arch != host_arch:
|
||||
if target_arch != ADDITIONAL_ARCHES.get(host_arch):
|
||||
return False
|
||||
if qemu_bin and "kvm" not in list_accel(qemu_bin):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def tcg_available(qemu_bin: str) -> bool:
|
||||
"""
|
||||
Check if TCG is available.
|
||||
|
||||
@param qemu_bin (str): path to the QEMU binary
|
||||
"""
|
||||
return 'tcg' in list_accel(qemu_bin)
|
||||
|
||||
|
||||
def hvf_available(qemu_bin: str) -> bool:
|
||||
"""
|
||||
Check if HVF is available.
|
||||
|
||||
@param qemu_bin (str): path to the QEMU binary
|
||||
"""
|
||||
return 'hvf' in list_accel(qemu_bin)
|
||||
Whitespace-only changes.
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
QEMU Guest Agent Client
|
||||
|
||||
Usage:
|
||||
|
||||
Start QEMU with:
|
||||
|
||||
# qemu [...] -chardev socket,path=/tmp/qga.sock,server=on,wait=off,id=qga0 \
|
||||
-device virtio-serial \
|
||||
-device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0
|
||||
|
||||
Run the script:
|
||||
|
||||
$ qemu-ga-client --address=/tmp/qga.sock <command> [args...]
|
||||
|
||||
or
|
||||
|
||||
$ export QGA_CLIENT_ADDRESS=/tmp/qga.sock
|
||||
$ qemu-ga-client <command> [args...]
|
||||
|
||||
For example:
|
||||
|
||||
$ qemu-ga-client cat /etc/resolv.conf
|
||||
# Generated by NetworkManager
|
||||
nameserver 10.0.2.3
|
||||
$ qemu-ga-client fsfreeze status
|
||||
thawed
|
||||
$ qemu-ga-client fsfreeze freeze
|
||||
2 filesystems frozen
|
||||
|
||||
See also: https://wiki.qemu.org/Features/QAPI/GuestAgent
|
||||
"""
|
||||
|
||||
# Copyright (C) 2012 Ryota Ozaki <[email protected]>
|
||||
#
|
||||
# This work is licensed under the terms of the GNU GPL, version 2. See
|
||||
# the COPYING file in the top-level directory.
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Optional,
|
||||
Sequence,
|
||||
)
|
||||
|
||||
from qemu.qmp import ConnectError, SocketAddrT
|
||||
from qemu.qmp.legacy import QEMUMonitorProtocol
|
||||
|
||||
|
||||
# This script has not seen many patches or careful attention in quite
|
||||
# some time. If you would like to improve it, please review the design
|
||||
# carefully and add docstrings at that point in time. Until then:
|
||||
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
|
||||
class QemuGuestAgent(QEMUMonitorProtocol):
|
||||
def __getattr__(self, name: str) -> Callable[..., Any]:
|
||||
def wrapper(**kwds: object) -> object:
|
||||
return self.cmd('guest-' + name.replace('_', '-'), **kwds)
|
||||
return wrapper
|
||||
|
||||
|
||||
class QemuGuestAgentClient:
|
||||
def __init__(self, address: SocketAddrT):
|
||||
self.qga = QemuGuestAgent(address)
|
||||
self.qga.connect(negotiate=False)
|
||||
|
||||
def sync(self, timeout: Optional[float] = 3) -> None:
|
||||
# Avoid being blocked forever
|
||||
if not self.ping(timeout):
|
||||
raise EnvironmentError('Agent seems not alive')
|
||||
uid = random.randint(0, (1 << 32) - 1)
|
||||
while True:
|
||||
ret = self.qga.sync(id=uid)
|
||||
if isinstance(ret, int) and int(ret) == uid:
|
||||
break
|
||||
|
||||
def __file_read_all(self, handle: int) -> bytes:
|
||||
eof = False
|
||||
data = b''
|
||||
while not eof:
|
||||
ret = self.qga.file_read(handle=handle, count=1024)
|
||||
_data = base64.b64decode(ret['buf-b64'])
|
||||
data += _data
|
||||
eof = ret['eof']
|
||||
return data
|
||||
|
||||
def read(self, path: str) -> bytes:
|
||||
handle = self.qga.file_open(path=path)
|
||||
try:
|
||||
data = self.__file_read_all(handle)
|
||||
finally:
|
||||
self.qga.file_close(handle=handle)
|
||||
return data
|
||||
|
||||
def info(self) -> str:
|
||||
info = self.qga.info()
|
||||
|
||||
msgs = []
|
||||
msgs.append('version: ' + info['version'])
|
||||
msgs.append('supported_commands:')
|
||||
enabled = [c['name'] for c in info['supported_commands']
|
||||
if c['enabled']]
|
||||
msgs.append('\tenabled: ' + ', '.join(enabled))
|
||||
disabled = [c['name'] for c in info['supported_commands']
|
||||
if not c['enabled']]
|
||||
msgs.append('\tdisabled: ' + ', '.join(disabled))
|
||||
|
||||
return '\n'.join(msgs)
|
||||
|
||||
@classmethod
|
||||
def __gen_ipv4_netmask(cls, prefixlen: int) -> str:
|
||||
mask = int('1' * prefixlen + '0' * (32 - prefixlen), 2)
|
||||
return '.'.join([str(mask >> 24),
|
||||
str((mask >> 16) & 0xff),
|
||||
str((mask >> 8) & 0xff),
|
||||
str(mask & 0xff)])
|
||||
|
||||
def ifconfig(self) -> str:
|
||||
nifs = self.qga.network_get_interfaces()
|
||||
|
||||
msgs = []
|
||||
for nif in nifs:
|
||||
msgs.append(nif['name'] + ':')
|
||||
if 'ip-addresses' in nif:
|
||||
for ipaddr in nif['ip-addresses']:
|
||||
if ipaddr['ip-address-type'] == 'ipv4':
|
||||
addr = ipaddr['ip-address']
|
||||
mask = self.__gen_ipv4_netmask(int(ipaddr['prefix']))
|
||||
msgs.append(f"\tinet {addr} netmask {mask}")
|
||||
elif ipaddr['ip-address-type'] == 'ipv6':
|
||||
addr = ipaddr['ip-address']
|
||||
prefix = ipaddr['prefix']
|
||||
msgs.append(f"\tinet6 {addr} prefixlen {prefix}")
|
||||
if nif['hardware-address'] != '00:00:00:00:00:00':
|
||||
msgs.append("\tether " + nif['hardware-address'])
|
||||
|
||||
return '\n'.join(msgs)
|
||||
|
||||
def ping(self, timeout: Optional[float]) -> bool:
|
||||
self.qga.settimeout(timeout)
|
||||
try:
|
||||
self.qga.ping()
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def fsfreeze(self, cmd: str) -> object:
|
||||
if cmd not in ['status', 'freeze', 'thaw']:
|
||||
raise ValueError('Invalid command: ' + cmd)
|
||||
# Can be int (freeze, thaw) or GuestFsfreezeStatus (status)
|
||||
return getattr(self.qga, 'fsfreeze' + '_' + cmd)()
|
||||
|
||||
def fstrim(self, minimum: int) -> Dict[str, object]:
|
||||
# returns GuestFilesystemTrimResponse
|
||||
ret = getattr(self.qga, 'fstrim')(minimum=minimum)
|
||||
assert isinstance(ret, dict)
|
||||
return ret
|
||||
|
||||
def suspend(self, mode: str) -> None:
|
||||
if mode not in ['disk', 'ram', 'hybrid']:
|
||||
raise ValueError('Invalid mode: ' + mode)
|
||||
|
||||
try:
|
||||
getattr(self.qga, 'suspend' + '_' + mode)()
|
||||
# On error exception will raise
|
||||
except asyncio.TimeoutError:
|
||||
# On success command will timed out
|
||||
pass
|
||||
|
||||
def shutdown(self, mode: str = 'powerdown') -> None:
|
||||
if mode not in ['powerdown', 'halt', 'reboot']:
|
||||
raise ValueError('Invalid mode: ' + mode)
|
||||
|
||||
try:
|
||||
self.qga.shutdown(mode=mode)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
def _cmd_cat(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
if len(args) != 1:
|
||||
print('Invalid argument')
|
||||
print('Usage: cat <file>')
|
||||
sys.exit(1)
|
||||
print(client.read(args[0]))
|
||||
|
||||
|
||||
def _cmd_fsfreeze(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
usage = 'Usage: fsfreeze status|freeze|thaw'
|
||||
if len(args) != 1:
|
||||
print('Invalid argument')
|
||||
print(usage)
|
||||
sys.exit(1)
|
||||
if args[0] not in ['status', 'freeze', 'thaw']:
|
||||
print('Invalid command: ' + args[0])
|
||||
print(usage)
|
||||
sys.exit(1)
|
||||
cmd = args[0]
|
||||
ret = client.fsfreeze(cmd)
|
||||
if cmd == 'status':
|
||||
print(ret)
|
||||
return
|
||||
|
||||
assert isinstance(ret, int)
|
||||
verb = 'frozen' if cmd == 'freeze' else 'thawed'
|
||||
print(f"{ret:d} filesystems {verb}")
|
||||
|
||||
|
||||
def _cmd_fstrim(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
if len(args) == 0:
|
||||
minimum = 0
|
||||
else:
|
||||
minimum = int(args[0])
|
||||
print(client.fstrim(minimum))
|
||||
|
||||
|
||||
def _cmd_ifconfig(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
assert not args
|
||||
print(client.ifconfig())
|
||||
|
||||
|
||||
def _cmd_info(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
assert not args
|
||||
print(client.info())
|
||||
|
||||
|
||||
def _cmd_ping(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
timeout = 3.0 if len(args) == 0 else float(args[0])
|
||||
alive = client.ping(timeout)
|
||||
if not alive:
|
||||
print("Not responded in %s sec" % args[0])
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _cmd_suspend(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
usage = 'Usage: suspend disk|ram|hybrid'
|
||||
if len(args) != 1:
|
||||
print('Less argument')
|
||||
print(usage)
|
||||
sys.exit(1)
|
||||
if args[0] not in ['disk', 'ram', 'hybrid']:
|
||||
print('Invalid command: ' + args[0])
|
||||
print(usage)
|
||||
sys.exit(1)
|
||||
client.suspend(args[0])
|
||||
|
||||
|
||||
def _cmd_shutdown(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
assert not args
|
||||
client.shutdown()
|
||||
|
||||
|
||||
_cmd_powerdown = _cmd_shutdown
|
||||
|
||||
|
||||
def _cmd_halt(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
assert not args
|
||||
client.shutdown('halt')
|
||||
|
||||
|
||||
def _cmd_reboot(client: QemuGuestAgentClient, args: Sequence[str]) -> None:
|
||||
assert not args
|
||||
client.shutdown('reboot')
|
||||
|
||||
|
||||
commands = [m.replace('_cmd_', '') for m in dir() if '_cmd_' in m]
|
||||
|
||||
|
||||
def send_command(address: str, cmd: str, args: Sequence[str]) -> None:
|
||||
if not os.path.exists(address):
|
||||
print(f"'{address}' not found. (Is QEMU running?)")
|
||||
sys.exit(1)
|
||||
|
||||
if cmd not in commands:
|
||||
print('Invalid command: ' + cmd)
|
||||
print('Available commands: ' + ', '.join(commands))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
client = QemuGuestAgentClient(address)
|
||||
except ConnectError as err:
|
||||
print(err)
|
||||
if isinstance(err.exc, ConnectionError):
|
||||
print('(Is QEMU running?)')
|
||||
sys.exit(1)
|
||||
|
||||
if cmd == 'fsfreeze' and args[0] == 'freeze':
|
||||
client.sync(60)
|
||||
elif cmd != 'ping':
|
||||
client.sync()
|
||||
|
||||
globals()['_cmd_' + cmd](client, args)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
address = os.environ.get('QGA_CLIENT_ADDRESS')
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--address', action='store',
|
||||
default=address,
|
||||
help='Specify a ip:port pair or a unix socket path')
|
||||
parser.add_argument('command', choices=commands)
|
||||
parser.add_argument('args', nargs='*')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.address is None:
|
||||
parser.error('address is not specified')
|
||||
sys.exit(1)
|
||||
|
||||
send_command(args.address, args.command, args.args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
QEMU Object Model testing tools.
|
||||
|
||||
usage: qom [-h] {set,get,list,tree,fuse} ...
|
||||
|
||||
Query and manipulate QOM data
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
|
||||
QOM commands:
|
||||
{set,get,list,tree,fuse}
|
||||
set Set a QOM property value
|
||||
get Get a QOM property value
|
||||
list List QOM properties at a given path
|
||||
tree Show QOM tree from a given path
|
||||
fuse Mount a QOM tree as a FUSE filesystem
|
||||
"""
|
||||
##
|
||||
# Copyright John Snow 2020, for Red Hat, Inc.
|
||||
# Copyright IBM, Corp. 2011
|
||||
#
|
||||
# Authors:
|
||||
# John Snow <[email protected]>
|
||||
# Anthony Liguori <[email protected]>
|
||||
#
|
||||
# This work is licensed under the terms of the GNU GPL, version 2 or later.
|
||||
# See the COPYING file in the top-level directory.
|
||||
#
|
||||
# Based on ./scripts/qmp/qom-[set|get|tree|list]
|
||||
##
|
||||
|
||||
import argparse
|
||||
from typing import List
|
||||
|
||||
from .qom_common import QOMCommand
|
||||
|
||||
|
||||
try:
|
||||
from .qom_fuse import QOMFuse
|
||||
except ModuleNotFoundError as _err:
|
||||
if _err.name != 'fuse':
|
||||
raise
|
||||
else:
|
||||
assert issubclass(QOMFuse, QOMCommand)
|
||||
|
||||
|
||||
class QOMSet(QOMCommand):
|
||||
"""
|
||||
QOM Command - Set a property to a given value.
|
||||
|
||||
usage: qom-set [-h] [--socket SOCKET] <path>.<property> <value>
|
||||
|
||||
Set a QOM property value
|
||||
|
||||
positional arguments:
|
||||
<path>.<property> QOM path and property, separated by a period '.'
|
||||
<value> new QOM property value
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--socket SOCKET, -s SOCKET
|
||||
QMP socket path or address (addr:port). May also be
|
||||
set via QMP_SOCKET environment variable.
|
||||
"""
|
||||
name = 'set'
|
||||
help = 'Set a QOM property value'
|
||||
|
||||
@classmethod
|
||||
def configure_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||
super().configure_parser(parser)
|
||||
cls.add_path_prop_arg(parser)
|
||||
parser.add_argument(
|
||||
'value',
|
||||
metavar='<value>',
|
||||
action='store',
|
||||
help='new QOM property value'
|
||||
)
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
super().__init__(args)
|
||||
self.path, self.prop = args.path_prop.rsplit('.', 1)
|
||||
self.value = args.value
|
||||
|
||||
def run(self) -> int:
|
||||
rsp = self.qmp.cmd(
|
||||
'qom-set',
|
||||
path=self.path,
|
||||
property=self.prop,
|
||||
value=self.value
|
||||
)
|
||||
print(rsp)
|
||||
return 0
|
||||
|
||||
|
||||
class QOMGet(QOMCommand):
|
||||
"""
|
||||
QOM Command - Get a property's current value.
|
||||
|
||||
usage: qom-get [-h] [--socket SOCKET] <path>.<property>
|
||||
|
||||
Get a QOM property value
|
||||
|
||||
positional arguments:
|
||||
<path>.<property> QOM path and property, separated by a period '.'
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--socket SOCKET, -s SOCKET
|
||||
QMP socket path or address (addr:port). May also be
|
||||
set via QMP_SOCKET environment variable.
|
||||
"""
|
||||
name = 'get'
|
||||
help = 'Get a QOM property value'
|
||||
|
||||
@classmethod
|
||||
def configure_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||
super().configure_parser(parser)
|
||||
cls.add_path_prop_arg(parser)
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
super().__init__(args)
|
||||
try:
|
||||
tmp = args.path_prop.rsplit('.', 1)
|
||||
except ValueError as err:
|
||||
raise ValueError('Invalid format for <path>.<property>') from err
|
||||
self.path = tmp[0]
|
||||
self.prop = tmp[1]
|
||||
|
||||
def run(self) -> int:
|
||||
rsp = self.qmp.cmd(
|
||||
'qom-get',
|
||||
path=self.path,
|
||||
property=self.prop
|
||||
)
|
||||
if isinstance(rsp, dict):
|
||||
for key, value in rsp.items():
|
||||
print(f"{key}: {value}")
|
||||
else:
|
||||
print(rsp)
|
||||
return 0
|
||||
|
||||
|
||||
class QOMList(QOMCommand):
|
||||
"""
|
||||
QOM Command - List the properties at a given path.
|
||||
|
||||
usage: qom-list [-h] [--socket SOCKET] <path>
|
||||
|
||||
List QOM properties at a given path
|
||||
|
||||
positional arguments:
|
||||
<path> QOM path
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--socket SOCKET, -s SOCKET
|
||||
QMP socket path or address (addr:port). May also be
|
||||
set via QMP_SOCKET environment variable.
|
||||
"""
|
||||
name = 'list'
|
||||
help = 'List QOM properties at a given path'
|
||||
|
||||
@classmethod
|
||||
def configure_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||
super().configure_parser(parser)
|
||||
parser.add_argument(
|
||||
'path',
|
||||
metavar='<path>',
|
||||
action='store',
|
||||
help='QOM path',
|
||||
)
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
super().__init__(args)
|
||||
self.path = args.path
|
||||
|
||||
def run(self) -> int:
|
||||
rsp = self.qom_list(self.path)
|
||||
for item in rsp:
|
||||
if item.child:
|
||||
print(f"{item.name}/")
|
||||
elif item.link:
|
||||
print(f"@{item.name}/")
|
||||
else:
|
||||
print(item.name)
|
||||
return 0
|
||||
|
||||
|
||||
class QOMTree(QOMCommand):
|
||||
"""
|
||||
QOM Command - Show the full tree below a given path.
|
||||
|
||||
usage: qom-tree [-h] [--socket SOCKET] [<path>]
|
||||
|
||||
Show QOM tree from a given path
|
||||
|
||||
positional arguments:
|
||||
<path> QOM path
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--socket SOCKET, -s SOCKET
|
||||
QMP socket path or address (addr:port). May also be
|
||||
set via QMP_SOCKET environment variable.
|
||||
"""
|
||||
name = 'tree'
|
||||
help = 'Show QOM tree from a given path'
|
||||
|
||||
@classmethod
|
||||
def configure_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||
super().configure_parser(parser)
|
||||
parser.add_argument(
|
||||
'path',
|
||||
metavar='<path>',
|
||||
action='store',
|
||||
help='QOM path',
|
||||
nargs='?',
|
||||
default='/'
|
||||
)
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
super().__init__(args)
|
||||
self.path = args.path
|
||||
|
||||
def _list_nodes(self, paths: List[str]) -> None:
|
||||
all_paths_props = self.qom_list_get(paths)
|
||||
i = 0
|
||||
|
||||
for props in all_paths_props:
|
||||
path = paths[i]
|
||||
i = i + 1
|
||||
print(path)
|
||||
if path == '/':
|
||||
path = ''
|
||||
newpaths = []
|
||||
|
||||
for item in props.properties:
|
||||
if item.child:
|
||||
newpaths += [f"{path}/{item.name}"]
|
||||
else:
|
||||
value = item.value
|
||||
if value is None:
|
||||
value = "<EXCEPTION: property could not be read>"
|
||||
print(f" {item.name}: {value} ({item.type})")
|
||||
|
||||
print('')
|
||||
|
||||
if newpaths:
|
||||
self._list_nodes(newpaths)
|
||||
|
||||
def run(self) -> int:
|
||||
self._list_nodes([self.path])
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""QOM script main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Query and manipulate QOM data'
|
||||
)
|
||||
subparsers = parser.add_subparsers(
|
||||
title='QOM commands',
|
||||
dest='command'
|
||||
)
|
||||
|
||||
for command in QOMCommand.__subclasses__():
|
||||
command.register(subparsers)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command is None:
|
||||
parser.error('Command not specified.')
|
||||
return 1
|
||||
|
||||
cmd_class = args.cmd_class
|
||||
assert isinstance(cmd_class, type(QOMCommand))
|
||||
return cmd_class.command_runner(args)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
QOM Command abstractions.
|
||||
"""
|
||||
##
|
||||
# Copyright John Snow 2020, for Red Hat, Inc.
|
||||
# Copyright IBM, Corp. 2011
|
||||
#
|
||||
# Authors:
|
||||
# John Snow <[email protected]>
|
||||
# Anthony Liguori <[email protected]>
|
||||
#
|
||||
# This work is licensed under the terms of the GNU GPL, version 2 or later.
|
||||
# See the COPYING file in the top-level directory.
|
||||
#
|
||||
# Based on ./scripts/qmp/qom-[set|get|tree|list]
|
||||
##
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Type,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from qemu.qmp import QMPError
|
||||
from qemu.qmp.legacy import QEMUMonitorProtocol
|
||||
|
||||
|
||||
class ObjectPropertyInfo:
|
||||
"""
|
||||
Represents the return type from e.g. qom-list.
|
||||
"""
|
||||
def __init__(self, name: str, type_: str,
|
||||
description: Optional[str] = None,
|
||||
default_value: Optional[object] = None):
|
||||
self.name = name
|
||||
self.type = type_
|
||||
self.description = description
|
||||
self.default_value = default_value
|
||||
|
||||
@classmethod
|
||||
def make(cls, value: Dict[str, Any]) -> 'ObjectPropertyInfo':
|
||||
"""
|
||||
Build an ObjectPropertyInfo from a Dict with an unknown shape.
|
||||
"""
|
||||
assert value.keys() >= {'name', 'type'}
|
||||
assert value.keys() <= {'name', 'type', 'description', 'default-value'}
|
||||
return cls(value['name'], value['type'],
|
||||
value.get('description'),
|
||||
value.get('default-value'))
|
||||
|
||||
@property
|
||||
def child(self) -> bool:
|
||||
"""Is this property a child property?"""
|
||||
return self.type.startswith('child<')
|
||||
|
||||
@property
|
||||
def link(self) -> bool:
|
||||
"""Is this property a link property?"""
|
||||
return self.type.startswith('link<')
|
||||
|
||||
|
||||
class ObjectPropertyValue:
|
||||
"""
|
||||
Represents a property return from e.g. qom-tree-get
|
||||
"""
|
||||
def __init__(self, name: str, type_: str, value: object):
|
||||
self.name = name
|
||||
self.type = type_
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def make(cls, value: Dict[str, Any]) -> 'ObjectPropertyValue':
|
||||
"""
|
||||
Build an ObjectPropertyValue from a Dict with an unknown shape.
|
||||
"""
|
||||
assert value.keys() >= {'name', 'type'}
|
||||
assert value.keys() <= {'name', 'type', 'value'}
|
||||
return cls(value['name'], value['type'], value.get('value'))
|
||||
|
||||
@property
|
||||
def child(self) -> bool:
|
||||
"""Is this property a child property?"""
|
||||
return self.type.startswith('child<')
|
||||
|
||||
|
||||
class ObjectPropertiesValues:
|
||||
"""
|
||||
Represents the return type from e.g. qom-list-get
|
||||
"""
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
def __init__(self, properties: List[ObjectPropertyValue]) -> None:
|
||||
self.properties = properties
|
||||
|
||||
@classmethod
|
||||
def make(cls, value: Dict[str, Any]) -> 'ObjectPropertiesValues':
|
||||
"""
|
||||
Build an ObjectPropertiesValues from a Dict with an unknown shape.
|
||||
"""
|
||||
assert value.keys() == {'properties'}
|
||||
props = [ObjectPropertyValue(item['name'],
|
||||
item['type'],
|
||||
item.get('value'))
|
||||
for item in value['properties']]
|
||||
return cls(props)
|
||||
|
||||
|
||||
CommandT = TypeVar('CommandT', bound='QOMCommand')
|
||||
|
||||
|
||||
class QOMCommand:
|
||||
"""
|
||||
Represents a QOM sub-command.
|
||||
|
||||
:param args: Parsed arguments, as returned from parser.parse_args.
|
||||
"""
|
||||
name: str
|
||||
help: str
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
if args.socket is None:
|
||||
raise QMPError("No QMP socket path or address given")
|
||||
self.qmp = QEMUMonitorProtocol(
|
||||
QEMUMonitorProtocol.parse_address(args.socket)
|
||||
)
|
||||
self.qmp.connect()
|
||||
|
||||
@classmethod
|
||||
def register(cls, subparsers: Any) -> None:
|
||||
"""
|
||||
Register this command with the argument parser.
|
||||
|
||||
:param subparsers: argparse subparsers object, from "add_subparsers".
|
||||
"""
|
||||
subparser = subparsers.add_parser(cls.name, help=cls.help,
|
||||
description=cls.help)
|
||||
cls.configure_parser(subparser)
|
||||
|
||||
@classmethod
|
||||
def configure_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||
"""
|
||||
Configure a parser with this command's arguments.
|
||||
|
||||
:param parser: argparse parser or subparser object.
|
||||
"""
|
||||
default_path = os.environ.get('QMP_SOCKET')
|
||||
parser.add_argument(
|
||||
'--socket', '-s',
|
||||
dest='socket',
|
||||
action='store',
|
||||
help='QMP socket path or address (addr:port).'
|
||||
' May also be set via QMP_SOCKET environment variable.',
|
||||
default=default_path
|
||||
)
|
||||
parser.set_defaults(cmd_class=cls)
|
||||
|
||||
@classmethod
|
||||
def add_path_prop_arg(cls, parser: argparse.ArgumentParser) -> None:
|
||||
"""
|
||||
Add the <path>.<proptery> positional argument to this command.
|
||||
|
||||
:param parser: The parser to add the argument to.
|
||||
"""
|
||||
parser.add_argument(
|
||||
'path_prop',
|
||||
metavar='<path>.<property>',
|
||||
action='store',
|
||||
help="QOM path and property, separated by a period '.'"
|
||||
)
|
||||
|
||||
def run(self) -> int:
|
||||
"""
|
||||
Run this command.
|
||||
|
||||
:return: 0 on success, 1 otherwise.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def qom_list(self, path: str) -> List[ObjectPropertyInfo]:
|
||||
"""
|
||||
:return: a strongly typed list from the 'qom-list' command.
|
||||
"""
|
||||
rsp = self.qmp.cmd('qom-list', path=path)
|
||||
# qom-list returns List[ObjectPropertyInfo]
|
||||
assert isinstance(rsp, list)
|
||||
return [ObjectPropertyInfo.make(x) for x in rsp]
|
||||
|
||||
def qom_list_get(self, paths: List[str]) -> List[ObjectPropertiesValues]:
|
||||
"""
|
||||
:return: a strongly typed list from the 'qom-list-get' command.
|
||||
"""
|
||||
rsp = self.qmp.cmd('qom-list-get', paths=paths)
|
||||
# qom-list-get returns List[ObjectPropertiesValues]
|
||||
assert isinstance(rsp, list)
|
||||
return [ObjectPropertiesValues.make(x) for x in rsp]
|
||||
|
||||
@classmethod
|
||||
def command_runner(
|
||||
cls: Type[CommandT],
|
||||
args: argparse.Namespace
|
||||
) -> int:
|
||||
"""
|
||||
Run a fully-parsed subcommand, with error-handling for the CLI.
|
||||
|
||||
:return: The return code from `run()`.
|
||||
"""
|
||||
try:
|
||||
cmd = cls(args)
|
||||
return cmd.run()
|
||||
except QMPError as err:
|
||||
print(f"{type(err).__name__}: {err!s}", file=sys.stderr)
|
||||
return -1
|
||||
|
||||
@classmethod
|
||||
def entry_point(cls) -> int:
|
||||
"""
|
||||
Build this command's parser, parse arguments, and run the command.
|
||||
|
||||
:return: `run`'s return code.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=cls.help)
|
||||
cls.configure_parser(parser)
|
||||
args = parser.parse_args()
|
||||
return cls.command_runner(args)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
QEMU Object Model FUSE filesystem tool
|
||||
|
||||
This script offers a simple FUSE filesystem within which the QOM tree
|
||||
may be browsed, queried and edited using traditional shell tooling.
|
||||
|
||||
This script requires the 'fusepy' python package.
|
||||
|
||||
|
||||
usage: qom-fuse [-h] [--socket SOCKET] <mount>
|
||||
|
||||
Mount a QOM tree as a FUSE filesystem
|
||||
|
||||
positional arguments:
|
||||
<mount> Mount point
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--socket SOCKET, -s SOCKET
|
||||
QMP socket path or address (addr:port). May also be
|
||||
set via QMP_SOCKET environment variable.
|
||||
"""
|
||||
##
|
||||
# Copyright IBM, Corp. 2012
|
||||
# Copyright (C) 2020 Red Hat, Inc.
|
||||
#
|
||||
# Authors:
|
||||
# Anthony Liguori <[email protected]>
|
||||
# Markus Armbruster <[email protected]>
|
||||
#
|
||||
# This work is licensed under the terms of the GNU GPL, version 2 or later.
|
||||
# See the COPYING file in the top-level directory.
|
||||
##
|
||||
|
||||
import argparse
|
||||
from errno import ENOENT, EPERM
|
||||
import stat
|
||||
import sys
|
||||
from typing import (
|
||||
IO,
|
||||
Dict,
|
||||
Iterator,
|
||||
Mapping,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
|
||||
import fuse
|
||||
from fuse import FUSE, FuseOSError, Operations
|
||||
from qemu.qmp import ExecuteError
|
||||
|
||||
from .qom_common import QOMCommand
|
||||
|
||||
|
||||
fuse.fuse_python_api = (0, 2)
|
||||
|
||||
|
||||
class QOMFuse(QOMCommand, Operations):
|
||||
"""
|
||||
QOMFuse implements both fuse.Operations and QOMCommand.
|
||||
|
||||
Operations implements the FS, and QOMCommand implements the CLI command.
|
||||
"""
|
||||
name = 'fuse'
|
||||
help = 'Mount a QOM tree as a FUSE filesystem'
|
||||
fuse: FUSE
|
||||
|
||||
@classmethod
|
||||
def configure_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||
super().configure_parser(parser)
|
||||
parser.add_argument(
|
||||
'mount',
|
||||
metavar='<mount>',
|
||||
action='store',
|
||||
help="Mount point",
|
||||
)
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
super().__init__(args)
|
||||
self.mount = args.mount
|
||||
self.ino_map: Dict[str, int] = {}
|
||||
self.ino_count = 1
|
||||
|
||||
def run(self) -> int:
|
||||
print(f"Mounting QOMFS to '{self.mount}'", file=sys.stderr)
|
||||
self.fuse = FUSE(self, self.mount, foreground=True)
|
||||
return 0
|
||||
|
||||
def get_ino(self, path: str) -> int:
|
||||
"""Get an inode number for a given QOM path."""
|
||||
if path in self.ino_map:
|
||||
return self.ino_map[path]
|
||||
self.ino_map[path] = self.ino_count
|
||||
self.ino_count += 1
|
||||
return self.ino_map[path]
|
||||
|
||||
def is_object(self, path: str) -> bool:
|
||||
"""Is the given QOM path an object?"""
|
||||
try:
|
||||
self.qom_list(path)
|
||||
return True
|
||||
except ExecuteError:
|
||||
return False
|
||||
|
||||
def is_property(self, path: str) -> bool:
|
||||
"""Is the given QOM path a property?"""
|
||||
path, prop = path.rsplit('/', 1)
|
||||
if path == '':
|
||||
path = '/'
|
||||
try:
|
||||
for item in self.qom_list(path):
|
||||
if item.name == prop:
|
||||
return True
|
||||
return False
|
||||
except ExecuteError:
|
||||
return False
|
||||
|
||||
def is_link(self, path: str) -> bool:
|
||||
"""Is the given QOM path a link?"""
|
||||
path, prop = path.rsplit('/', 1)
|
||||
if path == '':
|
||||
path = '/'
|
||||
try:
|
||||
for item in self.qom_list(path):
|
||||
if item.name == prop and item.link:
|
||||
return True
|
||||
return False
|
||||
except ExecuteError:
|
||||
return False
|
||||
|
||||
def read(self, path: str, size: int, offset: int, fh: IO[bytes]) -> bytes:
|
||||
if not self.is_property(path):
|
||||
raise FuseOSError(ENOENT)
|
||||
|
||||
path, prop = path.rsplit('/', 1)
|
||||
if path == '':
|
||||
path = '/'
|
||||
try:
|
||||
data = str(self.qmp.cmd('qom-get', path=path, property=prop))
|
||||
data += '\n' # make values shell friendly
|
||||
except ExecuteError as err:
|
||||
raise FuseOSError(EPERM) from err
|
||||
|
||||
if offset > len(data):
|
||||
return b''
|
||||
|
||||
return bytes(data[offset:][:size], encoding='utf-8')
|
||||
|
||||
def readlink(self, path: str) -> Union[bool, str]:
|
||||
if not self.is_link(path):
|
||||
return False
|
||||
path, prop = path.rsplit('/', 1)
|
||||
prefix = '/'.join(['..'] * (len(path.split('/')) - 1))
|
||||
return prefix + str(self.qmp.cmd('qom-get', path=path,
|
||||
property=prop))
|
||||
|
||||
def getattr(self, path: str,
|
||||
fh: Optional[IO[bytes]] = None) -> Mapping[str, object]:
|
||||
if self.is_link(path):
|
||||
value = {
|
||||
'st_mode': 0o755 | stat.S_IFLNK,
|
||||
'st_ino': self.get_ino(path),
|
||||
'st_dev': 0,
|
||||
'st_nlink': 2,
|
||||
'st_uid': 1000,
|
||||
'st_gid': 1000,
|
||||
'st_size': 4096,
|
||||
'st_atime': 0,
|
||||
'st_mtime': 0,
|
||||
'st_ctime': 0
|
||||
}
|
||||
elif self.is_object(path):
|
||||
value = {
|
||||
'st_mode': 0o755 | stat.S_IFDIR,
|
||||
'st_ino': self.get_ino(path),
|
||||
'st_dev': 0,
|
||||
'st_nlink': 2,
|
||||
'st_uid': 1000,
|
||||
'st_gid': 1000,
|
||||
'st_size': 4096,
|
||||
'st_atime': 0,
|
||||
'st_mtime': 0,
|
||||
'st_ctime': 0
|
||||
}
|
||||
elif self.is_property(path):
|
||||
value = {
|
||||
'st_mode': 0o644 | stat.S_IFREG,
|
||||
'st_ino': self.get_ino(path),
|
||||
'st_dev': 0,
|
||||
'st_nlink': 1,
|
||||
'st_uid': 1000,
|
||||
'st_gid': 1000,
|
||||
'st_size': 4096,
|
||||
'st_atime': 0,
|
||||
'st_mtime': 0,
|
||||
'st_ctime': 0
|
||||
}
|
||||
else:
|
||||
raise FuseOSError(ENOENT)
|
||||
return value
|
||||
|
||||
def readdir(self, path: str, fh: IO[bytes]) -> Iterator[str]:
|
||||
yield '.'
|
||||
yield '..'
|
||||
for item in self.qom_list(path):
|
||||
yield item.name
|
||||
Reference in new issue
Block a user