From fa780fb9d2cf37c2ee8addb1b1411d0ccff5785b Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Tue, 27 Oct 2026 15:57:41 +0100 Subject: [PATCH 01/11] Run reactor tests in jenkins Added the reactor tests to jenkins, to be able to run them in jenkins. This means we can test cf-reactor on all the different platforms. Signed-off-by: Victor Moene --- .gitignore | 1 + configure.ac | 1 + tests/Makefile.am | 2 +- tests/reactor/Makefile.am | 44 +++++++++ tests/reactor/lib.sh | 82 ++++++++++++++--- tests/reactor/run.sh | 65 +++---------- tests/reactor/run_test.sh | 92 +++++++++++++++++++ tests/reactor/tests/00_basics/009_interval.sh | 4 +- .../tests/00_basics/020_policy_reload.sh | 2 +- .../tests/02_then_bundles/010_processes.sh | 7 +- 10 files changed, 229 insertions(+), 71 deletions(-) create mode 100644 tests/reactor/Makefile.am create mode 100644 tests/reactor/run_test.sh 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..dcf07967ae5 100644 --- a/tests/reactor/run.sh +++ b/tests/reactor/run.sh @@ -2,10 +2,11 @@ # Usage: run.sh [--unsafe] # -# Must be run from the tests/reactor directory. +# Runs all the reactor tests, see run_test.sh. 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 only 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 @@ -13,10 +14,10 @@ fail=0 -unsafe=0 if [ "$1" = "--unsafe" ]; then - unsafe=1 + UNSAFE_TESTS=1 fi +export UNSAFE_TESTS banner() { name=" $(basename "$(dirname "$1")")/$(basename "$1") " @@ -36,62 +37,22 @@ error() { 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 -} - -CF_REACTOR=$(find_binary cf-reactor) -CF_PROMISES=$(find_binary cf-promises) -export CF_REACTOR +dir=$(cd "$(dirname "$0")" && pwd) -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 ;; + */unsafe/*) [ "$UNSAFE_TESTS" = 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" + sh "$dir/run_test.sh" "$t" + case $? in + 0|77) ;; + *) error "$t" ;; + esac done exit $fail diff --git a/tests/reactor/run_test.sh b/tests/reactor/run_test.sh new file mode 100644 index 00000000000..22e2f299a14 --- /dev/null +++ b/tests/reactor/run_test.sh @@ -0,0 +1,92 @@ +#!/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. +# +# 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" 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/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 From 824257acbc0412a6b95686c51dbf1e207a8705a4 Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Tue, 27 Oct 2026 16:50:48 +0100 Subject: [PATCH 02/11] Added better logging of the cf-reactor tests Made the logs prettier and added a log file where the xtrace is printed Signed-off-by: Victor Moene --- tests/reactor/run.sh | 90 ++++++++++++++++++++++++--------------- tests/reactor/run_test.sh | 10 ++++- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/tests/reactor/run.sh b/tests/reactor/run.sh index dcf07967ae5..8394314c5d6 100644 --- a/tests/reactor/run.sh +++ b/tests/reactor/run.sh @@ -2,57 +2,79 @@ # Usage: run.sh [--unsafe] # -# Runs all the reactor tests, see run_test.sh. They are also run by -# "make check". +# 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 (or UNSAFE_TESTS=1). +# 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 - if [ "$1" = "--unsafe" ]; then UNSAFE_TESTS=1 fi export UNSAFE_TESTS -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 ' ' '=')" -} +dir=$(cd "$(dirname "$0")" && pwd) -error() { - echo "test '$1' FAILED" >&2 - fail=1 -} +# for now() +. "$dir/lib.sh" -dir=$(cd "$(dirname "$0")" && pwd) +passed=0 +failed=0 +skipped=0 +total=0 +failures="" +suite_start=$(now) -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_TESTS" = 1 ] || continue ;; - esac +# 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)) - banner "$t" + start=$(now) + output=$(sh "$dir/run_test.sh" "$1" 2>&1) + result=$? + elapsed=$(($(now) - start)) - sh "$dir/run_test.sh" "$t" - case $? in - 0|77) ;; - *) error "$t" ;; + 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 +} + +for t in "$dir"/tests/*/*.sh "$dir"/tests/*/unsafe/*.sh; do + # skip the pattern itself if there are no unsafe tests + [ -f "$t" ] || continue + 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 index 22e2f299a14..dc4b66fb79b 100644 --- a/tests/reactor/run_test.sh +++ b/tests/reactor/run_test.sh @@ -7,6 +7,9 @@ # 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. # @@ -89,4 +92,9 @@ LOG="$WORKDIR/reactor.log" PROMISE_NAME=$(basename "$t" .sh) export WORKDIR CFENGINE_TEST_OVERRIDE_WORKDIR LOG PROMISE_NAME -sh "$t" "$policy" +sh "$t" "$policy" > "$WORKDIR/test.log" 2>&1 +result=$? +if [ "$result" -ne 0 ]; then + cat "$WORKDIR/test.log" +fi +exit "$result" From 1d6d3a400f866a57377c75ebab706d9ee4cd2db0 Mon Sep 17 00:00:00 2001 From: Lars Erik Wik Date: Tue, 27 Oct 2026 14:54:44 +0100 Subject: [PATCH 03/11] cf-reactor: Added user_added and user_removed events Ticket: ENT-14765 Changelog: `cf-reactor` can now react to a user being added or removed, with the `user_added` and `user_removed` attributes of `when` bodies Signed-off-by: Lars Erik Wik --- .github/workflows/reactor_tests.yml | 2 +- cf-reactor/Makefile.am | 1 + cf-reactor/reactor_transform.c | 7 + cf-reactor/user_watcher.c | 285 ++++++++++++++++++ cf-reactor/user_watcher.h | 37 +++ libpromises/mod_reactor.c | 2 + libpromises/mod_reactor.h | 2 + .../01_event_types/unsafe/002_user_events.cf | 38 +++ .../01_event_types/unsafe/002_user_events.sh | 45 +++ tests/unit/Makefile.am | 3 + tests/unit/user_watcher_test.c | 252 ++++++++++++++++ 11 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 cf-reactor/user_watcher.c create mode 100644 cf-reactor/user_watcher.h create mode 100644 tests/reactor/tests/01_event_types/unsafe/002_user_events.cf create mode 100644 tests/reactor/tests/01_event_types/unsafe/002_user_events.sh create mode 100644 tests/unit/user_watcher_test.c diff --git a/.github/workflows/reactor_tests.yml b/.github/workflows/reactor_tests.yml index b182dbe6796..5c3e8779b03 100644 --- a/.github/workflows/reactor_tests.yml +++ b/.github/workflows/reactor_tests.yml @@ -22,4 +22,4 @@ jobs: ln -s "$PWD/cf-reactor/cf-reactor" "$PWD/cf-promises/cf-promises" "$RUNNER_TEMP/bin/" cd tests/reactor chmod -R go-w . - CF_BINDIR="$RUNNER_TEMP/bin" sh run.sh + CF_BINDIR="$RUNNER_TEMP/bin" sh run.sh --unsafe diff --git a/cf-reactor/Makefile.am b/cf-reactor/Makefile.am index 4036d272376..3c84df15546 100644 --- a/cf-reactor/Makefile.am +++ b/cf-reactor/Makefile.am @@ -45,6 +45,7 @@ libcf_reactor_la_SOURCES = \ reactor_context.c reactor_context.h \ reactor_transform.c reactor_transform.h \ stoppable_thread.c stoppable_thread.h \ + user_watcher.c user_watcher.h \ wakeup_channel.c wakeup_channel.h \ watcher.c watcher.h diff --git a/cf-reactor/reactor_transform.c b/cf-reactor/reactor_transform.c index c3925ffd251..fbf08ef5ee5 100644 --- a/cf-reactor/reactor_transform.c +++ b/cf-reactor/reactor_transform.c @@ -42,6 +42,7 @@ #include // REACTOR_INTERVAL_RANGE, REACTOR_FILE_DELETED #include // CheckConstraintTypeMatch(), PromiseTypeSyntaxGet(), BodySyntaxGetConstraintSyntax() #include +#include #include /* Promise types evaluated within `bundle reactor NAME { ... }`. */ @@ -166,9 +167,15 @@ static const EventArg NO_EXTRA_ARGS[] = #define FileEventTypeNew(name, check) \ EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, FileWatcherStateNew, FileWatcherStateDestroy, check) +/* A user event, about the user with the name of its constraint */ +#define UserEventTypeNew(name, check) \ + EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, UserWatcherStateNew, UserWatcherStateDestroy, check) + static const EventType EVENT_TABLE[] = { FileEventTypeNew(REACTOR_FILE_DELETED, FileWatcherCheckFileDeleted), + UserEventTypeNew(REACTOR_USER_ADDED, UserWatcherCheckUserAdded), + UserEventTypeNew(REACTOR_USER_REMOVED, UserWatcherCheckUserRemoved), EventTypeNewNull() }; diff --git a/cf-reactor/user_watcher.c b/cf-reactor/user_watcher.c new file mode 100644 index 00000000000..3a82a99eb22 --- /dev/null +++ b/cf-reactor/user_watcher.c @@ -0,0 +1,285 @@ +/* + 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. +*/ + +#include +#include +#include +#include +#include +#include + +#ifdef __MINGW32__ +#include +#else +#include +#endif + +/* Minimum time between two logs of a persisting failure to look up the user, + * since the user is looked up on every poll */ +#define CHECK_ERROR_LOG_INTERVAL_SECS 60 + +typedef struct +{ + char *name; + time_t last_checked; /* Time of the last successful check, 0 if none yet */ + time_t + last_error_logged; /* Time the last error was logged, 0 if no errors */ + + /* What the last successful check observed */ + bool last_exists; +} UserWatcherState; + + +/** + * @brief Creates the state of a user_added or user_removed watcher. + * + * @param args The arguments of the event, the name of the user to watch + * @return The new state, to be destroyed with UserWatcherStateDestroy() + */ +void *UserWatcherStateNew(const Rlist *args) +{ + assert(args != NULL); + const char *name = RlistScalarValue(args); + + UserWatcherState *state = + (UserWatcherState *) xcalloc(1, sizeof(UserWatcherState)); + state->name = xstrdup(name); + + return state; +} + +/** + * @brief Destroys the state of a user watcher. + * + * @param state The watcher state, may be NULL + */ +void UserWatcherStateDestroy(void *_state) +{ + UserWatcherState *state = _state; + if (state != NULL) + { + free(state->name); + free(state); + } +} + +/** + * @brief Logs the error message of a failed operation performed by the + * watcher. + * + * A persisting failure is logged at most once every + * CHECK_ERROR_LOG_INTERVAL_SECS. + * + * @param state The watcher state + * @param operation The operation (e.g., LookupAccountName() or getpwnam_r()) + * @param err The error code (errno on Unix, Win32 error code on Windows) + */ +static void LogCheckError( + UserWatcherState *state, const char *operation, int err) +{ + assert(state != NULL); + + const time_t now = time(NULL); + if ((state->last_error_logged == 0) + || (now - state->last_error_logged >= CHECK_ERROR_LOG_INTERVAL_SECS)) + { + Log(LOG_LEVEL_ERR, + "Unable to look up user '%s' while checking for user events (%s: %s)", + state->name, + operation, + GetErrorStrFromCode(err)); + state->last_error_logged = now; + } +} + +/** + * @brief Checks whether the user of the watcher exists. + * + * Looks up the one name only, without listing all users, so it is safe to + * call while the main thread runs bundles. On Windows, only user accounts + * count, not groups or built-in accounts such as SYSTEM. + * + * @param state The watcher state + * @param[out] exists Whether user exists + * @return false on error + */ +static bool UserLookup(UserWatcherState *state, bool *exists) +{ + assert(state != NULL); + assert(exists != NULL); + +#ifdef __MINGW32__ + char sid[CF_MAXSIDSIZE]; + SID_NAME_USE type; + DWORD error = NovaWin_LookupAccountName( + state->name, (SID *) sid, sizeof(sid), &type); + if (error == ERROR_SUCCESS) + { + *exists = (type == SidTypeUser); + } + else if (NovaWin_IsAccountNotFound(error)) + { + *exists = false; + } + else + { + LogCheckError(state, "LookupAccountName", error); + return false; + } +#else + struct passwd pwd; + struct passwd *result = NULL; + char buf[GETPW_R_SIZE_MAX]; + int ret = getpwnam_r(state->name, &pwd, buf, sizeof(buf), &result); + if (result != NULL) + { + *exists = true; + } + else if (ret == 0) + { + /* POSIX: not found is a NULL result with a return value of 0. Any + * other return value is an error, so the user is not taken as + * removed on a failing lookup. */ + *exists = false; + } + else + { + LogCheckError(state, "getpwnam_r", ret); + return false; + } +#endif + + state->last_error_logged = 0; + return true; +} + +/** + * @brief Records the result of a successful check. + * + * The first successful check is only the reference for the next ones, so it + * never fires. + * + * @param state The watcher state + * @param exists Whether the user exists + * @param happened Whether the event happened since the last check + * @param event The name of the event for logging (e.g. "removal") + * @return WATCHER_CHECK_EVENT if the event fired, WATCHER_CHECK_NO_EVENT + * otherwise + */ +static WatcherCheckResult UserWatcherRecordObservation( + UserWatcherState *state, bool exists, bool happened, const char *event) +{ + assert(state != NULL); + assert(event != NULL); + + const time_t now = time(NULL); + happened = happened && (state->last_checked != 0); + + if (happened) + { + char last_checked_str[26]; + const char *last_checked_ts = + cf_strtimestamp_local(state->last_checked, last_checked_str); + + char now_str[26]; + const char *now_ts = cf_strtimestamp_local(now, now_str); + + Log(LOG_LEVEL_VERBOSE, + "Observed %s of user '%s' at %s (last checked at %s)", + event, + state->name, + (now_ts != NULL) ? now_ts : "unknown time", + (last_checked_ts != NULL) ? last_checked_ts : "unknown time"); + } + + state->last_checked = now; + state->last_exists = exists; + return happened ? WATCHER_CHECK_EVENT : WATCHER_CHECK_NO_EVENT; +} + +/** + * @brief Checks for the user_added event. + * + * Fires once when the user goes from absent to present. + * + * @param state The watcher state + * @return WATCHER_CHECK_EVENT if the user was added since the last check, + * WATCHER_CHECK_ERROR if the lookup failed, WATCHER_CHECK_NO_EVENT + * otherwise + */ +WatcherCheckResult UserWatcherCheckUserAdded(void *_state) +{ + assert(_state != NULL); + UserWatcherState *state = _state; + + bool exists; + if (!UserLookup(state, &exists)) + { + /* Unknown state, keep the last observation as is */ + return WATCHER_CHECK_ERROR; + } + + if (!exists && (state->last_exists || state->last_checked == 0)) + { + Log(LOG_LEVEL_VERBOSE, + "User '%s' is absent, watching for its addition", + state->name); + } + + bool added = !state->last_exists && exists; + return UserWatcherRecordObservation(state, exists, added, "addition"); +} + +/** + * @brief Checks for the user_removed event. + * + * Fires once when the user goes from present to absent. + * + * @param state The watcher state + * @return WATCHER_CHECK_EVENT if the user was removed since the last check, + * WATCHER_CHECK_ERROR if the lookup failed, WATCHER_CHECK_NO_EVENT + * otherwise + */ +WatcherCheckResult UserWatcherCheckUserRemoved(void *_state) +{ + assert(_state != NULL); + UserWatcherState *state = _state; + + bool exists; + if (!UserLookup(state, &exists)) + { + /* Unknown state, keep the last observation as is */ + return WATCHER_CHECK_ERROR; + } + + if (exists && !state->last_exists) + { + Log(LOG_LEVEL_VERBOSE, + "User '%s' is present, watching for its removal", + state->name); + } + + bool removed = state->last_exists && !exists; + return UserWatcherRecordObservation(state, exists, removed, "removal"); +} diff --git a/cf-reactor/user_watcher.h b/cf-reactor/user_watcher.h new file mode 100644 index 00000000000..4d4a414316d --- /dev/null +++ b/cf-reactor/user_watcher.h @@ -0,0 +1,37 @@ +/* + 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. +*/ + +#ifndef CFENGINE_USER_WATCHER_H +#define CFENGINE_USER_WATCHER_H + +#include +#include + + +void *UserWatcherStateNew(const Rlist *args); +WatcherCheckResult UserWatcherCheckUserAdded(void *state); +WatcherCheckResult UserWatcherCheckUserRemoved(void *state); +void UserWatcherStateDestroy(void *state); + +#endif diff --git a/libpromises/mod_reactor.c b/libpromises/mod_reactor.c index b65761c42cf..4f7e8b30860 100644 --- a/libpromises/mod_reactor.c +++ b/libpromises/mod_reactor.c @@ -42,6 +42,8 @@ static const ConstraintSyntax when_constraints[] = /* Row models */ ConstraintSyntaxNewString(REACTOR_FILE_DELETED, CF_ANYSTRING, "File to react for on deletion", SYNTAX_STATUS_NORMAL), + ConstraintSyntaxNewString(REACTOR_USER_ADDED, CF_ANYSTRING, "User to react for on addition", SYNTAX_STATUS_NORMAL), + ConstraintSyntaxNewString(REACTOR_USER_REMOVED, CF_ANYSTRING, "User to react for on removal", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewInt("interval", REACTOR_INTERVAL_RANGE, "Seconds between two checks for the event", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewNull() }; diff --git a/libpromises/mod_reactor.h b/libpromises/mod_reactor.h index d84ed22643c..2be286a4030 100644 --- a/libpromises/mod_reactor.h +++ b/libpromises/mod_reactor.h @@ -28,6 +28,8 @@ #include #define REACTOR_FILE_DELETED "file_deleted" +#define REACTOR_USER_ADDED "user_added" +#define REACTOR_USER_REMOVED "user_removed" /* Range of the 'interval' attribute of the 'when' body of events promises */ #define REACTOR_INTERVAL_RANGE "1,99999999999" diff --git a/tests/reactor/tests/01_event_types/unsafe/002_user_events.cf b/tests/reactor/tests/01_event_types/unsafe/002_user_events.cf new file mode 100644 index 00000000000..3e2352168fa --- /dev/null +++ b/tests/reactor/tests/01_event_types/unsafe/002_user_events.cf @@ -0,0 +1,38 @@ +####################################################### +# +# Test the user_added and user_removed events +# +####################################################### +body common control +{ + inputs => { "../../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when added(user) +{ + user_added => "$(user)"; +} + +body when removed(user) +{ + user_removed => "$(user)"; +} + +bundle reactor test +{ + vars: + "test_user" string => getenv("TEST_USER", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + + events: + "$(promise_name)_added" + when => added("$(test_user)"), + then => record_event("$(events_file)", "added"); + + "$(promise_name)_removed" + when => removed("$(test_user)"), + then => record_event("$(events_file)", "removed"); +} diff --git a/tests/reactor/tests/01_event_types/unsafe/002_user_events.sh b/tests/reactor/tests/01_event_types/unsafe/002_user_events.sh new file mode 100644 index 00000000000..dd271c696e6 --- /dev/null +++ b/tests/reactor/tests/01_event_types/unsafe/002_user_events.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +# check that user_added and user_removed fire once when a user is added and +# removed. Adds and removes a user with sudo. + +set -ex + +. "$(dirname "$0")/../../../lib.sh" + +POLICY_FILE="$1" + +TEST_USER="cfreactortest" +EVENTS_FILE="$WORKDIR/tmp/events" +export TEST_USER EVENTS_FILE + +# never touch an existing user +if id "$TEST_USER" >/dev/null 2>&1 +then + echo "User '$TEST_USER' already exists" >&2 + exit 1 +fi + +start_reactor "$POLICY_FILE" + +# replaces the trap of start_reactor +cleanup() { + stop_reactor + sudo userdel "$TEST_USER" 2>/dev/null || true +} +trap cleanup EXIT + +wait_for_watchers 2 + +# checked on registration +wait_for_log "User '$TEST_USER' is absent, watching for its addition" + +sudo useradd -M "$TEST_USER" +wait_for_lines "$EVENTS_FILE" 1 +check_event "$EVENTS_FILE" 1 "added" + +wait_for_log "User '$TEST_USER' is present, watching for its removal" + +sudo userdel "$TEST_USER" +wait_for_lines "$EVENTS_FILE" 2 +check_event "$EVENTS_FILE" 2 "removed" diff --git a/tests/unit/Makefile.am b/tests/unit/Makefile.am index 42e9128a522..d5084bafb22 100644 --- a/tests/unit/Makefile.am +++ b/tests/unit/Makefile.am @@ -159,6 +159,7 @@ check_PROGRAMS = \ client_code_test \ watcher_test \ file_watcher_test \ + user_watcher_test \ stoppable_thread_test \ wakeup_channel_test @@ -241,6 +242,8 @@ watcher_test_SOURCES = watcher_test.c \ file_watcher_test_SOURCES = file_watcher_test.c \ ../../cf-reactor/file_watcher.c +user_watcher_test_SOURCES = user_watcher_test.c + stoppable_thread_test_SOURCES = stoppable_thread_test.c \ ../../cf-reactor/stoppable_thread.c diff --git a/tests/unit/user_watcher_test.c b/tests/unit/user_watcher_test.c new file mode 100644 index 00000000000..470058fd8af --- /dev/null +++ b/tests/unit/user_watcher_test.c @@ -0,0 +1,252 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +/* Unix only, like file_watcher_test. Tells what the lookup found from the + * verbose log of the first check, since the first check never fires. The + * .c file is included so that tests can fake an earlier check. */ + +#define ABSENT_USER "cfengine_user_watcher_test_absent" + +typedef struct +{ + LoggingPrivContext ctx; + LogLevel old_level; + int count; /* Number of VERBOSE messages captured */ + char message[256]; /* Last VERBOSE message captured */ +} LogCapture; + +static char *CaptureVerboseLogHook( + LoggingPrivContext *pctx, LogLevel level, const char *message) +{ + if (level == LOG_LEVEL_VERBOSE) + { + LogCapture *capture = pctx->param; + capture->count++; + strlcpy(capture->message, message, sizeof(capture->message)); + } + return (char *) message; +} + +/* Captures VERBOSE messages with the log hook. The global level is set to + * CRIT so they aren't printed. force_hook_level still runs the hook for + * them. Stop with StopCapture(), which frees the returned capture. */ +static LogCapture *StartCapture(void) +{ + LogCapture *capture = xcalloc(1, sizeof(LogCapture)); + capture->ctx.log_hook = CaptureVerboseLogHook; + capture->ctx.param = capture; + capture->ctx.force_hook_level = LOG_LEVEL_VERBOSE; + LoggingPrivSetContext(&capture->ctx); + capture->old_level = LogGetGlobalLevel(); + LogSetGlobalLevel(LOG_LEVEL_CRIT); + return capture; +} + +static void StopCapture(LogCapture *capture) +{ + LogSetGlobalLevel(capture->old_level); + LoggingPrivSetContext(NULL); + free(capture); +} + +/* Copies the name of the user running the test, which always exists, to + * name */ +static void GetPresentUser(char *name, size_t name_size) +{ + const struct passwd *pw = getpwuid(getuid()); + assert_true(pw != NULL); + strlcpy(name, pw->pw_name, name_size); +} + +/* Returns a new state of a user watcher of the user with the given name */ +static void *NewUserState(const char *name) +{ + Rlist *args = NULL; + RlistAppendScalar(&args, name); + void *state = UserWatcherStateNew(args); + RlistDestroy(args); + return state; +} + +static void test_check_user_removed_present(void) +{ + char name[256]; + GetPresentUser(name, sizeof(name)); + char expected[512]; + snprintf( + expected, + sizeof(expected), + "User '%s' is present, watching for its removal", + name); + + LogCapture *capture = StartCapture(); + void *state = NewUserState(name); + + /* The first check is the reference, so it doesn't fire */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + assert_string_equal(capture->message, expected); + + /* Still present: not removed, and not logged again */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_removed_absent(void) +{ + LogCapture *capture = StartCapture(); + void *state = NewUserState(ABSENT_USER); + + /* Absent, so nothing to watch for yet and nothing logged */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 0); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_added_absent(void) +{ + LogCapture *capture = StartCapture(); + void *state = NewUserState(ABSENT_USER); + + /* The first check is the reference, so it doesn't fire */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + assert_string_equal( + capture->message, + "User '" ABSENT_USER "' is absent, watching for its addition"); + + /* Still absent: not added, and not logged again */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_added_present(void) +{ + char name[256]; + GetPresentUser(name, sizeof(name)); + + LogCapture *capture = StartCapture(); + void *state = NewUserState(name); + + /* Present, so nothing to watch for yet and nothing logged */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 0); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_added_fires(void) +{ + char name[256]; + GetPresentUser(name, sizeof(name)); + char expected[512]; + snprintf( + expected, + sizeof(expected), + "Observed addition of user '%s' at ", + name); + + LogCapture *capture = StartCapture(); + UserWatcherState *state = NewUserState(name); + + /* Fake an earlier check that saw the user absent */ + state->last_checked = time(NULL); + state->last_exists = false; + + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_EVENT); + assert_int_equal(capture->count, 1); + assert_true(StringStartsWith(capture->message, expected)); + + /* Fires only once */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_removed_fires(void) +{ + LogCapture *capture = StartCapture(); + UserWatcherState *state = NewUserState(ABSENT_USER); + + /* Fake an earlier check that saw the user present */ + state->last_checked = time(NULL); + state->last_exists = true; + + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_EVENT); + assert_int_equal(capture->count, 1); + assert_true(StringStartsWith( + capture->message, + "Observed removal of user '" ABSENT_USER "' at ")); + + /* Fires only once */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_empty_name(void) +{ + LogCapture *capture = StartCapture(); + + /* An empty name is a user that doesn't exist, not a lookup error */ + void *state = NewUserState(""); + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + assert_string_equal( + capture->message, "User '' is absent, watching for its addition"); + UserWatcherStateDestroy(state); + + StopCapture(capture); +} + +static void test_state_destroy_null(void) +{ + UserWatcherStateDestroy(NULL); +} + +int main() +{ + PRINT_TEST_BANNER(); + const UnitTest tests[] = { + unit_test(test_check_user_removed_present), + unit_test(test_check_user_removed_absent), + unit_test(test_check_user_added_absent), + unit_test(test_check_user_added_present), + unit_test(test_check_user_added_fires), + unit_test(test_check_user_removed_fires), + unit_test(test_check_empty_name), + unit_test(test_state_destroy_null), + }; + + return run_tests(tests); +} From 7f1032a8c08b40b2207a1908676f8ce7dd13326a Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 29 Oct 2026 08:50:58 -0500 Subject: [PATCH 04/11] Fixed reactor guest_environments test on hosts with dots or hyphens in their name environment_host is a class, the hostname must be canonified. Changelog: None --- tests/reactor/tests/02_then_bundles/008_guest_environments.cf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; } From ed9d6496af54d1019f3bed176ade2048ad140840 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Tue, 27 Oct 2026 23:59:09 -0500 Subject: [PATCH 05/11] Made cf-reactor's watcher thread immune to wall clock steps Stepping the system clock back postponed every watcher check by the size of the step. Ticket: TBD Changelog: Title --- cf-reactor/stoppable_thread.c | 65 +++++++++++++++++-- cf-reactor/watcher.c | 20 +++++- configure.ac | 4 ++ .../00_basics/unsafe/001_clock_step_back.cf | 30 +++++++++ .../00_basics/unsafe/001_clock_step_back.sh | 36 ++++++++++ 5 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf create mode 100644 tests/reactor/tests/00_basics/unsafe/001_clock_step_back.sh diff --git a/cf-reactor/stoppable_thread.c b/cf-reactor/stoppable_thread.c index e4198e1235b..048885e1ee6 100644 --- a/cf-reactor/stoppable_thread.c +++ b/cf-reactor/stoppable_thread.c @@ -25,8 +25,21 @@ #include #include #include -#include /* ThreadLock(), ThreadUnlock(), ThreadWait() */ +#include /* ThreadLock(), ThreadUnlock(), THREAD_BLOCK_INDEFINITELY */ #include +#include /* clock_gettime() */ + +/* The clock the timed waits on cond are measured against. The monotonic clock + * isn't affected by steps of the wall clock (NTP, an administrator, a resumed + * VM), so stepping it back doesn't stretch a sleep by the size of the step. + * mingw is excluded, as winpthreads accepts the attribute but may not honor + * it, which would make every timed wait return immediately. */ +#if defined(HAVE_PTHREAD_CONDATTR_SETCLOCK) && defined(CLOCK_MONOTONIC) && !defined(__MINGW32__) +# define STOPPABLE_THREAD_CLOCK CLOCK_MONOTONIC +# define STOPPABLE_THREAD_CLOCK_IS_MONOTONIC 1 +#else +# define STOPPABLE_THREAD_CLOCK CLOCK_REALTIME +#endif struct StoppableThread_ { @@ -73,7 +86,14 @@ StoppableThread *StoppableThreadStart(StoppableThreadFn fn, void *arg) thread->fn = fn; thread->arg = arg; pthread_mutex_init(&thread->lock, NULL); - pthread_cond_init(&thread->cond, NULL); + + pthread_condattr_t cond_attr; + pthread_condattr_init(&cond_attr); +#ifdef STOPPABLE_THREAD_CLOCK_IS_MONOTONIC + pthread_condattr_setclock(&cond_attr, STOPPABLE_THREAD_CLOCK); +#endif + pthread_cond_init(&thread->cond, &cond_attr); + pthread_condattr_destroy(&cond_attr); int ret = pthread_create(&thread->thread, NULL, StoppableThreadMain, thread); if (ret != 0) @@ -96,6 +116,39 @@ bool StoppableThreadShouldStop(StoppableThread *thread) return ret; } +/** Seconds on STOPPABLE_THREAD_CLOCK, for deadlines of waits on cond. */ +static time_t StoppableThreadClockNow(void) +{ + struct timespec ts; + clock_gettime(STOPPABLE_THREAD_CLOCK, &ts); + return ts.tv_sec; +} + +/** + * Waits on thread->cond with thread->lock held, like ThreadWait(), but with + * the deadline on STOPPABLE_THREAD_CLOCK, the clock cond was created with. + * ThreadWait() always uses the wall clock. + * + * @param timeout seconds, or THREAD_BLOCK_INDEFINITELY + */ +static void StoppableThreadCondWait(StoppableThread *thread, int timeout) +{ + assert(thread != NULL); + + if (timeout == THREAD_BLOCK_INDEFINITELY) + { + pthread_cond_wait(&thread->cond, &thread->lock); + return; + } + + struct timespec deadline; + clock_gettime(STOPPABLE_THREAD_CLOCK, &deadline); + deadline.tv_sec += timeout; + /* Timing out is the normal end of a sleep, and spurious wakeups are + * handled by the callers re-checking their state */ + pthread_cond_timedwait(&thread->cond, &thread->lock, &deadline); +} + /* timeout is in seconds, or THREAD_BLOCK_INDEFINITELY */ static void StoppableThreadWaitForWakeup(StoppableThread *thread, int timeout) { @@ -104,7 +157,7 @@ static void StoppableThreadWaitForWakeup(StoppableThread *thread, int timeout) ThreadLock(&thread->lock); if (!thread->stop_requested && !thread->wake_requested) { - ThreadWait(&thread->cond, &thread->lock, timeout); + StoppableThreadCondWait(thread, timeout); } /* The routine re-checks its state after any sleep, which covers the * wakeup, whether it arrived before or during the sleep */ @@ -146,11 +199,11 @@ bool StoppableThreadStop(StoppableThread *thread, int timeout_secs) thread->stop_requested = true; pthread_cond_broadcast(&thread->cond); - const time_t deadline = time(NULL) + timeout_secs; + const time_t deadline = StoppableThreadClockNow() + timeout_secs; time_t now; - while (!thread->exited && (now = time(NULL)) < deadline) + while (!thread->exited && (now = StoppableThreadClockNow()) < deadline) { - ThreadWait(&thread->cond, &thread->lock, (int) (deadline - now)); + StoppableThreadCondWait(thread, (int) (deadline - now)); } const bool exited = thread->exited; ThreadUnlock(&thread->lock); diff --git a/cf-reactor/watcher.c b/cf-reactor/watcher.c index 1073f68066c..87c4f857580 100644 --- a/cf-reactor/watcher.c +++ b/cf-reactor/watcher.c @@ -46,6 +46,22 @@ #define WATCHER_THREAD_EXIT_TIMEOUT_SECS 5 +/** + * Seconds on a clock that only moves forward, for scheduling checks. With the + * wall clock, stepping it back would postpone every check by the size of the + * step. Falls back to the wall clock where there is no monotonic clock. + */ +static time_t WatcherClockNow(void) +{ + struct timespec ts; +#ifdef CLOCK_MONOTONIC + clock_gettime(CLOCK_MONOTONIC, &ts); +#else + clock_gettime(CLOCK_REALTIME, &ts); +#endif + return ts.tv_sec; +} + typedef struct { WatcherCheckFn check_callback; @@ -54,7 +70,7 @@ typedef struct Promise *promise; // copy of the expanded events promise, see PromiseCopy() char *promise_hash; // hash of `promise`, see WatcherPromiseHash() time_t poll_interval_secs; - time_t next_due; + time_t next_due; // on WatcherClockNow()'s clock WatcherCheckResult last_check; // only touched by the watcher thread } Watcher; @@ -212,7 +228,7 @@ static void WatcherThreadMain(StoppableThread *thread, ARG_UNUSED void *unused) while (!IsPendingTermination() && !StoppableThreadShouldStop(thread)) { - time_t now = time(NULL); + time_t now = WatcherClockNow(); time_t sleep_for = MAX_WATCHER_THREAD_SLEEP_SECS; bool any_event = false; diff --git a/configure.ac b/configure.ac index 414aa1d4c66..8bd05bff0df 100644 --- a/configure.ac +++ b/configure.ac @@ -192,6 +192,10 @@ dnl and LIBS are appropriately set for the local pthreads situation. dnl ###################################################################### AC_CHECK_FUNCS([pthread_cancel]) +dnl cf-reactor/stoppable_thread.c times its waits on the monotonic clock when +dnl the condition variable's clock can be set (not on macOS). +AC_CHECK_FUNCS([pthread_condattr_setclock]) + dnl ###################################################################### dnl Whether to build extensions as builtin extensions or a separate dnl plugin. The default is plugin. diff --git a/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf new file mode 100644 index 00000000000..b7dbf291f56 --- /dev/null +++ b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf @@ -0,0 +1,30 @@ +####################################################### +# +# Test that stepping the wall clock back doesn't postpone checks +# +####################################################### +body common control +{ + inputs => { "../../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when deleted(file) +{ + file_deleted => "$(file)"; + interval => 1; +} + +bundle reactor test +{ + vars: + "test" string => getenv("TESTFILE", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + + events: + "$(promise_name)" + when => deleted("$(test)"), + then => record_event("$(events_file)", "$(test) was deleted"); +} diff --git a/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.sh b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.sh new file mode 100644 index 00000000000..843a33c86f9 --- /dev/null +++ b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +# check that stepping the wall clock back while cf-reactor is running doesn't +# postpone its checks by the size of the step, which is longer than the +# harness waits for an event. Steps the system clock with sudo. + +set -ex + +. "$(dirname "$0")/../../../lib.sh" + +POLICY_FILE="$1" + +TESTFILE="$WORKDIR/tmp/test" +EVENTS_FILE="$WORKDIR/tmp/events" +export TESTFILE EVENTS_FILE + +STEP_SECS=300 + +touch "$TESTFILE" + +start_reactor "$POLICY_FILE" + +restore_clock() { + sudo date -s "@$(($(date +%s) + STEP_SECS))" >/dev/null + stop_reactor +} +trap restore_clock EXIT + +wait_for_log "File '$TESTFILE' is present, watching for its deletion" + +# step back while the watcher thread is between two checks +sudo date -s "@$(($(date +%s) - STEP_SECS))" >/dev/null + +rm "$TESTFILE" +wait_for_lines "$EVENTS_FILE" 1 +check_event "$EVENTS_FILE" 1 "$TESTFILE was deleted" From fd7980c77e5bf793e4ab21bb55c0b4b2790fe45b Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Wed, 28 Oct 2026 01:35:25 -0500 Subject: [PATCH 06/11] Made file_content_modified only hash files whose stat changed Ticket: TBD Changelog: None --- cf-reactor/file_watcher.c | 53 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/cf-reactor/file_watcher.c b/cf-reactor/file_watcher.c index b44730355ea..16ceadcf467 100644 --- a/cf-reactor/file_watcher.c +++ b/cf-reactor/file_watcher.c @@ -40,6 +40,16 @@ #define CONTENT_HASH_METHOD HASH_METHOD_SHA256 +/* What changes when a file's content may have changed, see StatSignatureOf() */ +typedef struct +{ + dev_t dev; + ino_t ino; + off_t size; + struct timespec mtime; + struct timespec ctime; +} StatSignature; + typedef struct { char *path; @@ -60,6 +70,7 @@ typedef struct { bool hashed; /* Whether it was a regular file, that was hashed */ unsigned char digest[EVP_MAX_MD_SIZE + 1]; + StatSignature signature; /* of the file when it was hashed */ } content; /* FileWatcherCheckFilePermissionsChanged() */ @@ -280,6 +291,37 @@ WatcherCheckResult FileWatcherCheckFileModified(void *state) // =========== code for the file_content_modified event =========== +static struct timespec StatCtime(const struct stat *sb) +{ + assert(sb != NULL); + +#if defined(HAVE_STRUCT_STAT_ST_MTIM) + return sb->st_ctim; +#elif defined(HAVE_STRUCT_STAT_ST_MTIMESPEC) + return sb->st_ctimespec; +#else + return (struct timespec) { .tv_sec = sb->st_ctime, .tv_nsec = 0 }; +#endif +} + +/* Content can't change without one of these changing too: writing updates + * mtime and ctime, replacing the file changes the inode, and setting mtime + * back still updates ctime. So the file is only hashed again when they + * differ, which keeps frequent checks of many files cheap. */ +static StatSignature StatSignatureOf(const struct stat *sb) +{ + assert(sb != NULL); + + StatSignature signature; + memset(&signature, 0, sizeof(signature)); /* no padding garbage for memcmp() */ + signature.dev = sb->st_dev; + signature.ino = sb->st_ino; + signature.size = sb->st_size; + signature.mtime = StatMtime(sb); + signature.ctime = StatCtime(sb); + return signature; +} + /* Hashes the content of the file into digest. Returns false and sets errno * on failure. */ static bool HashFileContent(const char *path, unsigned char digest[EVP_MAX_MD_SIZE + 1]) @@ -335,7 +377,15 @@ WatcherCheckResult FileWatcherCheckFileContentModified(void *state) bool hashed = false; unsigned char digest[EVP_MAX_MD_SIZE + 1] = { 0 }; - if (exists && S_ISREG(sb.st_mode)) + const StatSignature signature = exists ? StatSignatureOf(&sb) : (StatSignature) { 0 }; + if (exists && S_ISREG(sb.st_mode) && fws->last.content.hashed && + memcmp(&fws->last.content.signature, &signature, sizeof(signature)) == 0) + { + /* Unchanged since hashed, see StatSignatureOf() */ + hashed = true; + memcpy(digest, fws->last.content.digest, sizeof(digest)); + } + else if (exists && S_ISREG(sb.st_mode)) { if (HashFileContent(fws->path, digest)) { @@ -354,6 +404,7 @@ WatcherCheckResult FileWatcherCheckFileContentModified(void *state) fws->last.content.hashed = hashed; memcpy(fws->last.content.digest, digest, sizeof(digest)); + fws->last.content.signature = signature; return FileWatcherRecordObservation(fws, exists, modified, "content modification"); } From f9857f03e1ae77556369a9847bbfae77e15f2919 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Wed, 28 Oct 2026 01:35:27 -0500 Subject: [PATCH 07/11] Added watch methods to cf-reactor The new watch_methods attribute of when bodies lists, in order of preference, how a watcher is woken up before its interval. The methods other than polling are provided by the Enterprise extension; without it, cf-reactor polls. Polling at the interval continues underneath every method. Without watch_methods, the first available method is used. Ticket: TBD Changelog: Title --- cf-reactor/Makefile.am | 1 + cf-reactor/reactor_transform.c | 52 ++- cf-reactor/watch_backends.c | 282 +++++++++++++ cf-reactor/watch_backends.h | 64 +++ cf-reactor/watcher.c | 375 +++++++++++++++++- cf-reactor/watcher.h | 22 +- libpromises/Makefile.am | 1 + libpromises/enterprise_stubs.c | 5 + libpromises/mod_reactor.c | 9 +- libpromises/mod_reactor.h | 7 + libpromises/prototypes3.h | 4 + libpromises/reactor_watch_backend.h | 85 ++++ tests/reactor/tests/00_basics/009_interval.cf | 3 + .../00_basics/unsafe/001_clock_step_back.cf | 3 + .../003_no_method_available.cf | 30 ++ .../003_no_method_available.sh | 24 ++ .../tests/03_watch_methods/004_poll_only.cf | 31 ++ .../tests/03_watch_methods/004_poll_only.sh | 24 ++ .../005_methods_change_keeps_state.cf | 39 ++ .../005_methods_change_keeps_state.sh | 43 ++ .../03_watch_methods/006_repair_converges.cf | 37 ++ .../03_watch_methods/006_repair_converges.sh | 35 ++ .../03_watch_methods/007_trigger_limit.cf | 30 ++ .../03_watch_methods/007_trigger_limit.sh | 40 ++ 24 files changed, 1222 insertions(+), 24 deletions(-) create mode 100644 cf-reactor/watch_backends.c create mode 100644 cf-reactor/watch_backends.h create mode 100644 libpromises/reactor_watch_backend.h create mode 100644 tests/reactor/tests/03_watch_methods/003_no_method_available.cf create mode 100644 tests/reactor/tests/03_watch_methods/003_no_method_available.sh create mode 100644 tests/reactor/tests/03_watch_methods/004_poll_only.cf create mode 100644 tests/reactor/tests/03_watch_methods/004_poll_only.sh create mode 100644 tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.cf create mode 100644 tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.sh create mode 100644 tests/reactor/tests/03_watch_methods/006_repair_converges.cf create mode 100644 tests/reactor/tests/03_watch_methods/006_repair_converges.sh create mode 100644 tests/reactor/tests/03_watch_methods/007_trigger_limit.cf create mode 100644 tests/reactor/tests/03_watch_methods/007_trigger_limit.sh diff --git a/cf-reactor/Makefile.am b/cf-reactor/Makefile.am index 3c84df15546..0ef86284aa4 100644 --- a/cf-reactor/Makefile.am +++ b/cf-reactor/Makefile.am @@ -47,6 +47,7 @@ libcf_reactor_la_SOURCES = \ stoppable_thread.c stoppable_thread.h \ user_watcher.c user_watcher.h \ wakeup_channel.c wakeup_channel.h \ + watch_backends.c watch_backends.h \ watcher.c watcher.h if !BUILTIN_EXTENSIONS diff --git a/cf-reactor/reactor_transform.c b/cf-reactor/reactor_transform.c index 77a2418cfc5..b38a9817913 100644 --- a/cf-reactor/reactor_transform.c +++ b/cf-reactor/reactor_transform.c @@ -148,6 +148,7 @@ typedef struct WatcherStateNewFn new_state; WatcherStateDestroyFn destroy_state; WatcherCheckFn check; + ReactorWatchTargetType target; /* what the 'name' constraint is about */ } EventType; static const EventArg NO_EXTRA_ARGS[] = @@ -161,17 +162,20 @@ static const EventArg FILE_SIZE_EXCEEDS_ARGS[] = { NULL, RVAL_TYPE_NOPROMISEE } }; -#define EventTypeNew(name, type, args, new_state, destroy_state, check) \ - { name, type, args, new_state, destroy_state, check } -#define EventTypeNewNull() EventTypeNew(NULL, RVAL_TYPE_NOPROMISEE, NULL, NULL, NULL, NULL) +#define EventTypeNew(name, type, args, new_state, destroy_state, check, target) \ + { name, type, args, new_state, destroy_state, check, target } +#define EventTypeNewNull() \ + EventTypeNew(NULL, RVAL_TYPE_NOPROMISEE, NULL, NULL, NULL, NULL, REACTOR_WATCH_TARGET_FILE) /* A file event, about the file at the path of its constraint */ #define FileEventTypeNew(name, check) \ - EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, FileWatcherStateNew, FileWatcherStateDestroy, check) + EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, FileWatcherStateNew, FileWatcherStateDestroy, \ + check, REACTOR_WATCH_TARGET_FILE) /* A user event, about the user with the name of its constraint */ #define UserEventTypeNew(name, check) \ - EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, UserWatcherStateNew, UserWatcherStateDestroy, check) + EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, UserWatcherStateNew, UserWatcherStateDestroy, \ + check, REACTOR_WATCH_TARGET_USERS) static const EventType EVENT_TABLE[] = { @@ -182,7 +186,8 @@ static const EventType EVENT_TABLE[] = FileEventTypeNew(REACTOR_FILE_PERMISSIONS_CHANGED, FileWatcherCheckFilePermissionsChanged), FileEventTypeNew(REACTOR_FILE_OWNER_CHANGED, FileWatcherCheckFileOwnerChanged), EventTypeNew(REACTOR_FILE_SIZE_EXCEEDS, RVAL_TYPE_SCALAR, FILE_SIZE_EXCEEDS_ARGS, - FileWatcherSizeStateNew, FileWatcherStateDestroy, FileWatcherCheckFileSizeExceeds), + FileWatcherSizeStateNew, FileWatcherStateDestroy, FileWatcherCheckFileSizeExceeds, + REACTOR_WATCH_TARGET_FILE), UserEventTypeNew(REACTOR_USER_ADDED, UserWatcherCheckUserAdded), UserEventTypeNew(REACTOR_USER_REMOVED, UserWatcherCheckUserRemoved), EventTypeNewNull() @@ -271,6 +276,31 @@ static bool EventsPromiseGetInterval(const Promise *pp, time_t *interval) return true; } +/* Sets *methods to the 'watch_methods' of the (expanded) events promise, NULL + * if it has none. The method names were checked against the syntax already, + * on expansion. */ +static bool EventsPromiseGetWatchMethods(const Promise *pp, const Rlist **methods) +{ + assert(pp != NULL); + assert(methods != NULL); + + *methods = NULL; + const Constraint *con = PromiseGetConstraint(pp, REACTOR_WATCH_METHODS); + if (con == NULL) + { + return true; + } + if (con->rval.type != RVAL_TYPE_LIST) + { + Log(LOG_LEVEL_ERR, + "Reactor events promise '%s' has an invalid '%s', expected a list of methods", + pp->promiser, REACTOR_WATCH_METHODS); + return false; + } + *methods = RvalRlistValue(con->rval); + return true; +} + static bool ResolveWhenBody(const Promise *pp, WatcherOptions *opt) { assert(pp != NULL); @@ -317,6 +347,7 @@ static bool ResolveWhenBody(const Promise *pp, WatcherOptions *opt) if (BodySyntaxGetConstraintSyntax(when_syntax, cp->lval) == NULL || /* not a 'when' constraint */ StringEqual(cp->lval, event->name) || StringEqual(cp->lval, "interval") || + StringEqual(cp->lval, REACTOR_WATCH_METHODS) || StringEqual(cp->lval, "meta")) /* also a promise attribute */ { continue; @@ -337,12 +368,19 @@ static bool ResolveWhenBody(const Promise *pp, WatcherOptions *opt) } } - if (!EventsPromiseGetInterval(pp, &opt->interval)) + if (!EventsPromiseGetInterval(pp, &opt->interval) || + !EventsPromiseGetWatchMethods(pp, &opt->methods)) { RlistDestroy(args); return false; } + /* The path is the value of the event's constraint, which lives as long + * as pp, unlike args */ + opt->target.type = event->target; + opt->target.path = (event->target == REACTOR_WATCH_TARGET_FILE) ? + RvalScalarValue(PromiseGetConstraint(pp, event->name)->rval) : NULL; + opt->state = event->new_state(args); opt->check_callback = event->check; opt->destroy_state = event->destroy_state; diff --git a/cf-reactor/watch_backends.c b/cf-reactor/watch_backends.c new file mode 100644 index 00000000000..efdd6abbf78 --- /dev/null +++ b/cf-reactor/watch_backends.c @@ -0,0 +1,282 @@ +/* + 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. +*/ + +#include +#include /* REACTOR_WATCH_METHOD_POLL */ +#include /* ReactorEnterpriseWatchBackends() */ +#include +#include +#include /* StringEqual() */ +#include /* SeqStringFromString() */ +#include /* ProgrammingError() */ + +/* Preferred first wherever available: the methods closest to the kernel see + * the most, and the order doesn't change what fires, only how fast. */ +static const char *const DEFAULT_WATCH_METHODS[] = +{ + "ebpf", "fanotify", "inotify", "usn", "rdcw", REACTOR_WATCH_METHOD_POLL, NULL +}; + +#define MAX_WATCH_BACKENDS 16 + +/* A method that failed to start (e.g. out of fanotify marks or locked memory) + * is tried again when a watcher asks for it after this long */ +#define BACKEND_START_RETRY_SECS 60 + +typedef enum +{ + BACKEND_NOT_STARTED, + BACKEND_STARTED, + BACKEND_UNAVAILABLE, /* not supported here */ + BACKEND_FAILED, /* start() failed at `failed_at` */ +} BackendStatus; + +typedef struct +{ + const ReactorWatchBackend *backend; + BackendStatus status; + time_t failed_at; /* on BackendClockNow()'s clock */ +} BackendEntry; + +static BackendEntry backends[MAX_WATCH_BACKENDS]; +static size_t num_backends = 0; +static const ReactorWatchHost *watch_host = NULL; + +static void BackendAppend(const ReactorWatchBackend *backend) +{ + assert(backend != NULL); + assert(backend->name != NULL); + + if (num_backends >= MAX_WATCH_BACKENDS) + { + Log(LOG_LEVEL_ERR, "Too many cf-reactor watch methods, ignoring '%s'", backend->name); + return; + } + backends[num_backends].backend = backend; + backends[num_backends].status = BACKEND_NOT_STARTED; + backends[num_backends].failed_at = 0; + num_backends++; +} + +void WatchBackendsInitialize(const ReactorWatchHost *host) +{ + assert(host != NULL); + assert(num_backends == 0); + + watch_host = host; + + const ReactorWatchBackend *const *extra = ReactorEnterpriseWatchBackends(); + for (size_t i = 0; extra != NULL && extra[i] != NULL; i++) + { + BackendAppend(extra[i]); + } + + for (size_t i = 0; i < num_backends; i++) + { + Log(LOG_LEVEL_DEBUG, "cf-reactor watch method '%s' is built in", backends[i].backend->name); + } +} + +void WatchBackendsFinalize(void) +{ + for (size_t i = 0; i < num_backends; i++) + { + if (backends[i].status == BACKEND_STARTED) + { + backends[i].backend->stop(); + } + } + num_backends = 0; + watch_host = NULL; +} + +/** + * Seconds on a clock that wall clock steps don't move, for the start retry + */ +static time_t BackendClockNow(void) +{ + struct timespec ts; +#ifdef CLOCK_MONOTONIC + clock_gettime(CLOCK_MONOTONIC, &ts); +#else + clock_gettime(CLOCK_REALTIME, &ts); +#endif + return ts.tv_sec; +} + +static BackendEntry *BackendLookup(const char *name) +{ + assert(name != NULL); + + for (size_t i = 0; i < num_backends; i++) + { + if (StringEqual(backends[i].backend->name, name)) + { + return &backends[i]; + } + } + return NULL; +} + +/** + * Starts the method on first use, so that methods no policy asks for (e.g. + * eBPF, which loads a program into the kernel) are never set up + */ +static bool BackendEnsureStarted(BackendEntry *entry) +{ + assert(entry != NULL); + assert(watch_host != NULL); + + if (entry->status == BACKEND_FAILED && + BackendClockNow() - entry->failed_at >= BACKEND_START_RETRY_SECS) + { + entry->status = BACKEND_NOT_STARTED; + } + + if (entry->status == BACKEND_NOT_STARTED) + { + const ReactorWatchBackend *b = entry->backend; + if (!b->available()) + { + Log(LOG_LEVEL_VERBOSE, "cf-reactor watch method '%s' is not available on this host", b->name); + entry->status = BACKEND_UNAVAILABLE; + } + else if (!b->start(watch_host)) + { + Log(LOG_LEVEL_ERR, "Failed to start cf-reactor watch method '%s', trying it again in %d seconds", + b->name, BACKEND_START_RETRY_SECS); + entry->status = BACKEND_FAILED; + entry->failed_at = BackendClockNow(); + } + else + { + Log(LOG_LEVEL_VERBOSE, "Started cf-reactor watch method '%s'", b->name); + entry->status = BACKEND_STARTED; + } + } + return (entry->status == BACKEND_STARTED); +} + +/** + * Tries method `name` for the watcher, returns its static name on success. + * `requested` is whether the policy named the method, rather than it coming + * from the default list: then a method missing from this build is worth + * telling at verbose level, it's why the watcher may end up with none. + */ +static const char *TryMethod(const char *name, uint64_t watcher_id, + const ReactorWatchTarget *target, const char *promiser, + bool requested) +{ + assert(name != NULL); + assert(target != NULL); + + if (StringEqual(name, REACTOR_WATCH_METHOD_POLL)) + { + return REACTOR_WATCH_METHOD_POLL; + } + + BackendEntry *entry = BackendLookup(name); + if (entry == NULL) + { + Log(requested ? LOG_LEVEL_VERBOSE : LOG_LEVEL_DEBUG, + "Reactor watcher '%s': watch method '%s' isn't part of this build, trying the next one", + promiser, name); + return NULL; + } + if (!BackendEnsureStarted(entry)) + { + return NULL; + } + if (!entry->backend->add(watcher_id, target)) + { + Log(LOG_LEVEL_VERBOSE, + "Reactor watcher '%s': watch method '%s' can't watch this, trying the next one", + promiser, name); + return NULL; + } + return entry->backend->name; +} + +const char *WatchBackendsAdd(uint64_t watcher_id, const ReactorWatchTarget *target, + const Rlist *methods, const char *promiser) +{ + assert(target != NULL); + assert(promiser != NULL); + + if (methods == NULL) + { + const char *override = getenv(WATCH_METHODS_TEST_OVERRIDE_ENV); + if (override != NULL && override[0] != '\0') + { + Seq *names = SeqStringFromString(override, ','); + const char *used = NULL; + bool poll_allowed = false; + const size_t length = SeqLength(names); + for (size_t i = 0; i < length && used == NULL; i++) + { + const char *name = SeqAt(names, i); + poll_allowed = poll_allowed || StringEqual(name, REACTOR_WATCH_METHOD_POLL); + used = TryMethod(name, watcher_id, target, promiser, true); + } + SeqDestroy(names); + return (used != NULL || !poll_allowed) ? used : REACTOR_WATCH_METHOD_POLL; + } + + for (size_t i = 0; DEFAULT_WATCH_METHODS[i] != NULL; i++) + { + const char *used = TryMethod(DEFAULT_WATCH_METHODS[i], watcher_id, target, promiser, + false); + if (used != NULL) + { + return used; + } + } + ProgrammingError("The default list of watch methods doesn't end with '%s'", + REACTOR_WATCH_METHOD_POLL); + } + + for (const Rlist *rp = methods; rp != NULL; rp = rp->next) + { + const char *used = TryMethod(RlistScalarValue(rp), watcher_id, target, promiser, true); + if (used != NULL) + { + return used; + } + } + return NULL; +} + +void WatchBackendsRemove(uint64_t watcher_id, const char *method) +{ + if (method == NULL || StringEqual(method, REACTOR_WATCH_METHOD_POLL)) + { + return; + } + + BackendEntry *entry = BackendLookup(method); + if (entry != NULL && entry->status == BACKEND_STARTED) + { + entry->backend->remove(watcher_id); + } +} diff --git a/cf-reactor/watch_backends.h b/cf-reactor/watch_backends.h new file mode 100644 index 00000000000..8120e28d260 --- /dev/null +++ b/cf-reactor/watch_backends.h @@ -0,0 +1,64 @@ +/* + 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. +*/ + +#ifndef CFENGINE_WATCH_BACKENDS_H +#define CFENGINE_WATCH_BACKENDS_H + +#include +#include + +/* Environment variable replacing the default 'watch_methods' (of watchers + * that don't set it), a comma-separated list, so that the reactor tests can + * be run with each method in turn */ +#define WATCH_METHODS_TEST_OVERRIDE_ENV "CFENGINE_TEST_OVERRIDE_WATCH_METHODS" + +/** + * @brief Collect the watch methods of this binary (cf-reactor's own and the + * Enterprise extension's). They are started when first used. + * @param host what the methods call back into, see ReactorWatchHost + */ +void WatchBackendsInitialize(const ReactorWatchHost *host); + +/** @brief Stop the started methods. */ +void WatchBackendsFinalize(void); + +/** + * @brief Watch target with the first available method of methods. + * + * Methods for another platform, or that this binary doesn't have, are + * skipped. "poll" always succeeds, as the watcher is polled at its interval + * anyway: it means polling alone is acceptable. + * + * @param watcher_id the id passed back to ReactorWatchHost.mark_dirty() + * @param methods method names in order of preference, NULL for the default + * @return the name of the method used (static), or NULL if none of methods + * is available and they don't include "poll" + */ +const char *WatchBackendsAdd(uint64_t watcher_id, const ReactorWatchTarget *target, + const Rlist *methods, const char *promiser); + +/** @brief Stop watching for watcher_id with method, as returned by WatchBackendsAdd(). */ +void WatchBackendsRemove(uint64_t watcher_id, const char *method); + +#endif diff --git a/cf-reactor/watcher.c b/cf-reactor/watcher.c index 87c4f857580..e9c0b4d59e4 100644 --- a/cf-reactor/watcher.c +++ b/cf-reactor/watcher.c @@ -37,6 +37,12 @@ #include // PromiseRuntimeHash() #include // HashPrintSafe() #include // Promise +#include // REACTOR_WATCH_METHODS +#include // WatchBackendsAdd(), WatchBackendsRemove() +#include +#include // safe_fopen() +#include // FileWriter() +#include // GetStateDir() /* Upper bound on how long the watcher thread sleeps in one go while it has * watchers to check, regardless of what poll intervals they asked for. When @@ -46,6 +52,26 @@ #define WATCHER_THREAD_EXIT_TIMEOUT_SECS 5 +/* How long the watcher thread waits after a watch method marked watchers + * dirty, before checking them. A single operation is often several changes + * in a row (touch creates a file then sets its times, a package manager + * replaces a file then fixes its owner), and checking in between would + * observe states polling never sees. The marks arriving meanwhile are + * checked together. */ +#define WATCH_SETTLE_MSECS 200 + +/* Limit on how often a watcher runs its 'then' bundles. Polling alone caps it + * at once per interval, but the watch methods wake a watcher up on every + * change, so anyone able to touch a watched file could otherwise make + * cf-reactor run the bundles continuously. Beyond MAX_TRIGGERS_PER_WINDOW + * runs in TRIGGER_WINDOW_SECS, the watcher waits before its next run, twice + * as long each time (up to MAX_TRIGGER_BACKOFF_SECS), until a window passes + * below the limit. Events in the meantime are coalesced into the next run. */ +#define TRIGGER_WINDOW_SECS 60 +#define MAX_TRIGGERS_PER_WINDOW 10 +#define MIN_TRIGGER_BACKOFF_SECS 10 +#define MAX_TRIGGER_BACKOFF_SECS 300 + /** * Seconds on a clock that only moves forward, for scheduling checks. With the * wall clock, stepping it back would postpone every check by the size of the @@ -72,12 +98,30 @@ typedef struct time_t poll_interval_secs; time_t next_due; // on WatcherClockNow()'s clock WatcherCheckResult last_check; // only touched by the watcher thread + + uint64_t id; // passed to the watch methods, see WatcherMarkDirty() + const char *method; // watch method used, static, see WatchBackendsAdd() + + /* Whether an event was queued and its bundles haven't finished running. + * Events observed meanwhile are held, see EventHandled(). */ + bool event_pending; + + /* An observed event not run yet because of the trigger limit */ + bool event_held; + + /* A check skipped while an event was held, done once it can run, see + * WatcherThreadMain() */ + bool check_deferred; + time_t throttled_until; // 0 when not throttled + time_t window_start; + unsigned int triggers_in_window; + time_t backoff_secs; } Watcher; /* Item of the event queue */ typedef struct { - const Watcher *watcher; + Watcher *watcher; WatcherCheckResult check; // see WatcherEventFn } WatcherEvent; @@ -102,20 +146,56 @@ static WakeupChannel wakeup_channel = { .fds = { -1, -1 } }; static ThreadedQueue *event_queue = NULL; static StoppableThread *watcher_thread = NULL; +/* Ids of the watchers to check right away, marked by the watch methods (see + * WatcherMarkDirty()). Only ever taken after watchers_mutex, never before: + * the watch methods mark watchers while holding their own lock, which is + * taken after watchers_mutex when (un)registering. */ +static pthread_mutex_t dirty_mutex = PTHREAD_MUTEX_INITIALIZER; +static uint64_t *dirty_ids = NULL; +static size_t num_dirty_ids = 0; +static size_t max_dirty_ids = 0; +static bool all_dirty = false; + +/* Ids of the watchers, never reused */ +static uint64_t last_watcher_id = 0; + +static const ReactorWatchHost WATCH_HOST = +{ + .abi_version = REACTOR_WATCH_BACKEND_ABI_VERSION, + .mark_dirty = WatcherMarkDirty, + .mark_all_dirty = WatcherMarkAllDirty, +}; + /*****************************************************************************/ /* The hash covers the whole expanded promise, including what is watched * (the constraints of the 'when' body) and what is run ('then'), so that a * state is only taken over by a watcher of the same type, on the same - * thing. */ + * thing. It leaves out 'watch_methods', which only sets how the event is + * watched for: changing it must not reset the state, or an event happening + * while the policy is re-read would be missed. */ void WatcherPromiseHash(const Promise *pp, char *hash, size_t hash_size) { assert(pp != NULL); assert(hash != NULL); + const size_t length = SeqLength(pp->conlist); + Seq *conlist = SeqNew(length, NULL); + for (size_t i = 0; i < length; i++) + { + Constraint *cp = SeqAt(pp->conlist, i); + if (!StringEqual(cp->lval, REACTOR_WATCH_METHODS)) + { + SeqAppend(conlist, cp); + } + } + Promise hashed = *pp; + hashed.conlist = conlist; + unsigned char digest[EVP_MAX_MD_SIZE + 1]; - PromiseRuntimeHash(pp, "events_promise_hash", digest, CF_DEFAULT_DIGEST); + PromiseRuntimeHash(&hashed, "events_promise_hash", digest, CF_DEFAULT_DIGEST); HashPrintSafe(hash, hash_size, digest, CF_DEFAULT_DIGEST, true); + SeqDestroy(conlist); } void WatcherRegistryInitialize(void) @@ -157,6 +237,8 @@ bool WatcherRegister(const Promise *pp, WatcherOptions *opt) return true; } + const uint64_t id = ++last_watcher_id; + // Take over the state of an identical watcher Watcher *prev = (old_watchers != NULL) ? MapGet(old_watchers, promise_hash) : NULL; const bool taken_over = (prev != NULL); @@ -187,8 +269,42 @@ bool WatcherRegister(const Promise *pp, WatcherOptions *opt) } } - Watcher *w = xmalloc(sizeof(Watcher)); + /* Before the watcher is in the registry, so a wakeup from the method in + * between is for an unknown id, and ignored. The watcher is checked on the + * watcher thread's first pass anyway. */ + const char *method = WatchBackendsAdd(id, &opt->target, opt->methods, pp->promiser); + if (method == NULL) + { + Log(LOG_LEVEL_ERR, + "Reactor events promise '%s': none of its watch methods is available on this host", + pp->promiser); + if (!taken_over) + { + opt->destroy_state(state); + } + else + { + /* Hand the state back, for a later registration to take it over */ + ThreadLock(&watchers_mutex); + if (old_watchers != NULL && prev == MapGet(old_watchers, promise_hash)) + { + prev->state = state; + } + else + { + opt->destroy_state(state); + } + ThreadUnlock(&watchers_mutex); + } + return false; + } + Log(LOG_LEVEL_VERBOSE, "Reactor watcher '%s' watches with '%s'", pp->promiser, method); + + Watcher *w = xcalloc(1, sizeof(Watcher)); + w->id = id; + w->method = method; w->promise_hash = xstrdup(promise_hash); + w->window_start = WatcherClockNow(); w->promise = PromiseCopy(pp); w->poll_interval_secs = opt->interval; w->next_due = 0; /* due immediately on the watcher thread's first pass */ @@ -207,6 +323,7 @@ bool WatcherRegister(const Promise *pp, WatcherOptions *opt) static void WatcherDestroy(void *item) { Watcher *w = item; + WatchBackendsRemove(w->id, w->method); // w->state is NULL when it was borrowed by a new watcher if (w->destroy_state != NULL && w->state != NULL) { @@ -217,6 +334,74 @@ static void WatcherDestroy(void *item) free(w); } +static bool IdIsDirty(const uint64_t *ids, size_t num_ids, uint64_t id) +{ + for (size_t i = 0; i < num_ids; i++) + { + if (ids[i] == id) + { + return true; + } + } + return false; +} + +/* Called by the watcher thread, holding watchers_mutex */ +static void QueueEvent(Watcher *w, WatcherCheckResult check) +{ + assert(w != NULL); + + WatcherEvent *event = xmalloc(sizeof(WatcherEvent)); + event->watcher = w; + event->check = check; + ThreadedQueuePush(event_queue, event); +} + +/* Whether the watcher may run its bundles now, see TRIGGER_WINDOW_SECS. + * Counts the run if so. */ +static bool WatcherMayTrigger(Watcher *w, time_t now) +{ + assert(w != NULL); + + if (now - w->window_start >= TRIGGER_WINDOW_SECS) + { + if (w->triggers_in_window < MAX_TRIGGERS_PER_WINDOW) + { + /* A calm window ends the backoff */ + w->backoff_secs = 0; + } + w->window_start = now; + w->triggers_in_window = 0; + } + + if (w->throttled_until > now) + { + return false; + } + if (w->throttled_until != 0) + { + /* The delay is over: run once for the events held meanwhile */ + w->throttled_until = 0; + w->triggers_in_window++; + return true; + } + + if (w->triggers_in_window >= MAX_TRIGGERS_PER_WINDOW) + { + w->backoff_secs = (w->backoff_secs == 0) ? + MIN_TRIGGER_BACKOFF_SECS : MIN(w->backoff_secs * 2, MAX_TRIGGER_BACKOFF_SECS); + w->throttled_until = now + w->backoff_secs; + Log(LOG_LEVEL_WARNING, + "Reactor watcher '%s' fired more than %d times in %d seconds, delaying its next run by %jd seconds", + w->promise->promiser, MAX_TRIGGERS_PER_WINDOW, TRIGGER_WINDOW_SECS, + (intmax_t) w->backoff_secs); + return false; + } + + w->triggers_in_window++; + return true; +} + static void WatcherThreadMain(StoppableThread *thread, ARG_UNUSED void *unused) { /* Keep termination signals landing on the main thread (which owns the @@ -226,14 +411,39 @@ static void WatcherThreadMain(StoppableThread *thread, ARG_UNUSED void *unused) MaskTerminationSignalsInThread(); #endif + uint64_t *dirty = NULL; + size_t num_dirty = 0; + while (!IsPendingTermination() && !StoppableThreadShouldStop(thread)) { time_t now = WatcherClockNow(); time_t sleep_for = MAX_WATCHER_THREAD_SLEEP_SECS; bool any_event = false; + ThreadLock(&dirty_mutex); + const bool marked = (num_dirty_ids > 0 || all_dirty); + ThreadUnlock(&dirty_mutex); + if (marked) + { + const struct timespec settle = { 0, WATCH_SETTLE_MSECS * 1000000L }; + nanosleep(&settle, NULL); + now = WatcherClockNow(); + } + ThreadLock(&watchers_mutex); + /* Take the dirty marks, see WatcherMarkDirty() */ + ThreadLock(&dirty_mutex); + free(dirty); + dirty = dirty_ids; + num_dirty = num_dirty_ids; + const bool check_all = all_dirty; + dirty_ids = NULL; + num_dirty_ids = 0; + max_dirty_ids = 0; + all_dirty = false; + ThreadUnlock(&dirty_mutex); + /* Nothing to check until EventWatcherWakeUp() or EventWatcherResume() * (EventWatcherPause() empties `watchers`) wakes the thread up */ const bool idle = (MapSize(watchers) == 0); @@ -247,26 +457,53 @@ static void WatcherThreadMain(StoppableThread *thread, ARG_UNUSED void *unused) // check_callback cannot be null if it is correctly assigned during policy evaluation assert(w->check_callback != NULL); - if (now >= w->next_due) + const bool due = (now >= w->next_due || check_all || + IdIsDirty(dirty, num_dirty, w->id)); + + /* While an event is held, checking again can't change what runs + * next, and a check may be costly (file_content_modified hashes + * the file). Skip it and check once when the event can run, so + * a busy file is checked about as often as the bundles run. */ + const bool can_run = !w->event_held || + (!w->event_pending && w->throttled_until <= now); + if (due && !can_run) { + w->check_deferred = true; + w->next_due = now + w->poll_interval_secs; + } + else if (can_run && (due || w->check_deferred)) + { + w->check_deferred = false; WatcherCheckResult check = w->check_callback(w->state); w->next_due = now + w->poll_interval_secs; + if (check == WATCHER_CHECK_EVENT) + { + w->event_held = true; + } /* Only report a failing watcher once, not on every check, * until it recovers */ - if (check == WATCHER_CHECK_EVENT || - (check == WATCHER_CHECK_ERROR && w->last_check != WATCHER_CHECK_ERROR)) + else if (check == WATCHER_CHECK_ERROR && w->last_check != WATCHER_CHECK_ERROR) { - WatcherEvent *event = xmalloc(sizeof(WatcherEvent)); - event->watcher = w; - event->check = check; - ThreadedQueuePush(event_queue, event); + QueueEvent(w, WATCHER_CHECK_ERROR); any_event = true; } w->last_check = check; } + if (w->event_held && !w->event_pending && WatcherMayTrigger(w, now)) + { + w->event_held = false; + w->event_pending = true; + QueueEvent(w, WATCHER_CHECK_EVENT); + any_event = true; + } + time_t until_due = (w->next_due > now) ? (w->next_due - now) : 0; + if (w->event_held && w->throttled_until > now) + { + until_due = MIN(until_due, w->throttled_until - now); + } sleep_for = MIN(sleep_for, until_due); } @@ -286,6 +523,7 @@ static void WatcherThreadMain(StoppableThread *thread, ARG_UNUSED void *unused) StoppableThreadSleep(thread, sleep_for); } } + free(dirty); } bool EventWatcherInitialize(int *fd) @@ -300,11 +538,13 @@ bool EventWatcherInitialize(int *fd) } event_queue = ThreadedQueueNew(16, free); + WatchBackendsInitialize(&WATCH_HOST); watcher_thread = StoppableThreadStart(WatcherThreadMain, NULL); if (watcher_thread == NULL) { Log(LOG_LEVEL_ERR, "Unable to start cf-reactor watcher thread"); + WatchBackendsFinalize(); ThreadedQueueDestroy(event_queue); event_queue = NULL; WakeupChannelClose(&wakeup_channel); @@ -319,6 +559,27 @@ bool EventWatcherInitialize(int *fd) return true; } +/* The event's bundles have run: the watcher may queue events again. Whatever + * it observed meanwhile, including what the bundles changed themselves, was + * kept aside (event_held) and fires now. A converging bundle runs once more + * and changes nothing, so this ends. Taking the state after the run as the + * new reference instead would avoid that run, but lose any change made by + * something else while the bundles ran. */ +static void EventHandled(Watcher *w) +{ + assert(w != NULL); + + ThreadLock(&watchers_mutex); + w->event_pending = false; + const bool held = w->event_held; + ThreadUnlock(&watchers_mutex); + + if (held && watcher_thread != NULL) + { + StoppableThreadWake(watcher_thread); + } +} + /* Watchers are only destroyed by the main thread (EventWatcherResume()), * which is also the one handling events, and never while events are queued, * so the watchers of queued events can be used without holding @@ -331,7 +592,7 @@ static void HandleQueuedEvents(EvalContext *ctx, WatcherEventFn on_event) while (ThreadedQueuePop(event_queue, &item, 0)) { WatcherEvent *event = item; - const Watcher *w = event->watcher; + Watcher *w = event->watcher; if (event->check == WATCHER_CHECK_ERROR) { Log(LOG_LEVEL_ERR, "Reactor watcher '%s' failed to check for its event", w->promise->promiser); @@ -341,6 +602,10 @@ static void HandleQueuedEvents(EvalContext *ctx, WatcherEventFn on_event) Log(LOG_LEVEL_VERBOSE, "Reactor watcher '%s' fired", w->promise->promiser); } on_event(ctx, w->promise, event->check); + if (event->check == WATCHER_CHECK_EVENT) + { + EventHandled(w); + } free(event); } } @@ -408,12 +673,63 @@ void EventWatcherResume(void) EventWatcherWakeUp(); } +/* Writes which method each watcher watches with to REACTOR_WATCHERS_REPORT_FILE, + * for Enterprise reporting to show which hosts get real-time detection. */ +static void WriteWatchersReport(void) +{ + JsonElement *report = JsonArrayCreate(8); + + ThreadLock(&watchers_mutex); + MapIterator it = MapIteratorInit(watchers); + MapKeyValue *item; + while ((item = MapIteratorNext(&it)) != NULL) + { + const Watcher *w = item->value; + JsonElement *entry = JsonObjectCreate(4); + JsonObjectAppendString(entry, "promiser", w->promise->promiser); + JsonObjectAppendString(entry, "bundle", PromiseGetBundle(w->promise)->name); + JsonObjectAppendString(entry, "method", w->method); + JsonObjectAppendInteger(entry, "interval", (int) w->poll_interval_secs); + JsonArrayAppendObject(report, entry); + } + ThreadUnlock(&watchers_mutex); + + /* Written aside and renamed over, so that a reader never sees half of it */ + char path[PATH_MAX]; + NDEBUG_UNUSED int ret = snprintf(path, sizeof(path), "%s%c%s", + GetStateDir(), FILE_SEPARATOR, REACTOR_WATCHERS_REPORT_FILE); + assert(ret >= 0 && ret < (int) sizeof(path)); + char tmp_path[PATH_MAX]; + ret = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); + assert(ret >= 0 && ret < (int) sizeof(tmp_path)); + + FILE *file = safe_fopen(tmp_path, "w"); + if (file == NULL) + { + Log(LOG_LEVEL_VERBOSE, "Failed to write the reactor watchers report '%s': %s", + tmp_path, GetErrorStr()); + } + else + { + Writer *writer = FileWriter(file); + JsonWrite(writer, report, 0); + WriterClose(writer); + if (rename(tmp_path, path) != 0) + { + Log(LOG_LEVEL_VERBOSE, "Failed to rename '%s' to '%s': %s", + tmp_path, path, GetErrorStr()); + } + } + JsonDestroy(report); +} + void EventWatcherWakeUp(void) { /* Called once the watchers of the (re-)read policy are registered */ ThreadLock(&watchers_mutex); const size_t count = MapSize(watchers); ThreadUnlock(&watchers_mutex); + WriteWatchersReport(); Log(LOG_LEVEL_VERBOSE, "cf-reactor event watcher has %zu watcher(s)", count); if (watcher_thread != NULL) @@ -424,6 +740,9 @@ void EventWatcherWakeUp(void) void EventWatcherFinalize(void) { + /* Before stopping the watcher thread, which they wake up */ + WatchBackendsFinalize(); + bool joined = StoppableThreadStop(watcher_thread, WATCHER_THREAD_EXIT_TIMEOUT_SECS); watcher_thread = NULL; if (!joined) @@ -440,3 +759,35 @@ void EventWatcherFinalize(void) WatcherRegistryFinalize(); } + +void WatcherMarkDirty(uint64_t watcher_id) +{ + ThreadLock(&dirty_mutex); + if (!IdIsDirty(dirty_ids, num_dirty_ids, watcher_id)) + { + if (num_dirty_ids == max_dirty_ids) + { + max_dirty_ids = (max_dirty_ids == 0) ? 16 : max_dirty_ids * 2; + dirty_ids = xrealloc(dirty_ids, max_dirty_ids * sizeof(uint64_t)); + } + dirty_ids[num_dirty_ids++] = watcher_id; + } + ThreadUnlock(&dirty_mutex); + + if (watcher_thread != NULL) + { + StoppableThreadWake(watcher_thread); + } +} + +void WatcherMarkAllDirty(void) +{ + ThreadLock(&dirty_mutex); + all_dirty = true; + ThreadUnlock(&dirty_mutex); + + if (watcher_thread != NULL) + { + StoppableThreadWake(watcher_thread); + } +} diff --git a/cf-reactor/watcher.h b/cf-reactor/watcher.h index bf12a91f63c..dec64da7cb1 100644 --- a/cf-reactor/watcher.h +++ b/cf-reactor/watcher.h @@ -27,6 +27,7 @@ #include /* Bundle */ #include /* CF_HOSTKEY_STRING_SIZE */ +#include typedef enum { @@ -48,6 +49,8 @@ typedef struct WatcherStateDestroyFn destroy_state; void *state; /* owned by the registry once registered */ time_t interval; /* seconds between two checks, > 0 */ + ReactorWatchTarget target; /* what the event is about, for the watch methods */ + const Rlist *methods; /* 'watch_methods', NULL for the default */ } WatcherOptions; /** @@ -98,8 +101,13 @@ void WatcherRegistryFinalize(void); * owned by the registry, which destroys it right away if not * used, so it must not be used by the caller afterwards. * opt->interval must be strictly greater than 0. - * @return false if the check of opt->state failed (WATCHER_CHECK_ERROR), in - * which case the watcher is not registered, true otherwise. + * The watcher is also watched with the first available method of + * opt->methods (see WatchBackendsAdd()), which wakes it up to be checked + * before its interval elapses. + * + * @return false if the check of opt->state failed (WATCHER_CHECK_ERROR), or + * none of opt->methods is available and they don't include "poll", + * in which case the watcher is not registered, true otherwise. */ bool WatcherRegister(const Promise *pp, WatcherOptions *opt); bool EventWatcherInitialize(int *fd); @@ -136,4 +144,14 @@ void EventWatcherResume(void); void EventWatcherWakeUp(void); void EventWatcherFinalize(void); +/** + * @brief Check the watcher with this id as soon as possible, regardless of + * its interval. Thread-safe, an id that isn't registered is ignored. + * See ReactorWatchHost. + */ +void WatcherMarkDirty(uint64_t watcher_id); + +/** @brief Check all the watchers as soon as possible. Thread-safe. */ +void WatcherMarkAllDirty(void); + #endif diff --git a/libpromises/Makefile.am b/libpromises/Makefile.am index dc3fcf9dd77..32e5d46d1a4 100644 --- a/libpromises/Makefile.am +++ b/libpromises/Makefile.am @@ -149,6 +149,7 @@ libpromises_la_SOURCES = \ process_lib.h process_unix_priv.h \ promises.c promises.h \ prototypes3.h \ + reactor_watch_backend.h \ rlist.c rlist.h \ scope.c scope.h \ shared_lib.c shared_lib.h \ diff --git a/libpromises/enterprise_stubs.c b/libpromises/enterprise_stubs.c index dccf8bc2f67..cfe42d269a8 100644 --- a/libpromises/enterprise_stubs.c +++ b/libpromises/enterprise_stubs.c @@ -232,6 +232,11 @@ ENTERPRISE_VOID_FUNC_2ARG_DEFINE_STUB(void, Nova_ClassHistoryEnable, { } +ENTERPRISE_FUNC_0ARG_DEFINE_STUB(const ReactorWatchBackend *const *, ReactorEnterpriseWatchBackends) +{ + return NULL; +} + ENTERPRISE_FUNC_0ARG_DEFINE_STUB(size_t, ReactorNovaMaxFds) { return 0; diff --git a/libpromises/mod_reactor.c b/libpromises/mod_reactor.c index ea1f8c6b616..5b47b0df218 100644 --- a/libpromises/mod_reactor.c +++ b/libpromises/mod_reactor.c @@ -54,12 +54,13 @@ static const ConstraintSyntax when_constraints[] = ConstraintSyntaxNewString(REACTOR_USER_ADDED, CF_ANYSTRING, "User to react for on addition", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewString(REACTOR_USER_REMOVED, CF_ANYSTRING, "User to react for on removal", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewInt("interval", REACTOR_INTERVAL_RANGE, "Seconds between two checks for the event", SYNTAX_STATUS_NORMAL), + ConstraintSyntaxNewStringList(REACTOR_WATCH_METHODS, REACTOR_WATCH_METHODS_RANGE, "Methods to watch for the event with, in order of preference. Without '" REACTOR_WATCH_METHOD_POLL "', the promise is not kept if none of them is available", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewNull() }; /* Checks that a 'when' body has an event attribute, i.e. any attribute other - * than the global ones (meta, inherit_from) and 'interval', which only sets - * how the event is checked for. A body inheriting from another one may get + * than the global ones (meta, inherit_from), 'interval' and 'watch_methods', + * which only set how the event is checked for. A body inheriting from another one may get * its event from it, and that one is checked on its own. */ static bool WhenBodyCheck(const Body *body, Seq *errors) { @@ -73,7 +74,9 @@ static bool WhenBodyCheck(const Body *body, Seq *errors) for (size_t i = 0; i < SeqLength(body->conlist); i++) { const Constraint *cp = SeqAt(body->conlist, i); - if (!StringEqual(cp->lval, "meta") && !StringEqual(cp->lval, "interval")) + if (!StringEqual(cp->lval, "meta") && + !StringEqual(cp->lval, "interval") && + !StringEqual(cp->lval, REACTOR_WATCH_METHODS)) { return true; } diff --git a/libpromises/mod_reactor.h b/libpromises/mod_reactor.h index eeb5380a122..1dab86bda0f 100644 --- a/libpromises/mod_reactor.h +++ b/libpromises/mod_reactor.h @@ -39,6 +39,13 @@ #define REACTOR_USER_ADDED "user_added" #define REACTOR_USER_REMOVED "user_removed" +/* How the event of a 'when' body is watched for, an ordered list of methods. + * Every method name of every edition is known here, so that policy validates + * the same way everywhere; a method this binary doesn't have is skipped. */ +#define REACTOR_WATCH_METHODS "watch_methods" +#define REACTOR_WATCH_METHOD_POLL "poll" +#define REACTOR_WATCH_METHODS_RANGE "poll|inotify|fanotify|ebpf|usn|rdcw" + /* Range of the 'interval' attribute of the 'when' body of events promises */ #define REACTOR_INTERVAL_RANGE "1,99999999999" diff --git a/libpromises/prototypes3.h b/libpromises/prototypes3.h index 061e7afc40d..407389d314a 100644 --- a/libpromises/prototypes3.h +++ b/libpromises/prototypes3.h @@ -29,6 +29,7 @@ #include #include #include +#include bool BootstrapAllowed(void); @@ -84,6 +85,9 @@ ENTERPRISE_FUNC_3ARG_DECLARE(bool, ReactorNovaInitialize, int*, fds, size_t, max ENTERPRISE_VOID_FUNC_1ARG_DECLARE(void, ReactorNovaHandleTimeout, time_t *, next_tick); ENTERPRISE_VOID_FUNC_3ARG_DECLARE(void, ReactorNovaHandleEvents, fd_set *, readfds, int *, fds, time_t *, next_tick); ENTERPRISE_VOID_FUNC_0ARG_DECLARE(void, ReactorNovaFinalize); +/* NULL-terminated array of the extra cf-reactor watch methods, see + * reactor_watch_backend.h, or NULL */ +ENTERPRISE_FUNC_0ARG_DECLARE(const ReactorWatchBackend *const *, ReactorEnterpriseWatchBackends); /* manual.c */ diff --git a/libpromises/reactor_watch_backend.h b/libpromises/reactor_watch_backend.h new file mode 100644 index 00000000000..0ed7f90e2f1 --- /dev/null +++ b/libpromises/reactor_watch_backend.h @@ -0,0 +1,85 @@ +/* + 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. +*/ + +#ifndef CFENGINE_REACTOR_WATCH_BACKEND_H +#define CFENGINE_REACTOR_WATCH_BACKEND_H + +/* Interface between cf-reactor and the methods it can watch for events with + * (the 'watch_methods' attribute of 'when' bodies), shared with the + * Enterprise extension, which provides some of the methods. + * + * A method doesn't detect events itself. It only tells cf-reactor that + * something touched what a watcher is about, by marking the watcher dirty, + * and the watcher's own check then decides whether its event happened. So + * every event type behaves the same with every method, and polling at the + * watcher's interval keeps running underneath as the reference. */ + +#include + +/* JSON array written to the state directory by cf-reactor whenever its + * watchers are (re-)registered, one object per watcher: "promiser", + * "bundle", "method" (the watch method it resolved to) and "interval". + * Read by Enterprise reporting. */ +#define REACTOR_WATCHERS_REPORT_FILE "cf_reactor_watchers.json" + +/* Bumped on any incompatible change of the structs below */ +#define REACTOR_WATCH_BACKEND_ABI_VERSION 1 + +typedef enum +{ + REACTOR_WATCH_TARGET_FILE, /* the file at `path`, which may not exist */ + REACTOR_WATCH_TARGET_USERS, /* the local user accounts, `path` is NULL */ +} ReactorWatchTargetType; + +typedef struct +{ + ReactorWatchTargetType type; + const char *path; +} ReactorWatchTarget; + +/* What cf-reactor provides to the methods. Both functions are thread-safe, + * and may be called with a watcher_id that is no longer registered, which is + * ignored. */ +typedef struct +{ + int abi_version; /* REACTOR_WATCH_BACKEND_ABI_VERSION */ + void (*mark_dirty)(uint64_t watcher_id); /* check this watcher now */ + void (*mark_all_dirty)(void); /* events were lost, check all now */ +} ReactorWatchHost; + +/* A method to watch for events with. Each one runs its own thread (or uses + * the OS's own callbacks) to read its notifications, and calls the host's + * mark_dirty() for the watchers they concern. add() and remove() are called + * from cf-reactor's main thread. */ +typedef struct +{ + const char *name; /* as in 'watch_methods' */ + bool (*available)(void); /* this host supports it */ + bool (*start)(const ReactorWatchHost *host); + void (*stop)(void); + bool (*add)(uint64_t watcher_id, const ReactorWatchTarget *target); + void (*remove)(uint64_t watcher_id); +} ReactorWatchBackend; + +#endif diff --git a/tests/reactor/tests/00_basics/009_interval.cf b/tests/reactor/tests/00_basics/009_interval.cf index 008cf55c893..bf05012bbd2 100644 --- a/tests/reactor/tests/00_basics/009_interval.cf +++ b/tests/reactor/tests/00_basics/009_interval.cf @@ -14,6 +14,9 @@ body when deleted(file) { file_deleted => "$(file)"; interval => 10; + # The interval only spaces the checks of polling: a watch method wakes the + # watcher up on every change + watch_methods => { "poll" }; } bundle reactor test diff --git a/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf index b7dbf291f56..6a6431afc94 100644 --- a/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf +++ b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf @@ -14,6 +14,9 @@ body when deleted(file) { file_deleted => "$(file)"; interval => 1; + # A watch method wakes the watcher up whatever the clock says: only + # polling shows whether the next check is postponed + watch_methods => { "poll" }; } bundle reactor test diff --git a/tests/reactor/tests/03_watch_methods/003_no_method_available.cf b/tests/reactor/tests/03_watch_methods/003_no_method_available.cf new file mode 100644 index 00000000000..f94c02db621 --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/003_no_method_available.cf @@ -0,0 +1,30 @@ +####################################################### +# +# Test that without poll, an unavailable method makes the promise not kept +# +####################################################### +body common control +{ + inputs => { "../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when deleted(file) +{ + file_deleted => "$(file)"; + watch_methods => { "usn", "rdcw" }; +} + +bundle reactor test +{ + vars: + "test" string => getenv("TESTFILE", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + + events: + "$(promise_name)" + when => deleted("$(test)"), + then => record_event("$(events_file)", "$(test) was deleted"); +} diff --git a/tests/reactor/tests/03_watch_methods/003_no_method_available.sh b/tests/reactor/tests/03_watch_methods/003_no_method_available.sh new file mode 100644 index 00000000000..36ab178049c --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/003_no_method_available.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +# check that a watcher whose methods are all unavailable (Windows ones, here) +# and that doesn't accept polling isn't registered + +set -ex + +. "$(dirname "$0")/../../lib.sh" + +POLICY_FILE="$1" + +TESTFILE="$WORKDIR/tmp/test" +EVENTS_FILE="$WORKDIR/tmp/events" +export TESTFILE EVENTS_FILE + +case "$(uname -s)" in + *MINGW*|*MSYS*|*CYGWIN*) exit 0 ;; +esac + +touch "$TESTFILE" + +start_reactor "$POLICY_FILE" +wait_for_log "Reactor events promise '$PROMISE_NAME': none of its watch methods is available on this host" +wait_for_watchers 0 diff --git a/tests/reactor/tests/03_watch_methods/004_poll_only.cf b/tests/reactor/tests/03_watch_methods/004_poll_only.cf new file mode 100644 index 00000000000..ad9cba8e20f --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/004_poll_only.cf @@ -0,0 +1,31 @@ +####################################################### +# +# Test that watch_methods => { "poll" } polls +# +####################################################### +body common control +{ + inputs => { "../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when deleted(file) +{ + file_deleted => "$(file)"; + interval => 1; + watch_methods => { "poll" }; +} + +bundle reactor test +{ + vars: + "test" string => getenv("TESTFILE", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + + events: + "$(promise_name)" + when => deleted("$(test)"), + then => record_event("$(events_file)", "$(test) was deleted"); +} diff --git a/tests/reactor/tests/03_watch_methods/004_poll_only.sh b/tests/reactor/tests/03_watch_methods/004_poll_only.sh new file mode 100644 index 00000000000..27b136ea80f --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/004_poll_only.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +# check that a watcher limited to polling uses it, and still fires + +set -ex + +. "$(dirname "$0")/../../lib.sh" + +POLICY_FILE="$1" + +TESTFILE="$WORKDIR/tmp/test" +EVENTS_FILE="$WORKDIR/tmp/events" +export TESTFILE EVENTS_FILE + +touch "$TESTFILE" + +start_reactor "$POLICY_FILE" +wait_for_watchers 1 +wait_for_log "File '$TESTFILE' is present, watching for its deletion" +wait_until log_contains "Reactor watcher '$PROMISE_NAME' watches with 'poll'" + +rm "$TESTFILE" +wait_for_lines "$EVENTS_FILE" 1 +check_event "$EVENTS_FILE" 1 "$TESTFILE was deleted" diff --git a/tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.cf b/tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.cf new file mode 100644 index 00000000000..81c36ee64fd --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.cf @@ -0,0 +1,39 @@ +####################################################### +# +# Test that changing watch_methods doesn't reset the state of a watcher +# +####################################################### +body common control +{ + inputs => { "../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when deleted(file, methods) +{ + file_deleted => "$(file)"; + watch_methods => { @(methods) }; +} + +bundle reactor test +{ + vars: + "test" string => getenv("TESTFILE", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + "methods_file" string => getenv("METHODS_FILE", 999); + "reload_marker" string => getenv("RELOAD_MARKER", 999); + "methods" slist => readstringlist("$(methods_file)", "", "\n", 10, 1000); + + # Evaluated on start and on every policy reload, after the watchers were + # set aside: tells the test it can change the file, and keeps the watchers + # paused long enough for it to do so + "slow" + string => execresult("touch '$(reload_marker)' && sleep 3", "useshell"); + + events: + "$(promise_name)" + when => deleted("$(test)", @(methods)), + then => record_event("$(events_file)", "$(test) was deleted"); +} diff --git a/tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.sh b/tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.sh new file mode 100644 index 00000000000..81e42117597 --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/005_methods_change_keeps_state.sh @@ -0,0 +1,43 @@ +#!/bin/sh + +# check that a watcher whose 'watch_methods' changed over a policy reload still +# takes over the state of the watcher from before the reload, so that a +# deletion while reloading fires. With the methods part of the watcher's +# identity, it would start from scratch and only see the file missing. + +set -ex + +. "$(dirname "$0")/../../lib.sh" + +POLICY_FILE="$1" + +TESTFILE="$WORKDIR/tmp/test" +EVENTS_FILE="$WORKDIR/tmp/events" +export TESTFILE EVENTS_FILE + +METHODS_FILE="$WORKDIR/tmp/methods" +RELOAD_MARKER="$WORKDIR/tmp/reload_marker" +export METHODS_FILE RELOAD_MARKER + +echo poll > "$METHODS_FILE" +touch "$TESTFILE" + +start_reactor "$POLICY_FILE" +wait_for_watchers 1 +wait_for_log "File '$TESTFILE' is present, watching for its deletion" +wait_until log_contains "Reactor watcher '$PROMISE_NAME' watches with 'poll'" + +# a different list, that still works everywhere +printf 'inotify\nrdcw\npoll\n' > "$METHODS_FILE" +rm "$RELOAD_MARKER" +kill -HUP "$reactor_pid" +wait_for_log "Rereading policy file" +if ! wait_until [ -f "$RELOAD_MARKER" ]; then + echo "Timed out waiting for the policy to be reloaded, reactor log:" >&2 + cat "$LOG" >&2 + exit 1 +fi +rm "$TESTFILE" + +wait_for_lines "$EVENTS_FILE" 1 +check_event "$EVENTS_FILE" 1 "$TESTFILE was deleted" diff --git a/tests/reactor/tests/03_watch_methods/006_repair_converges.cf b/tests/reactor/tests/03_watch_methods/006_repair_converges.cf new file mode 100644 index 00000000000..6e0a663f1d8 --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/006_repair_converges.cf @@ -0,0 +1,37 @@ +####################################################### +# +# Test that a bundle repairing the watched file converges +# +####################################################### +body common control +{ + inputs => { "../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when content_modified(file) +{ + file_content_modified => "$(file)"; + interval => 1; +} + +bundle reactor test +{ + vars: + "test" string => getenv("TESTFILE", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + + events: + "$(promise_name)" + when => content_modified("$(test)"), + then => { record_event("$(events_file)", "$(test) was modified"), repair("$(test)") }; +} + +bundle agent repair(file) +{ + files: + "$(file)" + content => "good"; +} diff --git a/tests/reactor/tests/03_watch_methods/006_repair_converges.sh b/tests/reactor/tests/03_watch_methods/006_repair_converges.sh new file mode 100644 index 00000000000..c8dc12c9aaa --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/006_repair_converges.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +# check that a 'then' bundle repairing the watched file runs once more, for +# its own change, and that this converges: the second run changes nothing, so +# there is no third. Changes made while the bundles run are never lost. + +set -ex + +. "$(dirname "$0")/../../lib.sh" + +POLICY_FILE="$1" + +TESTFILE="$WORKDIR/tmp/test" +EVENTS_FILE="$WORKDIR/tmp/events" +export TESTFILE EVENTS_FILE + +echo good > "$TESTFILE" + +start_reactor "$POLICY_FILE" +wait_for_watchers 1 + +echo bad > "$TESTFILE" +wait_for_lines "$EVENTS_FILE" 1 +wait_until grep -q -x good "$TESTFILE" + +# the run for its own repair +wait_for_lines "$EVENTS_FILE" 2 + +# and no more, give it a few checks +sleep 3 +wait_for_lines "$EVENTS_FILE" 2 + +# and the watcher still fires on a later change +echo bad again > "$TESTFILE" +wait_for_lines "$EVENTS_FILE" 3 diff --git a/tests/reactor/tests/03_watch_methods/007_trigger_limit.cf b/tests/reactor/tests/03_watch_methods/007_trigger_limit.cf new file mode 100644 index 00000000000..3f0b44e60de --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/007_trigger_limit.cf @@ -0,0 +1,30 @@ +####################################################### +# +# Test the limit on how often a watcher runs its bundles +# +####################################################### +body common control +{ + inputs => { "../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when modified(file) +{ + file_modified => "$(file)"; + interval => 1; +} + +bundle reactor test +{ + vars: + "test" string => getenv("TESTFILE", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + + events: + "$(promise_name)" + when => modified("$(test)"), + then => record_event("$(events_file)", "$(test) was modified"); +} diff --git a/tests/reactor/tests/03_watch_methods/007_trigger_limit.sh b/tests/reactor/tests/03_watch_methods/007_trigger_limit.sh new file mode 100644 index 00000000000..d2f1c7441cf --- /dev/null +++ b/tests/reactor/tests/03_watch_methods/007_trigger_limit.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# check that a watcher firing more than 10 times a minute is delayed, its +# further events coalesced into one run once the delay is over + +set -ex + +. "$(dirname "$0")/../../lib.sh" + +POLICY_FILE="$1" + +TESTFILE="$WORKDIR/tmp/test" +EVENTS_FILE="$WORKDIR/tmp/events" +export TESTFILE EVENTS_FILE + +touch "$TESTFILE" + +start_reactor "$POLICY_FILE" +wait_for_watchers 1 + +# Each modification seen as its own event: wait for each run before the next +i=0 +while [ $i -lt 10 ]; do + i=$((i + 1)) + touch "$TESTFILE" + wait_for_lines "$EVENTS_FILE" $i +done + +# Beyond the limit, a burst of modifications only runs the bundles once more, +# after the delay +i=0 +while [ $i -lt 5 ]; do + i=$((i + 1)) + touch "$TESTFILE" + sleep 1.1 2>/dev/null || sleep 2 +done +wait_for_log "fired more than 10 times in 60 seconds, delaying its next run by 10 seconds" +wait_for_lines "$EVENTS_FILE" 11 +sleep 3 +wait_for_lines "$EVENTS_FILE" 11 From ee8db89982dd805d701e33239c3bbfe1fd53a7ed Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 29 Oct 2026 08:51:02 -0500 Subject: [PATCH 08/11] Allowed cf-reactor's watch methods in the SELinux policy Without these, a confined cf-reactor falls back to polling. Changelog: None --- misc/selinux/cfengine-enterprise.te.all | 11 +++++++++++ misc/selinux/cfengine-enterprise.te.el10 | 12 ++++++++++++ misc/selinux/cfengine-enterprise.te.el9 | 12 ++++++++++++ 3 files changed, 35 insertions(+) diff --git a/misc/selinux/cfengine-enterprise.te.all b/misc/selinux/cfengine-enterprise.te.all index c03df4170d3..f6a515fed1c 100644 --- a/misc/selinux/cfengine-enterprise.te.all +++ b/misc/selinux/cfengine-enterprise.te.all @@ -17,6 +17,7 @@ require { attribute non_auth_file_type; type autofs_t; type bin_t; + type bpf_t; type cert_t; type devlog_t; type kernel_t; @@ -750,6 +751,16 @@ allow cfengine_reactor_t sendmail_exec_t:file map; allow cfengine_reactor_t sendmail_exec_t:file { execute execute_no_trans open read }; allow cfengine_reactor_t smtp_port_t:tcp_socket name_connect; +# cf-reactor watch methods. A denial only makes a watcher fall back to polling. +# The 'watch' and CAP_BPF/CAP_PERFMON rules are in the EL9/EL10 files, the EL8 +# policy doesn't define them. +# ebpf reads the kernel's BTF (/sys/kernel/btf/vmlinux), then loads and +# attaches its watch program (kprobe.multi or fentry) +allow cfengine_reactor_t sysfs_t:file { getattr open read }; +allow cfengine_reactor_t self:bpf { map_create map_read map_write prog_load prog_run }; +# libbpf opens /sys/fs/bpf to try a BPF token, which it doesn't need +dontaudit cfengine_reactor_t bpf_t:dir read; + #============= cfengine_action_script_t ============== # A special type and domain for action (notification/alert) scripts executed by diff --git a/misc/selinux/cfengine-enterprise.te.el10 b/misc/selinux/cfengine-enterprise.te.el10 index 1aee2439e41..2fab7d89cc7 100644 --- a/misc/selinux/cfengine-enterprise.te.el10 +++ b/misc/selinux/cfengine-enterprise.te.el10 @@ -48,3 +48,15 @@ allow cfengine_reactor_t kernel_t:unix_stream_socket connectto; #============= cfengine_serverd_t ============== allow cfengine_serverd_t http_port_t:tcp_socket name_connect; + +#============= cf-reactor watch methods ============== +# A denial only makes a watcher fall back to polling. +# inotify and fanotify watch the watched file and its parent directory (or the +# nearest existing ancestor), which can be anywhere +allow cfengine_reactor_t file_type:dir watch; +allow cfengine_reactor_t file_type:file watch; +# Without CAP_SYS_ADMIN the kernel gives fanotify an unprivileged group, which +# is all it needs (inode marks, own events still identified by PID) +dontaudit cfengine_reactor_t self:capability sys_admin; +# ebpf +allow cfengine_reactor_t self:capability2 { bpf perfmon }; diff --git a/misc/selinux/cfengine-enterprise.te.el9 b/misc/selinux/cfengine-enterprise.te.el9 index 14fe34b0ca0..8d2cff12d64 100644 --- a/misc/selinux/cfengine-enterprise.te.el9 +++ b/misc/selinux/cfengine-enterprise.te.el9 @@ -14,3 +14,15 @@ allow cfengine_reactor_t systemd_userdbd_runtime_t:sock_file write; # these permissions are not be needed if changes from ENT-12954 to masterfiles policy move inventory from common to an agent bundle are in place. allow cfengine_serverd_t http_port_t:tcp_socket name_connect; allow cfengine_execd_t http_port_t:tcp_socket name_connect; + +#============= cf-reactor watch methods ============== +# A denial only makes a watcher fall back to polling. +# inotify and fanotify watch the watched file and its parent directory (or the +# nearest existing ancestor), which can be anywhere +allow cfengine_reactor_t file_type:dir watch; +allow cfengine_reactor_t file_type:file watch; +# Without CAP_SYS_ADMIN the kernel gives fanotify an unprivileged group, which +# is all it needs (inode marks, own events still identified by PID) +dontaudit cfengine_reactor_t self:capability sys_admin; +# ebpf +allow cfengine_reactor_t self:capability2 { bpf perfmon }; From abea573ccf735f52aa41d227298ff810a53b8a63 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 29 Oct 2026 09:22:43 -0500 Subject: [PATCH 09/11] Made cf-reactor file checks handle locked files and long paths on Windows A file in a directory opened without sharing no longer looks deleted, a watcher whose first check fails is kept, and \\?\ prefixes and trailing separators are stripped before checking. Changelog: None --- cf-reactor/file_watcher.c | 49 ++++++++++++++++++++++++++++++++++++--- cf-reactor/watcher.c | 11 ++++----- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/cf-reactor/file_watcher.c b/cf-reactor/file_watcher.c index 16ceadcf467..43d5352385d 100644 --- a/cf-reactor/file_watcher.c +++ b/cf-reactor/file_watcher.c @@ -32,6 +32,7 @@ #include /* cf_strtimestamp_local() */ #include #include +#include /* strerror() */ #include /* Minimum time between two logs of a persisting failure to check the file, @@ -92,6 +93,33 @@ typedef struct } last; } FileWatcherState; +/** + * Copy of path in a form stat() accepts. On Windows, msvcrt's stat() fails + * with ENOENT for a path with a trailing separator, or with the \\?\ (or + * //?/) prefix, so a watcher with such a path would never see its file. + */ +static char *WatchPathCopy(const char *path) +{ + assert(path != NULL); + + char *copy = xstrdup(path); +#ifdef __MINGW32__ + if ((copy[0] == '\\' || copy[0] == '/') && (copy[1] == '\\' || copy[1] == '/') && + copy[2] == '?' && (copy[3] == '\\' || copy[3] == '/')) + { + memmove(copy, copy + 4, strlen(copy + 4) + 1); + } + + /* Keeps the separator of a drive's root, e.g. C:/ */ + size_t length = strlen(copy); + while (length > 1 && (copy[length - 1] == '\\' || copy[length - 1] == '/') && + !(length == 3 && copy[1] == ':')) + { + copy[--length] = '\0'; + } +#endif + return copy; +} void *FileWatcherStateNew(const Rlist *args) { @@ -99,7 +127,7 @@ void *FileWatcherStateNew(const Rlist *args) const char *path = RlistScalarValue(args); FileWatcherState *fws = (FileWatcherState *) xcalloc(1, sizeof(FileWatcherState)); - fws->path = xstrdup(path); + fws->path = WatchPathCopy(path); return fws; } @@ -111,7 +139,7 @@ void *FileWatcherSizeStateNew(const Rlist *args) const long size_threshold = IntFromString(RlistScalarValue(args->next)); FileWatcherState *fws = (FileWatcherState *) xcalloc(1, sizeof(FileWatcherState)); - fws->path = xstrdup(path); + fws->path = WatchPathCopy(path); fws->last.file_size.threshold = size_threshold; return fws; @@ -140,8 +168,10 @@ static void LogCheckError(FileWatcherState *fws, const char *op, int err) if (fws->last_error_logged == 0 || now - fws->last_error_logged >= CHECK_ERROR_LOG_INTERVAL_SECS) { + /* strerror(), as err is an errno value: GetErrorStrFromCode() + * takes a Windows error code on Windows */ Log(LOG_LEVEL_ERR, "Unable to %s '%s' while checking for file events: %s", - op, fws->path, GetErrorStrFromCode(err)); + op, fws->path, strerror(err)); fws->last_error_logged = now; } } @@ -166,6 +196,19 @@ static bool FileLstat(FileWatcherState *fws, bool *exists, struct stat *sb) return true; } +#ifdef __MINGW32__ + /* msvcrt's stat() also fails with ENOENT while the file's directory is + * open without sharing (FindFirstFile() gets a sharing violation), when + * the file is still there: then whether it changed is unknown */ + WIN32_FILE_ATTRIBUTE_DATA attributes; + if (errno == ENOENT && + GetFileAttributesExA(fws->path, GetFileExInfoStandard, &attributes)) + { + LogCheckError(fws, "lstat", EBUSY); + return false; + } +#endif + if (errno == ENOENT || errno == ENOTDIR) { *exists = false; diff --git a/cf-reactor/watcher.c b/cf-reactor/watcher.c index e9c0b4d59e4..99487b14c34 100644 --- a/cf-reactor/watcher.c +++ b/cf-reactor/watcher.c @@ -260,13 +260,10 @@ bool WatcherRegister(const Promise *pp, WatcherOptions *opt) { /* Without holding watchers_mutex, as the check may block (e.g. * lstat() on an unresponsive NFS mount). The watcher isn't registered - * yet, so the watcher thread can't check it concurrently. */ - if (opt->check_callback(state) == WATCHER_CHECK_ERROR) - { - /* No reference for the next checks, so not registered */ - opt->destroy_state(state); - return false; - } + * yet, so the watcher thread can't check it concurrently. A failure + * (e.g. the file locked on Windows) is logged by the check, and the + * watcher's first successful check becomes the reference instead. */ + opt->check_callback(state); } /* Before the watcher is in the registry, so a wakeup from the method in From 27dd1330a6fbadb8e3bc0e684375fe199d0534d4 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 29 Oct 2026 12:28:58 -0500 Subject: [PATCH 10/11] Reformatted the cf-reactor test policies Changelog: None --- tests/reactor/tests/00_basics/009_interval.cf | 1 + .../reactor/tests/00_basics/unsafe/001_clock_step_back.cf | 1 + .../tests/03_watch_methods/006_repair_converges.cf | 8 +++++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/reactor/tests/00_basics/009_interval.cf b/tests/reactor/tests/00_basics/009_interval.cf index bf05012bbd2..1873a2f01c1 100644 --- a/tests/reactor/tests/00_basics/009_interval.cf +++ b/tests/reactor/tests/00_basics/009_interval.cf @@ -14,6 +14,7 @@ body when deleted(file) { file_deleted => "$(file)"; interval => 10; + # The interval only spaces the checks of polling: a watch method wakes the # watcher up on every change watch_methods => { "poll" }; diff --git a/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf index 6a6431afc94..fe2924f004b 100644 --- a/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf +++ b/tests/reactor/tests/00_basics/unsafe/001_clock_step_back.cf @@ -14,6 +14,7 @@ body when deleted(file) { file_deleted => "$(file)"; interval => 1; + # A watch method wakes the watcher up whatever the clock says: only # polling shows whether the next check is postponed watch_methods => { "poll" }; diff --git a/tests/reactor/tests/03_watch_methods/006_repair_converges.cf b/tests/reactor/tests/03_watch_methods/006_repair_converges.cf index 6e0a663f1d8..f472a606fb9 100644 --- a/tests/reactor/tests/03_watch_methods/006_repair_converges.cf +++ b/tests/reactor/tests/03_watch_methods/006_repair_converges.cf @@ -26,12 +26,14 @@ bundle reactor test events: "$(promise_name)" when => content_modified("$(test)"), - then => { record_event("$(events_file)", "$(test) was modified"), repair("$(test)") }; + then => { + record_event("$(events_file)", "$(test) was modified"), + repair("$(test)"), + }; } bundle agent repair(file) { files: - "$(file)" - content => "good"; + "$(file)" content => "good"; } From 0304da533fa9327e532059c5ee70713663318566 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 29 Oct 2026 12:29:01 -0500 Subject: [PATCH 11/11] Fixed watcher_test after adding watch methods to cf-reactor It didn't link watch_backends.c, and its promises had no constraints or bundle, which WatcherPromiseHash() and the watchers report now use. A watcher whose first check fails is now kept. Changelog: None --- cf-reactor/watcher.c | 2 +- tests/unit/Makefile.am | 1 + tests/unit/watcher_test.c | 42 ++++++++++++++++++++++++++++----------- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/cf-reactor/watcher.c b/cf-reactor/watcher.c index 99487b14c34..470ac5dd76a 100644 --- a/cf-reactor/watcher.c +++ b/cf-reactor/watcher.c @@ -179,7 +179,7 @@ void WatcherPromiseHash(const Promise *pp, char *hash, size_t hash_size) assert(pp != NULL); assert(hash != NULL); - const size_t length = SeqLength(pp->conlist); + const size_t length = (pp->conlist == NULL) ? 0 : SeqLength(pp->conlist); Seq *conlist = SeqNew(length, NULL); for (size_t i = 0; i < length; i++) { diff --git a/tests/unit/Makefile.am b/tests/unit/Makefile.am index d5084bafb22..0d19e6d5320 100644 --- a/tests/unit/Makefile.am +++ b/tests/unit/Makefile.am @@ -235,6 +235,7 @@ regex_test_SOURCES = regex_test.c ../../libpromises/match_scope.c watcher_test_SOURCES = watcher_test.c \ ../../cf-reactor/watcher.c \ + ../../cf-reactor/watch_backends.c \ ../../cf-reactor/file_watcher.c \ ../../cf-reactor/stoppable_thread.c \ ../../cf-reactor/wakeup_channel.c diff --git a/tests/unit/watcher_test.c b/tests/unit/watcher_test.c index c297793698f..7732397414f 100644 --- a/tests/unit/watcher_test.c +++ b/tests/unit/watcher_test.c @@ -12,13 +12,17 @@ #include /* open() */ /* The registry only hashes the expanded promise it is given (its promiser - * here, as it has no constraints, comment or parent) and hands a copy of it - * back to the WatcherEventFn, so minimal promises are enough. Their org_pp - * tells apart the identical promises of different policies. */ + * and bundle here, as it has no constraints or comment) and hands a copy of + * it back to the WatcherEventFn, so minimal promises are enough. Their org_pp + * tells apart the identical promises of different policies. The bundle is + * there for the watchers report. */ +static Bundle test_bundle = { .name = "test" }; +static BundleSection test_section = { .parent_bundle = &test_bundle }; static const Promise unexpanded_promise; static const Promise expanded_promise = { .promiser = "test-event", .org_pp = &unexpanded_promise, + .parent_section = &test_section, }; /* Returns the options of a file_deleted watcher, valid until the next call @@ -161,8 +165,10 @@ static void test_watcher_register_iterations(void) /* The iterations of a promise (same promise, other promisers) are * different watchers */ - const Promise iteration_a = { .promiser = "/a", .org_pp = &unexpanded_promise }; - const Promise iteration_b = { .promiser = "/b", .org_pp = &unexpanded_promise }; + const Promise iteration_a = { .promiser = "/a", .org_pp = &unexpanded_promise, + .parent_section = &test_section }; + const Promise iteration_b = { .promiser = "/b", .org_pp = &unexpanded_promise, + .parent_section = &test_section }; WatcherRegister(&iteration_a, FileDeletedOptions("/nonexistent/a", 5)); WatcherRegister(&iteration_b, FileDeletedOptions("/nonexistent/b", 5)); @@ -185,6 +191,7 @@ static void test_watcher_register_identical_ignored(void) const Promise other_expanded_promise = { .promiser = "test-event", .org_pp = &other_unexpanded_promise, + .parent_section = &test_section, }; assert_true(WatcherRegister(&expanded_promise, FileDeletedOptions(path, 1))); assert_true(WatcherRegister(&other_expanded_promise, FileDeletedOptions(path, 1))); @@ -373,6 +380,7 @@ static void test_event_watcher_state_kept_across_pause(void) const Promise new_expanded_promise = { .promiser = "test-event", .org_pp = &new_unexpanded_promise, + .parent_section = &test_section, }; WatcherRegister(&new_expanded_promise, FileDeletedOptions(path, 1)); EventWatcherResume(); @@ -412,10 +420,12 @@ static void test_event_watcher_state_kept_identical_ignored(void) const Promise new_expanded_promise = { .promiser = "test-event", .org_pp = &new_unexpanded_promise, + .parent_section = &test_section, }; const Promise other_expanded_promise = { .promiser = "test-event", .org_pp = &other_unexpanded_promise, + .parent_section = &test_section, }; WatcherRegister(&new_expanded_promise, FileDeletedOptions(path, 1)); WatcherRegister(&other_expanded_promise, FileDeletedOptions(path, 1)); @@ -452,6 +462,7 @@ static void test_event_watcher_state_not_kept_for_other_promise(void) const Promise other_expanded_promise = { .promiser = "other-event", .org_pp = &unexpanded_promise, + .parent_section = &test_section, }; WatcherRegister(&other_expanded_promise, FileDeletedOptions(path, 1)); EventWatcherResume(); @@ -474,8 +485,10 @@ static void test_event_watcher_several_events(void) CreateTempFile(dir_b, path_b, sizeof(path_b)); WatcherRegistryInitialize(); - const Promise iteration_a = { .promiser = "a", .org_pp = &unexpanded_promise }; - const Promise iteration_b = { .promiser = "b", .org_pp = &unexpanded_promise }; + const Promise iteration_a = { .promiser = "a", .org_pp = &unexpanded_promise, + .parent_section = &test_section }; + const Promise iteration_b = { .promiser = "b", .org_pp = &unexpanded_promise, + .parent_section = &test_section }; WatcherRegister(&iteration_a, FileDeletedOptions(path_a, 1)); WatcherRegister(&iteration_b, FileDeletedOptions(path_b, 1)); @@ -575,15 +588,20 @@ static void test_watcher_register_check_failure(void) const LogLevel old_level = LogGetGlobalLevel(); LogSetGlobalLevel(LOG_LEVEL_CRIT); - /* Without a reference, the watcher is not registered (its state is - * destroyed by the registry, checked by valgrind/ASan) */ + /* Without a reference, the watcher is still registered: its first + * successful check becomes the reference */ WatcherRegistryInitialize(); - assert_false(WatcherRegister(&expanded_promise, FileDeletedOptions(path, 1))); + assert_true(WatcherRegister(&expanded_promise, FileDeletedOptions(path, 1))); - /* So the watcher thread doesn't check it, and doesn't report its failure */ + /* Its failing checks are reported once, as a failure, not as an event */ int fd = -1; assert_true(EventWatcherInitialize(&fd)); - assert_false(WaitForQueuedEvent(fd, 20)); + assert_true(WaitForQueuedEvent(fd, 50)); + ResetRecordedEvents(); + HandleEvents(fd); + assert_int_equal(handled_event_count, 1); + assert_int_equal(last_event_check, WATCHER_CHECK_ERROR); + assert_false(WaitForQueuedEvent(fd, 30)); EventWatcherFinalize();