view sbin/ftjail @ 437:59622f32279d

Add two examples of how to use ftjail and friends to (freebsd-)update a thin jail. 1. Update the thin jail template 2. Update the thin jails These scripts are not yet polished. They are added as used for some times when managing jails.
author Franz Glasner <fzglas.hg@dom66.de>
date Wed, 01 May 2024 20:58:54 +0200
parents 91b275a3facf
children 9c3b1966ba91
line wrap: on
line source

#!/bin/sh
# -*- indent-tabs-mode: nil; -*-
#:
#: A very minimal BSD Thin Jail management 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: ftjail [ 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:

  datasets-tmpl -L|-P PARENT-BASE PARENT-SKELETON NAME

  mount-tmpl -L|-P [-n] [-u] BASE-RO SKELETON-RW MOUNTPOINT

  umount-tmpl BASE-RO SKELETON-RW

  interlink-tmpl MOUNTPOINT

  populate-tmpl -L|-P DIRECTORY BASETXZ

  snapshot-tmpl BASE-RO SKELETON-RW SNAPSHOT-NAME

  copy-skel [-A] [-D] [-L] [-M MOUNTPOINT] [-P] [-u]  SOURCE-DS SNAPSHOT-NAME TARGET-DS

  build-etcupdate-current-tmpl DIRECTORY TARBALL

  freebsd-update [-k] [-o OLD-ORIGIN] DIRECTORY NEW-ORIGIN [ETCUPDATE-TARBALL]

ENVIRONMENT:

  All environment variables that affect "zfs" are effective also.

DESCRIPTION:

  All commands with the exception of "populate-tmpl" require ZFS as
  filesystem.
'

# Reset to standard umask
umask 0022


#:
#: Ensure that no command line options are given
#:
#: Args:
#:   $@:
#:
#: Exit:
#:   2: If formally `getopts` finds options in "$@"
#:
#: Return:
#:   - 0
#:
_ensure_no_options() {
    local _opt

    while getopts ":" _opt ; do
        [ "${_opt}" = '?' ] && { echo "ERROR: no option allowed" 1>&2; exit 2; }
    done
    return 0
}


_get_dataset_for_mountpoint() {
    : 'Use `mount -t zfs -p` to determine the ZFS dataset for a given mountpoint.

    '
    local _mountpoint

    local _ds _mount _rest

    _mountpoint="$1"

    mount -t zfs -p \
    | {
        while IFS=' '$'\t' read -r _ds _mount _rest ; do
            if [ "$_mount" = "$_mountpoint" ]; then
                echo "${_ds}"
                return 0
            fi
        done
        return 1
    }
}


#:
#: Search for a running jail where it's "path" points to a given location
#:
#: Args:
#:   $1: the location to search for
#:
#: Output (stdout):
#:   The name if the jail with a "path" that is equal to the input param.
#:   Nothing if a jail is not found.
#:
#: Return:
#:   - 0: if a running jail is found
#:   - 1: error
#:   - 2: no running jail found
#:   - 3: jail found but currently dying
#:
_get_jail_from_path() {
  local _location

  local _name _path _dying

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


  jls -d name path dying \
  | {
      while IFS=' '$'\t' read -r _name _path _dying ; do
        if [ "${_path}" = "${_location}" ]; then
          if [ "${_dying}" != "false" ]; then
            echo "Jail \`${_name}' is currently dying" 1>&2
            return 3
          fi
          echo "${_name}"
          return 0
        fi
      done
      return 2
  }
}


#:
#: Search for mounts and sub-mounts at a given directory.
#:
#: The output is sorted by the mountpoint.
#:
#: Args:
#:   $1: the directory where to start for mounts and sub-mounts
#:
#: Output (stdout):
#:   The sorted list (lines) of mounts in :manpage:`fstab(5)` format.
#:   This list may be empty.
#:
#: Exit:
#:   1: on fatal errors (usage et al.)
#:
#: Important:
#:   The input directory **must** be an absolute path.
#:
_get_mounts_at_directory() {
    local _directory

    local _fstab

    _directory=${1-}
    case "${_directory}" in
        */)
            echo "ERROR: a trailing slash in directory name given" 1>&2;
            exit 1;
            ;;
        /*)
            :
            ;;
        '')
            echo "ERROR: no directory given" 1>&2;
            exit 1;
            ;;
        *)
            echo "ERROR: directory must be an absolute path" 1>&2;
            exit 1;
            ;;
    esac
    _fstab="$(mount -p | awk -v pa1="^${_directory}\$" -v pa2="^${_directory}/" '($2 ~ pa1) || ($2 ~ pa2 ) { print; }' | sort -k3)"
    echo "${_fstab}"
}


#
# PARENT-BASE NAME DRY-RUN
#
command_datasets_tmpl_base() {
    local _p_base _name

    local _opt_dry_run

    local _ds_base _opt

    _opt_dry_run=""
    while getopts "nu" _opt ; do
        case ${_opt} in
            n|u)
                _opt_dry_run="yes"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _p_base="${1-}"
    _name="${2-}"

    if [ -z "${_p_base}" ]; then
        echo "ERROR: no parent dataset for base given" >&2
        return 2
    fi
    if [ -z "${_name}" ]; then
        echo "ERROR: no name given" >&2
        return 2
    fi

    if ! zfs list -H -o mountpoint -t filesystem "${_p_base}" >/dev/null 2>/dev/null; then
        echo "ERROR: parent dataset \`${_p_base}' does not exist" >&2
        return 1
    fi
    _ds_base="${_p_base}/${_name}"
    if zfs list -H -o mountpoint -t filesystem "${_ds_base}" >/dev/null 2>/dev/null; then
        echo "ERROR: dataset \`${_ds_base}' does already exist" >&2
        return 1
    fi


    [ "${_opt_dry_run}" = "yes" ] && return 0

    echo "Creating RO base datasets in:"
    printf "\\t%s\\n" "${_ds_base}"

    zfs create -u -o canmount=noauto "${_ds_base}"

}


#
# SKELETON NAME DRY-RUN
#
command_datasets_tmpl_skel() {
    local _p_base _name

    local _opt_dry_run _opt_symlink

    local _ds_skel _child _child_zfsopts _opt

    _opt_dry_run=""
    _opt_symlink=""

    while getopts "LPnu" _opt ; do
        case ${_opt} in
            L)
                _opt_symlink="yes"
                ;;
            P)
                _opt_symlink="no"
                ;;
            n|u)
                _opt_dry_run="yes"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    [ -z "${_opt_symlink}" ] && { echo "ERROR: -L or -P must be given" 1>&2; return 2; }

    _p_skel="${1-}"
    _name="${2-}"

    if [ -z "${_p_skel}" ]; then
        echo "ERROR: no parent dataset for skeleton given" >&2
        return 2
    fi
    if [ -z "${_name}" ]; then
        echo "ERROR: no name given" >&2
        return 2
    fi

    if ! zfs list -H -o mountpoint -t filesystem "${_p_skel}" >/dev/null 2>/dev/null; then
        echo "ERROR: parent dataset \`${_p_skel}' does not exist" >&2
        return 1
    fi
    _ds_skel="${_p_skel}/${_name}"
    if zfs list -H -o mountpoint -t filesystem "${_ds_skel}" >/dev/null 2>/dev/null; then
        echo "ERROR: dataset \`${_ds_skel}' does already exist" >&2
        return 1
    fi

    [ "${_opt_dry_run}" = "yes" ] && return 0

    echo "Creating RW skeleton datasets in:"
    printf "\\t%s\\n" "${_ds_skel}"

    if [ "${_opt_symlink}" = "yes" ]; then
        # In this case the skeleton root needs to be mounted into a "skeleton" subdir
        zfs create -u -o canmount=noauto "${_ds_skel}"
    else
        # Only children are to be mounted
        zfs create -u -o canmount=off "${_ds_skel}"
    fi
    # "usr" is only a container holding "usr/local"
    zfs create -u -o canmount=off "${_ds_skel}/usr"
    #
    # XXX FIXME: What about usr/ports/distfiles
    #            We typically want to use binary packages.
    #            And if we use ports they are not in usr/ports typically.
    #
    #zfs create -u -o canmount=off "${_ds_skel}/usr/ports"
    #
    # XXX FIXME: What about home
    #
    # /var/mail is here because it relies on atime
    #
    for _child in etc home root tmp usr/local var var/mail ; do
        case "${_child}" in
            "tmp"|"var/tmp")
                _child_zfsopts="-o sync=disabled -o setuid=off"
                ;;
            "home")
                _child_zfsopts="-o setuid=off"
                ;;
            "usr/ports/distfiles")
                _child_zfsopts="-o exec=off -o setuid=off -o compression=off -o primarycache=metadata"
                ;;
            "var/mail")
                _child_zfsopts="-o atime=on -o exec=off -o setuid=off"
                ;;
            *)
                _child_zfsopts=""
                ;;
        esac
        zfs create -u -o canmount=noauto ${_child_zfsopts} "${_ds_skel}/${_child}"
    done
}


#
# "datasets-tmpl" -- create the ZFS dataset tree
#
# PARENT-BASE PARENT-SKELETON NAME
#
command_datasets_tmpl() {
    # parent ZFS dataset -- child ZFS dataset name
    local _p_base _p_skel _name
    local _opt_symlink

    local _ds_base _ds_skel _opt

    _opt_symlink=""

    while getopts "LP" _opt ; do
        case ${_opt} in
            L)
                _opt_symlink="-L"
                ;;
            P)
                _opt_symlink="-P"
                ;;
            \?)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    [ -z "${_opt_symlink}" ] && { echo "ERROR: -L or -P must be given" 1>&2; return 2; }

    _p_base="${1-}"
    _p_skel="${2-}"
    _name="${3-}"

    # Check preconditions
    command_datasets_tmpl_base -n "${_p_base}" "${_name}" || return
    command_datasets_tmpl_skel -n ${_opt_symlink} "${_p_skel}" "${_name}" || return

    # Really do it
    command_datasets_tmpl_base "${_p_base}" "${_name}" || return
    command_datasets_tmpl_skel ${_opt_symlink} "${_p_skel}" "${_name}" || return
    return 0
}


#
# "populate-tmpl" -- populate the datasets with content from a FreeBSD base.txz
#
# command_populate_tmpl mountpoint basetxz
#
command_populate_tmpl() {
    # MOUNTPOINT -- base.txz
    local _mp _basetxz
    local _opt_symlink

    local _opt _dir

    _opt_symlink=""

    while getopts "LP" _opt ; do
        case ${_opt} in
            L)
                _opt_symlink="yes"
                ;;
            P)
                _opt_symlink="no"
                ;;
            \?)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    [ -z "${_opt_symlink}" ] && { echo "ERROR: -L or -P must be given" 1>&2; return 2; }

    _mp="${1-}"
    _basetxz="${2-}"

    if [ -z "${_mp}" ]; then
        echo "ERROR: no mountpoint given" >&2
        return 2
    fi
    if [ -z "${_basetxz}" ]; then
        echo "ERROR: no base.txz given" >&2
        return 2
    fi
    if [ ! -d "${_mp}" ]; then
        echo "ERROR: mountpoint \`${_mp}' does not exist" >&2
        return 1
    fi
    if [ ! -r "${_basetxz}" ]; then
        echo "ERROR: file \`${_basetxz}' is not readable" >&2
        return 1
    fi

    if [ "${_opt_symlink}" = "yes" ]; then
        echo "Extracting RO base ..."
        tar -C "${_mp}" --exclude=./etc --exclude=./root --exclude=./tmp --exclude=./usr/local --exclude=./var --no-safe-writes -xJp -f "${_basetxz}" || return
        # "home" is not part of base
        for _dir in etc root tmp usr/local var ; do
            echo "Extracting RW skeleton: ${_dir} ..."
            tar -C "${_mp}/skeleton" --include="./${_dir}" --exclude=./root/.cshrc --exclude=./root/.profile -xJp -f "${_basetxz}" || return
        done
        # In the original archive they are archived as hardlinks: make proper symlinks here
        (cd "${_mp}/skeleton/root" && ln -s ../../.profile .profile) || return
        (cd "${_mp}/skeleton/root" && ln -s ../../.cshrc .cshrc) || return
    else
        echo "Extracting base ..."
        tar -C "${_mp}" --exclude=./root/.cshrc --exclude=./root/.profile --no-safe-writes -xJp -f "${_basetxz}" || return
        # In the original archive they are archived as hardlinks: make proper symlinks here
        (cd "${_mp}/root" && ln -s ../.profile .profile) || return
        (cd "${_mp}/root" && ln -s ../.cshrc .cshrc) || return
    fi

    find "${_mp}/boot" -type f -delete || true
}


#
# _do_mount dataset mountpoint dry-run mount-natural childs-only
#
_do_mount() {
    local _dsname _mountpoint _dry_run _mount_natural _childs_only

    local _name _mp _canmount _mounted
    local _rootds_mountpoint _relative_mp _real_mp

    _dsname="${1}"
    _mountpoint="${2}"
    _dry_run="${3}"
    _mount_natural="${4}"
    _childs_only="${5}"

    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 \`${_dsname}' does not exist" >&2; return 1; }

    if [ -z "${_mountpoint}" ]; then
        if [ "${_mount_natural}" = "yes" ]; then
            _mountpoint="${_rootds_mountpoint}"
        else
            echo "ERROR: no mountpoint given" >&2
            return 2
        fi
    else
        if [ "${_mount_natural}" = "yes" ]; then
            echo "ERROR: Cannot have a custom mountpoint when mount-natural is activated" >&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
            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 [ "${_mount_natural}" = "yes" ]; then
                        if [ "${_real_mp}" != "${_mp}" ]; then
                            echo "ERROR: mountpoint mismatch" 1>&2
                            return 1
                        fi
                    fi

                    if [ \( "${_childs_only}" = "yes" \) -a \( "${_name}" = "${_dsname}" \) ]; then
                        echo "Skipping ${_name} because mounting childs only" 1>&2
                    else
                        if [ "${_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}" 1>&2; return 1; }
                            echo "Mounting ${_name} on ${_real_mp}"
                            mount -t zfs "${_name}" "${_real_mp}" || return 1
                        fi
                    fi
                    ;;
                *)
                    echo "Skipping ${_name} because its configured ZFS mountpoint is not relative to given root dataset" 1>&2
                    ;;
            esac
        done

        return 0
    }
}


#
# "mount-tmpl" -- recursively mount a base and skeleton datasets including subordinate datasets
#
# command_mount_tmpl base-ro skeleton-rw  mountpoint
#
command_mount_tmpl() {
    local _ds_base _ds_skel _mountpoint
    local _opt_dry_run _opt_symlink

    local _opt

    _opt_dry_run=""
    _opt_symlink=""

    while getopts "LPnu" _opt ; do
        case ${_opt} in
            L)
                _opt_symlink="yes"
                ;;
            P)
                _opt_symlink="no"
                ;;
            n|u)
                _opt_dry_run="yes"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    [ -z "${_opt_symlink}" ] && { echo "ERROR: -L or -P must be given" 1>&2; return 2; }

    _ds_base="${1-}"
    _ds_skel="${2-}"
    _mountpoint="${3-}"

    _do_mount "${_ds_base}" "${_mountpoint}" "${_opt_dry_run}" "" "" || return
    if [ "${_opt_symlink}" = "yes" ]; then
        if [ "${_opt_dry_run}" != "yes" ]; then
            if [ ! -d "${_mountpoint}/skeleton" ]; then
                mkdir "${_mountpoint}/skeleton" || return
            fi
        fi
        _do_mount "${_ds_skel}" "${_mountpoint}/skeleton" "${_opt_dry_run}" "" "" || return
    else
        _do_mount "${_ds_skel}" "${_mountpoint}" "${_opt_dry_run}" "" "yes" || return
    fi

    return 0
}


#
# _do_umount dataset
#
_do_umount() {
    local _dsname

    local _name _mp _rest
    local _rootds_mountpoint

    _dsname="${1}"
    [ -z "${_dsname}" ] && { echo "ERROR: no dataset given" >&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" >&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
    }
}


#
# "umount-tmpl" -- umount skeleton and base datasets
#
# command_umount_tmpl ds-base ds-skeleton
#
command_umount_tmpl() {
    local _ds_base _ds_skel

    _ensure_no_options "$@"

    _ds_base="${1-}"
    _ds_skel="${2-}"

    [ -z "${_ds_base}" ] && { echo "ERROR: no RO base dataset given" >&2; return 2; }
    [ -z "${_ds_skel}" ] && { echo "ERROR: no RW skeleton dataset given" >&2; return 2; }

    _do_umount "${_ds_skel}" || return
    _do_umount "${_ds_base}" || return

    return 0
}


#
# "interlink-tmpl" -- create links from base to skeleton
#
# command_interlink_tmpl mountpint
#
command_interlink_tmpl() {
    local _mountpoint

    local _dir _dirpart _basepart

    _ensure_no_options "$@"

    _mountpoint="${1-}"

    [ -z "${_mountpoint}" ] && { echo "ERROR: no mountpoint given" 1>&2; return 2; }
    [ -d "${_mountpoint}" ] || { echo "ERROR: mountpoint \`${_mountpoint}' does not exist" 1>&2; return 1; }
    [ -d "${_mountpoint}/skeleton" ] || { echo "WARNING: skeleton is not mounted at \`${_mountpoint}/skeleton'" 1>&2; }

    for _dir in etc home root tmp usr/local var ; do
        case "${_dir}" in
            "usr/local")
                _dirpart="$(dirname "${_dir}")"
                _basepart="$(basename "${_dir}")"
                [ -d "${_mountpoint}/${_dirpart}" ] || mkdir "${_mountpoint}/${_dirpart}" || return
                ( cd "${_mountpoint}/${_dirpart}" && ln -s "../skeleton/${_dir}" "${_basepart}" ) || return
                ;;
            *)
                ( cd "${_mountpoint}" && ln -s "skeleton/${_dir}" "${_dir}" ) || return
                ;;
        esac
    done
    return 0
}

#:
#: Create a snapshot for a dataset and all of its children.
#:
#: Args:
#:     $1: the datasets
#:     $2: the name of the snapshot
#:
_do_snapshot() {
    local _ds _snap_name

    _ds="${1}"
    _snap_name="${2}"

    [ -z "${_ds}" ] && { echo "ERROR: no dataset given" 1>&2; return 2; }
    [ -z "${_snap_name}" ] && { echo "ERROR: no snapshot name given" 1>&2; return 2; }

    if ! zfs list -H -o name -t filesystem "${_ds}" 1> /dev/null 2> /dev/null ; then
        echo "ERROR: parent dataset \`${_ds}' does not exist or is not available" 1>&2
        return 1
    fi

    zfs snapshot -r "${_ds}@${_snap_name}" || return
}


#:
#: Implement the "snapshot-tmpl" command
#:
command_snapshot_tmpl() {
    local _ds_base _ds_skel _snap_name

    _ensure_no_options "$@"

    _ds_base="${1-}"
    _ds_skel="${2-}"
    _snap_name="${3-}"

    # Here extra checks because of better error messages possible
    [ -z "${_ds_base}" ] && { echo "ERROR: no RO base dataset name given" 1>&2; return 2; }
    [ -z "${_ds_skel}" ] && { echo "ERROR: no RW skeleton dataset name given" 1>&2; return 2; }

    _do_snapshot "${_ds_base}" "${_snap_name}" || return
    _do_snapshot "${_ds_skel}" "${_snap_name}" || return
    return 0
}


#:
#: Implementation of "copy-skel"
#:
command_copy_skel() {
    local _ds_source _snapshot_name _ds_target
    local _opt_symlink _opt_nomount _opt_canmount _opt_mountpoint _opt_nodata

    local _opt _name _relative_name  _root_canmount

    _opt_symlink=""
    _opt_nomount=""
    _opt_canmount="-o canmount=on"
    _opt_mountpoint=""
    _opt_nodata=""

    while getopts "ADLM:Pu" _opt ; do
        case ${_opt} in
            A)
                _opt_canmount="-o canmount=noauto"
                ;;
            D)
                _opt_nodata="yes"
                ;;
            L)
                _opt_symlink="yes"
                ;;
            M)
                _opt_mountpoint="${OPTARG}"
                ;;
            P)
                _opt_symlink="no"
                ;;
            u)
                _opt_nomount="-u"
                ;;
            \?)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    [ -z "${_opt_symlink}" ] && { echo "ERROR: -L or -P must be given" 1>&2; return 2; }
    [ \( "${_opt_nodata}" = "yes" \) -a \( "${_opt_symlink}" = "yes" \) ] && { echo "ERROR: -L and -D are incompatible" 1>&2; return 2; }

    _ds_source="${1-}"
    _snapshot_name="${2-}"
    _ds_target="${3-}"

    [ -z "${_ds_source}" ] && { echo "ERROR: no source given" 1>&2; return 2; }
    [ -z "${_snapshot_name}" ] && { echo "ERROR: no snapshot name given" 1>&2; return 2; }
    [ -z "${_ds_target}" ] && { echo "ERROR: no target given" 1>&2; return 2; }

    zfs list -r -t all -o name "${_ds_source}" \
    | {
        while IFS=$'\t' read -r _name ; do
            if [ "${_name}" = "${_name%@*}@${_snapshot_name}" ]; then
                echo "FOUND: $_name"
                # Determine the relative name of the dataset
                _relative_name="${_name#${_ds_source}}"
                _relative_name="${_relative_name%@*}"
                echo "  -> $_relative_name"
                if [ -z "${_relative_name}" ]; then
                    #
                    # Root
                    #
                    if [ "${_opt_symlink}" = "yes" ]; then
                        _root_canmount="${_opt_canmount}"
                    else
                        _root_canmount="-o canmount=off"
                    fi
                    if [ -n "${_opt_mountpoint}" ]; then
                        zfs send -Lec -p -v "${_name}" | zfs receive ${_opt_nomount} -v ${_root_canmount} -o "mountpoint=${_opt_mountpoint}" -o readonly=off "${_ds_target}${_relative_name}"
                    else
                        zfs send -Lec -p -v "${_name}" | zfs receive ${_opt_nomount} -o readonly=off -v ${_root_canmount} -x mountpoint "${_ds_target}${_relative_name}"
                    fi
                else
                    #
                    # Children
                    #
                    if [ "${_relative_name}" = "/usr" ]; then
                        zfs send -Lec -p -v "${_name}" | zfs receive ${_opt_nomount} -v -o canmount=off -x mountpoint "${_ds_target}${_relative_name}"
                    else
                        zfs send -Lec -p -v "${_name}" | zfs receive ${_opt_nomount} -v ${_opt_canmount} -x mountpoint "${_ds_target}${_relative_name}"
                    fi
                fi
            fi
        done
    }
    # Need only the filesystem data (no associated snapshots)
    echo "Destroying unneeded snapshots ..."
    zfs destroy -rv "${_ds_target}@${_snapshot_name}"
}


#:
#: Implement the "build-etcupdate-current-tmpl" command
#:
command_build_etcupdate_current_tmpl() {
    local _directory _tarball

    _directory="${1-}"
    _tarball="${2-}"

    [ -z "${_directory}" ] && { echo "ERROR: no directory given" 1>&2; return 2; }
    [ -z "${_tarball}" ] && { echo "ERROR: no directory given" 1>&2; return 2; }
    [ -e "${_tarball}" ] && { echo "ERROR: \`${_tarball}' exists already" 1>&2; return 1; }

    if ! tar -cjf "${_tarball}" -C "${_directory}/var/db/etcupdate/current" . ; then
        rm -f "${_tarball}" || true
        return 1
    fi
}


#:
#: Determine extra clone options with respect to the "mountpoint" property
#:
#: Args:
#:   $1: the dataset
#:
#: Output (stdout)
#:   The extra clone arguments
#:
#: Exit:
#:   On unexpected source values
#:
_get_clone_extra_prop_for_mountpoint() {
    local ds

    local _mp_name _mp_property _mp_value _mp_source

    ds="${1}"

    zfs get -H mountpoint "${ds}" \
    | {
        IFS=$'\t' read -r _mp_name _mp_property _mp_value _mp_source
        case "${_mp_source}" in
            local)
                echo -n "-o mountpoint=${_mp_value}"
                ;;
            default|inherited*)
                ;;
            temporary*|received*|'-'|none)
                # XXX FIXME: Is this relevant on FreeBSD?
                echo "ERROR: Unexpected SOURCE \"${_mp_source}\" for mountpoint at \`${_mp_value}'" 1>&2
                exit 1
                ;;
            *)
                echo "ERROR: Unexpected SOURCE for mountpoint property at \`${_mp_value}'" 1>&2
                exit 1;
                ;;
        esac
        if [ "${_mp_value}" != "${_directory}" ]; then
            echo "WARNING: dataset is not mounted at its configured mountpoint but elsewhere (probably via \"mount -t zfs\")" 1>&2
        fi
    }
}


#:
#: Determine the "canmount" property for a dataset
#:
#: Args:
#:   $1: the dataset
#:
#: Output (stdout):
#:   The local value or "DEFAULT" for the (unset) default
#:
#: Exit:
#:   On unexpected source values
#:
_get_canmount_setting_for_dataset() {
    local ds

    local _cm_name _cm_property _cm_value _cm_source

    ds="${1}"

    zfs get -H canmount "${ds}" \
    | {
        IFS=$'\t' read -r _cm_name _cm_property _cm_value _cm_source
        case "${_cm_source}" in
            local)
                echo -n "canmount=${_cm_value}"
                ;;
            default)
                echo -n "DEFAULT"
                ;;
            inherited|temporary*|received*|'-'|none)
                # XXX FIXME: Is this relevant on FreeBSD?
                echo "ERROR: Unexpected SOURCE \"${_cm_source}\" for canmount at \`${_cm_name}'" 1>&2
                exit 1
                ;;
            *)
                echo "ERROR: Unexpected SOURCE for canmount property at \`${_cm_name}'" 1>&2
                exit 1;
                ;;
        esac
    }
}


#:
#: Implement the "freebsd-update" command for a thin jail
#:
#: .. note:: FreeBSD's :command:`etcupdate` also executes
#:           :command:`certctl rehash` if certs are to be added or removed!
#:
command_freebsd_update() {
    local _directory _new_origin _etcupdate_tarball
    local _opt_keep _opt_old_origin

    local _res _jailname _dir_mounts _dir_fn_fstab _dir_basename _dir_fn_tldir
    local _root_dataset _root_mountpoint _root_type _root_options
    local _clone_extra_props _canmount_prop
    local _line _opt
    local _root_readonly _root_origin
    local _u_tmpdir
    local _add_log_sock

    _opt_keep="no"
    _opt_old_origin=""
    while getopts "ko:" _opt ; do
        case ${_opt} in
            k)
                _opt_keep="yes"
                ;;
            o)
                _opt_old_origin="$OPTARG"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _directory="${1-}"
    _new_origin="${2-}"
    _etcupdate_tarball="${3-}"

    [ -z "${_directory}" ] && { echo "ERROR: no directory given" 1>&2; return 2; }
    [ -d "${_directory}" ] || { echo "ERROR: directory \`${_directory}' does not exist" 1>&2; return 1; }

    [ -z "${_new_origin}" ] && { echo "ERROR: no new origin given" 1>&2; return 2; }
    zfs list -H -o name -t snapshot "${_new_origin}" >/dev/null || { echo "ERROR: new origin does not exist" 1>&2; return 1; }
    if [ -n "${_etcupdate_tarball}" ]; then
        [ -f "${_etcupdate_tarball}" ] || { echo "ERROR: given etcupdate tarball does not exist " 1>&2; return 1; }
    fi

    _dir_basename="$(basename ${_directory})"

    set +e
    _jailname=$(_get_jail_from_path "${_directory}")ยด
    _res=$?
    set -e
    if [ ${_res} -ne 2 ] ; then
        if [ ${_res} -ne 0 ] ; then
            return ${_res}
        else
            echo "ERROR: Please stop the \`${_jailname}' jail" >&2
            return 1
        fi
    fi
    #
    # Check whether additional log sockets are opened at their default
    # locations. Because they hinder proper unmounting of filesystems.
    #
    for _add_log_sock in /var/run/log /var/run/logpriv ; do
        if [ -S "${_directory}${_add_log_sock}" ]; then
            echo "ERROR: additional log socket is open at \`${_directory}${_add_log_sock}'" >&2
            return 1
        fi
    done
    #
    # Check whether there are any open files within the jail.
    #
    # "procstat file" also lists fifo, socket, message queue, kgueue et al.
    # file types.
    #
    # Note that procstat places extra whitespace at the end of lines sometimes.
    #
    #
    if procstat -a file | egrep '['$'\t '']+'"${_directory}"'(/|(['$'\t '']*)$)' ; then
        echo "ERROR: There are open files within the jail" >&2
        return 1
    fi
    # The same for memory mappings
    if procstat -a vm | egrep '['$'\t '']+'"${_directory}"'(/|(['$'\t '']*)$)' ; then
        echo "ERROR: There are open memory mappings within the jail" >&2
        return 1
    fi

    _dir_mounts="$(_get_mounts_at_directory "${_directory}")"

    #
    # Check preconditions thoroughly!
    #
    # Check that the first item/line is a read-only ZFS mount directly
    # at the given directory. This must also be its configured
    # mountpoint in ZFS.
    # Also check that it is a clone proper.
    #
    IFS=' '$'\t' read -r _root_dataset _root_mountpoint _root_type _root_options _line <<EOF4tHGCSS
${_dir_mounts}
EOF4tHGCSS
    [ "${_root_mountpoint}" != "${_directory}" ] && { echo "ERROR: found root mountpoint does not match given directory" 1>&2; return 1; }
    [ "${_root_type}" != "zfs" ] && { echo "ERROR: root mountpoint is not from a ZFS dataset" 1>&2; return 1; }
    _root_readonly="$(zfs list -H -o readonly "${_root_dataset}")"
    [ "${_root_readonly}" != "on" ] &&  { echo "ERROR: the root dataset is not mounted read-only" 1>&2; return 1; }
    _root_origin="$(zfs list -H -o origin "${_root_dataset}")"
    if [ -n "${_opt_old_origin}" ]; then
        [ "${_opt_old_origin}" != "${_root_origin}" ] && { echo "ERROR: origin mismatch" 1>&2; return 1; }
    else
        [ "${_root_origin}" = '-' ] &&  { echo "ERROR: the root dataset is not a ZFS clone" 1>&2; return 1; }
    fi

    # Determine we need to clone with a custom (non inherited) "mountpoint"
    _clone_extra_props="$(_get_clone_extra_prop_for_mountpoint "${_root_dataset}") "
    # Determine we need to clone with a custom (non inherited) "canmount"
    _canmount_prop="$(_get_canmount_setting_for_dataset "${_root_dataset}")"

    #
    # XXX FIXME: should we check that _root_options equals "ro" or
    #            start with "ro,"
    #    _root_origin="$(zfs list -H -o origin "${_root_dataset}")"

    _u_tmpdir="$(env TMPDIR=/var/tmp mktemp -d -t ftjail_${_dir_basename})"
    [ -z "${_u_tmpdir}" ] && { echo "ERROR: cannot create unique temp dir" 1>&2; return 1; }
    _dir_fn_fstab="${_u_tmpdir}/fstab"
    echo -n "${_dir_mounts}" >>"${_dir_fn_fstab}"
    _dir_fn_tldir="${_u_tmpdir}/tldirs"
    find "${_directory}" -depth 1 -type d 2>/dev/null | sort >>"${_dir_fn_tldir}"

    # Unmount in reverse order: unmount can do it for us
    echo "Unmounting all datasets mounted at \`${_directory}'"
    umount -a -F "${_dir_fn_fstab}" -v

    #
    # XXX TBD: Hooks to create some new top-level dirs (/srv /proc et
    #          al.)  if needed: clone RW, mount, make the dirs,
    #          umount, make the clone RO and continue "normally" by
    #          completely mounting the stored fstab.
    #

    #
    # Destroy the current read-only root clone and make a new clone based
    # on the given new origin.
    # The new clone temporarily is RW and is not to be mounted automatically.
    # These both properties are set again below after the new base is
    # adjusted properly.
    #
    echo "Destroying the cloned root dataset \`${_root_dataset}'"
    zfs destroy -v "${_root_dataset}"
    echo "Cloning a new root dataset \`${_root_dataset}' from new origin \`${_new_origin}'"
    zfs clone -o readonly=off -o canmount=noauto ${_clone_extra_props} "${_new_origin}" "${_root_dataset}"
    #
    # NOTE: Always mount with "mount -t zfs" because a custom
    #       mountpoint is not reflected in the "mountpoint"
    #       property. So in scripts to be sure to unmount and re-mount
    #       at the same location always use "mount -t zfs".
    #
    echo "Remounting only the root dataset at \`${_directory}'"
    [ ! -d "${_directory}" ] && mkdir "${_directory}"
    mount -t zfs "${_root_dataset}" "${_directory}"
    #
    # Re-create all currently missing top-level dirs (aka mountpoint)
    # in the new clone. Most probably they serve as mountpoints for other
    # datasets.
    #
    # XXX FIXME: Re-create the current mode bits and/or ACLs also.
    #            But most probably they are set properly in the mounted
    #            datasets.
    #
    echo "Recreating missing top-level directories"
    cat "${_dir_fn_tldir}" \
    | {
        while IFS='' read -r _line ; do
            if [ ! -d "${_line}" ]; then
                echo "Recreating top-level directory: ${_line}"
                mkdir "${_line}"
            fi
        done
    }
    echo "Unmounting the new root dataset"
    umount "${_directory}"
    echo "Re-setting some ZFS properties on the new cloned dataset"
    zfs set readonly=on "${_root_dataset}"
    #
    # Copy "canmount" properly last because it has been set to "noauto"
    # temporarily.
    #
    if [ -n "${_canmount_prop}" ]; then
        if [ "${_canmount_prop}" = "DEFAULT" ]; then
            #
            # "zfs inherit" is not possible for "canmount".
            # Use "inherit -S" to simulate a reset to "default" somewhat
            #
            # See also: https://github.com/openzfs/zfs/issues/5733
            #
            zfs inherit -S canmount "${_root_dataset}"
        else
            zfs set "${_canmount_prop}" "${_root_dataset}"
        fi
    fi

    # Mount again
    echo "Mounting all datasets rooted at \`${_directory}'"
    [ ! -d "${_directory}" ] && mkdir "${_directory}"
    mount -a -F "${_dir_fn_fstab}" -v

    # Update and/or merge configs
    if [ -n "${_etcupdate_tarball}" ]; then
        echo "Calling etcupdate for DESTDIR=${_directory}"
        etcupdate -D "${_directory}" -t "${_etcupdate_tarball}"
    fi

    if [ "${_opt_keep}" != "yes" ]; then
        echo "Cleaning up..."""
        [ -n "${_u_tmpdir}" ] && [ -d "${_u_tmpdir}" ] && rm -rvf "${_u_tmpdir}"
    fi
    echo "Done."
}


#
# Global option handling
#
while getopts "Vh" _opt ; do
    case ${_opt} in
        V)
            printf 'ftjail %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
    datasets-tmpl)
        command_datasets_tmpl "$@"
        ;;
    mount-tmpl)
        command_mount_tmpl "$@"
        ;;
    umount-tmpl|unmount-tmpl)
        command_umount_tmpl "$@"
        ;;
    interlink-tmpl)
        command_interlink_tmpl "$@"
        ;;
    populate-tmpl)
        command_populate_tmpl "$@"
        ;;
    snapshot-tmpl)
        command_snapshot_tmpl "$@"
        ;;
    copy-skel)
        command_copy_skel "$@"
        ;;
    build-etcupdate-current-tmpl)
        command_build_etcupdate_current_tmpl "$@"
        ;;
    configure)
        echo "ERROR: use \`fjail configure' instead" 1>&2;
        exit 2
        ;;
    freebsd-update)
        command_freebsd_update "$@"
        ;;
    *)
        echo "ERROR: unknown command \`${command}'" 1>&2
        exit 2
        ;;
esac