aboutsummaryrefslogtreecommitdiff
path: root/files/usr/local/sbin/vmctl.freebsd_hypervisor
diff options
context:
space:
mode:
Diffstat (limited to 'files/usr/local/sbin/vmctl.freebsd_hypervisor')
-rw-r--r--files/usr/local/sbin/vmctl.freebsd_hypervisor1198
1 files changed, 1198 insertions, 0 deletions
diff --git a/files/usr/local/sbin/vmctl.freebsd_hypervisor b/files/usr/local/sbin/vmctl.freebsd_hypervisor
new file mode 100644
index 0000000..b5bc9cf
--- /dev/null
+++ b/files/usr/local/sbin/vmctl.freebsd_hypervisor
@@ -0,0 +1,1198 @@
+#!/bin/sh
+#
+# Bhyve management utility.
+
+set -eu -o pipefail
+
+. /usr/local/etc/vmctl.conf
+
+cmd::main(){
+ local usage="COMMAND [ARGS]...
+FreeBSD bhyve management utility.
+Commands:
+ create Create a new VM
+ create-snapshot Take a snapshot of a VM
+ create-template Create a template from a VM disk image
+ console Connect to a VM's serial console
+ destroy Destroy a VM and its dataset
+ destroy-iso Delete an ISO
+ destroy-snapshot Delete a VM snapshot
+ destroy-template Delete a VM template
+ download-iso Download an ISO
+ download-template Download a disk image
+ edit Edit a VM's configuration
+ list List VMs
+ list-isos List available ISOs
+ list-snapshots List VM snapshots
+ list-templates List VM templates
+ reprovision Wipe and reprovision an OS disk from a template
+ restart Restart a VM
+ rollback Rollback a VM to a given snapshot
+ show Show VM configuration
+ start Start a VM
+ status Show VM status
+ stop Stop a VM"
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -ge 1 ] || cmd::usage 'no comand specified'
+ local cmd=$1; shift
+
+ case $cmd in
+ console) cmd::console "$@" ;;
+ create) cmd::create "$@" ;;
+ create-snapshot|snapshot|snap) cmd::create_snapshot "$@" ;;
+ create-template) cmd::create_template "$@" ;;
+ destroy|rm) cmd::destroy "$@" ;;
+ destroy-iso|rmi) cmd::destroy_iso "$@" ;;
+ destroy-snapshot|rms) cmd::destroy_snapshot "$@" ;;
+ destroy-template|rmt) cmd::destroy_template "$@" ;;
+ download-iso|dli) cmd::download_iso "$@" ;;
+ download-template|dlt) cmd::download_template "$@" ;;
+ edit) cmd::edit "$@" ;;
+ list|ls) cmd::list "$@" ;;
+ list-isos|lsi) cmd::list_isos "$@" ;;
+ list-snapshots|lss) cmd::list_snapshots "$@" ;;
+ list-templates|lst) cmd::list_templates "$@" ;;
+ reprovision) cmd::reprovision "$@" ;;
+ restart|reboot) cmd::restart "$@" ;;
+ rollback) cmd::rollback "$@" ;;
+ show) cmd::show "$@" ;;
+ start|boot) cmd::start "$@" ;;
+ status) cmd::status "$@" ;;
+ stop|shutdown) cmd::stop "$@" ;;
+ # The following commands are internal and should not be invoked manually.
+ _start-all) cmd::_start_all "$@" ;;
+ _stop-all) cmd::_stop_all "$@" ;;
+ *) cmd::usage "unknown command: ${cmd}" ;;
+ esac
+}
+
+
+################################################################################
+# Standard helper functions.
+################################################################################
+die(){
+ printf '%s: %s\n' vmctl "$*" 1>&2
+ exit 1
+}
+
+
+################################################################################
+# CLI-related functions.
+################################################################################
+cmd::help(){
+ printf 'Usage: %s %s\n' vmctl "$usage"
+ [ -n "${help:-}" ] && printf '%s\n' "$help"
+ exit 0
+}
+
+cmd::usage(){
+ [ $# -gt 0 ] && printf '%s: %s\n' "vmctl" "$1" 1>&2
+ printf 'Usage: %s %s\n' vmctl "${usage}" 1>&2
+ exit 2
+}
+
+cmd::getopt_help(){
+ local opt
+ while getopts :h opt; do
+ case $opt in
+ h) cmd::help ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+}
+
+cmd::console(){
+ local usage='console VM'
+ local help='Connect to serial console.
+Notes:
+ Type ~. to return to your shell.
+ See `man 1 cu` for additional escape codes.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+ vm::running "$vm" || die "vm not running: ${vm}"
+
+ exec cu -l "/dev/nmdm_${vm}B" -s 9600
+}
+
+cmd::create(){
+ local usage='create [-A] [-a IP] [-c CORES] [-d DOMAIN] [-g GATEWAY] [-i ISO] [-k SSHKEY]
+ [-m MEM] [-p PREFIXLEN] [-q DATA_SIZE] [-Q OS_SIZE] [-r NAMESERVER]
+ [-s SEARCHDOMAIN] [-v VLANID] NAME [TEMPLATE]'
+ local help="Create a new VM.
+Options:
+ -A Don't autostart on boot
+ -a IP IPv4 address (cloud-init)
+ -c CORES Number of CPU cores
+ -d DOMAIN Host domain name (cloud-init)
+ -g GATEWAY Default IPv4 gateway (cloud-init)
+ -i ISO Boot from an ISO
+ -k SSHKEY Path to SSH pubkey for root's authorized_keys (cloud-init)
+ -m MEM Size of memory (RAM)
+ -p PREFIXLEN IPv4 network prefix length (cloud-init)
+ -q DATA_SIZE Size of data disk
+ -Q OS_SIZE Size of OS disk
+ -r NAMESERVER DNS resolver (cloud-init)
+ -s SEARCHDOMAIN DNS search domain (cloud-init)
+ -v VLANID VLAN ID number"
+
+ local \
+ autostart=true \
+ cpus=$DEFAULT_CPUS \
+ data_size=$DEFAULT_DATA_SIZE \
+ domain=$DEFAULT_DOMAIN \
+ gateway \
+ ip \
+ memory=$DEFAULT_MEMORY \
+ nameservers \
+ prefixlen=$DEFAULT_PREFIXLEN \
+ os_size=$DEFAULT_OS_SIZE \
+ searchdomains \
+ vlan=$DEFAULT_VLAN \
+ ssh_key_files \
+ ssh_keys \
+ iso \
+ opt
+
+ while getopts :Aa:c:d:g:hi:k:m:p:q:Q:r:s:v: opt; do
+ case $opt in
+ A) autostart=false ;;
+ a) ip=${OPTARG} ;;
+ c) cpus=$OPTARG ;;
+ d) domain=$OPTARG ;;
+ g) gateway=$OPTARG ;;
+ h) cmd::help ;;
+ i) iso=$OPTARG ;;
+ k) ssh_key_files="${ssh_key_files:-} ${OPTARG}" ;;
+ m) memory=$OPTARG ;;
+ p) prefixlen=$OPTARG ;;
+ q) data_size=$OPTARG ;;
+ Q) os_size=$OPTARG ;;
+ r) nameservers="${nameservers:-}${OPTARG}," ;;
+ s) searchdomains="${searchdomains:-}${OPTARG}," ;;
+ v) vlan=$OPTARG ;;
+ :) cmd::usage "missing option value: -${OPTARG}" ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'NAME not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ local name=$1 template=${2:-}
+
+ vm::exists "$name" && die "vm name already in use: ${name}"
+
+ if [ -n "$template" ]; then
+ template::exists "$template" || die "no such template: ${template}"
+ fi
+
+ if [ -n "${iso:-}" ]; then
+ iso::exists "$iso" || die "no such iso: ${iso}"
+ fi
+
+ local ssh_keys key
+ for key in ${ssh_key_files:-}; do
+ [ -f "${key}" ] || die "no such ssh key: ${key}"
+ ssh_keys="${ssh_keys:-} - $(cat "$key")
+"
+ done
+
+ interface::exists "bridge${vlan}" || interface::add_vlan "$vlan"
+
+ # Create the parent dataset.
+ zfs create -v "${VM_DATASET}/${name}"
+
+ if [ -n "$template" ]; then
+ # Clone the OS zvol from template.
+ zfs::ensure_snapshot snapshot "${VM_DATASET}/templates/${template}"
+ zfs clone $ZFS_OPTS -o volmode=dev "$snapshot" "${VM_DATASET}/${name}/os"
+ else
+ # Create a new OS zvol.
+ zfs create -v -o volblocksize="$ZFS_VOLBLOCKSIZE" $ZFS_OPTS -o volmode=dev -V "$os_size" "${VM_DATASET}/${name}/os"
+ fi
+
+ # Create a data zvol.
+ zfs create -v -o volblocksize="$ZFS_VOLBLOCKSIZE" $ZFS_OPTS -o volmode=dev -V "$data_size" "${VM_DATASET}/${name}/data"
+
+ local macaddr uuid
+ macaddr=$(interface::derive_mac "$TRUNK_INTERFACE" "$name")
+ uuid=$(uuidgen)
+
+ # Generate bhyve config file.
+ cat <<EOF > "${VM_HOME}/${name}/bhyve.conf"
+name=${name}
+uuid=${uuid}
+cpus=${cpus}
+memory.size=${memory}
+acpi_tables=true
+destroy_on_poweroff=true
+keyboard.layout=us_unix
+rtc.use_localtime=false
+x86.strictmsr=true
+x86.vmexit_on_hlt=true
+x86.vmexit_on_pause=true
+pci.0.0.0.device=hostbridge
+pci.0.1.0.device=lpc
+pci.0.2.0.device=virtio-net
+pci.0.2.0.backend=$(interface::tap::derive_name "$name")
+pci.0.2.0.mac=${macaddr}
+pci.0.3.0.device=nvme
+pci.0.3.0.path=/dev/zvol/${VM_DATASET}/%(name)/os
+pci.0.4.0.device=nvme
+pci.0.4.0.path=/dev/zvol/${VM_DATASET}/%(name)/data
+pci.0.5.0.device=ahci
+pci.0.5.0.port.0.type=cd
+pci.0.5.0.port.0.path=${VM_HOME}/%(name)/seed.iso
+pci.0.5.0.port.0.ro=true
+lpc.com1.path=/dev/nmdm_%(name)A
+lpc.bootrom=/usr/local/share/uefi-firmware/BHYVE_UEFI.fd
+lpc.bootvars=${VM_HOME}/${name}/uefi-vars.fd
+vmctl.vlan=${vlan}
+EOF
+
+ # Generate cloud-init network configuration.
+ if [ -z "${ip:-}" ]; then
+ cat <<EOF > "${VM_HOME}/${name}/network-config"
+version: 2
+ethernets:
+ id0:
+ match:
+ macaddress: '${macaddr}'
+ dhcp4: True
+ dhcp6: False
+EOF
+ else
+ cat <<EOF > "${VM_HOME}/${name}/network-config"
+version: 2
+ethernets:
+ id0:
+ match:
+ macaddress: '${macaddr}'
+ addresses: [${ip}/${prefixlen}]
+ routes:
+ - to: 0.0.0.0/0
+ via: ${gateway:-}
+ dhcp4: False
+ dhcp6: False
+ nameservers:
+ search: [${searchdomains:-}]
+ addresses: [${nameservers:-}]
+EOF
+ fi
+
+ # Generate cloud-init metadata.
+ cat <<EOF > "${VM_HOME}/${name}/meta-data"
+instance-id: ${uuid}
+local-hostname: ${name}
+EOF
+
+ # Generate cloud-init userdata.
+ cat <<EOF > "${VM_HOME}/${name}/user-data"
+#cloud-config
+disable_root: False
+ssh_pwauth: False
+resize_rootfs: True
+ssh_authorized_keys:
+${ssh_keys:-}
+users: []
+fqdn: ${name}.${domain}
+prefer_fqdn_over_hostname: True
+runcmd:
+ - sed -i.bak -E '/^#?PermitRootLogin/s/^.*$/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
+ - rm -f /etc/ssh/sshd_config.bak
+ - service ssh restart
+ - service sshd restart
+EOF
+
+ # Generate cloud-init ISO file.
+ genisoimage -output "${VM_HOME}/${name}/seed.iso" -volid cidata -joliet -input-charset utf-8 -rock \
+ "${VM_HOME}/${name}/network-config" \
+ "${VM_HOME}/${name}/meta-data" \
+ "${VM_HOME}/${name}/user-data"
+
+ # Copy initial UEFI vars.
+ cp /usr/local/share/uefi-firmware/BHYVE_UEFI_VARS.fd "${VM_HOME}/${name}/uefi-vars.fd"
+
+ # Enable autostart (if requested).
+ [ "$autostart" = true ] && sysrc "vmctl_list+=${name}"
+
+ # Start the VM.
+ vm::start "$name" "${iso:-}"
+}
+
+cmd::create_snapshot(){
+ local usage='create-snapshot [-d] [-o] VM [SNAPNAME]'
+ local help='Create a VM snapshot.
+Options:
+ -d Only snapshot the data dataset
+ -o Only snapshot the OS dataset'
+
+ local opt both=true os_only=false data_only=false snapname
+ while getopts :dho opt; do
+ case $opt in
+ d) data_only=true; both=false ;;
+ h) cmd::help ;;
+ o) os_only=true; both=false ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ vm=$1 snapname=${2:-}
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+
+ [ -n "$snapname" ] || snapname=$(date +%Y-%m-%dT%H:%M:%S)
+
+ # Snapshot the OS disk.
+ if [ "$both" = true ] || [ "$os_only" = true ]; then
+ zfs snapshot "${VM_DATASET}/${vm}/os@${snapname}"
+ fi
+
+ # Snapshot the data disk.
+ if [ "$both" = true ] || [ "$data_only" = true ]; then
+ zfs snapshot "${VM_DATASET}/${vm}/data@${snapname}"
+ fi
+}
+
+cmd::create_template(){
+ local usage='create-template NAME VM'
+ local help='Create a template from a VM.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'NAME not specified'
+ [ $# -lt 2 ] && cmd::usage 'VM not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ local name=$1 vm=$2
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+ template::exists "$name" && die "template already exists: $name"
+ vm::running "$vm" && die "refusing to create template while vm is running: ${vm}"
+
+ local snapname
+ snapname=$(date +%Y-%m-%dT%H:%M:%S)
+ zfs snapshot "${VM_DATASET}/${vm}/os@${snapname}"
+ zfs send "${VM_DATASET}/${vm}/os@${snapname}" | zfs receive -v $ZFS_OPTS -o volmode=dev "${VM_DATASET}/templates/${name}"
+ zfs destroy "${VM_DATASET}/${vm}/os@${snapname}"
+}
+
+cmd::destroy_iso(){
+ local usage='destroy-iso ISO'
+ local help='Delete an ISO.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'ISO not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ iso=$1
+
+ iso::exists "$iso" || die "no such iso: ${iso}"
+ rm "${VM_HOME}/isos/${iso}.iso"
+}
+
+cmd::destroy(){
+ local usage='destroy [-y] VM'
+ local help="Delete a VM and its dataset.
+Options:
+ -y Don't prompt for confirmation"
+
+ local noconfirm=false answer opt
+
+ while getopts :hy opt; do
+ case $opt in
+ h) cmd::help ;;
+ y) noconfirm=true ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+
+ if [ "$noconfirm" != true ]; then
+ read -rp "Really destroy vm ${vm}? (y/N) " answer
+ case $answer in
+ [yY]|[yY][eE][sS]) : ;;
+ *) die 'operation cancelled' ;;
+ esac
+ fi
+
+ # If the VM is running, stop it.
+ vm::running "$1" && vm::stop "$1" KILL
+
+ # Destroy the VM's dataset.
+ zfs destroy -v -r "${VM_DATASET}/${vm}"
+
+ # Remove VM from autostart list.
+ sysrc -v "vmctl_list-=${vm}"
+}
+
+cmd::destroy_snapshot(){
+ local usage='destroy-snapshot [-y] VM SNAPSHOT'
+ local help="Delete a VM snapshot.
+Options:
+ -y Don't prompt for confirmation"
+
+ local noconfirm=false answer opt
+
+ while getopts :hy opt; do
+ case $opt in
+ h) cmd::help ;;
+ y) noconfirm=true ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -lt 2 ] && cmd::usage 'SNAPSHOT not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ local vm=$1 snapshot=$2
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+
+ local datasets=''
+
+ if [ "${snapshot#*@}" != "$snapshot" ]; then
+ # If the snapshot name contains '@', then we have something like 'os@snapname'.
+ zfs::dataset_exists "${VM_DATASET}/${vm}/${snapshot}" && \
+ datasets="${VM_DATASET}/${vm}/${snapshot}"
+ else
+ # Otherwise, check if either os or data dataset contains a matching snapshot.
+ zfs::dataset_exists "${VM_DATASET}/${vm}/os@${snapshot}" && \
+ datasets="${VM_DATASET}/${vm}/os@${snapshot}"
+
+ zfs::dataset_exists "${VM_DATASET}/${vm}/data@${snapshot}" && \
+ datasets="${datasets} ${VM_DATASET}/${vm}/data@${snapshot}"
+ fi
+
+ [ -n "$datasets" ] || die "no such snapshot for vm ${vm}: ${snapshot}"
+
+ if [ "$noconfirm" != true ]; then
+ read -rp "Really destroy ${vm} snapshot ${snapshot}? (y/N) " answer
+ case $answer in
+ [yY]|[yY][eE][sS]) : ;;
+ *) die 'operation cancelled' ;;
+ esac
+ fi
+
+ local dataset
+ for dataset in $datasets; do
+ zfs destroy -v "$dataset"
+ done
+}
+
+cmd::destroy_template(){
+ local usage='destroy-template [-y] TEMPLATE'
+ local help="Delete a VM template image.
+Options:
+ -y Don't prompt for confirmation
+Notes:
+ A template cannot be deleted while its clones still exist."
+
+ local noconfirm=false answer opt
+
+ while getopts :hy opt; do
+ case $opt in
+ h) cmd::help ;;
+ y) noconfirm=true ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'TEMPLATE not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local template=$1
+
+ template::exists "$template" || die "no such template: ${template}"
+
+ if [ "$noconfirm" != true ]; then
+ read -rp "Really destroy template ${template}? (y/N) " answer
+ case $answer in
+ [yY]|[yY][eE][sS]) : ;;
+ *) die 'operation cancelled' ;;
+ esac
+ fi
+
+ zfs destroy -v -r "${VM_DATASET}/templates/${template}"
+}
+
+cmd::download_iso(){
+ local usage='download-iso URL [NAME]'
+ local help='Download an ISO.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'URL not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ local url=$1 name=${2:-}
+
+ [ -n "$name" ] || name=$(basename "$url" .iso)
+
+ iso::exists "$name" && die "iso already exists: ${name}"
+
+ fetch -o "${VM_HOME}/isos/${name}.iso" "$url"
+}
+
+cmd::download_template(){
+ local usage='download-template URL [NAME]'
+ local help='Download a disk image.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'URL not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ local url=$1 name=${2:-}
+
+ local ext image_ok=false
+ for ext in .raw .raw.xz .qcow2; do
+ if [ -z "${url%%*"$ext"}" ]; then
+ image_ok=true
+ [ -n "$name" ] || name=$(basename "$url" "$ext")
+ break
+ fi
+ done
+
+ [ "$image_ok" = true ] || die "unknown image type: $(basename "$url")"
+ template::exists "$name" && die "template already exists: ${name}"
+
+ local dataset="${VM_DATASET}/templates/${name}" \
+ zvol="/dev/zvol/${VM_DATASET}/templates/${name}"
+
+ # Create a ZFS dataset for the template.
+ zfs create -v -o volblocksize="$ZFS_VOLBLOCKSIZE" $ZFS_OPTS -o volmode=dev -V "$TEMPLATE_ZVOL_SIZE" "$dataset"
+
+ # Extract the template into the zvol, based on its file type.
+ case $url in
+ http://*.raw|https://*.raw)
+ fetch "$url" -o "$zvol" || zfs::cleanup_and_die "$dataset"
+ ;;
+ *.raw)
+ dd if="$url" of="$zvol" bs=1M || zfs::cleanup_and_die "$dataset"
+ ;;
+ http://*.raw.xz|https://*.raw.xz)
+ fetch "$url" -o - | xz -d | dd of="$zvol" bs=1M || zfs::cleanup_and_die "$dataset"
+ ;;
+ *.raw.xz)
+ xz -cd "$url" | dd of="$zvol" bs=1M || zfs::cleanup_and_die "$dataset"
+ ;;
+ http://*.qcow2|https://*.qcow2)
+ fetch "$url" -o "/root/${name}.qcow2" || zfs::cleanup_and_die "$dataset"
+ qemu-img dd -O raw if="/root/${name}.qcow2" of="$zvol" bs=1M || zfs::cleanup_and_die "$dataset"
+ rm "/root/${name}.qcow2"
+ ;;
+ *.qcow2)
+ qemu-img dd -O raw if="$url" of="$zvol" bs=1M || zfs::cleanup_and_die "$dataset"
+ ;;
+ *) # NOTREACHED
+ ;;
+ esac
+
+ # Snapshot the template so it can be cloned.
+ zfs snapshot "${VM_DATASET}/templates/${name}@$(date +%Y-%m-%dT%H:%M:%S)"
+}
+
+cmd::edit(){
+ local usage='edit VM'
+ local help='Edit VM configuration.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+ "$EDITOR" "${VM_HOME}/${vm}/bhyve.conf"
+}
+
+cmd::list(){
+ local usage='list [-t]'
+ local help='List configured VMs.'
+
+ local opt terse=false
+ while getopts :th opt; do
+ case $opt in
+ t) terse=true ;;
+ h) cmd::help ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -eq 0 ] || cmd::usage 'too many arguments'
+
+ local vm status
+ { [ $terse = true ] || echo 'VM STATUS'
+ for vm in $(vm::list); do
+ if [ $terse = true ]; then
+ printf '%s\n' "$vm"
+ else
+ if vm::running "${vm}"; then
+ status=running
+ else
+ status=stopped
+ fi
+ printf '%s %s\n' "$vm" "$status"
+ fi
+ done
+ } | column -t
+}
+
+cmd::list_isos(){
+ local usage='list-isos'
+ local help='List available ISOs.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+ [ $# -gt 0 ] && cmd::usage 'too many arguments'
+
+ local file
+ for file in "${VM_HOME}"/isos/*.iso; do
+ [ -e "$file" ] || continue
+ name=${file##*/}
+ name=${name%.iso}
+ printf '%s\n' "$name"
+ done
+}
+
+cmd::list_snapshots(){
+ local usage='list-snapshots VM'
+ local help='List VM snapshots.
+Options:
+ -d List snapshots from data dataset only
+ -o List snapshots from OS dataset only
+ -t Use terse output'
+
+ local opt terse=false dataset=''
+ while getopts :dhot opt; do
+ case $opt in
+ d) dataset=/data ;;
+ h) cmd::help ;;
+ o) dataset=/os ;;
+ t) terse=true ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+
+ if [ "$terse" = true ]; then
+ zfs list -r -t snapshot -H -o name -s creation "${VM_DATASET}/${vm}${dataset}" 2>/dev/null \
+ | sed 's/^.*\///' \
+ | sort -k1,1 -t@ -s
+ else
+ { echo 'DATASET SNAPSHOT USED REFER'
+ zfs list -r -t snapshot -H -o name,used,refer -s creation "${VM_DATASET}/${vm}${dataset}" 2>/dev/null
+ } | sed 's/^.*\///' \
+ | sort -k1,1 -t@ -s \
+ | tr '@' ' ' \
+ | column -t
+ fi
+}
+
+cmd::list_templates(){
+ local usage='list-templates'
+ local help='list template images.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -eq 0 ] || cmd::usage 'too many arguments'
+
+ ls -1 /dev/zvol/${VM_DATASET}/templates 2>/dev/null
+}
+
+cmd::reprovision(){
+ local usage='reprovision VM TEMPLATE'
+ local help="Wipe and reprovision a VM's OS dataset from a template."
+
+ local opt noconfirm=false running=false
+ while getopts :hy opt; do
+ case $opt in
+ h) cmd::help ;;
+ y) noconfirm=true ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -lt 2 ] && cmd::usage 'TEMPLATE not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ local vm=$1 template=$2
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+ template::exists "$template" || die "no such template: ${template}"
+
+ if [ "$noconfirm" != true ]; then
+ read -rp "Really reprovision ${vm}? (y/N) " answer
+ case $answer in
+ [yY]|[yY][eE][sS]) : ;;
+ *) die 'operation cancelled' ;;
+ esac
+ fi
+
+ # If the vm is running, stop it.
+ if vm::running "$vm"; then
+ running=true
+ vm::stop "$vm"
+ fi
+
+ local snapshot old_size
+
+ # Get the latest snapshot for the template (if not specified).
+ zfs::ensure_snapshot snapshot "${VM_DATASET}/templates/${template}"
+
+ # Stash old disk size.
+ old_size=$(zfs get -Hp -o value volsize "${VM_DATASET}/${vm}/os")
+
+ # Reprovision OS dataset from template.
+ zfs destroy -v -r "${VM_DATASET}/${vm}/os"
+ zfs clone $ZFS_OPTS -o volmode=dev -o volsize="$old_size" "$snapshot" "${VM_DATASET}/${vm}/os"
+
+ # If the jail was running, restart it.
+ if [ "$running" = true ]; then
+ vm::start "$vm"
+ fi
+}
+
+cmd::restart(){
+ local usage='restart VM'
+ local help='Restart a VM.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+
+ vm::exists "$1" || die "no such vm: ${1}"
+ vm::running "$1" || die "vm not running: ${1}"
+
+ vm::stop "$1"
+ vm::start "$1"
+}
+
+cmd::show(){
+ local usage='show VM'
+ local help='Show VM configuration.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+
+ printf -- '------------------------- BHYVE CONFIGURATION -------------------------\n'
+ cat "${VM_HOME}/${vm}/bhyve.conf"
+ printf -- '\n---------------------------- ZFS DATASET -----------------------------\n'
+ zfs list -o name,volsize,used,refer -S name \
+ "${VM_DATASET}/${vm}/os" \
+ "${VM_DATASET}/${vm}/data"
+}
+
+cmd::rollback(){
+ local usage='rollback VM SNAPSHOT'
+ local help='Rollback a VM to a given snapshot.
+Options:
+ -d Rollback the data zvol only
+ -f Attempt rollback even if the jail is running
+ -o Rollback the OS zvol only'
+
+ local opt force=false both=true os_only=false data_only=false
+ while getopts :dfho opt; do
+ case $opt in
+ d) data_only=true; both=false ;;
+ f) force=true ;;
+ h) cmd::help ;;
+ o) os_only=true; both=false ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -lt 2 ] && cmd::usage 'SNAPSHOT not specified'
+ [ $# -gt 2 ] && cmd::usage 'too many arguments'
+ local vm=$1 snapshot=$2
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+
+ if [ "$both" = true ] || [ "$os_only" = true ]; then
+ zfs::dataset_exists "${VM_DATASET}/${vm}/os@${snapshot}" \
+ || die "no such snapshot for ${vm}/os: ${snapshot}"
+ fi
+ if [ "$both" = true ] || [ "$data_only" = true ]; then
+ zfs::dataset_exists "${VM_DATASET}/${vm}/data@${snapshot}" \
+ || die "no such snapshot for ${vm}/data: ${snapshot}"
+ fi
+
+ if vm::running "$vm" && [ "$force" != true ]; then
+ die "vm ${vm} is running, refusing to rollback (-f to override)"
+ fi
+
+ # Rollback the OS disk.
+ if [ "$both" = true ] || [ "$os_only" = true ]; then
+ zfs rollback -r "${VM_DATASET}/${vm}/os@${snapshot}"
+ fi
+
+ # Rollback the data disk.
+ if [ "$both" = true ] || [ "$data_only" = true ]; then
+ zfs rollback -r "${VM_DATASET}/${vm}/data@${snapshot}"
+ fi
+}
+
+cmd::start(){
+ local usage='start [-i ISO] VM'
+ local help='Start a VM.
+Options:
+ -i ISO Boot from an ISO'
+
+ local iso opt
+ while getopts :hi: opt; do
+ case $opt in
+ h) cmd::help ;;
+ i) iso=$OPTARG ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+ vm::running "$vm" && die "vm already running: ${vm}"
+
+ if [ -n "${iso:-}" ]; then
+ iso::exists "$iso" || die "no such iso: ${iso}"
+ fi
+
+ vm::start "$vm" "${iso:-}"
+}
+
+cmd::_start_all(){
+ local usage='_start-all'
+ local help='Start all VMs.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+ [ $# -eq 0 ] || cmd::usage 'too many arguments'
+
+ rc::load_config
+
+ local vm
+ for vm in ${vmctl_list:-}; do
+ echo "vmctl: starting ${vm}"
+ vm::start "$vm"
+ sleep "${vmctl_delay:-$DEFAULT_AUTOSTART_DELAY}"
+ done
+}
+
+cmd::status(){
+ local usage='status VM'
+ local help='Show running VM status.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+ vm::running "$vm" || die "vm not running: ${vm}"
+
+ local pid
+ pid=$(vm::pid "$vm")
+
+ printf -- '----------------------------- BHYVE PROCESS ------------------------------\n'
+ ps -auxdr -p "$pid"
+ printf -- '\n---------------------------- ZFS DATASET -----------------------------\n'
+ zfs list -o name,volsize,used,refer -S name \
+ "${VM_DATASET}/${vm}/os" \
+ "${VM_DATASET}/${vm}/data" \
+ | sed "s|^${VM_DATASET}/${vm}/||" \
+ | column -t
+ printf -- '\n--------------------------- RESOURCE USAGE ---------------------------\n'
+ rctl -h -u "process:${pid}" \
+ | grep -E '^(pcpu|memoryuse|vmemoryuse|swapuse|readbps|writebps|readiops|writeiops)=' \
+ | rs -c= -C' ' -T \
+ | column -t
+}
+
+cmd::stop(){
+ local usage='start VM'
+ local help='Shutdown a VM.
+Options:
+ -f Send SIGKILL'
+
+ local opt signal=TERM
+ while getopts :fh opt; do
+ case $opt in
+ f) signal=KILL ;;
+ h) cmd::help ;;
+ ?) cmd::usage "unknown option: -${OPTARG}" ;;
+ esac
+ done
+ shift $((OPTIND - 1))
+
+ [ $# -lt 1 ] && cmd::usage 'VM not specified'
+ [ $# -gt 1 ] && cmd::usage 'too many arguments'
+ local vm=$1
+
+ vm::exists "$vm" || die "no such vm: ${vm}"
+ vm::running "$vm" || die "vm not running: ${vm}"
+
+ vm::stop "$vm" "$signal"
+}
+
+cmd::_stop_all(){
+ local usage='_stop-all'
+ local help='Stop all VMs.'
+
+ cmd::getopt_help "$@"; shift $((OPTIND - 1))
+ [ $# -eq 0 ] || cmd::usage 'too many arguments'
+
+ rc::load_config
+
+ # Reverse the autostart list.
+ local vm vmctl_list_rev=''
+ for vm in ${vmctl_list:-}; do
+ vmctl_list_rev="${vm} ${vmctl_list_rev}"
+ done
+
+ # Stop the VMs in reverse order.
+ for vm in $vmctl_list_rev; do
+ if vm::running "$vm"; then
+ echo "vmctl: stopping ${vm}"
+ vm::stop "$vm"
+ sleep "${vmctl_delay:-$DEFAULT_AUTOSTART_DELAY}"
+ fi
+ done
+
+ # Stop any other VMs that might be running.
+ for vm in $(vm::list); do
+ if vm::running "$vm"; then
+ echo "vmctl: stopping ${vm}"
+ vm::stop "$vm"
+ fi
+ done
+}
+
+
+################################################################################
+# "Library" functions.
+################################################################################
+interface::add_vlan(){
+ local id=$1
+ ifconfig "vlan${id}" create
+ ifconfig "vlan${id}" vlan "$id" vlandev "$TRUNK_INTERFACE" up
+ ifconfig "bridge${id}" create
+ ifconfig "bridge${id}" addm "vlan${id}" up
+ sysrc -v \
+ "cloned_interfaces+=vlan${id} bridge${id}" \
+ "ifconfig_vlan${id}=vlan ${id} vlandev ${TRUNK_INTERFACE} up" \
+ "ifconfig_bridge${id}=addm vlan${id} up"
+}
+
+interface::derive_mac(){
+ # Derive unique mac addresses for a virtual NIC, based on the physical
+ # interface address + vm name.
+ local iface=$1 name=$2
+ local hwaddr checksum virtual_mac
+
+ # Similar to /usr/share/examples/jails/jib
+ hwaddr=$(ifconfig "$iface" ether | awk '/ether/,$0=$2')
+ # ??:??:??:II:II:II
+ virtual_mac=${hwaddr#??:??:??} # => :II:II:II
+ # => :SS:SS:II:II:II
+ virtual_mac=":$(printf '%s' "$name" | md5 | sed 's/^\(..\)\(..\).*/\1:\2/')${virtual_mac}"
+ # => NP:SS:SS:II:II:II
+ case $hwaddr in
+ ?2:*) virtual_mac="1e${virtual_mac}" ;;
+ ?[Ee]:*) virtual_mac="16${virtual_mac}" ;;
+ *) virtual_mac="1e${virtual_mac}" ;;
+ esac
+
+ # Sanity check for duplicate MAC
+ ifconfig | grep -qE "(ether|hwaddr) ${virtual_mac}" && die "MAC collision when configuring virutal interface!"
+
+ printf '%s' "${virtual_mac}"
+}
+
+interface::exists(){
+ ifconfig "$1" > /dev/null 2>&1
+}
+
+interface::tap::derive_name(){
+ # Generate an tap(4) interface name, based on the VM name.
+ #
+ # The maximum length of a network interface name on FreeBSD is 15 characters.
+ # We use a prefix of 'tap_', leaving 11 characters for a unique suffix for each
+ # VM.
+ #
+ # If the sanitized VM name is less than 11 characters, we'll simply use it
+ # for the suffix. Otherwise, we'll use the last 11 characters of the VM
+ # name's SHA-1 hash.
+ local name=$1 sanitized hash
+ sanitized=$(printf '%s' "$name" | tr -dC '[:alnum:]_')
+
+ if [ "${#sanitized}" -le 11 ]; then
+ printf 'tap_%s' "$sanitized"
+ else
+ hash=$(printf '%s' "$name" | sha1 | tail -c 11)
+ printf 'tap_%s' "$hash"
+ fi
+}
+
+iso::exists(){
+ test -f "${VM_HOME}/isos/${1}.iso"
+}
+
+rc::load_config(){
+ set +eu +o pipefail
+ . /etc/rc.subr
+ load_rc_config vmctl
+ set -eu -o pipefail
+}
+
+template::exists(){
+ zfs list -H "${VM_DATASET}/templates/${1}" > /dev/null 2>&1
+}
+
+vm::exists(){
+ test -f "${VM_HOME}/${1}/bhyve.conf"
+}
+
+vm::list(){
+ for file in "${VM_HOME}"/*/bhyve.conf; do
+ [ -e "$file" ] || continue
+ printf '%s\n' "$(basename "$(dirname "$file")")"
+ done
+}
+
+vm::pid(){
+ pgrep -fx "bhyve: ${1}"
+}
+
+vm::run(){
+ local vm=$1 iso=${2:-} bootdisk zvol iso_args bootcount=0 rc=0
+
+ zvol="/dev/zvol/${VM_DATASET}/${vm}/os"
+ bootdisk=$zvol
+ iso_args=''
+
+ # bhyve exit codes:
+ # 0: reboot
+ # 1: shutdown
+ # >1: error
+ # As long as rc=0, keep rebooting.
+
+ while [ "$rc" -eq 0 ]; do
+ if [ -n "$iso" ]; then
+ # If this is the first boot, or no bootsector exists, boot from ISO.
+ if [ "$bootcount" -eq 0 ] || ! file -bs "$zvol" | grep -q 'boot sector'; then
+ bootdisk="${VM_HOME}/isos/${iso}.iso"
+ iso_args="-s 31:0,ahci-cd,${bootdisk}"
+ else
+ # Otherwise, boot from disk.
+ bootdisk=$zvol
+ iso_args=''
+ fi
+ fi
+
+ bootcount=$(( bootcount + 1 ))
+
+ bhyve -k "${VM_HOME}/${vm}/bhyve.conf" $iso_args "$vm" && rc=$? || rc=$?
+ done
+
+ # If we reach this point, the bhyve process has terminated. Clean up the
+ # /dev/vmm device and tap interface.
+ bhyvectl --vm="${vm}" --destroy
+ ifconfig "$(interface::tap::derive_name "$vm")" destroy
+}
+
+vm::running(){
+ vm::pid "$1" > /dev/null
+}
+
+vm::stop(){
+ local vm=$1 signal=${2:-TERM} pid
+ pid=$(vm::pid "$vm")
+
+ kill -s "$signal" "$pid"
+
+ # Loop until the bhyve process terminates.
+ while kill -0 "$pid" 2>/dev/null; do
+ sleep 1
+ done
+}
+
+vm::start(){
+ local vm=$1 iso=${2:-} vlan newif
+
+ vlan=$(vm::config::get "$vm" vmctl.vlan)
+
+ # Create the tap interface.
+ newif=$(ifconfig tap create)
+ ifconfig "bridge${vlan}" addm "$newif"
+ ifconfig "$newif" name "$(interface::tap::derive_name "$vm")" descr "vm/${vm}"
+
+ # Start the bhyve process in the background.
+ vm::run "$vm" "$iso" > /dev/null 2>&1 &
+}
+
+vm::config::get(){
+ local vm=$1 key=$2
+ awk -F= -v "key=${key}" \
+ '$1 == key {rc=1; print $2} END {exit !rc}' \
+ "${VM_HOME}/${vm}/bhyve.conf" \
+ || die "config value not set for vm ${vm}: ${key}"
+}
+
+zfs::ensure_snapshot(){
+ # If the given zfs dataset is a snapshot, return as-is.
+ # Otherwise, get the latest snapshot from the given dataset and return that.
+ local upvar=$1 dataset=$2 latest
+
+ if [ "${dataset#*@}" = "$dataset" ]; then
+ latest=$(zfs list -t snapshot -o name -s creation -H "$dataset" | tail -1)
+ setvar "$upvar" "$latest"
+ else
+ zfs list -t snapshot -H "$dataset" > /dev/null 2>&1 || die "no such snapshot: ${dataset}"
+ setvar "$upvar" "$dataset"
+ fi
+}
+
+zfs::cleanup_and_die(){
+ zfs destroy -v -r "$1"
+ exit 1
+}
+
+zfs::dataset_exists(){
+ zfs list "$1" > /dev/null 2>&1
+}
+
+
+cmd::main "$@"