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

# set us up for zfs use
# we need the zfs dataset name for zfs commands, and the file system path
# for bhyve. It's highly possible these will be different.
# We ask user to specify the dataset name as "zfs:pool/dataset"
# This can then be used for zfs commands, and we can retrieve the mountpoint
# to find out the file path for bhyve
#
# This then overwrites $vm_dir with the file system path, so that the
# rest of vm-bhyve can work normally, regardless of whether we are in zfs mode
# or not
#
# If zfs is enabled, the following global variables are set
# VM_ZFS=1
# VM_ZFS_DATASET={pool/dataset}
#
# @modifies VM_ZFS VM_ZFS_DATASET vm_dir
#
zfs::init(){

    # check for zfs storage location
    # user should specify "zfs:pool/dataset" if they want ZFS support
    if [ "${vm_dir%%:*}" = "zfs" ]; then

        # check zfs running
        kldstat -qm zfs >/dev/null 2>&1
        [ $? -eq 0 ] || util::err "ZFS support requested but ZFS not available"

        # global zfs details
        VM_ZFS="1"
        VM_ZFS_DATASET="${vm_dir#*:}"

        # update vm_dir
        # this makes sure it exists, confirms it's mounted & gets correct path in one go
        vm_dir=$(mount | grep "^${VM_ZFS_DATASET} " |cut -d' ' -f3)
        [ -z "${vm_dir}" ] && util::err "unable to locate mountpoint for ZFS dataset ${VM_ZFS_DATASET}"
    fi
}

# make a new dataset
# this is always called when creating a new vm, but will do nothing
#
# @param string _name name of the dataset to create
#
zfs::make_dataset(){
    local _name="$1"
    local _opts="$2"

    if [ -n "${_name}" -a "${VM_DS_ZFS}" = "1" ]; then
        zfs::__format_options "_opts" "${_opts}"
        zfs create ${_opts} "${_name}"
        [ $? -eq 0 ] || util::err "failed to create new ZFS dataset ${_name}"
    fi
}

# destroy a dataset
#
# @param string _name name of the dataset to destroy
#
zfs::destroy_dataset(){
    local _name="$1"

    if [ -n "${_name}" -a "${VM_DS_ZFS}" = "1" ]; then
        zfs destroy -rf "${_name}" >/dev/null 2>&1
        [ $? -eq 0 ] || util::err "failed to destroy ZFS dataset ${_name}"
    fi
}

# rename a dataset
# as with other zfs functions, the arguments should just be the name
# of the dataset under $VM_ZFS_DATASET (in this case just guest name)
#
# @param string _old the name of the dataset to rename
# @param string _new the new name
#
zfs::rename_dataset(){
    local _old="$1"
    local _new="$2"

    if [ -n "${_old}" -a -n "${_new}" -a "${VM_DS_ZFS}" = "1" ]; then
        zfs rename "${VM_DS_ZFS_DATASET}/${_old}" "${VM_DS_ZFS_DATASET}/${_new}" >/dev/null 2>&1
        [ $? -eq 0 ] || util::err "failed to rename ZFS dataset ${VM_DS_ZFS_DATASET}/${_old}"
    fi
}

# make a zvol for a guest disk image
#
# @param string _name name of the zvol to create
# @param string _size how big to create the dataset
# @param int _sparse=0 set to 1 for a sparse zvol
#
zfs::make_zvol(){
    local _name="$1"
    local _size="$2"
    local _sparse="$3"
    local _user_opts="$4"
    local _opt="-V"

    [ ! "${VM_DS_ZFS}" = "1" ] && util::err "cannot use ZVOL storage unless ZFS support is enabled"
    [ "${_sparse}" = "1" ] && _opt="-sV"

    zfs::__format_options "_user_opts" "${_user_opts}"
    zfs create ${_opt} ${_size} -o volmode=dev ${_user_opts} "${_name}"
    [ $? -eq 0 ] || util::err "failed to create new ZVOL ${_name}"
}

# format options for zfs commands
# options are stored in configuration separated by a space
# we need to replace that with -o
#
# @modifies $_val
#
zfs::__format_options(){
    local _val="$1"
    local _c_opts="$2"

    if [ -n "${_c_opts}" ]; then
        _c_opts=$(echo "${_c_opts}" |sed -e 's/\ / -o /')
        _c_opts="-o ${_c_opts}"
        setvar "${_val}" "${_c_opts}"
        return 0
    fi

    setvar "${_val}" ""
}

# 'vm snapshot'
# create a snapshot of a guest
# specify the snapshot name in zfs format guest@snap
# if no snapshot name is specified, Y-m-d-H:M:S will be used
#
# @param flag (-f) force snapshot if guest is running
# @param string _name the name of the guest to snapshot
#
zfs::snapshot(){
    local _name _snap

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

    # try to get snapshot name
    # we support normal zfs syntax for this
    echo "${_name}" | grep -qs "@"

    if [ $? -eq 0 ]; then
        _snap=${_name##*@}
        _name=${_name%%@*}
    fi

    [ -z "${_name}" ] && util::usage
    datastore::get_guest "${_name}" || util::err "${_name} does not appear to be an existing virtual machine"
    [ ! "${VM_DS_ZFS}" = "1" ] && util::err "cannot snapshot guests on non-zfs datastores"
    [ -z "${_snap}" ] && _snap=$(date +"%Y-%m-%d-%H:%M:%S")

    if ! vm::confirm_stopped "${_name}" >/dev/null; then
        [ -z "${VM_OPT_FORCE}" ] && util::err "${_name} must be powered off first (use -f to override)"
    fi

    zfs snapshot -r ${VM_DS_ZFS_DATASET}/${_name}@${_snap}
    [ $? -eq 0 ] || util::err "failed to create recursive snapshot of virtual machine"
}

# try to remove a snapshot
#
# @param string _name the guest name and snapshot (guest@snap)
# @return true if successful
#
zfs::remove_snapshot(){
    local _name="$1"
    local _snap

    # split name and snapshot
    _snap=${_name##*@}
    _name=${_name%%@*}

    # try to load guest
    datastore::get_guest "${_name}" || util::err "${_name} does not appear to be an existing virtual machine"
    [ ! "${VM_DS_ZFS}" = "1" ] && util::err "cannot snapshot guests on non-zfs datastores"

    # remove
    zfs destroy -r ${VM_DS_ZFS_DATASET}/${_name}@${_snap}
    [ $? -eq 0 ] || util::err "failed to remove snapshot ${VM_DS_ZFS_DATASET}/${_name}@${_snap}"
}

# 'vm rollback'
# roll a guest back to a previous snapshot
# we show zfs errors here as it will fail if the snapshot is not the most recent.
# zfs will output an error mentioning to use '-r', and listing the snapshots
# that will be deleted. makes sense to let user see this and just support
# that option directly
#
# @param flag (-r) force deletion of more recent snapshots
# @param string _name name of the guest
#
zfs::rollback(){
    local _name _snap _opt _force _fs _snap_exists

    while getopts r _opt; do
        case $_opt in
            r) _force="-r" ;;
            *) util::usage ;;
        esac
    done

    shift $((OPTIND - 1))

    _snap_exists=$(echo "${1}" | grep "@")
    [ -z "${_snap_exists}" ] && util::err "a snapshot name must be provided in guest@snapshot format"

    _name="${1%%@*}"
    _snap="${1##*@}"

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

    datastore::get_guest "${_name}" || util::err "${_name} does not appear to be an existing virtual machine"
    [ ! "${VM_DS_ZFS}" = "1" ] && util::err "cannot rollback guests on non-zfs datastores"

    vm::confirm_stopped "${_name}" || exit 1

    # list all datasets and zvols under guest
    zfs list -o name -rHt filesystem,volume ${VM_DS_ZFS_DATASET}/${_name} | \
    while read _fs; do
        zfs rollback ${_force} ${_fs}@${_snap}
        [ $? -ne 0 ] && exit $?
    done
}

# 'vm clone'
# clone a vm
# this makes a true zfs clone of the specifies guest
#
# @param string _old the guest to clone
# @param string _new name of the new guest
#
zfs::clone(){
    local _old="$1"
    local _name="$2"
    local _fs _newfs _snap _snap_exists _fs_list _entry
    local _num=0 _error=0
    local _uuid=$(uuidgen)

    # check args and make sure new guest doesn't already exist
    [ -z "${_old}" -o -z "${_name}" ] && util::usage
    datastore::get_guest "${_name}" && util::err "new guest already exists in ${VM_DS_PATH}/${_name}"

    # try to get snapshot name
    # we support normal zfs syntax for this
    _snap_exists=$(echo "${_old}" | grep "@")

    if [ -n "${_snap_exists}" ]; then
        _snap=${_old##*@}
        _old=${_old%%@*}
    fi

    # make sure old guest exists
    datastore::get_guest "${_old}" || util::err "${_old} does not appear to be an existing virtual machine"
    [ ! "${VM_DS_ZFS}" = "1" ] && util::err "cannot clone guests on non-zfs datastores"

    # get list of datasets to copy
    _fs_list=$(zfs list -rHo name -t filesystem,volume "${VM_DS_ZFS_DATASET}/${_old}")
    [ $? -eq 0 ] || util::err "unable to list datasets for ${VM_DS_ZFS_DATASET}/${_old}"

    # generate a short uuid and create snapshot if no custom snap given
    if [ -z "${_snap}" ]; then
        vm::confirm_stopped "${_old}" || exit 1
        vm::has_saved_state "${_old}" && util::err "guest appears to be suspended, stop it before cloning"
        _snap=$(echo "${_uuid}" |awk -F- '{print $1}')

        zfs snapshot -r "${VM_DS_ZFS_DATASET}/${_old}@${_snap}"
        [ $? -eq 0 ] || util::err "failed to create snapshot ${VM_DS_ZFS_DATASET}/${_old}@${_snap}"
    else
        for _fs in ${_fs_list}; do
            zfs get creation "${_fs}@${_snap}" >/dev/null 2>&1
            [ $? -eq 0 ] || util::err "snapshot ${_fs}@${_snap} doesn't seem to exist"
        done
    fi

    # clone
    for _fs in ${_fs_list}; do
        _newfs=$(echo "${_fs}" | sed "s@${VM_DS_ZFS_DATASET}/${_old}@${VM_DS_ZFS_DATASET}/${_name}@")

        zfs clone "${_fs}@${_snap}" "${_newfs}"
        [ $? -eq 0 ] || util::err "error while cloning dataset ${_fs}@${_snap}"
    done

    # update new guest files
    unlink "${VM_DS_PATH}/${_name}/vm-bhyve.log" >/dev/null 2>&1
    mv "${VM_DS_PATH}/${_name}/${_old}.conf" "${VM_DS_PATH}/${_name}/${_name}.conf" >/dev/null 2>&1
    if [ $? -ne 0 ]; then
        echo "Unable to rename configuration file to ${_name}.conf"
        echo "This will need to be renamed manually"
        echo "Please also remove any uuid or mac address settings, these will be regenerated automatically"
        exit 1
    fi

    # generalise the clone
    vm::generalise "${_name}"
}

# 'vm image create'
# create an image of a vm
# this creates an archive of the specified guest, stored in $vm_dir/images
# we use a uuid just in case we want to provide the ability to share images at any point
#
# @param optional string (-d) description of the image
# @param string _name name of guest to take image of
#
zfs::image_create(){
    local _name _opt _desc
    local _uuid _snap _date _no_compress _filename
    local _compress _decompress

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

    shift $((OPTIND - 1))
    _name=$1
    _uuid=$(uuidgen)
    _snap=${_uuid%%-*}
    _date=$(date -Iseconds)

    [ -z "${_desc}" ] && _desc="No description provided"

    datastore::get_guest "${_name}" || util::err "${_name} does not appear to be a valid virtual machine"
    [ -z "${VM_ZFS}" -o -z "${VM_DS_ZFS}" ] && util::err "this command is only supported on zfs datastores"

    # create the image dataset if we don't have it
    if [ ! -e "${vm_dir}/images" ]; then
        zfs create "${VM_ZFS_DATASET}/images" >/dev/null 2>&1
        [ $? -eq 0 ] || util::err "failed to create image store ${VM_ZFS_DATASET}/images"
    fi

    # try to snapshot
    zfs snapshot -r "${VM_DS_ZFS_DATASET}/${_name}@${_snap}" >/dev/null 2>&1
    [ $? -eq 0 ] || util::err "failed to create snapshot of source dataset ${VM_DS_ZFS_DATASET}/${_name}@${_snap}"

    # copy source
    if [ -n "${_no_compress}" ]; then
        _filename="${_uuid}.zfs"

        echo "Creating guest image, this may take some time..."
        zfs send -R "${VM_DS_ZFS_DATASET}/${_name}@${_snap}" > "${vm_dir}/images/${_filename}"
    else
        _filename="${_uuid}.zfs.z"

        config::core::get "_compress" "compress"
        config::core::get "_decompress" "decompress"

        # use defaults if either of these settings are missing
        # no point using user defined compress if we don't know how to decompress
        if [ "${_compress}" = "" -o "${_decompress}" = "" ]; then
            _compress="xz -T0"
            _decompress="xz -d"
        fi

        echo "Creating a compressed image, this may take some time..."
        zfs send -R "${VM_DS_ZFS_DATASET}/${_name}@${_snap}" | ${_compress} > "${vm_dir}/images/${_filename}"
    fi

    [ $? -ne 0 ] && exit 1

    # done with the source snapshot
    zfs destroy -r ${VM_DS_ZFS_DATASET}/${_name}@${_snap}

    # create a description file
    sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" "description=${_desc}" >/dev/null 2>&1
    sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" "created=${_date}" >/dev/null 2>&1
    sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" "name=${_name}" >/dev/null 2>&1
    sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" "filename=${_filename}" >/dev/null 2>&1
    sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" "decompress=${_decompress}" >/dev/null 2>&1

    echo "Image of ${_name} created with UUID ${_uuid}"
}

# 'vm image provision'
# create a new vm from an image
#
# @param string _uuid the uuid of the image to use
# @param string _name name of the new guest
#
zfs::image_provision(){
    local _uuid _name _file _oldname _entry _num=0 _type
    local _datastore="default" _decompress

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

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

    [ -z "${_uuid}" -o -z "${_name}" ] && util::usage
    [ ! -e "${vm_dir}/images/${_uuid}.manifest" ] && util::err "unable to locate image with uuid ${_uuid}"
    datastore::get_guest "${_name}" && util::err "new guest already exists in ${VM_DS_PATH}/${_name}"

    # get the data filename
    _file=$(sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" filename)
    _type=${_file##*.}

    _oldname=$(sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" name)
    [ -z "${_file}" -o -z "${_oldname}" ] && util::err "unable to locate required details from the specified image manifest"
    [ ! -e "${vm_dir}/images/${_file}" ] && util::err "image data file does not exist: ${vm_dir}/images/${_file}"

    # get the datastore to create on
    datastore::get "${_datastore}" || util::err "unable to locate datastore '${_datastore}'"

    # try to recieve
    echo "Unpacking guest image, this may take some time..."

    # check format of image
    case ${_type} in
        zfs) cat "${vm_dir}/images/${_file}" | zfs recv "${VM_DS_ZFS_DATASET}/${_name}" ;;
        xz)  xz -dc "${vm_dir}/images/${_file}" 2>/dev/null | zfs recv "${VM_DS_ZFS_DATASET}/${_name}" ;;
        z)   _decompress=$(sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" decompress)
             [ -z "${_decompress}" ] && util::err "unable to locate decompression configuration"
             ${_decompress} <"${vm_dir}/images/${_file}" 2>/dev/null | zfs recv "${VM_DS_ZFS_DATASET}/${_name}" ;;
        *)   util::err "unsupported guest image type - '${_type}'" ;;
    esac

    # error unpacking?
    [ $? -eq 0 ] || util::err "errors occured while trying to unpackage the image file"

    # remove the original snapshot
    zfs destroy -r "${VM_DS_ZFS_DATASET}/${_name}@${_uuid%%-*}" >/dev/null 2>&1

    # rename the guest configuration file
    mv "${VM_DS_PATH}/${_name}/${_oldname}.conf" "${VM_DS_PATH}/${_name}/${_name}.conf" >/dev/null 2>&1
    [ $? -eq 0 ] || util::err "unpackaged image but unable to update guest configuration file"

    # update mac addresses and create a new uuid
    _uuid=$(uuidgen)

    # remove unique settings from new image
    vm::generalise "${_name}"

    # vm may be started when 'vm image create' is executed
    rm -f "${VM_DS_PATH}/${_name}/run.lock" >/dev/null 2>&1
    rm -f "${VM_DS_PATH}/${_name}/vm-bhyve.log*" >/dev/null 2>&1
}

# 'vm image list'
# list available images
#
zfs::image_list(){
    local _file _uuid _ext
    local _format="%s^%s^%s^%s\n"

    {
        printf "${_format}" "UUID" "NAME" "CREATED" "DESCRIPTION"

        [ ! -e "${vm_dir}/images" ] && exit

        ls -1 ${vm_dir}/images/ | \
        while read _file; do
            if [ "${_file##*.}" = "manifest" ]; then
                _uuid=${_file%.*}
                _desc=$(sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" description)
                _created=$(sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" created)
                _name=$(sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" name)

                printf "${_format}" "${_uuid}" "${_name}" "${_created}" "${_desc}"
            fi
        done
    } | column -ts^
}

# 'vm image destroy'
# destroy an image
#
# @param string _uuid the uuid of the image
#
zfs::image_destroy(){
    local _uuid="$1"
    local _file

    [ -z "${_uuid}" ] && util::usage
    [ ! -e "${vm_dir}/images/${_uuid}.manifest" ] && util::err "unable to locate image with uuid ${_uuid}"

    # get the image filename
    _file=$(sysrc -inqf "${vm_dir}/images/${_uuid}.manifest" filename)
    [ -z "${_file}" ] && util::err "unable to locate filename for the specified image"

    unlink "${vm_dir}/images/${_uuid}.manifest"
    unlink "${vm_dir}/images/${_file}"
}
