#!/bin/sh
set -eu
usage() {
cat <<-'EOF'
Usage:
vm [-G] [-i IMAGE ] [-n] [-p PORTMAP ] [-S] [-v] ACTION [OS [-- QEMU_OPTIONS...]]
vm -h
EOF
}
help() {
cat <<-'EOF'
Options:
-G use graphics
-i IMAGE provide a custom QCoW2 image file
-m MEMORY memory (default: 1G)
-n dry-run: don't execute any code, assumes -v
-S disable snapshots
-p PORTMAP map the host port $HOSTP to the guest port
$GUESTP using the $HOSTP:$GUESTP syntax
-v verbose mode
-h, --help show this message
ACTION one of:
- up
- down
- status
- ls-remote
- download
OS the name or prefix of the OS to be acted upon
QEMU_OPTIONS command line options to be given verbatim to QEMU
Manage the state of known virtual machines.
The VM QCOW2 images are stored under
$XDG_STATE_HOME/vm/qemu/, as "$OS.qcow2" files. The PIDs
of the running images are stored in individual files under
$XDG_RUNTIME_DIR/vm/pids/, as "$OS.pid" files.
It also generates an SSH configuration file under
$XDG_DATA_HOME/vm/ssh.conf to be `Included` by the main
~/.ssh/config file, which contains one alias entry for each VM,
so that one can do a combination of `vm up alpine && ssh alpine`.
If the given OS name is a unique prefix, than it is enough to
guess the rest of the name, i.e. if there is only only type of
"Fedora" VM, fedora-amd64-headless, than saying "fedora" is enough.
Otherwise, a sufficiently uniqe prefix is required, like
"fedora-amd64", when there are more than one architecture of Fedora
VMs.
Examples:
Start the VM for Alpine in verbose mode, with guest port 8000
becoming available to the host via port 5555:
$ vm -v -p 5555:8000 up alpine
Stop the VM for Slackware, which was already down:
$ vm down slackware
The VM for "slackware" is not running, already.
List the available VMs, and their current state:
$ vm status
alpine-amd64-headless up
slackware-amd64-plasma down
freebsd-amd64-headless up
Create a new VM, using graphics and verbose mode:
$ qemu-img create -f qcow2 $XDG_STATE_HOME/vm/qemu/debian-amd64-headless.qcow2 32G
$ vm -vSG up debian -- -cdrom ~/Downloads/ISO/debian-11.5.0-amd64-netinst.iso
List images available on the remote, and download one of them:
$ vm ls-remote
debian-i386-headless
fedora-aarch64-headless
$ vm download fedora-aarch64-headless
EOF
}
for flag in "$@"; do
case "$flag" in
(--)
break
;;
(--help)
usage
help
exit
;;
(*)
;;
esac
done
IMAGE=''
DRY_RUN=false
GRAPHICS=false
MEMORY=1G
PORTMAPS=''
SNAPSHOT=true
VERBOSE=false
while getopts 'Gi:m:np:Svh' flag; do
case "$flag" in
G)
GRAPHICS=true
;;
(i)
IMAGE="$OPTARG"
;;
(m)
MEMORY="$OPTARG"
;;
(n)
DRY_RUN=true
VERBOSE=true
;;
(p)
PORTMAPS="$PORTMAPS $OPTARG"
;;
S)
SNAPSHOT=false
;;
(v)
VERBOSE=true
;;
(h)
usage
help
exit
;;
(*)
usage >&2
exit 2
;;
esac
done
shift $((OPTIND - 1))
REMOTE='https://euandre.org/s/vm'
QCOW_DIR="${XDG_STATE_HOME:-$HOME/.local/state}"/vm/qemu
RUNDIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/vm"
LOGS="${XDG_LOG_HOME:-$HOME/.local/var/log}"/vm/vm.log
mkdir -p "$RUNDIR" "$QCOW_DIR" "$XDG_DATA_HOME"/vm "$(dirname "$LOGS")"
guess_name() {
PREFIX="$1"
IMAGES="$(find "$QCOW_DIR" '(' -type f -or -type l ')' -name "${PREFIX}*")"
COUNT="$(printf '%s\n' "$IMAGES" | wc -l)"
if [ "$COUNT" != 1 ]; then
printf 'Cannot guess name with the given prefix: "%s".\n' "$PREFIX" >&2
printf '\nThe possibilities are:\n' >&2
printf '%s\n' "$IMAGES" |
xargs -I% basename % .qcow2 |
sed 's/^/- /' >&2
exit 2
fi
if [ -z "$IMAGES" ]; then
printf 'Cannot guess name with the given prefix: "%s".\n' "$PREFIX" >&2
printf '\nNo possibilities were found.\n' >&2
exit 2
fi
printf '%s\n' "$(basename "$IMAGES" .qcow2)"
}
guess_arch() {
NAME="$(printf '%s\n' "$1" | cut -d- -f2)"
case "$NAME" in
amd64)
printf 'x86_64'
;;
(*)
printf '%s' "$NAME"
;;
esac
}
hostfwd() {
for m in "$@"; do
HOST="$( printf '%s\n' "$m" | cut -d: -f1)"
GUEST="$(printf '%s\n' "$m" | cut -d: -f2)"
printf ',hostfwd=tcp::%s-:%s' "$HOST" "$GUEST"
done
}
write_ssh_config() {
for port in "$RUNDIR"/*.ssh; do
if [ ! -e "$port" ]; then
break
fi
NAME="$(basename "$port" .ssh)"
PORT="$(cat "$port")"
cat <<-EOF
Host $NAME
User vm
HostName localhost
Port $PORT
UserKnownHostsFile /dev/null
GlobalKnownHostsFile /dev/null
StrictHostKeyChecking no
EOF
done | sponge "$XDG_DATA_HOME"/vm/ssh.conf
}
write_ssh_config
ACTION="${1:-}"
OS="${2:-}"
eval "$(assert-arg -- "$ACTION" 'ACTION')"
shift
FLAGS=''
if [ "$GRAPHICS" = false ]; then
FLAGS="$FLAGS -nographic"
else
FLAGS="$FLAGS -display sdl"
fi
if [ "$SNAPSHOT" = true ]; then
FLAGS="$FLAGS -snapshot"
fi
case "$ACTION" in
status)
for img in "$QCOW_DIR"/*.qcow2; do
if [ ! -e "$img" ]; then
break
fi
NAME="$(basename "$img" .qcow2)"
PORTS=''
if [ -e "$RUNDIR/$NAME.pid" ]; then
STATUS=up
PORTS="$(sed 's/ /,/g' "$RUNDIR/$NAME.ports")"
else
STATUS=down
fi
SNAPSHOT="$(cat "$RUNDIR/$NAME.snapshot" 2>/dev/null ||:)"
if [ "$SNAPSHOT" = true ] && [ "$STATUS" = 'up' ]; then
SNAPSHOT='snapshot'
else
SNAPSHOT=''
fi
printf '%s\t%s\t%s\t%s\n' "$NAME" "$STATUS" "$PORTS" "$SNAPSHOT"
done
;;
up)
eval "$(assert-arg -- "$OS" 'OS')"
shift
if [ "${1:-}" = '--' ]; then
shift
fi
OS="$(guess_name "$OS")"
ARCH="$(guess_arch "$OS")"
PID_F="$RUNDIR/$OS.pid"
SSH_PORT_F="$RUNDIR/$OS.ssh"
PORTMAPS_F="$RUNDIR/$OS.ports"
SNAPSHOT_F="$RUNDIR/$OS.snapshot"
if [ -e "$PID_F" ]; then
printf 'The VM for "%s" is already running with PID %s.\n' \
"$OS" "$(cat "$PID_F")" >&2
exit
fi
if [ -z "$IMAGE" ]; then
IMAGE="$QCOW_DIR"/"$OS".qcow2
fi
SSH_PORT="$(free-port)"
PORTMAPS="$SSH_PORT:22${PORTMAPS}"
# shellcheck disable=SC2086
HOSTFWD="$(hostfwd $PORTMAPS)"
# shellcheck disable=2086
set -- qemu-system-"$ARCH" \
-m "$MEMORY" \
-nic user,model=virtio"$HOSTFWD" \
-drive file="$IMAGE",media=disk,if=virtio \
-enable-kvm \
$FLAGS "$@"
if [ "$DRY_RUN" = true ]; then
printf '%s\n' "$@"
exit
fi
if [ "$VERBOSE" = true ]; then
set -x
fi
"$@" 1>>"$LOGS" 2>&1 &
PID=$!
set +x
printf '%s' "$PID" > "$PID_F"
printf '%s' "$SSH_PORT" > "$SSH_PORT_F"
printf '%s' "$PORTMAPS" > "$PORTMAPS_F"
printf '%s' "$SNAPSHOT" > "$SNAPSHOT_F"
write_ssh_config
;;
down)
eval "$(assert-arg -- "$OS" 'OS')"
shift
if [ "${1:-}" = '--' ]; then
shift
fi
OS="$(guess_name "$OS")"
PID_F="$RUNDIR/$OS.pid"
SSH_PORT_F="$RUNDIR/$OS.ssh"
PORTMAPS_F="$RUNDIR/$OS.port"
if [ ! -e "$PID_F" ]; then
printf 'The VM for "%s" is not running, already.\n' "$OS" >&2
exit
fi
PID="$(cat "$PID_F")"
if [ "$DRY_RUN" = true ]; then
echo rm -f "$PID_F" "$SSH_PORT_F" "$PORTMAPS_F"
echo kill "$PID"
exit
fi
if [ "$VERBOSE" = true ]; then
set -x
fi
rm -f "$PID_F" "$SSH_PORT_F" "$PORTMAPS_F"
kill "$PID"
set +x
write_ssh_config
;;
ls-remote)
wget -qO- "$REMOTE"/vms.txt
;;
download)
eval "$(assert-arg -- "$OS" 'OS')"
wget -cO "$QCOW_DIR/$OS".qcow2 "$REMOTE"/qemu/"$OS".qcow2
;;
*)
printf 'Unrecognized action: "%s".\n\n' "$ACTION" >&2
usage >&2
exit 2
esac