Initial commit

This commit is contained in:
AlexSSD7 2023-08-25 15:12:19 +01:00
commit b905244626
17 changed files with 1462 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
vldisk
*.qcow2

View file

@ -0,0 +1,630 @@
#!/bin/sh
# vim: set ts=4 sw=4:
# SPDX-FileCopyrightText: © 2017 Jakub Jirutka <jakub@jirutka.cz>
# SPDX-License-Identifier: MIT
#---help---
# Usage: alpine-make-vm-image [options] [--] <image> [<script> [<script-opts...>]]
#
# This script creates a bootable Alpine Linux disk image for virtual machines.
# If running on Alpine system (detected by file /etc/alpine-release), then it
# also installs needed packages on the host system. On other systems you must
# install them yourself: qemu-img, qemu-nbd, rsync, sfdisk, and mkfs utility
# for the chosen ROOTFS. If $APK is not available on the host system, then
# static apk-tools specified by $APK_TOOLS_URI is downloaded and used.
#
# Note that it does not create any partitions by default (it's really not
# needed), filesystem is created directly on the image. If you must use some
# platform that foolishly requires partitions in images, use --partition.
#
# Arguments:
# <image> Path of disk image to use or create if not exists.
#
# <script> Path of script to execute after installing base system in
# the mounted image and before umounting it.
#
# <script-opts> Arguments to pass to the script.
#
# Options and Environment Variables:
# -b --branch ALPINE_BRANCH Alpine branch to install; used only when
# --repositories-file is not specified. Default is
# latest-stable.
#
# -S --fs-skel-dir FS_SKEL_DIR Path of directory which content to recursively copy
# (using rsync) into / in the image.
#
# --fs-skel-chown FS_SKEL_CHOWN Force all files from FS_SKEL_DIR to be owned by the
# specified USER:GROUP.
#
# -P --partition (PARTITION) Create GUID Partition Table (GPT) with a single partition.
#
# -f --image-format IMAGE_FORMAT Format of the disk image (see qemu-img --help).
#
# -s --image-size IMAGE_SIZE Size of the disk image to create in bytes or with suffix
# (e.g. 1G, 1024M). Default is 2G.
#
# -i --initfs-features INITFS_FEATURES List of additional mkinitfs features (basically modules)
# to be included in initramfs (see mkinitfs -L). "base" and
# $ROOTFS is always included, don't specify them here.
# Default is "scsi virtio".
#
# -k --kernel-flavor KERNEL_FLAVOR The kernel flavour to install; virt (default), lts
# (Alpine >=3.11), or vanilla (Alpine <3.11).
#
# --keys-dir KEYS_DIR Path of directory with Alpine keys to copy into the image.
# Default is /etc/apk/keys. If does not exist, keys for
# x86_64 embedded in this script will be used.
#
# -m --mirror-uri ALPINE_MIRROR URI of the Aports mirror to fetch packages; used only
# when --repositories-file is not specified. Default is
# http://dl-cdn.alpinelinux.org/alpine.
#
# -C --no-cleanup (CLEANUP) Don't umount and disconnect image when done.
#
# -p --packages PACKAGES Additional packages to install into the image.
#
# -r --repositories-file REPOS_FILE Path of the repositories file to copy into the rootfs.
# If not provided, Alpine's main and community repositories
# on --mirror-uri will be used.
#
# --rootfs ROOTFS Filesystem to create on the image. Default is ext4.
#
# -c --script-chroot (SCRIPT_CHROOT) Bind <script>'s directory at /mnt inside image and chroot
# into the image before executing <script>.
#
# -t --serial-console (SERIAL_CONSOLE) Add configuration for a serial console on ttyS0.
#
# -h --help Show this help message and exit.
#
# -v --version Print version and exit.
#
# APK APK command to use. Default is "apk".
#
# APK_OPTS Options to pass into apk on each execution.
# Default is "--no-progress".
#
# APK_TOOLS_URI URL of apk-tools binary to download if $APK is not found
# on the host system. Default is x86_64 apk.static from
# https://gitlab.alpinelinux.org/alpine/apk-tools/-/packages.
#
# APK_TOOLS_SHA256 SHA-256 checksum of $APK_TOOLS_URI.
#
# Each option can be also provided by environment variable. If both option and
# variable is specified and the option accepts only one argument, then the
# option takes precedence.
#
# https://github.com/alpinelinux/alpine-make-vm-image
#---help---
set -eu
# Some distros (e.g. Arch Linux) does not have /bin or /sbin in PATH.
PATH="$PATH:/usr/sbin:/usr/bin:/sbin:/bin"
readonly PROGNAME='alpine-make-vm-image'
readonly VERSION='0.11.1'
readonly VIRTUAL_PKG=".make-$PROGNAME"
# An opaque string used to detect changes in resolv.conf.
readonly RESOLVCONF_MARK="### created by $PROGNAME ###"
# Alpine APK keys for verification of packages for x86_64.
readonly ALPINE_KEYS='
alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1yHJxQgsHQREclQu4Ohe\nqxTxd1tHcNnvnQTu/UrTky8wWvgXT+jpveroeWWnzmsYlDI93eLI2ORakxb3gA2O\nQ0Ry4ws8vhaxLQGC74uQR5+/yYrLuTKydFzuPaS1dK19qJPXB8GMdmFOijnXX4SA\njixuHLe1WW7kZVtjL7nufvpXkWBGjsfrvskdNA/5MfxAeBbqPgaq0QMEfxMAn6/R\nL5kNepi/Vr4S39Xvf2DzWkTLEK8pcnjNkt9/aafhWqFVW7m3HCAII6h/qlQNQKSo\nGuH34Q8GsFG30izUENV9avY7hSLq7nggsvknlNBZtFUcmGoQrtx3FmyYsIC8/R+B\nywIDAQAB
alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwlzMkl7b5PBdfMzGdCT0\ncGloRr5xGgVmsdq5EtJvFkFAiN8Ac9MCFy/vAFmS8/7ZaGOXoCDWbYVLTLOO2qtX\nyHRl+7fJVh2N6qrDDFPmdgCi8NaE+3rITWXGrrQ1spJ0B6HIzTDNEjRKnD4xyg4j\ng01FMcJTU6E+V2JBY45CKN9dWr1JDM/nei/Pf0byBJlMp/mSSfjodykmz4Oe13xB\nCa1WTwgFykKYthoLGYrmo+LKIGpMoeEbY1kuUe04UiDe47l6Oggwnl+8XD1MeRWY\nsWgj8sF4dTcSfCMavK4zHRFFQbGp/YFJ/Ww6U9lA3Vq0wyEI6MCMQnoSMFwrbgZw\nwwIDAQAB
alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub:MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAutQkua2CAig4VFSJ7v54\nALyu/J1WB3oni7qwCZD3veURw7HxpNAj9hR+S5N/pNeZgubQvJWyaPuQDm7PTs1+\ntFGiYNfAsiibX6Rv0wci3M+z2XEVAeR9Vzg6v4qoofDyoTbovn2LztaNEjTkB+oK\ntlvpNhg1zhou0jDVYFniEXvzjckxswHVb8cT0OMTKHALyLPrPOJzVtM9C1ew2Nnc\n3848xLiApMu3NBk0JqfcS3Bo5Y2b1FRVBvdt+2gFoKZix1MnZdAEZ8xQzL/a0YS5\nHd0wj5+EEKHfOd3A75uPa/WQmA+o0cBFfrzm69QDcSJSwGpzWrD1ScH3AK8nWvoj\nv7e9gukK/9yl1b4fQQ00vttwJPSgm9EnfPHLAtgXkRloI27H6/PuLoNvSAMQwuCD\nhQRlyGLPBETKkHeodfLoULjhDi1K2gKJTMhtbnUcAA7nEphkMhPWkBpgFdrH+5z4\nLxy+3ek0cqcI7K68EtrffU8jtUj9LFTUC8dERaIBs7NgQ/LfDbDfGh9g6qVj1hZl\nk9aaIPTm/xsi8v3u+0qaq7KzIBc9s59JOoA8TlpOaYdVgSQhHHLBaahOuAigH+VI\nisbC9vmqsThF2QdDtQt37keuqoda2E6sL7PUvIyVXDRfwX7uMDjlzTxHTymvq2Ck\nhtBqojBnThmjJQFgZXocHG8CAwEAAQ==
'
: ${APK_TOOLS_URI:="https://gitlab.alpinelinux.org/api/v4/projects/5/packages/generic/v2.12.10/x86_64/apk.static"}
: ${APK_TOOLS_SHA256:="d7506bb11327b337960910daffed75aa289d8bb350feab624c52965be82ceae8"}
: ${APK:="apk"}
: ${APK_OPTS:="--no-progress"}
# For compatibility with systems that does not have "realpath" command.
if ! command -v realpath >/dev/null; then
alias realpath='readlink -f'
fi
die() {
printf '\033[1;31mERROR:\033[0m %s\n' "$@" >&2 # bold red
exit 1
}
einfo() {
printf '\n\033[1;36m> %s\033[0m\n' "$@" >&2 # bold cyan
}
# Prints help and exists with the specified status.
help() {
sed -En '/^#---help---/,/^#---help---/p' "$0" | sed -E 's/^# ?//; 1d;$d;'
exit ${1:-0}
}
# Cleans the host system. This function is executed before exiting the script.
cleanup() {
set +eu
trap '' EXIT HUP INT TERM # unset trap to avoid loop
cd /
if [ -d "$temp_dir" ]; then
rm -Rf "$temp_dir"
fi
if [ "$mount_dir" ]; then
umount_recursively "$mount_dir" \
|| die "Failed to unmount $mount_dir; unmount it and disconnect $nbd_dev manually"
rm -Rf "$mount_dir"
fi
if [ "$nbd_dev" ]; then
qemu-nbd --disconnect "$nbd_dev" \
|| die "Failed to disconnect $nbd_dev; disconnect it manually"
fi
if [ "$INSTALL_HOST_PKGS" = yes ]; then
_apk del $VIRTUAL_PKG
fi
}
_apk() {
"$APK" $APK_OPTS "$@"
}
# Attaches the specified image as a NBD block device and prints its path.
attach_image() {
local image="$1"
local format="${2:-}"
local nbd_dev
nbd_dev=$(get_available_nbd) || {
modprobe nbd max_part=16
sleep 1
nbd_dev=$(get_available_nbd)
} || die 'No available nbd device found!'
qemu-nbd --connect="$nbd_dev" --cache=writeback \
${format:+--format=$format} "$image" \
&& echo "$nbd_dev"
}
# Prints UUID of filesystem on the specified block device.
blk_uuid() {
local dev="$1"
blkid "$dev" | sed -En 's/.*UUID="([^"]+)".*/\1/p'
}
# Writes Alpine APK keys embedded in this script into directory $1.
dump_alpine_keys() {
local dest_dir="$1"
local content file line
mkdir -p "$dest_dir"
for line in $ALPINE_KEYS; do
file=${line%%:*}
content=${line#*:}
printf -- "-----BEGIN PUBLIC KEY-----\n$content\n-----END PUBLIC KEY-----\n" \
> "$dest_dir/$file"
done
}
# Prints path of available nbdX device, or returns 1 if not any.
get_available_nbd() {
local dev; for dev in $(find /dev -maxdepth 2 -name 'nbd[0-9]*'); do
if [ "$(blockdev --getsize64 "$dev")" -eq 0 ]; then
echo "$dev"; return 0
fi
done
return 1
}
# Prints name of the package needed for creating the specified filesystem.
fs_progs_pkg() {
local fs="$1" # filesystem name
case "$fs" in
ext4) echo 'e2fsprogs';;
btrfs) echo 'btrfs-progs';;
xfs) echo 'xfsprogs';;
esac
}
# Tests if the specified command exists on the system.
has_cmd() {
command -v "$1" >/dev/null
}
# Binds the directory $1 at the mountpoint $2 and sets propagation to private.
mount_bind() {
mkdir -p "$2"
mount --bind "$1" "$2"
mount --make-private "$2"
}
# Prepares chroot at the specified path.
prepare_chroot() {
local dest="$1"
mkdir -p "$dest"/proc
mount -t proc none "$dest"/proc
mount_bind /dev "$dest"/dev
mount_bind /sys "$dest"/sys
install -D -m 644 /etc/resolv.conf "$dest"/etc/resolv.conf
echo "$RESOLVCONF_MARK" >> "$dest"/etc/resolv.conf
}
# Adds specified services to the runlevel. Current working directory must be
# root of the image.
rc_add() {
local runlevel="$1"; shift # runlevel name
local services="$*" # names of services
local svc; for svc in $services; do
mkdir -p etc/runlevels/$runlevel
ln -s /etc/init.d/$svc etc/runlevels/$runlevel/$svc
echo " * service $svc added to runlevel $runlevel"
done
}
# Ensures that the specified device node exists.
settle_dev_node() {
local dev="$1"
[ -e "$dev" ] && return 0
sleep 1 # give hotplug handler some time to kick in
[ -e "$dev" ] && return 0
if has_cmd udevadm; then
udevadm settle --exit-if-exists="$dev"
elif has_cmd mdev; then
mdev -s
fi
[ -e "$dev" ] && return 0
return 1
}
# Installs and configures extlinux.
setup_extlinux() {
local mnt="$1" # path of directory where is root device currently mounted
local root_dev="$2" # root device
local modules="$3" # modules which should be loaded before pivot_root
local kernel_flavor="$4" # name of default kernel to boot
local serial_port="$5" # serial port number for serial console
local default_kernel="$kernel_flavor"
local kernel_opts=''
[ -z "$serial_port" ] || kernel_opts="console=$serial_port"
if [ "$kernel_flavor" = 'virt' ]; then
_apk search --root . --exact --quiet linux-lts | grep -q . \
&& default_kernel='lts' \
|| default_kernel='vanilla'
fi
sed -Ei \
-e "s|^[# ]*(root)=.*|\1=$root_dev|" \
-e "s|^[# ]*(default_kernel_opts)=.*|\1=\"$kernel_opts\"|" \
-e "s|^[# ]*(modules)=.*|\1=\"$modules\"|" \
-e "s|^[# ]*(default)=.*|\1=$default_kernel|" \
-e "s|^[# ]*(serial_port)=.*|\1=$serial_port|" \
"$mnt"/etc/update-extlinux.conf
chroot "$mnt" extlinux --install /boot
chroot "$mnt" update-extlinux --warn-only 2>&1 \
| { grep -Fv 'extlinux: cannot open device /dev' ||:; } >&2
}
# Configures mkinitfs.
setup_mkinitfs() {
local mnt="$1" # path of directory where is root device currently mounted
local features="$2" # list of mkinitfs features
features=$(printf '%s\n' $features | sort | uniq | xargs)
sed -Ei "s|^[# ]*(features)=.*|\1=\"$features\"|" \
"$mnt"/etc/mkinitfs/mkinitfs.conf
}
# Unmounts all filesystem under the specified directory tree.
umount_recursively() {
local mount_point="$1"
test -n "$mount_point" || return 1
cat /proc/mounts \
| cut -d ' ' -f 2 \
| grep "^$mount_point" \
| sort -r \
| xargs umount -rn
}
# Downloads the specified file using wget and checks checksum.
wgets() (
local url="$1"
local sha256="$2"
local dest="${3:-.}"
cd "$dest" \
&& wget -T 10 --no-verbose "$url" \
&& echo "$sha256 ${url##*/}" | sha256sum -c
)
#============================= M a i n ==============================#
opts=$(getopt -n $PROGNAME -o b:cCf:hi:k:m:p:Pr:s:S:tv \
-l branch:,fs-skel-chown:,fs-skel-dir:,image-format:,image-size:,initfs-features:,kernel-flavor:,keys-dir:,mirror-uri:,no-cleanup,packages:,partition,repositories-file:,rootfs:,script-chroot,serial-console,help,version \
-- "$@") || help 1 >&2
eval set -- "$opts"
while [ $# -gt 0 ]; do
n=2
case "$1" in
-b | --branch) ALPINE_BRANCH="$2";;
-S | --fs-skel-dir) FS_SKEL_DIR="$(realpath "$2")";;
--fs-skel-chown) FS_SKEL_CHOWN="$2";;
-f | --image-format) IMAGE_FORMAT="$2";;
-s | --image-size) IMAGE_SIZE="$2";;
-i | --initfs-features) INITFS_FEATURES="${INITFS_FEATURES:-} $2";;
-k | --kernel-flavor) KERNEL_FLAVOR="$2";;
--keys-dir) KEYS_DIR="$(realpath "$2")";;
-m | --mirror-uri) ALPINE_MIRROR="$2";;
-C | --no-cleanup) CLEANUP='no'; n=1;;
-p | --packages) PACKAGES="${PACKAGES:-} $2";;
-P | --partition) PARTITION='yes'; n=1;;
-r | --repositories-file) REPOS_FILE="$(realpath "$2")";;
--rootfs) ROOTFS="$2";;
-t | --serial-console) SERIAL_CONSOLE='yes'; n=1;;
-c | --script-chroot) SCRIPT_CHROOT='yes'; n=1;;
-h | --help) help 0;;
-V | --version) echo "$PROGNAME $VERSION"; exit 0;;
--) shift; break;;
esac
shift $n
done
: ${ALPINE_BRANCH:="latest-stable"}
: ${ALPINE_MIRROR:="http://dl-cdn.alpinelinux.org/alpine"}
: ${CLEANUP:="yes"}
: ${FS_SKEL_CHOWN:=}
: ${FS_SKEL_DIR:=}
: ${IMAGE_FORMAT:=}
: ${IMAGE_SIZE:="2G"}
: ${INITFS_FEATURES:="scsi virtio"}
: ${KERNEL_FLAVOR:="virt"}
: ${KEYS_DIR:="/etc/apk/keys"}
: ${PACKAGES:=}
: ${PARTITION:="no"}
: ${REPOS_FILE:=}
: ${ROOTFS:="ext4"}
: ${SCRIPT_CHROOT:="no"}
: ${SERIAL_CONSOLE:="no"}
case "$ALPINE_BRANCH" in
[0-9]*) ALPINE_BRANCH="v$ALPINE_BRANCH";;
esac
if [ -f /etc/alpine-release ]; then
: ${INSTALL_HOST_PKGS:="yes"}
else
: ${INSTALL_HOST_PKGS:="no"}
fi
SERIAL_PORT=
[ "$SERIAL_CONSOLE" = 'no' ] || SERIAL_PORT='ttyS0'
[ $# -ne 0 ] || help 1 >&2
IMAGE_FILE="$1"; shift
SCRIPT=
[ $# -eq 0 ] || { SCRIPT=$(realpath "$1"); shift; }
[ "$CLEANUP" = no ] || trap cleanup EXIT HUP INT TERM
#-----------------------------------------------------------------------
if [ "$INSTALL_HOST_PKGS" = yes ]; then
einfo 'Installing needed packages on host system'
# We need load btrfs module to avoid the error message:
# 'failed to open /dev/btrfs-control'
if ! grep -q -w "$ROOTFS" /proc/filesystems; then
modprobe $ROOTFS
fi
_apk add -t $VIRTUAL_PKG qemu-img $(fs_progs_pkg "$ROOTFS") rsync sfdisk
fi
#-----------------------------------------------------------------------
temp_dir=''
if ! command -v "$APK" >/dev/null; then
einfo "$APK not found, downloading static apk-tools"
temp_dir="$(mktemp -d /tmp/$PROGNAME.XXXXXX)"
wgets "$APK_TOOLS_URI" "$APK_TOOLS_SHA256" "$temp_dir"
APK="$temp_dir/apk.static"
chmod +x "$APK"
fi
#-----------------------------------------------------------------------
if [ ! -f "$IMAGE_FILE" ]; then
einfo "Creating $IMAGE_FORMAT image of size $IMAGE_SIZE"
qemu-img create ${IMAGE_FORMAT:+-f $IMAGE_FORMAT} "$IMAGE_FILE" "$IMAGE_SIZE"
fi
#-----------------------------------------------------------------------
einfo "Attaching image $IMAGE_FILE as a NBD device"
nbd_dev=$(attach_image "$IMAGE_FILE" "$IMAGE_FORMAT")
#-----------------------------------------------------------------------
if [ "$PARTITION" = yes ]; then
einfo 'Creating GPT partition table'
printf '%s\n' \
'label: gpt' \
'name=system,type=L,bootable,attrs=LegacyBIOSBootable' \
| sfdisk "$nbd_dev"
root_dev="${nbd_dev}p1"
# This is needed when running in a container.
settle_dev_node "$root_dev" || die "system didn't create $root_dev node"
else
root_dev="$nbd_dev"
fi
#-----------------------------------------------------------------------
einfo "Formatting image to $ROOTFS"
# syslinux 6.0.3 cannot boot from ext4 w/ 64bit feature enabled.
# -E nodiscard / -K - do not attempt to discard blocks at mkfs time (it's
# useless for NBD image and prints confusing error).
[ "$ROOTFS" = ext4 ] && mkfs_args='-O ^64bit -E nodiscard' || mkfs_args='-K'
mkfs.$ROOTFS -L root $mkfs_args "$root_dev"
root_uuid=$(blk_uuid "$root_dev")
mount_dir=$(mktemp -d /tmp/$PROGNAME.XXXXXX)
#-----------------------------------------------------------------------
einfo "Mounting image at $mount_dir"
mount "$root_dev" "$mount_dir"
#-----------------------------------------------------------------------
einfo 'Installing base system'
cd "$mount_dir"
mkdir -p etc/apk/keys
if [ "$REPOS_FILE" ]; then
install -m 644 "$REPOS_FILE" etc/apk/repositories
else
cat > etc/apk/repositories <<-EOF
$ALPINE_MIRROR/$ALPINE_BRANCH/main
$ALPINE_MIRROR/$ALPINE_BRANCH/community
EOF
fi
if [ -d "$KEYS_DIR" ]; then
cp "$KEYS_DIR"/* etc/apk/keys/
else
dump_alpine_keys etc/apk/keys/
fi
# Use APK cache if available.
if [ -L /etc/apk/cache ]; then
ln -s "$(realpath /etc/apk/cache)" etc/apk/cache
fi
_apk add --root . --update-cache --initdb alpine-base
prepare_chroot .
#-----------------------------------------------------------------------
einfo "Installing and configuring mkinitfs"
_apk add --root . mkinitfs
setup_mkinitfs . "base $ROOTFS $INITFS_FEATURES"
#-----------------------------------------------------------------------
einfo "Installing kernel linux-$KERNEL_FLAVOR"
if [ "$KERNEL_FLAVOR" = 'virt' ]; then
_apk add --root . linux-$KERNEL_FLAVOR
else
# Avoid installing *all* linux-firmware-* packages (see #21).
_apk add --root . linux-$KERNEL_FLAVOR linux-firmware-none
fi
#-----------------------------------------------------------------------
einfo 'Setting up extlinux bootloader'
_apk add --root . --no-scripts syslinux
setup_extlinux . "UUID=$root_uuid" "$ROOTFS" "$KERNEL_FLAVOR" "$SERIAL_PORT"
if [ "$PARTITION" = yes ]; then
dd bs=440 count=1 conv=notrunc if=usr/share/syslinux/gptmbr.bin of="$nbd_dev"
sync
fi
cat > etc/fstab <<-EOF
# <fs> <mountpoint> <type> <opts> <dump/pass>
UUID=$root_uuid / $ROOTFS noatime 0 1
EOF
#-----------------------------------------------------------------------
einfo 'Configuring system'
# We just prepare this config, but don't start the networking service.
cat > etc/network/interfaces <<-EOF
iface lo inet loopback
iface eth0 inet dhcp
post-up /etc/network/if-post-up.d/*
post-down /etc/network/if-post-down.d/*
EOF
if [ "$SERIAL_PORT" ]; then
echo "$SERIAL_PORT" >> etc/securetty
sed -Ei "s|^[# ]*($SERIAL_PORT:.*)|\1|" etc/inittab
fi
#-----------------------------------------------------------------------
einfo 'Enabling base system services'
rc_add sysinit devfs dmesg mdev hwdrivers
[ -e etc/init.d/cgroups ] && rc_add sysinit cgroups ||: # since v3.8
rc_add boot modules hwclock swap hostname sysctl bootmisc syslog
rc_add shutdown killprocs savecache mount-ro
#-----------------------------------------------------------------------
if [ "$PACKAGES" ]; then
einfo 'Installing additional packages'
_apk add --root . $PACKAGES
fi
#-----------------------------------------------------------------------
if [ -L /etc/apk/cache ]; then
rm etc/apk/cache >/dev/null 2>&1
fi
#-----------------------------------------------------------------------
if [ "$FS_SKEL_DIR" ]; then
einfo "Copying content of $FS_SKEL_DIR into image"
[ "$FS_SKEL_CHOWN" ] \
&& rsync_opts="--chown $FS_SKEL_CHOWN" \
|| rsync_opts='--numeric-ids'
rsync --archive --info=NAME2 --whole-file $rsync_opts "$FS_SKEL_DIR"/ . >&2
# rsync may modify perms of the rootfs dir itself, so make sure it's correct.
install -d -m 0755 -o root -g root .
fi
#-----------------------------------------------------------------------
if [ "$SCRIPT" ]; then
script_name="${SCRIPT##*/}"
if [ "$SCRIPT_CHROOT" = 'no' ]; then
einfo "Executing script: $script_name $*"
"$SCRIPT" "$@" || die 'Script failed'
else
einfo "Executing script in chroot: $script_name $*"
mount_bind "${SCRIPT%/*}" mnt/
chroot . /bin/sh -c "cd /mnt && ./$script_name \"\$@\"" -- "$@" \
|| die 'Script failed'
fi
fi
#-----------------------------------------------------------------------
if grep -qw "$RESOLVCONF_MARK" etc/resolv.conf 2>/dev/null; then
cat > etc/resolv.conf <<-EOF
# Default nameservers, replace them with your own.
nameserver 1.1.1.1
nameserver 2606:4700:4700::1111
EOF
fi
rm -Rf var/cache/apk/* ||:
einfo 'Completed'
cd - >/dev/null
ls -lh "$IMAGE_FILE"

View file

@ -0,0 +1,14 @@
# wget https://raw.githubusercontent.com/alpinelinux/alpine-make-vm-image/v0.11.1/alpine-make-vm-image \
# && echo '0d5d3e375cb676d6eb5c1a52109a3a0a8e4cd7ac alpine-make-vm-image' | sha1sum -c \
# || exit 1
set -e
sudo bash alpine-make-vm-image -t \
--image-format qcow2 \
--repositories-file img/repositories \
--packages "$(cat img/packages)" \
--script-chroot \
alpine.qcow2 -- ./img/configure.sh
sudo rmmod nbd

1
alpine-img/img/configure.sh Executable file
View file

@ -0,0 +1 @@
echo "PasswordAuthentication no" >> /etc/ssh/sshd_config

3
alpine-img/img/packages Normal file
View file

@ -0,0 +1,3 @@
openssh
lvm2
util-linux

View file

@ -0,0 +1 @@
http://dl-cdn.alpinelinux.org/alpine/latest-stable/main

120
cmd/ls.go Normal file
View file

@ -0,0 +1,120 @@
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"github.com/AlexSSD7/vldisk/vm"
"github.com/inconshreveable/log15"
"github.com/spf13/cobra"
)
var lsCmd = &cobra.Command{
Use: "ls",
// TODO: Fill this
// Short: "",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
doRootCheck()
passthroughConfig := getDevicePassthroughConfig(args[0])
// TODO: We should download alpine image ourselves.
// TODO: ALSO, we need make it usable offline. We can't always download packages from the web.
// TODO: CLI-friendly logging.
vi, err := vm.NewInstance(log15.New(), "alpine-img/alpine.qcow2", []vm.USBDevicePassthroughConfig{passthroughConfig}, true)
if err != nil {
fmt.Printf("Failed to create VM instance: %v.\n", err)
os.Exit(1)
}
runErrCh := make(chan error, 1)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
err := vi.Run()
runErrCh <- err
}()
fm := vm.NewFileManager(vi)
for {
select {
case err := <-runErrCh:
fmt.Printf("Failed to run the VM: %v.\n", err)
os.Exit(1)
case <-vi.SSHUpNotifyChan():
err := fm.Init()
if err != nil {
fmt.Printf("Failed to initialize file manager: %v.\n", err)
os.Exit(1)
}
lsblkOut, err := fm.Lsblk()
if err != nil {
fmt.Printf("Failed to run list block devices in the VM: %v.\n", err)
os.Exit(1)
}
fmt.Print(string(lsblkOut))
err = vi.Cancel()
if err != nil {
fmt.Printf("Failed to cancel VM context: %v.\n", err)
os.Exit(1)
}
wg.Wait()
return nil
}
}
},
}
func getDevicePassthroughConfig(val string) vm.USBDevicePassthroughConfig {
valSplit := strings.Split(val, ":")
if want, have := 2, len(valSplit); want != have {
fmt.Printf("Bad device passthrough syntax (wrong items split by ':' count: want %v, have %v).\n", want, have)
os.Exit(1)
}
switch valSplit[0] {
case "usb":
usbValsSplit := strings.Split(valSplit[1], ",")
if want, have := 2, len(usbValsSplit); want != have {
fmt.Printf("Bad USB device passthrough syntax (wrong args split by ',' count: want %v, have %v).\n", want, have)
os.Exit(1)
}
usbBus, err := strconv.ParseUint(usbValsSplit[0], 10, 8)
if err != nil {
fmt.Printf("Bad USB device bus number '%v' (%v).\n", usbValsSplit[0], err)
os.Exit(1)
}
usbPort, err := strconv.ParseUint(usbValsSplit[1], 10, 8)
if err != nil {
fmt.Printf("Bad USB device port number '%v' (%v).\n", usbValsSplit[1], err)
os.Exit(1)
}
return vm.USBDevicePassthroughConfig{
HostBus: uint8(usbBus),
HostPort: uint8(usbPort),
}
default:
fmt.Printf("Unknown device passthrough type '%v'.\n", valSplit[0])
os.Exit(1)
// This unreachable code is required to compile.
return vm.USBDevicePassthroughConfig{}
}
}

25
cmd/root.go Normal file
View file

@ -0,0 +1,25 @@
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "vldisk",
// TODO: Fill this
// Short: "",
// Long: ``,
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.AddCommand(lsCmd)
}

30
cmd/utils.go Normal file
View file

@ -0,0 +1,30 @@
package cmd
import (
"fmt"
"os"
"os/user"
"github.com/pkg/errors"
)
func checkIfRoot() (bool, error) {
currentUser, err := user.Current()
if err != nil {
return false, errors.Wrap(err, "get current user")
}
return currentUser.Username == "root", nil
}
func doRootCheck() {
ok, err := checkIfRoot()
if err != nil {
fmt.Printf("Failed to check whether the command is ran by root: %v.\n", err)
os.Exit(1)
}
if !ok {
fmt.Printf("Root permissions are required.\n")
os.Exit(1)
}
}

23
go.mod Normal file
View file

@ -0,0 +1,23 @@
module github.com/AlexSSD7/vldisk
go 1.21
require (
github.com/alessio/shellescape v1.4.2
github.com/inconshreveable/log15 v2.16.0+incompatible
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.7.0
go.uber.org/multierr v1.11.0
golang.org/x/crypto v0.12.0
)
require (
github.com/go-stack/stack v1.8.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/term v0.11.0 // indirect
)

40
go.sum Normal file
View file

@ -0,0 +1,40 @@
github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=
github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/inconshreveable/log15 v2.16.0+incompatible h1:6nvMKxtGcpgm7q0KiGs+Vc+xDvUXaBqsPKHWKsinccw=
github.com/inconshreveable/log15 v2.16.0+incompatible/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

9
main.go Normal file
View file

@ -0,0 +1,9 @@
package main
import (
"github.com/AlexSSD7/vldisk/cmd"
)
func main() {
cmd.Execute()
}

15
utils/utils.go Normal file
View file

@ -0,0 +1,15 @@
package utils
import (
"strings"
"unicode"
)
func ClearUnprintableChars(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsPrint(r) {
return r
}
return -1
}, s)
}

31
vm/errors.go Normal file
View file

@ -0,0 +1,31 @@
package vm
import (
"fmt"
"strings"
"github.com/AlexSSD7/vldisk/utils"
"github.com/pkg/errors"
)
var (
ErrSSHUnavailable = errors.New("ssh unavailable")
)
func wrapErrWithLog(err error, msg, log string) error {
return errors.Wrapf(err, "%v %v", msg, getLogErrMsg(log))
}
func getLogErrMsg(s string) string {
logToInclude := strings.ReplaceAll(s, "\n", "\\n")
logToInclude = strings.TrimSuffix(logToInclude, "\\n")
logToInclude = utils.ClearUnprintableChars(logToInclude)
origLogLen := len(logToInclude)
const maxLogLen = 256
if origLogLen > maxLogLen {
logToInclude = fmt.Sprintf("[%v chars trimmed]", origLogLen) + logToInclude[len(logToInclude)-maxLogLen:]
}
return fmt.Sprintf("(log: '%v')", logToInclude)
}

59
vm/filemanager.go Normal file
View file

@ -0,0 +1,59 @@
package vm
import (
"bytes"
"github.com/pkg/errors"
)
type FileManager struct {
vi *Instance
}
func NewFileManager(vi *Instance) *FileManager {
return &FileManager{
vi: vi,
}
}
func (fm *FileManager) Init() error {
c, err := fm.vi.DialSSH()
if err != nil {
return errors.Wrap(err, "dial vm ssh")
}
_, err = runSSHCmd(c, "apk add util-linux lvm2")
if err != nil {
return errors.Wrap(err, "install utilities")
}
_, err = runSSHCmd(c, "vgchange -ay")
if err != nil {
return errors.Wrap(err, "run vgchange cmd")
}
return nil
}
func (fm *FileManager) Lsblk() ([]byte, error) {
c, err := fm.vi.DialSSH()
if err != nil {
return nil, errors.Wrap(err, "dial vm ssh")
}
sess, err := c.NewSession()
if err != nil {
return nil, errors.Wrap(err, "create new vm ssh session")
}
ret := new(bytes.Buffer)
sess.Stdout = ret
err = sess.Run("lsblk -o NAME,SIZE,FSTYPE,LABEL -e 7,11,2,253")
if err != nil {
return nil, errors.Wrap(err, "run lsblk")
}
return ret.Bytes(), nil
}

168
vm/ssh.go Normal file
View file

@ -0,0 +1,168 @@
package vm
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"net"
"strings"
"time"
"github.com/alessio/shellescape"
"github.com/pkg/errors"
"golang.org/x/crypto/ssh"
)
func ParseSSHKeyScan(knownHosts []byte) (ssh.HostKeyCallback, error) {
knownKeysMap := make(map[string][]byte)
for _, line := range strings.Split(string(knownHosts), "\n") {
if len(line) == 0 {
continue
}
lineSplit := strings.Split(line, " ")
if want, have := 3, len(lineSplit); want != have {
return nil, fmt.Errorf("bad split ssh identity string length: want %v, have %v ('%v')", want, have, line)
}
b, err := base64.StdEncoding.DecodeString(lineSplit[2])
if err != nil {
return nil, errors.Wrap(err, "decode base64 public key")
}
knownKeysMap[lineSplit[1]] = b
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
knownKey, ok := knownKeysMap[key.Type()]
if !ok {
return fmt.Errorf("unknown key type '%v'", key.Type())
}
if !bytes.Equal(key.Marshal(), knownKey) {
return fmt.Errorf("public key mismatch")
}
return nil
}, nil
}
func (vi *Instance) scanSSHIdentity() ([]byte, error) {
vi.resetSerialStdout()
err := vi.writeSerial([]byte(`ssh-keyscan -H localhost; echo "SERIAL STATUS: $?"` + "\n"))
if err != nil {
return nil, errors.Wrap(err, "write keyscan command to serial")
}
deadline := time.Now().Add(time.Second * 5)
var ret bytes.Buffer
for {
select {
case <-vi.ctx.Done():
return nil, vi.ctx.Err()
case <-time.After(time.Until(deadline)):
return nil, fmt.Errorf("keyscan command timed out")
case data := <-vi.serialStdoutCh:
if len(data) == 0 {
continue
}
prefix := []byte("SERIAL STATUS: ")
if bytes.HasPrefix(data, prefix) {
if len(data) == len(prefix) {
return nil, fmt.Errorf("keyscan command status code did not show up")
}
if data[len(prefix)] != '0' {
return nil, fmt.Errorf("non-zero keyscan command status code: '%v'", string(data[len(prefix)]))
}
return ret.Bytes(), nil
} else if data[0] == '|' {
ret.Write(data)
}
}
}
}
func (vi *Instance) sshSetup() (ssh.Signer, error) {
vi.resetSerialStdout()
sshSigner, sshPublicKey, err := generateSSHKey()
if err != nil {
return nil, errors.Wrap(err, "generate ssh key")
}
cmd := `set -ex; do_setup () { sh -c "set -ex; ifconfig eth0 up; ifconfig lo up; udhcpc; mkdir -p ~/.ssh; echo ` + shellescape.Quote(string(sshPublicKey)) + ` > ~/.ssh/authorized_keys; rc-update add sshd; service sshd start"; echo "SERIAL STATUS: $?"; }; do_setup` + "\n"
err = vi.writeSerial([]byte(cmd))
if err != nil {
return nil, errors.Wrap(err, "write ssh setup serial command")
}
deadline := time.Now().Add(time.Second * 5)
stdOutErrBuf := bytes.NewBuffer(nil)
for {
select {
case <-vi.ctx.Done():
return nil, vi.ctx.Err()
case <-time.After(time.Until(deadline)):
return nil, fmt.Errorf("setup command timed out %v", getLogErrMsg(stdOutErrBuf.String()))
case data := <-vi.serialStdoutCh:
prefix := []byte("SERIAL STATUS: ")
stdOutErrBuf.Write(data)
if bytes.HasPrefix(data, prefix) {
if len(data) == len(prefix) {
return nil, fmt.Errorf("setup command status code did not show up")
}
if data[len(prefix)] != '0' {
return nil, fmt.Errorf("non-zero setup command status code: '%v' %v", string(data[len(prefix)]), getLogErrMsg(stdOutErrBuf.String()))
}
return sshSigner, nil
}
}
}
}
func generateSSHKey() (ssh.Signer, []byte, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, errors.Wrap(err, "generate rsa private key")
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
return nil, nil, errors.Wrap(err, "create signer from key")
}
return signer, ssh.MarshalAuthorizedKey(signer.PublicKey()), nil
}
func runSSHCmd(c *ssh.Client, cmd string) ([]byte, error) {
sess, err := c.NewSession()
if err != nil {
return nil, errors.Wrap(err, "create new vm ssh session")
}
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
sess.Stdout = stdout
sess.Stderr = stderr
err = sess.Run(cmd)
if err != nil {
return nil, wrapErrWithLog(err, "run cmd", stderr.String())
}
return stdout.Bytes(), nil
}

291
vm/vm.go Normal file
View file

@ -0,0 +1,291 @@
package vm
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/alessio/shellescape"
"github.com/inconshreveable/log15"
"github.com/phayes/freeport"
"github.com/pkg/errors"
"go.uber.org/multierr"
"golang.org/x/crypto/ssh"
)
type USBDevicePassthroughConfig struct {
HostBus uint8
HostPort uint8
}
type Instance struct {
logger log15.Logger
ctx context.Context
ctxCancel context.CancelFunc
cmd *exec.Cmd
sshMappedPort uint16
sshConf *ssh.ClientConfig
sshReadyCh chan struct{}
serialRead *io.PipeReader
serialReader *bufio.Reader
serialWrite *io.PipeWriter
serialWriteMu sync.Mutex
stderrBuf *bytes.Buffer
serialStdoutCh chan []byte
// These are to be interacted with using `atomic` package
disposed uint32
canceled uint32
}
func NewInstance(logger log15.Logger, alpineImagePath string, usbDevices []USBDevicePassthroughConfig, debug bool) (*Instance, error) {
alpineImagePath = filepath.Clean(alpineImagePath)
_, err := os.Stat(alpineImagePath)
if err != nil {
return nil, errors.Wrap(err, "failed to stat alpine image path")
}
sshPort, err := freeport.GetFreePort()
if err != nil {
return nil, errors.Wrap(err, "get free port for ssh server")
}
// TODO: Disable internet access
// TODO: Configurable memory allocation
baseCmd := "qemu-system-x86_64"
cmdArgs := []string{"-serial", "stdio", "-enable-kvm", "-m", "2048", "-smp", fmt.Sprint(runtime.NumCPU()),
"-device", "e1000,netdev=net0", "-netdev", "user,id=net0,hostfwd=tcp::" + fmt.Sprint(sshPort) + "-:22"}
cmdArgs = append(cmdArgs, "-drive", "file="+shellescape.Quote(alpineImagePath)+",format=qcow2,if=virtio", "-snapshot")
if !debug {
cmdArgs = append(cmdArgs, "-display", "none")
}
if len(usbDevices) != 0 {
cmdArgs = append(cmdArgs, "-usb", "-device", "nec-usb-xhci,id=xhci")
for _, dev := range usbDevices {
cmdArgs = append(cmdArgs, "-device", "usb-host,hostbus="+strconv.FormatUint(uint64(dev.HostBus), 10)+",hostport="+strconv.FormatUint(uint64(dev.HostPort), 10))
}
}
sysRead, userWrite := io.Pipe()
userRead, sysWrite := io.Pipe()
cmd := exec.Command(baseCmd, cmdArgs...)
cmd.Stdin = sysRead
cmd.Stdout = sysWrite
stderrBuf := bytes.NewBuffer(nil)
cmd.Stderr = stderrBuf
userReader := bufio.NewReader(userRead)
ctx, ctxCancel := context.WithCancel(context.Background())
vi := &Instance{
logger: logger,
ctx: ctx,
ctxCancel: ctxCancel,
cmd: cmd,
sshMappedPort: uint16(sshPort),
sshReadyCh: make(chan struct{}),
serialRead: userRead,
serialReader: userReader,
serialWrite: userWrite,
stderrBuf: stderrBuf,
}
vi.resetSerialStdout()
return vi, nil
}
func (vi *Instance) Run() error {
if atomic.AddUint32(&vi.disposed, 1) != 1 {
return fmt.Errorf("vm disposed")
}
err := vi.cmd.Start()
if err != nil {
return errors.Wrap(err, "start qemu cmd")
}
var globalErrsMu sync.Mutex
var globalErrs []error
globalErrFn := func(err error) {
globalErrsMu.Lock()
defer globalErrsMu.Unlock()
globalErrs = append(globalErrs, err, errors.Wrap(vi.Cancel(), "cancel on error"))
}
vi.logger.Info("Booting the VM")
go func() {
_ = vi.runSerialReader()
_ = vi.Cancel()
}()
go func() {
err = vi.runVMLoginHandler()
if err != nil {
globalErrFn(errors.Wrap(err, "run vm login handler"))
return
}
sshSigner, err := vi.sshSetup()
if err != nil {
globalErrFn(errors.Wrap(err, "set up ssh"))
return
}
vi.logger.Debug("Set up SSH server successfully")
sshKeyScan, err := vi.scanSSHIdentity()
if err != nil {
globalErrFn(errors.Wrap(err, "scan ssh identity"))
return
}
vi.logger.Debug("Scanned SSH identity")
knownHosts, err := ParseSSHKeyScan(sshKeyScan)
if err != nil {
// TODO: Test what actually happens in inline critical errors like this.
globalErrFn(errors.Wrap(err, "parse ssh key scan"))
return
}
vi.sshConf = &ssh.ClientConfig{
User: "root",
HostKeyCallback: knownHosts,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(sshSigner),
},
Timeout: time.Second * 5,
}
// This is to notify everyone waiting for SSH to be up that it's ready to go.
close(vi.sshReadyCh)
vi.logger.Info("SSH up, the VM ready for work")
}()
_, err = vi.cmd.Process.Wait()
cancelErr := vi.Cancel()
if err != nil {
combinedErr := multierr.Combine(
errors.Wrap(err, "wait for cmd to finish execution"),
errors.Wrap(cancelErr, "cancel"),
)
return fmt.Errorf("%w %v", combinedErr, getLogErrMsg(vi.stderrBuf.String()))
}
combinedErr := multierr.Combine(
append(globalErrs, errors.Wrap(cancelErr, "cancel on exit"))...,
)
return fmt.Errorf("%w %v", combinedErr, getLogErrMsg(vi.stderrBuf.String()))
}
func (vi *Instance) Cancel() error {
if atomic.AddUint32(&vi.canceled, 1) != 1 {
return nil
}
vi.ctxCancel()
return multierr.Combine(
errors.Wrap(vi.cmd.Process.Signal(os.Interrupt), "cancel cmd"),
errors.Wrap(vi.serialRead.Close(), "close serial read pipe"),
errors.Wrap(vi.serialWrite.Close(), "close serial write pipe"),
)
}
func (vi *Instance) runSerialReader() error {
for {
raw, err := vi.serialReader.ReadBytes('\n')
if err != nil {
return errors.Wrap(err, "read from serial reader")
}
select {
case vi.serialStdoutCh <- raw:
default:
// Message gets discarded if the buffer is full.
}
}
}
func (vi *Instance) writeSerial(b []byte) error {
vi.serialWriteMu.Lock()
defer vi.serialWriteMu.Unlock()
_, err := vi.serialWrite.Write(b)
return err
}
func (vi *Instance) runVMLoginHandler() error {
for {
select {
case <-vi.ctx.Done():
return nil
case <-time.After(time.Second):
peek, err := vi.serialReader.Peek(vi.serialReader.Buffered())
if err != nil {
return errors.Wrap(err, "peek stdout")
}
if bytes.Contains(peek, []byte("login:")) {
err = vi.writeSerial([]byte("root\n"))
if err != nil {
return errors.Wrap(err, "failed to stdio write login")
}
vi.logger.Debug("Logged into the VM serial")
return nil
}
}
}
}
func (vi *Instance) resetSerialStdout() {
vi.serialStdoutCh = make(chan []byte, 32)
}
func (vi *Instance) DialSSH() (*ssh.Client, error) {
if vi.sshConf == nil {
return nil, ErrSSHUnavailable
}
return ssh.Dial("tcp", "localhost:"+fmt.Sprint(vi.sshMappedPort), vi.sshConf)
}
func (vi *Instance) SSHUpNotifyChan() chan struct{} {
return vi.sshReadyCh
}