#!/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