#!/usr/bin/env bash # # Usage: test-apiv2 [PORT] # # DEVELOPER NOTE: you almost certainly don't need to play in here. See README. # ME=$(basename $0) ############################################################################### # BEGIN stuff you can but probably shouldn't customize PODMAN_TEST_IMAGE_REGISTRY=${PODMAN_TEST_IMAGE_REGISTRY:-"quay.io"} PODMAN_TEST_IMAGE_USER=${PODMAN_TEST_IMAGE_USER:-"libpod"} PODMAN_TEST_IMAGE_NAME=${PODMAN_TEST_IMAGE_NAME:-"alpine_labels"} PODMAN_TEST_IMAGE_TAG=${PODMAN_TEST_IMAGE_TAG:-"latest"} PODMAN_TEST_IMAGE_FQN="$PODMAN_TEST_IMAGE_REGISTRY/$PODMAN_TEST_IMAGE_USER/$PODMAN_TEST_IMAGE_NAME:$PODMAN_TEST_IMAGE_TAG" IMAGE=$PODMAN_TEST_IMAGE_FQN REGISTRY_IMAGE="${PODMAN_TEST_IMAGE_REGISTRY}/${PODMAN_TEST_IMAGE_USER}/registry:2.7" # END stuff you can but probably shouldn't customize ############################################################################### # BEGIN setup TMPDIR=${TMPDIR:-/tmp} WORKDIR=$(mktemp --tmpdir -d $ME.tmp.XXXXXX) # Log of all HTTP requests and responses; always make '.log' point to latest LOGBASE=${TMPDIR}/$ME.log LOG=${LOGBASE}.$(date +'%Y%m%dT%H%M%S') ln -sf $LOG $LOGBASE HOST=localhost PORT=${PODMAN_SERVICE_PORT:-8081} # Keep track of test count and failures in files, not variables, because # variables don't carry back up from subshells. testcounter_file=$WORKDIR/.testcounter failures_file=$WORKDIR/.failures echo 0 >$testcounter_file echo 0 >$failures_file # Where the tests live TESTS_DIR=$(realpath $(dirname $0)) # As of 2021-11 podman has one external helper binary, rootlessport, needed # for rootless networking. if [[ -z "$CONTAINERS_HELPER_BINARY_DIR" ]]; then export CONTAINERS_HELPER_BINARY_DIR=$(realpath ${TESTS_DIR}/../../bin) fi # Path to podman binary PODMAN_BIN=${PODMAN:-${CONTAINERS_HELPER_BINARY_DIR}/podman} # Cleanup handlers clean_up_server() { if [ -n "$service_pid" ]; then # Remove any containers and images; this prevents the following warning: # 'rm: cannot remove '/.../overlay': Device or resource busy podman rm -a podman rmi -af stop_registry --cleanup stop_service fi } # Any non-test-related error, be it syntax or podman-command, fails here. err_handler() { echo "Fatal error in ${BASH_SOURCE[1]}:${BASH_LINENO[0]}" echo "Log:" sed -e 's/^/ >/' <$WORKDIR/output.log echo "Bailing." clean_up_server } trap err_handler ERR # END setup ############################################################################### # BEGIN infrastructure code - the helper functions used in tests themselves ######### # die # Exit error with a message to stderr ######### function die() { echo "$ME: $*" >&2 clean_up_server exit 1 } ######## # is # Simple comparison ######## function is() { local actual=$1 local expect=$2 local testname=$3 if [ "$actual" = "$expect" ]; then # On success, include expected value; this helps readers understand _show_ok 1 "$testname=$expect" return fi _show_ok 0 "$testname" "$expect" "$actual" } ############ # is_not # Simple disequality ############ function is_not() { local actual=$1 local expect_not=$2 local testname=$3 if [ "$actual" != "$expect_not" ]; then # On success, include expected value; this helps readers understand _show_ok 1 "$testname!=$expect" return fi _show_ok 0 "$testname" "!= $expect" "$actual" } ########## # like # Compare, but allowing patterns ########## function like() { local actual=$1 local expect=$2 local testname=$3 if expr "$actual" : "$expect" &>/dev/null; then # On success, include expected value; this helps readers understand # (but don't show enormous multi-line output like 'generate kube') blurb=$(head -n1 <<<"$actual") _show_ok 1 "$testname ('$blurb') ~ $expect" return fi _show_ok 0 "$testname" "~ $expect" "$actual" } ############## # _show_ok # Helper for is() and like(): displays 'ok' or 'not ok' ############## function _show_ok() { local ok=$1 # Exec tests include control characters; filter them out local testname=$(tr -d \\012 <<<"$2"|cat -vT) # If output is a tty, colorize pass/fail local red= local green= local reset= local bold= if [ -t 1 ]; then red='\e[31m' green='\e[32m' reset='\e[0m' bold='\e[1m' fi _bump $testcounter_file count=$(<$testcounter_file) # "skip" is a special case of "ok". Assume that our caller has included # the magical '# skip - reason" comment string. if [[ $ok == "skip" ]]; then # colon-plus: replace green with yellow, but only if green is non-null green="${green:+\e[33m}" ok=1 fi if [ $ok -eq 1 ]; then echo -e "${green}ok $count ${TEST_CONTEXT} $testname${reset}" echo "ok $count ${TEST_CONTEXT} $testname" >>$LOG return fi # Failed local expect=$3 local actual=$4 echo -e "${red}not ok $count ${TEST_CONTEXT} $testname${reset}" echo -e "${red}# expected: $expect${reset}" echo -e "${red}# actual: ${bold}$actual${reset}" echo "not ok $count ${TEST_CONTEXT} $testname" >>$LOG echo " expected: $expect" >>$LOG _bump $failures_file } ########### # _bump # Increment a counter in a file ########### function _bump() { local file=$1 count=$(<$file) echo $(( $count + 1 )) >| $file } ############# # jsonify # convert 'foo=bar,x=y' to json {"foo":"bar","x":"y"} ############# function jsonify() { # convert each to double-quoted form local -a settings_out for i in "$@"; do # Each argument is of the form foo=bar. Separate into left and right. local lhs local rhs IFS='=' read lhs rhs <<<"$i" if [[ $rhs =~ \" || $rhs == true || $rhs == false || $rhs =~ ^-?[0-9]+$ ]]; then # rhs has been pre-formatted for JSON or a non-string, do not change it : elif [[ $rhs == False ]]; then # JSON boolean is lowercase only rhs=false elif [[ $rhs == True ]]; then # JSON boolean is lowercase only rhs=true else rhs="\"${rhs}\"" fi settings_out+=("\"${lhs}\":${rhs}") done # ...and wrap inside braces, with comma separator if multiple fields (IFS=','; echo "{${settings_out[*]}}") } ####### # t # Main test helper ####### function t() { local method=$1; shift local path=$1; shift local -a curl_args local content_type="application/json" local testname="$method $path" # POST and PUT requests may be followed by one or more key=value pairs. # Slurp the command line until we see a 3-digit status code. if [[ $method = "POST" || $method == "PUT" || $method = "DELETE" ]]; then local -a post_args if [[ $method = "POST" ]]; then function _add_curl_args() { curl_args+=(--data-binary @$1); } else function _add_curl_args() { curl_args+=(--upload-file $1); } fi for arg; do case "$arg" in *=*) post_args+=("$arg"); shift;; *.json) _add_curl_args $arg; content_type="application/json"; shift;; *.tar) _add_curl_args $arg; content_type="application/x-tar"; shift;; *.yaml) _add_curl_args $arg; shift;; application/*) content_type="$arg"; shift;; [1-9][0-9][0-9]) break;; *) die "Internal error: invalid POST arg '$arg'" ;; esac done if [[ -z "$curl_args" ]]; then curl_args=(-d $(jsonify ${post_args[@]})) testname="$testname [${curl_args[@]}]" fi fi # entrypoint path can include a descriptive comment; strip it off path=${path%% *} local url=$path if ! [[ $path =~ ^'http://' ]]; then # path may include JSONish params that curl will barf on; url-encode them path="${path//'['/%5B}" path="${path//']'/%5D}" path="${path//'{'/%7B}" path="${path//'}'/%7D}" path="${path//':'/%3A}" # If given path begins with /, use it as-is; otherwise prepend /version/ url=http://$HOST:$PORT case "$path" in /*) url="$url$path" ;; libpod/*) url="$url/v4.0.0/$path" ;; *) url="$url/v1.41/$path" ;; esac fi # curl -X HEAD but without --head seems to wait for output anyway if [[ $method == "HEAD" ]]; then curl_args+=("--head") fi # If this is set, we're *expecting* curl to time out if [[ -n "$APIV2_TEST_EXPECT_TIMEOUT" ]]; then curl_args+=("-m" $APIV2_TEST_EXPECT_TIMEOUT) fi local expected_code=$1; shift # Log every action we do echo "-------------------------------------------------------------" >>$LOG echo "\$ $testname" >>$LOG rm -f $WORKDIR/curl.* # -s = silent, but --write-out 'format' gives us important response data # The hairy "{ ...;rc=$?; } || :" lets us capture curl's exit code and # give a helpful diagnostic if it fails. { response=$(curl -s -X $method "${curl_args[@]}" \ -H "Content-type: $content_type" \ --dump-header $WORKDIR/curl.headers.out \ --write-out '%{http_code}^%{content_type}^%{time_total}' \ -o $WORKDIR/curl.result.out "$url"); rc=$?; } || : # Special case: this means we *expect and want* a timeout if [[ -n "$APIV2_TEST_EXPECT_TIMEOUT" ]]; then # Hardcoded. See curl(1) for list of exit codes if [[ $rc -eq 28 ]]; then _show_ok 1 "$testname: curl timed out (expected)" else _show_ok 0 "$testname: expected curl to time out; it did not" fi return fi # Any error from curl is instant bad news, from which we can't recover if [[ $rc -ne 0 ]]; then die "curl failure ($rc) on $url - cannot continue" fi # Show returned headers (without trailing ^M or empty lines) in log file. # Sometimes -- I can't remember why! -- we don't get headers. if [[ -e $WORKDIR/curl.headers.out ]]; then tr -d '\015' < $WORKDIR/curl.headers.out | egrep '.' >>$LOG fi IFS='^' read actual_code content_type time_total <<<"$response" printf "X-Response-Time: ${time_total}s\n\n" >>$LOG # Log results, if text. If JSON, filter through jq for readability. if [[ $content_type =~ /octet ]]; then output="[$(file --brief $WORKDIR/curl.result.out)]" echo "$output" >>$LOG elif [[ -e $WORKDIR/curl.result.out ]]; then # Output from /logs sometimes includes NULs. Strip them. output=$(tr -d '\0' < $WORKDIR/curl.result.out) if [[ $content_type =~ application/json ]] && [[ $method != "HEAD" ]]; then jq . <<<"$output" >>$LOG else echo "$output" >>$LOG fi else output= echo "[no output]" >>$LOG fi # Test return code is "$actual_code" "$expected_code" "$testname : status" # Special case: 204/304, by definition, MUST NOT return content (rfc2616) if [[ $expected_code = 204 || $expected_code = 304 ]]; then if [ -n "$*" ]; then die "Internal error: ${expected_code} status returns no output; fix your test." fi if [ -n "$output" ]; then _show_ok 0 "$testname: ${expected_code} status returns no output" "''" "$output" fi return fi local i # Special case: if response code does not match, dump the response body # and skip all further subtests. if [[ $actual_code != $expected_code ]]; then echo -e "# response: $output" for i; do _show_ok skip "$testname: $i # skip - wrong return code" done return fi for i; do if expr "$i" : '[^\!]\+\!=.\+' >/dev/null; then # Disequality on json field json_field=$(expr "$i" : '\([^!]*\)!') expect_not=$(expr "$i" : '[^\!]*\!=\(.*\)') actual=$(jq -r "$json_field" <<<"$output") is_not "$actual" "$expect_not" "$testname : $json_field" elif expr "$i" : "[^=~]\+=.*" >/dev/null; then # Exact match on json field json_field=$(expr "$i" : "\([^=]*\)=") expect=$(expr "$i" : '[^=]*=\(.*\)') actual=$(jq -r "$json_field" <<<"$output") is "$actual" "$expect" "$testname : $json_field" elif expr "$i" : "[^=~]\+~.*" >/dev/null; then # regex match on json field json_field=$(expr "$i" : "\([^~]*\)~") expect=$(expr "$i" : '[^~]*~\(.*\)') actual=$(jq -r "$json_field" <<<"$output") like "$actual" "$expect" "$testname : $json_field" else # Direct string comparison is "$output" "$i" "$testname : output" fi done } ################### # start_service # Run the socket listener ################### service_pid= function start_service() { # If there's a listener on the port, nothing for us to do { exec 3<> /dev/tcp/$HOST/$PORT; } &>/dev/null && return test -x $PODMAN_BIN || die "Not found: $PODMAN_BIN" if [ "$HOST" != "localhost" ]; then die "Cannot start service on non-localhost ($HOST)" fi # FIXME: EXPERIMENTAL: 2022-06-13: podman rootless needs a namespace. If # system-service is the first podman command run (as is the case in CI) # this will happen as a fork-exec, where the parent podman creates the # namespace and the child is the server. Then, when stop_service() kills # the parent, the child (server) happily stays alive and ruins subsequent # tests that try to restart service with different settings. # Workaround: run an unshare to get namespaces initialized. if [[ $(id -u) != 0 ]]; then $PODMAN_BIN unshare true fi $PODMAN_BIN \ --root $WORKDIR/server_root --syslog=true \ system service \ --time 0 \ tcp:127.0.0.1:$PORT \ &> $WORKDIR/server.log & service_pid=$! echo "# started service, pid $service_pid" wait_for_port $HOST $PORT } function stop_service() { # Stop the server if [[ -n $service_pid ]]; then kill $service_pid || : wait $service_pid || : echo "# stopped service, pid $service_pid" fi service_pid= if { exec 3<> /dev/tcp/$HOST/$PORT; } &>/dev/null; then echo "# WARNING: stop_service: Service still running on port $PORT" fi } #################### # start_registry # Run a local registry #################### REGISTRY_PORT= REGISTRY_USERNAME= REGISTRY_PASSWORD= function start_registry() { # We can be called multiple times, but each time should start a new # registry container with (possibly) different configuration. That # means that all callers must be responsible for invoking stop_registry. if [[ -n "$REGISTRY_PORT" ]]; then die "start_registry invoked twice in succession, without stop_registry" fi # First arg is auth type (default: "none", but can also be "htpasswd") local auth="${1:-none}" REGISTRY_PORT=$(random_port) local REGDIR=$WORKDIR/registry local AUTHDIR=$REGDIR/auth mkdir -p $AUTHDIR mkdir -p ${REGDIR}/{root,runroot} local PODMAN_REGISTRY_ARGS="--root ${REGDIR}/root --runroot ${REGDIR}/runroot" # Give it three tries, to compensate for network flakes podman ${PODMAN_REGISTRY_ARGS} pull $REGISTRY_IMAGE || podman ${PODMAN_REGISTRY_ARGS} pull $REGISTRY_IMAGE || podman ${PODMAN_REGISTRY_ARGS} pull $REGISTRY_IMAGE # Create a local cert (no need to do this more than once) if [[ ! -e $AUTHDIR/domain.key ]]; then # FIXME: is there a hidden "--quiet" flag? This is too noisy. openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout $AUTHDIR/domain.key -x509 -days 2 \ -out $AUTHDIR/domain.crt \ -subj "/C=US/ST=Foo/L=Bar/O=Red Hat, Inc./CN=registry host certificate" \ -addext subjectAltName=DNS:localhost fi # If invoked with auth=htpasswd, create credentials REGISTRY_USERNAME= REGISTRY_PASSWORD= declare -a registry_auth_params=(-e "REGISTRY_AUTH=$auth") if [[ "$auth" = "htpasswd" ]]; then REGISTRY_USERNAME=u$(random_string 7) REGISTRY_PASSWORD=p$(random_string 7) htpasswd -Bbn ${REGISTRY_USERNAME} ${REGISTRY_PASSWORD} \ > $AUTHDIR/htpasswd registry_auth_params+=( -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" -e "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd" ) fi # Run the registry, and wait for it to come up podman ${PODMAN_REGISTRY_ARGS} run -d \ -p ${REGISTRY_PORT}:5000 \ --name registry \ -v $AUTHDIR:/auth:Z \ "${registry_auth_params[@]}" \ -e REGISTRY_HTTP_TLS_CERTIFICATE=/auth/domain.crt \ -e REGISTRY_HTTP_TLS_KEY=/auth/domain.key \ ${REGISTRY_IMAGE} wait_for_port localhost $REGISTRY_PORT 10 echo "# started registry (auth=$auth) on port $PORT" } function stop_registry() { local REGDIR=${WORKDIR}/registry if [[ -d $REGDIR ]]; then local OPTS="--root ${REGDIR}/root --runroot ${REGDIR}/runroot" podman $OPTS stop -i -t 0 registry # rm/rmi are important when running rootless: without them we # get EPERMS in tmpdir cleanup because files are owned by subuids. podman $OPTS rm -f -i registry if [[ "$1" = "--cleanup" ]]; then podman $OPTS rmi -f -a fi echo "# stopped registry on port $PORT" fi REGISTRY_PORT= REGISTRY_USERNAME= REGISTRY_PASSWORD= } ################# # random_port # Random open port; arg is range (min-max), default 5000-5999 ################# function random_port() { local range=${1:-5000-5999} local port for port in $(shuf -i ${range}); do if ! { exec 5<> /dev/tcp/127.0.0.1/$port; } &>/dev/null; then echo $port return fi done die "Could not find open port in range $range" } ################### # random_string # Pseudorandom alphanumeric string of given length ################### function random_string() { local length=${1:-10} head /dev/urandom | tr -dc a-zA-Z0-9 | head -c$length } ################### # wait_for_port # Returns once port is available on host ################### function wait_for_port() { local host=$1 # Probably "localhost" local port=$2 # Numeric port local _timeout=${3:-5} # Optional; default to 5 seconds local path=/dev/tcp/$host/$port # Wait local i=$_timeout while [ $i -gt 0 ]; do { exec 3<> /dev/tcp/$host/$port; } &>/dev/null && return sleep 1 i=$(( $i - 1 )) done die "Timed out (${_timeout}s) waiting for service ($path)" } ############ # podman # Needed by some test scripts to invoke the actual podman binary ############ function podman() { echo "\$ $PODMAN_BIN $*" >>$WORKDIR/output.log # env CONTAINERS_REGISTRIES_CONF=$TESTS_DIR/../registries.conf \ $PODMAN_BIN --root $WORKDIR/server_root "$@" >>$WORKDIR/output.log 2>&1 } #################### # root, rootless # Is server rootless? #################### ROOTLESS= function root() { ! rootless } function rootless() { if [[ -z $ROOTLESS ]]; then ROOTLESS=$(curl -s http://$HOST:$PORT/v1.40/info | jq .Rootless) fi test "$ROOTLESS" = "true" } # True if cgroups v2 are enabled function have_cgroupsv2() { cgroup_type=$(stat -f -c %T /sys/fs/cgroup) test "$cgroup_type" = "cgroup2fs" } # END infrastructure code ############################################################################### # BEGIN sanity checks for tool in curl jq podman; do type $tool &>/dev/null || die "$ME: Required tool '$tool' not found" done # END sanity checks ############################################################################### # BEGIN entry handler (subtest invoker) echo '============================= test session starts ==============================' echo "podman client -- $(curl --version)" # Identify the tests to run. If called with args, use those as globs. tests_to_run=() if [ -n "$*" ]; then shopt -s nullglob for i; do match=(${TESTS_DIR}/*${i}*.at) if [ ${#match} -eq 0 ]; then die "No match for $TESTS_DIR/*$i*.at" fi tests_to_run+=("${match[@]}") done shopt -u nullglob else tests_to_run=($TESTS_DIR/*.at) fi echo -e "collected ${#tests_to_run[@]} items\n" start_service for i in ${tests_to_run[@]}; do TEST_CONTEXT="[$(basename $i .at)]" # Clear output from 'podman' helper >| $WORKDIR/output.log source $i done # END entry handler ############################################################################### clean_up_server test_count=$(<$testcounter_file) failure_count=$(<$failures_file) if [ -z "$PODMAN_TESTS_KEEP_WORKDIR" ]; then rm -rf $WORKDIR fi echo "1..${test_count}" exit $failure_count