diff --git a/.gitignore b/.gitignore index ff74fda4594..cb1a01a8fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ stamp-h1 /tests/acceptance/25_cf-execd/Makefile /tests/acceptance/Makefile /tests/load/Makefile +/tests/reactor/Makefile /tests/unit/Makefile /tests/static-check/Makefile /tests/valgrind-check/Makefile diff --git a/configure.ac b/configure.ac index 5ed3b61a351..414aa1d4c66 100644 --- a/configure.ac +++ b/configure.ac @@ -2032,6 +2032,7 @@ AC_CONFIG_FILES([Makefile tests/acceptance/25_cf-execd/Makefile tests/unit/Makefile tests/load/Makefile + tests/reactor/Makefile tests/static-check/Makefile tests/valgrind-check/Makefile tests/asan-check/Makefile]) diff --git a/tests/Makefile.am b/tests/Makefile.am index 0f80e566120..8603fc839e3 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -21,4 +21,4 @@ # (COSL) may apply to this file if you as a licensee so wish it. See # included file COSL.txt. # -SUBDIRS = unit load acceptance static-check valgrind-check asan-check +SUBDIRS = unit load acceptance reactor static-check valgrind-check asan-check diff --git a/tests/reactor/Makefile.am b/tests/reactor/Makefile.am new file mode 100644 index 00000000000..eb5325d0fbf --- /dev/null +++ b/tests/reactor/Makefile.am @@ -0,0 +1,44 @@ +# +# Copyright 2026 Northern.tech AS +# +# This file is part of CFEngine 3 - written and maintained by Northern.tech AS. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA +# +# To the extent this program is licensed as part of the Enterprise +# versions of CFEngine, the applicable Commercial Open Source License +# (COSL) may apply to this file if you as a licensee so wish it. See +# included file COSL.txt. +# + +# The test scripts, with their policy next to them +REACTOR_TEST_SH = $(sort $(wildcard \ + $(srcdir)/tests/*/*.sh \ + $(srcdir)/tests/*/unsafe/*.sh)) +REACTOR_TEST_CF = $(REACTOR_TEST_SH:.sh=.cf) + +# cf-reactor is not built with builtin extensions (e.g. on Windows) +if !BUILTIN_EXTENSIONS +TESTS = $(patsubst $(srcdir)/%,%,$(REACTOR_TEST_SH)) +endif + +# Each test is run by run_test.sh, with the binaries of this build. The ones +# in an unsafe/ directory are skipped unless UNSAFE_TESTS=1. +TESTS_ENVIRONMENT = env \ + CF_REACTOR=$(abs_top_builddir)/cf-reactor/cf-reactor \ + CF_PROMISES=$(abs_top_builddir)/cf-promises/cf-promises \ + $(SHELL) $(srcdir)/run_test.sh + +EXTRA_DIST = lib.cf lib.sh run.sh run_test.sh \ + $(patsubst $(srcdir)/%,%,$(REACTOR_TEST_SH) $(REACTOR_TEST_CF)) diff --git a/tests/reactor/lib.sh b/tests/reactor/lib.sh index f49a25905a2..d9754155079 100644 --- a/tests/reactor/lib.sh +++ b/tests/reactor/lib.sh @@ -1,21 +1,81 @@ # Helpers sourced by the tests. Expects CF_REACTOR and LOG to be exported by -# run.sh. +# run_test.sh. +# +# Only POSIX tools are used, as the tests are run on all the platforms (e.g. +# no timeout(1), grep -m or date +%s). + +# Seconds to wait for something before giving up +WAIT_TIMEOUT=60 # number of log lines already consumed by wait_for_log log_offset=0 +# Run command $@ until it succeeds, for at most WAIT_TIMEOUT seconds, polling +# every 0.1 second, or every second where sleep only takes whole seconds. +# Fails if it never succeeded. Runs in a subshell without tracing, so that +# set -x doesn't log every poll. +wait_until() { + ( + set +x + if sleep 0.1 2>/dev/null; then + interval=0.1 polls=$((WAIT_TIMEOUT * 10)) + else + interval=1 polls=$WAIT_TIMEOUT + fi + until "$@"; do + [ "$polls" -gt 0 ] || exit 1 + polls=$((polls - 1)) + sleep "$interval" + done + ) +} + +# Print the current time in seconds since the Epoch. date +%s is not POSIX; +# without it, srand() seeds with the time of day and returns the last seed. +now() { + t=$(date +%s 2>/dev/null) + case "$t" in + ''|*[!0-9]*) awk 'BEGIN { srand(); print srand() }' ;; + *) echo "$t" ;; + esac +} + +# Print the first line of the reactor log after line $1 that contains $2, +# prefixed with its number counted from line $1. Fails if there is none. +log_match() { + tail -n "+$(($1 + 1))" "$LOG" 2>/dev/null | grep -n -F -- "$2" | head -n 1 | grep . +} + +# Fail unless the reactor log contains $1. +log_contains() { + grep -q -F -- "$1" "$LOG" 2>/dev/null +} + +# Print the number of lines of file $1, 0 if it doesn't exist. +count_lines() { + if [ -f "$1" ]; then + # without the padding of some wc implementations + echo $(( $(wc -l < "$1") )) + else + echo 0 + fi +} + +# Fail unless file $1 has at least $2 lines. +has_lines() { + [ "$(count_lines "$1")" -ge "$2" ] +} + # Wait until the reactor log contains $1 in a line after the last match, then # skip past that line, so that the next wait only sees newer output. wait_for_log() { - if ! match=$(timeout 60 sh -c ' - until tail -n "+$(($2 + 1))" "$1" | grep -n -F -m 1 -- "$3"; do - sleep 0.1 - done' _ "$LOG" "$log_offset" "$1") + if ! wait_until log_match "$log_offset" "$1" >/dev/null then echo "Timed out waiting for '$1' in reactor log:" >&2 cat "$LOG" >&2 return 1 fi + match=$(log_match "$log_offset" "$1") log_offset=$((log_offset + ${match%%:*})) } @@ -23,10 +83,7 @@ wait_for_log() { # The watcher thread may log before this line, so search the whole log and # leave the offset of wait_for_log as is. wait_for_watchers() { - if ! timeout 60 sh -c ' - until grep -q -F -- "$2" "$1"; do - sleep 0.1 - done' _ "$LOG" "verbose: cf-reactor event watcher has $1 watcher(s)" + if ! wait_until log_contains "verbose: cf-reactor event watcher has $1 watcher(s)" then echo "Timed out waiting for $1 watcher(s) in reactor log:" >&2 cat "$LOG" >&2 @@ -36,17 +93,14 @@ wait_for_watchers() { # Wait until file $1 has at least $2 lines, then fail if it has more than $2. wait_for_lines() { - if ! timeout 60 sh -c ' - until [ "$(cat "$1" 2>/dev/null | wc -l)" -ge "$2" ]; do - sleep 0.1 - done' _ "$1" "$2" + if ! wait_until has_lines "$1" "$2" then echo "Timed out waiting for $2 line(s) in '$1', reactor log:" >&2 cat "$LOG" >&2 return 1 fi - lines=$(wc -l < "$1") + lines=$(count_lines "$1") if [ "$lines" -ne "$2" ] then echo "Expected $2 line(s) in '$1', got $lines" >&2 diff --git a/tests/reactor/run.sh b/tests/reactor/run.sh index 17e21f198c5..8394314c5d6 100644 --- a/tests/reactor/run.sh +++ b/tests/reactor/run.sh @@ -2,96 +2,79 @@ # Usage: run.sh [--unsafe] # -# Must be run from the tests/reactor directory. +# Runs all the reactor tests, see run_test.sh, and prints a summary of their +# results. The output of a test is only printed if it failed. They are also +# run by "make check". # # Tests in an unsafe/ directory modify the system they are running on, so they -# are only run with --unsafe. +# are skipped unless run with --unsafe (or UNSAFE_TESTS=1). # # The binaries are taken from $CF_BINDIR if it is set, otherwise from the # CFEngine build tree this directory is part of if they are built, otherwise # from /var/cfengine/bin. -fail=0 - -unsafe=0 if [ "$1" = "--unsafe" ]; then - unsafe=1 + UNSAFE_TESTS=1 fi - -banner() { - name=" $(basename "$(dirname "$1")")/$(basename "$1") " - width=72 - left=$(( (width - ${#name}) / 2 )) - right=$(( width - ${#name} - left )) - [ "$left" -lt 0 ] && left=0 - [ "$right" -lt 0 ] && right=0 - printf '%s%s%s\n' \ - "$(printf "%${left}s" '' | tr ' ' '=')" \ - "$name" \ - "$(printf "%${right}s" '' | tr ' ' '=')" -} - -error() { - echo "test '$1' FAILED" >&2 - fail=1 -} - -# Print the path of binary $1, see the usage above. -find_binary() { - if [ -n "$CF_BINDIR" ]; then - echo "$CF_BINDIR/$1" - elif [ -x "$PWD/../../$1/$1" ]; then - # in-tree build, may be a libtool wrapper script - echo "$PWD/../../$1/$1" - else - echo "/var/cfengine/bin/$1" - fi +export UNSAFE_TESTS + +dir=$(cd "$(dirname "$0")" && pwd) + +# for now() +. "$dir/lib.sh" + +passed=0 +failed=0 +skipped=0 +total=0 +failures="" +suite_start=$(now) + +# Run test $1 and print its result, with its output if it failed +run_test() { + name=${1#"$dir/tests/"} + name=${name%.sh} + total=$((total + 1)) + + start=$(now) + output=$(sh "$dir/run_test.sh" "$1" 2>&1) + result=$? + elapsed=$(($(now) - start)) + + case $result in + 0) + passed=$((passed + 1)) + echo "--- PASS: $name (${elapsed}s)" + ;; + 77) + skipped=$((skipped + 1)) + echo "--- SKIP: $name (${elapsed}s)" + ;; + *) + failed=$((failed + 1)) + failures="$failures $name +" + echo "--- FAIL: $name (${elapsed}s)" + echo "$output" + echo "---" + ;; + esac } -CF_REACTOR=$(find_binary cf-reactor) -CF_PROMISES=$(find_binary cf-promises) -export CF_REACTOR - -for b in "$CF_REACTOR" "$CF_PROMISES"; do - if [ ! -x "$b" ]; then - echo "'$b' not found, build CFEngine or set CF_BINDIR" >&2 - exit 2 - fi -done -echo "Using $CF_REACTOR and $CF_PROMISES" - -for t in "$PWD"/tests/*/*.sh "$PWD"/tests/*/unsafe/*.sh; do +for t in "$dir"/tests/*/*.sh "$dir"/tests/*/unsafe/*.sh; do # skip the pattern itself if there are no unsafe tests [ -f "$t" ] || continue - case "$t" in - */unsafe/*) [ "$unsafe" = 1 ] || continue ;; - esac - - banner "$t" - - # create throwaway work dir - WORKDIR=$(mktemp -d) - mkdir -p "$WORKDIR/bin" "$WORKDIR/tmp" - ln -s "$CF_PROMISES" "$WORKDIR/bin/cf-promises" - - # Run a copy of the policy, keeping its path relative to lib.cf, as - # CFEngine refuses to read policy files writable by group or others, which - # those of a checkout made with a umask of 002 are - rel=${t#"$PWD"/} - policy="$WORKDIR/policy/${rel%.sh}.cf" - mkdir -p "$(dirname "$policy")" - cp lib.cf "$WORKDIR/policy/" - cp "${t%.sh}.cf" "$policy" - chmod 600 "$WORKDIR/policy/lib.cf" "$policy" - - CFENGINE_TEST_OVERRIDE_WORKDIR="$WORKDIR" - LOG="$WORKDIR/reactor.log" - PROMISE_NAME=$(basename "$t" .sh) - export WORKDIR CFENGINE_TEST_OVERRIDE_WORKDIR LOG PROMISE_NAME - - sh "$t" "$policy" || error "$t" - - rm -rf "$WORKDIR" + run_test "$t" done -exit $fail +echo "" +echo "==============================" +echo "Test Results: $passed passed, $failed failed, $skipped skipped (total: $total, $(($(now) - suite_start))s)" +if [ "$failed" -gt 0 ]; then + echo "" + echo "Failed tests:" + printf '%s' "$failures" + exit 1 +fi +echo "==============================" +echo "All reactor tests passed" diff --git a/tests/reactor/run_test.sh b/tests/reactor/run_test.sh new file mode 100644 index 00000000000..dc4b66fb79b --- /dev/null +++ b/tests/reactor/run_test.sh @@ -0,0 +1,100 @@ +#!/bin/sh + +# Usage: run_test.sh TEST +# +# Runs the reactor test TEST (a tests/*/*.sh or tests/*/unsafe/*.sh file) in a +# throwaway work dir, and exits with its result: 0 if it passed, 77 if it was +# skipped, anything else if it failed. Used as the test driver by "make check" +# and by run.sh. +# +# The output of the test, with its set -x trace, is only printed if it didn't +# pass, so that the output of the tests that pass doesn't bury it. +# +# Tests in an unsafe/ directory modify the system they are running on, so they +# are skipped unless UNSAFE_TESTS=1. +# +# The binaries are taken from $CF_REACTOR and $CF_PROMISES if they are set, +# otherwise from $CF_BINDIR if it is set, otherwise from the CFEngine build +# tree this directory is part of if they are built, otherwise from +# /var/cfengine/bin. + +if [ $# -ne 1 ]; then + echo "Usage: $0 TEST" >&2 + exit 2 +fi + +t=$1 + +# Explicitly use POSIX tools if needed (Solaris) +if [ -f /usr/xpg4/bin/grep ]; then + PATH=/usr/xpg4/bin:$PATH + export PATH +fi + +case "$t" in + */unsafe/*) + if [ "$UNSAFE_TESTS" != 1 ]; then + echo "Skipping unsafe test '$t', set UNSAFE_TESTS=1 to run it" + exit 77 + fi + ;; +esac + +# directory of lib.cf and lib.sh +REACTOR_TESTS_DIR=$(cd "$(dirname "$0")" && pwd) + +# Print the path of binary $1, see the usage above. +find_binary() { + if [ -n "$CF_BINDIR" ]; then + echo "$CF_BINDIR/$1" + elif [ -x "$REACTOR_TESTS_DIR/../../$1/$1" ]; then + # in-tree build, may be a libtool wrapper script + echo "$REACTOR_TESTS_DIR/../../$1/$1" + else + echo "/var/cfengine/bin/$1" + fi +} + +CF_REACTOR=${CF_REACTOR:-$(find_binary cf-reactor)} +CF_PROMISES=${CF_PROMISES:-$(find_binary cf-promises)} +export CF_REACTOR + +for b in "$CF_REACTOR" "$CF_PROMISES"; do + if [ ! -x "$b" ]; then + echo "'$b' not found, build CFEngine or set CF_BINDIR" >&2 + exit 2 + fi +done + +# Create the throwaway work dir, with mktemp -d where available +WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/cf-reactor-test.XXXXXX" 2>/dev/null) || { + WORKDIR="${TMPDIR:-/tmp}/cf-reactor-test.$$" + (umask 077 && mkdir "$WORKDIR") || exit 2 +} +WORKDIR=$(cd "$WORKDIR" && pwd) +trap 'rm -rf "$WORKDIR"' EXIT +mkdir -p "$WORKDIR/bin" "$WORKDIR/tmp" +ln -s "$CF_PROMISES" "$WORKDIR/bin/cf-promises" + +# Run a copy of the policy, keeping its path relative to lib.cf, as CFEngine +# refuses to read policy files writable by group or others, which those of a +# checkout made with a umask of 002 are +p="/$t" +rel="tests/${p##*/tests/}" +policy="$WORKDIR/policy/${rel%.sh}.cf" +mkdir -p "$(dirname "$policy")" +cp "$REACTOR_TESTS_DIR/lib.cf" "$WORKDIR/policy/" +cp "${t%.sh}.cf" "$policy" +chmod 600 "$WORKDIR/policy/lib.cf" "$policy" + +CFENGINE_TEST_OVERRIDE_WORKDIR="$WORKDIR" +LOG="$WORKDIR/reactor.log" +PROMISE_NAME=$(basename "$t" .sh) +export WORKDIR CFENGINE_TEST_OVERRIDE_WORKDIR LOG PROMISE_NAME + +sh "$t" "$policy" > "$WORKDIR/test.log" 2>&1 +result=$? +if [ "$result" -ne 0 ]; then + cat "$WORKDIR/test.log" +fi +exit "$result" diff --git a/tests/reactor/tests/00_basics/009_interval.sh b/tests/reactor/tests/00_basics/009_interval.sh index 4a749849370..df73a4cf09d 100644 --- a/tests/reactor/tests/00_basics/009_interval.sh +++ b/tests/reactor/tests/00_basics/009_interval.sh @@ -19,7 +19,7 @@ wait_for_log "File '$TESTFILE' is present, watching for its deletion" rm "$TESTFILE" wait_for_lines "$EVENTS_FILE" 1 check_event "$EVENTS_FILE" 1 "$TESTFILE was deleted" -t1=$(date +%s) +t1=$(now) # recreate the file and wait for the watcher to see it before deleting it again touch "$TESTFILE" @@ -27,7 +27,7 @@ wait_for_log "File '$TESTFILE' is present, watching for its deletion" rm "$TESTFILE" wait_for_lines "$EVENTS_FILE" 2 check_event "$EVENTS_FILE" 2 "$TESTFILE was deleted" -t2=$(date +%s) +t2=$(now) # checking that the interval is at minimum 10 seconds delta=$((t2 - t1)) diff --git a/tests/reactor/tests/00_basics/020_policy_reload.sh b/tests/reactor/tests/00_basics/020_policy_reload.sh index 48348e72338..d6476072c9d 100644 --- a/tests/reactor/tests/00_basics/020_policy_reload.sh +++ b/tests/reactor/tests/00_basics/020_policy_reload.sh @@ -30,7 +30,7 @@ reload_and_wait_for_paused_watchers() { kill -HUP "$reactor_pid" wait_for_log "Rereading policy file" - if ! timeout 60 sh -c 'until [ -f "$1" ]; do sleep 0.1; done' _ "$RELOAD_MARKER" + if ! wait_until [ -f "$RELOAD_MARKER" ] then echo "Timed out waiting for the policy to be reloaded, reactor log:" >&2 cat "$LOG" >&2 diff --git a/tests/reactor/tests/02_then_bundles/005_users.cf b/tests/reactor/tests/02_then_bundles/005_users.cf index 29bbb8063fd..d696e8ecefe 100644 --- a/tests/reactor/tests/02_then_bundles/005_users.cf +++ b/tests/reactor/tests/02_then_bundles/005_users.cf @@ -15,12 +15,15 @@ body when deleted(file) file_deleted => "$(file)"; } -bundle reactor run_users(user) +bundle reactor run_users { users: - # the user running the test, so that it is kept without being root - "$(user)" - policy => "present", + # a user that doesn't exist, so that the promise is kept without changing + # anything, whether or not the test runs as root. An existing user with + # policy "present" is unlocked if its password is locked, which happens + # to root on some systems. + "cf-reactor-test-no-such-user" + policy => "absent", classes => then_outcome("users"); } @@ -29,13 +32,10 @@ bundle reactor test vars: "test" string => getenv("TESTFILE", 999); "events_file" string => getenv("EVENTS_FILE", 999); - "user" string => getenv("TEST_USER", 999); "promise_name" string => basename("$(this.promise_filename)", ".cf"); events: "$(promise_name)" when => deleted("$(test)"), - then => { - run_users("$(user)"), record_then_outcome("$(events_file)", "users") - }; + then => { run_users, record_then_outcome("$(events_file)", "users") }; } diff --git a/tests/reactor/tests/02_then_bundles/005_users.sh b/tests/reactor/tests/02_then_bundles/005_users.sh index 84c8a509110..9601df9f8dd 100644 --- a/tests/reactor/tests/02_then_bundles/005_users.sh +++ b/tests/reactor/tests/02_then_bundles/005_users.sh @@ -13,9 +13,6 @@ EVENTS_FILE="$WORKDIR/tmp/events" export TESTFILE EVENTS_FILE -TEST_USER=$(id -un) -export TEST_USER - touch "$TESTFILE" start_reactor "$POLICY_FILE" diff --git a/tests/reactor/tests/02_then_bundles/008_guest_environments.cf b/tests/reactor/tests/02_then_bundles/008_guest_environments.cf index 9cbb424c202..eb03bec6746 100644 --- a/tests/reactor/tests/02_then_bundles/008_guest_environments.cf +++ b/tests/reactor/tests/02_then_bundles/008_guest_environments.cf @@ -24,7 +24,7 @@ bundle reactor run_guest_environments # no classes body here: the test looks at the log instead. "test" environment_type => "test", - environment_host => "$(sys.host)", + environment_host => canonify("$(sys.host)"), environment_state => "running"; } diff --git a/tests/reactor/tests/02_then_bundles/010_processes.sh b/tests/reactor/tests/02_then_bundles/010_processes.sh index b653eb059e9..ea0aa104fef 100644 --- a/tests/reactor/tests/02_then_bundles/010_processes.sh +++ b/tests/reactor/tests/02_then_bundles/010_processes.sh @@ -21,8 +21,13 @@ start_process() { ( sleep 978654 & echo $! > "$PID_FILE" ) } +# Fail if process $1 still exists +process_gone() { + ! kill -0 "$1" 2>/dev/null +} + wait_for_process_killed() { - if ! timeout 60 sh -c 'while kill -0 "$1" 2>/dev/null; do sleep 0.1; done' _ "$(cat "$PID_FILE")" + if ! wait_until process_gone "$(cat "$PID_FILE")" then echo "Process $(cat "$PID_FILE") not killed, reactor log:" >&2 cat "$LOG" >&2