systemtest/helpers.bash
#!/bin/bash

# Directory containing system test sources
TEST_SOURCE_DIR=${TEST_SOURCE_DIR:-$(dirname ${BASH_SOURCE})}

# Skopeo executable
SKOPEO_BINARY=${SKOPEO_BINARY:-${TEST_SOURCE_DIR}/../bin/skopeo}

# Default timeout for a skopeo command.
SKOPEO_TIMEOUT=${SKOPEO_TIMEOUT:-300}

# Default image to run as a local registry
REGISTRY_FQIN=${SKOPEO_TEST_REGISTRY_FQIN:-quay.io/libpod/registry:2.8.2}

###############################################################################
# BEGIN setup/teardown

# Provide common setup and teardown functions, but do not name them such!
# That way individual tests can override with their own setup/teardown,
# while retaining the ability to include these if they so desire.

function standard_setup() {
    # Argh. Although BATS provides $BATS_TMPDIR, it's just /tmp!
    # That's bloody worthless. Let's make our own, in which subtests
    # can write whatever they like and trust that it'll be deleted
    # on cleanup.
    TESTDIR=$(mktemp -d --tmpdir=${BATS_TMPDIR:-/tmp} skopeo_bats.XXXXXX)
}

function standard_teardown() {
    if [[ -n $TESTDIR ]]; then
        rm -rf $TESTDIR
    fi
}

# Individual .bats files may override or extend these
function setup() {
    standard_setup
}

function teardown() {
    standard_teardown
}

# END   setup/teardown
###############################################################################
# BEGIN standard helpers for running skopeo and testing results

#################
#  run_skopeo  #  Invoke skopeo, with timeout, using BATS 'run'
#################
#
# This is the preferred mechanism for invoking skopeo:
#
#  * we use 'timeout' to abort (with a diagnostic) if something
#    takes too long; this is preferable to a CI hang.
#  * we log the command run and its output. This doesn't normally
#    appear in BATS output, but it will if there's an error.
#  * we check exit status. Since the normal desired code is 0,
#    that's the default; but the first argument can override:
#
#     run_skopeo 125  nonexistent-subcommand
#     run_skopeo '?'  some-other-command       # let our caller check status
#
# Since we use the BATS 'run' mechanism, $output and $status will be
# defined for our caller.
#
function run_skopeo() {
    # Number as first argument = expected exit code; default 0
    expected_rc=0
    case "$1" in
        [0-9])           expected_rc=$1; shift;;
        [1-9][0-9])      expected_rc=$1; shift;;
        [12][0-9][0-9])  expected_rc=$1; shift;;
        '?')             expected_rc=  ; shift;;  # ignore exit code
    esac

    # Remember command args, for possible use in later diagnostic messages
    MOST_RECENT_SKOPEO_COMMAND="skopeo $*"

    # stdout is only emitted upon error; this echo is to help a debugger
    echo "\$ $SKOPEO_BINARY $*"
    run timeout --foreground --kill=10 $SKOPEO_TIMEOUT ${SKOPEO_BINARY} "$@"
    # without "quotes", multiple lines are glommed together into one
    if [ -n "$output" ]; then
        echo "$output"
    fi
    if [ "$status" -ne 0 ]; then
        echo -n "[ rc=$status ";
        if [ -n "$expected_rc" ]; then
            if [ "$status" -eq "$expected_rc" ]; then
                echo -n "(expected) ";
            else
                echo -n "(** EXPECTED $expected_rc **) ";
            fi
        fi
        echo "]"
    fi

    if [ "$status" -eq 124 -o "$status" -eq 137 ]; then
        # FIXME: 'timeout -v' requires coreutils-8.29; travis seems to have
        #        an older version. If/when travis updates, please add -v
        #        to the 'timeout' command above, and un-comment this out:
        # if expr "$output" : ".*timeout: sending" >/dev/null; then
        echo "*** TIMED OUT ***"
        false
    fi

    if [ -n "$expected_rc" ]; then
        if [ "$status" -ne "$expected_rc" ]; then
            die "exit code is $status; expected $expected_rc"
        fi
    fi
}

#################
#  log_and_run  #  log a command for later debugging, then run it
#################
#
# When diagnosing a test failure,  it can be really nice to see the
# more important commands that have been run in test setup: openssl,
# podman registry, other complex commands that can give one a boost
# when trying to reproduce problems. This simple wrapper takes a
# command as its arg, echoes it to stdout (with a '$' prefix),
# then runs the command. BATS does not show stdout unless there's
# an error. Use this judiciously.
#
function log_and_run() {
    echo "\$ $*"
    "$@"
}

#########
#  die  #  Abort with helpful message
#########
function die() {
    echo "#/vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv"  >&2
    echo "#| FAIL: $*"                                           >&2
    echo "#\\^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^" >&2
    false
}

###################
#  expect_output  #  Compare actual vs expected string; fail if mismatch
###################
#
# Compares $output against the given string argument. Optional second
# argument is descriptive text to show as the error message (default:
# the command most recently run by 'run_skopeo'). This text can be
# useful to isolate a failure when there are multiple identical
# run_skopeo invocations, and the difference is solely in the
# config or setup; see, e.g., run.bats:run-cmd().
#
# By default we run an exact string comparison; use --substring to
# look for the given string anywhere in $output.
#
# By default we look in "$output", which is set in run_skopeo().
# To override, use --from="some-other-string" (e.g. "${lines[0]}")
#
# Examples:
#
#   expect_output "this is exactly what we expect"
#   expect_output "foo=bar"  "description of this particular test"
#   expect_output --from="${lines[0]}"  "expected first line"
#
function expect_output() {
    # By default we examine $output, the result of run_skopeo
    local actual="$output"
    local check_substring=

    # option processing: recognize --from="...", --substring
    local opt
    for opt; do
        local value=$(expr "$opt" : '[^=]*=\(.*\)')
        case "$opt" in
            --from=*)       actual="$value";   shift;;
            --substring)    check_substring=1; shift;;
            --)             shift; break;;
            -*)             die "Invalid option '$opt'" ;;
            *)              break;;
        esac
    done

    local expect="$1"
    local testname="${2:-${MOST_RECENT_SKOPEO_COMMAND:-[no test name given]}}"

    if [ -z "$expect" ]; then
        if [ -z "$actual" ]; then
            return
        fi
        expect='[no output]'
    elif [ "$actual" = "$expect" ]; then
	return
    elif [ -n "$check_substring" ]; then
        if [[ "$actual" =~ $expect ]]; then
            return
        fi
    fi

    # This is a multi-line message, which may in turn contain multi-line
    # output, so let's format it ourselves, readably
    local -a actual_split
    readarray -t actual_split <<<"$actual"
    printf "#/vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\n" >&2
    printf "#|     FAIL: $testname\n"                          >&2
    printf "#| expected: '%s'\n" "$expect"                     >&2
    printf "#|   actual: '%s'\n" "${actual_split[0]}"          >&2
    local line
    for line in "${actual_split[@]:1}"; do
        printf "#|         > '%s'\n" "$line"                   >&2
    done
    printf "#\\^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n" >&2
    false
}

#######################
#  expect_line_count  #  Check the expected number of output lines
#######################
#
# ...from the most recent run_skopeo command
#
function expect_line_count() {
    local expect="$1"
    local testname="${2:-${MOST_RECENT_SKOPEO_COMMAND:-[no test name given]}}"

    local actual="${#lines[@]}"
    if [ "$actual" -eq "$expect" ]; then
        return
    fi

    printf "#/vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\n"          >&2
    printf "#| FAIL: $testname\n"                                       >&2
    printf "#| Expected %d lines of output, got %d\n" $expect $actual   >&2
    printf "#| Output was:\n"                                           >&2
    local line
    for line in "${lines[@]}"; do
        printf "#| >%s\n" "$line"                                       >&2
    done
    printf "#\\^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n"         >&2
    false
}

# END   standard helpers for running skopeo and testing results
###############################################################################
# BEGIN helpers for starting/stopping registries

####################
#  start_registry  #  Run a local registry container
####################
#
# Usage:  start_registry [OPTIONS] NAME
#
#   OPTIONS
#       --port=NNNN         Port to listen on (default: 5000)
#       --testuser=XXX      Require authentication; this is the username
#       --testpassword=XXX  ...and the password (these two go together)
#       --with-cert         Create a cert for running with TLS (not working)
#       --enable-delete     Set allowing registry deletions (default: false)
#
#   NAME is the container name to assign.
#
start_registry() {
    local port=5000
    local testuser=
    local testpassword=
    local create_cert=
    local enable_delete=false

    # option processing: recognize options for running the registry
    # in different modes.
    local opt
    for opt; do
        local value=$(expr "$opt" : '[^=]*=\(.*\)')
        case "$opt" in
            --port=*)           port="$value";          shift;;
            --testuser=*)       testuser="$value";      shift;;
            --testpassword=*)   testpassword="$value";  shift;;
            --with-cert)        create_cert=1;          shift;;
            --enable-delete=*)  enable_delete="$value"; shift;;
            -*)                 die "Invalid option '$opt'" ;;
            *)                  break;;
        esac
    done

    local name=${1?start_registry() invoked without a NAME}

    # Temp directory must be defined and must exist
    [[ -n $TESTDIR && -d $TESTDIR ]]

    AUTHDIR=$TESTDIR/auth
    mkdir -p $AUTHDIR

    local -a reg_args=(-v $AUTHDIR:/auth:Z -p $port:5000)
    if [[ "$enable_delete" == "true" ]]; then
        reg_args+=( -e REGISTRY_STORAGE_DELETE_ENABLED=true)
    fi

    # TODO: This is TEMPORARY (as of 2020-03-30); remove once crun is fixed.
    # Skopeo PR #836 claims there's a "regression" in crun with cgroupsv1,
    # but offers no details about what it is (crun issue nor PR) nor when/if
    # it's fixed. It's simply a workaround, forcing podman to use runc,
    # which might work great for skopeo CI but breaks Fedora gating tests.
    # Instead of always forcing runc, do so only when under cgroups v1:
    local runtime=
    cgroup_type=$(stat -f -c %T /sys/fs/cgroup)
    if [[ $cgroup_type == "tmpfs" ]]; then
        runtime="--runtime runc"
    fi

    # cgroup option necessary under podman-in-podman (CI tests),
    # and doesn't seem to do any harm otherwise.
    PODMAN="podman $runtime --cgroup-manager=cgroupfs"

    # Called with --testuser? Create an htpasswd file
    if [[ -n $testuser ]]; then
        if [[ -z $testpassword ]]; then
            die "start_registry() invoked with testuser but no testpassword"
        fi

        if ! grep -E -q "^$testuser:" $AUTHDIR/htpasswd; then
            htpasswd -Bbn $testuser $testpassword >> $AUTHDIR/htpasswd
        fi

        reg_args+=(
            -e REGISTRY_AUTH=htpasswd
            -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd
            -e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm"
        )
    fi

    # Called with --with-cert? Create certificates.
    if [[ -n $create_cert ]]; then
        CERT=$AUTHDIR/domain.crt
        if [ ! -e $CERT ]; then
            log_and_run openssl req -newkey rsa:4096 -nodes -sha256 \
                    -keyout $AUTHDIR/domain.key -x509 -days 2 \
                    -out $CERT \
                    -subj "/C=US/ST=Foo/L=Bar/O=Red Hat, Inc./CN=registry host certificate" \
                    -addext subjectAltName=DNS:localhost
        fi

        reg_args+=(
            -e REGISTRY_HTTP_TLS_CERTIFICATE=/auth/domain.crt
            -e REGISTRY_HTTP_TLS_KEY=/auth/domain.key
        )

        # Copy .crt file to a directory *without* the .key one, so we can
        # test the client. (If client sees a matching .key file, it fails)
        # Thanks to Miloslav Trmac for this hint.
        mkdir -p $TESTDIR/client-auth
        log_and_run cp $CERT $TESTDIR/client-auth/
    fi

    log_and_run $PODMAN run -d --name $name "${reg_args[@]}" $REGISTRY_FQIN

    # Wait for registry to actually come up
    timeout=10
    while [[ $timeout -ge 1 ]]; do
        if echo -n >/dev/tcp/127.0.0.1/$port; then
            return
        fi

        timeout=$(( timeout - 1 ))
        sleep 1
    done
    log_and_run $PODMAN logs $name
    die "Timed out waiting for registry container to respond on :$port"
}

# END   helpers for starting/stopping registries
###############################################################################
# BEGIN miscellaneous tools

###################
#  random_string  #  Returns a pseudorandom human-readable string
###################
#
# Numeric argument, if present, is desired length of string
#
function random_string() {
    local length=${1:-10}

    head /dev/urandom | tr -dc a-zA-Z0-9 | head -c$length
}

# END   miscellaneous tools
###############################################################################
