#!/bin/sh
# -*- indent-tabs-mode: nil; -*-
#:
#: A ZFS management helper tool.
#:
#: :Author:    Franz Glasner
#: :Copyright: (c) 2022-2023 Franz Glasner.
#:             All rights reserved.
#: :License:   BSD 3-Clause "New" or "Revised" License.
#:             See LICENSE for details.
#:             If you cannot find LICENSE see
#:             <https://opensource.org/licenses/BSD-3-Clause>
#: :ID:        @(#)@@SIMPLEVERSIONTAG@@
#:

set -eu

VERSION="@@VERSION@@"

USAGE='
USAGE: fzfs [ OPTIONS ] COMMAND [ COMMAND OPTIONS ] [ ARG ... ]

OPTIONS:

  -V    Print the program name and version number to stdout and exit

  -h    Print this help message to stdout and exit

COMMANDS:

  create-tree [-A] [-M MOUNTPOINT] [-n] [-p] [-u] SOURCE-DATASET DEST-DATASET

  mount [-O] [-N] [-P] [-u] [-n] DATASET [MOUNTPOINT]

  umount DATASET

  unmount

'


#:
#: Determine some important dataset properties that are set locally
#:
#: Args:
#:   $1: the dataset
#:   $2 ...: the properties to check for
#:
#: Output (stdout):
#:   An option string suited for use in "zfs create"
#:
_get_local_properties() {
    local ds

    local _res _prop _value _source

    ds="${1}"
    shift

    _res=""

    for _prop in "$@" ; do
        IFS=$'\t' read -r _value _source <<EOF73GHASGJKKJ354
$(zfs get -H -p -o value,source "${_prop}" "${ds}")
EOF73GHASGJKKJ354
        case "${_source}" in
            local)
                if [ -z "${_res}" ]; then
                    _res="-o ${_prop}=${_value}"
                else
                    _res="${_res} -o ${_prop}=${_value}"
                fi
                ;;
            *)
                # VOID
                ;;
        esac
    done
    echo "${_res}"
}


#
#: Implementation of the "mount" command.
#:
#: Mount a dataset and recursively all its children datasets.
#:
command_mount() {
    local _dsname _mountpoint
    local _opt_dry_run _opt_mount_outside _opt_mount_natural
    local _opt_mount_children_only

    local _name _mp _canmount _mounted _rootds_mountpoint _relative_mp _real_mp

    _opt_dry_run=""
    _opt_mount_outside=""
    _opt_mount_natural=""
    _opt_mount_children_only=""
    while getopts "ONPnu" _opt ; do
        case ${_opt} in
            O)
                _opt_mount_outside="yes"
                ;;
            N)
                _opt_mount_natural="yes"
                ;;
            P)
                _opt_mount_children_only="yes"
                ;;
            n|u)
                _opt_dry_run="yes"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _dsname="${1-}"
    _mountpoint="${2-}"

    if [ -z "${_dsname}" ]; then
        echo "ERROR: no dataset given" >&2
        return 2
    fi

    _rootds_mountpoint="$(zfs list -H -o mountpoint -t filesystem "${_dsname}")"  || \
        { echo "ERROR: root dataset does not exist" >&2; return 1; }

    if [ -z "${_mountpoint}" ]; then
        if [ "${_opt_mount_natural}" = "yes" ]; then
            _mountpoint="${_rootds_mountpoint}"
        else
            echo "ERROR: no mountpoint given" >&2
            return 2
        fi
    else
        if [ "${_opt_mount_natural}" = "yes" ]; then
            echo "ERROR: Cannot have a custom mountpoint when \"-O\" is given" >&2
            return 2
        fi
    fi

    # Eventually remove a trailing slash
    _mountpoint="${_mountpoint%/}"
    if [ -z "${_mountpoint}" ]; then
        echo "ERROR: would mount over the root filesystem" >&2
        return 1
    fi

    zfs list -H -o name,mountpoint,canmount,mounted -s mountpoint -t filesystem -r "${_dsname}" \
    | {
        while IFS=$'\t' read -r _name _mp _canmount _mounted ; do
            # Skip filesystems that are already mounted
            [ "${_mounted}" = "yes" ] && continue
            # Skip filesystems that must not be mounted
            [ "${_canmount}" = "off" ] && continue
            #
            # Mount only the children and skip the given parent dataset
            # if required
            #
            [ \( "${_opt_mount_children_only}" = "yes" \) -a \( "${_name}" = "${_dsname}" \) ] && continue
            case "${_mp}" in
                "none"|"legacy")
                    # Do nothing for filesystem with unset or legacy mountpoints
                    ;;
                "${_rootds_mountpoint}"|"${_rootds_mountpoint}/"*)
                    #
                    # Handle only mountpoints that have a mountpoint below
                    # the parent datasets mountpoint
                    #

                    # Determine the mountpoint relative to the parent mountpoint
                    _relative_mp="${_mp#${_rootds_mountpoint}}"
                    # Eventually remove a trailing slash
                    _relative_mp="${_relative_mp%/}"
                    # The real effective full mountpoint
                    _real_mp="${_mountpoint}${_relative_mp}"

                    #
                    # Consistency and sanity check: computed real mountpoint must
                    # be equal to the configured mountpoint when no custom mountpoint
                    # is given.
                    #
                    if [ "${_opt_mount_natural}" = "yes" ]; then
                        if [ "${_real_mp}" != "${_mp}" ]; then
                            echo "ERROR: mountpoint mismatch" >&2
                            return 1
                        fi
                    fi

                    if [ "${_opt_dry_run}" = "yes" ]; then
                        echo "Would mount ${_name} on ${_real_mp}"
                    else
                        mkdir -p "${_real_mp}" 1> /dev/null 2> /dev/null || \
                            { echo "ERROR: cannot create mountpoint ${_real_mp}" >&2; return 1; }
                        echo "Mounting ${_name} on ${_real_mp}"
                        mount -t zfs "${_name}" "${_real_mp}" || return 1
                    fi
                    ;;
                *)
                    if [ "${_opt_mount_outside}" = "yes" ]; then
                        if [ "${_opt_dry_run}" = "yes" ]; then
                            echo "Would mount ${_name} on configured ZFS dataset mountpoint ${_mp}"
                        else
                            echo "Mounting ${_name} on configured ZFS dataset mountpoint ${_mp}"
                            zfs mount "${_name}" || return 1
                        fi
                    else
                        echo "Skipping ${_name} because its configured ZFS mountpoint is not relative to given root dataset" 2>&1
                    fi
                    ;;
            esac
        done

        return 0
    }
}


#:
#: Implement the "umount" command.
#:
#: Umount a datasets and recursively all its children datasets.
#:
command_umount() {
    local _dsname

    local _name _mp _rest _rootds_mountpoint

    _dsname="${1-}"
    [ -z "${_dsname}" ] && { echo "ERROR: no dataset given" 1>&2; return 2; }

    # Just determine whether the given dataset name exists
    _rootds_mountpoint="$(zfs list -H -o mountpoint -t filesystem "${_dsname}")" || { echo "ERROR: dataset not found" 1>&2; return 1; }

    mount -t zfs -p \
    | grep -E "^${_dsname}(/|\s)" \
    | sort -n -r \
    | {
        while IFS=' '$'\t' read -r _name _mp _rest ; do
            echo "Umounting ${_name} on ${_mp}"
            umount "${_mp}" || return 1
        done
    }
    return 0
}


#:
#: Implement the "create-tree" command
#:
#: Create a ZFS dataset tree from a source tree tree including important properties
#:
command_create_tree() {
    local _source_ds _target_ds
    local _opt_dry_run _opt_mountpoint _opt_noauto _opt_nomount _opt_canmount_parent_only

    local _opt _source_name _snapshot_name _current_name _current_type _relative_name _zfs_opts _zfs_canmount _zfs_mountpoint

    _opt_noauto=""
    _opt_dry_run=""
    _opt_mountpoint=""
    _opt_nomount=""
    _opt_canmount_parent_only=""

    while getopts "AM:npu" _opt ; do
        case ${_opt} in
            A)
                _opt_noauto="-o canmount=noauto"
                ;;
            M)
                _opt_mountpoint="${OPTARG}"
                ;;
            n)
                _opt_dry_run="yes"
                ;;
            p)
                _opt_canmount_parent_only="yes"
                ;;
            u)
                _opt_nomount="-u"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _source_ds="${1-}"
    _target_ds="${2-}"

    [ -z "${_source_ds}" ] && { echo "ERROR: no source dataset given" 1>&2; return 2; }
    [ -z "${_target_ds}" ] && { echo "ERROR: no destination dataset given" 1>&2; return 2; }

    _source_name="${_source_ds%@*}"
    if [ "${_source_name}" != "${_source_ds}" ]; then
        _snapshot_name="${_source_ds##*@}"
    else
        _snapshot_name=""
    fi

    [ -n "${_snapshot_name}" ] && { echo "ERROR: No snapshot sources are supported yet" 1>&2; return 2; }

    zfs list -H -r -t filesystem -o name,type "${_source_ds}" \
    | {
        while IFS=$'\t' read -r _current_name _current_type ; do
            # Determine the relative name of the dataset
            _relative_name="${_current_name#${_source_name}}"
            _relative_name="${_relative_name%@*}"
            if [ "${_current_type}" != "filesystem" ]; then
                echo "ERROR: Got a snapshot but expected a filesystem" 1>&2
                return 1
            fi
            _zfs_opts="$(_get_local_properties "${_current_name}" atime exec setuid compression primarycache sync readonly)"
            if [ -z "${_relative_name}" ]; then
                #
                # Root
                #
                if [ -z "${_opt_noauto}" ]; then
                    _zfs_canmount="$(_get_local_properties "${_current_name}" canmount)"
                else
                    _zfs_canmount="${_opt_noauto}"
                fi
                if [ -n "${_opt_mountpoint}" ]; then
                    _zfs_mountpoint="${_opt_mountpoint}"
                else
                    _zfs_mountpoint=""
                fi

                if [ "${_opt_dry_run}" = "yes" ]; then
                    if [ -z "${_zfs_mountpoint}" ]; then
                        echo "Would call: zfs create ${_opt_nomount} ${_zfs_opts} ${_zfs_canmount} ${_target_ds}${_relative_name}"
                    else
                        echo "Would call: zfs create ${_opt_nomount} ${_zfs_opts} ${_zfs_canmount} -o mountpoint=${_zfs_mountpoint} ${_target_ds}${_relative_name}"
                    fi
                else
                    if [ -z "${_zfs_mountpoint}" ]; then
                        zfs create -v ${_opt_nomount} ${_zfs_opts} ${_zfs_canmount} "${_target_ds}${_relative_name}"
                    else
                        zfs create -v ${_opt_nomount} ${_zfs_opts} ${_zfs_canmount} -o "mountpoint=${_zfs_mountpoint}" "${_target_ds}${_relative_name}"
                    fi
                fi
            else
                #
                # Children
                #
                if [ -z "${_opt_noauto}" ]; then
                    if [ "${_opt_canmount_parent_only}" = "yes" ]; then
                        _zfs_canmount=""
                    else
                        _zfs_canmount="$(_get_local_properties "${_current_name}" canmount)"
                    fi
                else
                    _zfs_canmount="${_opt_noauto}"
                fi
                if [ "${_opt_dry_run}" = "yes" ]; then
                    echo "Would call: zfs create ${_opt_nomount} ${_zfs_opts} ${_zfs_canmount} ${_target_ds}${_relative_name}"
                else
                    zfs create -v ${_opt_nomount} ${_zfs_opts} ${_zfs_canmount} "${_target_ds}${_relative_name}"
                fi
            fi
        done
    }
    return 0
}


#
# Global option handling
#
while getopts "Vh" _opt ; do
    case ${_opt} in
        V)
            printf 'fzfs %s\n' '@@SIMPLEVERSIONSTR@@'
            exit 0
            ;;
        h)
            echo "${USAGE}"
            exit 0
            ;;
        \?)
            exit 2;
            ;;
        *)
            echo "ERROR: option handling failed" 1>&2
            exit 2
            ;;
    esac
done
#
# Reset the Shell's option handling system to prepare for handling
# command-local options.
#
shift $((OPTIND-1))
OPTIND=1

test $# -gt 0 || { echo "ERROR: no command given" 1>&2; exit 2; }

command="$1"
shift

case "${command}" in
    mount)
        command_mount "$@"
        ;;
    umount|unmount)
        command_umount "$@"
        ;;
    create-tree)
        command_create_tree "$@"
        ;;
    *)
        echo "ERROR: unknown command \`${command}'" 1>&2
        exit 2
        ;;
esac
