From 241833b7f320e7fca84ba226f1ecbb0c963534f7 Mon Sep 17 00:00:00 2001
From: Cullum Smith <cullum@sacredheartsc.com>
Date: Fri, 12 Jul 2024 15:20:54 -0400
Subject: initial commit of hypervisor configs

---
 files/usr/local/sbin/vmctl.freebsd_hypervisor | 1198 +++++++++++++++++++++++++
 1 file changed, 1198 insertions(+)
 create mode 100644 files/usr/local/sbin/vmctl.freebsd_hypervisor

(limited to 'files/usr/local/sbin/vmctl.freebsd_hypervisor')

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 "$@"
-- 
cgit v1.2.3