#!/bin/bash

### Setup we need right away

set -e
export LC_ALL=C  # predictable sorting


### Functions we need right away

fatal () {
    printf 'error: %s\n' "$1" 1>&2
    exit 1
}

warning () {
    printf 'warning: %s\n' "$1" 1>&2
}


### Setup

lib=$(cd "$(dirname "$0")" && pwd)/../lib/charliecloud
if [[ ! -f ${lib}/base.sh ]]; then
    fatal "install or build problem: not found: ${lib}/base.sh"
fi
. "${lib}/base.sh"
. "${lib}/contributors.bash"
export ch_bin

# shellcheck disable=SC2034
usage=$(cat <<EOF
Run some or all of the Charliecloud test suite.

Usage:

  $ $(basename "$0") [PHASE] [--scope SCOPE] [OPTIONS]

Valid phases: build, run, examples, all, mk-perm-dirs, clean, rm-perm-dirs
Valid scopes: quick, standard, full

Additional options:

  -b, --builder BUILDER  image builder to use
  --dry-run              print summary of what would be tested and exit
  --file FILE            run tests in FILE only
  -h, --help             print this help and exit
  --img-dir DIR          unpacked images directory; same as \$CH_TEST_IMGDIR
  --pack-dir DIR         packed imaged directory; same as \$CH_TEST_TARDIR
  --pack-fmt FMT         packed image format ("squash" or "tar")
  --pedandic {yes,no}    set pedantic mode rather than guessing
  --perm-dir DIR         permissions fixture dir, can repeat; \$CH_TEST_PERMDIRS
  --sudo                 enable tests that need sudo
  --lustre DIR           run-phase Lustre test directory; \$CH_TEST_LUSTREDIR
  --version              print version and exit

See the man page for important details.
EOF
)


### Functions

builder_check () {
    printf 'checking builder ...\n'
    case $CH_BUILDER in
    ch-image|ch-grow)
        if ! "${ch_bin}/ch-image" --dependencies; then
            fatal 'builder: ch-image: missing dependencies'
        fi
        bl=$(readlink -f "${ch_bin}/ch-image")
        bv=$("$bl" --version)
        ;;
    buildah)
        ;&
    buildah-runc)
        ;&
    buildah-setuid)
        if ! command -v buildah > /dev/null 2>&1; then
            fatal 'builder: buildah: not installed.'
        fi
        bl=$(command -v buildah)
        bv=$(buildah --version | awk '{print $3}')
        min='1.11.2'
        ;;
    docker)
        if ! command -v docker > /dev/null 2>&1; then
            fatal 'builder: docker: not installed'
        fi
        bl=$(command -v docker)
        bv=$(docker_ --version | awk '{print $3}' | sed -e 's/,$//')
        ;;
    none)
        bl='none'
        bv=
        ;;
    *)
        fatal "builder: $CH_BUILDER: not supported"
        ;;
    esac
    printf 'found: %s %s\n\n' "$bl" "$bv"
    version_check 'builder' "$min" "$bv"
}

builder_set () {
    width=$1
    if [[ -n $builder ]]; then
        export CH_BUILDER=$builder
        method='command line'
    elif [[ -n $CH_BUILDER ]]; then
        method='environment'
    else
        builder_choose
        method='default'
    fi
    if [[ $CH_BUILDER == ch-grow ]]; then
        export CH_BUILDER=ch-image
        cat <<'EOF' 1>&2

WARNING: ch-grow is now called ch-image. We plan to remove the ch-grow name in
Charliecloud version 0.23.

EOF
    fi
    if [[ -n $CH_GROW_STORAGE ]]; then
        export CH_IMAGE_STORAGE=$CH_GROW_STORAGE
        unset CH_GROW_STORAGE
        cat <<'EOF' 1>&2

WARNING: $CH_GROW_STORAGE is now called $CH_IMAGE_STORAGE. We plan to remove
the old name in Charliecloud version 0.23.

EOF
    fi
    printf "%-*s %s (%s)\n" "$width" 'builder:' "$CH_BUILDER" "$method"
    if [[ $CH_BUILDER == ch-image ]]; then
        vset CH_IMAGE_STORAGE '' "$CH_IMAGE_STORAGE" "/var/tmp/$USER/ch-image" \
                              "$width" 'ch-image storage'
    fi
}

# Create CH_TEST_IMGDIR, avoiding #347.
dir_img_mk () {
    dir_img_rm
    printf "creating %s\n\n" "$CH_TEST_IMGDIR"
    $ch_mpirun_node mkdir "$CH_TEST_IMGDIR"
    $ch_mpirun_node touch "$CH_TEST_IMGDIR/WEIRD_AL_YANKOVIC"
}

dir_img_rm () {
    # Remove CH_TEST_IMGDIR if 1) the directory is empty; or 2) the directory
    # is non-empty but contains a sentinel file.
    if find "$CH_TEST_TARDIR" -mindepth 1 &> /dev/null | read -r \
       &&  [[ ! -e $CH_TEST_TARDIR/WEIRD_AL_YANKOVIC ]]; then
        fatal "$CH_TEST_IMGDIR missing sentinel file; manually delete."
    fi
    echo "removing $CH_TEST_IMGDIR"
    $ch_mpirun_node rm -rf --one-file-system "$CH_TEST_IMGDIR"
}

# Remove a filesystem permissions fixture directory. Ensure that the target
# directory has exactly the two subdirectories expected first.
dir_perm_rm () {
    if [[    $(find "${1}" -maxdepth 1 -mindepth 1 | wc -l) == 2 \
          && -d "${1}/pass" && -d "${1}/nopass" ]]; then
        echo "removing ${1}"
        sudo rm -rf --one-file-system "$1"
    fi
}

# The run phase requires artifacts from a successful build phase. Thus, we
# check sanity based on the minimal set of artifacts (no builder).
dir_tar_check () {
    printf 'checking %s: ' "$CH_TEST_TARDIR"
    dir_tar_check_file chtest{.tar.gz,.sqfs}
    printf 'ok\n\n'
}

dir_tar_check_file () {
    local missing
    for f in "$@"; do
        if [[ -f ${CH_TEST_TARDIR}/${f} ]]; then
            return 0
        else
            missing+=("${CH_TEST_TARDIR}/${f}")
        fi
    done
    fatal "phase $phase: missing packed images: ${missing[*]}"
}

dir_tar_mk () {
    dir_tar_rm
    printf "creating %s\n\n" "$CH_TEST_TARDIR"
    $ch_mpirun_node mkdir "$CH_TEST_TARDIR"
    $ch_mpirun_node touch "${CH_TEST_TARDIR}/WEIRD_AL_YANKOVIC"
}

dir_tar_rm () {
    # Remove CH_TEST_TARDIR if 1) the directory is empty; or 2) the directory
    # is non-empty but contains a sentinel file.
    if find "$CH_TEST_TARDIR" -mindepth 1 &> /dev/null | read -r \
       && [[ ! -e $CH_TEST_TARDIR/WEIRD_AL_YANKOVIC ]]; then
        fatal "$CH_TEST_TARDIR missing sentinel file; manually delete."
    fi
    echo "removing $CH_TEST_TARDIR"
    $ch_mpirun_node rm -rf --one-file-system "$CH_TEST_TARDIR"
}

dir_tmp_rm () {
    if [[ $TMP_ == "/tmp/ch-test.tmp.${USER}" ]]; then
        echo "removing $TMP_"
        rm -rf --one-file-system "$TMP_"
    fi
}

dirs_unpriv_rm () {
    dir_tar_rm
    dir_img_rm
    dir_tmp_rm
}

pack_fmt_set () {
    width=$1
    if [[ -n $pack_fmt ]]; then
        case $pack_fmt in
        '🐘')  # elephant emoji
            pack_fmt=squash
            ;;
        '📠')  # fax machine emoji
            pack_fmt=tar
            ;;
        esac
        export CH_PACK_FMT=$pack_fmt
        method='command line'
    elif [[ -n $CH_PACK_FMT ]]; then
        method='environment'
    else
        pack_fmt_choose
        method='default'
    fi
    printf "%-*s %s (%s)\n" \
           "$width" 'packed image format:' "$CH_PACK_FMT" "$method"
    pack_fmt_valid "$CH_PACK_FMT"
}

pedantry_set () {
    width=$1
    default=no
    # Default to pedantic on CI or if user is a contributor.
    if [[ -n $ch_contributor || -n $CI ]]; then
        default=yes
    fi
    vset ch_pedantic "$pedantic" '' $default "$width" 'pedantic mode'
    if [[ $ch_pedantic == no ]]; then
        ch_pedantic=  # proper boolean
    fi
    # The motivation here is that in pedantic mode, we want to run all the
    # tests we reasonably can. So, if the user *has* sudo, then default --sudo
    # to yes. What is a little awkward is that "sudo -v" can generate a
    # password prompt in the middle of the status output. An alternative is
    # "sudo -nv", which doesn't; drawbacks are that you have to analyze the
    # output (not exit code) and it generates a failed password log message if
    # there is not already a sudo session going.
    if    [[ -n $ch_pedantic ]] \
       && command -v sudo > /dev/null \
       && sudo -v > /dev/null 2>&1; then
        use_sudo_default=yes
    else
        use_sudo_default=
    fi
}

pq_missing () {
    if [[ $phase == all || $phase == build ]]; then
        local img=$1
        local out=$2
        local tag
        tag=$(test_make_auto tag "$img")
        printf '%s\n' "$out" >> "${CH_TEST_TARDIR}/${tag}.pq_missing"
    fi
}

require_unset () {
    name=$1
    value=${!1}
    if [[ -n $value ]]; then
        fatal "$name: no multiple assignment (already \"$value\")"
    fi
}

scope_check () {
    case $1 in
    quick|standard|full)
        return 0
        ;;
    *)
        fatal "invalid scope: $1"
        ;;
    esac
}

# Assign scope a sortable opaque integer value. This value is used to help
# filter images and tests that are out of scope.
scope_to_digit () {
    case $1 in
        quick)
            echo 1
            ;;
        standard)
            echo 2
            ;;
        full)
            echo 3
            ;;
        skip*)
            # skip has the highest value to ensure it is always filtered out
            echo 4
            ;;
        *)
            fatal "scope '$scope' invalid"
    esac
}

test_build () {
    echo 'executing build phase tests ...'
    if [[ ! -f ${TMP_}/build_auto.bats ]]; then
        fatal "${TMP_}/build_auto.bats not found"
    fi
    bats "${TMP_}/build_auto.bats" build/*.bats
}

test_examples () {
    printf '\n'
    if [[ $CH_TEST_SCOPE == quick ]]; then
        echo "no examples for $CH_TEST_SCOPE scope"
    fi
    echo 'executing example phase tests ...'
    if find "$TMP_" -name '*_example.bats' | grep -q .; then
        bats "${TMP_}"/*_example.bats
    fi
}

test_make () {
    local bat_file
    local img_pack
    local pack_files
    local tag
    case $phase in
    build)
        printf "finding tests compatible with %s phase settings ...\n" "$phase"
        for i in $images $examples; do
           if test_make_check_image "$i"; then
               echo "found: $i"
               build_targets+=( "$i" )
           fi
        done
        printf '\n'
        printf 'generate build_auto.bats ...\n'
        test_make_auto "$phase" "${build_targets[@]}" > "${TMP_}/build_auto.bats"
        printf 'ok\n\n'
        ;;
    run)
        printf "finding tests compatible with %s phase settings ...\n" "$phase"
        # For each tarball or squashfs file in --pack-dir look for a
        # corresponding example or image that produces a matching tag. If
        # found, check the image for exclusion conidtions.
        pack_files=$(find "$CH_TEST_TARDIR"     -name '*.tar.gz' \
                                            -o  -name '*.sqfs' | sort)
        for i in $pack_files; do
            img_pack=${i##*/}
            img_pack=${img_pack%%.*}
            for j in $images $examples; do
                if [[ $(test_make_tag_from_path "$j") == "$img_pack" ]]; then
                    if test_make_check_image "$j"; then
                        echo "found: $i"
                        run_targets+=( "$j" )
                    fi
                fi
            done
        done
        printf '\n'
        printf 'generate run_auto.bats ...\n'
        test_make_auto "$phase" "${run_targets[@]}" > "${TMP_}/run_auto.bats"
        printf 'ok\n\n'
        ;;
    examples)
        printf "finding tests compatible with %s phase settings ...\n" "$phase"
        if [[ $CH_TEST_SCOPE == quick ]]; then
            echo 'found: none'
            return
        fi
        for i in $examples; do
            if test_make_check_image "$i"; then
                bat_file=$(dirname "$i")/test.bats
                tag=$(test_make_tag_from_path "$i")
                cp "$bat_file" "${TMP_}/${tag}_example.bats"
                # Substitute $ch_test_tag here with sed because we run all the
                # examples together later, but the value needs to vary between
                # the files. Watch the escaping here.
                sed -i "s/\\\$ch_test_tag/${tag}/g" \
                       "${TMP_}/${tag}_example.bats"
                echo "found: $(dirname "$i")"
            fi
        done
        printf '\n'
        printf 'generate example bat files ...\n'
        printf 'ok\n\n'
        ;;
    *)
        ;;
    esac
}

test_make_auto () {
    local mode
    mode=$1;shift
    if [[ $mode != tag ]]; then
        printf "# Do not edit this file; it's autogenerated\n\n"
        printf "load %s/common.bash\n\n" "$CHTEST_DIR"
    fi
    while [[ "$#" -gt 0 ]]; do
        path_=$1;shift
        basename_=$(basename "$path_")
        dirname_=$(dirname "$path_")
        tag=$(test_make_tag_from_path "$path_")

        if [[ $dir == "" ]];then
            dir='.'
        fi

        if [[ $mode == tag ]]; then
            echo "$tag"
            exit 0
        fi

        if [[ $mode == build ]]; then
            case $basename_ in
                Build|Build.*)
                    test_make_template_print 'build_custom.bats.in'
                    ;;
                Dockerfile|Dockerfile.*)
                    test_make_template_print 'build.bats.in'
                    test_make_template_print 'builder_to_archive.bats.in'
                    ;;
                *)
                    fatal "test_make_auto: unknown build type"
                    ;;
            esac
        elif [[ $mode == run ]];then
            test_make_template_print 'unpack.bats.in'
        else
             fatal "test_make_auto: invalid mode '$mode'"
        fi
    done
}

test_make_check_image () {
    img_ok=yes
    img=$1
    dir=$(basename "$(dirname "$img")")

    arch_exclude=$(cat "$img" | grep -F 'ch-test-arch-exclude: ' \
                              | sed 's/.*: //' | awk '{print $1}')

    builder_include=$(cat "$img" | grep -F 'ch-test-builder-include: ' \
                                 | sed 's/.*: //' | awk '{print $1}')

    builder_exclude=$(cat "$img" | grep -F 'ch-test-builder-exclude: ' \
                                 | sed 's/.*: //' | awk '{print $1}')

    img_scope_int=$(scope_to_digit "$(cat "$img" | grep -F 'ch-test-scope' \
                                                 | sed 's/.*: //' \
                                                 | awk '{print $1}')")

    sudo_required=$(cat "$img" | grep -F 'ch-test-need-sudo')

    if [[ $phase == 'build' ]]; then
        # Exclude Dockerfiles if we have no builder.
        if [[ $CH_BUILDER == none && $img == *Dockerfile* ]]; then
            pq_missing "$img" 'builder required'
            img_ok=
        fi
        # Exclude if included builders are given and $CH_BUILDER isn't one.
        if [[ -n $builder_include ]]; then
            builder_ok=
            for b in $builder_include; do
                if [[ $b == "$CH_BUILDER" ]]; then
                    builder_ok=yes
                fi
            done
            if [[ -z $builder_ok ]]; then
                pq_missing "$img" "builder not included: ${CH_BUILDER}"
                img_ok=
            fi
        fi
        # Exclude images that are not compatible with CH_BUILDER.
        for b in $builder_exclude; do
            if [[ $b == "$CH_BUILDER" ]]; then
                pq_missing "$img" "builder excluded: ${CH_BUILDER}"
                img_ok=
            fi
        done
    fi

    # Exclude images with a scope that is not a subset of CH_TEST_SCOPE.
    if [[ $scope_int -lt "$img_scope_int" ]]; then
        pq_missing "$img" "not in scope: ${CH_TEST_SCOPE}"
        img_ok=
    fi

    # Exclude images that do not work with the host architecture.
    for a in $arch_exclude; do
        if [[ $a == "$(uname -m)" ]]; then
            pq_missing "$img" "incompatible architecture: ${a}"
            img_ok=
        fi
    done

    # Exclude images that require sudo if CH_TEST_SUDO is empty
    if [[ -n $sudo_required && -z $CH_TEST_SUDO ]]; then
        pq_missing "$img" 'generic sudo required'
        img_ok=
    fi

    # In examples phase, exclude chtest and any images not in a subdirectory of
    # examples.
    if [[ $phase == examples && ( $dir == chtest || $dir == examples ) ]]; then
        img_ok=
    fi

    if [[ -n $img_ok ]]; then
        return 0  # include image
    else
        return 1  # exclude image
    fi
}

test_make_tag_from_path () {
    # Generate a tag from given path.
    #
    # Consider the following path: $CHTEST/examples/Dockerfile.openmpi
    # First break the path into four components:
    #   1) dir:        the parent directory of the file (examples)
    #   2) base:       the full file name (Dockerfile.openmpi)
    #   3) basicname:  the file name (Dockerfile)
    #   4) extension:  the file's extension (openmpi)
    #
    # $basicname must be 'Build' or 'Dockerfile'; otherwise error.
    # if $dir is '.', 'test', or 'examples' then tag=$extension; otherwise
    # tag is $extension if set or $dir-$exenstion.
    local base
    local basicname
    local dir
    local extension
    local tag
    dir=$(basename "$(dirname "$1")")  # last directory only
    base=$(basename "$1")
    basicname=${base%%.*}
    extension=${base##*.}

    if [[ $extension == "$basicname" ]]; then
        extension=''
    else
        extension=${extension/\./} # remove dot
    fi

    case $basicname in
        Build|Dockerfile)
            case $dir in
                .|test|examples)  # dot is directory "test"
                    if [[ -z $extension ]]; then
                        fatal "can't compute tag: $1"
                    else
                        tag=$extension
                    fi
                    ;;
                *)
                    if [[ -z $extension ]]; then
                        tag=$(basename "$dir")
                    else
                        tag=$(basename "${dir}-${extension}")
                    fi
            esac
            ;;
        *)
            fatal "test_make_auto: invalid basic name '$basicname'"
            ;;
    esac
    echo "$tag"
}

test_make_template_print () {
    local template
    template="./make-auto.d/$1"

    cat "$template" | sed "s@%(basename)s@$basename_@g" \
                    | sed "s@%(dirname)s@$dirname_@g" \
                    | sed "s@%(path)s@$path_@g" \
                    | sed "s@%(scope)s@$CH_TEST_SCOPE@g" \
                    | sed "s@%(tag)s@$tag@g"
    printf '\n'
    return
}

test_one_file () {
    if [[ $one_file != *.bats ]]; then
        # Assume it's a Dockerfile or Build file; derive tag and test.bats.
        ch_test_tag=$(test_make_tag_from_path "$one_file")
        export ch_test_tag
        one_file=$(dirname "$one_file")/test.bats
        printf 'tag:   %s\n' "$ch_test_tag"
    fi
    printf 'file:  %s\n' "$one_file"
    bats "$one_file"
}

test_run () {
    echo 'executing run phase tests ...'
    if [[ ! -f ${TMP_}/run_auto.bats ]]; then
        fatal "${TMP_}/run_auto.bats not found"
    fi
    bats run_first.bats "${TMP_}/run_auto.bats" ./run/*.bats
    set -e
    if [[ $CH_TEST_SCOPE != quick ]]; then
        for guest_user in $(id -un) root nobody; do
            for guest_group in $(id -gn) root $(id -gn nobody); do
                export GUEST_USER=$guest_user
                export GUEST_GROUP=$guest_group
                echo "testing as $GUEST_USER $GUEST_GROUP"
                bats run/ch-run_uidgid.bats
            done
        done
    fi
    set +e
}

# Exit with failure if given version number is below a minimum.
#
# $1: human-readable descriptor
# $2: minimum version
# $3: actual version
version_check () {
    desc=$1
    min=$2
    actual=$3
    if [[ $(  printf '%s\n%s\n' "$min" "$actual" \
            | sort -V | head -n1) != "$min" ]]; then
        fatal "$desc: mininum version $min, found $actual"
    fi
}

win () {
    printf "\nAll tests passed.\n"
}


### Body of script

# Ensure ch-run has been compiled (issue #329).
if ! "${ch_bin}/ch-run" --version > /dev/null 2>&1; then
    fatal "no working ch-run found in $ch_bin"
fi

# Ensure we have Bash 4.1 or higher
if /bin/bash -c 'set -e; [[ 1 = 0 ]]; exit 0'; then
    # Bash bug: [[ ... ]] expression doesn't exit with set -e
    # https://github.com/sstephenson/bats/issues/49
    fatal 'Bash minimum version is 4.1'
fi

# Create a directory to hold auto-generated test artifacts.
TMP_=/tmp/ch-test.tmp.$USER
if [[ ! -d $TMP_ ]]; then
    mkdir "$TMP_"
    chmod 700 "$TMP_"
fi

# Find test directories. (At one point we tried to unify the paths between the
# two conditions using a deeper directory hierarchy and symlinks to . in the
# source code to keep it from being even deeper, but this became too unruly.)
if [[ -d ${ch_base}/share ]]; then
    # installed
    CHTEST_INSTALLED=yes
    CHTEST_GITWD=
    CHTEST_DIR=${ch_base}/libexec/charliecloud/test
    CHTEST_EXAMPLES_DIR=${ch_base}/share/doc/charliecloud/examples
else
    # build dir
    CHTEST_INSTALLED=
    if [[ -d ${ch_base}/.git ]]; then
        CHTEST_GITWD=yes
    else
        CHTEST_GITWD=
    fi
    CHTEST_DIR=${ch_base}/test
    CHTEST_EXAMPLES_DIR=${ch_base}/examples
fi
export ch_base
export CHTEST_INSTALLED
export CHTEST_GITWD
export CHTEST_DIR
export CHTEST_EXAMPLES_DIR
export TMP_

# Check for test directory.
if [[ ! -d $CHTEST_DIR ]]; then
    fatal "test directory not found: $CHTEST_DIR"
fi
if [[ ! -d $CHTEST_EXAMPLES_DIR ]]; then
    fatal "examples not found: $CHTEST_EXAMPLES_DIR"
fi

# Is the user a contributor?
email=
# First, ask Git for the configured e-mail address.
if command -v git > /dev/null 2>&1; then
    email="$(git config --get user.email || true)"
fi
# If that doesn't work, construct it from the environment.
if [[ -z $email ]]; then
    email="$USER@$(hostname --domain)"
fi
ch_contributor=
for i in "${ch_contributors[@]}"; do
    if [[ $i == "$email" ]]; then
        ch_contributor=yes
    fi
done

# Ensure Bats is installed.
if command -v bats > /dev/null 2>&1; then
    bats=$(command -v bats)
    bats_version="$(bats --version | awk '{print $2}')"
else
    fatal 'Bats not found'
fi

if [[ $# == 0 ]]; then
    usage 1
fi

# Parse arguments.
while [[ $# -gt 0 ]]; do
    opt=$1; shift
    case $opt in
    all|build|clean|examples|mk-perm-dirs|rm-perm-dirs|run)
        require_unset phase
        phase=$opt
        ;;
    -b|--builder)
        require_unset builder
        builder=$1; shift
        ;;
    --builder=*)
        require_unset builder
        builder=${opt#*=}
        ;;
    --dry-run)
        dry=true
        ;;
    -f|--file)
        require_unset phase
        phase=one-file
        one_file=$1; shift
        ;;
    --file=*)
        require_unset phase
        phase=one-file
        one_file=${opt#*=}
        ;;
    -h|--help)
        usage 0
        ;;
    --img-dir)
        require_unset imgdir
        imgdir=$1; shift
        ;;
    --img-dir=*)
        require_unset imgdir
        imgdir=${opt#*=}
        ;;
    --is-pedantic)  # undocumented; for CI
        is_pedantic=yes
        ;;
    --is-sudo)      # undocumented; for CI
        is_sudo=yes
        ;;
    --pack-dir)
        require_unset tardir
        tardir=$1; shift
        ;;
    --pack-dir=*)
        require_unset tardir
        tardir=${opt#*=}
        ;;
    --pack-fmt)
        require_unset pack_fmt
        pack_fmt=$1; shift
        ;;
    --pack-fmt=*)
        require_unset pack_fmt
        pack_fmt=${opt#*=}
        ;;
    --pedantic)
        pedantic=$1; shift
        ;;
    --pedantic=*)
        pedantic=${opt#*=}
        ;;
    --perm-dir)
        use_sudo=yes
        permdirs+=("$1"); shift
        ;;
    --perm-dir=*)
        use_sudo=yes
        permdirs+=("${opt#*=}")
        ;;
    -s|--scope)
        require_unset scope
        scope_check "$1"
        scope=$1; shift
        ;;
    --scope=*)
        require_unset scope
        scope=${opt#*=}
        scope_check "$scope"
        ;;
    --sudo)
        use_sudo=yes
        ;;
    --lustre)
        require_unset lustredir
        lustredir=$1; shift
        ;;
    --lustre=*)
        require_unset lustredir
        lustredir=${opt#*=}
        ;;
    --version)
        version; exit 0
        ;;
    *)
        fatal "unrecognized argument: $opt"
        ;;
    esac
done

printf 'ch-run:       %s\n' "${ch_bin}/ch-run"
printf 'bats:         %s (%s)\n' "$bats" "$bats_version"
printf 'tests:        %s\n' "$CHTEST_DIR"
printf 'installed:    %s\n' "${CHTEST_INSTALLED:-no}"
printf 'git workdir:  %s\n' "${CHTEST_GITWD:-no}"
if [[ -n $ch_contributor ]]; then
    ch_contributor_note="yes; $email in README.rst"
else
    ch_contributor_note="no; $email not in README.rst"
fi
printf 'contributor:  %s\n\n' "$ch_contributor_note"

if [[ $phase = one-file ]]; then
    if [[ ! -f $one_file ]]; then
        fatal "not a file: $one_file"
    fi
    one_file=$(readlink -f "$one_file")  # make absolute b/c we cd later
    if [[ $one_file = */test.bats ]]; then
        fatal '--file: must specify build recipe file, not test.bats'
    fi
fi

printf "%-21s %s" 'phase:' "$phase"
if [[ $phase = one-file ]]; then
    printf ': %s' "$one_file"
fi
if [[ -z $phase ]]; then
    fatal 'phase: no phase specified'
fi
printf '\n'

#    variable name    CLI              environment         default
#                     desc. width  description
vset CH_TEST_SCOPE    "$scope"         "$CH_TEST_SCOPE"    standard \
                      21 'scope'
builder_set 21
pedantry_set 21
vset CH_TEST_SUDO     "$use_sudo"      "$CH_TEST_SUDO"     "$use_sudo_default" \
                      21 'use generic sudo'
vset CH_TEST_IMGDIR   "$imgdir"        "$CH_TEST_IMGDIR"   /var/tmp/img \
                      21 'unpacked images dir'
vset CH_TEST_TARDIR   "$tardir"        "$CH_TEST_TARDIR"   /var/tmp/tar \
                      21 'packed images dir'
pack_fmt_set 21
vset CH_TEST_PERMDIRS "${permdirs[*]}" "$CH_TEST_PERMDIRS" skip \
                      21 'fs permissions dirs'
vset CH_TEST_LUSTREDIR "$lustredir"    "$CH_TEST_LUSTREDIR" skip \
                      21 'Lustre test dir'
printf '\n'

if [[ $phase == *'perm'* ]] && [[ $CH_TEST_PERMDIRS == skip ]]; then
    fatal "phase $phase: CH_TEST_PERMDIRS: can't be 'skip'"
fi

# Check that different sources of version number are consistent.
printf 'ch-test version: %s\n' "$ch_version"
ch_run_version=$("${ch_bin}/ch-run" --version 2>&1)
if [[ $ch_version != "$ch_run_version" ]]; then
    warning "inconsistent ch-run version: ${ch_run_version}"
fi
if [[ -z $CHTEST_INSTALLED ]]; then
    cf_version=$("${ch_base}/configure" --version | head -1 | cut -d' ' -f3)
    if [[ $ch_version != "$cf_version" ]]; then
        warning "inconsistent configure version: ${cf_version}"
    fi
    src_version=$("${ch_base}/misc/version")
    if [[ $ch_version != "$src_version" ]]; then
        warning "inconsistent source version: ${src_version}"
    fi
fi
printf '\n'

# Ensure BATS_TMPDIR is set to /tmp (issue #278).
if [[ -n $BATS_TMPDIR && $BATS_TMPDIR != '/tmp' ]]; then
    fatal "BATS_TMPDIR: must be /tmp; found '$BATS_TMPDIR' (issue #278)"
fi

# Ensure namespaces are configured properly.
printf 'checking namespaces ...\n'
if ! "${ch_bin}/ch-checkns"; then
    fatal 'namespace sanity check (ch-checkns) failed'
fi
printf '\n'

if [[ $CH_TEST_SUDO ]]; then
    printf 'checking sudo ...\n'
    sudo echo ok
    printf '\n'
fi

if [[ -n $is_pedantic ]]; then
    printf 'exiting per --is-pedantic\n'
    if [[ -n $ch_pedantic ]]; then exit 0; else exit 1; fi
fi

if [[ -n $is_sudo ]]; then
    printf 'exiting per --is-sudo\n'
    if [[ -n $CH_TEST_SUDO ]]; then exit 0; else exit 1; fi
fi

if [[ -n $dry ]];then
    printf 'exiting per --dry-run\n'
    exit 0
fi

cd "$CHTEST_DIR"

export PATH=$ch_bin:$PATH

# Now that CH_TEST_* variables, PATH, and BATS_TMPDIR has been set and checked,
# we source CHTEST_DIR/common.bash.
. "${CHTEST_DIR}/common.bash"

# The distinction here is that "images" are purely for testing and have no
# value as examples for the user, while "examples" are dual-purpose. We call
# "find" twice for each to preserve desired sort order.
images=$(     find "$CHTEST_DIR"             -name 'Dockerfile.*' | sort \
           && find "$CHTEST_DIR"             -name 'Build' \
                                          -o -name 'Build.*' | sort)
examples=$(   find "$CHTEST_EXAMPLES_DIR"    -name 'Dockerfile' \
                                          -o -name 'Dockerfile.*' | sort \
           && find "$CHTEST_EXAMPLES_DIR"    -name 'Build' \
                                          -o -name 'Build.*' | sort)

scope_int=$(scope_to_digit "$CH_TEST_SCOPE")

# Execute phase
case $phase in
    all)
        phase=build
        dir_tar_mk
        builder_check
        test_make
        test_build

        phase=run
        dir_img_mk
        dir_tar_check
        test_make
        test_run

        phase=examples
        dir_tar_check
        test_make
        test_examples

        # Kobe.
        win
        ;;
    build)
        builder_check
        dir_tar_mk
        test_make
        test_build
        win
        ;;
    clean)
        dirs_unpriv_rm
        if [[ -d $TMP_ ]] && [[ -e $TMP_/build_auto.bats ]]; then
            echo "removing $TMP_"
            rm -rf --one-file-system "$TMP_"
        fi
        ;;
    examples)
        test_make
        test_examples
        win
        ;;
    mk-perm-dirs)
        printf 'creating filesystem permissions fixtures ...\n'
        for d in $CH_TEST_PERMDIRS; do
            if [[ -d ${d} ]]; then
                printf '%s already exists\n' "$d"
                continue
            else
                sudo "${CHTEST_DIR}/make-perms-test" "$d" "$USER" nobody
            fi
        done
        echo
        ;;
    one-file)
        test_one_file
        win
        ;;
    rm-perm-dirs)
        for d in $CH_TEST_PERMDIRS; do
            dir_perm_rm "$d"
        done
        ;;
    run)
        dir_img_mk
        test_make
        test_run
        win
        ;;
esac
