#!/bin/sh
# SPDX-License-Identifier: BSD-2-Clause
# SPDX-FileCopyrightText: 2016 Matt Churchyard <churchers@gmail.com>

# 'vm list'
# list virtual machines
#
core::list(){
    local _running_only
    local _name _loader _cpu _our_host
    local _memory _run _vm _auto _num _vnc _pid
    local _state _pcpu _rss _uptime
    local _format="%s^%s^%s^%s^%s^%s^%s^%s\n"

    while getopts rv _opt ; do
        case ${_opt} in
            r) _running_only='true' ;;
            v) VM_OPT_VERBOSE="1" ;;
            *) util::usage ;;
        esac
    done

    _our_host=$(hostname)

    vm::running_load

    [ -n "${VM_OPT_VERBOSE}" ] && _format="%s^%s^%s^%s^%s^%s^%s^%5s^%8s^%14s^%s\n";

    # pass everything below here to column(1)
    {
        if [ -n "${VM_OPT_VERBOSE}" ]; then
            printf "${_format}" "NAME" "DATASTORE" "LOADER" "CPU" "MEMORY" "VNC" "AUTO" "%CPU" "RSZ" "UPTIME" "STATE"
        else
            printf "${_format}" "NAME" "DATASTORE" "LOADER" "CPU" "MEMORY" "VNC" "AUTO" "STATE"
        fi

        for _ds in ${VM_DATASTORE_LIST}; do
            datastore::get "${_ds}" || continue

            ls -1 "${VM_DS_PATH}" 2>/dev/null | \
            while read _name; do
                [ ! -e "${VM_DS_PATH}/${_name}/${_name}.conf" ] && continue

                config::load "${VM_DS_PATH}/${_name}/${_name}.conf"
                config::get "_loader" "loader" "none"
                config::get "_cpu" "cpu"
                config::get "_memory" "memory"

                # defaults
                _vnc="-"
                _pid=""
                _state=""
                _pcpu="-"
                _rss="-"
                _uptime="-"

                # check if the guest is running
                if vm::running_check "_run" "_pid" "${_name}" || \
                    [ -e "${VM_DS_PATH}/${_name}/run.lock" -a "$(head -n1 ${VM_DS_PATH}/${_name}/run.lock 2>/dev/null)" = "${_our_host}" ]; then

                    # if running and graphics, try to get vnc port
                    if config::yesno "graphics"; then
                        _vnc=$(grep ^vnc "${VM_DS_PATH}/${_name}/console" 2>/dev/null |cut -d= -f2)
                        [ -z "${_vnc}" ] && _vnc="-"
                    fi
                fi

                if [ -n "${_pid}" -a -n "${VM_OPT_VERBOSE}" ]; then
                    _state=$(ps -o"%cpu"= -o"rss"= -o"etime"= -p "${_pid}")

                    if [ -n "${_state}" ]; then
                        util::get_part "_pcpu" "${_state}" 1
                        util::get_part "_rss" "${_state}" 2
                        util::get_part "_uptime" "${_state}" 3

                        [ -n "${_rss}" ] && _rss=$(info::__bytes_human "${_rss}" 1 2)
                        _uptime=$(echo "${_uptime}" |sed 's/\-/d /')
                    fi
                fi

                _num=1
                _auto="No"

                # find out if we auto-start this vm, and get sequence number
                for _vm in ${vm_list}; do
                    [ "${_vm}" = "${_name}" ] && _auto="Yes [${_num}]"
                    _num=$((_num + 1))
                done

                # if stopped, see if it's locked by another host
                if [ "${_run}" = "Stopped" -a -e "${VM_DS_PATH}/${_name}/run.lock" ]; then
                    _run=$(head -n1 "${VM_DS_PATH}/${_name}/run.lock")
                    _run="Locked (${_run})"
                fi

                if [ -n "${_running_only}" ]; then
                    if [ "${_run}" = "Stopped" -o "${_run}" = "Suspended" ]; then
                        continue
                    fi
                fi

                if [ -n "${VM_OPT_VERBOSE}" ]; then
                    printf "${_format}" "${_name}" "${_ds}" "${_loader}" "${_cpu}" "${_memory}" "${_vnc}" "${_auto}" "${_pcpu}" "${_rss}" "${_uptime}" "${_run}"
                else
                    printf "${_format}" "${_name}" "${_ds}" "${_loader}" "${_cpu}" "${_memory}" "${_vnc}" "${_auto}" "${_run}"
                fi
            done
        done
    } | column -ts^
}

# 'vm create'
# create a new virtual machine
#
# @param optional string (-t) _template the template to use (default = default)
# @param optional string (-s) _size guest size (default = 20G)
# @param string _name the name of the guest to create
#
core::create(){
    local _name _opt _size _vmdir _disk _disk_dev _num=0
    local _zfs_opts _disk_size _template="default" _ds="default" _ds_path _img _cpu _memory _uuid
    local _enable_cloud_init _cloud_init_dir _ssh_public_keys _ssh_public_key _ssh_key_file _network_config _mac

    while getopts d:t:s:i:c:m:Ck:n: _opt ; do
        case $_opt in
            t) _template=${OPTARG} ;;
            s) _size=${OPTARG} ;;
            d) _ds=${OPTARG} ;;
            c) _cpu=${OPTARG} ;;
            m) _memory=${OPTARG} ;;
            i) _img=${OPTARG} ;;
            k) _ssh_key_file=${OPTARG} ;;
            C) _enable_cloud_init='true' ;;
            n) _network_config=${OPTARG} ;;

            *) util::usage ;;
        esac
    done

    shift $((OPTIND - 1))
    _name=$1

    [ -z "${_name}" ] && util::usage

    # check guest name
    util::check_name "${_name}" || util::err "invalid virtual machine name - '${_name}'"
    datastore::get_guest "${_name}" && util::err "virtual machine already exists in ${VM_DS_PATH}/${_name}"
    datastore::get "${_ds}" || util::err "unable to load datastore - '${_ds}'"

    [ ! -f "${vm_dir}/.templates/${_template}.conf" ] && \
        util::err "unable to find template ${vm_dir}/.templates/${_template}.conf"

    # we need to get disk0 name and device type from the template
    config::load "${vm_dir}/.templates/${_template}.conf"
    config::get "_disk" "disk0_name"
    config::get "_disk_dev" "disk0_dev"
    config::get "_disk_size" "disk0_size" "20G"
    config::get "_zfs_opts" "zfs_dataset_opts"

    # make sure template has a disk before we start creating anything
    [ -z "${_disk}" ] && util::err "template is missing disk0_name specification"

    # get ssh public key for cloud-init from file
    if [ -n "${_ssh_key_file}" ]; then

      [ -z "${_enable_cloud_init}" ] && util::err "cloud-init is required for injecting public key. Use -C to enable it."
      [ ! -r "${_ssh_key_file}" ] && util::err "can't read file with public key (${_ssh_key_file})"
      _ssh_public_keys="$(sed -e '/^$/d' -e '/^#/d' "${_ssh_key_file}")"
    fi

    # if we're on zfs, make a new filesystem
    zfs::make_dataset "${VM_DS_ZFS_DATASET}/${_name}" "${_zfs_opts}"

    [ ! -d "${VM_DS_PATH}/${_name}" ] && mkdir "${VM_DS_PATH}/${_name}" >/dev/null 2>&1
    [ ! -d "${VM_DS_PATH}/${_name}" ] && util::err "unable to create virtual machine directory ${VM_DS_PATH}/${_name}"

    cp "${vm_dir}/.templates/${_template}.conf" "${VM_DS_PATH}/${_name}/${_name}.conf"
    [ $? -eq 0 ] || util::err "unable to copy template to virtual machine directory"

    # generate a uuid
    _uuid=$(uuidgen)
    config::set "${_name}" "uuid" ${_uuid}

    # get any zvol options
    config::get "_zfs_opts" "zfs_zvol_opts"

    # generate mac address - it's saved in _mac variable
    vm::generate_static_mac

    # Optional overrides
    [ -n "${_cpu}" ] && config::set "${_name}" "cpu" "${_cpu}"
    [ -n "${_memory}" ] && config::set "${_name}" "memory" "${_memory}"
    # use cmd line size for disk 0 if specified
    [ -n "${_size}" ] && _disk_size="${_size}"

    # create each disk
    while [ -n "${_disk}" ]; do
        case "${_disk_dev}" in
            zvol)
                zfs::make_zvol "${VM_DS_ZFS_DATASET}/${_name}/${_disk}" "${_disk_size}" "0" "${_zfs_opts}"
                [ $_num -eq 0 ] && [ ! -z "$_img" ] && core::write_img "/dev/zvol/${VM_DS_ZFS_DATASET}/${_name}/${_disk}" "${_img}" "${_disk_dev}" "${_disk_size}"
                ;;
            sparse-zvol)
                zfs::make_zvol "${VM_DS_ZFS_DATASET}/${_name}/${_disk}" "${_disk_size}" "1" "${_zfs_opts}"
                [ $_num -eq 0 ] && [ ! -z "$_img" ] && core::write_img "/dev/zvol/${VM_DS_ZFS_DATASET}/${_name}/${_disk}" "${_img}" "${_disk_dev}" "${_disk_size}"
                ;;
            custom)
                ;;
            iscsi)
                ;;
            *)
                truncate -s "${_disk_size}" "${VM_DS_PATH}/${_name}/${_disk}"
                [ $? -eq 0 ] || util::err "failed to create sparse file for disk image"

                # make sure only owner can read the disk image
                chmod 600 "${VM_DS_PATH}/${_name}/${_disk}"
                [ $_num -eq 0 ] && [ ! -z "$_img" ] && core::write_img "${VM_DS_PATH}/${_name}/${_disk}" "${_img}" "file" "${_disk_size}"
                ;;
        esac

        # scrap size option from guest template
        sysrc -inxqf "${VM_DS_PATH}/${_name}/${_name}.conf" "disk${_num}_size"

        # look for another disk
        _num=$((_num + 1))
        config::get "_disk" "disk${_num}_name"
        config::get "_disk_dev" "disk${_num}_dev"
        config::get "_disk_size" "disk${_num}_size" "20G"
    done

    if [ -n "${_enable_cloud_init}" ]; then
        core::create_cloud_init
    fi

    exit 0
}

core::create_cloud_init(){
    local _cloud_init_dir _hostname
    local _network_config_interface _network_config_ipaddress _network_config_gateway4 \
          _network_config_gateway6 _network_config_nameservers _network_config_searchdomains \
          _network_config_hostname

    # create disk with metadata for cloud-init
    _cloud_init_dir="${VM_DS_PATH}/${_name}/.cloud-init"
    # Use VM's name as a hostname by default
    _hostname="${_name}"

    mkdir -p "${_cloud_init_dir}"

    if [ -n "${_network_config}" ]; then
        core::parse_netconfig "${_network_config}"
        core::compile_network_config > "${_cloud_init_dir}/network-config"

        # Override default hostname when network config is passed to cloud-init
        if [ ! -z "${_network_config_hostname}" ]; then
                _hostname="${_network_config_hostname}"
        fi
    fi

    cat << EOF > "${_cloud_init_dir}/meta-data"
instance-id: ${_uuid}
local-hostname: ${_hostname}
EOF

    cat << EOF > "${_cloud_init_dir}/user-data"
#cloud-config
resize_rootfs: True
manage_etc_hosts: localhost
EOF

    if [ -n "${_ssh_public_keys}" ]; then
        cat << EOF >> "${_cloud_init_dir}/user-data"
ssh_authorized_keys:
EOF
        echo "${_ssh_public_keys}" | while read -r _ssh_public_key; do
            cat << EOF >> "${_cloud_init_dir}/user-data"
  - ${_ssh_public_key}
EOF
        done
    fi

    config::set "${_name}" "disk${_num}_type" "ahci-cd"
    config::set "${_name}" "disk${_num}_name" "seed.iso"
    config::set "${_name}" "disk${_num}_dev" "file"
}


# parse cloud-init netconfig string passed via `vm create -C -n [netconfig]`
#
# @private
# @param string netconfig
#
core::parse_netconfig(){
    local _network_config

    _network_config="$1"

    _network_config_interface="$(echo "${_network_config}" | \
        grep -oE 'interface=[^;]*' | sed 's/^interface=//')";
    _network_config_ipaddress="$(echo "${_network_config}" | \
        grep -oE 'ip=[^;]*' | sed 's/^ip=//')"
    _network_config_gateway4="$(echo "${_network_config}" | \
        grep -oE 'gateway4=[^;]*' | sed 's/^gateway4=//')"
    _network_config_gateway6="$(echo "${_network_config}" | \
        grep -oE 'gateway6=[^;]*' | sed 's/^gateway6=//')"
    _network_config_nameservers="$(echo "${_network_config}" | \
        grep -oE 'nameservers=[^;]*' | sed 's/^nameservers=//')"
    _network_config_searchdomains="$(echo "${_network_config}" | \
        grep -oE 'searchdomains=[^;]*' | sed 's/^searchdomains=//')"
    _network_config_hostname="$(echo "${_network_config}" | \
        grep -oE 'hostname=[^;]*' | sed 's/^hostname=//')"

}

# compile cloud-init network-config
#
# @private
#
core::compile_network_config(){
    local _addr _ipv4_found ipv6_found
    local _tmpfile=$(mktemp)

    cat << EOF >> "${_tmpfile}"
version: 2
ethernets:
  id0:
EOF

    echo "    set-name: ${_network_config_interface:-eth0}" >> ${_tmpfile}
    echo "    match:" >> ${_tmpfile}
    echo "      macaddress: ${_mac}" >> ${_tmpfile}

    # IP addresses
    if [ ! -z "${_network_config_ipaddress}" ]; then
        OLDIFS=$IFS
        IFS=,
        for _addr in ${_network_config_ipaddress}; do
            case "${_addr}" in
                *:*)
                    _ipv6_found="1"
                    ;;
                *.*)
                    _ipv4_found="1"
                    ;;

            esac
        done
        IFS=$OLDIFS
        echo "    addresses: [${_network_config_ipaddress}]" >> ${_tmpfile}
    fi

    # use DHCP if no IPv4 address specified
    if [ -z "${_ipv4_found}" ]; then
        echo "    dhcp4: true" >> ${_tmpfile}
    fi

    # use DHCP and RA if no IPv6 specified
    if [ -z "${_ipv6_found}" ]; then
        echo "    dhcp6: true"  >> ${_tmpfile}
        echo "    accept-ra: true"  >> ${_tmpfile}
    fi

    # nameservers & searchdomains
    if [ -n "${_network_config_serachdomains}" -o -n "${_network_config_nameservers}" ]; then
        echo "    nameservers:" >> ${_tmpfile}

        if [ -n "${_network_config_nameservers}" ]; then
            echo "      addresses: [${_network_config_nameservers}]" >> ${_tmpfile}
        fi

        if [ -n "${_searchdomains}" ]; then
            echo "      search: [${_searchdomains}]" >> ${_tmpfile}
        fi
    fi

    # gateway
    if [ -n "${_network_config_gateway4}" -o -n "${_network_config_gateway6}" ]; then
        echo "    routes:" >> ${_tmpfile}

        if [ -n "${_network_config_gateway4}" ]; then
            echo "      - to: 0.0.0.0/0" >> ${_tmpfile}
            echo "        via: ${_network_config_gateway4}" >> ${_tmpfile}
        fi

        if [ -n "${_network_config_gateway6}" ]; then
            echo "      - to: ::/0" >> ${_tmpfile}
            echo "        via: ${_network_config_gateway6}" >> ${_tmpfile}
        fi
    fi


    cat "${_tmpfile}"
    rm -f -- "${_tmpfile}"
}

# write cloud image to disk image
#
# @private
# @param string _disk_dev device to write image to
# @param string _img the img file in $vm_dir/.img to use
#
core::write_img(){
    local _disk_dev _img _imgpath

    cmd::parse_args "$@"
    shift $?
    _disk_dev="${1}"
    _img="$2"
    _disk_type="$3"
    _disk_size="$4"
    timeout=30
    i=0

    # wait a few seconds for newly created zvol device to appear
    while [ $i -lt $timeout ]; do
        if [ ! -r "${_disk_dev}" ]; then
            sleep 1
            i=$(($i+1))
        else
            break
        fi
    done

    # just run start with an iso
    datastore::img_find "_imgpath" "${_img}" || util::err "unable to locate img file - '${_img}'"
    qemu-img dd -O raw if="${_imgpath}" of="${_disk_dev}" bs=1M
    if [ $? -ne 0 ]; then
        util::err "failed to write img file with qemu-img"
    fi

    # if disk type is file then we need to resize it
    if [ "${_disk_type}" = "file" ]; then
        qemu-img resize "${_disk_dev}" "${_disk_size}"
        if [ $? -ne 0 ]; then
            util::err "failed to resize img file with qemu-img"
        fi
    fi
}

# 'vm add'
# add a device to an existing guest
#
# @param string (-d) _device=network|disk the type of device to add
# @param string (-t) _type for disk, the type of disk - file|zvol|sparse-zvol
# @param string (-s) _sopt for disk the size, for network the virtual switch name
# @param string _name name of the guest
#
core::add(){
    local _name _device _type _sopt _opt

    while getopts d:t:s: _opt; do
        case $_opt in
            d) _device=${OPTARG} ;;
            t) _type=${OPTARG} ;;
            s) _sopt=${OPTARG} ;;
            *) util::usage ;;
        esac
    done

    shift $((OPTIND - 1))
    _name="$1"

    # check guest
    [ -z "${_name}" ] && util::usage
    datastore::get_guest "${_name}" || "${_name} does not appear to be a valid virtual machine"

    case "${_device}" in
        disk)    core::add_disk "${_name}" "${_type}" "${_sopt}" ;;
        network) core::add_network "${_name}" "${_sopt}" ;;
        *)       util::err "device must be one of the following: disk network" ;;
    esac
}

# add a disk to guest
# this creates the disk image or zvol and updates configuration file
# we use the same emulation as the existing disk(s)
#
# @private
# @param string _name name of the guest
# @param string _device type of device file|zvol|sparse-zvol
# @param string _size size of the disk to create
#
core::add_disk(){
    local _name="$1"
    local _device="$2"
    local _size="$3"
    local _num=0 _curr _diskname _emulation _zfs_opts

    : ${_device:=file}

    [ -z "${_size}" ] && util::usage

    # get the last existing disk
    config::load "${VM_DS_PATH}/${_name}/${_name}.conf"
    config::get "_zfs_opts" "zfs_zvol_opts"

    while true; do
        config::get "_curr" "disk${_num}_name"
        [ -z "${_curr}" ] && break
        config::get "_emulation" "disk${_num}_type"
        _num=$((_num + 1))
    done

    [ -z "${_emulation}" ] && util::err "failed to get emulation type of the existing guest disks"

    # create the disk first, then update config if no problems
    case "${_device}" in
        zvol)
            zfs::make_zvol "${VM_DS_ZFS_DATASET}/${_name}/disk${_num}" "${_size}" "0" "${_zfs_opts}"
            _diskname="disk${_num}"
            ;;
        sparse-zvol)
            zfs::make_zvol "${VM_DS_ZFS_DATASET}/${_name}/disk${_num}" "${_size}" "1" "${_zfs_opts}"
            _diskname="disk${_num}"
            ;;
        file)
            truncate -s "${_size}" "${VM_DS_PATH}/${_name}/disk${_num}.img"
            [ $? -eq 0 ] || util::err "failed to create sparse file for disk image"
            _diskname="disk${_num}.img"
            ;;
        *)
            util::err "device type must be one of the following: zvol sparse-zvol file"
            ;;
    esac

    # update configuration
    config::set "${_name}" "disk${_num}_name" "${_diskname}"
    config::set "${_name}" "disk${_num}_type" "${_emulation}" "1"
    config::set "${_name}" "disk${_num}_dev" "${_device}" "1"
    [ $? -eq 0 ] || util::err "disk image created but errors while updating guest configuration"
}

# add network interface to guest
#
# @private
# @param string _name name of the guest
# @param string _switch the switch name for this interface
#
core::add_network(){
    local _name="$1"
    local _switch="$2"
    local _num=0 _curr _emulation

    [ -z "${_switch}" ] && util::usage

    config::load "${VM_DS_PATH}/${_name}/${_name}.conf"

    while true; do
        _emulation="${_curr}"
        config::get "_curr" "network${_num}_type"
        [ -z "${_curr}" ] && break
        _num=$((_num + 1))
    done

    # handle no existing network
    : ${_emulation:=virtio-net}

    # update configuration
    config::set "${_name}" "network${_num}_type" "${_emulation}"
    config::set "${_name}" "network${_num}_switch" "${_switch}" "1"
    [ $? -eq 0 ] || util::err "errors encountered while updating guest configuration"
}

# 'vm install'
# install os to a virtual machine
#
# @param string _name the guest to install to
# @param string _iso the iso file in $vm_dir/.iso to use
#
core::install(){
    local _name _iso _fulliso

    cmd::parse_args "$@"
    shift $?
    _name="$1"
    _iso="$2"

    [ -z "${_name}" -o -z "${_iso}" ] && util::usage

    # just run start with an iso
    datastore::iso_find "_fulliso" "${_iso}" || util::err "unable to locate iso file - '${_iso}'"
    core::__start "${_name}" "${_fulliso}"
}

# 'vm startall'
# start all virtual machines listed in rc.conf:$vm_list
#
core::startall(){
    [ -z "${vm_list}" ] && exit
    core::start ${vm_list}
}

# 'vm stopall'
# stop all bhyve instances
# note this will also stop instances not started by vm-bhyve
#
core::stopall(){
    local _pids=$(pgrep -f 'bhyve:' | tr '\n' ' ')
    local _stop_parallel _stop_list _running _list_rev _curr
    local _stop_p_pids _pid

    cmd::parse_args "$@"
    : ${vm_delay:=2}

    # get a list of running bhyve instances
    _running=$(pgrep -lf 'bhyve:' | cut -d" " -f3 | tr '\n' ' ')
    [ -z "${_running}" ] && return 0

    # do we have any guests to stop in order
    if [ -z "${VM_OPT_FORCE}" -a -n "${vm_list}" ]; then
        # reverse the configured start order
        for _curr in ${vm_list}; do
            _list_rev="${_curr} ${_list_rev}"
        done

        # go through each autostart guest
        # if running add to stop list.
        # we are searching in reverse start order, so should get an ordered shutdown
        for _curr in ${_list_rev}; do
            echo "${_running}" | grep -qs "${_curr} "

            if [ $? -eq 0 ]; then
                _stop_list="${_stop_list}${_stop_list:+ }${_curr}"
            fi
        done

        # look for anything running that isn't in the ordered stop list
        for _curr in ${_running}; do
            echo "${_stop_list}" | grep -qs "${_curr}\b"
            if [ $? -ne 0 ]; then
                _stop_parallel="${_stop_parallel}${_stop_parallel:+ }${_curr}"
                util::getpid "_pid" "bhyve: ${_curr}\$"
                [ $? -eq 0 ] && _stop_p_pids="${_stop_p_pids}${_stop_p_pids:+ }${_pid}"
            fi
        done
    else
        # nothing ordered, or a force shutdown
        # just do everything at once
        _stop_parallel="${_running}"
        _stop_p_pids="${_pids}"
    fi

    echo "Beginning shutdown process"

    # have any guests to stop in parallel
    if [ -n "${_stop_p_pids}" ]; then
        echo "  * parallel shutdown of non-ordered guests: ${_stop_parallel}"
        kill ${_stop_p_pids} >/dev/null 2>&1
        sleep 1
        kill ${_stop_p_pids} >/dev/null 2>&1
    fi

    if [ -n "${_stop_list}" ]; then
        _pid=""
        for _curr in ${_stop_list}; do
            [ -n "${_pid}" ] && echo "  * waiting ${vm_delay} second(s)" && sleep ${vm_delay}
            util::getpid "_pid" "bhyve: ${_curr}\$"

            if [ $? -eq 0 ]; then
                echo "  * stopping ${_curr}"
                kill ${_pid} >/dev/null 2>&1
                sleep 1
                kill ${_pid} >/dev/null 2>&1
            fi
        done
    fi

    echo ""
    wait_for_pids ${_pids}
}

# 'vm suspendall'
#
# suspend all bhyve instances
#
# Unlike stopall, this will only suspend instances started by vm-bhyve,
# as it cannot determine where to save the state file for instances
# started by other tools.
#
core::suspendall()
{
    local _pids=$(pgrep -f 'bhyve:' | tr '\n' ' ')
    local _stop_parallel _stop_list _running _list_rev _curr
    local _stop_p_pids _pid

    util::check_bhyve_suspend || util::err "unable to suspend the guest"

    cmd::parse_args "$@"
    : ${vm_delay:=2}

    # get a list of running bhyve instances
    _running=$(pgrep -lf 'bhyve:' | cut -d" " -f3 | tr '\n' ' ')
    [ -z "${_running}" ] && return 0

    # do we have any guests to stop in order
    if [ -z "${VM_OPT_FORCE}" -a -n "${vm_list}" ]; then
        # reverse the configured start order
        for _curr in ${vm_list}; do
            _list_rev="${_curr} ${_list_rev}"
        done

        # go through each autostart guest
        # if running add to stop list.
        # we are searching in reverse start order, so should get an ordered shutdown
        for _curr in ${_list_rev}; do
            echo "${_running}" | grep -qs "${_curr} "

            if [ $? -eq 0 ]; then
                _stop_list="${_stop_list}${_stop_list:+ }${_curr}"
            fi
        done

        # look for anything running that isn't in the ordered stop list
        for _curr in ${_running}; do
            echo "${_stop_list}" | grep -qs "${_curr}\b"
            if [ $? -ne 0 ]; then
                _stop_parallel="${_stop_parallel}${_stop_parallel:+ }${_curr}"
                util::getpid "_pid" "bhyve: ${_curr}\$"
                [ $? -eq 0 ] && _stop_p_pids="${_stop_p_pids}${_stop_p_pids:+ }${_pid}"
            fi
        done
    else
        # nothing ordered, or a force suspend
        # just do everything at once
        _stop_parallel="${_running}"
        _stop_p_pids="${_pids}"
    fi

    # ===== same as core::stopall() up to this line ===

    echo "Beginning shutdown process (suspend)"

    # suspending non-ordered guests
    if [ -n "${_stop_parallel}" ]; then
        echo "  * parallel suspend of non-ordered guests: ${_stop_parallel}"
        for _curr in ${_stop_parallel}; do
            echo "  * suspending ${_curr}"
            core::suspend "${_curr}" >/dev/null 2>&1
        done
    fi

    # suspending ordered guests
    if [ -n "${_stop_list}" ]; then
        for _curr in ${_stop_list}; do
            echo "  * waiting ${vm_delay} second(s)" && sleep ${vm_delay}
            echo "  * suspending ${_curr}"
            core::suspend "${_curr}" >/dev/null 2>&1
        done
    fi

    echo ""
    wait_for_pids ${_pids}
}

# 'vm start'
# start a virtual machine
#
# @param string[multiple] _name the name of the guest(s) to start
#
core::start(){
    local _name
    local _done
    local _rc _rcsum=0

    cmd::parse_args "$@"
    shift $?
    _name="$1"

    [ -z "${_name}" ] && util::usage
    : ${vm_delay:=2}

    # disable foreground/interactive if we're starting more than one
    if [ $# -ge 2 ]; then
        VM_OPT_FOREGROUND=""
        VM_OPT_INTERACTIVE=""
    fi

    while [ -n "${_name}" ]; do
        [ -n "${_done}" ] && echo "Waiting ${vm_delay} second(s)" && sleep ${vm_delay}
        core::__start "${_name}"

        _rc=$?
        if [ ${_rc} -ne 0 ]; then
            _rcsum=$((${_rcsum} + ${_rc}))
        fi
        shift

        _name="$1"
        _done="1"
    done

    # returns the number of VMs that failed to start
    return ${_rcsum}
}

# actually start a virtual machine
#
# @param string _name the name of the guest to start
# @param optional string _iso iso file is this is an install (can only be provided through 'vm install' command)
#
core::__start(){
    local _name="$1"
    local _iso="$2"
    local _cpu _memory _disk _guest _loader _console
    local _tmux_cmd _tmux_name _util _uefi

    [ -z "${_name}" ] && util::usage

    echo "Starting ${_name}"

    # try to find guest
    if ! datastore::get_guest "${_name}"; then
        echo "  ! ${_name} does not seem to be a valid virtual machine"
        return 1
    fi

    echo "  * found guest in ${VM_DS_PATH}/${_name}"

    # confirm we aren't running
    if ! vm::confirm_stopped "${_name}" "1" >/dev/null 2>&1; then
        echo "  ! guest appears to be running already"
        return 1
    fi

    # check basic settings before going into background mode
    config::load "${VM_DS_PATH}/${_name}/${_name}.conf"
    config::get "_cpu" "cpu"
    config::get "_memory" "memory"
    config::get "_loader" "loader"

    # check minimum configuration
    if [ -z "${_cpu}" -o -z "${_memory}" ]; then
        echo "  ! incomplete virtual machine configuration"
        return 1
    fi

    # we can only load freebsd without unrestricted guest support
    if [ -n "${VM_NO_UG}" -a "${_loader}" != "bhyveload" ]; then
        echo "  ! no unrestricted guest support in cpu. only single vcpu FreeBSD guests supported"
        return 1
    fi

    # check loader
    if [ "${_loader}" = "grub" ]; then
        _util=$(which grub-bhyve)

        if [ -z "${_util}" ]; then
            echo "  ! grub requested but sysutils/grub2-bhyve not installed?"
            return 1
        fi
    fi

    # check for tmux
    config::core::get "_console" "console" "nmdm"
    _tmux_cmd=$(which tmux)

    if [ "${_console}" = "tmux" -a -z "${_tmux_cmd}" ]; then
        echo "  ! tmux support enabled but misc/tmux not found"
        return 1
    fi

    if util::check_bhyve_suspend >/dev/null 2>&1 && vm::has_saved_state "${_name}"; then
        echo "  * found saved vm state, resuming..."
    else
        echo "  * booting..."
    fi


    # run background process to actually start bhyve
    # this will run as long as vm is running, including restarting bhyve after guest reboot
    if [ -n "${VM_OPT_FOREGROUND}" ]; then
        $0 _run -f "${_name}" "${_iso}"
    elif [ "${_console}" = "tmux" ]; then
        # can't have dots in tmux session :( (looks like it may use . to separate window.pane)
        # use ~ which we don't normally allow
        _tmux_name=$(echo "${_name}" | tr "." "~")

        # start session and connect if in interactive mode
        if [ -n "${VM_OPT_INTERACTIVE}" ]; then
            ${_tmux_cmd} new -s "${_tmux_name}" $0 _run -tf "${_name}" "${_iso}"
        else
            ${_tmux_cmd} new -ds "${_tmux_name}" $0 _run -tf "${_name}" "${_iso}"
        fi
    elif [ -n "${VM_OPT_INTERACTIVE}" ]; then
        # interactive && console != tmux
        # connect to the console using cu if global console setting not set to tmux
        $0 _run "${_name}" "${_iso}" >/dev/null 2>&1 &
        core::console -w "${_name}"
    else
        $0 _run "${_name}" "${_iso}" >/dev/null 2>&1 &
    fi
}

# 'vm restart'
# restart a guest
# all we do is create a "restart" file which vm-run looks for
#
# @param string _name name of the guest to restart
#
core::restart(){
    local _name="$1"

    datastore::get_guest "${_name}" || util::err "unable to locate specified guest"
    echo "Setting guest restart flag"
    touch "${VM_DS_PATH}/${_name}/restart" >/dev/null 2>&1
    core::stop "${_name}"
}

# 'vm stop'
# send a kill signal to the specified guest
#
# @param string[multiple] _name name of the guest to stop
#
core::stop(){
    local _name="$1"
    local _pid _loadpid

    [ -z "${_name}" ] && util::usage

    while [ -n "${_name}" ]; do
        if [ ! -e "/dev/vmm/${_name}" ]; then
            util::warn "${_name} doesn't appear to be a running virtual machine"
        else
            _pid=$(pgrep -fx "bhyve: ${_name}")
            _loadpid=$(pgrep -fl "grub-bhyve|bhyveload" | grep " ${_name}\$" |cut -d' ' -f1)

            if [ -n "${_pid}" ]; then
                echo "Sending ACPI shutdown to ${_name}"
                kill "${_pid}" >/dev/null 2>&1
                sleep 1
                kill "${_pid}" >/dev/null 2>&1
                # remove saved vm state after normal shutdown
                core::discard -f "${_name}"
            elif [ -n "${_loadpid}" ]; then
                if util::confirm "Guest ${_name} is in bootloader stage, do you wish to force exit"; then
                    echo "Killing ${_name}"
                    kill "${_loadpid}"
                    bhyvectl --destroy --vm=${_name} >/dev/null 2>&1
                fi
            else
                util::warn "unable to locate process id for ${_name}"
            fi
        fi
        shift
        _name="$1"
    done
}

core::suspend(){
    local _name="$1"
    local _pid _loadpid

    util::check_bhyve_suspend || util::err "unable to suspend the guest"

    [ -z "${_name}" ] && util::usage

    datastore::get_guest "${_name}" || util::err "${_name} doesn't appear to be a valid virtual machine"

    while [ -n "${_name}" ]; do
        if [ ! -e "/dev/vmm/${_name}" ]; then
            util::warn "${_name} doesn't appear to be a running virtual machine"
        else
            _pid=$(pgrep -fx "bhyve: ${_name}")
            _loadpid=$(pgrep -fl "grub-bhyve|bhyveload" | grep " ${_name}\$" |cut -d' ' -f1)

            if [ -n "${_pid}" ]; then
                echo "Suspending ${_name}"
                # TODO: convert to function
                hostname > "${VM_DS_PATH}/${_name}/suspending.lock"
                bhyvectl --vm=${_name} --suspend="${VM_DS_PATH}/${_name}/${_name}.save" >/dev/null 2>&1
            elif [ -n "${_loadpind}" ]; then
                if util::confirm "Guest ${_name} is in bootloader stage and cannot suspend, do you wish to force exit"; then
                    echo "Killing ${_name}"
                    kill "${_loadpid}"
                    bhyvectl --destroy --vm=${_name} >/dev/null 2>&1
                fi
            else
                util::warn "unable to locate process id for ${_name}"
            fi
        fi
        shift
        _name="$1"
    done
}

# 'vm reset'
# force reset
#
# @param string _name name of the guest
#
core::reset(){
    local _name

    cmd::parse_args "$@"
    shift $?
    _name="$1"

    [ -z "${_name}" ] && util::usage
    [ ! -e "/dev/vmm/${_name}" ] && util::err "${_name} doesn't appear to be a running virtual machine"

    if [ -z "${VM_OPT_FORCE}" ]; then
        util::confirm "Are you sure you want to forcefully reset this virtual machine" || exit 0
    fi

    bhyvectl --force-reset --vm=${_name}
}

# 'vm poweroff'
# force poweroff
#
# @param string _name name of the guest
#
core::poweroff(){
    local _name

    cmd::parse_args "$@"
    shift $?
    _name="$1"

    [ -z "${_name}" ] && util::usage
    [ ! -e "/dev/vmm/${_name}" ] && util::err "${_name} doesn't appear to be a running virtual machine"

    if [ -z "${VM_OPT_FORCE}" ]; then
        util::confirm "Are you sure you want to forcefully poweroff this virtual machine" || exit 0
    fi

    # remove saved vm state on forceful poweroff
    bhyvectl --force-poweroff --vm=${_name} && core::discard -f "${_name}"
}

# 'vm destroy'
# completely remove a guest
#
# @param string _name name of the guest
#
core::destroy(){
    local _name

    cmd::parse_args "$@"
    shift $?
    _name="$1"

    [ -z "${_name}" ] && util::usage

    # trying to remove a snapshot?
    echo "${_name}" | grep -qs "@"

    if [ $? -eq 0 ]; then
        zfs::remove_snapshot "${_name}"
        exit $?
    fi

    # make sure it's stopped!
    datastore::get_guest "${_name}" || util::err "${_name} doesn't appear to be a valid virtual machine"
    vm::confirm_stopped "${_name}" || exit 1

    if [ -z "${VM_OPT_FORCE}" ]; then
        util::confirm "Are you sure you want to completely remove this virtual machine" || exit 0
    fi

    [ -n "${VM_DS_ZFS_DATASET}" ] && zfs::destroy_dataset "${VM_DS_ZFS_DATASET:?}/${_name:?}"
    [ -e "${VM_DS_PATH}/${_name}" ] && rm -R "${VM_DS_PATH:?}/${_name:?}"

    exit 0
}

# 'vm discard'
# discard the saved state of a guest
#
# @param string _name name of the guest
#
core::discard(){
    local _name _rc=0

    cmd::parse_args "$@"
    shift $?
    _name="$1"

    [ -z "${_name}" ] && util::usage

    # core::discard only accepts one target guest
    [ ! -z "$2" ] && util::usage

    datastore::get_guest "${_name}" || util::err "${_name} doesn't appear to be a valid virtual machine"

    if [ -z "${VM_OPT_FORCE}" ]; then
        util::confirm "Are you sure you want to remove the saved state of this virtual machine" || exit 0
    fi

    if ! vm::has_saved_state "${_name}"; then
        if [ -z "${VM_OPT_FORCE}" ]; then
            util::err "no saved state exists for ${_name}"
        else
            # When VM_OPT_FORCE is set, do not error if no saved state exists
            return 0
        fi
    fi

    # remove vm state file
    find "${VM_DS_PATH}/${_name}" -name "${_name}.save*" -type f -delete

    exit $?
}

# 'vm rename'
# rename an existing guest
#
# @param string _old the existing guest name
# @param string _new the new guest name
#
core::rename(){
    local _old="$1"
    local _new="$2"
    local _mv_failed

    [ -z "${_old}" -o -z "${_new}" ] && util::usage
    util::check_name "${_new}" || util::err "invalid virtual machine name - '${_name}'"

    datastore::get_guest "${_new}" && util::err "directory ${VM_DS_PATH}/${_new} already exists"
    datastore::get_guest "${_old}" || util::err "${_old} doesn't appear to be a valid virtual machine"

    # confirm guest stopped
    vm::confirm_stopped "${_old}" || exit 1

    # rename zfs dataset
    zfs::rename_dataset "${_old}" "${_new}"

    # rename folder if it still exists (shouldn't if zfs mode and rename worked)
    if [ -d "${VM_DS_PATH}/${_old}" ]; then
        mv "${VM_DS_PATH}/${_old}" "${VM_DS_PATH}/${_new}" >/dev/null 2>&1
        [ $? -eq 0 ] || util::err "failed to rename guest directory"
    fi

    # rename config file
    mv "${VM_DS_PATH}/${_new}/${_old}.conf" "${VM_DS_PATH}/${_new}/${_new}.conf" >/dev/null 2>&1
    [ $? -eq 0 ] || util::err "changed guest directory but failed to rename configuration file"

    # rename saved vm state file
    _mv_failed=0
    (
        cd "${VM_DS_PATH}/${_new}" || exit 1
        find . -type f -depth 1 -name "${_old}.save*" | while read -r oldf; do
            newf="$(echo "${oldf}" | sed "s|${_old}|${_new}|" )"
            echo mv "${oldf} ${newf}" || _mv_failed=1
        done
    )
    [ "${_mv_failed}" -eq 0 ] || util::err "changed guest directory but failed to rename saved vm state file"
}

# 'vm console'
# connect to the console (nmdm) of the specified guest
# we store the nmdm for com1 & com2 in $vm_dir/{guest}/console
# if no port is specified, we use the first one that is specified in the configuration file
# so if comports="com2 com1", it will connect to com2
# the boot loader always using the nmdm device of the first com port listed
#
# @param string _name name of the guest
# @param string _port the port to connect to (default = first in configuration)
#
core::console(){
    local _name _port
    local _console _tmux _tmux_cmd _wait_for_vm

    while getopts w _opt ; do
        case $_opt in
            w) _wait_for_vm="1" ;;
            *) util::usage ;;
        esac
    done

    shift $((OPTIND - 1))

    _name="$1"
    _port="$2"

    [ -z "${_name}" ] && util::usage

    datastore::get_guest "${_name}" || util::err "${_name} doesn't appear to be a valid virtual machine"

    if [ -n "${_wait_for_vm}" ]; then
        echo "Waiting for ${_name} to start..."
        # wait for the VM to start; /dev/vmm/${_name} usually appears within 50 ms
        sleep 0.05
        while [ ! -e "/dev/vmm/${_name}" ]; do
            sleep 1
        done
    else
        [ ! -e "/dev/vmm/${_name}" ] && util::err "${_name} doesn't appear to be a running virtual machine"
    fi

    if [ -e "${VM_DS_PATH}/${_name}/console" ]; then

        # did user specify a com port?
        # if not, get first in the file (the first will also be the console used for loader)
        if [ -n "${_port}" ]; then
            _console=$(grep "${_port}=" "${VM_DS_PATH}/${_name}/console" | cut -d= -f2)
        else
            _console=$(head -n 1 "${VM_DS_PATH}/${_name}/console" | grep "^com" | cut -d= -f2)
        fi
    fi

    # is this a tmux console?
    if [ "${_console%%/*}" = "tmux" ]; then
        _tmux_cmd=$(which tmux)

        if [ -n "${_tmux_cmd}" ]; then
            _tmux=$("${_tmux_cmd}" ls |grep "^${_name}:")

            if [ -n "${_tmux}" ]; then
                ${_tmux_cmd} attach -t ${_console##*/}
                exit
            fi
        fi
    fi

    [ -z "${_console}" ] && util::err "unable to locate console device for this virtual machine"
    cu -l "${_console}"
}

# 'vm configure'
# configure a machine (edit the configuration file)
#
# @param string _name name of the guest
#
core::configure(){
    local _name="$1"
    local _target="$2"

    [ -z "${_name}" ] && util::usage
    [ -z "${EDITOR}" ] && EDITOR="vi"

    datastore::get_guest "${_name}" || \
        util::err "cannot locate configuration file for virtual machine: ${_name}"

    case "$_target" in
        meta-data|network-config|user-data)
            [ -d "${VM_DS_PATH}/${_name}/.cloud-init" ] || \
                util::err "cloud-init is not enabled for this guest"
            if [ -e "${VM_DS_PATH}/${_name}/seed.iso" ]; then
                util::confirm "cloud-init has run already. changes won't take effect. edit anyway?" || exit 0
            fi

            $EDITOR "${VM_DS_PATH}/${_name}/.cloud-init/${_target}"
            ;;
        "")
            $EDITOR "${VM_DS_PATH}/${_name}/${_name}.conf"
            ;;
        *)
            util::usage
            ;;
    esac
}

# 'vm iso'
# list iso images or get a new one
#
# @param string _url if specified, the url will be fetch'ed into $vm_dir/.iso
#
core::iso(){
    local _url _ds="default"

    while getopts d:u _opt ; do
        case $_opt in
            d) _ds=${OPTARG} ;;
            *) util::usage ;;
        esac
    done

    shift $((OPTIND - 1))
    _url=$1

    if [ -n "${_url}" ]; then
        datastore::get_iso "${_ds}" || util::err "unable to locate path for datastore '${_ds}'"
        fetch -o "${VM_DS_PATH}" "${_url}"
    else
        datastore::iso_list
    fi
}

# uncompress cloud image
#
# @private
# @param string _filepath path to file to decompress
#
core::decompress(){
    local _filepath

    cmd::parse_args "$@"
    shift $?
    _filepath="${1}"

    if echo "${_filepath}" | grep "\.xz$" > /dev/null; then
        xz -d "${_filepath}"
    elif echo "${_filepath}" | grep "\.tar\.gz$" > /dev/null; then
        tar Szxf "${_filepath}" -C "$(dirname "${_filepath}")"
        rm -f "${_filepath}"
    elif echo "${_filepath}" | grep "\.gz$" > /dev/null; then
        gunzip "${_filepath}"
    fi
}

# 'vm img'
# list cloud images or get a new one
#
# @param string _url if specified, the url will be fetch'ed into $vm_dir/.img
#
core::img(){
    local _url _ds="default" _filename
    if ! which qemu-img > /dev/null; then
        util::err "Error: qemu-img is required to work with cloud images! Run 'pkg install qemu-tools'."
    fi

    while getopts d:u _opt ; do
        case $_opt in
            d) _ds=${OPTARG} ;;
            *) util::usage ;;
        esac
    done

    shift $((OPTIND - 1))
    _url=$1

    if [ -n "${_url}" ]; then
        datastore::get_img "${_ds}" || util::err "unable to locate path for datastore '${_ds}'"
        _filename=$(basename "${_url}")
        fetch -o "${VM_DS_PATH}" "${_url}"
        core::decompress "${VM_DS_PATH}/${_filename}"
    else
        datastore::img_list
    fi
}

# 'vm passthru'
# show a list of available passthrough devices
# and their device number
#
core::passthru(){
    local _dev _sbf _desc _ready
    local _format="%-10s %-12s %-12s %s\n"

    printf "${_format}" "DEVICE" "BHYVE ID" "READY" "DESCRIPTION"

    pciconf -l | awk -F'[@:]' '{ print $1,$3 "/" $4 "/" $5}' | \
    while read _dev _sbf; do

        _ready=$(echo "${_dev}" | grep ^ppt)
        [ -n "${_ready}" ] && _ready="Yes"

        _desc=$(pciconf -lv | grep -A2 "^${_dev}@" | tail -n1 | grep device | cut -d\' -f2)
        printf "${_format}" "${_dev}" "${_sbf}" "${_ready:-No}" "${_desc:--}"
    done
}

# 'vm get'
# get a core configuration setting
#
core::get(){
    local _var="$1"
    local _val _format="%-20s%s\n"
    local IFS=";"

    printf "${_format}" "SETTING" "VALUE"

    if [ "${_var}" = "all" ]; then

        for _var in ${VM_CONFIG_USER}; do
            config::core::get "_val" "${_var}"
            printf "${_format}" "${_var}" "${_val:--}"
        done
    else
        while [ -n "${_var}" ]; do
            if util::valid_config_setting "${_var}"; then
                config::core::get "_val" "${_var}"
                printf "${_format}" "${_var}" "${_val:--}"
            fi

            shift
            _var="$1"
        done
    fi
}

# 'vm set'
# set a core configuration setting
#
core::set(){
    local _pair="$1"
    local _key _val

    while [ -n "${_pair}" ]; do
        _key="${_pair%%=*}"
        _val="${_pair#*=}"

        if util::valid_config_setting "${_key}"; then
            config::core::set "${_key}" "${_val}"
        else
            util::err "invalid configuration setting - '${_key}'"
        fi

        shift
        _pair="$1"
    done
}
