view share/local-bsdtools/common.subr @ 644:0c7917469e04

Put the check for opened files with "procstat" into a subroutine and use it
author Franz Glasner <fzglas.hg@dom66.de>
date Fri, 27 Sep 2024 17:23:01 +0200
parents de090ff199ff
children aa21ec8b86c5
line wrap: on
line source

#!/bin/sh
# -*- mode: shell-script; indent-tabs-mode: nil; -*-
#:
#: :Author:    Franz Glasner
#: :Copyright: (c) 2017-2024 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@@
#:

#
# "procstat --libxo=json,no-locale -a file | jq -r $'.procstat.files | map(.) | .[] | [ .command, .files.[] | .fd, .fd_type, .path] | @tsv '
#
# WORKING (without the command name)
# procstat --libxo=json,no-locale -a file | jq -r $'.procstat.files | map(.)| .[] | .files.[] | [.fd, .fd_type, .path] | @tsv '
#
# FULL WITH command name
# procstat --libxo=json,no-locale -a file | jq -r $'.procstat.files | map(.) | .[] | .command as $cmd | .files[] | [ $cmd, .fd, .fd_type, .vode_type, .path ] | @tsv '



#:
#: Dummy function to make the first "shellcheck" directive below non-global
#: for this file.
#:
__dummy_for_shellcheck_must_be_first_function_in_common_subr() {
    :
}


# shellcheck disable=SC2223    # quote
#: The path to the external jq executable (JSON parser)
: ${JQ="/usr/local/bin/jq"}


#:
#: Display an error message to stderr and exit with given exit code
#:
#: Args:
#:   $1 (int): The exit code to exit with
#:   $2 ...: The message to print to
#:
#: Exit:
#:   Always exits with $1
#:
#: The name of the script is prepended to the error message always.
#:
fatal() {
    local _ec

    if [ $# -ge 1 ]; then
        _ec="$1"
        shift
    else
        _ec=1
    fi
    printf "%s: ERROR: %s\\n" "$0" "$*" 1>&2
    exit "${_ec}"
}


#:
#: Display an error message to stderr
#:
#: Args:
#:   $*: The message to print to
#:
#: Returns:
#:   int: 0
#:
#: The name of the script is prepended to the error message always.
#:
err() {
    printf "%s: ERROR: %s\\n" "$0" "$*" 1>&2
    return 0
}


#:
#: Display a warning to stderr
#:
#: Args:
#:   $*: the fields to display
#:
#: Returns:
#:   int: 0
#:
#: The name of the script is prepended to the warning message always.
#:
warn() {
    printf "%s: WARNING: %s\\n" "$0" "$*" 1>&2
    return 0
}


#:
#: Test $1 variable, and warn if not set to YES or NO.
#:
#: Args:
#:   $1 (str): the name of the variable to test to
#:
#: Returns:
#:   int: 0 (truthy) iff ``yes`` (et al),
#:        1 (falsy) otherwise.
#:
#: This function warns if other than proper YES/NO values are found.
#:
checkyesno() {
    local _value

    eval _value=\$\{"${1}"\}
    case "${_value}" in
        # "yes", "true", "on", or "1"
        [Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1)
            return 0
            ;;
        # "no", "false", "off", or "0"
        [Nn][Oo]|[Ff][Aa][Ll][Ss][Ee]|[Oo][Ff][Ff]|0)
            return 1
            ;;
        *)
            warn "\${${1}} is not set properly"
            return 1
            ;;
    esac
}


#:
#: Test $1 variable whether it is set to YES.
#:
#: Args:
#:   $1 (str): the name of the variable to test to
#:
#: Returns:
#:   int: 0 (truthy) iff ``yes`` (et al),
#:        1 (falsy) otherwise.
#:
#: Contrary to `checkyesno` this function does not warn if the variable
#: contains other values as YES.
#:
checkyes() {
    local _value

    eval _value=\$\{"${1}"\}
    case "${_value}" in
        # "yes", "true", "on", or "1"
        [Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1)
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}


#:
#: 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}" = '?' ] &&  fatal 2 "no option allowed (given: -${OPTARG-<unknown>})"
    done
    return 0
}


#:
#: 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_zfs_properties_for_create() {
    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
    printf '%s' "${_res}"
}


#:
#: Use `mount -t zfs -p` to determine the ZFS dataset for a given mountpoint
#:
#: Args:
#:  $1: the mountpoint
#:
#: Output (stdout):
#:   The name of the ZFS dataset that is mounted on `$1`
#:
#: Return:
#:   0: if a mounted dataset is found
#:   1: if no ZFS dataset is found that is mounted on `$1`
#:
#: The dataset has to be mounted.
#:
_get_zfs_dataset_for_mountpoint() {
    local _mountpoint

    local _ds _mount _rest

    _mountpoint="$1"

    if [ -x "${JQ}" ]; then
        /sbin/mount -t zfs -p --libxo=json,no-locale \
        | LC_ALL=C.UTF-8 "${JQ}" -r $'.mount.fstab[] | [.device, .mntpoint, .fstype, .opts, .dump, .pass] | @tsv ' \
        | {
            while IFS=$'\t' read -r _ds _mount _rest ; do
                if [ "$_mount" = "$_mountpoint" ]; then
                    printf '%s' "${_ds}"
                    return 0
                fi
            done
            return 1
          }
    else
        # Check for unexpected spaces
        if ! check_for_proper_fstab; then
            fatal 1 "Unexpected spaces in fstab. Please install \`${JQ}'."
        fi
        /sbin/mount -t zfs -p \
        | {
            while IFS=$' \t' read -r _ds _mount _rest ; do
                if [ "$_mount" = "$_mountpoint" ]; then
                    printf '%s' "${_ds}"
                    return 0
                fi
            done
            return 1
          }
    fi
}


#:
#: Determine the ZFS dataset that is mounted at `$1`/var/empty.
#:
#: Allow special handling for <mountpoint>/var/empty which may be
#: mounted read-only.
#:
#: Args:
#:  $1: the root mountpoint
#:
#: Output (stdout):
#:   The name of the ZFS dataset that is mounted on `$1`/var/empty
#:
#: Return:
#:   0: if a mounted dataset is found
#:   1: if no ZFS dataset is found that is mounted on `$1`
#:
#: The dataset has to be mounted.
#:
_get_zfs_dataset_for_varempty() {
    local _mountpoint

    local _ve_mount

    _mountpoint="$1"

    if [ "$_mountpoint" = '/' ]; then
        _ve_mount='/var/empty'
    else
        _ve_mount="${_mountpoint}/var/empty"
    fi

    _get_zfs_dataset_for_mountpoint "${_ve_mount}"
}


#:
#: 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: jail found but currently dying
#:   3: no running jail found
#:
_get_jail_from_path() {
    local _location

    local _name _path _dying

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

    if [ -x "${JQ}" ]; then
        /usr/sbin/jls --libxo=json,no-locale -d dying name path \
        | LC_ALL=C.UTF-8 "${JQ}" -r $'.["jail-information"].jail[] | [.dying, .name, .path] | @tsv ' \
        | { # '
            while IFS=$'\t' read -r _dying _name _path ; do
                if [ "${_path}" = "${_location}" ]; then
                    if [ "${_dying}" != "false" ]; then
                        echo "Jail \`${_name}' is currently dying" 1>&2
                        return 2
                    fi
                    printf '%s' "${_name}"
                    return 0
                fi
            done
            return 3
          }
    else
        /usr/sbin/jls --libxo=text,no-locale -d name dying path \
        | {
            while IFS=' '$'\t' read -r _name _dying _path ; do
                if [ "${_path}" = "${_location}" ]; then
                    if [ "${_dying}" != "false" ]; then
                        echo "Jail \`${_name}' is currently dying" 1>&2
                        return 2
                    fi
                    printf '%s' "${_name}"
                    return 0
                fi
            done
            return 3
        }
    fi
}


#:
#: Search for current mounts of a given ZFS dataset and its children.
#:
#: Only ZFS mounts are considered. Any returned datasets are just
#: the given mounted dataset or its mounted children.
#:
#: The output is sorted by the mountpoint.
#:
#: Args:
#:   $1 (str): The dataset the to check for.
#:             The dataset and its children are searched for.
#:
#: Other Parameters:
#:   -r (optional): If given the the output is sorted in reverse order.
#:
#: Output (stdout):
#:   The sorted list (lines) of mounts in :manpage:`fstab(5)` format.
#:   This list may be empty.
#:   The fields are separated by a single TAB character always.
#:
#: Exit:
#:   1: on fatal errors (usage et al.)
#:
_get_zfs_mounts_for_dataset_tree() {
    local _dsname
    local _opt_reversed

    local _opt _fstab

    _opt_reversed=""
    while getopts "r" _opt ; do
        case ${_opt} in
            r)
                _opt_reversed="--reverse"
                ;;
            \?)
                fatal 2 "invalid option given"
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _dsname="${1-}"
    [ -z "${_dsname}" ] && fatal 2 "no dataset given"

    if [ -x "${JQ}" ]; then
        /sbin/mount -t zfs -p --libxo=json,no-locale \
        | LC_ALL=C.UTF-8 "${JQ}" -r $'.mount.fstab[] | [.device, .mntpoint, .fstype, .opts, .dump, .pass] | @tsv ' \
        | LC_ALL=C.UTF-8 /usr/bin/awk -F '\t' -v OFS=$'\t' -v ds1="${_dsname}" -v ds2="${_dsname}/" $'{ if (($1 == ds1) || (index($1, ds2) == 1)) { print $1, $2, $3, $4, $5, $6; } }' \
        | LC_ALL=C.UTF-8 /usr/bin/sort --field-separator=$'\t' --key=2 ${_opt_reversed}
    else
        # Check for unexpected spaces
        if ! check_for_proper_fstab; then
            fatal 1 "Unexpected spaces in fstab. Please install \`${JQ}'."
        fi
        _fstab="$(/sbin/mount -t zfs -p | LC_ALL=C awk -v OFS=$'\t' -v ds1="${_dsname}" -v ds2="${_dsname}/" $'{ if (($1 == ds1) || (index($1, ds2) == 1)) { print $1, $2, $3, $4, $5, $6; } }' | LC_ALL=C /usr/bin/sort --field-separator=$'\t' --key=2 ${_opt_reversed})"
        printf '%s' "${_fstab}"
    fi
}


#:
#: Search for mounts and sub-mounts at a given directory.
#:
#: The output is sorted by the mountpoint.
#:
#: Args:
#:   $1 (str, optional): The directory where to start for mounts and sub-mounts.
#:       It must be an absolute path and may not have a trailing slash ``/``.
#:       The root directory :file:`/` is allowed.
#:       A `null` value is treated as if the root directory :file:`/`
#:       is given as argument.
#:
#: Other Parameters:
#:   -r (optional): If given the the output is sorted in reverse order.
#:
#: Output (stdout):
#:   The sorted list (lines) of mounts in :manpage:`fstab(5)` format.
#:   This list may be empty.
#:   The fields are separated by a single TAB character always.
#:
#: Exit:
#:   1: on fatal errors (usage et al.)
#:
#: Important:
#:   The input directory **must** be an absolute path.
#:
_get_mounts_at_directory() {
    local _directory
    local _opt_reversed

    local _opt _fstab _mp1 _mp2

    _opt_reversed=""
    while getopts "r" _opt ; do
        case ${_opt} in
            r)
                _opt_reversed="--reverse"
                ;;
            \?)
                fatal 2 "invalid option given"
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _directory="${1-}"
    case "${_directory}" in
        '')
            # OK this matches at the root directory
            _mp1='/'
            _mp2='/'
            ;;
        /)
            # OK this matches at the root directory
            _mp1='/'
            _mp2='/'
            ;;
        /*/)
            fatal 1 "a trailing slash in directory path given"
            ;;
        /*)
            _mp1="${_directory}"
            _mp2="${_directory}/"
            ;;
        *)
            fatal 1 "given directory must be an absolute path"
            ;;
    esac
    if [ -x "${JQ}" ]; then
        /sbin/mount -p --libxo=json,no-locale \
        | LC_ALL=C.UTF-8 "${JQ}" -r $'.mount.fstab[] | [.device, .mntpoint, .fstype, .opts, .dump, .pass] | @tsv ' \
        | LC_ALL=C.UTF-8 /usr/bin/awk -F '\t' -v OFS=$'\t' -v mp1="${_mp1}" -v mp2="${_mp2}" $'{ if (($2 == mp1) || (index($2, mp2) == 1)) { print; } }' \
        | LC_ALL=C.UTF-8 /usr/bin/sort --field-separator=$'\t' --key=2 ${_opt_reversed}
    else
        # Check for unexpected spaces
        if ! check_for_proper_fstab; then
            fatal 1 "Unexpected spaces in fstab. Please install \`${JQ}'."
        fi
        _fstab="$(/sbin/mount -p | LC_ALL=C awk -v OFS=$'\t' -v mp1="${_mp1}" -v mp2="${_mp2}" $'{ if (($2 == mp1) || (index($2, mp2) == 1)) { print $1, $2, $3, $4, $5, $6; } }' | LC_ALL=C /usr/bin/sort --field-separator=$'\t' --key=2 ${_opt_reversed})"
        printf '%s' "${_fstab}"
    fi
}


#:
#: Use :command:`/usr/bin/procstat` to check if there are opened files or
#: VM mappings with a given path prefix.
#:
#: Args:
#:   $1 (str, optional): The path prefix to check open files for.
#:       This must be an absolute path.
#:       The root directory :file:`/` is allowed.
#:       A `null` value is treated as if the root directory :file:`/` is given
#:       as argument.
#:
#: Returns:
#:   int: 0 if there are no opened files found,
#:        10 if there are opened files found.
#:        Other error codes may also returned on errors.
#:
_check_no_open_files_from_all_proc() {
    local _path_prefix

    local _command _fd _fd_type _vnode_type _path _pid _kve_type _kve_path _rc

    _path_prefix="${1-}"
    case "${_path_prefix}" in
        '')
            # OK this matches at the root directory
            _p1='/'
            _p2='/'
            ;;
        /)
            # OK this matches at the root directory
            _p1='/'
            _p2='/'
            ;;
        *//*)
            fatal 1 "given path prefix must not contain consequtive slashes"
            ;;
        /*/)
            # Remove trailing slash for equality check
            _p1="${_path_prefix%/}"
            # Hold the trailing slash as-is for prefix check
            _p2="${_path_prefix}"
            ;;
        /*)
            _p1="${_path_prefix}"
            _p2="${_path_prefix}/"
            ;;
        *)
            fatal 1 "given path prefix must be an absolute path"
            ;;
    esac
    if [ -x "${JQ}" ]; then
        # shellcheck disable=SC2016    # $cmd is really not to expand here
        LC_ALL=C.UTF-8 /usr/bin/procstat --libxo=json -a file | LC_ALL=C.UTF-8 "${JQ}" -r '.procstat.files | map(.)| .[] | .command as $cmd | .files[] | [ $cmd, .fd, .fd_type, .vode_type, .path ] | @tsv' \
        | {
            _rc=0
            while IFS=$'\t' read -r _command _fd _fd_type _vnode_type _path; do
                case "${_path}" in
                    "${_p1}"|"${_p2}"*)
                        _rc=10
                        ;;
                    *)
                        ;;
                esac
            done
            [ "${_rc}" -ne 0 ] && return ${_rc}
          }
        LC_ALL=C.UTF-8 /usr/bin/procstat --libxo=json -a vm | LC_ALL=C.UTF-8 "${JQ}" -r $'.procstat.vm | map(.) | .[] | .process_id as $pid | .vm[] | [ $pid, .kve_type, .kve_path ] | @tsv' \
        | {
            _rc=0
            while IFD=$'\t' read -r _pid _kve_type _kve_path; do
                case "${_kve_path}" in
                    "${_p1}"|"${_p2}"*)
                        _rc=10
                        ;;
                    *)
                        ;;
                esac
            done
            return ${_rc}
          }
    else
        /usr/bin/procstat --libxo=text,no-locale -a file \
        | LC_ALL=C /usr/bin/awk -v OFS=$'\t' '{ print $2, $3, $4, $10; }' \
        | {
            _rc=0
            while IFS=$'\t' read -r _command _fd _fd_type _path; do
                case "${_path}" in
                    "${_p1}"|"${_p2}"*)
                        _rc=10
                        ;;
                    *)
                        ;;
                esac
            done
            [ "${_rc}" -ne 0 ] && return ${_rc}
          }
        /usr/bin/procstat --libxo=text,no-locale -a vm \
        | LC_ALL=C /usr/bin/awk -v OFS=$'\t' '{ print $1, $10, $11; }' \
        | {
            _rc=0
            while IFD=$'\t' read -r _pid _kve_type _kve_path; do
                case "${_kve_path}" in
                    "${_p1}"|"${_p2}"*)
                        _rc=10
                        ;;
                    *)
                        ;;
                esac
            done
            return ${_rc}
          }
    fi
}


#:
#: Check the validity of ZFS dataset names.
#:
#: See: ZFS Component Naming Requirements
#: - https://docs.oracle.com/cd/E26505_01/html/E37384/gbcpt.html
#: - https://illumos.org/books/zfs-admin/zfsover-1.html#gbcpt
#:
#: But it seems that in OpenZFS a space character is also allowed in
#: name components; see https://github.com/openzfs/zfs/issues/439:
#: It is in zfs_namecheck.c:valid_char() line #55; its the last
#: character comparison.
#:
#: In OpenZFS pool names allow also the colon.
#:
#: Also there is no difference between "starting with" and other
#: "containing" (with the exception of pools starting with a alpha
#: character.
#:
#: Source code: https://iris.cs.tu-dortmund.de/freebsd-lockdoc/lockdoc-v13.0-0.1/source/sys/contrib/openzfs/module/zcommon/zfs_namecheck.c
#:
#: Args:
#:   $1 (str): The name of the dataset.
#:   $2 (bool, optional): If this evals to yes/on/1 then strict checking is
#:                        done, otherwise the OpenZFS implementation is
#:                        followed.
#:
#: Returns:
#:   int: 0 (truish) if it is a valid name,
#:        1 (falsy) if not.
#:
#: We never check for special pool names (such as ``mirror``, ``raidz``,
#: ``c0`` to ``c9`` et al.) because we do not create any pools.
#:
#: That also means that proper quoting with double quotes is enough to
#: handle all sorts of ZFS names.
#:
check_zfs_naming() {
    local _strict

    _strict="${2-}"
    if checkyes _strict; then
        # Oracle docs
        printf "%s" "$1" | LC_ALL=C /usr/bin/grep -q -E '^[A-Za-z][-A-Za-z0-9_.]*(/[A-Za-z0-9][-A-Za-z0-9:_.]*)*(@[A-Za-z0-9][-A-Za-z0-9:_.]*)?$'
    else
        # OpenZFS
        printf "%s" "$1" | LC_ALL=C /usr/bin/grep -q -E '^[A-Za-z][-A-Za-z0-9:_. ]*(/[-A-Za-z0-9:_. ]+)*(@[-A-Za-z0-9:_. ]+)?$'
    fi
}


#:
#: Check that the current fstab as returned by :command:`mount -p` does not
#: contain any spaces at improper -- and therefore unhandled -- locations.
#:
#: Returns:
#:   int: 0 (truish) if :command:`mount -p` contains no spaces,
#:        1 (falsy) otherwise
#:
check_for_proper_fstab() {
    local _dev _mp _fstype _opts _dump _pass _rest

    /sbin/mount -p \
    | {
        while IFS=$' \t' read -r _dev _mp _fstype _opts _dump _pass _rest; do
            if [ -n "${_rest}" ]; then
                return 1
            fi
            # XXX TBD: Check that _dump and _pass are numbers proper
        done
        return 0
      }
}


#:
#: Clean the current process environment somewhat
#:
reset_environment() {
    unset PATH_FSTAB
    unset GREP_OPTIONS

    # XXX: should we do this
    # export LC_ALL=C
}


# Automatically reset the environment
reset_environment