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