drgn/vmtest/vm.py
Omar Sandoval 87b7292aa5 Relicense drgn from GPLv3+ to LGPLv2.1+
drgn is currently licensed as GPLv3+. Part of the long term vision for
drgn is that other projects can use it as a library providing
programmatic interfaces for debugger functionality. A more permissive
license is better suited to this goal. We decided on LGPLv2.1+ as a good
balance between software freedom and permissiveness.

All contributors not employed by Meta were contacted via email and
consented to the license change. The only exception was the author of
commit c4fbf7e589 ("libdrgn: fix for compilation error"), who did not
respond. That commit reverted a single line of code to one originally
written by me in commit 640b1c011d ("libdrgn: embed DWARF index in
DWARF info cache").

Signed-off-by: Omar Sandoval <osandov@osandov.com>
2022-11-01 17:05:16 -07:00

365 lines
11 KiB
Python

# Copyright (c) Meta Platforms, Inc. and affiliates.
# SPDX-License-Identifier: LGPL-2.1-or-later
import os
from pathlib import Path
import re
import shlex
import socket
import subprocess
import sys
import tempfile
from util import nproc, out_of_date
# Script run as init in the virtual machine.
_INIT_TEMPLATE = r"""#!/bin/sh
# Having /proc from the host visible in the guest can confuse some commands. In
# particular, if BusyBox is configured with FEATURE_SH_STANDALONE, then busybox
# sh executes BusyBox applets using /proc/self/exe. So, before doing anything
# else, mount /proc (using the fully qualified executable path so that BusyBox
# doesn't use /proc/self/exe).
/bin/mount -t proc -o nosuid,nodev,noexec proc /proc
set -eu
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
{kdump_needs_nosmp}
trap 'poweroff -f' EXIT
umask 022
HOSTNAME=vmtest
VPORT_NAME=com.osandov.vmtest.0
RELEASE=$(uname -r)
# Set up overlayfs.
if [ ! -w /tmp ]; then
mount -t tmpfs tmpfs /tmp
fi
mkdir /tmp/upper /tmp/work /tmp/merged
mkdir /tmp/upper/dev /tmp/upper/etc /tmp/upper/mnt
mkdir -m 555 /tmp/upper/proc /tmp/upper/sys
mkdir -m 1777 /tmp/upper/tmp
if [ -e /tmp/host ]; then
mkdir /tmp/host_upper /tmp/host_work /tmp/upper/host
fi
mount -t overlay -o lowerdir=/,upperdir=/tmp/upper,workdir=/tmp/work overlay /tmp/merged
if [ -e /tmp/host ]; then
mount -t overlay -o lowerdir=/tmp/host,upperdir=/tmp/host_upper,workdir=/tmp/host_work overlay /tmp/merged/host
fi
# Mount core filesystems.
mount -t devtmpfs -o nosuid,noexec dev /tmp/merged/dev
mkdir /tmp/merged/dev/shm
mount -t tmpfs -o nosuid,nodev tmpfs /tmp/merged/dev/shm
mount -t proc -o nosuid,nodev,noexec proc /tmp/merged/proc
mount -t sysfs -o nosuid,nodev,noexec sys /tmp/merged/sys
# cgroup2 was added in Linux v4.5.
mount -t cgroup2 -o nosuid,nodev,noexec cgroup2 /tmp/merged/sys/fs/cgroup || true
# Ideally we'd just be able to create an opaque directory for /tmp on the upper
# layer. However, before Linux kernel commit 51f7e52dc943 ("ovl: share inode
# for hard link") (in v4.8), overlayfs doesn't handle hard links correctly,
# which breaks some tests.
mount -t tmpfs -o nosuid,nodev tmpfs /tmp/merged/tmp
# Pivot into the new root.
pivot_root /tmp/merged /tmp/merged/mnt
cd /
umount -l /mnt
# Load kernel modules.
mkdir -p "/lib/modules/$RELEASE"
mount --bind {kernel_dir} "/lib/modules/$RELEASE"
for module in configs rng_core virtio_rng; do
modprobe "$module"
done
# Create static device nodes.
grep -v '^#' "/lib/modules/$RELEASE/modules.devname" |
while read -r module name node; do
name="/dev/$name"
dev=${{node#?}}
major=${{dev%%:*}}
minor=${{dev##*:}}
type=${{node%"${{dev}}"}}
mkdir -p "$(dirname "$name")"
mknod "$name" "$type" "$major" "$minor"
done
ln -s /proc/self/fd /dev/fd
ln -s /proc/self/fd/0 /dev/stdin
ln -s /proc/self/fd/1 /dev/stdout
ln -s /proc/self/fd/2 /dev/stderr
# Configure networking.
cat << EOF > /etc/hosts
127.0.0.1 localhost
::1 localhost
127.0.1.1 $HOSTNAME.localdomain $HOSTNAME
EOF
: > /etc/resolv.conf
hostname "$HOSTNAME"
ip link set lo up
# Find virtio port.
vport=
for vport_dir in /sys/class/virtio-ports/*; do
if [ -r "$vport_dir/name" -a "$(cat "$vport_dir/name")" = "$VPORT_NAME" ]; then
vport="${{vport_dir#/sys/class/virtio-ports/}}"
break
fi
done
if [ -z "$vport" ]; then
echo "could not find virtio-port \"$VPORT_NAME\""
exit 1
fi
cd {cwd}
set +e
sh -c {command}
rc=$?
set -e
echo "Exited with status $rc"
echo "$rc" > "/dev/$vport"
"""
def _compile(
*args: str,
CPPFLAGS: str = "",
CFLAGS: str = "",
LDFLAGS: str = "",
LIBADD: str = "",
) -> None:
# This mimics automake: the order of the arguments allows for the default
# flags to be overridden by environment variables, and we use the same
# default CFLAGS.
cmd = [
os.getenv("CC", "cc"),
*shlex.split(CPPFLAGS),
*shlex.split(os.getenv("CPPFLAGS", "")),
*shlex.split(CFLAGS),
*shlex.split(os.getenv("CFLAGS", "-g -O2")),
*shlex.split(LDFLAGS),
*shlex.split(os.getenv("LDFLAGS", "")),
*args,
*shlex.split(LIBADD),
*shlex.split(os.getenv("LIBS", "")),
]
print(" ".join([shlex.quote(arg) for arg in cmd]))
subprocess.check_call(cmd)
def _build_onoatimehack(dir: Path) -> Path:
dir.mkdir(parents=True, exist_ok=True)
onoatimehack_so = dir / "onoatimehack.so"
onoatimehack_c = (Path(__file__).parent / "onoatimehack.c").relative_to(Path.cwd())
if out_of_date(onoatimehack_so, onoatimehack_c):
_compile(
"-o",
str(onoatimehack_so),
str(onoatimehack_c),
CPPFLAGS="-D_GNU_SOURCE",
CFLAGS="-fPIC",
LDFLAGS="-shared",
LIBADD="-ldl",
)
return onoatimehack_so
class LostVMError(Exception):
pass
def run_in_vm(command: str, root_dir: Path, kernel_dir: Path, build_dir: Path) -> int:
match = re.search(
r"QEMU emulator version ([0-9]+(?:\.[0-9]+)*)",
subprocess.check_output(
["qemu-system-x86_64", "-version"], universal_newlines=True
),
)
if not match:
raise Exception("could not determine QEMU version")
qemu_version = tuple(int(x) for x in match.group(1).split("."))
# QEMU's 9pfs O_NOATIME handling was fixed in 5.1.0. The fix was backported
# to 5.0.1.
env = os.environ.copy()
if qemu_version < (5, 0, 1):
onoatimehack_so = _build_onoatimehack(build_dir)
env["LD_PRELOAD"] = f"{str(onoatimehack_so)}:{env.get('LD_PRELOAD', '')}"
if os.access("/dev/kvm", os.R_OK | os.W_OK):
kvm_args = ["-cpu", "host", "-enable-kvm"]
else:
print(
"warning: /dev/kvm cannot be accessed; falling back to emulation",
file=sys.stderr,
)
kvm_args = []
virtfs_options = "security_model=none,readonly=on"
# multidevs was added in QEMU 4.2.0.
if qemu_version >= (4, 2):
virtfs_options += ",multidevs=remap"
_9pfs_mount_options = f"trans=virtio,cache=loose,msize={1024 * 1024}"
with tempfile.TemporaryDirectory(prefix="drgn-vmtest-") as temp_dir, socket.socket(
socket.AF_UNIX
) as server_sock:
temp_path = Path(temp_dir)
socket_path = temp_path / "socket"
server_sock.bind(str(socket_path))
server_sock.listen()
init_path = temp_path / "init"
if root_dir == Path("/"):
host_virtfs_args = []
init = str(init_path.resolve())
host_dir_prefix = ""
else:
host_virtfs_args = [
"-virtfs",
f"local,path=/,mount_tag=host,{virtfs_options}",
]
init = f'/bin/sh -- -c "/bin/mount -t tmpfs tmpfs /tmp && /bin/mkdir /tmp/host && /bin/mount -t 9p -o {_9pfs_mount_options},ro host /tmp/host && . /tmp/host{init_path.resolve()}"'
host_dir_prefix = "/host"
with init_path.open("w") as init_file:
init_file.write(
_INIT_TEMPLATE.format(
cwd=shlex.quote(host_dir_prefix + os.getcwd()),
kernel_dir=shlex.quote(host_dir_prefix + str(kernel_dir.resolve())),
command=shlex.quote(command),
kdump_needs_nosmp="" if kvm_args else "export KDUMP_NEEDS_NOSMP=1",
)
)
init_path.chmod(0o755)
with subprocess.Popen(
[
# fmt: off
"qemu-system-x86_64", *kvm_args,
"-smp", str(nproc()), "-m", "2G",
"-nodefaults", "-display", "none", "-serial", "mon:stdio",
# This along with -append panic=-1 ensures that we exit on a
# panic instead of hanging.
"-no-reboot",
"-virtfs",
f"local,id=root,path={root_dir},mount_tag=/dev/root,{virtfs_options}",
*host_virtfs_args,
"-device", "virtio-rng-pci",
"-device", "virtio-serial",
"-chardev", f"socket,id=vmtest,path={socket_path}",
"-device",
"virtserialport,chardev=vmtest,name=com.osandov.vmtest.0",
"-kernel", str(kernel_dir / "vmlinuz"),
"-append",
f"rootfstype=9p rootflags={_9pfs_mount_options} ro console=0,115200 panic=-1 crashkernel=256M init={init}",
# fmt: on
],
env=env,
):
server_sock.settimeout(5)
try:
sock = server_sock.accept()[0]
except socket.timeout:
raise LostVMError(
f"QEMU did not connect within {server_sock.gettimeout()} seconds"
)
try:
status_buf = bytearray()
while True:
try:
buf = sock.recv(4)
except ConnectionResetError:
buf = b""
if not buf:
break
status_buf.extend(buf)
finally:
sock.close()
if not status_buf:
raise LostVMError("VM did not return status")
if status_buf[-1] != ord("\n") or not status_buf[:-1].isdigit():
raise LostVMError(f"VM returned invalid status: {repr(status_buf)[11:-1]}")
return int(status_buf)
if __name__ == "__main__":
import argparse
import logging
logging.basicConfig(
format="%(asctime)s:%(levelname)s:%(name)s:%(message)s", level=logging.INFO
)
parser = argparse.ArgumentParser(
description="run vmtest virtual machine",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"-d",
"--directory",
metavar="DIR",
type=Path,
default="build/vmtest",
help="directory for build artifacts and downloaded kernels",
)
parser.add_argument(
"--lost-status",
metavar="STATUS",
type=int,
default=128,
help="exit status if VM is lost",
)
parser.add_argument(
"-k",
"--kernel",
default=argparse.SUPPRESS,
help="kernel to use (default: latest available kernel)",
)
parser.add_argument(
"-r",
"--root-directory",
metavar="DIR",
default=Path("/"),
type=Path,
help="directory to use as root directory in VM",
)
parser.add_argument(
"command",
type=str,
nargs=argparse.REMAINDER,
help="command to run in VM (default: sh -i)",
)
args = parser.parse_args()
kernel = getattr(args, "kernel", "*")
if kernel.startswith(".") or kernel.startswith("/"):
kernel_dir = Path(kernel)
else:
from vmtest.download import download_kernels
kernel_dir = next(download_kernels(args.directory, "x86_64", (kernel,)))
try:
command = " ".join(args.command) if args.command else "sh -i"
sys.exit(run_in_vm(command, args.root_directory, kernel_dir, args.directory))
except LostVMError as e:
print("error:", e, file=sys.stderr)
sys.exit(args.lost_status)