From 9b5b00ad2bba01321f4a94231a0e93ea95982779 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 10 Oct 2026 19:17:03 -0700 Subject: [PATCH 01/10] test(ci): reject missing nightly suites before accepting results Signed-off-by: Thomas Vincent --- .github/workflows/nightly.yml | 2 ++ Makefile.am | 4 ++- Makefile.in | 55 ++++++++++++++++-------------- tests/tools/check_nightly_tests.sh | 30 ++++++++++++++++ tests/tools/test_nightly_tests.sh | 51 +++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 27 deletions(-) create mode 100755 tests/tools/check_nightly_tests.sh create mode 100755 tests/tools/test_nightly_tests.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 96f8356b..6822c485 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -115,6 +115,8 @@ jobs: else make -j"$(nproc)" check fi + make --no-print-directory coverage-unit-program-list > nightly-unit-programs.txt + tests/tools/check_nightly_tests.sh nightly-unit-programs.txt - name: Load the regression fixtures run: | diff --git a/Makefile.am b/Makefile.am index bbc5cee2..079e68aa 100644 --- a/Makefile.am +++ b/Makefile.am @@ -68,6 +68,8 @@ man_MANS = docs/man/spine.1 check_PROGRAMS = test_spine_regressions dist_noinst_DATA = tests/fixtures/configuration.sql tests/fixtures/poller.sql tests/fixtures/snmpd.conf tests/fixtures/snmpv3-agent.conf tests/fixtures/snmpv3-users.conf TESTS = $(check_PROGRAMS) +TESTS += tests/tools/test_nightly_tests.sh +EXTRA_DIST = tests/tools/check_nightly_tests.sh tests/tools/test_nightly_tests.sh test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_regressions.c tests/contracts/test_spine_contracts.c tests/contracts/test_spine_reindex_contracts.c tests/contracts/test_spine_snmpv3_contracts.c tests/contracts/test_spine_settings_contracts.c tests/contracts/test_spine_cli_contracts.c tests/contracts/test_spine_stream_contracts.c tests/contracts/test_spine_output_contracts.c tests/contracts/test_spine_privilege_contracts.c # Link the real production translation units and globals, with a separate test # entry point. Production and regression binaries share the runtime objects. @@ -94,7 +96,7 @@ noinst_HEADERS = src/internal/common.h src/app/signals.h src/config/keywords.h s src/snmp/snmp.h src/app/spine.h src/platform/spine_sem.h src/internal/spine_types.h \ src/database/sql.h vendor/uthash/uthash.h src/internal/util.h -EXTRA_DIST = docs/man/spine.1 etc/spine.conf.dist \ +EXTRA_DIST += docs/man/spine.1 etc/spine.conf.dist \ CONTRIBUTORS.md docs/licensing.md LICENSES/LicenseRef-Xenadyne.txt LICENSES/LicenseRef-Xenadyne-Header.txt \ LICENSES/GPL-2.0-or-later.txt LICENSES/LGPL-2.1-or-later.txt LICENSES/LGPL-2.1-only.txt \ tests/README.md \ diff --git a/Makefile.in b/Makefile.in index 1729bc9f..5bab3ac5 100644 --- a/Makefile.in +++ b/Makefile.in @@ -1108,11 +1108,39 @@ configdir = $(sysconfdir) dist_config_DATA = etc/spine.conf.dist man_MANS = docs/man/spine.1 dist_noinst_DATA = tests/fixtures/configuration.sql tests/fixtures/poller.sql tests/fixtures/snmpd.conf tests/fixtures/snmpv3-agent.conf tests/fixtures/snmpv3-users.conf -TESTS = $(check_PROGRAMS) tests/regression/test_remote_push.sh \ +TESTS = $(check_PROGRAMS) tests/tools/test_nightly_tests.sh \ + tests/regression/test_remote_push.sh \ tests/integration/test_alloc_failure.sh \ tests/regression/test_child_process_safety.sh \ tests/regression/test_setuid_privilege_drop.sh \ tests/regression/test_helper_scripts.sh +EXTRA_DIST = tests/tools/check_nightly_tests.sh \ + tests/tools/test_nightly_tests.sh docs/man/spine.1 \ + etc/spine.conf.dist CONTRIBUTORS.md docs/licensing.md \ + LICENSES/LicenseRef-Xenadyne.txt \ + LICENSES/LicenseRef-Xenadyne-Header.txt \ + LICENSES/GPL-2.0-or-later.txt LICENSES/LGPL-2.1-or-later.txt \ + LICENSES/LGPL-2.1-only.txt tests/README.md \ + tests/support/test-harness.sh \ + tests/integration/test_concurrency.sh \ + tests/integration/test_network_faults.sh \ + tests/integration/test_memory_soak.sh \ + tests/tools/run_snmpv3.sh tests/tools/run_coverage.sh \ + tests/tools/coverage_process_exit.h \ + tests/tools/verify_coverage.sh \ + tests/tools/test_coverage_verifier.sh \ + tests/tools/normalize_entry_coverage.py \ + tests/tools/test_entry_coverage.py \ + tests/regression/test_remote_push.sh \ + tests/integration/test_alloc_failure.sh \ + tests/regression/test_child_process_safety.sh \ + tests/regression/test_setuid_privilege_drop.sh \ + tests/fixtures/c17_develop_behavior_baseline.json \ + scripts/debug.sh scripts/package.sh \ + tests/regression/test_helper_scripts.sh \ + docs/architecture/source-modules.md \ + tests/tools/test_configure_contracts.sh \ + tests/tools/test_clang_tidy_gate.py scripts/cppcheck/spine.cfg test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_regressions.c tests/contracts/test_spine_contracts.c tests/contracts/test_spine_reindex_contracts.c tests/contracts/test_spine_snmpv3_contracts.c tests/contracts/test_spine_settings_contracts.c tests/contracts/test_spine_cli_contracts.c tests/contracts/test_spine_stream_contracts.c tests/contracts/test_spine_output_contracts.c tests/contracts/test_spine_privilege_contracts.c # Link the same runtime implementation with a separate fault-test entry point. @HAVE_LINKER_WRAP_TRUE@test_spine_faults_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_faults.c @@ -1146,31 +1174,6 @@ noinst_HEADERS = src/internal/common.h src/app/signals.h \ src/platform/thread.h src/poller/result.h src/script/escape.h \ src/snmp/response.h src/database/cacti_query.h \ src/database/persistence.h -EXTRA_DIST = docs/man/spine.1 etc/spine.conf.dist CONTRIBUTORS.md \ - docs/licensing.md LICENSES/LicenseRef-Xenadyne.txt \ - LICENSES/LicenseRef-Xenadyne-Header.txt \ - LICENSES/GPL-2.0-or-later.txt LICENSES/LGPL-2.1-or-later.txt \ - LICENSES/LGPL-2.1-only.txt tests/README.md \ - tests/support/test-harness.sh \ - tests/integration/test_concurrency.sh \ - tests/integration/test_network_faults.sh \ - tests/integration/test_memory_soak.sh \ - tests/tools/run_snmpv3.sh tests/tools/run_coverage.sh \ - tests/tools/coverage_process_exit.h \ - tests/tools/verify_coverage.sh \ - tests/tools/test_coverage_verifier.sh \ - tests/tools/normalize_entry_coverage.py \ - tests/tools/test_entry_coverage.py \ - tests/regression/test_remote_push.sh \ - tests/integration/test_alloc_failure.sh \ - tests/regression/test_child_process_safety.sh \ - tests/regression/test_setuid_privilege_drop.sh \ - tests/fixtures/c17_develop_behavior_baseline.json \ - scripts/debug.sh scripts/package.sh \ - tests/regression/test_helper_scripts.sh \ - docs/architecture/source-modules.md \ - tests/tools/test_configure_contracts.sh \ - scripts/cppcheck/spine.cfg AM_CPPFLAGS = -I$(top_srcdir) -I$(top_srcdir)/src \ -I$(top_srcdir)/vendor/uthash -I$(top_builddir) diff --git a/tests/tools/check_nightly_tests.sh b/tests/tools/check_nightly_tests.sh new file mode 100755 index 00000000..270f1ebc --- /dev/null +++ b/tests/tools/check_nightly_tests.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: LGPL-2.1-or-later +set -eu + +if [ "$#" -ne 1 ] || [ ! -s "$1" ]; then + echo 'Nightly requires a nonempty unit-test manifest.' >&2 + exit 1 +fi +if [ -n "$(sort "$1" | uniq -d)" ]; then + echo 'Nightly unit-test manifest contains duplicate suites.' >&2 + exit 1 +fi +while IFS= read -r program || [ -n "$program" ]; do + case "$program" in + ''|*[!a-zA-Z0-9_/]*|*..*) echo 'Invalid nightly test identity.' >&2; exit 1 ;; + tests/unit/test_*) ;; + *) echo 'Unexpected nightly test identity.' >&2; exit 1 ;; + esac + if [ ! -x "$program" ] || [ ! -f "$program" ]; then + echo "Missing nightly test executable: $program" >&2 + exit 1 + fi +done < "$1" +for program in test_spine_regressions test_spine_faults; do + if [ ! -x "$program" ] || [ ! -f "$program" ]; then + echo "Missing nightly regression executable: $program" >&2 + exit 1 + fi +done diff --git a/tests/tools/test_nightly_tests.sh b/tests/tools/test_nightly_tests.sh new file mode 100755 index 00000000..374bce50 --- /dev/null +++ b/tests/tools/test_nightly_tests.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: LGPL-2.1-or-later +set -eu +gate=$(cd "$(dirname "$0")" && pwd)/check_nightly_tests.sh +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +cd "$fixture" +mkdir -p tests/unit +for program in tests/unit/test_example test_spine_regressions test_spine_faults; do + printf '#!/bin/sh\nexit 0\n' > "$program" + chmod +x "$program" +done +printf 'tests/unit/test_example\n' > manifest +"$gate" manifest +must_reject() { + if "$gate" "$@" >diagnostic 2>&1; then + echo 'Nightly accepted a missing or invalid suite.' >&2 + exit 1 + fi + test -s diagnostic +} +must_reject +must_reject nonexistent +: > manifest +must_reject manifest +printf '\n' > manifest +must_reject manifest +printf 'tests/unit/test_missing\n' > manifest +must_reject manifest +printf 'tests/unit/test_example\ntests/unit/test_example\n' > manifest +must_reject manifest +printf '../test_example\n' > manifest +must_reject manifest +printf 'tests/unit/test_example;echo\n' > manifest +must_reject manifest +printf 'other/test_example\n' > manifest +must_reject manifest +printf 'tests/unit/test_example\n' > manifest +chmod -x tests/unit/test_example +must_reject manifest +chmod +x tests/unit/test_example +chmod -x test_spine_regressions +must_reject manifest +chmod +x test_spine_regressions +chmod -x test_spine_faults +must_reject manifest +chmod +x test_spine_faults +printf 'tests/unit/test_example' > manifest +"$gate" manifest +echo 'Nightly test-presence contracts passed.' From a223c27a3323fd29fea72d05e29398cff8d5934d Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 10 Oct 2026 21:42:11 -0700 Subject: [PATCH 02/10] feat(logging): add checked opt-in JSON records and cancellation-safe writes Signed-off-by: Thomas Vincent --- .github/workflows/ci.yml | 1 + Makefile.am | 9 +- Makefile.in | 71 +++++++++++--- README.md | 15 +++ etc/spine.conf.dist | 5 + src/config/file.c | 7 +- src/config/model.h | 3 + src/log/json.c | 86 +++++++++++++++++ src/log/log.c | 55 +++++++++-- src/log/log.h | 4 + tests/regression/test_spine_faults.c | 68 ++++++++++++++ tests/tools/test_json_encoder.py | 103 ++++++++++++++++++++ tests/unit/test_log_config.c | 136 +++++++++++++++++++++++++++ 13 files changed, 541 insertions(+), 22 deletions(-) create mode 100644 src/log/json.c create mode 100644 tests/tools/test_json_encoder.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe13bf12..e6b6c13a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -358,6 +358,7 @@ jobs: - name: Report coverage run: | set -euo pipefail + python3 tests/tools/test_json_encoder.py # configure leaves instrumented conftest objects behind and gcovr # cannot resolve their sources rm -f ./*conftest*.gcno ./*conftest*.gcda diff --git a/Makefile.am b/Makefile.am index 079e68aa..704ecbf5 100644 --- a/Makefile.am +++ b/Makefile.am @@ -18,7 +18,7 @@ spine_support_sources = src/app/buffer.c \ src/log/sanitize.c \ src/script/escape.c \ src/config/settings.c src/config/options.c src/config/file.c \ - src/log/log.c src/poller/result.c \ + src/log/log.c src/log/json.c src/poller/result.c \ src/platform/privileges.c src/poller/regex.c spine_database_sources = src/database/sql.c \ src/database/connection.c \ @@ -70,6 +70,7 @@ dist_noinst_DATA = tests/fixtures/configuration.sql tests/fixtures/poller.sql te TESTS = $(check_PROGRAMS) TESTS += tests/tools/test_nightly_tests.sh EXTRA_DIST = tests/tools/check_nightly_tests.sh tests/tools/test_nightly_tests.sh +EXTRA_DIST += tests/tools/test_json_encoder.py test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_regressions.c tests/contracts/test_spine_contracts.c tests/contracts/test_spine_reindex_contracts.c tests/contracts/test_spine_snmpv3_contracts.c tests/contracts/test_spine_settings_contracts.c tests/contracts/test_spine_cli_contracts.c tests/contracts/test_spine_stream_contracts.c tests/contracts/test_spine_output_contracts.c tests/contracts/test_spine_privilege_contracts.c # Link the real production translation units and globals, with a separate test # entry point. Production and regression binaries share the runtime objects. @@ -78,7 +79,7 @@ if HAVE_LINKER_WRAP check_PROGRAMS += test_spine_faults # Link the same runtime implementation with a separate fault-test entry point. test_spine_faults_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_faults.c -test_spine_faults_LDFLAGS = $(AM_LDFLAGS) -Wl,--wrap=strftime -Wl,--wrap=mysql_query -Wl,--wrap=mysql_ping -Wl,--wrap=mysql_errno \ +test_spine_faults_LDFLAGS = $(AM_LDFLAGS) -Wl,--wrap=write -Wl,--wrap=strftime -Wl,--wrap=mysql_query -Wl,--wrap=mysql_ping -Wl,--wrap=mysql_errno \ -Wl,--wrap=mysql_real_connect -Wl,--wrap=mysql_thread_id -Wl,--wrap=mysql_store_result \ -Wl,--wrap=mysql_num_rows -Wl,--wrap=mysql_fetch_row -Wl,--wrap=mysql_free_result -Wl,--wrap=mysql_num_fields \ -Wl,--wrap=pipe -Wl,--wrap=socketpair -Wl,--wrap=posix_spawn -Wl,--wrap=posix_spawnattr_setpgroup \ @@ -180,7 +181,7 @@ endif # test_util_strings pulls in common.h and the sources under test, so it needs # the same libraries spine links against. The others are self-contained. -tests_unit_test_util_strings_SOURCES = tests/unit/test_util_strings.c +tests_unit_test_util_strings_SOURCES = tests/unit/test_util_strings.c src/log/json.c tests_unit_test_util_strings_LDADD = $(CMOCKA_LIBS) $(LIBS) tests_unit_test_build_fixes_SOURCES = tests/unit/test_build_fixes.c @@ -218,7 +219,7 @@ tests_unit_test_classify_result_SOURCES = tests/unit/test_classify_result.c test tests_unit_test_classify_result_LDADD = $(CMOCKA_LIBS) $(LIBS) # Includes production helpers, so it needs the same libraries as Spine -tests_unit_test_util_paths_SOURCES = tests/unit/test_util_paths.c +tests_unit_test_util_paths_SOURCES = tests/unit/test_util_paths.c src/log/json.c tests_unit_test_util_paths_LDADD = $(CMOCKA_LIBS) $(LIBS) tests_unit_test_snmpv3_session_SOURCES = tests/unit/test_snmpv3_session.c tests/support/spine_runtime.c \ diff --git a/Makefile.in b/Makefile.in index 5bab3ac5..f8fbff5b 100644 --- a/Makefile.in +++ b/Makefile.in @@ -165,8 +165,8 @@ am__objects_2 = src/app/buffer.$(OBJEXT) src/platform/clock.$(OBJEXT) \ src/log/sanitize.$(OBJEXT) src/script/escape.$(OBJEXT) \ src/config/settings.$(OBJEXT) src/config/options.$(OBJEXT) \ src/config/file.$(OBJEXT) src/log/log.$(OBJEXT) \ - src/poller/result.$(OBJEXT) src/platform/privileges.$(OBJEXT) \ - src/poller/regex.$(OBJEXT) + src/log/json.$(OBJEXT) src/poller/result.$(OBJEXT) \ + src/platform/privileges.$(OBJEXT) src/poller/regex.$(OBJEXT) am__objects_3 = src/snmp/requests.$(OBJEXT) \ src/snmp/response.$(OBJEXT) src/snmp/session.$(OBJEXT) am__objects_4 = src/poller/poller.$(OBJEXT) \ @@ -204,7 +204,7 @@ am__test_spine_faults_SOURCES_DIST = src/database/sql.c \ src/config/text.c src/config/validate.c src/log/debug.c \ src/log/sanitize.c src/script/escape.c src/config/settings.c \ src/config/options.c src/config/file.c src/log/log.c \ - src/poller/result.c src/platform/privileges.c \ + src/log/json.c src/poller/result.c src/platform/privileges.c \ src/poller/regex.c src/snmp/requests.c src/snmp/response.c \ src/snmp/session.c src/platform/locks.c src/poller/poller.c \ src/script/external.c src/poller/host.c src/poller/reindex.c \ @@ -281,6 +281,7 @@ am__objects_11 = src/app/tests_unit_test_linked-buffer.$(OBJEXT) \ src/config/tests_unit_test_linked-options.$(OBJEXT) \ src/config/tests_unit_test_linked-file.$(OBJEXT) \ src/log/tests_unit_test_linked-log.$(OBJEXT) \ + src/log/tests_unit_test_linked-json.$(OBJEXT) \ src/poller/tests_unit_test_linked-result.$(OBJEXT) \ src/platform/tests_unit_test_linked-privileges.$(OBJEXT) \ src/poller/tests_unit_test_linked-regex.$(OBJEXT) @@ -355,6 +356,7 @@ am__objects_18 = src/app/tests_unit_test_php_runtime-buffer.$(OBJEXT) \ src/config/tests_unit_test_php_runtime-options.$(OBJEXT) \ src/config/tests_unit_test_php_runtime-file.$(OBJEXT) \ src/log/tests_unit_test_php_runtime-log.$(OBJEXT) \ + src/log/tests_unit_test_php_runtime-json.$(OBJEXT) \ src/poller/tests_unit_test_php_runtime-result.$(OBJEXT) \ src/platform/tests_unit_test_php_runtime-privileges.$(OBJEXT) \ src/poller/tests_unit_test_php_runtime-regex.$(OBJEXT) @@ -450,13 +452,13 @@ tests_unit_test_snmpv3_session_OBJECTS = \ tests_unit_test_snmpv3_session_DEPENDENCIES = $(am__DEPENDENCIES_1) \ $(am__DEPENDENCIES_1) am_tests_unit_test_util_paths_OBJECTS = \ - tests/unit/test_util_paths.$(OBJEXT) + tests/unit/test_util_paths.$(OBJEXT) src/log/json.$(OBJEXT) tests_unit_test_util_paths_OBJECTS = \ $(am_tests_unit_test_util_paths_OBJECTS) tests_unit_test_util_paths_DEPENDENCIES = $(am__DEPENDENCIES_1) \ $(am__DEPENDENCIES_1) am_tests_unit_test_util_strings_OBJECTS = \ - tests/unit/test_util_strings.$(OBJEXT) + tests/unit/test_util_strings.$(OBJEXT) src/log/json.$(OBJEXT) tests_unit_test_util_strings_OBJECTS = \ $(am_tests_unit_test_util_strings_OBJECTS) tests_unit_test_util_strings_DEPENDENCIES = $(am__DEPENDENCIES_1) \ @@ -517,11 +519,14 @@ am__depfiles_remade = src/app/$(DEPDIR)/buffer.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-sql.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po \ src/database/$(DEPDIR)/transfer.Po src/log/$(DEPDIR)/debug.Po \ - src/log/$(DEPDIR)/log.Po src/log/$(DEPDIR)/sanitize.Po \ + src/log/$(DEPDIR)/json.Po src/log/$(DEPDIR)/log.Po \ + src/log/$(DEPDIR)/sanitize.Po \ src/log/$(DEPDIR)/tests_unit_test_linked-debug.Po \ + src/log/$(DEPDIR)/tests_unit_test_linked-json.Po \ src/log/$(DEPDIR)/tests_unit_test_linked-log.Po \ src/log/$(DEPDIR)/tests_unit_test_linked-sanitize.Po \ src/log/$(DEPDIR)/tests_unit_test_php_runtime-debug.Po \ + src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Po \ src/log/$(DEPDIR)/tests_unit_test_php_runtime-log.Po \ src/log/$(DEPDIR)/tests_unit_test_php_runtime-sanitize.Po \ src/ping/$(DEPDIR)/address.Po src/ping/$(DEPDIR)/icmp4.Po \ @@ -1076,7 +1081,7 @@ spine_support_sources = src/app/buffer.c \ src/log/sanitize.c \ src/script/escape.c \ src/config/settings.c src/config/options.c src/config/file.c \ - src/log/log.c src/poller/result.c \ + src/log/log.c src/log/json.c src/poller/result.c \ src/platform/privileges.c src/poller/regex.c spine_database_sources = src/database/sql.c \ @@ -1115,7 +1120,8 @@ TESTS = $(check_PROGRAMS) tests/tools/test_nightly_tests.sh \ tests/regression/test_setuid_privilege_drop.sh \ tests/regression/test_helper_scripts.sh EXTRA_DIST = tests/tools/check_nightly_tests.sh \ - tests/tools/test_nightly_tests.sh docs/man/spine.1 \ + tests/tools/test_nightly_tests.sh \ + tests/tools/test_json_encoder.py docs/man/spine.1 \ etc/spine.conf.dist CONTRIBUTORS.md docs/licensing.md \ LICENSES/LicenseRef-Xenadyne.txt \ LICENSES/LicenseRef-Xenadyne-Header.txt \ @@ -1144,7 +1150,7 @@ EXTRA_DIST = tests/tools/check_nightly_tests.sh \ test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_regressions.c tests/contracts/test_spine_contracts.c tests/contracts/test_spine_reindex_contracts.c tests/contracts/test_spine_snmpv3_contracts.c tests/contracts/test_spine_settings_contracts.c tests/contracts/test_spine_cli_contracts.c tests/contracts/test_spine_stream_contracts.c tests/contracts/test_spine_output_contracts.c tests/contracts/test_spine_privilege_contracts.c # Link the same runtime implementation with a separate fault-test entry point. @HAVE_LINKER_WRAP_TRUE@test_spine_faults_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_faults.c -@HAVE_LINKER_WRAP_TRUE@test_spine_faults_LDFLAGS = $(AM_LDFLAGS) -Wl,--wrap=strftime -Wl,--wrap=mysql_query -Wl,--wrap=mysql_ping -Wl,--wrap=mysql_errno \ +@HAVE_LINKER_WRAP_TRUE@test_spine_faults_LDFLAGS = $(AM_LDFLAGS) -Wl,--wrap=write -Wl,--wrap=strftime -Wl,--wrap=mysql_query -Wl,--wrap=mysql_ping -Wl,--wrap=mysql_errno \ @HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=mysql_real_connect -Wl,--wrap=mysql_thread_id -Wl,--wrap=mysql_store_result \ @HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=mysql_num_rows -Wl,--wrap=mysql_fetch_row -Wl,--wrap=mysql_free_result -Wl,--wrap=mysql_num_fields \ @HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=pipe -Wl,--wrap=socketpair -Wl,--wrap=posix_spawn -Wl,--wrap=posix_spawnattr_setpgroup \ @@ -1181,7 +1187,7 @@ AM_CPPFLAGS = -I$(top_srcdir) -I$(top_srcdir)/src \ # test_util_strings pulls in common.h and the sources under test, so it needs # the same libraries spine links against. The others are self-contained. -tests_unit_test_util_strings_SOURCES = tests/unit/test_util_strings.c +tests_unit_test_util_strings_SOURCES = tests/unit/test_util_strings.c src/log/json.c tests_unit_test_util_strings_LDADD = $(CMOCKA_LIBS) $(LIBS) tests_unit_test_build_fixes_SOURCES = tests/unit/test_build_fixes.c tests_unit_test_build_fixes_LDADD = $(CMOCKA_LIBS) @@ -1215,7 +1221,7 @@ tests_unit_test_classify_result_SOURCES = tests/unit/test_classify_result.c test tests_unit_test_classify_result_LDADD = $(CMOCKA_LIBS) $(LIBS) # Includes production helpers, so it needs the same libraries as Spine -tests_unit_test_util_paths_SOURCES = tests/unit/test_util_paths.c +tests_unit_test_util_paths_SOURCES = tests/unit/test_util_paths.c src/log/json.c tests_unit_test_util_paths_LDADD = $(CMOCKA_LIBS) $(LIBS) tests_unit_test_snmpv3_session_SOURCES = tests/unit/test_snmpv3_session.c tests/support/spine_runtime.c \ $(spine_database_sources) $(spine_support_sources) src/platform/locks.c $(spine_poller_sources) $(spine_process_sources) $(spine_php_sources) $(spine_ping_sources) \ @@ -1468,6 +1474,8 @@ src/config/file.$(OBJEXT): src/config/$(am__dirstamp) \ src/config/$(DEPDIR)/$(am__dirstamp) src/log/log.$(OBJEXT): src/log/$(am__dirstamp) \ src/log/$(DEPDIR)/$(am__dirstamp) +src/log/json.$(OBJEXT): src/log/$(am__dirstamp) \ + src/log/$(DEPDIR)/$(am__dirstamp) src/poller/$(am__dirstamp): @$(MKDIR_P) src/poller @: > src/poller/$(am__dirstamp) @@ -1705,6 +1713,8 @@ src/config/tests_unit_test_linked-file.$(OBJEXT): \ src/config/$(DEPDIR)/$(am__dirstamp) src/log/tests_unit_test_linked-log.$(OBJEXT): src/log/$(am__dirstamp) \ src/log/$(DEPDIR)/$(am__dirstamp) +src/log/tests_unit_test_linked-json.$(OBJEXT): \ + src/log/$(am__dirstamp) src/log/$(DEPDIR)/$(am__dirstamp) src/poller/tests_unit_test_linked-result.$(OBJEXT): \ src/poller/$(am__dirstamp) \ src/poller/$(DEPDIR)/$(am__dirstamp) @@ -1848,6 +1858,8 @@ src/config/tests_unit_test_php_runtime-file.$(OBJEXT): \ src/config/$(DEPDIR)/$(am__dirstamp) src/log/tests_unit_test_php_runtime-log.$(OBJEXT): \ src/log/$(am__dirstamp) src/log/$(DEPDIR)/$(am__dirstamp) +src/log/tests_unit_test_php_runtime-json.$(OBJEXT): \ + src/log/$(am__dirstamp) src/log/$(DEPDIR)/$(am__dirstamp) src/poller/tests_unit_test_php_runtime-result.$(OBJEXT): \ src/poller/$(am__dirstamp) \ src/poller/$(DEPDIR)/$(am__dirstamp) @@ -2033,12 +2045,15 @@ distclean-compile: @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/transfer.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/debug.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/json.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/log.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/sanitize.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_linked-debug.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_linked-json.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_linked-log.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_linked-sanitize.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_php_runtime-debug.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_php_runtime-log.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/tests_unit_test_php_runtime-sanitize.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/ping/$(DEPDIR)/address.Po@am__quote@ # am--include-marker @@ -2497,6 +2512,20 @@ src/log/tests_unit_test_linked-log.obj: src/log/log.c @AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ @am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/log/tests_unit_test_linked-log.obj `if test -f 'src/log/log.c'; then $(CYGPATH_W) 'src/log/log.c'; else $(CYGPATH_W) '$(srcdir)/src/log/log.c'; fi` +src/log/tests_unit_test_linked-json.o: src/log/json.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/log/tests_unit_test_linked-json.o -MD -MP -MF src/log/$(DEPDIR)/tests_unit_test_linked-json.Tpo -c -o src/log/tests_unit_test_linked-json.o `test -f 'src/log/json.c' || echo '$(srcdir)/'`src/log/json.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/log/$(DEPDIR)/tests_unit_test_linked-json.Tpo src/log/$(DEPDIR)/tests_unit_test_linked-json.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/log/json.c' object='src/log/tests_unit_test_linked-json.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/log/tests_unit_test_linked-json.o `test -f 'src/log/json.c' || echo '$(srcdir)/'`src/log/json.c + +src/log/tests_unit_test_linked-json.obj: src/log/json.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/log/tests_unit_test_linked-json.obj -MD -MP -MF src/log/$(DEPDIR)/tests_unit_test_linked-json.Tpo -c -o src/log/tests_unit_test_linked-json.obj `if test -f 'src/log/json.c'; then $(CYGPATH_W) 'src/log/json.c'; else $(CYGPATH_W) '$(srcdir)/src/log/json.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/log/$(DEPDIR)/tests_unit_test_linked-json.Tpo src/log/$(DEPDIR)/tests_unit_test_linked-json.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/log/json.c' object='src/log/tests_unit_test_linked-json.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/log/tests_unit_test_linked-json.obj `if test -f 'src/log/json.c'; then $(CYGPATH_W) 'src/log/json.c'; else $(CYGPATH_W) '$(srcdir)/src/log/json.c'; fi` + src/poller/tests_unit_test_linked-result.o: src/poller/result.c @am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/poller/tests_unit_test_linked-result.o -MD -MP -MF src/poller/$(DEPDIR)/tests_unit_test_linked-result.Tpo -c -o src/poller/tests_unit_test_linked-result.o `test -f 'src/poller/result.c' || echo '$(srcdir)/'`src/poller/result.c @am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/poller/$(DEPDIR)/tests_unit_test_linked-result.Tpo src/poller/$(DEPDIR)/tests_unit_test_linked-result.Po @@ -3183,6 +3212,20 @@ src/log/tests_unit_test_php_runtime-log.obj: src/log/log.c @AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ @am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/log/tests_unit_test_php_runtime-log.obj `if test -f 'src/log/log.c'; then $(CYGPATH_W) 'src/log/log.c'; else $(CYGPATH_W) '$(srcdir)/src/log/log.c'; fi` +src/log/tests_unit_test_php_runtime-json.o: src/log/json.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/log/tests_unit_test_php_runtime-json.o -MD -MP -MF src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Tpo -c -o src/log/tests_unit_test_php_runtime-json.o `test -f 'src/log/json.c' || echo '$(srcdir)/'`src/log/json.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Tpo src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/log/json.c' object='src/log/tests_unit_test_php_runtime-json.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/log/tests_unit_test_php_runtime-json.o `test -f 'src/log/json.c' || echo '$(srcdir)/'`src/log/json.c + +src/log/tests_unit_test_php_runtime-json.obj: src/log/json.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/log/tests_unit_test_php_runtime-json.obj -MD -MP -MF src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Tpo -c -o src/log/tests_unit_test_php_runtime-json.obj `if test -f 'src/log/json.c'; then $(CYGPATH_W) 'src/log/json.c'; else $(CYGPATH_W) '$(srcdir)/src/log/json.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Tpo src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/log/json.c' object='src/log/tests_unit_test_php_runtime-json.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/log/tests_unit_test_php_runtime-json.obj `if test -f 'src/log/json.c'; then $(CYGPATH_W) 'src/log/json.c'; else $(CYGPATH_W) '$(srcdir)/src/log/json.c'; fi` + src/poller/tests_unit_test_php_runtime-result.o: src/poller/result.c @am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/poller/tests_unit_test_php_runtime-result.o -MD -MP -MF src/poller/$(DEPDIR)/tests_unit_test_php_runtime-result.Tpo -c -o src/poller/tests_unit_test_php_runtime-result.o `test -f 'src/poller/result.c' || echo '$(srcdir)/'`src/poller/result.c @am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/poller/$(DEPDIR)/tests_unit_test_php_runtime-result.Tpo src/poller/$(DEPDIR)/tests_unit_test_php_runtime-result.Po @@ -4244,12 +4287,15 @@ distclean: distclean-am -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po -rm -f src/database/$(DEPDIR)/transfer.Po -rm -f src/log/$(DEPDIR)/debug.Po + -rm -f src/log/$(DEPDIR)/json.Po -rm -f src/log/$(DEPDIR)/log.Po -rm -f src/log/$(DEPDIR)/sanitize.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-debug.Po + -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-json.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-log.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-sanitize.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-debug.Po + -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-log.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-sanitize.Po -rm -f src/ping/$(DEPDIR)/address.Po @@ -4464,12 +4510,15 @@ maintainer-clean: maintainer-clean-am -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po -rm -f src/database/$(DEPDIR)/transfer.Po -rm -f src/log/$(DEPDIR)/debug.Po + -rm -f src/log/$(DEPDIR)/json.Po -rm -f src/log/$(DEPDIR)/log.Po -rm -f src/log/$(DEPDIR)/sanitize.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-debug.Po + -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-json.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-log.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_linked-sanitize.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-debug.Po + -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-json.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-log.Po -rm -f src/log/$(DEPDIR)/tests_unit_test_php_runtime-sanitize.Po -rm -f src/ping/$(DEPDIR)/address.Po diff --git a/README.md b/README.md index a1a1902c..188e0feb 100644 --- a/README.md +++ b/README.md @@ -176,3 +176,18 @@ fixtures. [Platform requirements](docs/platforms.md) list the compiler and library dependencies. Runtime contracts exercise availability, retries, script framing, result persistence, worker failures and shutdown. Portable checked buffer helpers use the existing C/POSIX interfaces without Annex K. + +### Structured logging + +Set `Log_Format json` in `spine.conf` to emit one JSON record per log message. +Text remains the default. Records contain a UTC timestamp, severity, and the +message, retaining device identifiers in the message without guessing structured +fields from ambiguous text. File, console, and syslog destinations retain their +existing routing. Startup banners and fatal diagnostics outside the logging +API remain operational diagnostics. + +The fixed record encoder escapes quotes, backslashes, and controls, preserves +valid UTF-8, and rejects malformed UTF-8 with a structured error record. An +allocation or formatting failure also emits a structured error and returns +failure to the caller. It does not add a JSON parser or a build dependency; the +legacy capabilities-string contract remains separate from log formatting. diff --git a/etc/spine.conf.dist b/etc/spine.conf.dist index 1c90b68d..832c89de 100644 --- a/etc/spine.conf.dist +++ b/etc/spine.conf.dist @@ -49,6 +49,11 @@ DB_Port 3306 #DB_SSL_Key #DB_SSL_Cert #DB_SSL_CA + +# Optional structured logging. Default text preserves existing log consumers. +# JSON records use UTC timestamps and retain valid UTF-8. Invalid UTF-8 emits +# a structured error record instead of the original message. +#Log_Format text #SNMP_Clientaddr #Cacti_Log /var/www/html/cacti/log/cacti.log diff --git a/src/config/file.c b/src/config/file.c index cb20ac12..7ff010e1 100644 --- a/src/config/file.c +++ b/src/config/file.c @@ -59,7 +59,11 @@ static int apply_config_directive(const char *name, const char *value) { else if (STRIMATCH(name, "DB_Port")) set.database.port = atoi(value); else if (STRIMATCH(name, "DB_UseSSL")) set.database.ssl = atoi(value); else if (STRIMATCH(name, "Poller")) set.poller.poller_id = atoi(value); - else if (STRIMATCH(name, "DB_PreG")) { + else if (STRIMATCH(name, "Log_Format")) { + if (STRIMATCH(value, "json")) set.logging.json_logs = TRUE; + else if (STRIMATCH(value, "text")) set.logging.json_logs = FALSE; + else die("ERROR: Log_Format must be text or json"); + } else if (STRIMATCH(name, "DB_PreG")) { if (!set.console.stderr_notty) fprintf(stderr, "WARNING: DB_PreG is no longer supported\n"); } else if (STRIMATCH(name, "Cacti_Log")) { STRNCOPY(set.logging.path_logfile, value); @@ -166,4 +170,5 @@ void config_defaults() { STRNCOPY(config_paths[3], CONFIG_PATH_4); set.logging.log_destination = LOGDEST_FILE; + set.logging.json_logs = FALSE; } diff --git a/src/config/model.h b/src/config/model.h index f447a05c..a6ea5d99 100644 --- a/src/config/model.h +++ b/src/config/model.h @@ -16,6 +16,8 @@ #ifndef SPINE_CONFIG_MODEL_H #define SPINE_CONFIG_MODEL_H +#include + #include #include "internal/constants.h" @@ -94,6 +96,7 @@ typedef struct { int spine_log_level; int log_datetime_separator; int log_datetime_format; + bool json_logs; } spine_logging_config_t; /* Availability configuration and runtime state. */ diff --git a/src/log/json.c b/src/log/json.c new file mode 100644 index 00000000..54668be2 --- /dev/null +++ b/src/log/json.c @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include +#include +#include "log/log.h" + +/* A fixed log record needs only JSON string encoding, not a general parser. + * Preserve valid UTF-8 and reject invalid sequences before emitting a record. */ +typedef struct { + char *cursor; + size_t remaining; +} json_writer_t; + +static bool json_bytes(json_writer_t *writer, const char *bytes, size_t length) { + if (length >= writer->remaining) return false; + memcpy(writer->cursor, bytes, length); + writer->cursor += length; + writer->remaining -= length; + *writer->cursor = '\0'; + return true; +} + +static size_t utf8_length(const unsigned char *bytes, size_t remaining) { + size_t count; + size_t index; + unsigned char first = bytes[0]; + + if (first < 0x80) return 1; + if (first >= 0xc2 && first <= 0xdf) count = 2; + else if (first >= 0xe0 && first <= 0xef) count = 3; + else if (first >= 0xf0 && first <= 0xf4) count = 4; + else return 0; + if (count > remaining) return 0; + for (index = 1; index < count; index++) { + if (bytes[index] < 0x80 || bytes[index] > 0xbf) return 0; + } + if ((first == 0xe0 && bytes[1] < 0xa0) || + (first == 0xed && bytes[1] >= 0xa0) || + (first == 0xf0 && bytes[1] < 0x90) || + (first == 0xf4 && bytes[1] >= 0x90)) return 0; + return count; +} + +static bool json_string(json_writer_t *writer, const char *text) { + const unsigned char *cursor = (const unsigned char *) text; + size_t remaining = strlen(text); + + if (!json_bytes(writer, "\"", 1)) return false; + while (remaining != 0) { + size_t count = utf8_length(cursor, remaining); + char escape[7]; + if (count == 0) return false; + if (*cursor < 0x20 || *cursor == 0x7f) { + snprintf(escape, sizeof(escape), "\\u%04x", (unsigned int) *cursor); + if (!json_bytes(writer, escape, 6)) return false; + } else if (*cursor == '\"' || *cursor == '\\') { + escape[0] = '\\'; + escape[1] = (char) *cursor; + if (!json_bytes(writer, escape, 2)) return false; + } else if (!json_bytes(writer, (const char *) cursor, count)) { + return false; + } + cursor += count; + remaining -= count; + } + return json_bytes(writer, "\"", 1); +} + +bool spine_json_log_record(char *output, size_t capacity, const char *timestamp, + const char *severity, const char *message) { + json_writer_t writer = {output, capacity}; + + if (output == NULL || capacity == 0) return false; + output[0] = '\0'; + if (timestamp == NULL || severity == NULL || message == NULL) return false; + if (!json_bytes(&writer, "{\"timestamp\":", 13) || !json_string(&writer, timestamp) || + !json_bytes(&writer, ",\"severity\":", 12) || !json_string(&writer, severity) || + !json_bytes(&writer, ",\"message\":", 11) || !json_string(&writer, message) || + !json_bytes(&writer, "}\n", 2)) { + output[0] = '\0'; + return false; + } + return true; +} diff --git a/src/log/log.c b/src/log/log.c index c81f3f4f..5146510a 100644 --- a/src/log/log.c +++ b/src/log/log.c @@ -159,12 +159,15 @@ static bool log_to_file(const char *message) { * creates missing files without a stat/open race that can truncate them. */ static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; static bool reported_error = FALSE; - if (pthread_mutex_lock(&mutex) != 0) return FALSE; + int oldstate; + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &oldstate); + if (pthread_mutex_lock(&mutex) != 0) { + pthread_setcancelstate(oldstate, NULL); + return FALSE; + } /* O_APPEND selects the end atomically and one write() keeps another * process sharing the log from splicing into a record. Reopening per * message preserves log rotation. */ - int oldstate; - pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &oldstate); int fd = open(set.logging.path_logfile, O_WRONLY | O_CREAT | O_APPEND, 0640); bool success = FALSE; if (fd >= 0) { @@ -173,12 +176,12 @@ static bool log_to_file(const char *message) { success = written >= 0 && (size_t) written == length; if (close(fd) != 0) success = FALSE; } - pthread_setcancelstate(oldstate, NULL); if (!success && !reported_error) { - printf("ERROR: Spine Log File Could Not Be Opened/Created or Written\n"); + printf("%s", set.logging.json_logs ? "{\"severity\":\"ERROR\",\"message\":\"Log file write failed\"}\n" : "ERROR: Spine Log File Could Not Be Opened/Created or Written\n"); reported_error = TRUE; } - if (pthread_mutex_unlock(&mutex) != 0) return FALSE; + if (pthread_mutex_unlock(&mutex) != 0) success = FALSE; + pthread_setcancelstate(oldstate, NULL); return success; } @@ -212,6 +215,45 @@ static log_buffers_t *log_buffers(void) { return buffers; } +_Static_assert((size_t) LOGSIZE <= (SIZE_MAX - 256) / 6, "JSON log allocation size overflows"); + +static int log_json_message(const char *message) { + static const char failure[] = "{\"severity\":\"ERROR\",\"message\":\"JSON log rejected: invalid UTF-8 or formatting failure\"}\n"; + const char *severity = strstr(message, "FATAL") ? "FATAL" : strstr(message, "ERROR") ? "ERROR" + : strstr(message, "WARNING") ? "WARNING" + : "INFO"; + char timestamp[32] = "unknown"; + struct tm utc; + time_t now = time(NULL); + size_t length = strlen(message); + char *owned = NULL; + const char *record = failure; + bool valid; + bool success; + + valid = gmtime_r(&now, &utc) != NULL && + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &utc) != 0; + owned = malloc(length * 6 + 256); + if (owned != NULL && spine_json_log_record(owned, length * 6 + 256, timestamp, severity, message)) { + record = owned; + } else { + valid = FALSE; + } + success = valid; + pthread_cleanup_push(free, owned); + if (IS_LOGGING_TO_STDOUT()) { + if (printf("%s", record) < 0) success = FALSE; + } else { + FILE *stream = strcmp(severity, "INFO") == 0 && valid ? stdout : log_error_stream(); + log_to_syslog(record); + if (!log_to_file(record)) success = FALSE; + if (set.logging.log_level >= POLLER_VERBOSITY_NONE && log_stream_available(stream) && + fprintf(stream, "%s", record) < 0) success = FALSE; + } + pthread_cleanup_pop(1); + return success; +} + int spine_log(const char *format, ...) { char fallback_message[BUFSIZE]; char fallback_formatted[BUFSIZE]; @@ -223,6 +265,7 @@ int spine_log(const char *format, ...) { va_start(args, format); vsnprintf(message, capacity - 1, format, args); va_end(args); + if (set.logging.json_logs) return log_json_message(message); spine_sanitize_log_message(message); if (IS_LOGGING_TO_STDOUT()) { return printf("Total[%3.4f] %s\n", get_time_as_double() - start_time, message) < 0 ? FALSE : TRUE; diff --git a/src/log/log.h b/src/log/log.h index e2c876ce..5b28fae6 100644 --- a/src/log/log.h +++ b/src/log/log.h @@ -26,5 +26,9 @@ _Noreturn void die(const char *format, ...) __attribute__((format(printf, 1, 2)) bool spine_should_log_device(int host_id, int verbosity); void set_date_format(void); char *get_date_format(void); +/* Caller-owned output; invalid UTF-8 or insufficient capacity rejects the + * whole record and clears output. No untrusted text becomes a JSON key. */ +bool spine_json_log_record(char *output, size_t capacity, const char *timestamp, + const char *severity, const char *message); #endif diff --git a/tests/regression/test_spine_faults.c b/tests/regression/test_spine_faults.c index 62ba25c6..83201faf 100644 --- a/tests/regression/test_spine_faults.c +++ b/tests/regression/test_spine_faults.c @@ -40,6 +40,17 @@ extern int __real_pthread_create(pthread_t *, const pthread_attr_t *, void *(*) #include "app/runtime.h" extern poller_thread_t **details; +extern ssize_t __real_write(int, const void *, size_t); +static bool cancel_log_write; + +ssize_t __wrap_write(int fd, const void *bytes, size_t length) { + if (cancel_log_write && fd > STDERR_FILENO) { + cancel_log_write = FALSE; + assert(pthread_cancel(pthread_self()) == 0); + } + return __real_write(fd, bytes, length); +} + static int format_failures; static int format_fault_calls; static MYSQL *query_fault_connection; @@ -666,6 +677,62 @@ static void test_privilege_drop_faults(void) { puts("production privilege drop faults passed"); } +static void *cancelled_logger(void *argument) { + assert(pthread_setcanceltype((int) (intptr_t) argument, NULL) == 0); + spine_log("cancellation ownership check"); + pthread_testcancel(); + return NULL; +} + +static void test_logger_cancellation_unlocks_file(void) { + char path[] = "spine-log-cancellation-XXXXXX"; + int fd = mkstemp(path); + pid_t process; + int status; + + assert(fd >= 0 && close(fd) == 0); + fflush(NULL); + process = fork(); + assert(process >= 0); + if (process == 0) { + pthread_t worker; + void *result = NULL; + config_t previous = set; + int kind; + + signal(SIGALRM, SIG_DFL); + alarm(5); + set.logging.json_logs = TRUE; + set.logging.log_destination = LOGDEST_FILE; + set.logging.log_level = POLLER_VERBOSITY_LOW; + set.logging.logfile_processed = TRUE; + set.console.stdout_notty = TRUE; + set.console.stderr_notty = TRUE; + STRNCOPY(set.logging.path_logfile, path); + for (kind = PTHREAD_CANCEL_DEFERRED; kind <= PTHREAD_CANCEL_ASYNCHRONOUS; kind++) { + cancel_log_write = TRUE; + assert(pthread_create(&worker, NULL, cancelled_logger, (void *) (intptr_t) kind) == 0); + assert(pthread_join(worker, &result) == 0); + assert(!cancel_log_write); + assert(result == PTHREAD_CANCELED); + /* A stranded mutex would hang here; the alarm makes that a failure. */ + assert(spine_log("logging remains usable after cancellation")); + } + allocation_failures = 1; + assert(!spine_log("allocation fault")); + assert(allocation_failures == 0); + format_failures = 1; + assert(!spine_log("timestamp fault")); + assert(format_failures == 0); + set = previous; + exit(0); + } + assert(waitpid(process, &status, 0) == process); + assert(unlink(path) == 0); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + puts("production JSON logger cancellation and allocation faults passed"); +} + static void test_logger_format_failure(void) { config_t previous = set; char filename[] = "spine-log-fault-XXXXXX"; @@ -1690,6 +1757,7 @@ int main(int argc, char **argv) { init_mutexes(); alarm(20); test_logger_format_failure(); + test_logger_cancellation_unlocks_file(); test_process_creation_failures(); test_spawn_group_failure(); test_interrupted_insert_retry(); diff --git a/tests/tools/test_json_encoder.py b/tests/tools/test_json_encoder.py new file mode 100644 index 00000000..d064c8f8 --- /dev/null +++ b/tests/tools/test_json_encoder.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: LGPL-2.1-or-later +"""Check the shipped encoder against Python JSON and strict UTF-8 decoding.""" +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +DRIVER = r'''#include +#include +#include +#include +#include "log/log.h" +int main(int argc, char **argv) { + char output[4098]; + size_t capacity; + assert(argc == 3); + assert(!spine_json_log_record(NULL, 1, "now", "INFO", "x")); + assert(!spine_json_log_record(output, 0, "now", "INFO", "x")); + assert(!spine_json_log_record(output, sizeof(output), NULL, "INFO", "x")); + assert(!spine_json_log_record(output, sizeof(output), "now", NULL, "x")); + assert(!spine_json_log_record(output, sizeof(output), "now", "INFO", NULL)); + capacity = strtoul(argv[2], NULL, 10); + assert(capacity <= 4096); + memset(output, 0xa5, sizeof(output)); + if (!spine_json_log_record(output, capacity, "time\"\n", "INFO", argv[1])) { + assert((unsigned char)output[capacity] == 0xa5); + if (capacity) assert(output[0] == 0); + return 3; + } + assert((unsigned char)output[capacity] == 0xa5); + return fputs(output, stdout) == EOF; +} +''' + + +class EncoderContracts(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.directory = tempfile.TemporaryDirectory(prefix="spine-json-contracts-") + cls.build = Path(cls.directory.name) + cls.driver = cls.build / "driver.c" + cls.driver.write_text(DRIVER) + cls.program = cls.build / "encoder" + compiler = shlex.split(os.environ.get("CC", "cc")) + flags = shlex.split(os.environ.get("SPINE_JSON_TEST_CFLAGS", "")) + subprocess.run(compiler + ["-std=gnu17", "-Wall", "-Wextra", "-Werror"] + flags + + ["-I", str(ROOT / "src"), str(cls.driver), str(ROOT / "src/log/json.c"), + "-o", str(cls.program)], check=True, cwd=cls.build) + + @classmethod + def tearDownClass(cls): + cls.directory.cleanup() + + def encode(self, value, capacity=4096): + return subprocess.run([os.fsencode(self.program), value, str(capacity).encode()], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + + def test_json_round_trip(self): + values = [b"", b"ordinary text", b'quote" slash\\', bytes(range(1, 32)) + b"\x7f", + "å漢字😀".encode(), b"\xc2\x80", b"\xdf\xbf", b"\xe0\xa0\x80", + b"\xed\x9f\xbf", b"\xef\xbf\xbf", b"\xf0\x90\x80\x80", b"\xf4\x8f\xbf\xbf"] + for value in values: + with self.subTest(value=value): + result = self.encode(value) + self.assertEqual(result.returncode, 0, result.stderr) + record = json.loads(result.stdout) + self.assertEqual(record, {"timestamp": 'time"\n', "severity": "INFO", + "message": value.decode("utf-8")}) + self.assertEqual(result.stdout.count(b"\n"), 1) + + def test_invalid_utf8_is_rejected(self): + values = [bytes([value]) for value in range(128, 256)] + values += [b"\xc0\xaf", b"\xc1\xbf", b"\xe1\x80", b"\xf1\x80\x80", + b"\xe0\x9f\xbf", b"\xed\xa0\x80", b"\xf0\x8f\xbf\xbf", + b"\xf4\x90\x80\x80", b"\xe1A\x80", b"\xe1\xc0\x80", b"\xe1\x80A"] + for value in values: + with self.subTest(value=value): + with self.assertRaises(UnicodeDecodeError): + value.decode("utf-8") + result = self.encode(value) + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(result.stdout, b"") + + def test_every_capacity_boundary(self): + for value in [b"", b'quote" slash\\', bytes(range(1, 32)), "漢字".encode()]: + full = self.encode(value) + self.assertEqual(full.returncode, 0) + for capacity in range(len(full.stdout) + 1): + result = self.encode(value, capacity) + self.assertEqual(result.returncode, 3, (capacity, result.stderr)) + exact = self.encode(value, len(full.stdout) + 1) + self.assertEqual(exact.returncode, 0, exact.stderr) + self.assertEqual(exact.stdout, full.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_log_config.c b/tests/unit/test_log_config.c index c0f8d88e..ee714a57 100644 --- a/tests/unit/test_log_config.c +++ b/tests/unit/test_log_config.c @@ -274,10 +274,146 @@ static void test_config_last_line_without_newline_is_read(void **state) { assert_int_equal(set.database.port, 3399); } +static void test_json_record_escaping_and_capacity(void **state) { + static const char expected[] = "{\"timestamp\":\"now\",\"severity\":\"INFO\",\"message\":\"quote\\\" slash\\\\ newline\\u000a tab\\u0009\\u007f\"}\n"; + char output[1024]; + size_t capacity; + + (void) state; + assert_true(spine_json_log_record(output, sizeof(output), "now", "INFO", "quote\" slash\\ newline\n tab\t\177")); + assert_string_equal(output, expected); + for (capacity = 1; capacity <= strlen(expected); capacity++) { + memset(output, 0xa5, sizeof(output)); + assert_false(spine_json_log_record(output, capacity, "now", "INFO", "quote\" slash\\ newline\n tab\t\177")); + assert_string_equal(output, ""); + assert_int_equal((unsigned char) output[capacity], 0xa5); + } + assert_true(spine_json_log_record(output, strlen(expected) + 1, "now", "INFO", "quote\" slash\\ newline\n tab\t\177")); + assert_false(spine_json_log_record(NULL, sizeof(output), "now", "INFO", "x")); + assert_false(spine_json_log_record(output, 0, "now", "INFO", "x")); + assert_false(spine_json_log_record(output, sizeof(output), NULL, "INFO", "x")); + assert_false(spine_json_log_record(output, sizeof(output), "now", NULL, "x")); + assert_false(spine_json_log_record(output, sizeof(output), "now", "INFO", NULL)); +} + +static void test_json_record_utf8_contract(void **state) { + static const char *const valid[] = {"", "ascii", "\xc2\x80", "\xdf\xbf", "\xe0\xa0\x80", + "\xed\x9f\xbf", "\xef\xbf\xbf", "\xf0\x90\x80\x80", "\xf4\x8f\xbf\xbf"}; + static const char *const invalid[] = {"\x80", "\xbf", "\xc0\xaf", "\xc1\xbf", "\xf5\x80\x80\x80", + "\xc2", "\xe1\x80", "\xf1\x80\x80", "\xe0\x9f\xbf", "\xed\xa0\x80", "\xf0\x8f\xbf\xbf", + "\xf4\x90\x80\x80", "\xe1\x41\x80", "\xe1\xc0\x80", "\xe1\x80\x41"}; + char output[512]; + size_t index; + + (void) state; + for (index = 0; index < sizeof(valid) / sizeof(valid[0]); index++) { + assert_true(spine_json_log_record(output, sizeof(output), "now", "INFO", valid[index])); + assert_non_null(strstr(output, valid[index])); + } + for (index = 0; index < sizeof(invalid) / sizeof(invalid[0]); index++) { + assert_false(spine_json_log_record(output, sizeof(output), "now", "INFO", invalid[index])); + assert_string_equal(output, ""); + } +} + +static void test_json_log_file_and_config(void **state) { + char path[] = "spine-json-log-XXXXXX"; + char line[1024]; + FILE *file; + int fd; + + (void) state; + fd = mkstemp(path); + assert_true(fd >= 0); + assert_int_equal(close(fd), 0); + write_config("Log_Format json\n"); + assert_int_equal(read_spine_config(config_path), 0); + assert_true(set.logging.json_logs); + STRNCOPY(set.logging.path_logfile, path); + set.logging.logfile_processed = TRUE; + set.logging.log_level = POLLER_VERBOSITY_LOW; + assert_true(spine_log("WARNING: Device[7] quote\" slash\\ value\nnext")); + assert_false(spine_log("bad UTF-8: %s", "\xff")); + file = fopen(path, "r"); + assert_non_null(file); + assert_non_null(fgets(line, sizeof(line), file)); + assert_non_null(strstr(line, "\"severity\":\"WARNING\"")); + assert_non_null(strstr(line, "quote\\\" slash\\\\ value\\u000anext")); + assert_non_null(fgets(line, sizeof(line), file)); + assert_non_null(strstr(line, "\"severity\":\"ERROR\"")); + assert_null(strstr(line, "\xff")); + assert_null(fgets(line, sizeof(line), file)); + assert_int_equal(fclose(file), 0); + unlink(path); + unlink(config_path); + write_config("Log_Format text\n"); + assert_int_equal(read_spine_config(config_path), 0); + assert_false(set.logging.json_logs); +} + +static void test_json_stdout_is_a_single_record(void **state) { + int descriptors[2]; + pid_t child; + int status; + char output[1024]; + ssize_t received; + + (void) state; + assert_int_equal(pipe(descriptors), 0); + fflush(NULL); + child = fork(); + assert_true(child >= 0); + if (child == 0) { + close(descriptors[0]); + assert_int_equal(dup2(descriptors[1], STDOUT_FILENO), STDOUT_FILENO); + close(descriptors[1]); + set.logging.json_logs = TRUE; + set.logging.log_destination = LOGDEST_STDOUT; + if (!spine_log("ERROR: quote\" newline\n")) _exit(1); + fflush(stdout); + _exit(0); + } + close(descriptors[1]); + received = read(descriptors[0], output, sizeof(output) - 1); + assert_true(received > 0); + output[received] = '\0'; + close(descriptors[0]); + assert_int_equal(waitpid(child, &status, 0), child); + assert_true(WIFEXITED(status)); + assert_int_equal(WEXITSTATUS(status), 0); + assert_true(output[0] == '{'); + assert_non_null(strstr(output, "\"severity\":\"ERROR\"")); + assert_non_null(strstr(output, "quote\\\" newline\\u000a")); + assert_ptr_equal(strchr(output, '\n'), output + received - 1); +} + +static void test_invalid_log_format_is_refused(void **state) { + pid_t child; + int status; + + (void) state; + write_config("Log_Format unknown\n"); + fflush(NULL); + child = fork(); + assert_true(child >= 0); + if (child == 0) { + read_spine_config(config_path); + _exit(0); + } + assert_int_equal(waitpid(child, &status, 0), child); + assert_true(WIFEXITED(status)); + assert_int_not_equal(WEXITSTATUS(status), 0); +} + int main(void) { const struct CMUnitTest tests[] = { cmocka_unit_test(test_spine_log_fits_a_musl_thread_stack), cmocka_unit_test(test_new_log_permissions), + cmocka_unit_test(test_json_record_escaping_and_capacity), + cmocka_unit_test(test_json_record_utf8_contract), + cmocka_unit_test_setup_teardown(test_json_stdout_is_a_single_record, config_setup, config_teardown), + cmocka_unit_test_setup_teardown(test_json_log_file_and_config, config_setup, config_teardown), + cmocka_unit_test_setup_teardown(test_invalid_log_format_is_refused, config_setup, config_teardown), cmocka_unit_test_setup_teardown(test_config_long_key_is_not_truncated_into_a_known_key, config_setup, config_teardown), cmocka_unit_test_setup_teardown(test_config_every_known_key_is_accepted, config_setup, config_teardown), cmocka_unit_test_setup_teardown(test_config_long_value_is_kept_whole, config_setup, config_teardown), From d81fc1d896bae1e171ad12aff96349ab2341e2a4 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 00:50:40 -0700 Subject: [PATCH 03/10] test(perf): add paired polling benchmarks and record measured results Signed-off-by: Thomas Vincent --- .github/workflows/benchmark.yml | 72 +++++++++ docs/benchmarks/README.md | 69 ++++++++ .../2026-10-11-default-logging/README.md | 49 ++++++ .../build-settings.txt | 5 + .../2026-10-11-default-logging/builds.sha256 | 4 + .../2026-10-11-default-logging/compiler.txt | 4 + .../2026-10-11-default-logging/cpu-quota.txt | 1 + .../2026-10-11-default-logging/cycles.csv | 45 ++++++ .../dependencies.txt | 9 ++ .../2026-10-11-default-logging/metadata.json | 25 +++ .../2026-10-11-default-logging/revisions.txt | 2 + .../2026-10-11-default-logging/summary.json | 67 ++++++++ tests/tools/benchmark_builds.py | 153 ++++++++++++++++++ tests/tools/build_benchmark_pair.sh | 27 ++++ tests/tools/test_benchmark_record.py | 76 +++++++++ tests/tools/verify_benchmark_record.py | 56 +++++++ 16 files changed, 664 insertions(+) create mode 100644 .github/workflows/benchmark.yml create mode 100644 docs/benchmarks/README.md create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/README.md create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/builds.sha256 create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/compiler.txt create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/cycles.csv create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/dependencies.txt create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/metadata.json create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/revisions.txt create mode 100644 docs/benchmarks/results/2026-10-11-default-logging/summary.json create mode 100644 tests/tools/benchmark_builds.py create mode 100644 tests/tools/build_benchmark_pair.sh create mode 100644 tests/tools/test_benchmark_record.py create mode 100644 tests/tools/verify_benchmark_record.py diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..8c173a3b --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,72 @@ +name: Paired build benchmark + +on: + workflow_dispatch: + inputs: + baseline: + description: Baseline Git revision + required: true + candidate: + description: Candidate Git revision + required: true + pairs: + description: Measured pairs after two warmup pairs (2 to 200) + default: '20' + required: true + +permissions: + contents: read + +jobs: + benchmark: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + services: + database: + image: mirror.gcr.io/library/mariadb:10.11 + env: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: '1' + ports: + - 3306:3306 + options: >- + --health-cmd="mariadb-admin ping --silent" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install shared toolchain and fixtures + run: | + sudo apt-get update + sudo apt-get install -y autoconf automake libtool help2man gcc libmariadb-dev mariadb-client libsnmp-dev snmp snmpd libssl-dev libcmocka-dev + sudo systemctl stop snmpd + - name: Verify benchmark evidence checks + run: python3 tests/tools/test_benchmark_record.py + - name: Build both exact revisions with identical settings + env: + BASELINE: ${{ inputs.baseline }} + CANDIDATE: ${{ inputs.candidate }} + run: bash tests/tools/build_benchmark_pair.sh "$BASELINE" "$CANDIDATE" benchmark-builds + - name: Measure alternating polling cycles and process CPU + env: + PAIRS: ${{ inputs.pairs }} + run: | + python3 tests/tools/benchmark_builds.py benchmark-builds/baseline/spine benchmark-builds/candidate/spine --pairs "$PAIRS" + python3 tests/tools/verify_benchmark_record.py benchmark-results + - name: Preserve measurements and build provenance + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: paired-benchmark-${{ github.run_id }} + path: | + benchmark-results/ + benchmark-builds/*-revision.txt + benchmark-builds/*-build.log + benchmark-builds/*-binary.sha256 + benchmark-builds/compiler.txt + benchmark-builds/dependencies.txt + benchmark-builds/build-settings.txt + if-no-files-found: error diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md new file mode 100644 index 00000000..40492de0 --- /dev/null +++ b/docs/benchmarks/README.md @@ -0,0 +1,69 @@ + + + +# Paired polling benchmarks + +The `Paired build benchmark` workflow builds two exact Git revisions with the +same GCC, dependencies, configure options and `-O2 -g0` flags. Dispatch it with +baseline and candidate revisions. Both must already exist in the repository. +The workflow has read-only repository permissions and does not publish results +or change a live Cacti installation. + +Both executables poll the same disposable MariaDB server and loopback Net-SNMP +agent on one runner. Defaults are four worker threads, 64 logical devices and +16 SNMPv2c items per device. Each item requests the agent's fixed `sysServices` +value of 72. Database TLS is disabled for both builds and checked on the server; +this workload does not measure TLS handshake or encrypted SNMPv3 performance. +There are two warmup pairs followed by 20 measured pairs, with alternating +baseline/candidate and candidate/baseline order. + +Before each cycle, the harness recreates only the disposable `spine_benchmark` +schema, including host status and scheduling state. Reset and verification time +are excluded from measurements. A successful sample must exit successfully and +store every expected value with the expected distinct data-source IDs. Missing, +duplicate or unknown output fails the run. A failed run retains `incomplete` +metadata and cannot produce a completed summary. + +`cycles.csv` records wall time, user CPU seconds, system CPU seconds, total CPU +seconds and CPU percentage for every cycle, including warmups. CPU percentage is +100 × CPU seconds / wall seconds and may exceed 100% for multithreaded work. +CPU measurements cover the Spine process and its children; database and agent +CPU are excluded. `summary.json` reports medians, observed ranges and median +paired candidate-minus-baseline deltas. Warmups are excluded from the summary. +Negative time deltas mean the candidate used less time in this experiment. + +The uploaded artifact includes measurements, cycle logs, the exact fixture, +configuration hashes, binary hashes, resolved revision IDs, compiler and package +versions, and build logs. Results from GitHub's shared hosted runners can contain +CPU scheduling noise. Short cycles can be dominated by worker wait intervals; +the synthetic loopback workload does not establish production-network behavior. +Repeat on an idle, fixed server with representative device counts and latency +before making a general performance claim. A single median difference is not +proof of an improvement, and no percentage improvement should be inferred +without measurements. + +## Recording results + +After a completed workflow run, download its artifact and add a directory under +`docs/benchmarks/results/` containing `cycles.csv`, `summary.json`, `metadata.json` +and the build revision/toolchain records. Add a short Markdown interpretation +with the workflow run URL, workload, limitations and scope of the comparison. +Review and commit these files through the normal pull-request process. The job +does not automatically push commits. Keep generated executables and large logs +in the workflow artifact rather than Git. + +For a dedicated Linux fixture with a loopback MariaDB server whose TLS is +disabled, the same harness can be run locally: + +```sh +bash tests/tools/build_benchmark_pair.sh BASE_REF CANDIDATE_REF benchmark-builds +python3 tests/tools/benchmark_builds.py benchmark-builds/baseline/spine \ + benchmark-builds/candidate/spine --pairs 20 +``` + +Use the repository's managed runtime through `mise` when selecting a Python +version locally. The harness intentionally accepts only the disposable loopback +database, and port 1161 must be available for its owned SNMP agent. It creates +disposable fixture users, never production credentials. Additional local +dimensions are available with `--threads`, `--devices`, `--items` and `--warmup`; +each setting applies identically to both builds. diff --git a/docs/benchmarks/results/2026-10-11-default-logging/README.md b/docs/benchmarks/results/2026-10-11-default-logging/README.md new file mode 100644 index 00000000..201f0b4e --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/README.md @@ -0,0 +1,49 @@ + + + +# Default text logging comparison, 2026-10-11 + +This is a local run of the implemented paired-build harness, not a GitHub Actions +run. The baseline is `24e105fc1a4002c334b5cffd78d571fbb10388ce`; the candidate is +`a223c27a3323fd29fea72d05e29398cff8d5934d`. The candidate adds the nightly suite +manifest gate and opt-in JSON logging with cancellation-safe log writes. JSON +logging remained disabled for this comparison. Subsequent uncommitted database, +TLS and ownership changes were excluded from both source archives. + +Both builds used GCC 13.3.0, `-O2 -g0`, empty `LDFLAGS` and +`--with-mysql-client=mariadb`, with the same installed packages. They ran in one +ARM64 Ubuntu 24.04 container under OrbStack, reporting 14 CPUs and an unlimited +cgroup CPU quota (`max 100000`). This was a shared development machine, not a +dedicated performance host. No other validation build ran during measurement. +Compiler, package, source-archive and binary hashes are recorded alongside the +raw samples. + +The workload used one unchanged MariaDB 10.11.14 server, one unchanged Net-SNMP +agent, four Spine threads, 64 logical loopback devices and 16 SNMPv2c items per +device. Database TLS was disabled for both builds and the server. Every cycle +verified all 1,024 stored numeric results. There were two warmup pairs and 20 +measured pairs in alternating AB/BA order; warmups are retained in the CSV and +excluded from the summary. + +| Measurement | Baseline median (observed range) | Candidate median (observed range) | Median paired candidate minus baseline | +| --- | --- | --- | --- | +| Polling cycle wall time, seconds | 0.748508 (0.738710–0.802304) | 0.748362 (0.734315–0.795132) | −0.000265 | +| Process CPU time, seconds | 0.022530 (0.018157–0.040387) | 0.022347 (0.018816–0.050931) | +0.000629 | +| Process CPU usage, percent | 2.987910 (2.319196–5.401776) | 2.995814 (2.508910–6.405355) | +0.105005 percentage points | + +The observed ranges overlap, and the paired differences are small relative to +cycle-to-cycle variation. This experiment does not establish a performance +improvement or regression. The difference between independent medians need not +equal the median of paired differences. CPU usage covers Spine and its children, +excluding the database and agent. The short loopback workload includes polling +startup, connection and worker wait time; it does not represent production +network latency, SNMPv3 or TLS workloads. No percentage speedup is claimed. + +`cycles.csv` contains every original sample, and `summary.json` retains full +numeric precision. `metadata.json` records fixture/configuration hashes and +server identity. To independently check this record from the repository root: + +```sh +python3 tests/tools/verify_benchmark_record.py \ + docs/benchmarks/results/2026-10-11-default-logging +``` diff --git a/docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt b/docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt new file mode 100644 index 00000000..122ad370 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt @@ -0,0 +1,5 @@ +CC=gcc +CFLAGS=-O2 -g0 +LDFLAGS= +configure=--with-mysql-client=mariadb +sources=git archive of each exact revision; no working-tree changes diff --git a/docs/benchmarks/results/2026-10-11-default-logging/builds.sha256 b/docs/benchmarks/results/2026-10-11-default-logging/builds.sha256 new file mode 100644 index 00000000..655e30e7 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/builds.sha256 @@ -0,0 +1,4 @@ +c8c7627d1ee4d05049b6efada786ad3d3082810b0c8ae52a71bee40a78e4ce72 /tmp/benchmark-baseline.tar +a9d85d6cf067eee055415445a1fad80df715cac5e418a19279cacc589af3fe7e /tmp/benchmark-candidate.tar +e38ab215ecfe3dcf4a49ee488c2bbd4d73e2675e79c24a8775a89c7e3bcb022b /tmp/benchmark-baseline/spine +31c83db2c47f0403fe90596e3161ce492bed0c8b148120856fffc8b3747d5836 /tmp/benchmark-candidate/spine diff --git a/docs/benchmarks/results/2026-10-11-default-logging/compiler.txt b/docs/benchmarks/results/2026-10-11-default-logging/compiler.txt new file mode 100644 index 00000000..9a3b6633 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/compiler.txt @@ -0,0 +1,4 @@ +gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 +Copyright (C) 2023 Free Software Foundation, Inc. +This is free software; see the source for copying conditions. There is NO +warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. diff --git a/docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt b/docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt new file mode 100644 index 00000000..1c1d3e7c --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt @@ -0,0 +1 @@ +max 100000 diff --git a/docs/benchmarks/results/2026-10-11-default-logging/cycles.csv b/docs/benchmarks/results/2026-10-11-default-logging/cycles.csv new file mode 100644 index 00000000..d43e6fce --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/cycles.csv @@ -0,0 +1,45 @@ +pair,position,build,warmup,elapsed_seconds,user_seconds,system_seconds,cpu_seconds,cpu_percent +0,0,baseline,True,0.7539715599850751,0.010882000000000003,0.012695999999999999,0.023578,3.127173656320237 +0,1,candidate,True,0.7531173009774648,0.00895,0.014915999999999999,0.023866,3.1689618561443895 +1,0,candidate,True,0.7477772919810377,0.007970000000000005,0.011954999999999993,0.019925,2.6645633952341603 +1,1,baseline,True,0.7387547490070574,0.007863999999999996,0.013483000000000009,0.021347000000000005,2.8895922535444947 +2,0,baseline,False,0.7486818839679472,0.010044000000000025,0.010043999999999997,0.020088000000000023,2.6831155434849596 +2,1,candidate,False,0.7423952409881167,0.009154999999999996,0.011443999999999982,0.02059899999999998,2.7746675709535835 +3,0,candidate,False,0.7441574659897014,0.010199999999999987,0.010200000000000015,0.0204,2.741355281958875 +3,1,baseline,False,0.7480305439676158,0.009156999999999998,0.011903999999999998,0.021060999999999996,2.815526741500516 +4,0,baseline,False,0.7485855080303736,0.007699000000000011,0.01283200000000001,0.02053100000000002,2.742639254935588 +4,1,candidate,False,0.7437005870160647,0.01063900000000001,0.01063900000000001,0.02127800000000002,2.8610976475591237 +5,0,candidate,False,0.749967020994518,0.011579000000000006,0.0072369999999999934,0.018816,2.5089103218230098 +5,1,baseline,False,0.7447509719640948,0.009807999999999983,0.008718000000000004,0.018525999999999987,2.487542909966574 +6,0,baseline,False,0.7413174399989657,0.0062570000000000126,0.017874999999999974,0.024131999999999987,3.2552856169192053 +6,1,candidate,False,0.7633003969676793,0.012496000000000007,0.008649999999999991,0.021145999999999998,2.7703378753640804 +7,0,candidate,False,0.75386634998722,0.0068569999999999465,0.013714999999999977,0.020571999999999924,2.7288656670175917 +7,1,baseline,False,0.7470205770223401,0.010425000000000018,0.018766000000000005,0.029191000000000022,3.9076567497453345 +8,0,baseline,False,0.7387097890023142,0.010788999999999993,0.012946999999999986,0.02373599999999998,3.2131698203237997 +8,1,candidate,False,0.7409268940100446,0.00923099999999999,0.018463000000000007,0.027693999999999996,3.737750677413601 +9,0,candidate,False,0.7396004649926908,0.009206000000000047,0.012658000000000058,0.021864000000000106,2.9561906779244898 +9,1,baseline,False,0.7467790319933556,0.010328000000000004,0.011360999999999954,0.02168899999999996,2.904339713731134 +10,0,baseline,False,0.7589514399878681,0.01836899999999997,0.00408200000000003,0.022451,2.9581602744384914 +10,1,candidate,False,0.7583376840339042,0.012854000000000032,0.012853999999999921,0.025707999999999953,3.3900464847333875 +11,0,candidate,False,0.7489783869823441,0.011550999999999978,0.010395000000000043,0.02194600000000002,2.9301246045858678 +11,1,baseline,False,0.7414845239836723,0.010511999999999966,0.007645000000000013,0.01815699999999998,2.4487362059089723 +12,0,baseline,False,0.7597573640523478,0.011842999999999937,0.011843999999999966,0.023686999999999903,3.117705878316153 +12,1,candidate,False,0.7513934929738753,0.008360000000000034,0.012538999999999967,0.020899,2.7813655821380165 +13,0,candidate,False,0.7457900649751537,0.010410999999999948,0.014873000000000025,0.025283999999999973,3.3902301984731213 +13,1,baseline,False,0.7512444090098143,0.005334000000000061,0.017336000000000018,0.02267000000000008,3.0176597293922645 +14,0,baseline,False,0.7484295900212601,0.00830900000000001,0.01661800000000002,0.024927000000000032,3.330573821819625 +14,1,candidate,False,0.7951315289828926,0.022919000000000023,0.028011999999999926,0.05093099999999995,6.405355358647303 +15,0,candidate,False,0.7477450830047019,0.009068999999999994,0.012368000000000046,0.02143700000000004,2.8668861203150486 +15,1,baseline,False,0.7476615399937145,0.015441999999999956,0.024945000000000106,0.04038700000000006,5.4017757821727175 +16,0,baseline,False,0.7587869190028869,0.010116000000000014,0.013149999999999995,0.02326600000000001,3.0662099487130843 +16,1,candidate,False,0.7652933460194618,0.006969000000000003,0.01626100000000008,0.023230000000000084,3.0354373418803164 +17,0,candidate,False,0.7636759579763748,0.0070790000000000575,0.01617999999999986,0.02325899999999992,3.045663511737718 +17,1,baseline,False,0.8023037640377879,0.01184099999999999,0.00676600000000005,0.01860700000000004,2.319196398425929 +18,0,baseline,False,0.7403300650184974,0.01356500000000005,0.009044000000000052,0.0226090000000001,3.053908123998072 +18,1,candidate,False,0.7462646960048005,0.011373999999999995,0.011373000000000077,0.022747000000000073,3.048114177419663 +19,0,candidate,False,0.7343151430250145,0.01869999999999994,0.016028000000000153,0.03472800000000009,4.729304622118773 +19,1,baseline,False,0.796409300994128,0.011807000000000123,0.015349999999999975,0.027157000000000098,3.4099300405081947 +20,0,baseline,False,0.7489052999881096,0.010856999999999895,0.010856999999999895,0.02171399999999979,2.899432011009208 +20,1,candidate,False,0.7499265579972416,0.012055999999999845,0.01096000000000008,0.023015999999999925,3.069100534519886 +21,0,candidate,False,0.7385923009715043,0.011487000000000025,0.012923000000000018,0.024410000000000043,3.304935614396799 +21,1,baseline,False,0.7607903110329062,0.006124000000000018,0.014697999999999878,0.020821999999999896,2.73689079606316 diff --git a/docs/benchmarks/results/2026-10-11-default-logging/dependencies.txt b/docs/benchmarks/results/2026-10-11-default-logging/dependencies.txt new file mode 100644 index 00000000..1589af15 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/dependencies.txt @@ -0,0 +1,9 @@ +autoconf 2.71-3 +automake 1:1.16.5-1.3ubuntu1 +gcc 4:13.2.0-7ubuntu1 +libc6-dev:arm64 2.39-0ubuntu8.9 +libmariadb-dev 1:10.11.14-0ubuntu0.24.04.1 +libsnmp-dev 5.9.4+dfsg-1.1ubuntu3.2 +libssl-dev:arm64 3.0.13-0ubuntu3.16 +libtool 2.4.7-7build1 +snmpd 5.9.4+dfsg-1.1ubuntu3.2 diff --git a/docs/benchmarks/results/2026-10-11-default-logging/metadata.json b/docs/benchmarks/results/2026-10-11-default-logging/metadata.json new file mode 100644 index 00000000..4d9f9e1a --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/metadata.json @@ -0,0 +1,25 @@ +{ + "status": "complete", + "timestamp_utc": "2026-10-11T07:40:54Z", + "platform": "Linux-7.0.14-orbstack-00380-ga7e0a2dc9535-aarch64-with-glibc2.39", + "machine": "aarch64", + "cpu_count": 14, + "threads": 4, + "devices": 64, + "items_per_device": 16, + "pairs": 20, + "warmup_pairs": 2, + "tls": "disabled on both builds and the loopback disposable server", + "server_tls": "have_ssl\tDISABLED", + "order": "alternating AB/BA", + "fixture_sha256": "2f76368129cfc0203c138eb4373d912c2d1b8c9f28931a742d9b1acbf7651078", + "config_sha256": "582c05ea67a7d9cbed391214b590c6a523b69177a8e59ccf31c9e841434bfb98", + "agent_config_sha256": "84b6c01407a1f72e727b0e97f2761164c27cff7ffc9eafd99852269753a22209", + "binary_sha256": { + "baseline": "e38ab215ecfe3dcf4a49ee488c2bbd4d73e2675e79c24a8775a89c7e3bcb022b", + "candidate": "31c83db2c47f0403fe90596e3161ce492bed0c8b148120856fffc8b3747d5836" + }, + "database_version": "10.11.14-MariaDB-0ubuntu0.24.04.1", + "snmpd_version": "NET-SNMP version: 5.9.4.pre2\nWeb: http://www.net-snmp.org/\nEmail: net-snmp-coders@lists.sourceforge.net", + "cpu_scope": "Spine process and its children; server and SNMP agent CPU excluded" +} diff --git a/docs/benchmarks/results/2026-10-11-default-logging/revisions.txt b/docs/benchmarks/results/2026-10-11-default-logging/revisions.txt new file mode 100644 index 00000000..8531dc91 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/revisions.txt @@ -0,0 +1,2 @@ +baseline 24e105fc1a4002c334b5cffd78d571fbb10388ce +candidate a223c27a3323fd29fea72d05e29398cff8d5934d diff --git a/docs/benchmarks/results/2026-10-11-default-logging/summary.json b/docs/benchmarks/results/2026-10-11-default-logging/summary.json new file mode 100644 index 00000000..e63340d2 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-default-logging/summary.json @@ -0,0 +1,67 @@ +{ + "elapsed_seconds": { + "baseline": { + "median": 0.7485075490258168, + "minimum": 0.7387097890023142, + "maximum": 0.8023037640377879 + }, + "candidate": { + "median": 0.748361734993523, + "minimum": 0.7343151430250145, + "maximum": 0.7951315289828926 + }, + "median_paired_delta": -0.0002651064714882523 + }, + "user_seconds": { + "baseline": { + "median": 0.01037650000000001, + "minimum": 0.005334000000000061, + "maximum": 0.01836899999999997 + }, + "candidate": { + "median": 0.010524999999999979, + "minimum": 0.0068569999999999465, + "maximum": 0.022919000000000023 + }, + "median_paired_delta": 7.499999999999174e-05 + }, + "system_seconds": { + "baseline": { + "median": 0.012368000000000004, + "minimum": 0.00408200000000003, + "maximum": 0.024945000000000106 + }, + "candidate": { + "median": 0.012598500000000012, + "minimum": 0.0072369999999999934, + "maximum": 0.028011999999999926 + }, + "median_paired_delta": 0.0006865000000000898 + }, + "cpu_seconds": { + "baseline": { + "median": 0.02253000000000005, + "minimum": 0.01815699999999998, + "maximum": 0.04038700000000006 + }, + "candidate": { + "median": 0.022346500000000047, + "minimum": 0.018816, + "maximum": 0.05093099999999995 + }, + "median_paired_delta": 0.0006289999999999768 + }, + "cpu_percent": { + "baseline": { + "median": 2.987910001915378, + "minimum": 2.319196398425929, + "maximum": 5.4017757821727175 + }, + "candidate": { + "median": 2.995814009902403, + "minimum": 2.5089103218230098, + "maximum": 6.405355358647303 + }, + "median_paired_delta": 0.10500521004607988 + } +} diff --git a/tests/tools/benchmark_builds.py b/tests/tools/benchmark_builds.py new file mode 100644 index 00000000..51082caa --- /dev/null +++ b/tests/tools/benchmark_builds.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: GPL-2.0-or-later +"""Measure paired full polling cycles against disposable loopback fixtures.""" +import argparse +import csv +import hashlib +import json +import os +from pathlib import Path +import platform +import resource +import statistics +import subprocess +import time + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("baseline", type=Path) + parser.add_argument("candidate", type=Path) + parser.add_argument("--pairs", type=int, default=20) + parser.add_argument("--warmup", type=int, default=2) + parser.add_argument("--threads", type=int, default=4) + parser.add_argument("--devices", type=int, default=64) + parser.add_argument("--items", type=int, default=16) + parser.add_argument("--output", type=Path, default=Path("benchmark-results")) + args = parser.parse_args() + if not (2 <= args.pairs <= 200 and 0 <= args.warmup <= 20 + and 1 <= args.threads <= 100 and 1 <= args.devices <= 1000 + and 1 <= args.items <= 1000): + parser.error("benchmark dimensions are outside supported bounds") + binaries = {"baseline": args.baseline.resolve(), "candidate": args.candidate.resolve()} + if any(not os.access(binary, os.X_OK) for binary in binaries.values()): + parser.error("both builds must be executable") + args.output.mkdir(parents=True, exist_ok=False) + output = args.output.resolve() + (output / "metadata.json").write_text('{"status": "incomplete"}\n') + root = Path(__file__).resolve().parents[2] + mysql = ["mariadb", "--protocol=TCP", "--host=127.0.0.1", "--user=root", "--batch", "--skip-column-names"] + + def sql(statement): + return subprocess.run(mysql, input=statement, text=True, check=True, + capture_output=True, timeout=30).stdout.strip() + + server_tls = sql("SHOW VARIABLES LIKE 'have_ssl';") + if server_tls not in ("have_ssl\tDISABLED", "have_ssl\tNO"): + raise RuntimeError("this fixture requires a disposable server with TLS disabled") + sql("CREATE USER IF NOT EXISTS 'spine_benchmark'@'127.0.0.1' IDENTIFIED BY 'benchmark-fixture-only'; " + "CREATE USER IF NOT EXISTS 'spine_benchmark'@'localhost' IDENTIFIED BY 'benchmark-fixture-only'; " + "GRANT SELECT,INSERT,UPDATE,DELETE ON spine_benchmark.* TO 'spine_benchmark'@'127.0.0.1'; " + "GRANT SELECT,INSERT,UPDATE,DELETE ON spine_benchmark.* TO 'spine_benchmark'@'localhost';") + + # Only this explicitly named disposable schema is ever modified. + schema = "".join((root / "tests/fixtures" / name).read_text() + for name in ("configuration.sql", "poller.sql")) + workload = ["UPDATE poller SET threads=%d WHERE id=1;" % args.threads, + "INSERT INTO settings VALUES ('log_destination','2'),('log_verbosity','1'),('poller_interval','60');"] + for device in range(1, args.devices + 1): + workload.append("INSERT INTO host(id,hostname,snmp_community,snmp_version,snmp_port,availability_method,max_oids) " + f"VALUES({device},'127.0.0.1','regression',2,1161,0,16);") + for item in range(args.items): + identifier = (device - 1) * args.items + item + 1 + workload.append("INSERT INTO poller_item(local_data_id,host_id,action,hostname,snmp_community,snmp_version,snmp_port,snmp_timeout,rrd_name,arg1) " + f"VALUES({identifier},{device},0,'127.0.0.1','regression',2,1161,1000,'value','.1.3.6.1.2.1.1.7.0');") + fixture = "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'; DROP DATABASE IF EXISTS spine_benchmark; CREATE DATABASE spine_benchmark; USE spine_benchmark;\n" + schema + "\n" + "\n".join(workload) + (output / "fixture.sql").write_text(fixture) + config = output / "spine.conf" + config.write_text("DB_Host 127.0.0.1\nDB_Database spine_benchmark\nDB_User spine_benchmark\nDB_Pass benchmark-fixture-only\nDB_Port 3306\nDB_UseSSL 0\nCacti_Log " + str(output / "spine.log") + "\n") + agent_config = output / "snmpd.conf" + agent_config.write_text("agentaddress udp:127.0.0.1:1161\nrocommunity regression 127.0.0.1\nsysServices 72\n") + metadata = {"status": "incomplete", "timestamp_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "platform": platform.platform(), "machine": platform.machine(), + "cpu_count": os.cpu_count(), "threads": args.threads, "devices": args.devices, + "items_per_device": args.items, "pairs": args.pairs, "warmup_pairs": args.warmup, + "tls": "disabled on both builds and the loopback disposable server", + "server_tls": server_tls, + "order": "alternating AB/BA", "fixture_sha256": digest(output / "fixture.sql"), + "config_sha256": digest(config), "agent_config_sha256": digest(agent_config), + "binary_sha256": {name: digest(binary) for name, binary in binaries.items()}, + "database_version": sql("SELECT VERSION();"), + "snmpd_version": subprocess.run(["snmpd", "--version"], capture_output=True, text=True, check=True).stdout.strip(), + "cpu_scope": "Spine process and its children; server and SNMP agent CPU excluded"} + metadata_file = output / "metadata.json" + metadata_file.write_text(json.dumps(metadata, indent=2) + "\n") + rows = [] + with (output / "agent.log").open("w") as agent_log: + agent = subprocess.Popen(["snmpd", "-f", "-Lo", "-C", "-c", str(agent_config)], stdout=agent_log, stderr=subprocess.STDOUT) + try: + for _ in range(50): + if agent.poll() is not None: + raise RuntimeError("owned SNMP agent exited; check for a port collision") + probe = subprocess.run(["snmpget", "-v2c", "-c", "regression", "-t", "0.1", "-r", "0", "-Oqv", "127.0.0.1:1161", ".1.3.6.1.2.1.1.7.0"], capture_output=True, text=True) + if probe.returncode == 0 and probe.stdout.strip() == "72": + time.sleep(0.2) + if agent.poll() is not None: + raise RuntimeError("owned SNMP agent failed to bind") + break + time.sleep(0.1) + else: + raise RuntimeError("owned SNMP agent did not become ready") + with (output / "cycles.csv").open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=["pair", "position", "build", "warmup", "elapsed_seconds", "user_seconds", "system_seconds", "cpu_seconds", "cpu_percent"]) + writer.writeheader() + for pair in range(args.warmup + args.pairs): + order = ("baseline", "candidate") if pair % 2 == 0 else ("candidate", "baseline") + for position, name in enumerate(order): + sql(fixture) # Reset ALL mutable polling state outside the timed region. + with (output / f"cycle-{pair:03d}-{name}.log").open("w") as log: + before = resource.getrusage(resource.RUSAGE_CHILDREN) + start = time.perf_counter() + subprocess.run([str(binaries[name]), "--conf", str(config), "-p", "1", "-t", str(args.threads), "--mode=online", "-O", "poller_interval:60", "-O", "active_profiles:1", "-S", "-V", "1"], stdout=log, stderr=subprocess.STDOUT, check=True, timeout=120) + elapsed = time.perf_counter() - start + after = resource.getrusage(resource.RUSAGE_CHILDREN) + expected = args.devices * args.items + counts = sql("USE spine_benchmark; SELECT COUNT(*),SUM(output='72'),COUNT(DISTINCT local_data_id) FROM poller_output;") + if counts != f"{expected}\t{expected}\t{expected}": + raise RuntimeError(f"{name} polling output failed validation: {counts}") + user = after.ru_utime - before.ru_utime + system = after.ru_stime - before.ru_stime + row = dict(pair=pair, position=position, build=name, warmup=pair < args.warmup, + elapsed_seconds=elapsed, user_seconds=user, system_seconds=system, + cpu_seconds=user + system, + cpu_percent=100 * (user + system) / elapsed) + writer.writerow(row) + stream.flush() + if pair >= args.warmup: + rows.append(row) + finally: + agent.terminate() + try: + agent.wait(timeout=5) + except subprocess.TimeoutExpired: + agent.kill() + agent.wait() + summary = {} + for metric in ("elapsed_seconds", "user_seconds", "system_seconds", "cpu_seconds", "cpu_percent"): + values = {name: [row[metric] for row in rows if row["build"] == name] for name in binaries} + summary[metric] = {name: {"median": statistics.median(data), "minimum": min(data), "maximum": max(data)} for name, data in values.items()} + paired = [candidate - baseline for baseline, candidate in zip(values["baseline"], values["candidate"])] + summary[metric]["median_paired_delta"] = statistics.median(paired) + (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + metadata["status"] = "complete" + metadata_file.write_text(json.dumps(metadata, indent=2) + "\n") + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/tools/build_benchmark_pair.sh b/tests/tools/build_benchmark_pair.sh new file mode 100644 index 00000000..7012276c --- /dev/null +++ b/tests/tools/build_benchmark_pair.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: GPL-2.0-or-later +set -euo pipefail +[[ $# -eq 3 ]] || { printf 'Usage: %s BASE_REF CANDIDATE_REF OUTPUT_DIRECTORY\n' "$0" >&2; exit 2; } +baseline=$(git rev-parse --verify --end-of-options "$1^{commit}") +candidate=$(git rev-parse --verify --end-of-options "$2^{commit}") +mkdir "$3" +destination=$(cd "$3" && pwd -P) +gcc --version > "$destination/compiler.txt" +dpkg-query -W gcc libc6-dev libmariadb-dev libsnmp-dev libssl-dev snmpd autoconf automake libtool > "$destination/dependencies.txt" +printf 'CC=gcc\nCFLAGS=-O2 -g0\nLDFLAGS=\nconfigure=--with-mysql-client=mariadb\n' > "$destination/build-settings.txt" +for name in baseline candidate; do + revision=$baseline + [[ "$name" != candidate ]] || revision=$candidate + printf '%s\n' "$revision" > "$destination/$name-revision.txt" + mkdir "$destination/$name" + git archive "$revision" | tar -x -C "$destination/$name" + ( + cd "$destination/$name" + mkdir -p config m4 + autoreconf -fi + ./configure --with-mysql-client=mariadb CC=gcc CFLAGS='-O2 -g0' LDFLAGS='' + make -j2 spine + ) > "$destination/$name-build.log" 2>&1 + sha256sum "$destination/$name/spine" > "$destination/$name-binary.sha256" +done diff --git a/tests/tools/test_benchmark_record.py b/tests/tools/test_benchmark_record.py new file mode 100644 index 00000000..6a1aa72f --- /dev/null +++ b/tests/tools/test_benchmark_record.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: GPL-2.0-or-later +"""Ensure malformed benchmark evidence cannot be accepted as a result.""" +import csv +import json +from pathlib import Path +import shutil +import tempfile +import unittest + +from verify_benchmark_record import verify + +RECORD = Path(__file__).resolve().parents[2] / "docs/benchmarks/results/2026-10-11-default-logging" + + +class BenchmarkRecordTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.record = Path(self.temporary.name) / "record" + shutil.copytree(RECORD, self.record) + + def change_rows(self, transform): + file = self.record / "cycles.csv" + with file.open(newline="") as stream: + reader = csv.DictReader(stream) + fields, rows = reader.fieldnames, list(reader) + transform(rows) + with file.open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + def test_measured_record(self): + verify(self.record) + + def test_missing_cycle(self): + self.change_rows(lambda rows: rows.pop()) + with self.assertRaisesRegex(ValueError, "missing cycle"): + verify(self.record) + + def test_duplicate_cycle(self): + self.change_rows(lambda rows: rows.append(rows[0])) + with self.assertRaisesRegex(ValueError, "duplicate cycle"): + verify(self.record) + + def test_nonfinite_cpu(self): + self.change_rows(lambda rows: rows[0].update(cpu_seconds="NaN")) + with self.assertRaisesRegex(ValueError, "invalid cycle measurement"): + verify(self.record) + + def test_incorrect_cpu_accounting(self): + self.change_rows(lambda rows: rows[0].update(cpu_percent="100")) + with self.assertRaisesRegex(ValueError, "CPU percentage disagrees"): + verify(self.record) + + def test_incomplete_run(self): + file = self.record / "metadata.json" + data = json.loads(file.read_text()) + data["status"] = "incomplete" + file.write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, "benchmark is incomplete"): + verify(self.record) + + def test_unmeasured_summary(self): + file = self.record / "summary.json" + data = json.loads(file.read_text()) + data["elapsed_seconds"]["candidate"]["median"] = 0.1 + file.write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, "summary disagrees"): + verify(self.record) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/verify_benchmark_record.py b/tests/tools/verify_benchmark_record.py new file mode 100644 index 00000000..af1dd68f --- /dev/null +++ b/tests/tools/verify_benchmark_record.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: GPL-2.0-or-later +"""Independently verify a completed benchmark's samples and summary.""" +import csv +import json +import math +from pathlib import Path +import statistics +import sys + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def verify(directory): + directory = Path(directory) + metadata = json.loads((directory / "metadata.json").read_text()) + require(metadata["status"] == "complete", "benchmark is incomplete") + pairs, warmup = metadata["pairs"], metadata["warmup_pairs"] + require(type(pairs) is int and pairs >= 2 and type(warmup) is int and warmup >= 0, + "invalid sample dimensions") + samples = {} + metrics = ("elapsed_seconds", "user_seconds", "system_seconds", "cpu_seconds", "cpu_percent") + with (directory / "cycles.csv").open(newline="") as stream: + for row in csv.DictReader(stream): + pair, name = int(row["pair"]), row["build"] + require(0 <= pair < pairs + warmup and name in ("baseline", "candidate"), "invalid sample key") + require((pair, name) not in samples, "duplicate cycle") + require(row["warmup"] == str(pair < warmup), "invalid warmup flag") + expected_first = "baseline" if pair % 2 == 0 else "candidate" + require(int(row["position"]) == int(name != expected_first), "invalid alternating order") + values = {metric: float(row[metric]) for metric in metrics} + require(all(math.isfinite(value) and value >= 0 for value in values.values()) + and values["elapsed_seconds"] > 0, "invalid cycle measurement") + require(math.isclose(values["cpu_seconds"], values["user_seconds"] + values["system_seconds"], abs_tol=1e-12), "CPU total disagrees") + require(math.isclose(values["cpu_percent"], 100 * values["cpu_seconds"] / values["elapsed_seconds"], abs_tol=1e-12), "CPU percentage disagrees") + samples[pair, name] = values + require(len(samples) == 2 * (pairs + warmup), "missing cycle") + summary = json.loads((directory / "summary.json").read_text()) + for metric in metrics: + values = {name: [samples[pair, name][metric] for pair in range(warmup, pairs + warmup)] + for name in ("baseline", "candidate")} + for name, data in values.items(): + for key, expected in (("median", statistics.median(data)), ("minimum", min(data)), ("maximum", max(data))): + require(math.isclose(summary[metric][name][key], expected, abs_tol=1e-12), "summary disagrees with measured cycles") + expected = statistics.median(candidate - baseline for baseline, candidate + in zip(values["baseline"], values["candidate"])) + require(math.isclose(summary[metric]["median_paired_delta"], expected, abs_tol=1e-12), "paired delta disagrees") + + +if __name__ == "__main__": + verify(sys.argv[1]) + print("Benchmark record verified") From 354e83d7bda0681dd12b512dd8830635cf1e20de Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 02:03:22 -0700 Subject: [PATCH 04/10] fix(runtime): enforce database policies and owned worker resources Signed-off-by: Thomas Vincent --- .github/workflows/regressions.yml | 48 ++- Makefile.am | 52 ++- Makefile.in | 372 ++++++++++++++++-- README.md | 17 + configure.ac | 17 + .../0004-formatting-and-json-dependencies.md | 42 ++ docs/operations/database-tls.md | 66 ++++ docs/operations/issue-boundaries.md | 80 ++++ docs/operations/script-limits.md | 73 ++++ etc/spine.conf.dist | 20 + src/app/runtime.c | 21 +- src/app/startup_internal.h | 9 +- src/config/file.c | 90 +++++ src/config/model.h | 23 ++ src/database/connection.c | 62 ++- src/database/lease.c | 104 +++++ src/database/lease.h | 26 ++ src/database/persistence.c | 10 +- src/database/pool.c | 80 ++-- src/database/sql.h | 7 +- src/database/tls.c | 123 ++++++ src/internal/constants.h | 4 - src/internal/spine_types.h | 4 + src/log/log.c | 5 +- src/platform/locks.c | 12 - src/platform/worker_set.c | 108 +++++ src/platform/worker_set.h | 37 ++ src/poller/poller.c | 87 ++-- src/poller/workers.c | 32 +- src/process/nft_popen.c | 39 +- src/process/nft_popen.h | 4 + src/script/external.c | 137 ++++++- tests/contracts/test_spine_contracts.c | 8 +- tests/contracts/test_spine_output_contracts.c | 86 +++- .../test_spine_script_limits_contracts.c | 136 +++++++ tests/contracts/test_spine_tls_contracts.c | 82 ++++ tests/fixtures/script_resource_probe.c | 48 +++ tests/fixtures/snmpd.conf | 2 +- tests/regression/test_spine_faults.c | 27 +- tests/regression/test_spine_regressions.c | 34 +- tests/tools/ownership-coverage-sources.txt | 2 + tests/tools/run_coverage.sh | 11 +- tests/tools/run_database_tls.sh | 78 ++++ tests/tools/run_script_limits.sh | 10 + tests/tools/test_coverage_verifier.sh | 13 + tests/tools/verify_coverage.sh | 17 +- tests/unit/test_db_lease.c | 155 ++++++++ tests/unit/test_linked.c | 142 +++++++ tests/unit/test_log_config.c | 81 ++++ tests/unit/test_poll_host.c | 32 +- tests/unit/test_worker_set.c | 275 +++++++++++++ 51 files changed, 2811 insertions(+), 239 deletions(-) create mode 100644 docs/adr/0004-formatting-and-json-dependencies.md create mode 100644 docs/operations/database-tls.md create mode 100644 docs/operations/issue-boundaries.md create mode 100644 docs/operations/script-limits.md create mode 100644 src/database/lease.c create mode 100644 src/database/lease.h create mode 100644 src/database/tls.c create mode 100644 src/platform/worker_set.c create mode 100644 src/platform/worker_set.h create mode 100644 tests/contracts/test_spine_script_limits_contracts.c create mode 100644 tests/contracts/test_spine_tls_contracts.c create mode 100644 tests/fixtures/script_resource_probe.c create mode 100644 tests/tools/ownership-coverage-sources.txt create mode 100644 tests/tools/run_database_tls.sh create mode 100644 tests/tools/run_script_limits.sh create mode 100644 tests/unit/test_db_lease.c create mode 100644 tests/unit/test_worker_set.c diff --git a/.github/workflows/regressions.yml b/.github/workflows/regressions.yml index bb4fb8f4..4672aa97 100644 --- a/.github/workflows/regressions.yml +++ b/.github/workflows/regressions.yml @@ -12,7 +12,18 @@ permissions: jobs: regressions: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + client: + - choice: mariadb + package: libmariadb-dev + - choice: mysql + package: libmysqlclient-dev + env: + SPINE_CLIENT_PACKAGE: ${{ matrix.client.package }} + SPINE_CLIENT_CHOICE: ${{ matrix.client.choice }} services: database: image: mirror.gcr.io/library/mariadb:10.11 @@ -26,6 +37,19 @@ jobs: --health-interval=5s --health-timeout=5s --health-retries=20 + remote-database: + image: mirror.gcr.io/library/mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: '1' + MYSQL_ROOT_HOST: '%' + MYSQL_DATABASE: spine_regressions + ports: + - 3307:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=5s + --health-timeout=5s + --health-retries=20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: @@ -34,23 +58,30 @@ jobs: run: | set -eu sudo apt-get update - sudo apt-get install -y autoconf automake libtool help2man libmariadb-dev mariadb-client libsnmp-dev snmpd libssl-dev libcap2-bin libcmocka-dev lcov + sudo apt-get install -y autoconf automake libtool help2man "$SPINE_CLIENT_PACKAGE" mariadb-client libsnmp-dev snmpd libssl-dev libcap2-bin libcmocka-dev lcov - name: Build and test production code run: | set -eu mkdir -p config m4 autoreconf -fi - ./configure + ./configure --with-mysql-client="$SPINE_CLIENT_CHOICE" make -j2 check + - name: Verify child resource and cancellation limits + run: bash tests/tools/run_script_limits.sh - name: Verify database configuration loading env: SPINE_TEST_DB_HOST: 127.0.0.1 + SPINE_TEST_REMOTE_DB_HOST: 127.0.0.1 run: | set -eu mariadb --protocol=TCP --host=127.0.0.1 --user=root spine_regressions < tests/fixtures/configuration.sql mariadb --protocol=TCP --host=127.0.0.1 --user=root spine_regressions < tests/fixtures/poller.sql + # The fixture preserves Cacti's historical zero-date defaults. + mariadb --protocol=TCP --host=127.0.0.1 --port=3307 --user=root --init-command="SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" spine_regressions < tests/fixtures/configuration.sql + mariadb --protocol=TCP --host=127.0.0.1 --port=3307 --user=root --init-command="SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" spine_regressions < tests/fixtures/poller.sql ./test_spine_regressions --database - name: Verify SNMP availability against local agent + # TLS fixtures are separate from the schema-mutating database suites. env: SPINE_TEST_SNMP_HOST: 127.0.0.1 SPINE_TEST_DB_HOST: 127.0.0.1 @@ -67,6 +98,15 @@ jobs: env: SPINE_SNMPV3_EVIDENCE_DIR: snmpv3-evidence run: tests/tools/run_snmpv3.sh + - name: Verify database TLS trust and downgrade boundaries + run: bash tests/tools/run_database_tls.sh + - name: Preserve database TLS evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: database-tls-${{ matrix.client.choice }}-${{ github.sha }} + path: tls-evidence/ + if-no-files-found: error - name: Verify ICMP against local loopback run: | set -eu @@ -100,6 +140,6 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: production-coverage-${{ github.sha }} + name: production-coverage-${{ matrix.client.choice }}-${{ github.sha }} path: coverage/ if-no-files-found: error diff --git a/Makefile.am b/Makefile.am index 704ecbf5..d4af94e5 100644 --- a/Makefile.am +++ b/Makefile.am @@ -21,7 +21,7 @@ spine_support_sources = src/app/buffer.c \ src/log/log.c src/log/json.c src/poller/result.c \ src/platform/privileges.c src/poller/regex.c spine_database_sources = src/database/sql.c \ - src/database/connection.c \ + src/database/connection.c src/database/tls.c src/database/lease.c \ src/database/pool.c src/database/transfer.c \ src/database/cacti_query.c src/database/persistence.c spine_snmp_sources = src/snmp/requests.c src/snmp/response.c \ @@ -40,7 +40,7 @@ spine_process_sources = src/process/nft_popen.c src/process/fd.c spine_shared_sources = $(spine_database_sources) $(spine_support_sources) $(spine_snmp_sources) src/platform/locks.c $(spine_poller_sources) \ $(spine_process_sources) $(spine_php_sources) $(spine_ping_sources) src/config/keywords.c src/app/signals.c -spine_runtime_sources = $(spine_shared_sources) src/app/runtime.c src/app/cli.c src/app/startup.c src/poller/workers.c +spine_runtime_sources = $(spine_shared_sources) src/platform/worker_set.c src/app/runtime.c src/app/cli.c src/app/startup.c src/poller/workers.c spine_SOURCES = $(spine_runtime_sources) src/app/main.c configdir = $(sysconfdir) @@ -71,7 +71,27 @@ TESTS = $(check_PROGRAMS) TESTS += tests/tools/test_nightly_tests.sh EXTRA_DIST = tests/tools/check_nightly_tests.sh tests/tools/test_nightly_tests.sh EXTRA_DIST += tests/tools/test_json_encoder.py +EXTRA_DIST += tests/tools/run_database_tls.sh +EXTRA_DIST += tests/tools/ownership-coverage-sources.txt +EXTRA_DIST += tests/tools/build_benchmark_pair.sh tests/tools/benchmark_builds.py +EXTRA_DIST += tests/tools/verify_benchmark_record.py tests/tools/test_benchmark_record.py +EXTRA_DIST += docs/benchmarks/README.md \ + docs/benchmarks/results/2026-10-11-default-logging/README.md \ + docs/benchmarks/results/2026-10-11-default-logging/cycles.csv \ + docs/benchmarks/results/2026-10-11-default-logging/metadata.json \ + docs/benchmarks/results/2026-10-11-default-logging/summary.json \ + docs/benchmarks/results/2026-10-11-default-logging/compiler.txt \ + docs/benchmarks/results/2026-10-11-default-logging/dependencies.txt \ + docs/benchmarks/results/2026-10-11-default-logging/builds.sha256 \ + docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt \ + docs/benchmarks/results/2026-10-11-default-logging/revisions.txt \ + docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_regressions.c tests/contracts/test_spine_contracts.c tests/contracts/test_spine_reindex_contracts.c tests/contracts/test_spine_snmpv3_contracts.c tests/contracts/test_spine_settings_contracts.c tests/contracts/test_spine_cli_contracts.c tests/contracts/test_spine_stream_contracts.c tests/contracts/test_spine_output_contracts.c tests/contracts/test_spine_privilege_contracts.c +test_spine_regressions_SOURCES += tests/contracts/test_spine_tls_contracts.c +test_spine_regressions_SOURCES += tests/contracts/test_spine_script_limits_contracts.c +EXTRA_DIST += tests/tools/run_script_limits.sh tests/fixtures/script_resource_probe.c +EXTRA_DIST += docs/operations/script-limits.md docs/operations/database-tls.md \ + docs/operations/issue-boundaries.md docs/adr/0004-formatting-and-json-dependencies.md # Link the real production translation units and globals, with a separate test # entry point. Production and regression binaries share the runtime objects. @@ -93,9 +113,11 @@ endif # Headers are not referenced by any _SOURCES, so automake will not distribute # them unless they are listed. Without this the release tarball builds nothing. noinst_HEADERS = src/internal/common.h src/app/signals.h src/config/keywords.h src/platform/locks.h \ + src/platform/worker_set.h \ src/process/nft_popen.h src/script/server.h src/ping/ping.h src/poller/poller.h \ src/snmp/snmp.h src/app/spine.h src/platform/spine_sem.h src/internal/spine_types.h \ src/database/sql.h vendor/uthash/uthash.h src/internal/util.h +noinst_HEADERS += src/database/lease.h EXTRA_DIST += docs/man/spine.1 etc/spine.conf.dist \ CONTRIBUTORS.md docs/licensing.md LICENSES/LicenseRef-Xenadyne.txt LICENSES/LicenseRef-Xenadyne-Header.txt \ @@ -166,7 +188,9 @@ check_PROGRAMS += \ tests/unit/test_snmpv3_session \ tests/unit/test_ping_icmp \ tests/unit/test_ping_timing \ - tests/unit/test_log_config + tests/unit/test_log_config \ + tests/unit/test_worker_set \ + tests/unit/test_db_lease # A small executable which speaks the subset of the PHP Script Server protocol # needed by test_php_runtime. Keeping it as a real child covers the shipped @@ -240,7 +264,7 @@ tests_unit_test_poll_host_SOURCES = tests/unit/test_poll_host.c tests/support/sp tests_unit_test_poll_host_LDADD = $(CMOCKA_LIBS) $(LIBS) tests_unit_test_poll_host_LDFLAGS = \ -Wl,--wrap=db_query -Wl,--wrap=db_insert -Wl,--wrap=db_escape \ - -Wl,--wrap=db_get_connection -Wl,--wrap=db_release_connection \ + -Wl,--wrap=db_acquire_lease -Wl,--wrap=db_release_lease \ -Wl,--wrap=db_free_result -Wl,--wrap=mysql_num_rows -Wl,--wrap=mysql_fetch_row \ -Wl,--wrap=snmp_host_init -Wl,--wrap=snmp_host_cleanup -Wl,--wrap=snmp_get_base \ -Wl,--wrap=snmp_get -Wl,--wrap=snmp_get_allow_fail -Wl,--wrap=snmp_get_multi \ @@ -257,6 +281,26 @@ tests_unit_test_log_config_SOURCES = tests/unit/test_log_config.c tests/support/ src/config/keywords.c src/app/signals.c tests_unit_test_log_config_LDADD = $(CMOCKA_LIBS) $(LIBS) +tests_unit_test_worker_set_SOURCES = tests/unit/test_worker_set.c src/platform/worker_set.c +tests_unit_test_worker_set_LDADD = $(CMOCKA_LIBS) $(LIBS) +tests_unit_test_worker_set_CPPFLAGS = $(AM_CPPFLAGS) +if HAVE_LINKER_WRAP +tests_unit_test_worker_set_CPPFLAGS += -DSPINE_WORKER_TEST_WRAP +tests_unit_test_worker_set_LDFLAGS = -Wl,--wrap=calloc -Wl,--wrap=pthread_mutex_init \ + -Wl,--wrap=pthread_mutex_destroy -Wl,--wrap=pthread_create -Wl,--wrap=pthread_join \ + -Wl,--wrap=pthread_attr_getdetachstate + +tests_unit_test_worker_set_LDFLAGS += -Wl,--wrap=pthread_mutex_lock +endif + +tests_unit_test_db_lease_SOURCES = tests/unit/test_db_lease.c src/database/lease.c +tests_unit_test_db_lease_LDADD = $(CMOCKA_LIBS) $(LIBS) +tests_unit_test_db_lease_CPPFLAGS = $(AM_CPPFLAGS) +if HAVE_LINKER_WRAP +tests_unit_test_db_lease_CPPFLAGS += -DSPINE_LEASE_TEST_WRAP +tests_unit_test_db_lease_LDFLAGS = -Wl,--wrap=calloc -Wl,--wrap=pthread_mutex_init -Wl,--wrap=pthread_mutex_destroy +endif + tests_unit_test_php_runtime_SOURCES = tests/unit/test_php_runtime.c tests/support/spine_runtime.c \ $(spine_database_sources) $(spine_support_sources) $(spine_snmp_sources) src/platform/locks.c $(spine_poller_sources) $(spine_process_sources) $(spine_php_sources) $(spine_ping_sources) \ src/config/keywords.c src/app/signals.c diff --git a/Makefile.in b/Makefile.in index f8fbff5b..e1a4ebc1 100644 --- a/Makefile.in +++ b/Makefile.in @@ -112,12 +112,16 @@ check_PROGRAMS = test_spine_regressions$(EXEEXT) $(am__EXEEXT_1) \ @HAVE_CMOCKA_TRUE@ tests/unit/test_snmpv3_session \ @HAVE_CMOCKA_TRUE@ tests/unit/test_ping_icmp \ @HAVE_CMOCKA_TRUE@ tests/unit/test_ping_timing \ -@HAVE_CMOCKA_TRUE@ tests/unit/test_log_config +@HAVE_CMOCKA_TRUE@ tests/unit/test_log_config \ +@HAVE_CMOCKA_TRUE@ tests/unit/test_worker_set \ +@HAVE_CMOCKA_TRUE@ tests/unit/test_db_lease @HAVE_CMOCKA_TRUE@noinst_PROGRAMS = \ @HAVE_CMOCKA_TRUE@ tests/support/php_test_server$(EXEEXT) @HAVE_CMOCKA_TRUE@@HAVE_LD_WRAP_TRUE@am__append_3 = tests/unit/test_php_runtime tests/unit/test_poll_host @HAVE_LD_WRAP_TRUE@am__append_4 = -DSPINE_TEST_WRAP_WAITPID +@HAVE_LINKER_WRAP_TRUE@am__append_5 = -DSPINE_WORKER_TEST_WRAP +@HAVE_LINKER_WRAP_TRUE@am__append_6 = -DSPINE_LEASE_TEST_WRAP subdir = . ACLOCAL_M4 = $(top_srcdir)/aclocal.m4 am__aclocal_m4_deps = $(top_srcdir)/m4/libtool.m4 \ @@ -148,13 +152,16 @@ am__installdirs = "$(DESTDIR)$(bindir)" "$(DESTDIR)$(man1dir)" \ @HAVE_CMOCKA_TRUE@ tests/unit/test_snmpv3_session$(EXEEXT) \ @HAVE_CMOCKA_TRUE@ tests/unit/test_ping_icmp$(EXEEXT) \ @HAVE_CMOCKA_TRUE@ tests/unit/test_ping_timing$(EXEEXT) \ -@HAVE_CMOCKA_TRUE@ tests/unit/test_log_config$(EXEEXT) +@HAVE_CMOCKA_TRUE@ tests/unit/test_log_config$(EXEEXT) \ +@HAVE_CMOCKA_TRUE@ tests/unit/test_worker_set$(EXEEXT) \ +@HAVE_CMOCKA_TRUE@ tests/unit/test_db_lease$(EXEEXT) @HAVE_CMOCKA_TRUE@@HAVE_LD_WRAP_TRUE@am__EXEEXT_3 = tests/unit/test_php_runtime$(EXEEXT) \ @HAVE_CMOCKA_TRUE@@HAVE_LD_WRAP_TRUE@ tests/unit/test_poll_host$(EXEEXT) PROGRAMS = $(bin_PROGRAMS) $(noinst_PROGRAMS) am__dirstamp = $(am__leading_dot)dirstamp am__objects_1 = src/database/sql.$(OBJEXT) \ - src/database/connection.$(OBJEXT) src/database/pool.$(OBJEXT) \ + src/database/connection.$(OBJEXT) src/database/tls.$(OBJEXT) \ + src/database/lease.$(OBJEXT) src/database/pool.$(OBJEXT) \ src/database/transfer.$(OBJEXT) \ src/database/cacti_query.$(OBJEXT) \ src/database/persistence.$(OBJEXT) @@ -185,9 +192,9 @@ am__objects_8 = $(am__objects_1) $(am__objects_2) $(am__objects_3) \ src/platform/locks.$(OBJEXT) $(am__objects_4) $(am__objects_5) \ $(am__objects_6) $(am__objects_7) \ src/config/keywords.$(OBJEXT) src/app/signals.$(OBJEXT) -am__objects_9 = $(am__objects_8) src/app/runtime.$(OBJEXT) \ - src/app/cli.$(OBJEXT) src/app/startup.$(OBJEXT) \ - src/poller/workers.$(OBJEXT) +am__objects_9 = $(am__objects_8) src/platform/worker_set.$(OBJEXT) \ + src/app/runtime.$(OBJEXT) src/app/cli.$(OBJEXT) \ + src/app/startup.$(OBJEXT) src/poller/workers.$(OBJEXT) am_spine_OBJECTS = $(am__objects_9) src/app/main.$(OBJEXT) spine_OBJECTS = $(am_spine_OBJECTS) spine_LDADD = $(LDADD) @@ -196,7 +203,8 @@ am__v_lt_ = $(am__v_lt_@AM_DEFAULT_V@) am__v_lt_0 = --silent am__v_lt_1 = am__test_spine_faults_SOURCES_DIST = src/database/sql.c \ - src/database/connection.c src/database/pool.c \ + src/database/connection.c src/database/tls.c \ + src/database/lease.c src/database/pool.c \ src/database/transfer.c src/database/cacti_query.c \ src/database/persistence.c src/app/buffer.c \ src/platform/clock.c src/platform/socket.c \ @@ -213,8 +221,9 @@ am__test_spine_faults_SOURCES_DIST = src/database/sql.c \ src/script/server.c src/poller/availability.c \ src/ping/icmp_shared.c src/ping/icmp4.c src/ping/icmp6.c \ src/ping/udp.c src/ping/tcp.c src/ping/address.c \ - src/config/keywords.c src/app/signals.c src/app/runtime.c \ - src/app/cli.c src/app/startup.c src/poller/workers.c \ + src/config/keywords.c src/app/signals.c \ + src/platform/worker_set.c src/app/runtime.c src/app/cli.c \ + src/app/startup.c src/poller/workers.c \ tests/regression/test_spine_faults.c @HAVE_LINKER_WRAP_TRUE@am_test_spine_faults_OBJECTS = \ @HAVE_LINKER_WRAP_TRUE@ $(am__objects_9) \ @@ -234,7 +243,9 @@ am_test_spine_regressions_OBJECTS = $(am__objects_9) \ tests/contracts/test_spine_cli_contracts.$(OBJEXT) \ tests/contracts/test_spine_stream_contracts.$(OBJEXT) \ tests/contracts/test_spine_output_contracts.$(OBJEXT) \ - tests/contracts/test_spine_privilege_contracts.$(OBJEXT) + tests/contracts/test_spine_privilege_contracts.$(OBJEXT) \ + tests/contracts/test_spine_tls_contracts.$(OBJEXT) \ + tests/contracts/test_spine_script_limits_contracts.$(OBJEXT) test_spine_regressions_OBJECTS = $(am_test_spine_regressions_OBJECTS) test_spine_regressions_LDADD = $(LDADD) am__tests_support_php_test_server_SOURCES_DIST = \ @@ -261,8 +272,21 @@ tests_unit_test_classify_result_OBJECTS = \ $(am_tests_unit_test_classify_result_OBJECTS) tests_unit_test_classify_result_DEPENDENCIES = $(am__DEPENDENCIES_1) \ $(am__DEPENDENCIES_1) +am_tests_unit_test_db_lease_OBJECTS = \ + tests/unit/test_db_lease-test_db_lease.$(OBJEXT) \ + src/database/tests_unit_test_db_lease-lease.$(OBJEXT) +tests_unit_test_db_lease_OBJECTS = \ + $(am_tests_unit_test_db_lease_OBJECTS) +tests_unit_test_db_lease_DEPENDENCIES = $(am__DEPENDENCIES_1) \ + $(am__DEPENDENCIES_1) +tests_unit_test_db_lease_LINK = $(LIBTOOL) $(AM_V_lt) --tag=CC \ + $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=link $(CCLD) \ + $(AM_CFLAGS) $(CFLAGS) $(tests_unit_test_db_lease_LDFLAGS) \ + $(LDFLAGS) -o $@ am__objects_10 = src/database/tests_unit_test_linked-sql.$(OBJEXT) \ src/database/tests_unit_test_linked-connection.$(OBJEXT) \ + src/database/tests_unit_test_linked-tls.$(OBJEXT) \ + src/database/tests_unit_test_linked-lease.$(OBJEXT) \ src/database/tests_unit_test_linked-pool.$(OBJEXT) \ src/database/tests_unit_test_linked-transfer.$(OBJEXT) \ src/database/tests_unit_test_linked-cacti_query.$(OBJEXT) \ @@ -338,6 +362,8 @@ tests_unit_test_log_config_DEPENDENCIES = $(am__DEPENDENCIES_1) \ am__objects_17 = \ src/database/tests_unit_test_php_runtime-sql.$(OBJEXT) \ src/database/tests_unit_test_php_runtime-connection.$(OBJEXT) \ + src/database/tests_unit_test_php_runtime-tls.$(OBJEXT) \ + src/database/tests_unit_test_php_runtime-lease.$(OBJEXT) \ src/database/tests_unit_test_php_runtime-pool.$(OBJEXT) \ src/database/tests_unit_test_php_runtime-transfer.$(OBJEXT) \ src/database/tests_unit_test_php_runtime-cacti_query.$(OBJEXT) \ @@ -463,6 +489,17 @@ tests_unit_test_util_strings_OBJECTS = \ $(am_tests_unit_test_util_strings_OBJECTS) tests_unit_test_util_strings_DEPENDENCIES = $(am__DEPENDENCIES_1) \ $(am__DEPENDENCIES_1) +am_tests_unit_test_worker_set_OBJECTS = \ + tests/unit/test_worker_set-test_worker_set.$(OBJEXT) \ + src/platform/tests_unit_test_worker_set-worker_set.$(OBJEXT) +tests_unit_test_worker_set_OBJECTS = \ + $(am_tests_unit_test_worker_set_OBJECTS) +tests_unit_test_worker_set_DEPENDENCIES = $(am__DEPENDENCIES_1) \ + $(am__DEPENDENCIES_1) +tests_unit_test_worker_set_LINK = $(LIBTOOL) $(AM_V_lt) --tag=CC \ + $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=link $(CCLD) \ + $(AM_CFLAGS) $(CFLAGS) $(tests_unit_test_worker_set_LDFLAGS) \ + $(LDFLAGS) -o $@ AM_V_P = $(am__v_P_@AM_V@) am__v_P_ = $(am__v_P_@AM_DEFAULT_V@) am__v_P_0 = false @@ -504,20 +541,27 @@ am__depfiles_remade = src/app/$(DEPDIR)/buffer.Po \ src/config/$(DEPDIR)/text.Po src/config/$(DEPDIR)/validate.Po \ src/database/$(DEPDIR)/cacti_query.Po \ src/database/$(DEPDIR)/connection.Po \ + src/database/$(DEPDIR)/lease.Po \ src/database/$(DEPDIR)/persistence.Po \ src/database/$(DEPDIR)/pool.Po src/database/$(DEPDIR)/sql.Po \ + src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Po \ src/database/$(DEPDIR)/tests_unit_test_linked-cacti_query.Po \ src/database/$(DEPDIR)/tests_unit_test_linked-connection.Po \ + src/database/$(DEPDIR)/tests_unit_test_linked-lease.Po \ src/database/$(DEPDIR)/tests_unit_test_linked-persistence.Po \ src/database/$(DEPDIR)/tests_unit_test_linked-pool.Po \ src/database/$(DEPDIR)/tests_unit_test_linked-sql.Po \ + src/database/$(DEPDIR)/tests_unit_test_linked-tls.Po \ src/database/$(DEPDIR)/tests_unit_test_linked-transfer.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-cacti_query.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-connection.Po \ + src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-persistence.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-pool.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-sql.Po \ + src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Po \ src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po \ + src/database/$(DEPDIR)/tls.Po \ src/database/$(DEPDIR)/transfer.Po src/log/$(DEPDIR)/debug.Po \ src/log/$(DEPDIR)/json.Po src/log/$(DEPDIR)/log.Po \ src/log/$(DEPDIR)/sanitize.Po \ @@ -561,7 +605,9 @@ am__depfiles_remade = src/app/$(DEPDIR)/buffer.Po \ src/platform/$(DEPDIR)/tests_unit_test_php_runtime-privileges.Po \ src/platform/$(DEPDIR)/tests_unit_test_php_runtime-socket.Po \ src/platform/$(DEPDIR)/tests_unit_test_php_runtime-thread.Po \ + src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Po \ src/platform/$(DEPDIR)/thread.Po \ + src/platform/$(DEPDIR)/worker_set.Po \ src/poller/$(DEPDIR)/availability.Po \ src/poller/$(DEPDIR)/host.Po src/poller/$(DEPDIR)/items.Po \ src/poller/$(DEPDIR)/poller.Po src/poller/$(DEPDIR)/regex.Po \ @@ -616,9 +662,11 @@ am__depfiles_remade = src/app/$(DEPDIR)/buffer.Po \ tests/contracts/$(DEPDIR)/test_spine_output_contracts.Po \ tests/contracts/$(DEPDIR)/test_spine_privilege_contracts.Po \ tests/contracts/$(DEPDIR)/test_spine_reindex_contracts.Po \ + tests/contracts/$(DEPDIR)/test_spine_script_limits_contracts.Po \ tests/contracts/$(DEPDIR)/test_spine_settings_contracts.Po \ tests/contracts/$(DEPDIR)/test_spine_snmpv3_contracts.Po \ tests/contracts/$(DEPDIR)/test_spine_stream_contracts.Po \ + tests/contracts/$(DEPDIR)/test_spine_tls_contracts.Po \ tests/regression/$(DEPDIR)/test_spine_faults.Po \ tests/regression/$(DEPDIR)/test_spine_regressions.Po \ tests/support/$(DEPDIR)/php_test_server.Po \ @@ -627,6 +675,7 @@ am__depfiles_remade = src/app/$(DEPDIR)/buffer.Po \ tests/support/$(DEPDIR)/unit_test_php_runtime-spine_runtime.Po \ tests/unit/$(DEPDIR)/test_build_fixes.Po \ tests/unit/$(DEPDIR)/test_classify_result.Po \ + tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Po \ tests/unit/$(DEPDIR)/test_linked-test_linked.Po \ tests/unit/$(DEPDIR)/test_log_config.Po \ tests/unit/$(DEPDIR)/test_php_runtime-test_php_runtime.Po \ @@ -636,7 +685,8 @@ am__depfiles_remade = src/app/$(DEPDIR)/buffer.Po \ tests/unit/$(DEPDIR)/test_safety_fixes.Po \ tests/unit/$(DEPDIR)/test_snmpv3_session.Po \ tests/unit/$(DEPDIR)/test_util_paths.Po \ - tests/unit/$(DEPDIR)/test_util_strings.Po + tests/unit/$(DEPDIR)/test_util_strings.Po \ + tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Po am__mv = mv -f COMPILE = $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) \ $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) @@ -661,6 +711,7 @@ SOURCES = $(spine_SOURCES) $(test_spine_faults_SOURCES) \ $(tests_support_php_test_server_SOURCES) \ $(tests_unit_test_build_fixes_SOURCES) \ $(tests_unit_test_classify_result_SOURCES) \ + $(tests_unit_test_db_lease_SOURCES) \ $(tests_unit_test_linked_SOURCES) \ $(tests_unit_test_log_config_SOURCES) \ $(tests_unit_test_php_runtime_SOURCES) \ @@ -670,12 +721,14 @@ SOURCES = $(spine_SOURCES) $(test_spine_faults_SOURCES) \ $(tests_unit_test_safety_fixes_SOURCES) \ $(tests_unit_test_snmpv3_session_SOURCES) \ $(tests_unit_test_util_paths_SOURCES) \ - $(tests_unit_test_util_strings_SOURCES) + $(tests_unit_test_util_strings_SOURCES) \ + $(tests_unit_test_worker_set_SOURCES) DIST_SOURCES = $(spine_SOURCES) $(am__test_spine_faults_SOURCES_DIST) \ $(test_spine_regressions_SOURCES) \ $(am__tests_support_php_test_server_SOURCES_DIST) \ $(tests_unit_test_build_fixes_SOURCES) \ $(tests_unit_test_classify_result_SOURCES) \ + $(tests_unit_test_db_lease_SOURCES) \ $(tests_unit_test_linked_SOURCES) \ $(tests_unit_test_log_config_SOURCES) \ $(tests_unit_test_php_runtime_SOURCES) \ @@ -685,7 +738,8 @@ DIST_SOURCES = $(spine_SOURCES) $(am__test_spine_faults_SOURCES_DIST) \ $(tests_unit_test_safety_fixes_SOURCES) \ $(tests_unit_test_snmpv3_session_SOURCES) \ $(tests_unit_test_util_paths_SOURCES) \ - $(tests_unit_test_util_strings_SOURCES) + $(tests_unit_test_util_strings_SOURCES) \ + $(tests_unit_test_worker_set_SOURCES) am__can_run_installinfo = \ case $$AM_UPDATE_INFO_DIR in \ n|no|NO) false;; \ @@ -1085,7 +1139,7 @@ spine_support_sources = src/app/buffer.c \ src/platform/privileges.c src/poller/regex.c spine_database_sources = src/database/sql.c \ - src/database/connection.c \ + src/database/connection.c src/database/tls.c src/database/lease.c \ src/database/pool.c src/database/transfer.c \ src/database/cacti_query.c src/database/persistence.c @@ -1107,7 +1161,7 @@ spine_process_sources = src/process/nft_popen.c src/process/fd.c spine_shared_sources = $(spine_database_sources) $(spine_support_sources) $(spine_snmp_sources) src/platform/locks.c $(spine_poller_sources) \ $(spine_process_sources) $(spine_php_sources) $(spine_ping_sources) src/config/keywords.c src/app/signals.c -spine_runtime_sources = $(spine_shared_sources) src/app/runtime.c src/app/cli.c src/app/startup.c src/poller/workers.c +spine_runtime_sources = $(spine_shared_sources) src/platform/worker_set.c src/app/runtime.c src/app/cli.c src/app/startup.c src/poller/workers.c spine_SOURCES = $(spine_runtime_sources) src/app/main.c configdir = $(sysconfdir) dist_config_DATA = etc/spine.conf.dist @@ -1121,9 +1175,31 @@ TESTS = $(check_PROGRAMS) tests/tools/test_nightly_tests.sh \ tests/regression/test_helper_scripts.sh EXTRA_DIST = tests/tools/check_nightly_tests.sh \ tests/tools/test_nightly_tests.sh \ - tests/tools/test_json_encoder.py docs/man/spine.1 \ - etc/spine.conf.dist CONTRIBUTORS.md docs/licensing.md \ - LICENSES/LicenseRef-Xenadyne.txt \ + tests/tools/test_json_encoder.py \ + tests/tools/run_database_tls.sh \ + tests/tools/ownership-coverage-sources.txt \ + tests/tools/build_benchmark_pair.sh \ + tests/tools/benchmark_builds.py \ + tests/tools/verify_benchmark_record.py \ + tests/tools/test_benchmark_record.py docs/benchmarks/README.md \ + docs/benchmarks/results/2026-10-11-default-logging/README.md \ + docs/benchmarks/results/2026-10-11-default-logging/cycles.csv \ + docs/benchmarks/results/2026-10-11-default-logging/metadata.json \ + docs/benchmarks/results/2026-10-11-default-logging/summary.json \ + docs/benchmarks/results/2026-10-11-default-logging/compiler.txt \ + docs/benchmarks/results/2026-10-11-default-logging/dependencies.txt \ + docs/benchmarks/results/2026-10-11-default-logging/builds.sha256 \ + docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt \ + docs/benchmarks/results/2026-10-11-default-logging/revisions.txt \ + docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt \ + tests/tools/run_script_limits.sh \ + tests/fixtures/script_resource_probe.c \ + docs/operations/script-limits.md \ + docs/operations/database-tls.md \ + docs/operations/issue-boundaries.md \ + docs/adr/0004-formatting-and-json-dependencies.md \ + docs/man/spine.1 etc/spine.conf.dist CONTRIBUTORS.md \ + docs/licensing.md LICENSES/LicenseRef-Xenadyne.txt \ LICENSES/LicenseRef-Xenadyne-Header.txt \ LICENSES/GPL-2.0-or-later.txt LICENSES/LGPL-2.1-or-later.txt \ LICENSES/LGPL-2.1-only.txt tests/README.md \ @@ -1147,7 +1223,18 @@ EXTRA_DIST = tests/tools/check_nightly_tests.sh \ docs/architecture/source-modules.md \ tests/tools/test_configure_contracts.sh \ tests/tools/test_clang_tidy_gate.py scripts/cppcheck/spine.cfg -test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_regressions.c tests/contracts/test_spine_contracts.c tests/contracts/test_spine_reindex_contracts.c tests/contracts/test_spine_snmpv3_contracts.c tests/contracts/test_spine_settings_contracts.c tests/contracts/test_spine_cli_contracts.c tests/contracts/test_spine_stream_contracts.c tests/contracts/test_spine_output_contracts.c tests/contracts/test_spine_privilege_contracts.c +test_spine_regressions_SOURCES = $(spine_runtime_sources) \ + tests/regression/test_spine_regressions.c \ + tests/contracts/test_spine_contracts.c \ + tests/contracts/test_spine_reindex_contracts.c \ + tests/contracts/test_spine_snmpv3_contracts.c \ + tests/contracts/test_spine_settings_contracts.c \ + tests/contracts/test_spine_cli_contracts.c \ + tests/contracts/test_spine_stream_contracts.c \ + tests/contracts/test_spine_output_contracts.c \ + tests/contracts/test_spine_privilege_contracts.c \ + tests/contracts/test_spine_tls_contracts.c \ + tests/contracts/test_spine_script_limits_contracts.c # Link the same runtime implementation with a separate fault-test entry point. @HAVE_LINKER_WRAP_TRUE@test_spine_faults_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_faults.c @HAVE_LINKER_WRAP_TRUE@test_spine_faults_LDFLAGS = $(AM_LDFLAGS) -Wl,--wrap=write -Wl,--wrap=strftime -Wl,--wrap=mysql_query -Wl,--wrap=mysql_ping -Wl,--wrap=mysql_errno \ @@ -1164,16 +1251,17 @@ test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_ # them unless they are listed. Without this the release tarball builds nothing. noinst_HEADERS = src/internal/common.h src/app/signals.h \ src/config/keywords.h src/platform/locks.h \ - src/process/nft_popen.h src/script/server.h src/ping/ping.h \ - src/poller/poller.h src/snmp/snmp.h src/app/spine.h \ - src/platform/spine_sem.h src/internal/spine_types.h \ - src/database/sql.h vendor/uthash/uthash.h src/internal/util.h \ - src/config/settings_internal.h src/ping/ping_internal.h \ - src/app/cli_internal.h src/poller/poller_internal.h \ - src/app/startup_internal.h src/script/server_internal.h \ - src/process/process_internal.h src/app/buffer.h \ - src/app/runtime.h src/config/model.h src/config/state.h \ - src/config/text.h src/config/validate.h \ + src/platform/worker_set.h src/process/nft_popen.h \ + src/script/server.h src/ping/ping.h src/poller/poller.h \ + src/snmp/snmp.h src/app/spine.h src/platform/spine_sem.h \ + src/internal/spine_types.h src/database/sql.h \ + vendor/uthash/uthash.h src/internal/util.h \ + src/database/lease.h src/config/settings_internal.h \ + src/ping/ping_internal.h src/app/cli_internal.h \ + src/poller/poller_internal.h src/app/startup_internal.h \ + src/script/server_internal.h src/process/process_internal.h \ + src/app/buffer.h src/app/runtime.h src/config/model.h \ + src/config/state.h src/config/text.h src/config/validate.h \ src/internal/constants.h src/log/debug.h src/log/log.h \ src/log/sanitize.h src/platform/clock.h \ src/platform/descriptor.h src/platform/socket.h \ @@ -1243,7 +1331,7 @@ tests_unit_test_poll_host_SOURCES = tests/unit/test_poll_host.c tests/support/sp tests_unit_test_poll_host_LDADD = $(CMOCKA_LIBS) $(LIBS) tests_unit_test_poll_host_LDFLAGS = \ -Wl,--wrap=db_query -Wl,--wrap=db_insert -Wl,--wrap=db_escape \ - -Wl,--wrap=db_get_connection -Wl,--wrap=db_release_connection \ + -Wl,--wrap=db_acquire_lease -Wl,--wrap=db_release_lease \ -Wl,--wrap=db_free_result -Wl,--wrap=mysql_num_rows -Wl,--wrap=mysql_fetch_row \ -Wl,--wrap=snmp_host_init -Wl,--wrap=snmp_host_cleanup -Wl,--wrap=snmp_get_base \ -Wl,--wrap=snmp_get -Wl,--wrap=snmp_get_allow_fail -Wl,--wrap=snmp_get_multi \ @@ -1261,6 +1349,21 @@ tests_unit_test_log_config_SOURCES = tests/unit/test_log_config.c tests/support/ src/config/keywords.c src/app/signals.c tests_unit_test_log_config_LDADD = $(CMOCKA_LIBS) $(LIBS) +tests_unit_test_worker_set_SOURCES = tests/unit/test_worker_set.c src/platform/worker_set.c +tests_unit_test_worker_set_LDADD = $(CMOCKA_LIBS) $(LIBS) +tests_unit_test_worker_set_CPPFLAGS = $(AM_CPPFLAGS) $(am__append_5) +@HAVE_LINKER_WRAP_TRUE@tests_unit_test_worker_set_LDFLAGS = \ +@HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=calloc \ +@HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=pthread_mutex_init \ +@HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=pthread_mutex_destroy \ +@HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=pthread_create \ +@HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=pthread_join \ +@HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=pthread_attr_getdetachstate \ +@HAVE_LINKER_WRAP_TRUE@ -Wl,--wrap=pthread_mutex_lock +tests_unit_test_db_lease_SOURCES = tests/unit/test_db_lease.c src/database/lease.c +tests_unit_test_db_lease_LDADD = $(CMOCKA_LIBS) $(LIBS) +tests_unit_test_db_lease_CPPFLAGS = $(AM_CPPFLAGS) $(am__append_6) +@HAVE_LINKER_WRAP_TRUE@tests_unit_test_db_lease_LDFLAGS = -Wl,--wrap=calloc -Wl,--wrap=pthread_mutex_init -Wl,--wrap=pthread_mutex_destroy tests_unit_test_php_runtime_SOURCES = tests/unit/test_php_runtime.c tests/support/spine_runtime.c \ $(spine_database_sources) $(spine_support_sources) $(spine_snmp_sources) src/platform/locks.c $(spine_poller_sources) $(spine_process_sources) $(spine_php_sources) $(spine_ping_sources) \ src/config/keywords.c src/app/signals.c @@ -1408,6 +1511,10 @@ src/database/sql.$(OBJEXT): src/database/$(am__dirstamp) \ src/database/$(DEPDIR)/$(am__dirstamp) src/database/connection.$(OBJEXT): src/database/$(am__dirstamp) \ src/database/$(DEPDIR)/$(am__dirstamp) +src/database/tls.$(OBJEXT): src/database/$(am__dirstamp) \ + src/database/$(DEPDIR)/$(am__dirstamp) +src/database/lease.$(OBJEXT): src/database/$(am__dirstamp) \ + src/database/$(DEPDIR)/$(am__dirstamp) src/database/pool.$(OBJEXT): src/database/$(am__dirstamp) \ src/database/$(DEPDIR)/$(am__dirstamp) src/database/transfer.$(OBJEXT): src/database/$(am__dirstamp) \ @@ -1554,6 +1661,8 @@ src/config/keywords.$(OBJEXT): src/config/$(am__dirstamp) \ src/config/$(DEPDIR)/$(am__dirstamp) src/app/signals.$(OBJEXT): src/app/$(am__dirstamp) \ src/app/$(DEPDIR)/$(am__dirstamp) +src/platform/worker_set.$(OBJEXT): src/platform/$(am__dirstamp) \ + src/platform/$(DEPDIR)/$(am__dirstamp) src/app/runtime.$(OBJEXT): src/app/$(am__dirstamp) \ src/app/$(DEPDIR)/$(am__dirstamp) src/app/cli.$(OBJEXT): src/app/$(am__dirstamp) \ @@ -1614,6 +1723,12 @@ tests/contracts/test_spine_output_contracts.$(OBJEXT): \ tests/contracts/test_spine_privilege_contracts.$(OBJEXT): \ tests/contracts/$(am__dirstamp) \ tests/contracts/$(DEPDIR)/$(am__dirstamp) +tests/contracts/test_spine_tls_contracts.$(OBJEXT): \ + tests/contracts/$(am__dirstamp) \ + tests/contracts/$(DEPDIR)/$(am__dirstamp) +tests/contracts/test_spine_script_limits_contracts.$(OBJEXT): \ + tests/contracts/$(am__dirstamp) \ + tests/contracts/$(DEPDIR)/$(am__dirstamp) test_spine_regressions$(EXEEXT): $(test_spine_regressions_OBJECTS) $(test_spine_regressions_DEPENDENCIES) $(EXTRA_test_spine_regressions_DEPENDENCIES) @rm -f test_spine_regressions$(EXEEXT) @@ -1651,6 +1766,16 @@ tests/support/spine_runtime.$(OBJEXT): tests/support/$(am__dirstamp) \ tests/unit/test_classify_result$(EXEEXT): $(tests_unit_test_classify_result_OBJECTS) $(tests_unit_test_classify_result_DEPENDENCIES) $(EXTRA_tests_unit_test_classify_result_DEPENDENCIES) tests/unit/$(am__dirstamp) @rm -f tests/unit/test_classify_result$(EXEEXT) $(AM_V_CCLD)$(LINK) $(tests_unit_test_classify_result_OBJECTS) $(tests_unit_test_classify_result_LDADD) $(LIBS) +tests/unit/test_db_lease-test_db_lease.$(OBJEXT): \ + tests/unit/$(am__dirstamp) \ + tests/unit/$(DEPDIR)/$(am__dirstamp) +src/database/tests_unit_test_db_lease-lease.$(OBJEXT): \ + src/database/$(am__dirstamp) \ + src/database/$(DEPDIR)/$(am__dirstamp) + +tests/unit/test_db_lease$(EXEEXT): $(tests_unit_test_db_lease_OBJECTS) $(tests_unit_test_db_lease_DEPENDENCIES) $(EXTRA_tests_unit_test_db_lease_DEPENDENCIES) tests/unit/$(am__dirstamp) + @rm -f tests/unit/test_db_lease$(EXEEXT) + $(AM_V_CCLD)$(tests_unit_test_db_lease_LINK) $(tests_unit_test_db_lease_OBJECTS) $(tests_unit_test_db_lease_LDADD) $(LIBS) tests/unit/test_linked-test_linked.$(OBJEXT): \ tests/unit/$(am__dirstamp) \ tests/unit/$(DEPDIR)/$(am__dirstamp) @@ -1663,6 +1788,12 @@ src/database/tests_unit_test_linked-sql.$(OBJEXT): \ src/database/tests_unit_test_linked-connection.$(OBJEXT): \ src/database/$(am__dirstamp) \ src/database/$(DEPDIR)/$(am__dirstamp) +src/database/tests_unit_test_linked-tls.$(OBJEXT): \ + src/database/$(am__dirstamp) \ + src/database/$(DEPDIR)/$(am__dirstamp) +src/database/tests_unit_test_linked-lease.$(OBJEXT): \ + src/database/$(am__dirstamp) \ + src/database/$(DEPDIR)/$(am__dirstamp) src/database/tests_unit_test_linked-pool.$(OBJEXT): \ src/database/$(am__dirstamp) \ src/database/$(DEPDIR)/$(am__dirstamp) @@ -1808,6 +1939,12 @@ src/database/tests_unit_test_php_runtime-sql.$(OBJEXT): \ src/database/tests_unit_test_php_runtime-connection.$(OBJEXT): \ src/database/$(am__dirstamp) \ src/database/$(DEPDIR)/$(am__dirstamp) +src/database/tests_unit_test_php_runtime-tls.$(OBJEXT): \ + src/database/$(am__dirstamp) \ + src/database/$(DEPDIR)/$(am__dirstamp) +src/database/tests_unit_test_php_runtime-lease.$(OBJEXT): \ + src/database/$(am__dirstamp) \ + src/database/$(DEPDIR)/$(am__dirstamp) src/database/tests_unit_test_php_runtime-pool.$(OBJEXT): \ src/database/$(am__dirstamp) \ src/database/$(DEPDIR)/$(am__dirstamp) @@ -1977,6 +2114,16 @@ tests/unit/test_util_strings.$(OBJEXT): tests/unit/$(am__dirstamp) \ tests/unit/test_util_strings$(EXEEXT): $(tests_unit_test_util_strings_OBJECTS) $(tests_unit_test_util_strings_DEPENDENCIES) $(EXTRA_tests_unit_test_util_strings_DEPENDENCIES) tests/unit/$(am__dirstamp) @rm -f tests/unit/test_util_strings$(EXEEXT) $(AM_V_CCLD)$(LINK) $(tests_unit_test_util_strings_OBJECTS) $(tests_unit_test_util_strings_LDADD) $(LIBS) +tests/unit/test_worker_set-test_worker_set.$(OBJEXT): \ + tests/unit/$(am__dirstamp) \ + tests/unit/$(DEPDIR)/$(am__dirstamp) +src/platform/tests_unit_test_worker_set-worker_set.$(OBJEXT): \ + src/platform/$(am__dirstamp) \ + src/platform/$(DEPDIR)/$(am__dirstamp) + +tests/unit/test_worker_set$(EXEEXT): $(tests_unit_test_worker_set_OBJECTS) $(tests_unit_test_worker_set_DEPENDENCIES) $(EXTRA_tests_unit_test_worker_set_DEPENDENCIES) tests/unit/$(am__dirstamp) + @rm -f tests/unit/test_worker_set$(EXEEXT) + $(AM_V_CCLD)$(tests_unit_test_worker_set_LINK) $(tests_unit_test_worker_set_OBJECTS) $(tests_unit_test_worker_set_LDADD) $(LIBS) mostlyclean-compile: -rm -f *.$(OBJEXT) @@ -2028,21 +2175,28 @@ distclean-compile: @AMDEP_TRUE@@am__include@ @am__quote@src/config/$(DEPDIR)/validate.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/cacti_query.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/connection.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/lease.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/persistence.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/pool.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/sql.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-cacti_query.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-connection.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-lease.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-persistence.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-pool.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-sql.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-tls.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_linked-transfer.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-cacti_query.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-connection.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-persistence.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-pool.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-sql.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/tls.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/database/$(DEPDIR)/transfer.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/debug.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/log/$(DEPDIR)/json.Po@am__quote@ # am--include-marker @@ -2091,7 +2245,9 @@ distclean-compile: @AMDEP_TRUE@@am__include@ @am__quote@src/platform/$(DEPDIR)/tests_unit_test_php_runtime-privileges.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/platform/$(DEPDIR)/tests_unit_test_php_runtime-socket.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/platform/$(DEPDIR)/tests_unit_test_php_runtime-thread.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/platform/$(DEPDIR)/thread.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@src/platform/$(DEPDIR)/worker_set.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/poller/$(DEPDIR)/availability.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/poller/$(DEPDIR)/host.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@src/poller/$(DEPDIR)/items.Po@am__quote@ # am--include-marker @@ -2152,9 +2308,11 @@ distclean-compile: @AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_output_contracts.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_privilege_contracts.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_reindex_contracts.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_script_limits_contracts.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_settings_contracts.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_snmpv3_contracts.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_stream_contracts.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@tests/contracts/$(DEPDIR)/test_spine_tls_contracts.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/regression/$(DEPDIR)/test_spine_faults.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/regression/$(DEPDIR)/test_spine_regressions.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/support/$(DEPDIR)/php_test_server.Po@am__quote@ # am--include-marker @@ -2163,6 +2321,7 @@ distclean-compile: @AMDEP_TRUE@@am__include@ @am__quote@tests/support/$(DEPDIR)/unit_test_php_runtime-spine_runtime.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_build_fixes.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_classify_result.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_linked-test_linked.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_log_config.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_php_runtime-test_php_runtime.Po@am__quote@ # am--include-marker @@ -2173,6 +2332,7 @@ distclean-compile: @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_snmpv3_session.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_util_paths.Po@am__quote@ # am--include-marker @AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_util_strings.Po@am__quote@ # am--include-marker +@AMDEP_TRUE@@am__include@ @am__quote@tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Po@am__quote@ # am--include-marker $(am__depfiles_remade): @$(MKDIR_P) $(@D) @@ -2204,6 +2364,34 @@ am--depfiles: $(am__depfiles_remade) @AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ @am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LTCOMPILE) -c -o $@ $< +tests/unit/test_db_lease-test_db_lease.o: tests/unit/test_db_lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT tests/unit/test_db_lease-test_db_lease.o -MD -MP -MF tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Tpo -c -o tests/unit/test_db_lease-test_db_lease.o `test -f 'tests/unit/test_db_lease.c' || echo '$(srcdir)/'`tests/unit/test_db_lease.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Tpo tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='tests/unit/test_db_lease.c' object='tests/unit/test_db_lease-test_db_lease.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o tests/unit/test_db_lease-test_db_lease.o `test -f 'tests/unit/test_db_lease.c' || echo '$(srcdir)/'`tests/unit/test_db_lease.c + +tests/unit/test_db_lease-test_db_lease.obj: tests/unit/test_db_lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT tests/unit/test_db_lease-test_db_lease.obj -MD -MP -MF tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Tpo -c -o tests/unit/test_db_lease-test_db_lease.obj `if test -f 'tests/unit/test_db_lease.c'; then $(CYGPATH_W) 'tests/unit/test_db_lease.c'; else $(CYGPATH_W) '$(srcdir)/tests/unit/test_db_lease.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Tpo tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='tests/unit/test_db_lease.c' object='tests/unit/test_db_lease-test_db_lease.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o tests/unit/test_db_lease-test_db_lease.obj `if test -f 'tests/unit/test_db_lease.c'; then $(CYGPATH_W) 'tests/unit/test_db_lease.c'; else $(CYGPATH_W) '$(srcdir)/tests/unit/test_db_lease.c'; fi` + +src/database/tests_unit_test_db_lease-lease.o: src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_db_lease-lease.o -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Tpo -c -o src/database/tests_unit_test_db_lease-lease.o `test -f 'src/database/lease.c' || echo '$(srcdir)/'`src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Tpo src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/lease.c' object='src/database/tests_unit_test_db_lease-lease.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_db_lease-lease.o `test -f 'src/database/lease.c' || echo '$(srcdir)/'`src/database/lease.c + +src/database/tests_unit_test_db_lease-lease.obj: src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_db_lease-lease.obj -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Tpo -c -o src/database/tests_unit_test_db_lease-lease.obj `if test -f 'src/database/lease.c'; then $(CYGPATH_W) 'src/database/lease.c'; else $(CYGPATH_W) '$(srcdir)/src/database/lease.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Tpo src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/lease.c' object='src/database/tests_unit_test_db_lease-lease.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_db_lease_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_db_lease-lease.obj `if test -f 'src/database/lease.c'; then $(CYGPATH_W) 'src/database/lease.c'; else $(CYGPATH_W) '$(srcdir)/src/database/lease.c'; fi` + tests/unit/test_linked-test_linked.o: tests/unit/test_linked.c @am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT tests/unit/test_linked-test_linked.o -MD -MP -MF tests/unit/$(DEPDIR)/test_linked-test_linked.Tpo -c -o tests/unit/test_linked-test_linked.o `test -f 'tests/unit/test_linked.c' || echo '$(srcdir)/'`tests/unit/test_linked.c @am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) tests/unit/$(DEPDIR)/test_linked-test_linked.Tpo tests/unit/$(DEPDIR)/test_linked-test_linked.Po @@ -2260,6 +2448,34 @@ src/database/tests_unit_test_linked-connection.obj: src/database/connection.c @AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ @am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_linked-connection.obj `if test -f 'src/database/connection.c'; then $(CYGPATH_W) 'src/database/connection.c'; else $(CYGPATH_W) '$(srcdir)/src/database/connection.c'; fi` +src/database/tests_unit_test_linked-tls.o: src/database/tls.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_linked-tls.o -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_linked-tls.Tpo -c -o src/database/tests_unit_test_linked-tls.o `test -f 'src/database/tls.c' || echo '$(srcdir)/'`src/database/tls.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_linked-tls.Tpo src/database/$(DEPDIR)/tests_unit_test_linked-tls.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/tls.c' object='src/database/tests_unit_test_linked-tls.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_linked-tls.o `test -f 'src/database/tls.c' || echo '$(srcdir)/'`src/database/tls.c + +src/database/tests_unit_test_linked-tls.obj: src/database/tls.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_linked-tls.obj -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_linked-tls.Tpo -c -o src/database/tests_unit_test_linked-tls.obj `if test -f 'src/database/tls.c'; then $(CYGPATH_W) 'src/database/tls.c'; else $(CYGPATH_W) '$(srcdir)/src/database/tls.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_linked-tls.Tpo src/database/$(DEPDIR)/tests_unit_test_linked-tls.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/tls.c' object='src/database/tests_unit_test_linked-tls.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_linked-tls.obj `if test -f 'src/database/tls.c'; then $(CYGPATH_W) 'src/database/tls.c'; else $(CYGPATH_W) '$(srcdir)/src/database/tls.c'; fi` + +src/database/tests_unit_test_linked-lease.o: src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_linked-lease.o -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_linked-lease.Tpo -c -o src/database/tests_unit_test_linked-lease.o `test -f 'src/database/lease.c' || echo '$(srcdir)/'`src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_linked-lease.Tpo src/database/$(DEPDIR)/tests_unit_test_linked-lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/lease.c' object='src/database/tests_unit_test_linked-lease.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_linked-lease.o `test -f 'src/database/lease.c' || echo '$(srcdir)/'`src/database/lease.c + +src/database/tests_unit_test_linked-lease.obj: src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_linked-lease.obj -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_linked-lease.Tpo -c -o src/database/tests_unit_test_linked-lease.obj `if test -f 'src/database/lease.c'; then $(CYGPATH_W) 'src/database/lease.c'; else $(CYGPATH_W) '$(srcdir)/src/database/lease.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_linked-lease.Tpo src/database/$(DEPDIR)/tests_unit_test_linked-lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/lease.c' object='src/database/tests_unit_test_linked-lease.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_linked-lease.obj `if test -f 'src/database/lease.c'; then $(CYGPATH_W) 'src/database/lease.c'; else $(CYGPATH_W) '$(srcdir)/src/database/lease.c'; fi` + src/database/tests_unit_test_linked-pool.o: src/database/pool.c @am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_linked_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_linked-pool.o -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_linked-pool.Tpo -c -o src/database/tests_unit_test_linked-pool.o `test -f 'src/database/pool.c' || echo '$(srcdir)/'`src/database/pool.c @am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_linked-pool.Tpo src/database/$(DEPDIR)/tests_unit_test_linked-pool.Po @@ -2960,6 +3176,34 @@ src/database/tests_unit_test_php_runtime-connection.obj: src/database/connection @AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ @am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_php_runtime-connection.obj `if test -f 'src/database/connection.c'; then $(CYGPATH_W) 'src/database/connection.c'; else $(CYGPATH_W) '$(srcdir)/src/database/connection.c'; fi` +src/database/tests_unit_test_php_runtime-tls.o: src/database/tls.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_php_runtime-tls.o -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Tpo -c -o src/database/tests_unit_test_php_runtime-tls.o `test -f 'src/database/tls.c' || echo '$(srcdir)/'`src/database/tls.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Tpo src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/tls.c' object='src/database/tests_unit_test_php_runtime-tls.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_php_runtime-tls.o `test -f 'src/database/tls.c' || echo '$(srcdir)/'`src/database/tls.c + +src/database/tests_unit_test_php_runtime-tls.obj: src/database/tls.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_php_runtime-tls.obj -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Tpo -c -o src/database/tests_unit_test_php_runtime-tls.obj `if test -f 'src/database/tls.c'; then $(CYGPATH_W) 'src/database/tls.c'; else $(CYGPATH_W) '$(srcdir)/src/database/tls.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Tpo src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/tls.c' object='src/database/tests_unit_test_php_runtime-tls.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_php_runtime-tls.obj `if test -f 'src/database/tls.c'; then $(CYGPATH_W) 'src/database/tls.c'; else $(CYGPATH_W) '$(srcdir)/src/database/tls.c'; fi` + +src/database/tests_unit_test_php_runtime-lease.o: src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_php_runtime-lease.o -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Tpo -c -o src/database/tests_unit_test_php_runtime-lease.o `test -f 'src/database/lease.c' || echo '$(srcdir)/'`src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Tpo src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/lease.c' object='src/database/tests_unit_test_php_runtime-lease.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_php_runtime-lease.o `test -f 'src/database/lease.c' || echo '$(srcdir)/'`src/database/lease.c + +src/database/tests_unit_test_php_runtime-lease.obj: src/database/lease.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_php_runtime-lease.obj -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Tpo -c -o src/database/tests_unit_test_php_runtime-lease.obj `if test -f 'src/database/lease.c'; then $(CYGPATH_W) 'src/database/lease.c'; else $(CYGPATH_W) '$(srcdir)/src/database/lease.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Tpo src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/database/lease.c' object='src/database/tests_unit_test_php_runtime-lease.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/database/tests_unit_test_php_runtime-lease.obj `if test -f 'src/database/lease.c'; then $(CYGPATH_W) 'src/database/lease.c'; else $(CYGPATH_W) '$(srcdir)/src/database/lease.c'; fi` + src/database/tests_unit_test_php_runtime-pool.o: src/database/pool.c @am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/database/tests_unit_test_php_runtime-pool.o -MD -MP -MF src/database/$(DEPDIR)/tests_unit_test_php_runtime-pool.Tpo -c -o src/database/tests_unit_test_php_runtime-pool.o `test -f 'src/database/pool.c' || echo '$(srcdir)/'`src/database/pool.c @am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/database/$(DEPDIR)/tests_unit_test_php_runtime-pool.Tpo src/database/$(DEPDIR)/tests_unit_test_php_runtime-pool.Po @@ -3604,6 +3848,34 @@ src/app/tests_unit_test_php_runtime-signals.obj: src/app/signals.c @AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ @am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_php_runtime_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/app/tests_unit_test_php_runtime-signals.obj `if test -f 'src/app/signals.c'; then $(CYGPATH_W) 'src/app/signals.c'; else $(CYGPATH_W) '$(srcdir)/src/app/signals.c'; fi` +tests/unit/test_worker_set-test_worker_set.o: tests/unit/test_worker_set.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT tests/unit/test_worker_set-test_worker_set.o -MD -MP -MF tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Tpo -c -o tests/unit/test_worker_set-test_worker_set.o `test -f 'tests/unit/test_worker_set.c' || echo '$(srcdir)/'`tests/unit/test_worker_set.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Tpo tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='tests/unit/test_worker_set.c' object='tests/unit/test_worker_set-test_worker_set.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o tests/unit/test_worker_set-test_worker_set.o `test -f 'tests/unit/test_worker_set.c' || echo '$(srcdir)/'`tests/unit/test_worker_set.c + +tests/unit/test_worker_set-test_worker_set.obj: tests/unit/test_worker_set.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT tests/unit/test_worker_set-test_worker_set.obj -MD -MP -MF tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Tpo -c -o tests/unit/test_worker_set-test_worker_set.obj `if test -f 'tests/unit/test_worker_set.c'; then $(CYGPATH_W) 'tests/unit/test_worker_set.c'; else $(CYGPATH_W) '$(srcdir)/tests/unit/test_worker_set.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Tpo tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='tests/unit/test_worker_set.c' object='tests/unit/test_worker_set-test_worker_set.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o tests/unit/test_worker_set-test_worker_set.obj `if test -f 'tests/unit/test_worker_set.c'; then $(CYGPATH_W) 'tests/unit/test_worker_set.c'; else $(CYGPATH_W) '$(srcdir)/tests/unit/test_worker_set.c'; fi` + +src/platform/tests_unit_test_worker_set-worker_set.o: src/platform/worker_set.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/platform/tests_unit_test_worker_set-worker_set.o -MD -MP -MF src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Tpo -c -o src/platform/tests_unit_test_worker_set-worker_set.o `test -f 'src/platform/worker_set.c' || echo '$(srcdir)/'`src/platform/worker_set.c +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Tpo src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/platform/worker_set.c' object='src/platform/tests_unit_test_worker_set-worker_set.o' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/platform/tests_unit_test_worker_set-worker_set.o `test -f 'src/platform/worker_set.c' || echo '$(srcdir)/'`src/platform/worker_set.c + +src/platform/tests_unit_test_worker_set-worker_set.obj: src/platform/worker_set.c +@am__fastdepCC_TRUE@ $(AM_V_CC)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -MT src/platform/tests_unit_test_worker_set-worker_set.obj -MD -MP -MF src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Tpo -c -o src/platform/tests_unit_test_worker_set-worker_set.obj `if test -f 'src/platform/worker_set.c'; then $(CYGPATH_W) 'src/platform/worker_set.c'; else $(CYGPATH_W) '$(srcdir)/src/platform/worker_set.c'; fi` +@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Tpo src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Po +@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='src/platform/worker_set.c' object='src/platform/tests_unit_test_worker_set-worker_set.obj' libtool=no @AMDEPBACKSLASH@ +@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ +@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(tests_unit_test_worker_set_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) -c -o src/platform/tests_unit_test_worker_set-worker_set.obj `if test -f 'src/platform/worker_set.c'; then $(CYGPATH_W) 'src/platform/worker_set.c'; else $(CYGPATH_W) '$(srcdir)/src/platform/worker_set.c'; fi` + mostlyclean-libtool: -rm -f *.lo @@ -3963,6 +4235,20 @@ tests/unit/test_log_config.log: tests/unit/test_log_config$(EXEEXT) --log-file $$b.log --trs-file $$b.trs \ $(am__common_driver_flags) $(AM_LOG_DRIVER_FLAGS) $(LOG_DRIVER_FLAGS) -- $(LOG_COMPILE) \ "$$tst" $(AM_TESTS_FD_REDIRECT) +tests/unit/test_worker_set.log: tests/unit/test_worker_set$(EXEEXT) + @p='tests/unit/test_worker_set$(EXEEXT)'; \ + b='tests/unit/test_worker_set'; \ + $(am__check_pre) $(LOG_DRIVER) --test-name "$$f" \ + --log-file $$b.log --trs-file $$b.trs \ + $(am__common_driver_flags) $(AM_LOG_DRIVER_FLAGS) $(LOG_DRIVER_FLAGS) -- $(LOG_COMPILE) \ + "$$tst" $(AM_TESTS_FD_REDIRECT) +tests/unit/test_db_lease.log: tests/unit/test_db_lease$(EXEEXT) + @p='tests/unit/test_db_lease$(EXEEXT)'; \ + b='tests/unit/test_db_lease'; \ + $(am__check_pre) $(LOG_DRIVER) --test-name "$$f" \ + --log-file $$b.log --trs-file $$b.trs \ + $(am__common_driver_flags) $(AM_LOG_DRIVER_FLAGS) $(LOG_DRIVER_FLAGS) -- $(LOG_COMPILE) \ + "$$tst" $(AM_TESTS_FD_REDIRECT) tests/unit/test_php_runtime.log: tests/unit/test_php_runtime$(EXEEXT) @p='tests/unit/test_php_runtime$(EXEEXT)'; \ b='tests/unit/test_php_runtime'; \ @@ -4270,21 +4556,28 @@ distclean: distclean-am -rm -f src/config/$(DEPDIR)/validate.Po -rm -f src/database/$(DEPDIR)/cacti_query.Po -rm -f src/database/$(DEPDIR)/connection.Po + -rm -f src/database/$(DEPDIR)/lease.Po -rm -f src/database/$(DEPDIR)/persistence.Po -rm -f src/database/$(DEPDIR)/pool.Po -rm -f src/database/$(DEPDIR)/sql.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-cacti_query.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-connection.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-lease.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-persistence.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-pool.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-sql.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-tls.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-transfer.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-cacti_query.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-connection.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-persistence.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-pool.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-sql.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po + -rm -f src/database/$(DEPDIR)/tls.Po -rm -f src/database/$(DEPDIR)/transfer.Po -rm -f src/log/$(DEPDIR)/debug.Po -rm -f src/log/$(DEPDIR)/json.Po @@ -4333,7 +4626,9 @@ distclean: distclean-am -rm -f src/platform/$(DEPDIR)/tests_unit_test_php_runtime-privileges.Po -rm -f src/platform/$(DEPDIR)/tests_unit_test_php_runtime-socket.Po -rm -f src/platform/$(DEPDIR)/tests_unit_test_php_runtime-thread.Po + -rm -f src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Po -rm -f src/platform/$(DEPDIR)/thread.Po + -rm -f src/platform/$(DEPDIR)/worker_set.Po -rm -f src/poller/$(DEPDIR)/availability.Po -rm -f src/poller/$(DEPDIR)/host.Po -rm -f src/poller/$(DEPDIR)/items.Po @@ -4394,9 +4689,11 @@ distclean: distclean-am -rm -f tests/contracts/$(DEPDIR)/test_spine_output_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_privilege_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_reindex_contracts.Po + -rm -f tests/contracts/$(DEPDIR)/test_spine_script_limits_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_settings_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_snmpv3_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_stream_contracts.Po + -rm -f tests/contracts/$(DEPDIR)/test_spine_tls_contracts.Po -rm -f tests/regression/$(DEPDIR)/test_spine_faults.Po -rm -f tests/regression/$(DEPDIR)/test_spine_regressions.Po -rm -f tests/support/$(DEPDIR)/php_test_server.Po @@ -4405,6 +4702,7 @@ distclean: distclean-am -rm -f tests/support/$(DEPDIR)/unit_test_php_runtime-spine_runtime.Po -rm -f tests/unit/$(DEPDIR)/test_build_fixes.Po -rm -f tests/unit/$(DEPDIR)/test_classify_result.Po + -rm -f tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Po -rm -f tests/unit/$(DEPDIR)/test_linked-test_linked.Po -rm -f tests/unit/$(DEPDIR)/test_log_config.Po -rm -f tests/unit/$(DEPDIR)/test_php_runtime-test_php_runtime.Po @@ -4415,6 +4713,7 @@ distclean: distclean-am -rm -f tests/unit/$(DEPDIR)/test_snmpv3_session.Po -rm -f tests/unit/$(DEPDIR)/test_util_paths.Po -rm -f tests/unit/$(DEPDIR)/test_util_strings.Po + -rm -f tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Po -rm -f Makefile distclean-am: clean-am distclean-compile distclean-generic \ distclean-hdr distclean-libtool distclean-tags @@ -4493,21 +4792,28 @@ maintainer-clean: maintainer-clean-am -rm -f src/config/$(DEPDIR)/validate.Po -rm -f src/database/$(DEPDIR)/cacti_query.Po -rm -f src/database/$(DEPDIR)/connection.Po + -rm -f src/database/$(DEPDIR)/lease.Po -rm -f src/database/$(DEPDIR)/persistence.Po -rm -f src/database/$(DEPDIR)/pool.Po -rm -f src/database/$(DEPDIR)/sql.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_db_lease-lease.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-cacti_query.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-connection.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-lease.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-persistence.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-pool.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-sql.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-tls.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_linked-transfer.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-cacti_query.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-connection.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-lease.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-persistence.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-pool.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-sql.Po + -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-tls.Po -rm -f src/database/$(DEPDIR)/tests_unit_test_php_runtime-transfer.Po + -rm -f src/database/$(DEPDIR)/tls.Po -rm -f src/database/$(DEPDIR)/transfer.Po -rm -f src/log/$(DEPDIR)/debug.Po -rm -f src/log/$(DEPDIR)/json.Po @@ -4556,7 +4862,9 @@ maintainer-clean: maintainer-clean-am -rm -f src/platform/$(DEPDIR)/tests_unit_test_php_runtime-privileges.Po -rm -f src/platform/$(DEPDIR)/tests_unit_test_php_runtime-socket.Po -rm -f src/platform/$(DEPDIR)/tests_unit_test_php_runtime-thread.Po + -rm -f src/platform/$(DEPDIR)/tests_unit_test_worker_set-worker_set.Po -rm -f src/platform/$(DEPDIR)/thread.Po + -rm -f src/platform/$(DEPDIR)/worker_set.Po -rm -f src/poller/$(DEPDIR)/availability.Po -rm -f src/poller/$(DEPDIR)/host.Po -rm -f src/poller/$(DEPDIR)/items.Po @@ -4617,9 +4925,11 @@ maintainer-clean: maintainer-clean-am -rm -f tests/contracts/$(DEPDIR)/test_spine_output_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_privilege_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_reindex_contracts.Po + -rm -f tests/contracts/$(DEPDIR)/test_spine_script_limits_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_settings_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_snmpv3_contracts.Po -rm -f tests/contracts/$(DEPDIR)/test_spine_stream_contracts.Po + -rm -f tests/contracts/$(DEPDIR)/test_spine_tls_contracts.Po -rm -f tests/regression/$(DEPDIR)/test_spine_faults.Po -rm -f tests/regression/$(DEPDIR)/test_spine_regressions.Po -rm -f tests/support/$(DEPDIR)/php_test_server.Po @@ -4628,6 +4938,7 @@ maintainer-clean: maintainer-clean-am -rm -f tests/support/$(DEPDIR)/unit_test_php_runtime-spine_runtime.Po -rm -f tests/unit/$(DEPDIR)/test_build_fixes.Po -rm -f tests/unit/$(DEPDIR)/test_classify_result.Po + -rm -f tests/unit/$(DEPDIR)/test_db_lease-test_db_lease.Po -rm -f tests/unit/$(DEPDIR)/test_linked-test_linked.Po -rm -f tests/unit/$(DEPDIR)/test_log_config.Po -rm -f tests/unit/$(DEPDIR)/test_php_runtime-test_php_runtime.Po @@ -4638,6 +4949,7 @@ maintainer-clean: maintainer-clean-am -rm -f tests/unit/$(DEPDIR)/test_snmpv3_session.Po -rm -f tests/unit/$(DEPDIR)/test_util_paths.Po -rm -f tests/unit/$(DEPDIR)/test_util_strings.Po + -rm -f tests/unit/$(DEPDIR)/test_worker_set-test_worker_set.Po -rm -f Makefile maintainer-clean-am: distclean-am maintainer-clean-generic diff --git a/README.md b/README.md index 188e0feb..98093782 100644 --- a/README.md +++ b/README.md @@ -177,17 +177,34 @@ library dependencies. Runtime contracts exercise availability, retries, script framing, result persistence, worker failures and shutdown. Portable checked buffer helpers use the existing C/POSIX interfaces without Annex K. +### Database output compatibility + +Result writes inspect the connected destination's server version. MySQL 8.0.20 +and newer use row aliases for duplicate-key updates; older MySQL and MariaDB +retain `VALUES(output)`. Local and remote writes choose independently, including +after reconnects. Regression tests verify duplicate-key replacement in both +output tables, with a separate MariaDB-to-MySQL destination lane in CI. + ### Structured logging + Set `Log_Format json` in `spine.conf` to emit one JSON record per log message. Text remains the default. Records contain a UTC timestamp, severity, and the message, retaining device identifiers in the message without guessing structured fields from ambiguous text. File, console, and syslog destinations retain their existing routing. Startup banners and fatal diagnostics outside the logging API remain operational diagnostics. +Error fallback records use `unknown` when a timestamp cannot be obtained. The fixed record encoder escapes quotes, backslashes, and controls, preserves valid UTF-8, and rejects malformed UTF-8 with a structured error record. An allocation or formatting failure also emits a structured error and returns failure to the caller. It does not add a JSON parser or a build dependency; the legacy capabilities-string contract remains separate from log formatting. + +Explicit [database TLS policies](docs/operations/database-tls.md) and +[external script limits](docs/operations/script-limits.md) fail when the selected +backend cannot enforce them. Defaults retain the existing compatibility policy. +The [paired-build benchmark job](docs/benchmarks/README.md) records repeated +polling-cycle timing and CPU measurements with otherwise identical conditions; +checked-in result files distinguish observations from performance claims. diff --git a/configure.ac b/configure.ac index d00f2e6c..6470d1c6 100644 --- a/configure.ac +++ b/configure.ac @@ -654,6 +654,23 @@ AC_LINK_IFELSE([AC_LANG_PROGRAM([[ ],[AC_MSG_RESULT(no) ]) +AC_CHECK_DECLS([MYSQL_OPT_SSL_MODE, MYSQL_OPT_SSL_ENFORCE, + MYSQL_OPT_SSL_VERIFY_SERVER_CERT, MYSQL_OPT_TLS_VERSION, + MYSQL_OPT_SSL_CIPHER, MYSQL_OPT_TLS_CIPHERSUITES, + MARIADB_OPT_TLS_VERIFICATION_CALLBACK], [], [], [[#include ]]) +AC_LINK_IFELSE([AC_LANG_PROGRAM([[ + #include + struct st_ma_pvio_tls; + extern int ma_pvio_tls_verify_server_cert(struct st_ma_pvio_tls *, unsigned int); +]], [[ + MYSQL mysql; + unsigned int flags = MARIADB_TLS_VERIFY_HOST | MARIADB_TLS_VERIFY_TRUST; + mysql_options(&mysql, MARIADB_OPT_TLS_VERIFICATION_CALLBACK, + (const void *) ma_pvio_tls_verify_server_cert); + return ma_pvio_tls_verify_server_cert((struct st_ma_pvio_tls *) 0, flags); +]])], [AC_DEFINE([HAVE_MARIADB_TLS_VERIFICATION_CALLBACK], [1], + [Can we require MariaDB certificate identity verification for every address?])], []) + spine_saved_ldflags=$LDFLAGS spine_linker_wrap=no case "$host_os" in diff --git a/docs/adr/0004-formatting-and-json-dependencies.md b/docs/adr/0004-formatting-and-json-dependencies.md new file mode 100644 index 00000000..95d2a0bf --- /dev/null +++ b/docs/adr/0004-formatting-and-json-dependencies.md @@ -0,0 +1,42 @@ + + + +# Formatting and JSON dependencies + +Status: accepted for the current fixed logging interface; revisit when consumers +need general JSON construction/parsing or profiling identifies a formatting +bottleneck. Tracks proposals #571 and #572 and the JSON logging consumer #568. + +## Decision + +Keep libc's checked formatting interfaces. No measured evidence currently shows +that replacing `snprintf` with `stb_sprintf` improves Spine's polling workload. +The paired-build benchmark job now provides reproducible timing and CPU +measurements for future experiments. Its initial default-logging comparison +does not test `stb_sprintf`, and cannot establish that dependency's benefit. +A replacement would also need to preserve truncation, return-value, floating +point and supported-platform behavior. Performance percentages require a +measured comparison with otherwise identical builds. + +Use a small reviewed encoder for the fixed JSON log record containing three +string fields. It escapes controls, quotes and backslashes, validates UTF-8, +checks every capacity boundary and rejects invalid input. Production-linked C +tests and an independent Python JSON parser exercise control bytes, valid and +malformed multibyte sequences, and every output-capacity boundary. Allocation, +formatting and cancellation failures are covered separately. + +This choice adds no parser or general JSON object API. Existing capabilities +strings retain their established compatibility contract. Do not extend the +encoder into a general JSON library: reconsider a maintained library such as +Jansson when arbitrary typed/nested data or JSON input is required. Such a +change needs dependency/version/license review, supported-platform builds and +consumer tests. The current decision is based on the narrow implemented +interface, not a claim that handwritten JSON is generally preferable. + +## Consequences + +There is no new installed library or vendored formatter to maintain. The fixed +encoder remains project-owned security-sensitive code and must retain its +strict parser-backed tests. Timestamp failures use `unknown` explicitly; +normal records use UTC. General JSON features and formatter substitutions stay +out of the implemented interface until their requirements and evidence exist. diff --git a/docs/operations/database-tls.md b/docs/operations/database-tls.md new file mode 100644 index 00000000..1a045b7d --- /dev/null +++ b/docs/operations/database-tls.md @@ -0,0 +1,66 @@ + + + +# Explicit database TLS policy + +Local and remote connections have independent `DB_SSL_*` and `RDB_SSL_*` +settings. Unset `Mode` preserves the legacy connector behavior. Explicit +unsupported policies fail instead of silently accepting weaker settings. + +| Mode | Policy | +| --- | --- | +| `legacy` | Existing `DB_UseSSL` and key/certificate/CA options; connector defaults apply. | +| `disabled` | Require plaintext; supported by MySQL's native SSL mode API. | +| `preferred` | Attempt TLS with plaintext fallback permitted. | +| `required` | Require encryption without peer certificate authentication. | +| `verify-ca` | Require encryption and verification against the configured CA. | +| `verify-full` | Require encryption, configured CA trust and matching server identity. | + +MySQL clients exposing `MYSQL_OPT_SSL_MODE` implement all modes. MariaDB +Connector/C clients exposing the complete TLS verification callback API support +`preferred`, `required`, `verify-ca` and `verify-full`; explicit `disabled` is +rejected because their enforcement API does not safely guarantee plaintext. +Older MariaDB clients lacking that callback support `preferred` and native +`verify-full`. They reject `required` and `verify-ca`: enforcement alone can +fall back to plaintext before authentication. A newer client advertising the +callback option without exporting the required verification function also +rejects the strict modes. Configure probes the actual headers and linked +library, rather than inferring capabilities from a server version. + +The callback adapter forces trust and identity checks even for loopback +addresses where newer Connector/C defaults can omit hostname verification. +`required` uses the connector's mandatory-TLS flag with an encryption-only +callback. Every connection retry starts with a fresh handle and reapplies all +TLS settings; a failed handshake cannot consume settings and weaken a retry. +Strict modes also check the negotiated connection for a TLS cipher. + +`DB_SSL_MinVersion` accepts `TLSv1.2` or `TLSv1.3`. `DB_SSL_Cipher` selects +pre-TLS-1.3 ciphers; `DB_SSL_Ciphersuites` selects TLS 1.3 suites when the client +exposes that separate option. Restriction directives require a strict mode +(`required`, `verify-ca` or `verify-full`); they cannot be combined with plaintext +fallback. Unknown modes, unsupported versions, excessive cipher strings, +unsupported client options and option-application failures reject the policy. + +```text +DB_SSL_Mode verify-full +DB_SSL_CA /etc/cacti/database-ca.pem +DB_SSL_MinVersion TLSv1.2 + +RDB_SSL_Mode verify-full +RDB_SSL_CA /etc/cacti/remote-database-ca.pem +RDB_SSL_MinVersion TLSv1.3 +``` + +Both verification modes require an explicit trusted CA. Server names/IP +addresses must match certificate identities for `verify-full`. Server-side +protocol/cipher configuration must be compatible with the selected client. +CA trust does not replace hostname verification. + +`bash tests/tools/run_database_tls.sh` generates disposable certificates and +servers to test trust, wrong identities, minimum protocols, cipher restrictions, +retry behavior and plaintext rejection for both database destinations. The +regression workflow exercises MariaDB and MySQL client libraries separately. +Native Connector/C 3.4.11 was also tested against those certificate fixtures. + +API references: [MariaDB mysql_optionsv](https://mariadb.com/docs/connectors/mariadb-connector-c/api-functions/mysql_optionsv), +[MySQL mysql_options](https://dev.mysql.com/doc/c-api/8.4/en/mysql-options.html). diff --git a/docs/operations/issue-boundaries.md b/docs/operations/issue-boundaries.md new file mode 100644 index 00000000..c5c54bbc --- /dev/null +++ b/docs/operations/issue-boundaries.md @@ -0,0 +1,80 @@ + + + +# Remaining issue acceptance boundaries + +This records requirements that cannot be established by changing Spine alone. +It does not mark the listed issues complete or claim untested platform support. + +## Output regular expressions (#210) + +Spine discovers an `output_regex` column on `poller_item` and applies configured +expressions before numeric validation in each SNMP batch flush. Regression +fixtures cover full batches, credential changes, final batches, units-bearing +strings and normalized hexadecimal strings. The tests add that column only to +the disposable fixture and remove it afterward. + +The audited Cacti develop schema at +`cad6762d44cf71cd9980d03ff1c266ef90e6108e` does not define `output_regex`. +A deployable Cacti feature still needs a schema migration, configuration UI and +poller-cache population contract in the Cacti repository. Spine does not add +columns to a live Cacti database. Leave #210 open until that integration exists. + +## SNMP Diffie-Hellman key changes (#295) + +The request must distinguish provisioning/rekeying a device's USM user from +ordinary authenticated polling. Spine currently consumes configured USM keys; +changing a device key without atomically updating Cacti's stored credentials +would interrupt polling and can strand credentials after a partial failure. + +Before implementing a protocol, obtain the target device/MIB specification, +required authentication/privacy combinations, administrator authorization, +credential persistence and rollback behavior, and a disposable device or +protocol fixture. Reuse Net-SNMP's supported implementation where applicable; +do not invent a key exchange. Cacti owns its credential schema and management +UI, including the related Cacti #5099 request. Spine-only unit tests cannot +establish provisioning interoperability. Leave #295 open pending that contract. + +## AIX and Solaris (#536) + +Linux/macOS builds cannot establish runtime support for AIX or Solaris. An +acceptance lane needs an owned runner or reproducible machine image, a C17 GNU +compiler/toolchain selection, Autotools, matching Net-SNMP and database client +headers/libraries, a documented TLS capability matrix, and disposable database +and SNMP endpoints. It must run build, production-linked tests, ownership tests, +polling, timeout/process cleanup and TLS trust/identity cases. Unsupported +explicit policies must remain failures, not become fallback behavior. + +No AIX/Solaris platform execution was available for this change. Leave #536 open +until those environments are supplied and the acceptance runs are recorded. + +## Nightly validation (#496) + +The nightly workflow runs its declared unit suites and dynamic checks, with a +manifest gate rejecting omitted, duplicate or nonexecutable suites. Local +sanitizer runs do not substitute for a completed scheduled workflow after the +stack merges. Record that run and its artifacts before declaring #496 complete. + +## Ownership audit (#609) + +The bounded joinable worker registry and database lease module have mandatory +100% line and branch coverage gates. Parent shutdown joins workers before pool +destruction. Leases bind the slot, generation, destination and borrowing thread; +stale, duplicate or foreign releases fail. Script cancellation owns its output, +descriptor, child process group and acquired permit. Obsolete pool/host-time +global locks are removed, and connection-count updates are atomic. + +These are concrete ownership fixes. They do not assert 100% coverage for every +legacy resource path: the remaining global-lock consumers and full lifetime +audit must be reviewed against #609's broader acceptance criteria. Keep that +broader audit open rather than treating helper coverage as whole-program proof. + +## Poller-wide script aborts (#476) + +The explicit script policy and per-call cancellation cleanup are implemented +and tested with real children. The broader acceptance criteria also include +poller-wide fatal/signal aborts while other threads own subprocesses. The fatal +signal handler intentionally uses async-signal-safe operations and cannot take +the subprocess registry mutex. A complete process-wide abort policy still +requires an ownership/shutdown design and dedicated tests; per-call cleanup is +not evidence for that path. Leave #476 open for this remaining acceptance work. diff --git a/docs/operations/script-limits.md b/docs/operations/script-limits.md new file mode 100644 index 00000000..a45ff1db --- /dev/null +++ b/docs/operations/script-limits.md @@ -0,0 +1,73 @@ + + + +# External script execution limits + +External data-source and reindex scripts can opt into a stricter per-command +policy through `spine.conf`. All six settings default to zero. With all settings +unset or zero, Spine preserves the existing configured script timeout and +single-read result contract. Persistent PHP script servers use their existing +lifecycle policy and are not subject to these per-command resource caps. + +| Setting | Unit and valid range | Meaning when nonzero | +| --- | --- | --- | +| `Script_DeadlineSeconds` | seconds, 1–86400 | Tightens the configured `script_timeout`; the smaller timeout applies. | +| `Script_OutputBytes` | bytes, 1–`RESULTS_BUFFER - 1` | Maximum complete stdout response, including whitespace and line endings. | +| `Script_CPUSeconds` | seconds, 1–86400 | Inherited per-process soft and hard CPU limit. | +| `Script_MemoryKiB` | KiB, 1–2147483647 | Inherited per-process soft and hard virtual-address-space limit. | +| `Script_FileKiB` | KiB, 1–2147483647 | Inherited per-file soft and hard size limit. | +| `Script_OpenFiles` | descriptors, 1–2147483647 | Inherited per-process soft and hard open-file limit. | + +Zero disables an individual setting. Negative values, signs, nonnumeric values, +overflow and missing policy values fail configuration loading. Output limits +cannot exceed Spine's compiled result-buffer capacity. CPU, memory, file-size +and descriptor caps currently require the Linux `/bin/sh` backend. Explicit +resource caps on other platforms fail before spawning the command. Failure to +apply a shell limit returns a failed child status rather than running uncapped. + +For example, this limits each command to five seconds, 1,024 output bytes, two +CPU seconds, 128 MiB of virtual address space, 1 MiB per file and 64 descriptors: + +```text +Script_DeadlineSeconds 5 +Script_OutputBytes 1024 +Script_CPUSeconds 2 +Script_MemoryKiB 131072 +Script_FileKiB 1024 +Script_OpenFiles 64 +``` + +Enabling any setting requires a complete response ending at stdout EOF within +the deadline and a successful child exit. If no output size is specified, the +complete-response maximum is `RESULTS_BUFFER - 1`. A result followed by more +output, a lingering child, nonzero exit, resource signal, read failure or limit +breach produces `U`; excessive output is never silently accepted as a truncated +value. On output or deadline failure, Spine kills the command's process group, +closes its descriptor and uses its existing bounded reap and deferred-reap +registry. Cancellation owns the same cleanup and releases only the permit that +the cancelled call acquired. Cancellation while waiting cannot return another +thread's permit. + +Policy code is fixed; numeric limits and the trusted Cacti command are supplied +as separate positional shell arguments. They are not concatenated into policy +code. Linux shell file-size units are converted from KiB to 512-byte blocks; +the production-linked tests check the resulting kernel soft and hard limits. +See the [Debian dash manual](https://manpages.debian.org/bullseye/dash/dash.1.en.html) +for the shell limit interface. + +Spine logs the host and breach reason. Failed child exit diagnostics also name +the data-source/query ID and executable, without printing its arguments. The +command still comes from the trusted Cacti database and intentionally runs as a +shell command. This is resource management, not an isolation sandbox: limits +apply per process, not to aggregate descendant memory/CPU, and a trusted script +that deliberately creates a new process group can leave the original group. +Linux process limits also do not bound network traffic or total disk writes +across multiple files. Use operating-system service/container controls when +those aggregate guarantees are needed. + +`bash tests/tools/run_script_limits.sh` exercises real children against CPU, +memory, file-size and descriptor limits, plus complete-output, delayed-output, +deadline, failed-exit and deferred/asynchronous cancellation cases. Its small C +workload is deliberately compiled without sanitizer instrumentation: ASan's +reserved virtual address space would prevent a low-memory workload from +starting. The production parent remains instrumented in sanitizer builds. diff --git a/etc/spine.conf.dist b/etc/spine.conf.dist index 832c89de..c8770c66 100644 --- a/etc/spine.conf.dist +++ b/etc/spine.conf.dist @@ -49,11 +49,27 @@ DB_Port 3306 #DB_SSL_Key #DB_SSL_Cert #DB_SSL_CA +# Explicit policy overrides legacy connector defaults; unset keeps compatibility. +# Unsupported explicit modes fail; see docs/operations/database-tls.md. +# verify-full requires a trusted DB_SSL_CA and a matching server identity. +#DB_SSL_Mode required +#DB_SSL_MinVersion TLSv1.2 +#DB_SSL_Cipher ECDHE-RSA-AES128-GCM-SHA256 +#DB_SSL_Ciphersuites TLS_AES_128_GCM_SHA256 # Optional structured logging. Default text preserves existing log consumers. # JSON records use UTC timestamps and retain valid UTF-8. Invalid UTF-8 emits # a structured error record instead of the original message. #Log_Format text +# Optional per-command limits. Zero/unset preserves legacy script behavior. +# Enabling any limit requires complete bounded output and successful child exit. +# Resource caps (CPU, memory, file size, descriptors) require Linux /bin/sh. +#Script_DeadlineSeconds 0 +#Script_OutputBytes 0 +#Script_CPUSeconds 0 +#Script_MemoryKiB 0 +#Script_FileKiB 0 +#Script_OpenFiles 0 #SNMP_Clientaddr #Cacti_Log /var/www/html/cacti/log/cacti.log @@ -66,4 +82,8 @@ DB_Port 3306 #RDB_SSL_Key #RDB_SSL_Cert #RDB_SSL_CA +#RDB_SSL_Mode verify-full +#RDB_SSL_MinVersion TLSv1.2 +#RDB_SSL_Cipher ECDHE-RSA-AES128-GCM-SHA256 +#RDB_SSL_Ciphersuites TLS_AES_128_GCM_SHA256 diff --git a/src/app/runtime.c b/src/app/runtime.c index 244f4272..9d9cb485 100644 --- a/src/app/runtime.c +++ b/src/app/runtime.c @@ -55,7 +55,7 @@ _Noreturn void spine_run(int argc, char *argv[]) { /* Before any option, file or setting is read; see drop_privileges(). */ drop_privileges(); - pthread_t *threads = NULL; + spine_worker_set_t workers = {0}; pthread_attr_t attr; int *ids = NULL; @@ -179,7 +179,7 @@ _Noreturn void spine_run(int argc, char *argv[]) { result = select_poll_hosts(&mysql); if (result == NULL) die("FATAL: Unable to select the devices to poll"); - prepare_worker_storage(result, &num_rows, &threads, &ids, &host_time); + prepare_worker_storage(result, &num_rows, &ids, &host_time); /* initialize winsock library on Windows */ SOCK_STARTUP; @@ -195,13 +195,16 @@ _Noreturn void spine_run(int argc, char *argv[]) { } /* initialize threads and mutexes */ - if (spine_thread_attr_init(&attr) != 0 || - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) != 0) { + if (spine_thread_attr_init(&attr) != 0) { set.exit.exit_code = EXIT_FAILURE; die("ERROR: Unable to initialize polling thread attributes"); } init_mutexes(); + if (spine_worker_set_init(&workers, (size_t) set.poller.threads) != 0) { + set.exit.exit_code = EXIT_FAILURE; + die("ERROR: Unable to initialize polling worker registry"); + } /* Initialize process-local concurrency permits before worker startup. */ if (spine_permits_init(&available_threads, set.poller.threads) != 0 || @@ -219,9 +222,13 @@ _Noreturn void spine_run(int argc, char *argv[]) { /* tell fork processes that they are now active */ set.poller.parent_fork = SPINE_FORK; - launch_poll_workers(&mysql, result, num_rows, threads, &thread_init_sem, &attr, host_time); + launch_poll_workers(&mysql, result, num_rows, &workers, &thread_init_sem, &attr, host_time); - a_threads_value = wait_for_workers(begin_time); + a_threads_value = wait_for_workers(begin_time, &workers); + if (spine_worker_set_destroy(&workers) != 0) { + set.exit.exit_code = EXIT_FAILURE; + die("ERROR: Unable to release joined polling worker registry"); + } threads_final = set.poller.threads - a_threads_value; @@ -241,7 +248,7 @@ _Noreturn void spine_run(int argc, char *argv[]) { close_main_php(); - free_worker_storage(num_rows, threads, ids, conf_file, host_time); + free_worker_storage(num_rows, ids, conf_file, host_time); /* close mysql */ db_free_result(result); diff --git a/src/app/startup_internal.h b/src/app/startup_internal.h index b53141a6..070b842d 100644 --- a/src/app/startup_internal.h +++ b/src/app/startup_internal.h @@ -19,6 +19,7 @@ #include "internal/common.h" #include "app/spine.h" #include "database/persistence.h" +#include "platform/worker_set.h" extern int *debug_devices; extern int entries; extern int num_hosts; @@ -28,14 +29,14 @@ extern poller_thread_t **details; -int wait_for_workers(double begin_time); +int wait_for_workers(double begin_time, spine_worker_set_t *workers); void report_worker_completion(int num_rows); void launch_poll_workers(MYSQL *mysql, MYSQL_RES *result, int num_rows, - pthread_t *threads, spine_permits_t *thread_init_sem, + spine_worker_set_t *workers, spine_permits_t *thread_init_sem, const pthread_attr_t *attr, char *host_time); void prepare_worker_storage(MYSQL_RES *result, int *rows, - pthread_t **worker_threads, int **host_ids, char **timestamp); -void free_worker_storage(int num_rows, pthread_t *threads, int *ids, + int **host_ids, char **timestamp); +void free_worker_storage(int num_rows, int *ids, char *conf_file, char *host_time); char *load_startup_configuration(char *conf_file); void report_startup(int mode); diff --git a/src/config/file.c b/src/config/file.c index 7ff010e1..c9a06a1e 100644 --- a/src/config/file.c +++ b/src/config/file.c @@ -17,6 +17,84 @@ #include "app/spine.h" #define SPINE_STRINGIFY_INNER(value) #value #define SPINE_STRINGIFY(value) SPINE_STRINGIFY_INNER(value) +#include + +static bool explicit_policy_name(const char *name) { + const char *option; + if (strncasecmp(name, "Script_", 7) == 0 || STRIMATCH(name, "Log_Format")) return TRUE; + if (strncasecmp(name, "DB_SSL_", 7) == 0) option = name + 7; + else if (strncasecmp(name, "RDB_SSL_", 8) == 0) option = name + 8; + else return FALSE; + return STRIMATCH(option, "Mode") || STRIMATCH(option, "MinVersion") || + STRIMATCH(option, "Cipher") || STRIMATCH(option, "Ciphersuites"); +} + +static bool apply_script_limit(const char *name, const char *value) { + static const struct { + const char *name; + size_t offset; + unsigned long maximum; + } directives[] = { + {"Script_DeadlineSeconds", offsetof(spine_script_limits_t, deadline_seconds), 86400}, + {"Script_OutputBytes", offsetof(spine_script_limits_t, output_bytes), RESULTS_BUFFER - 1}, + {"Script_CPUSeconds", offsetof(spine_script_limits_t, cpu_seconds), 86400}, + {"Script_MemoryKiB", offsetof(spine_script_limits_t, memory_kib), INT_MAX}, + {"Script_FileKiB", offsetof(spine_script_limits_t, file_kib), INT_MAX}, + {"Script_OpenFiles", offsetof(spine_script_limits_t, open_files), INT_MAX}}; + size_t index; + unsigned long number; + char *end; + for (index = 0; index < sizeof(directives) / sizeof(directives[0]); index++) { + if (!STRIMATCH(name, directives[index].name)) continue; + if (value[0] == '\0' || !all_digits(value)) die("ERROR: Invalid script resource limit"); + errno = 0; + number = strtoul(value, &end, 10); + if (errno != 0 || *end != '\0' || number > directives[index].maximum) die("ERROR: Script resource limit is out of range"); + *(unsigned int *) ((char *) &set.php.limits + directives[index].offset) = (unsigned int) number; + return TRUE; + } + return FALSE; +} + +static bool apply_tls_directive(const char *name, const char *value) { + spine_database_config_t *configuration; + const char *option; + static const char *const modes[] = {"legacy", "disabled", "preferred", "required", "verify-ca", "verify-full"}; + size_t index; + char *destination; + + if (strncasecmp(name, "DB_SSL_", 7) == 0) { + configuration = &set.database; + option = name + 7; + } else if (strncasecmp(name, "RDB_SSL_", 8) == 0) { + configuration = &set.remote_database; + option = name + 8; + } else { + return FALSE; + } + if (STRIMATCH(option, "Mode")) { + for (index = 0; index < sizeof(modes) / sizeof(modes[0]); index++) { + if (STRIMATCH(value, modes[index])) { + configuration->tls_mode = (spine_tls_mode_t) index; + return TRUE; + } + } + die("ERROR: Invalid database TLS mode"); + } + if (STRIMATCH(option, "MinVersion")) { + if (STRIMATCH(value, "TLSv1.2")) configuration->tls_min_version = 12; + else if (STRIMATCH(value, "TLSv1.3")) configuration->tls_min_version = 13; + else die("ERROR: Database TLS minimum must be TLSv1.2 or TLSv1.3"); + return TRUE; + } + if (STRIMATCH(option, "Cipher")) destination = configuration->tls_cipher; + else if (STRIMATCH(option, "Ciphersuites")) destination = configuration->tls_ciphersuites; + else return FALSE; + if (value[0] == '\0') die("ERROR: Missing database TLS cipher policy value"); + if (strlen(value) >= BUFSIZE) die("ERROR: Database TLS cipher policy exceeds the configuration limit"); + strncopy(destination, value, BUFSIZE); + return TRUE; +} /*! \fn int read_spine_config(const char *file) * \brief obtain default startup variables from the spine.conf file. @@ -25,6 +103,8 @@ * \return 0 if successful or -1 if the file could not be opened */ static int apply_config_directive(const char *name, const char *value) { + if (apply_script_limit(name, value)) return TRUE; + if (apply_tls_directive(name, value)) return TRUE; /* Immutable metadata cannot be changed by configuration values written * into set. Offsets and capacities describe the actual destination fields. */ static const struct { @@ -114,6 +194,8 @@ int read_spine_config(const char *file) { * pass is exactly one line */ line++; if (!config_line_complete(fp, buff, sizeof(buff))) { + if (sscanf(buff, "%1023s", name) == 1 && explicit_policy_name(name)) + die("ERROR: Explicit policy directive exceeds the configuration line limit"); if (!set.console.stderr_notty) { fprintf(stderr, "WARNING: Ignoring line %d of %s, longer than %d characters\n", line, display_file, BUFSIZE - 2); } @@ -128,6 +210,9 @@ int read_spine_config(const char *file) { * A lone token is a key without a value, or a line that is no * directive at all; the old %15s split such a line in two. */ if (fields == 1) { + /* Explicit policies must not silently become legacy defaults. */ + (void) apply_script_limit(name, ""); + (void) apply_tls_directive(name, ""); if (!set.console.stderr_notty) { fprintf(stderr, "WARNING: Directive without a value on line %d of %s\n", line, display_file); } @@ -146,6 +231,11 @@ int read_spine_config(const char *file) { * */ void config_defaults() { + memset(&set.php.limits, 0, sizeof(set.php.limits)); + set.database.tls_mode = set.remote_database.tls_mode = SPINE_TLS_LEGACY; + set.database.tls_min_version = set.remote_database.tls_min_version = 0; + set.database.tls_cipher[0] = set.remote_database.tls_cipher[0] = '\0'; + set.database.tls_ciphersuites[0] = set.remote_database.tls_ciphersuites[0] = '\0'; set.poller.threads = DEFAULT_THREADS; /* default server */ diff --git a/src/config/model.h b/src/config/model.h index a6ea5d99..16d81342 100644 --- a/src/config/model.h +++ b/src/config/model.h @@ -58,6 +58,15 @@ typedef struct { } spine_hosts_config_t; /* Database configuration and runtime state. */ +typedef enum { + SPINE_TLS_LEGACY = 0, + SPINE_TLS_DISABLED, + SPINE_TLS_PREFERRED, + SPINE_TLS_REQUIRED, + SPINE_TLS_VERIFY_CA, + SPINE_TLS_VERIFY_FULL +} spine_tls_mode_t; + typedef struct { char host[BUFSIZE]; char database[BUFSIZE]; @@ -67,12 +76,25 @@ typedef struct { char ssl_key[BUFSIZE]; char ssl_cert[BUFSIZE]; char ssl_ca[BUFSIZE]; + spine_tls_mode_t tls_mode; + int tls_min_version; + char tls_cipher[BUFSIZE]; + char tls_ciphersuites[BUFSIZE]; unsigned int port; char version[BUFSIZE]; int onupdate; } spine_database_config_t; /* Php configuration and runtime state. */ +typedef struct { + unsigned int deadline_seconds; + unsigned int output_bytes; + unsigned int cpu_seconds; + unsigned int memory_kib; + unsigned int file_kib; + unsigned int open_files; +} spine_script_limits_t; + typedef struct { char path_php[BUFSIZE]; char path_php_server[BUFSIZE]; @@ -81,6 +103,7 @@ typedef struct { int php_initialized; int php_servers; int php_current_server; + spine_script_limits_t limits; } spine_php_config_t; /* Logging configuration and runtime state. */ diff --git a/src/database/connection.c b/src/database/connection.c index b88524ad..18452f4a 100644 --- a/src/database/connection.c +++ b/src/database/connection.c @@ -15,6 +15,7 @@ #include "internal/common.h" #include "app/spine.h" +#include /*! \fn void db_connect(char *database, MYSQL *mysql) * \brief opens a connection to a MySQL database. @@ -58,6 +59,23 @@ void db_set_option(MYSQL *mysql, enum mysql_option option, const void *value, co } } +/* Inspect the connected destination, including after a reconnect. A cached + * capability for the local server cannot describe a remote output server. */ +const char *db_output_upsert_suffix(MYSQL *mysql) { + const char *version = mysql == NULL ? NULL : mysql_get_server_info(mysql); + unsigned long number; + + if (version == NULL || version[0] == '\0') { + return " ON DUPLICATE KEY UPDATE output=VALUES(output)"; + } + number = mysql_get_server_version(mysql); + + if (db_row_alias_upsert_supported(version, number)) { + return " AS rs ON DUPLICATE KEY UPDATE output=rs.output"; + } + return " ON DUPLICATE KEY UPDATE output=VALUES(output)"; +} + #ifdef HAS_MYSQL_OPT_SSL_KEY static void db_set_ssl_options(MYSQL *mysql, int type) { #ifdef HAS_MYSQL_OPT_SSL_VERIFY_SERVER_CERT @@ -89,7 +107,8 @@ int db_connect(int type, MYSQL *mysql) { int error = 0; const MYSQL *connect_error; db_address_t address; - static int connections = 0; + static atomic_uint connections; + unsigned int total_connections; bool local_address = set.poller.poller_id <= 1 || type == LOCAL; db_address_init(&address, local_address ? set.database.host : set.remote_database.host, local_address); @@ -102,25 +121,32 @@ int db_connect(int type, MYSQL *mysql) { wtimeout = 30; attempts = 1; - if (mysql_init(mysql) == NULL) { - db_address_release(&address); - printf("ERROR: Database unable to allocate memory and therefore can not connect\n"); - return FALSE; - } + while (tries > 0) { + /* Failed handshakes may consume connector TLS state. Each attempt owns a + * freshly initialized handle and reapplies the complete policy. */ + if (mysql_init(mysql) == NULL) { + db_address_release(&address); + printf("ERROR: Database unable to allocate memory and therefore can not connect\n"); + return FALSE; + } - db_set_option(mysql, MYSQL_OPT_READ_TIMEOUT, &rtimeout, "read timeout"); - db_set_option(mysql, MYSQL_OPT_WRITE_TIMEOUT, &wtimeout, "write timeout"); - db_set_option(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout, "general timeout"); + db_set_option(mysql, MYSQL_OPT_READ_TIMEOUT, &rtimeout, "read timeout"); + db_set_option(mysql, MYSQL_OPT_WRITE_TIMEOUT, &wtimeout, "write timeout"); + db_set_option(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout, "general timeout"); #ifdef HAS_MYSQL_OPT_RETRY_COUNT db_set_option(mysql, MYSQL_OPT_RETRY_COUNT, &tries, "retry count"); #endif #ifdef HAS_MYSQL_OPT_SSL_KEY - db_set_ssl_options(mysql, type); + db_set_ssl_options(mysql, local_address ? LOCAL : REMOTE); #endif + if (!db_apply_tls_policy(mysql, local_address ? &set.database : &set.remote_database)) { + SPINE_LOG(("ERROR: Database TLS policy is invalid or unsupported by the selected client library")); + db_address_release(&address); + return FALSE; + } - while (tries > 0) { tries--; if (set.poller.poller_id > 1) { @@ -151,11 +177,20 @@ int db_connect(int type, MYSQL *mysql) { success = FALSE; } } else { + const spine_database_config_t *configuration = local_address ? &set.database : &set.remote_database; + if (configuration->tls_mode >= SPINE_TLS_REQUIRED && mysql_get_ssl_cipher(mysql) == NULL) { + SPINE_LOG(("ERROR: Database connection rejected because the explicit TLS policy requires encryption")); + mysql_close(mysql); + mysql_init(mysql); + db_address_release(&address); + return FALSE; + } success = TRUE; break; } attempts++; + if (tries > 0) mysql_close(mysql); } db_address_release(&address); @@ -167,9 +202,8 @@ int db_connect(int type, MYSQL *mysql) { return FALSE; } - SPINE_LOG_DEBUG(("DEBUG: Total Connections made %i", connections)); - - connections++; + total_connections = atomic_fetch_add_explicit(&connections, 1, memory_order_relaxed) + 1; + SPINE_LOG_DEBUG(("DEBUG: Total Connections made %u", total_connections)); return TRUE; } diff --git a/src/database/lease.c b/src/database/lease.c new file mode 100644 index 00000000..ea65c960 --- /dev/null +++ b/src/database/lease.c @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include "database/lease.h" +#include + +typedef struct db_pool_ownership { + pool_t *slots; + size_t count; + pthread_mutex_t mutex; +} db_pool_ownership_t; + +size_t db_pool_owned_size(const pool_t *slots) { + if (slots == NULL || slots[0].ownership == NULL || slots[0].ownership->slots != slots) return 0; + return slots[0].ownership->count; +} + +int db_pool_ownership_init(pool_t *slots, size_t count) { + db_pool_ownership_t *owner; + size_t index; + int error; + if (slots == NULL || count == 0 || count > INT_MAX || slots[0].ownership != NULL) return EINVAL; + owner = calloc(1, sizeof(*owner)); + if (owner == NULL) return ENOMEM; + error = pthread_mutex_init(&owner->mutex, NULL); + if (error != 0) { + free(owner); + return error; + } + owner->slots = slots; + owner->count = count; + for (index = 0; index < count; index++) { + slots[index].ownership = owner; + slots[index].id = (int) index; + slots[index].generation = 0; + slots[index].leased = false; + } + return 0; +} + +int db_pool_ownership_destroy(pool_t *slots) { + db_pool_ownership_t *owner; + size_t index; + int error; + if (slots == NULL || slots[0].ownership == NULL) return EINVAL; + owner = slots[0].ownership; + if (owner->slots != slots) return EINVAL; + for (index = 0; index < owner->count; index++) { + if (slots[index].leased) return EBUSY; + } + /* Destruction belongs to the parent, after joins prevent new borrowers. */ + error = pthread_mutex_destroy(&owner->mutex); + if (error != 0) return error; + for (index = 0; index < owner->count; index++) slots[index].ownership = NULL; + free(owner); + return 0; +} + +pool_t *db_pool_take_owned(pool_t *slots, db_lease_t *lease) { + db_pool_ownership_t *owner; + size_t index; + pool_t *connection = NULL; + if (slots == NULL || lease == NULL || lease->connection != NULL || slots[0].ownership == NULL) return NULL; + owner = slots[0].ownership; + if (owner->slots != slots) return NULL; + pthread_mutex_lock(&owner->mutex); + for (index = 0; index < owner->count; index++) { + pool_t *slot = &slots[index]; + if (!slot->free || slot->leased || slot->generation == UINT64_MAX) continue; + slot->generation++; + slot->borrower = pthread_self(); + slot->leased = true; + slot->free = FALSE; + lease->connection = slot; + lease->index = index; + lease->generation = slot->generation; + connection = slot; + break; + } + pthread_mutex_unlock(&owner->mutex); + return connection; +} + +int db_pool_return_owned(pool_t *slots, db_lease_t *lease) { + db_pool_ownership_t *owner; + pool_t *slot; + int error = 0; + if (slots == NULL || lease == NULL || lease->connection == NULL || slots[0].ownership == NULL) return EINVAL; + owner = slots[0].ownership; + if (owner->slots != slots || lease->index >= owner->count) return EINVAL; + slot = &slots[lease->index]; + if (lease->connection != slot) return EINVAL; + pthread_mutex_lock(&owner->mutex); + if (!slot->leased || slot->generation != lease->generation || !pthread_equal(slot->borrower, pthread_self())) { + error = EPERM; + } else { + slot->leased = false; + slot->free = TRUE; + lease->connection = NULL; + } + pthread_mutex_unlock(&owner->mutex); + return error; +} diff --git a/src/database/lease.h b/src/database/lease.h new file mode 100644 index 00000000..a90ff028 --- /dev/null +++ b/src/database/lease.h @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#ifndef SPINE_DB_LEASE_H +#define SPINE_DB_LEASE_H +#include "internal/spine_types.h" + +typedef struct { + pool_t *connection; + size_t index; + uint64_t generation; + int type; +} db_lease_t; + +/* One owner per connection array. Slots are borrowed; its mutex and lease + * generations are owned until no borrower remains. Leases belong to their + * acquiring thread and cannot release a slot recycled for a later borrower. */ +int db_pool_ownership_init(pool_t *slots, size_t count); +int db_pool_ownership_destroy(pool_t *slots); +size_t db_pool_owned_size(const pool_t *slots); +pool_t *db_pool_take_owned(pool_t *slots, db_lease_t *lease); +int db_pool_return_owned(pool_t *slots, db_lease_t *lease); +bool db_acquire_lease(int type, db_lease_t *lease); +bool db_release_lease(db_lease_t *lease); +#endif diff --git a/src/database/persistence.c b/src/database/persistence.c index 582e22bc..f3be077a 100644 --- a/src/database/persistence.c +++ b/src/database/persistence.c @@ -49,6 +49,7 @@ poll_output_buffers_t write_poll_results(MYSQL *mysql, MYSQL *mysqlr, int new_buffer = TRUE; size_t out_buffer; MYSQL *mysqlt; + const char *suffix; int i; /* insert the query results into the database */ @@ -72,6 +73,7 @@ poll_output_buffers_t write_poll_results(MYSQL *mysql, MYSQL *mysqlr, } i = 0; + suffix = db_output_upsert_suffix(mysqlt); while (i < rows_processed) { char escaped_result[RESULTS_BUFFER * 2 + 1]; char escaped_rrd_name[DBL_BUFSIZE]; @@ -94,8 +96,8 @@ poll_output_buffers_t write_poll_results(MYSQL *mysql, MYSQL *mysqlr, /* if the next element to the buffer will overflow it, write to the database */ if ((out_buffer + result_length) >= MAX_MYSQL_BUF_SIZE) { - if (!flush_output_query(mysqlt, mode, query3, queries->suffix, queries->output)) failed = TRUE; - if (!flush_output_query(mysqlt, mode, query12, queries->suffix, queries->boost_output)) failed = TRUE; + if (!flush_output_query(mysqlt, mode, query3, suffix, queries->output)) failed = TRUE; + if (!flush_output_query(mysqlt, mode, query12, suffix, queries->boost_output)) failed = TRUE; /* reset the output buffer length */ out_buffer = strlen(query3); @@ -120,8 +122,8 @@ poll_output_buffers_t write_poll_results(MYSQL *mysql, MYSQL *mysqlr, /* perform the last insert if there is data to process */ if (out_buffer > strlen(queries->output)) { - if (!flush_output_query(mysqlt, mode, query3, queries->suffix, NULL)) failed = TRUE; - if (!flush_output_query(mysqlt, mode, query12, queries->suffix, NULL)) failed = TRUE; + if (!flush_output_query(mysqlt, mode, query3, suffix, NULL)) failed = TRUE; + if (!flush_output_query(mysqlt, mode, query12, suffix, NULL)) failed = TRUE; } /* MEMORY output tables can retain earlier rows after a rejected write. * Confirmed partial output remains; keep due items eligible for recollection. */ diff --git a/src/database/pool.c b/src/database/pool.c index 64828e45..fd7e5cbf 100644 --- a/src/database/pool.c +++ b/src/database/pool.c @@ -23,6 +23,11 @@ */ void db_create_connection_pool(int type) { int id; + pool_t *slots = type == LOCAL ? db_pool_local : type == REMOTE ? db_pool_remote + : NULL; + if (db_pool_ownership_init(slots, (size_t) set.poller.threads) != 0) { + die("FATAL: Unable to initialize database pool ownership"); + } if (type == LOCAL) { SPINE_LOG_DEBUG(("DEBUG: Creating Local Connection Pool of %i threads.", set.poller.threads)); @@ -60,21 +65,29 @@ void db_create_connection_pool(int type) { */ void db_close_connection_pool(int type) { int id; + pool_t *slots = type == LOCAL ? db_pool_local : type == REMOTE ? db_pool_remote + : NULL; + size_t count = db_pool_owned_size(slots); + if (db_pool_ownership_destroy(slots) != 0) { + die("FATAL: Cannot close a database pool while leases remain"); + } if (type == LOCAL) { - for (id = 0; id < set.poller.threads; id++) { + for (id = 0; (size_t) id < count; id++) { SPINE_LOG_DEBUG(("DEBUG: Closing Local Connection Pool ID %i", id)); db_disconnect(&db_pool_local[id].mysql); } free(db_pool_local); + db_pool_local = NULL; } else { - for (id = 0; id < set.poller.threads; id++) { + for (id = 0; (size_t) id < count; id++) { SPINE_LOG_DEBUG(("DEBUG: Closing Remote Connection Pool ID %i", id)); db_disconnect(&db_pool_remote[id].mysql); } free(db_pool_remote); + db_pool_remote = NULL; } } @@ -83,40 +96,12 @@ void db_close_connection_pool(int type) { * \param type the connection type, LOCAL or REMOTE * */ -pool_t *db_get_connection(int type) { - int id; - - thread_mutex_lock(LOCK_POOL); - - if (type == LOCAL) { - SPINE_LOG_DEBUG(("DEBUG: Traversing Local Connection Pool for free connection.")); - for (id = 0; id < set.poller.threads; id++) { - SPINE_LOG_DEBUG(("DEBUG: Checking Local Pool ID %i.", id)); - if (db_pool_local[id].free == TRUE) { - SPINE_LOG_DEBUG(("DEBUG: Allocating Local Pool ID %i.", id)); - db_pool_local[id].free = FALSE; - thread_mutex_unlock(LOCK_POOL); - return &db_pool_local[id]; - } - } - } else { - SPINE_LOG_DEBUG(("DEBUG: Traversing Remote Connection Pool for free connection.")); - for (id = 0; id < set.poller.threads; id++) { - SPINE_LOG_DEBUG(("DEBUG: Checking Remote Pool ID %i.", id)); - if (db_pool_remote[id].free == TRUE) { - SPINE_LOG_DEBUG(("DEBUG: Allocating Remote Pool ID %i.", id)); - db_pool_remote[id].free = FALSE; - thread_mutex_unlock(LOCK_POOL); - return &db_pool_remote[id]; - } - } - } - - SPINE_LOG(("FATAL: Connection Pool Fatal Error.")); - - thread_mutex_unlock(LOCK_POOL); - - return NULL; +bool db_acquire_lease(int type, db_lease_t *lease) { + pool_t *slots = type == LOCAL ? db_pool_local : type == REMOTE ? db_pool_remote + : NULL; + if (lease == NULL || db_pool_take_owned(slots, lease) == NULL) return FALSE; + lease->type = type; + return TRUE; } /*! \fn voi db_release_connection(int id) @@ -124,16 +109,15 @@ pool_t *db_get_connection(int type) { * \param id the connection id * */ -void db_release_connection(int type, int id) { - thread_mutex_lock(LOCK_POOL); - - if (type == LOCAL) { - SPINE_LOG_DEBUG(("DEBUG: Freeing Local Pool ID %i", id)); - db_pool_local[id].free = TRUE; - } else { - SPINE_LOG_DEBUG(("DEBUG: Freeing Remote Pool ID %i", id)); - db_pool_remote[id].free = TRUE; - } - - thread_mutex_unlock(LOCK_POOL); +bool db_release_lease(db_lease_t *lease) { + pool_t *slots; + if (lease == NULL) return FALSE; + slots = lease->type == LOCAL ? db_pool_local : lease->type == REMOTE ? db_pool_remote + : NULL; + if (db_pool_return_owned(slots, lease) == 0) return TRUE; + SPINE_LOG(("ERROR: Database lease release rejected; ownership does not match")); + thread_mutex_lock(LOCK_THDET); + set.exit.exit_code = EXIT_FAILURE; + thread_mutex_unlock(LOCK_THDET); + return FALSE; } diff --git a/src/database/sql.h b/src/database/sql.h index 8de9f86c..859949b1 100644 --- a/src/database/sql.h +++ b/src/database/sql.h @@ -15,6 +15,8 @@ #ifndef SPINE_SQL_H #define SPINE_SQL_H +#include "config/model.h" +#include "database/lease.h" typedef struct db_address { char *storage; char *hostname; @@ -31,8 +33,9 @@ extern void db_escape(MYSQL *mysql, char *output, int max_size, const char *inpu extern void db_free_result(MYSQL_RES *result); extern void db_create_connection_pool(int type); extern void db_close_connection_pool(int type); -extern pool_t *db_get_connection(int type); -extern void db_release_connection(int type, int id); +const char *db_output_upsert_suffix(MYSQL *mysql); +/* Explicit policies fail closed if the selected connector cannot enforce them. */ +bool db_apply_tls_policy(MYSQL *mysql, const spine_database_config_t *configuration); extern int db_set_session_mode(MYSQL *mysql); extern int db_reconnect(MYSQL *mysql, int type, int error, const char *location); extern int db_column_exists(MYSQL *mysql, int type, const char *table, const char *column); diff --git a/src/database/tls.c b/src/database/tls.c new file mode 100644 index 00000000..4bf475e5 --- /dev/null +++ b/src/database/tls.c @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include "internal/common.h" +#include "app/spine.h" +#ifdef HAVE_MARIADB_TLS_VERIFICATION_CALLBACK +/* The callback's opaque TLS type is declared in Connector/C's ma_tls.h. + * Some distributions install that header without its private ma_hash.h + * dependency. Probe the exported callback API without inspecting its layout. */ +struct st_ma_pvio_tls; +extern int ma_pvio_tls_verify_server_cert(struct st_ma_pvio_tls *, unsigned int); + +/* Connector/C 3.4 omits hostname checks for local addresses by default. + * Explicit verify-full requires identity checks even on those addresses. */ +static int verify_database_identity(struct st_ma_pvio_tls *tls, unsigned int flags) { + return ma_pvio_tls_verify_server_cert(tls, + flags | MARIADB_TLS_VERIFY_HOST | MARIADB_TLS_VERIFY_TRUST); +} + +static int verify_database_chain(struct st_ma_pvio_tls *tls, unsigned int flags) { + return ma_pvio_tls_verify_server_cert(tls, + (flags & ~MARIADB_TLS_VERIFY_HOST) | MARIADB_TLS_VERIFY_TRUST); +} + +/* Explicit 'required' matches MySQL's encryption-only mode. The connector's + * mandatory-TLS flag stays enabled; this callback omits peer authentication. */ +static int encryption_only(struct st_ma_pvio_tls *tls, unsigned int flags) { + (void) tls; + (void) flags; + return 0; +} +#endif + +static bool tls_option(MYSQL *mysql, enum mysql_option option, const void *value, const char *description) { + if (mysql_options(mysql, option, value) == 0) return TRUE; + SPINE_LOG(("ERROR: Database client could not apply TLS %s policy", description)); + return FALSE; +} + +bool db_apply_tls_policy(MYSQL *mysql, const spine_database_config_t *configuration) { + spine_tls_mode_t mode; + bool constrained; + const char *versions; + + if (mysql == NULL || configuration == NULL) return FALSE; + mode = configuration->tls_mode; + constrained = configuration->tls_min_version != 0 || + configuration->tls_cipher[0] != '\0' || configuration->tls_ciphersuites[0] != '\0'; + /* A protocol/cipher restriction cannot silently permit plaintext fallback. */ + if (constrained && mode < SPINE_TLS_REQUIRED) return FALSE; + if (configuration->tls_min_version != 0 && configuration->tls_min_version != 12 && + configuration->tls_min_version != 13) return FALSE; + if (mode < SPINE_TLS_LEGACY || mode > SPINE_TLS_VERIFY_FULL) return FALSE; + if (mode == SPINE_TLS_LEGACY) return TRUE; + if ((mode == SPINE_TLS_VERIFY_CA || mode == SPINE_TLS_VERIFY_FULL) && + configuration->ssl_ca[0] == '\0') return FALSE; + +#if HAVE_DECL_MYSQL_OPT_SSL_MODE + { + unsigned int native_mode; + switch (mode) { + case SPINE_TLS_DISABLED: native_mode = SSL_MODE_DISABLED; break; + case SPINE_TLS_PREFERRED: native_mode = SSL_MODE_PREFERRED; break; + case SPINE_TLS_REQUIRED: native_mode = SSL_MODE_REQUIRED; break; + case SPINE_TLS_VERIFY_CA: native_mode = SSL_MODE_VERIFY_CA; break; + case SPINE_TLS_VERIFY_FULL: native_mode = SSL_MODE_VERIFY_IDENTITY; break; + default: return FALSE; + } + if (!tls_option(mysql, MYSQL_OPT_SSL_MODE, &native_mode, "mode")) return FALSE; + } +#elif HAVE_DECL_MYSQL_OPT_SSL_ENFORCE && HAVE_DECL_MYSQL_OPT_SSL_VERIFY_SERVER_CERT + { + unsigned char enforce = 1; + unsigned char verify = mode != SPINE_TLS_PREFERRED; + if (mode == SPINE_TLS_DISABLED) return FALSE; + if (mode != SPINE_TLS_PREFERRED) { +#ifdef HAVE_MARIADB_TLS_VERIFICATION_CALLBACK + int (*callback)(struct st_ma_pvio_tls *, unsigned int) = mode == SPINE_TLS_REQUIRED ? encryption_only : mode == SPINE_TLS_VERIFY_CA ? verify_database_chain + : verify_database_identity; + if (!tls_option(mysql, MARIADB_OPT_TLS_VERIFICATION_CALLBACK, + (const void *) callback, "verification callback")) return FALSE; +#elif HAVE_DECL_MARIADB_OPT_TLS_VERIFICATION_CALLBACK + /* A connector exposing this newer policy must offer its complete + * verification API; do not silently accept its local-address bypass. */ + return FALSE; +#else + /* Older connectors cannot require encryption independently of + * peer verification. Their native verify-full remains supported. */ + if (mode != SPINE_TLS_VERIFY_FULL) return FALSE; +#endif + } + if (!tls_option(mysql, MYSQL_OPT_SSL_ENFORCE, &enforce, "enforcement") || + !tls_option(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &verify, "verification")) return FALSE; + } +#else + return FALSE; +#endif + if (configuration->tls_min_version != 0) { + versions = configuration->tls_min_version == 12 ? "TLSv1.2,TLSv1.3" : "TLSv1.3"; +#if HAVE_DECL_MYSQL_OPT_TLS_VERSION + if (!tls_option(mysql, MYSQL_OPT_TLS_VERSION, versions, "minimum version")) return FALSE; +#else + (void) versions; + return FALSE; +#endif + } + if (configuration->tls_cipher[0] != '\0') { +#if HAVE_DECL_MYSQL_OPT_SSL_CIPHER + if (!tls_option(mysql, MYSQL_OPT_SSL_CIPHER, configuration->tls_cipher, "cipher")) return FALSE; +#else + return FALSE; +#endif + } + if (configuration->tls_ciphersuites[0] != '\0') { +#if HAVE_DECL_MYSQL_OPT_TLS_CIPHERSUITES + if (!tls_option(mysql, MYSQL_OPT_TLS_CIPHERSUITES, configuration->tls_ciphersuites, "TLS 1.3 ciphersuite")) return FALSE; +#else + return FALSE; +#endif + } + return TRUE; +} diff --git a/src/internal/constants.h b/src/internal/constants.h index 379fe199..1adbc60b 100644 --- a/src/internal/constants.h +++ b/src/internal/constants.h @@ -162,7 +162,6 @@ #define LOCK_SNMP 0 #define LOCK_ICMP 2 #define LOCK_GHBN 3 -#define LOCK_POOL 4 #define LOCK_PHP 6 #define LOCK_PHP_PROC_0 7 #define LOCK_PHP_PROC_1 8 @@ -180,12 +179,10 @@ #define LOCK_PHP_PROC_13 20 #define LOCK_PHP_PROC_14 21 #define LOCK_THDET 40 -#define LOCK_HOST_TIME 41 #define LOCK_SNMP_O 0 #define LOCK_ICMP_O 2 #define LOCK_GHBN_O 3 -#define LOCK_POOL_O 4 #define LOCK_PHP_O 6 #define LOCK_PHP_PROC_0_O 7 #define LOCK_PHP_PROC_1_O 8 @@ -203,7 +200,6 @@ #define LOCK_PHP_PROC_13_O 20 #define LOCK_PHP_PROC_14_O 21 #define LOCK_THDET_O 40 -#define LOCK_HOST_TIME_O 41 /* poller actions */ #define POLLER_ACTION_SNMP 0 diff --git a/src/internal/spine_types.h b/src/internal/spine_types.h index 6db4502d..539e4fc2 100644 --- a/src/internal/spine_types.h +++ b/src/internal/spine_types.h @@ -215,6 +215,10 @@ typedef struct db_connection { int id; int free; MYSQL mysql; + struct db_pool_ownership *ownership; + pthread_t borrower; + uint64_t generation; + bool leased; } pool_t; #endif /* SPINE_TYPES_H */ diff --git a/src/log/log.c b/src/log/log.c index 5146510a..9036226d 100644 --- a/src/log/log.c +++ b/src/log/log.c @@ -177,7 +177,7 @@ static bool log_to_file(const char *message) { if (close(fd) != 0) success = FALSE; } if (!success && !reported_error) { - printf("%s", set.logging.json_logs ? "{\"severity\":\"ERROR\",\"message\":\"Log file write failed\"}\n" : "ERROR: Spine Log File Could Not Be Opened/Created or Written\n"); + printf("%s", set.logging.json_logs ? "{\"timestamp\":\"unknown\",\"severity\":\"ERROR\",\"message\":\"Log file write failed\"}\n" : "ERROR: Spine Log File Could Not Be Opened/Created or Written\n"); reported_error = TRUE; } if (pthread_mutex_unlock(&mutex) != 0) success = FALSE; @@ -218,7 +218,7 @@ static log_buffers_t *log_buffers(void) { _Static_assert((size_t) LOGSIZE <= (SIZE_MAX - 256) / 6, "JSON log allocation size overflows"); static int log_json_message(const char *message) { - static const char failure[] = "{\"severity\":\"ERROR\",\"message\":\"JSON log rejected: invalid UTF-8 or formatting failure\"}\n"; + static const char failure[] = "{\"timestamp\":\"unknown\",\"severity\":\"ERROR\",\"message\":\"JSON log rejected: invalid UTF-8 or formatting failure\"}\n"; const char *severity = strstr(message, "FATAL") ? "FATAL" : strstr(message, "ERROR") ? "ERROR" : strstr(message, "WARNING") ? "WARNING" : "INFO"; @@ -233,6 +233,7 @@ static int log_json_message(const char *message) { valid = gmtime_r(&now, &utc) != NULL && strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &utc) != 0; + if (!valid) strcpy(timestamp, "unknown"); owned = malloc(length * 6 + 256); if (owned != NULL && spine_json_log_record(owned, length * 6 + 256, timestamp, severity, message)) { record = owned; diff --git a/src/platform/locks.c b/src/platform/locks.c index 8519b36e..352ad3e6 100644 --- a/src/platform/locks.c +++ b/src/platform/locks.c @@ -34,7 +34,6 @@ DEFINE_SPINE_LOCK(snmp) DEFINE_SPINE_LOCK(icmp) DEFINE_SPINE_LOCK(ghbn) -DEFINE_SPINE_LOCK(pool) DEFINE_SPINE_LOCK(php) DEFINE_SPINE_LOCK(php_proc_0) DEFINE_SPINE_LOCK(php_proc_1) @@ -52,13 +51,11 @@ DEFINE_SPINE_LOCK(php_proc_12) DEFINE_SPINE_LOCK(php_proc_13) DEFINE_SPINE_LOCK(php_proc_14) DEFINE_SPINE_LOCK(thdet) -DEFINE_SPINE_LOCK(host_time) void init_mutexes(void) { pthread_once(get_attr(LOCK_SNMP_O), init_snmp_lock); pthread_once(get_attr(LOCK_ICMP_O), init_icmp_lock); pthread_once(get_attr(LOCK_GHBN_O), init_ghbn_lock); - pthread_once(get_attr(LOCK_POOL_O), init_pool_lock); pthread_once(get_attr(LOCK_PHP_O), init_php_lock); pthread_once(get_attr(LOCK_PHP_PROC_0_O), init_php_proc_0_lock); pthread_once(get_attr(LOCK_PHP_PROC_1_O), init_php_proc_1_lock); @@ -76,7 +73,6 @@ void init_mutexes(void) { pthread_once(get_attr(LOCK_PHP_PROC_13_O), init_php_proc_13_lock); pthread_once(get_attr(LOCK_PHP_PROC_14_O), init_php_proc_14_lock); pthread_once(get_attr(LOCK_THDET_O), init_thdet_lock); - pthread_once(get_attr(LOCK_HOST_TIME_O), init_host_time_lock); } const char *get_name(int lock) { @@ -84,7 +80,6 @@ const char *get_name(int lock) { case LOCK_SNMP: return "snmp"; case LOCK_ICMP: return "icmp"; case LOCK_GHBN: return "ghbn"; - case LOCK_POOL: return "pool"; case LOCK_PHP: return "php"; case LOCK_PHP_PROC_0: return "php_proc_0"; case LOCK_PHP_PROC_1: return "php_proc_1"; @@ -102,7 +97,6 @@ const char *get_name(int lock) { case LOCK_PHP_PROC_13: return "php_proc_13"; case LOCK_PHP_PROC_14: return "php_proc_14"; case LOCK_THDET: return "thdet"; - case LOCK_HOST_TIME: return "host_time"; default: return "Unknown lock"; } } @@ -114,7 +108,6 @@ pthread_cond_t *get_cond(int lock) { case LOCK_SNMP: ret_val = &snmp_cond; break; case LOCK_ICMP: ret_val = &icmp_cond; break; case LOCK_GHBN: ret_val = &ghbn_cond; break; - case LOCK_POOL: ret_val = &pool_cond; break; case LOCK_PHP: ret_val = &php_cond; break; case LOCK_PHP_PROC_0: ret_val = &php_proc_0_cond; break; case LOCK_PHP_PROC_1: ret_val = &php_proc_1_cond; break; @@ -132,7 +125,6 @@ pthread_cond_t *get_cond(int lock) { case LOCK_PHP_PROC_13: ret_val = &php_proc_13_cond; break; case LOCK_PHP_PROC_14: ret_val = &php_proc_14_cond; break; case LOCK_THDET: ret_val = &thdet_cond; break; - case LOCK_HOST_TIME: ret_val = &host_time_cond; break; default: break; } @@ -148,7 +140,6 @@ pthread_mutex_t *get_lock(int lock) { case LOCK_SNMP: ret_val = &snmp_lock; break; case LOCK_ICMP: ret_val = &icmp_lock; break; case LOCK_GHBN: ret_val = &ghbn_lock; break; - case LOCK_POOL: ret_val = &pool_lock; break; case LOCK_PHP: ret_val = &php_lock; break; case LOCK_PHP_PROC_0: ret_val = &php_proc_0_lock; break; case LOCK_PHP_PROC_1: ret_val = &php_proc_1_lock; break; @@ -166,7 +157,6 @@ pthread_mutex_t *get_lock(int lock) { case LOCK_PHP_PROC_13: ret_val = &php_proc_13_lock; break; case LOCK_PHP_PROC_14: ret_val = &php_proc_14_lock; break; case LOCK_THDET: ret_val = &thdet_lock; break; - case LOCK_HOST_TIME: ret_val = &host_time_lock; break; default: break; } @@ -182,7 +172,6 @@ pthread_once_t *get_attr(int locko) { case LOCK_SNMP_O: ret_val = &snmp_lock_o; break; case LOCK_ICMP_O: ret_val = &icmp_lock_o; break; case LOCK_GHBN_O: ret_val = &ghbn_lock_o; break; - case LOCK_POOL_O: ret_val = &pool_lock_o; break; case LOCK_PHP_O: ret_val = &php_lock_o; break; case LOCK_PHP_PROC_0_O: ret_val = &php_proc_0_lock_o; break; case LOCK_PHP_PROC_1_O: ret_val = &php_proc_1_lock_o; break; @@ -200,7 +189,6 @@ pthread_once_t *get_attr(int locko) { case LOCK_PHP_PROC_13_O: ret_val = &php_proc_13_lock_o; break; case LOCK_PHP_PROC_14_O: ret_val = &php_proc_14_lock_o; break; case LOCK_THDET_O: ret_val = &thdet_lock_o; break; - case LOCK_HOST_TIME_O: ret_val = &host_time_lock_o; break; default: break; } diff --git a/src/platform/worker_set.c b/src/platform/worker_set.c new file mode 100644 index 00000000..2f552023 --- /dev/null +++ b/src/platform/worker_set.c @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include "platform/worker_set.h" +#include +#include +#include + +static void finish_worker(void *argument) { + spine_worker_slot_t *slot = argument; + int previous_state; + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &previous_state); + pthread_mutex_lock(&slot->owner->mutex); + slot->finished = true; + pthread_mutex_unlock(&slot->owner->mutex); + pthread_setcancelstate(previous_state, NULL); +} + +void *spine_worker_entry(void *argument) { + spine_worker_slot_t *slot = argument; + void *result; + pthread_cleanup_push(finish_worker, slot); + result = slot->function(slot->argument); + pthread_cleanup_pop(1); + return result; +} + +int spine_worker_set_init(spine_worker_set_t *workers, size_t capacity) { + int error; + if (workers == NULL || workers->slots != NULL || capacity == 0 || capacity > SIZE_MAX / sizeof(*workers->slots)) return EINVAL; + workers->slots = calloc(capacity, sizeof(*workers->slots)); + if (workers->slots == NULL) return ENOMEM; + error = pthread_mutex_init(&workers->mutex, NULL); + if (error != 0) { + free(workers->slots); + workers->slots = NULL; + return error; + } + workers->capacity = capacity; + return 0; +} + +int spine_worker_set_reap(spine_worker_set_t *workers, size_t *remaining) { + size_t index; + size_t count = 0; + if (workers == NULL || workers->slots == NULL || remaining == NULL) return EINVAL; + for (index = 0; index < workers->capacity; index++) { + spine_worker_slot_t *slot = &workers->slots[index]; + bool finished; + int error; + pthread_mutex_lock(&workers->mutex); + finished = slot->occupied && slot->finished; + pthread_mutex_unlock(&workers->mutex); + if (finished) { + error = pthread_join(slot->thread, NULL); + if (error != 0) return error; + slot->occupied = false; + slot->argument = NULL; + } + if (slot->occupied) count++; + } + *remaining = count; + return 0; +} + +int spine_worker_set_start(spine_worker_set_t *workers, const pthread_attr_t *attributes, + void *(*function)(void *), void *argument) { + size_t remaining; + size_t index; + int error; + int detached; + if (function == NULL || attributes == NULL) return EINVAL; + error = pthread_attr_getdetachstate(attributes, &detached); + if (error != 0) return error; + if (detached != PTHREAD_CREATE_JOINABLE) return EINVAL; + error = spine_worker_set_reap(workers, &remaining); + if (error != 0) return error; + for (index = 0; index < workers->capacity; index++) { + spine_worker_slot_t *slot = &workers->slots[index]; + if (slot->occupied) continue; + slot->owner = workers; + slot->function = function; + slot->argument = argument; + slot->finished = false; + slot->occupied = true; + error = pthread_create(&slot->thread, attributes, spine_worker_entry, slot); + if (error != 0) { + slot->occupied = false; + slot->argument = NULL; + } + return error; + } + return EAGAIN; +} + +int spine_worker_set_destroy(spine_worker_set_t *workers) { + size_t remaining; + int error = spine_worker_set_reap(workers, &remaining); + if (error != 0) return error; + if (remaining != 0) return EBUSY; + error = pthread_mutex_destroy(&workers->mutex); + if (error != 0) return error; + free(workers->slots); + workers->slots = NULL; + workers->capacity = 0; + return 0; +} diff --git a/src/platform/worker_set.h b/src/platform/worker_set.h new file mode 100644 index 00000000..1fe2398c --- /dev/null +++ b/src/platform/worker_set.h @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#ifndef SPINE_WORKER_SET_H +#define SPINE_WORKER_SET_H +#include +#include +#include + +typedef struct spine_worker_set spine_worker_set_t; +typedef struct { + pthread_t thread; + spine_worker_set_t *owner; + void *(*function)(void *); + void *argument; + bool occupied; + bool finished; +} spine_worker_slot_t; + +/* The parent owns this bounded registry until every child has been joined. + * Arguments retain the callback's ownership contract. A completion flag only + * permits joining; it never permits freeing shared runtime state by itself. */ +struct spine_worker_set { + spine_worker_slot_t *slots; + size_t capacity; + pthread_mutex_t mutex; +}; + +/* Initialize a zeroed registry. Only its parent calls start/reap/destroy. */ +int spine_worker_set_init(spine_worker_set_t *workers, size_t capacity); +int spine_worker_set_start(spine_worker_set_t *workers, const pthread_attr_t *attributes, + void *(*function)(void *), void *argument); +int spine_worker_set_reap(spine_worker_set_t *workers, size_t *remaining); +int spine_worker_set_destroy(spine_worker_set_t *workers); +void *spine_worker_entry(void *argument); +#endif diff --git a/src/poller/poller.c b/src/poller/poller.c index 36c118f8..0f46b916 100644 --- a/src/poller/poller.c +++ b/src/poller/poller.c @@ -135,15 +135,15 @@ enum poll_result_status normalize_poll_result(char *result, bool snmp) { } static void release_poll_connections(const poller_thread_t *work, - const pool_t *local, const pool_t *remote) { - if (local != NULL) { - db_release_connection(LOCAL, local->id); + db_lease_t *local, db_lease_t *remote) { + if (local->connection != NULL) { + db_release_lease(local); } else { SPINE_LOG(("WARNING: Device[%i] HT[%i] Trying to close uninitialized local connection.", work->host_id, work->host_thread)); } if (set.poller.poller_id > 1 && set.poller.mode == REMOTE_ONLINE) { - if (remote != NULL) { - db_release_connection(REMOTE, remote->id); + if (remote->connection != NULL) { + db_release_lease(remote); } else { SPINE_LOG(("WARNING: Device[%i] HT[%i] Trying to close uninitialized remote connection.", work->host_id, work->host_thread)); } @@ -301,6 +301,10 @@ void poll_host(const poller_thread_t *work, int *host_errors) { pool_t *local_cnn = NULL; pool_t *remote_cnn = NULL; + db_lease_t local_lease = {0}; + db_lease_t remote_lease = {0}; + int old_cancel_state; + bool report_completion = FALSE; reindex_t *reindex = NULL; host_t *host = NULL; @@ -318,13 +322,17 @@ void poll_host(const poller_thread_t *work, int *host_errors) { MYSQL *mysql; MYSQL *mysqlr = NULL; - MYSQL_RES *result; + MYSQL_RES *result = NULL; - local_cnn = db_get_connection(LOCAL); + /* A device's bounded I/O completes ownership transfers before cancellation + * is delivered. Helpers may allocate private resources before returning; + * cancellation must not strand those resources or a database lease. */ + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &old_cancel_state); + if (db_acquire_lease(LOCAL, &local_lease)) local_cnn = local_lease.connection; if (local_cnn != NULL && set.poller.poller_id > 1 && set.poller.mode == REMOTE_ONLINE) { - remote_cnn = db_get_connection(REMOTE); + if (db_acquire_lease(REMOTE, &remote_lease)) remote_cnn = remote_lease.connection; if (remote_cnn == NULL) { - db_release_connection(LOCAL, local_cnn->id); + db_release_lease(&local_lease); local_cnn = NULL; } } @@ -333,6 +341,7 @@ void poll_host(const poller_thread_t *work, int *host_errors) { SPINE_LOG(("ERROR: Device[%i] HT[%i] No database connection available for polling", host_id, host_thread)); complete_poll_host(NULL, NULL, work, &error_context, FALSE, TRUE, poll_time); mysql_thread_end(); + pthread_setcancelstate(old_cancel_state, NULL); return; } @@ -372,29 +381,11 @@ void poll_host(const poller_thread_t *work, int *host_errors) { complete_poll_host(mysql, &queries, work, &error_context, FALSE, TRUE, poll_time); } if (loaded != POLL_HOST_LOADED) { - release_poll_connections(work, local_cnn, remote_cnn); - SPINE_FREE(host); - SPINE_FREE(reindex); - SPINE_FREE(ping); - mysql_thread_end(); - - return; + goto finish_poll; } if (set.availability.ping_only) { - if (host->snmp.session != NULL) { - snmp_host_cleanup(host->snmp.session); - host->snmp.session = NULL; - } - SPINE_FREE(host); - SPINE_FREE(reindex); - SPINE_FREE(ping); - - release_poll_connections(work, local_cnn, remote_cnn); - - mysql_thread_end(); - - return; + goto finish_poll; } const reindex_evaluation_t evaluation = {mysql, mysqlr, host, work, &errors, &spike_kill}; @@ -410,6 +401,7 @@ void poll_host(const poller_thread_t *work, int *host_errors) { if (num_rows > 0) { poll_item_storage_t storage = load_poll_items(result, host, num_rows); + result = NULL; /* load_poll_items consumes the result. */ poller_items = storage.items; snmp_oids = storage.oids; @@ -423,42 +415,37 @@ void poll_host(const poller_thread_t *work, int *host_errors) { query3 = output.output; query12 = output.boost; output_failed = output.failed; + } - /* cleanup memory and prepare for function exit */ - if (host->snmp.session != NULL) { - snmp_host_cleanup(host->snmp.session); - host->snmp.session = NULL; - } - - SPINE_FREE(query3); - if (query12 != NULL) { - SPINE_FREE(query12); - } + complete_poll_host(mysql, &queries, work, &error_context, output_failed, poll_failed, poll_time); + report_completion = TRUE; - SPINE_FREE(poller_items); - SPINE_FREE(snmp_oids); - } else { - /* free the mysql result */ - db_free_result(result); +finish_poll: + if (host != NULL && host->snmp.session != NULL) { + snmp_host_cleanup(host->snmp.session); + host->snmp.session = NULL; } - + if (result != NULL) db_free_result(result); + SPINE_FREE(query3); + SPINE_FREE(query12); + SPINE_FREE(poller_items); + SPINE_FREE(snmp_oids); SPINE_FREE(host); SPINE_FREE(reindex); SPINE_FREE(ping); - complete_poll_host(mysql, &queries, work, &error_context, output_failed, poll_failed, poll_time); - - release_poll_connections(work, local_cnn, remote_cnn); + release_poll_connections(work, &local_lease, &remote_lease); mysql_thread_end(); - SPINE_LOG_DEVICE(host_id, POLLER_VERBOSITY_DEBUG, ("Device[%i] HT[%i] DEBUG: HOST COMPLETE: About to Exit Device Polling Thread Function", host_id, host_thread)); + if (report_completion) SPINE_LOG_DEVICE(host_id, POLLER_VERBOSITY_DEBUG, ("Device[%i] HT[%i] DEBUG: HOST COMPLETE: About to Exit Device Polling Thread Function", host_id, host_thread)); - if (set.logging.spine_log_level == 1) { + if (report_completion && set.logging.spine_log_level == 1) { buffer_output_errors(error_string, &buf_size, &buf_errors, host_id, host_thread, 0, true); } *host_errors = errors; + pthread_setcancelstate(old_cancel_state, NULL); } /*! \fn void buffer_output_errors(local_data_id) { diff --git a/src/poller/workers.c b/src/poller/workers.c index 9243398f..2547a4f1 100644 --- a/src/poller/workers.c +++ b/src/poller/workers.c @@ -127,7 +127,7 @@ static void prepare_device_partition(MYSQL *mysql, int host_id, int current_thre } static bool start_poll_worker(const poller_thread_t *device, int current_thread, spine_permits_t *startup, - const pthread_attr_t *attributes, pthread_t *thread) { + const pthread_attr_t *attributes, spine_worker_set_t *workers) { if (!acquire_worker_permits(startup, device->host_id, current_thread)) { thread_mutex_lock(LOCK_THDET); set.exit.exit_code = EXIT_FAILURE; @@ -142,11 +142,11 @@ static bool start_poll_worker(const poller_thread_t *device, int current_thread, worker->host_thread = current_thread; int status; do { - status = pthread_create(thread, attributes, child, worker); + status = spine_worker_set_start(workers, attributes, child, worker); if (status == EAGAIN) spine_sleep_usec(10000); } while (status == EAGAIN && get_time_as_double() - start_time < set.poller.poller_interval); if (status == 0) { - SPINE_LOG_DEBUG(("Device[%i] DEBUG: Valid Thread to be Created (%ld)", device->host_id, (unsigned long int) *thread)); + SPINE_LOG_DEBUG(("Device[%i] DEBUG: Polling worker created", device->host_id)); return TRUE; } SPINE_LOG(("ERROR: Device[%i] HT[%i] unable to create polling thread (error %d)", device->host_id, current_thread, status)); @@ -170,15 +170,22 @@ static void report_worker_launch(const poller_thread_t *device, int device_count thread_mutex_unlock(LOCK_THDET); } -int wait_for_workers(double begin_time) { +int wait_for_workers(double begin_time, spine_worker_set_t *workers) { int a_threads_value; double cur_time; + size_t remaining; a_threads_value = spine_permits_available(&available_threads); /* wait for all threads to 'complete' * using the mutex here as the semaphore will * show zero before the children are done */ - while (a_threads_value < set.poller.threads) { + for (;;) { + int error = spine_worker_set_reap(workers, &remaining); + if (error != 0) { + set.exit.exit_code = EXIT_FAILURE; + die("ERROR: Unable to join polling workers (error %i)", error); + } + if (remaining == 0 && a_threads_value == set.poller.threads) break; cur_time = get_time_as_double(); if (cur_time - begin_time > set.poller.poller_interval) { @@ -241,7 +248,7 @@ void report_worker_completion(int num_rows) { } void launch_poll_workers(MYSQL *mysql, MYSQL_RES *result, int num_rows, - pthread_t *threads, spine_permits_t *thread_init_sem, + spine_worker_set_t *workers, spine_permits_t *thread_init_sem, const pthread_attr_t *attr, char *host_time) { poller_partition_t partition = {.timestamp = host_time}; poller_thread_t *poller_details = NULL; @@ -328,7 +335,7 @@ void launch_poll_workers(MYSQL *mysql, MYSQL_RES *result, int num_rows, } - if (start_poll_worker(poller_details, current_thread, thread_init_sem, attr, &threads[device_counter])) { + if (start_poll_worker(poller_details, current_thread, thread_init_sem, attr, workers)) { if (change_host) device_counter++; report_worker_launch(poller_details, device_counter); } else { @@ -338,9 +345,8 @@ void launch_poll_workers(MYSQL *mysql, MYSQL_RES *result, int num_rows, } void prepare_worker_storage(MYSQL_RES *result, int *rows, - pthread_t **worker_threads, int **host_ids, char **timestamp) { + int **host_ids, char **timestamp) { int num_rows; - pthread_t *threads = NULL; int *ids = NULL; char *host_time = NULL; if (set.poller.poller_id == 1) { @@ -354,10 +360,6 @@ void prepare_worker_storage(MYSQL_RES *result, int *rows, } if (num_rows > 0) { - if (!(threads = malloc(num_rows * sizeof(*threads)))) { - die("ERROR: Fatal malloc error: spine.c threads!"); - } - if (!(details = (poller_thread_t **) calloc((size_t) num_rows, sizeof(poller_thread_t *)))) { die("ERROR: Fatal malloc error: spine.c details!"); } @@ -375,12 +377,11 @@ void prepare_worker_storage(MYSQL_RES *result, int *rows, *rows = num_rows; - *worker_threads = threads; *host_ids = ids; *timestamp = host_time; } -void free_worker_storage(int num_rows, pthread_t *threads, int *ids, +void free_worker_storage(int num_rows, int *ids, char *conf_file, char *host_time) { /* free malloc'd variables */ for (int i = 0; i < num_rows; i++) { @@ -390,7 +391,6 @@ void free_worker_storage(int num_rows, pthread_t *threads, int *ids, } SPINE_FREE(details); - SPINE_FREE(threads); SPINE_FREE(ids); SPINE_FREE(conf_file); SPINE_FREE(debug_devices); diff --git a/src/process/nft_popen.c b/src/process/nft_popen.c index 0148fefe..3f2a8b80 100644 --- a/src/process/nft_popen.c +++ b/src/process/nft_popen.c @@ -167,14 +167,22 @@ static __attribute__((noinline)) struct pid *pid_list_close_and_take(int fd) { /* WARNING: command is passed to /bin/sh -c without shell escaping. * The caller MUST ensure command originates from a trusted source * (the Cacti database). Do not pass user-controlled input directly. */ -int nft_popen(const char *command, const char *type) { +int nft_popen_limited(const char *command, const char *type, const spine_script_limits_t *limits) { struct pid *cur; struct pid *p; int pdes[2]; int inherit_fd = -1; int fd, twoway; pid_t pid; - char *argv[4]; + char *argv[10]; + char limit_values[4][32]; + bool resources = limits != NULL && (limits->cpu_seconds || limits->memory_kib || limits->file_kib || limits->open_files); + static char limit_policy[] = + "if [ \"$1\" -ne 0 ]; then ulimit -t \"$1\" || exit 125; fi; " + "if [ \"$2\" -ne 0 ]; then ulimit -v \"$2\" || exit 125; fi; " + "if [ \"$3\" -ne 0 ]; then ulimit -f \"$3\" || exit 125; fi; " + "if [ \"$4\" -ne 0 ]; then ulimit -n \"$4\" || exit 125; fi; " + "exec /bin/sh -c \"$5\""; char *command_copy; char shell_cmd[] = "sh"; char shell_flag[] = "-c"; @@ -192,6 +200,12 @@ int nft_popen(const char *command, const char *type) { errno = EINVAL; return -1; } +#ifndef __linux__ + if (resources) { + errno = ENOTSUP; + return -1; + } +#endif /* On platforms where pipe() is bidirectional, * "r+" gives two-way communication. Only the documented modes are @@ -244,6 +258,23 @@ int nft_popen(const char *command, const char *type) { argv[1] = shell_flag; argv[2] = command_copy; argv[3] = NULL; + if (resources) { + /* Policy is constant code. Numeric limits and the trusted command are + * separate positional arguments, never concatenated into that code. + * /bin/sh's POSIX file-size unit is a 512-byte block. */ + snprintf(limit_values[0], sizeof(limit_values[0]), "%u", limits->cpu_seconds); + snprintf(limit_values[1], sizeof(limit_values[1]), "%u", limits->memory_kib); + snprintf(limit_values[2], sizeof(limit_values[2]), "%llu", (unsigned long long) limits->file_kib * 2); + snprintf(limit_values[3], sizeof(limit_values[3]), "%u", limits->open_files); + argv[2] = limit_policy; + argv[3] = (char *) "spine-script-limits"; + argv[4] = limit_values[0]; + argv[5] = limit_values[1]; + argv[6] = limit_values[2]; + argv[7] = limit_values[3]; + argv[8] = command_copy; + argv[9] = NULL; + } /* Lock the list mutex prior to forking, to ensure that * the child process sees PidList in a consistent list state. @@ -432,6 +463,10 @@ int nft_popen(const char *command, const char *type) { return fd; } +int nft_popen(const char *command, const char *type) { + return nft_popen_limited(command, type, NULL); +} + /*! ------------------------------------------------------------------------------ * * nft_pchild diff --git a/src/process/nft_popen.h b/src/process/nft_popen.h index 441740fe..4229bc2c 100644 --- a/src/process/nft_popen.h +++ b/src/process/nft_popen.h @@ -36,6 +36,7 @@ */ #include +#include "config/model.h" /*! * The nft_popen() function forks a command in a child process, and returns @@ -56,6 +57,9 @@ * EAGAIN fork() failed. */ extern int nft_popen(const char *command, const char *mode); +/* Per-process resource caps are supported by the Linux shell backend only. + * An explicit unsupported policy fails before spawning a child. */ +extern int nft_popen_limited(const char *command, const char *mode, const spine_script_limits_t *limits); /*! * nft_pchild diff --git a/src/script/external.c b/src/script/external.c index 21f6baeb..09f8756a 100644 --- a/src/script/external.c +++ b/src/script/external.c @@ -15,6 +15,70 @@ #include "internal/common.h" #include "app/spine.h" +#include + +static bool script_limits_enabled(void) { + const spine_script_limits_t *limits = &set.php.limits; + return limits->deadline_seconds || limits->output_bytes || limits->cpu_seconds || + limits->memory_kib || limits->file_kib || limits->open_files; +} + +static bool read_limited_script(const host_t *host, int fd, double deadline, char *result) { + size_t used = 0; + size_t maximum = set.php.limits.output_bytes ? set.php.limits.output_bytes : RESULTS_BUFFER - 1; + char chunk[RESULTS_BUFFER]; + int ready; + ssize_t bytes; + if (maximum >= RESULTS_BUFFER) { + SET_UNDEFINED(result); + return FALSE; + } + while ((ready = spine_wait_readable(fd, deadline)) > 0) { + bytes = read(fd, chunk, sizeof(chunk)); + if (bytes < 0 && errno == EINTR) continue; + if (bytes == 0 && used != 0) { + result[used] = '\0'; + return TRUE; + } + if (bytes <= 0) { + SPINE_LOG(("Device[%i] ERROR: Script policy rejected empty output or a read failure", host->id)); + break; + } + if ((size_t) bytes > maximum - used) { + SPINE_LOG(("Device[%i] ERROR: Script output exceeded its configured byte limit", host->id)); + break; + } + memcpy(result + used, chunk, (size_t) bytes); + used += (size_t) bytes; + } + if (ready == 0) SPINE_LOG(("Device[%i] ERROR: Script execution exceeded its configured deadline", host->id)); + if (ready < 0) SPINE_LOG(("Device[%i] ERROR: Script policy could not wait for output", host->id)); + SET_UNDEFINED(result); + return FALSE; +} + +typedef struct { + char *result; + char *command; + int fd; + int host_id; + bool permit; +} script_cleanup_t; + +static void cleanup_script_poll(void *argument) { + script_cleanup_t *owned = argument; + int pid; + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + if (owned->fd >= 0) { + pid = nft_pchild(owned->fd); + if (pid > 1) kill(-pid, SIGKILL); + nft_pclose(owned->fd); + } + if (owned->permit) spine_permits_release(&available_scripts); + free(owned->command); + free(owned->result); + SPINE_LOG(("Device[%i] ERROR: Script execution cancelled and owned resources released", owned->host_id)); +} static int acquire_script_permit(const host_t *host) { if (set.php.script_timeout <= 0) return EINVAL; @@ -110,6 +174,19 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) { double deadline; char *proc_command; char *result_string; + int previous_state; + int previous_type; + int status; + int child_pid; + int spawn_error; + bool valid_result; + unsigned int execution_seconds; + bool limited = script_limits_enabled(); + script_cleanup_t owned = {NULL, NULL, -1, current_host->id, FALSE}; + /* Deferred cancellation allows ownership to be registered between APIs. + * Cancellation remains supported while waiting for a permit or output. */ + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &previous_state); + pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, &previous_type); /* compensate for back slashes in arguments */ #if defined(__CYGWIN__) @@ -124,17 +201,21 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) { /* set zeros */ memset(result_string, 0, RESULTS_BUFFER); + owned.result = result_string; +#if defined(__CYGWIN__) + owned.command = proc_command; +#endif /* don't run too many scripts, operating systems do not like that. */ int sem_err; - int needs_cleanup = 0; /* used for checking executable status */ char executable[BUFSIZE]; char *saveptr = NULL; - pthread_cleanup_push(child_cleanup_script, NULL); + pthread_cleanup_push(cleanup_script_poll, &owned); + pthread_setcancelstate(previous_state, NULL); sem_err = acquire_script_permit(current_host); @@ -142,14 +223,17 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) { SPINE_LOG(("ERROR: Device[%i]: Failed to obtain a script execution lock (error %d)", current_host->id, sem_err)); SET_UNDEFINED(result_string); #if defined(__CYGWIN__) - SPINE_FREE(proc_command); + /* The common ownership cleanup releases this allocation. */ #endif } else { /* Mark for cleanup */ - needs_cleanup = 1; + owned.permit = TRUE; /* record start time */ - deadline = spine_monotonic_time() + set.php.script_timeout; + execution_seconds = (unsigned int) set.php.script_timeout; + if (set.php.limits.deadline_seconds && set.php.limits.deadline_seconds < execution_seconds) + execution_seconds = set.php.limits.deadline_seconds; + deadline = spine_monotonic_time() + execution_seconds; /* peel the executable from the command */ saveptr = proc_command; @@ -167,17 +251,38 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) { SPINE_LOG_DEBUG(("The executable is '%s'", executable)); if (access(executable, X_OK | F_OK) != -1) { - cmd_fd = nft_popen(proc_command, "r"); + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + cmd_fd = nft_popen_limited(proc_command, "r", &set.php.limits); + spawn_error = errno; + owned.fd = cmd_fd; + pthread_setcancelstate(previous_state, NULL); SPINE_LOG_DEVICE(current_host->id, POLLER_VERBOSITY_DEBUG, ("Device[%i] DEBUG: The NIFTY POPEN returned the following File Descriptor %i", current_host->id, cmd_fd)); if (cmd_fd >= 0) { const script_result_context_t context = {current_host, command, id, type}; - (void) read_script_result(&context, cmd_fd, deadline, result_string); + if (limited) { + valid_result = read_limited_script(current_host, cmd_fd, deadline, result_string); + if (!valid_result) { + child_pid = nft_pchild(cmd_fd); + if (child_pid > 1) kill(-child_pid, SIGKILL); + } + } else (void) read_script_result(&context, cmd_fd, deadline, result_string); /* close pipe */ - nft_pclose(cmd_fd); + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + status = nft_pclose(cmd_fd); + owned.fd = -1; + pthread_setcancelstate(previous_state, NULL); + if (limited && (status < 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0)) { + SPINE_LOG(("Device[%i] DS/DQ[%i] ERROR: Script resource policy or child exit failed for '%s'", current_host->id, id, executable)); + SET_UNDEFINED(result_string); + } } else { - SPINE_LOG(("Device[%i] ERROR: Problem executing POPEN [%s]: '%s'", current_host->id, current_host->hostname, executable)); + if (spawn_error == ENOTSUP && limited) { + SPINE_LOG(("Device[%i] DS/DQ[%i] ERROR: Configured child resource limits are unsupported on this platform for '%s'", current_host->id, id, executable)); + } else { + SPINE_LOG(("Device[%i] ERROR: Problem executing POPEN [%s]: '%s': %s", current_host->id, current_host->hostname, executable, strerror(spawn_error))); + } SET_UNDEFINED(result_string); } } else { @@ -186,12 +291,20 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) { } #if defined(__CYGWIN__) - SPINE_FREE(proc_command); + /* The common ownership cleanup releases this allocation. */ #endif } - /* reduce the active script count */ - pthread_cleanup_pop(needs_cleanup); + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + if (owned.permit) spine_permits_release(&available_scripts); + owned.permit = FALSE; + free(owned.command); + owned.command = NULL; + pthread_setcanceltype(previous_type, NULL); + pthread_setcancelstate(previous_state, NULL); + pthread_testcancel(); + owned.result = NULL; + pthread_cleanup_pop(0); return result_string; } diff --git a/tests/contracts/test_spine_contracts.c b/tests/contracts/test_spine_contracts.c index d63ae1cf..330e6c32 100644 --- a/tests/contracts/test_spine_contracts.c +++ b/tests/contracts/test_spine_contracts.c @@ -64,15 +64,15 @@ static void *try_busy_lock(void *argument) { } static void test_lock_contracts(void) { - static const int locks[] = {LOCK_SNMP, LOCK_ICMP, LOCK_GHBN, LOCK_POOL, LOCK_PHP, + static const int locks[] = {LOCK_SNMP, LOCK_ICMP, LOCK_GHBN, LOCK_PHP, LOCK_PHP_PROC_0, LOCK_PHP_PROC_1, LOCK_PHP_PROC_2, LOCK_PHP_PROC_3, LOCK_PHP_PROC_4, LOCK_PHP_PROC_5, LOCK_PHP_PROC_6, LOCK_PHP_PROC_7, LOCK_PHP_PROC_8, LOCK_PHP_PROC_9, LOCK_PHP_PROC_10, LOCK_PHP_PROC_11, - LOCK_PHP_PROC_12, LOCK_PHP_PROC_13, LOCK_PHP_PROC_14, LOCK_THDET, LOCK_HOST_TIME}; - static const char *const names[] = {"snmp", "icmp", "ghbn", "pool", "php", + LOCK_PHP_PROC_12, LOCK_PHP_PROC_13, LOCK_PHP_PROC_14, LOCK_THDET}; + static const char *const names[] = {"snmp", "icmp", "ghbn", "php", "php_proc_0", "php_proc_1", "php_proc_2", "php_proc_3", "php_proc_4", "php_proc_5", "php_proc_6", "php_proc_7", "php_proc_8", "php_proc_9", - "php_proc_10", "php_proc_11", "php_proc_12", "php_proc_13", "php_proc_14", "thdet", "host_time"}; + "php_proc_10", "php_proc_11", "php_proc_12", "php_proc_13", "php_proc_14", "thdet"}; init_mutexes(); init_mutexes(); /* pthread_once must preserve the already-created locks. */ for (size_t i = 0; i < sizeof(locks) / sizeof(locks[0]); i++) { diff --git a/tests/contracts/test_spine_output_contracts.c b/tests/contracts/test_spine_output_contracts.c index 65a60fe5..91d6280b 100644 --- a/tests/contracts/test_spine_output_contracts.c +++ b/tests/contracts/test_spine_output_contracts.c @@ -7,6 +7,7 @@ */ #include "internal/common.h" #include "app/spine.h" +#include "database/persistence.h" #include extern poller_thread_t **details; @@ -121,6 +122,78 @@ static void assert_sample_at(MYSQL *mysql, const char *table, int id, assert(output_count(mysql, query) == 1); } +/* Exercise the writer with real destination metadata and duplicate keys. + * Temporary tables isolate these samples from the other polling contracts. */ +static void test_destination_upserts(MYSQL *local) { + config_t previous = set; + MYSQL remote_storage; + MYSQL *remote = local; + const char *remote_host = getenv("SPINE_TEST_REMOTE_DB_HOST"); + poller_queries_t queries; + target_t item = {0}; + int mode; + int pass; + int table; + const char *tables[] = {"spine_upsert_output", "spine_upsert_boost"}; + const char *sources[] = {"poller_output", "poller_output_boost"}; + char query[BUFSIZE]; + + if (remote_host != NULL && remote_host[0] != '\0') { + assert(mysql_init(&remote_storage) != NULL); + assert(mysql_real_connect(&remote_storage, remote_host, "root", "", + "spine_regressions", 3307, NULL, 0) != NULL); + remote = &remote_storage; + /* Match db_connect's Cacti-compatible session before cloning the + * legacy fixture tables with zero-date column defaults. */ + assert(mysql_query(remote, "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'") == 0); + /* This lane must really cover different server families. */ + assert(strstr(mysql_get_server_info(local), "MariaDB") != NULL); + assert(strstr(mysql_get_server_info(remote), "MariaDB") == NULL); + assert(strstr(db_output_upsert_suffix(remote), " AS rs ") != NULL); + } + set.poller.poller_id = 2; + set.poller.SQL_readonly = FALSE; + set.boost.boost_enabled = TRUE; + set.boost.boost_redirect = TRUE; + item.local_data_id = 930001; + STRNCOPY(item.rrd_name, "upsert"); + poller_prepare_queries(&queries, 901, 1, 1); + STRNCOPY(queries.output, "INSERT INTO spine_upsert_output (local_data_id,rrd_name,time,output) VALUES"); + STRNCOPY(queries.boost_output, "INSERT INTO spine_upsert_boost (local_data_id,rrd_name,time,output) VALUES"); + /* A stale, invalid suffix must never reach either destination. */ + STRNCOPY(queries.suffix, " INVALID CACHED UPSERT"); + for (mode = 0; mode < 2; mode++) { + MYSQL *destination = mode == 0 ? local : remote; + set.poller.mode = mode == 0 ? REMOTE_OFFLINE : REMOTE_ONLINE; + set.database.onupdate = mode; + set.remote_database.onupdate = !mode; + for (table = 0; table < 2; table++) { + spine_snprintf(query, sizeof(query), "CREATE TEMPORARY TABLE %s LIKE %s", tables[table], sources[table]); + assert(db_insert(destination, LOCAL, query)); + } + for (pass = 0; pass < 2; pass++) { + poll_output_buffers_t buffers; + STRNCOPY(item.result, pass == 0 ? "123" : "456"); + buffers = write_poll_results(local, remote, &queries, &item, 1, "1791244800"); + assert(!buffers.failed); + free(buffers.output); + free(buffers.boost); + for (table = 0; table < 2; table++) { + spine_snprintf(query, sizeof(query), "SELECT COUNT(*) FROM %s", tables[table]); + assert(output_count(destination, query) == 1); + assert_sample_at(destination, tables[table], item.local_data_id, "1791244800", item.result); + } + } + for (table = 0; table < 2; table++) { + spine_snprintf(query, sizeof(query), "DROP TEMPORARY TABLE %s", tables[table]); + assert(db_insert(destination, LOCAL, query)); + } + } + if (remote != local) mysql_close(remote); + set = previous; + puts("production destination-aware duplicate-key upsert regressions passed"); +} + static void test_output_recollection(MYSQL *mysql) { poller_thread_t **previous_details = details; char query[BUFSIZE]; @@ -221,6 +294,8 @@ static void test_concurrent_output_failure(MYSQL *mysql) { poller_thread_t **previous_details = details; int previous_threads = set.poller.threads; int previous_timeout = set.php.script_timeout; + db_lease_t probe = {0}; + db_lease_t second_probe = {0}; assert(previous_threads == 1 && db_pool_local[0].free); assert(spine_permits_available(&available_threads) == 1); assert(spine_permits_available(&available_scripts) == 2); @@ -293,9 +368,7 @@ static void test_concurrent_output_failure(MYSQL *mysql) { assert(spine_permits_available(&startup) == 2); assert(spine_permits_available(&available_threads) == 0); assert(spine_permits_available(&available_scripts) == 0); - thread_mutex_lock(LOCK_POOL); - assert(!db_pool_local[0].free && !db_pool_local[1].free); - thread_mutex_unlock(LOCK_POOL); + assert(!db_acquire_lease(LOCAL, &probe)); /* Both real workers have selected their own partition and hold resources. * Allow only the rejected partition to finish first. */ send_output_sample(release[1], "resume\n", 7); @@ -310,9 +383,9 @@ static void test_concurrent_output_failure(MYSQL *mysql) { assert_sample_at(mysql, "poller_output_boost", 930002, "1791244800", "123"); assert(spine_permits_available(&available_threads) == 1); assert(spine_permits_available(&available_scripts) == 1); - thread_mutex_lock(LOCK_POOL); - assert(db_pool_local[0].free + db_pool_local[1].free == 1); - thread_mutex_unlock(LOCK_POOL); + assert(db_acquire_lease(LOCAL, &probe)); + assert(!db_acquire_lease(LOCAL, &second_probe)); + assert(db_release_lease(&probe)); send_output_sample(release[0], "resume\n", 7); await_output_byte(finished[0][0], 'F', spine_monotonic_time() + 10); assert(pthread_join(threads[0], NULL) == 0); @@ -538,6 +611,7 @@ void test_output_write_contracts(MYSQL *mysql) { php_t *previous_servers = php_processes; int *previous_debug = debug_devices; static int owned_debug[100]; + test_destination_upserts(mysql); debug_devices = owned_debug; assert_output_engine(mysql, "poller_output", "MEMORY"); assert_output_engine(mysql, "poller_output_boost", "InnoDB"); diff --git a/tests/contracts/test_spine_script_limits_contracts.c b/tests/contracts/test_spine_script_limits_contracts.c new file mode 100644 index 00000000..54e91a7d --- /dev/null +++ b/tests/contracts/test_spine_script_limits_contracts.c @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include "internal/common.h" +#include "app/spine.h" + +static void check_script(host_t *host, const char *command, const char *expected) { + char *copy = strdup(command); + char *result; + assert(copy != NULL); + result = exec_poll(host, copy, 1, "DS"); + if (result == NULL || strcmp(result, expected) != 0) + fprintf(stderr, "script limit result: expected='%s' actual='%s'\n", expected, result == NULL ? "NULL" : result); + assert(result != NULL && strcmp(result, expected) == 0); + free(copy); + free(result); + assert(spine_permits_available(&available_scripts) == 1); +} + +typedef struct { + host_t host; + int cancellation_type; + char command[BUFSIZE]; +} cancelled_script_t; + +static void *run_cancelled_script(void *argument) { + cancelled_script_t *script = argument; + char *result; + pthread_setcanceltype(script->cancellation_type, NULL); + result = exec_poll(&script->host, script->command, 1, "DS"); + free(result); + return NULL; +} + +static void test_script_cancellation(int cancellation_type) { + cancelled_script_t script = {{0}, cancellation_type, "/bin/sleep 10"}; + pthread_t thread; + void *returned; + char marker[] = "spine-script-cancel-XXXXXX"; + int fd; + FILE *file; + int pid; + double deadline; + /* Cancellation while waiting must not return a permit owned by another + * thread. This previously inflated the permit count and leaked result. */ + assert(spine_permits_try_acquire(&available_scripts) == 0); + assert(pthread_create(&thread, NULL, run_cancelled_script, &script) == 0); + spine_sleep_usec(20000); + assert(pthread_cancel(thread) == 0); + assert(pthread_join(thread, &returned) == 0 && returned == PTHREAD_CANCELED); + assert(spine_permits_available(&available_scripts) == 0); + assert(spine_permits_release(&available_scripts) == 0); + fd = mkstemp(marker); + assert(fd >= 0 && close(fd) == 0); + /* Use the registered group leader's PID, avoiding an orphan grandchild + * zombie when the container's PID 1 does not reap adopted descendants. */ + spine_snprintf(script.command, sizeof(script.command), "/usr/bin/printf ''; echo $$ > %s; exec /bin/sleep 10", marker); + assert(pthread_create(&thread, NULL, run_cancelled_script, &script) == 0); + deadline = spine_monotonic_time() + 5; + pid = 0; + while (pid <= 1 && spine_monotonic_time() < deadline) { + file = fopen(marker, "r"); + assert(file != NULL); + if (fscanf(file, "%i", &pid) != 1) pid = 0; + assert(fclose(file) == 0); + spine_sleep_usec(1000); + } + assert(pid > 1); + assert(pthread_cancel(thread) == 0); + assert(pthread_join(thread, &returned) == 0 && returned == PTHREAD_CANCELED); + assert(spine_permits_available(&available_scripts) == 1); + deadline = spine_monotonic_time() + 5; + while (nft_abandoned_pending() && spine_monotonic_time() < deadline) spine_sleep_usec(1000); + assert(nft_abandoned_pending() == 0); + errno = 0; + assert(kill(pid, 0) == -1 && errno == ESRCH); + assert(unlink(marker) == 0); +} + +void run_script_limit_contracts(void) { + spine_php_config_t previous = set.php; + host_t host = {0}; + char command[BUFSIZE]; + char file_path[] = "spine-script-file-XXXXXX"; + const char *probe = getenv("SPINE_SCRIPT_RESOURCE_PROBE"); + int fd; + struct stat status; + assert(spine_permits_init(&available_scripts, 1) == 0); + set.php.script_timeout = 5; + memset(&set.php.limits, 0, sizeof(set.php.limits)); + set.php.limits.output_bytes = 3; + check_script(&host, "/usr/bin/printf 123", "123"); + check_script(&host, "/usr/bin/printf 1234", "U"); + check_script(&host, "/bin/sh -c 'printf 1; sleep 0.05; printf 23'", "123"); + check_script(&host, "/bin/sh -c 'printf 123; sleep 0.05; printf 4'", "U"); + check_script(&host, "/bin/sh -c 'printf 1; exit 42'", "U"); + set.php.limits.deadline_seconds = 1; + check_script(&host, "/bin/sh -c 'printf 1; sleep 3'", "U"); + check_script(&host, "/usr/bin/printf ''", "U"); + memset(&set.php.limits, 0, sizeof(set.php.limits)); + test_script_cancellation(PTHREAD_CANCEL_DEFERRED); + test_script_cancellation(PTHREAD_CANCEL_ASYNCHRONOUS); +#ifdef __linux__ + if (probe != NULL) { + assert(strchr(probe, '\'') == NULL); + set.php.limits = (spine_script_limits_t){0, 3, 1, 16384, 1, 16}; + spine_snprintf(command, sizeof(command), "/bin/sh -c 'exec \"$1\" policy' spine-probe '%s'", probe); + check_script(&host, command, "1"); + spine_snprintf(command, sizeof(command), "/bin/sh -c 'exec \"$1\" cpu' spine-probe '%s'", probe); + check_script(&host, command, "U"); + spine_snprintf(command, sizeof(command), "/bin/sh -c 'exec \"$1\" memory' spine-probe '%s'", probe); + check_script(&host, command, "U"); + spine_snprintf(command, sizeof(command), "/bin/sh -c 'exec \"$1\" files' spine-probe '%s'", probe); + check_script(&host, command, "U"); + fd = mkstemp(file_path); + assert(fd >= 0 && close(fd) == 0); + spine_snprintf(command, sizeof(command), "/bin/sh -c 'exec \"$1\" size \"$2\"' spine-probe '%s' '%s'", probe, file_path); + check_script(&host, command, "U"); + assert(stat(file_path, &status) == 0 && status.st_size <= 1024); + assert(unlink(file_path) == 0); + puts("production child CPU, memory, file-size and descriptor limit contracts passed"); + } +#else + (void) probe; + (void) command; + (void) file_path; + (void) fd; + (void) status; + set.php.limits.cpu_seconds = 1; + check_script(&host, "/usr/bin/printf 1", "U"); +#endif + assert(spine_permits_destroy(&available_scripts) == 0); + set.php = previous; + puts("production script output, deadline and cancellation ownership contracts passed"); +} diff --git a/tests/contracts/test_spine_tls_contracts.c b/tests/contracts/test_spine_tls_contracts.c new file mode 100644 index 00000000..9fea94f5 --- /dev/null +++ b/tests/contracts/test_spine_tls_contracts.c @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include "internal/common.h" +#include "app/spine.h" + +static unsigned int fixture_port(const char *name) { + const char *value = getenv(name); + char *end; + unsigned long port; + assert(value != NULL && value[0] != '\0'); + port = strtoul(value, &end, 10); + assert(*end == '\0' && port > 0 && port <= 65535); + return (unsigned int) port; +} + +static void assert_tls_connection(int type, bool expected) { + MYSQL mysql; + int connected; + connected = db_connect(type, &mysql); + fflush(stdout); + fprintf(stderr, "TLS connection type=%i expected=%i connected=%i\n", type, expected, connected); + assert(connected == expected); + if (connected) assert(mysql_get_ssl_cipher(&mysql) != NULL); + db_disconnect(&mysql); +} + +void test_database_tls_contracts(void) { + const char *ca = getenv("SPINE_TEST_TLS_CA"); + const char *untrusted_ca = getenv("SPINE_TEST_TLS_UNTRUSTED_CA"); + unsigned int trusted = fixture_port("SPINE_TEST_TLS_PORT"); + unsigned int wrong_name = fixture_port("SPINE_TEST_TLS_WRONG_NAME_PORT"); + unsigned int plaintext = fixture_port("SPINE_TEST_TLS_PLAINTEXT_PORT"); + int type; + assert(ca != NULL && untrusted_ca != NULL); + config_defaults(); + set.logging.log_destination = LOGDEST_STDOUT; + set.console.stdout_notty = TRUE; + set.console.stderr_notty = TRUE; + set.poller.poller_id = 2; + STRNCOPY(set.database.host, "127.0.0.1"); + STRNCOPY(set.database.database, "spine_regressions"); + STRNCOPY(set.database.user, "root"); + set.database.password[0] = '\0'; + set.database.tls_mode = SPINE_TLS_VERIFY_FULL; + set.database.tls_min_version = 12; + STRNCOPY(set.database.ssl_ca, ca); + set.database.port = trusted; + set.remote_database = set.database; + for (type = LOCAL; type <= REMOTE; type++) { + spine_database_config_t *configuration = type == LOCAL ? &set.database : &set.remote_database; + assert_tls_connection(type, TRUE); + STRNCOPY(configuration->ssl_ca, untrusted_ca); + assert_tls_connection(type, FALSE); + STRNCOPY(configuration->ssl_ca, ca); + configuration->port = wrong_name; + assert_tls_connection(type, FALSE); + configuration->port = trusted; + configuration->tls_min_version = 13; + assert_tls_connection(type, FALSE); + configuration->tls_min_version = 12; + STRNCOPY(configuration->tls_cipher, "ECDHE-RSA-AES128-GCM-SHA256"); + assert_tls_connection(type, TRUE); + STRNCOPY(configuration->tls_cipher, "ECDHE-RSA-AES256-GCM-SHA384"); + assert_tls_connection(type, FALSE); + configuration->tls_cipher[0] = '\0'; +#if HAVE_DECL_MYSQL_OPT_SSL_MODE || defined(HAVE_MARIADB_TLS_VERIFICATION_CALLBACK) + configuration->tls_mode = SPINE_TLS_REQUIRED; + configuration->port = plaintext; + assert_tls_connection(type, FALSE); + configuration->port = trusted; + assert_tls_connection(type, TRUE); +#else + configuration->port = plaintext; + assert_tls_connection(type, FALSE); + configuration->port = trusted; +#endif + configuration->tls_mode = SPINE_TLS_VERIFY_FULL; + } + puts("production database TLS trust, identity, protocol, cipher and downgrade contracts passed"); +} diff --git a/tests/fixtures/script_resource_probe.c b/tests/fixtures/script_resource_probe.c new file mode 100644 index 00000000..a5d8cde1 --- /dev/null +++ b/tests/fixtures/script_resource_probe.c @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: GPL-2.0-or-later + * Disposable external workload, deliberately built without sanitizers so + * virtual-memory caps measure the child rather than sanitizer address space. + */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + struct rlimit limit; + void *memory; + int index; + int fd; + char block[4096] = {0}; + volatile unsigned int counter = 0; + if (argc < 2) return 2; + if (strcmp(argv[1], "cpu") == 0) { + for (;;) counter++; + } + if (strcmp(argv[1], "memory") == 0) { + memory = malloc(64 * 1024 * 1024); + if (memory == NULL) return 42; + memset(memory, 1, 64 * 1024 * 1024); + free(memory); + } else if (strcmp(argv[1], "files") == 0) { + for (index = 0; index < 64; index++) { + if (open("/dev/null", O_RDONLY) < 0) return 42; + } + } else if (strcmp(argv[1], "size") == 0) { + if (argc != 3) return 2; + fd = open(argv[2], O_WRONLY | O_TRUNC); + if (fd < 0) return 2; + if (write(fd, block, sizeof(block)) != sizeof(block)) return 42; + if (close(fd) != 0) return 42; + } else if (strcmp(argv[1], "policy") == 0) { + if (getrlimit(RLIMIT_CPU, &limit) != 0 || limit.rlim_cur != 1 || limit.rlim_max != 1) return 42; + if (getrlimit(RLIMIT_AS, &limit) != 0 || limit.rlim_cur != 16 * 1024 * 1024 || limit.rlim_max != 16 * 1024 * 1024) return 42; + if (getrlimit(RLIMIT_FSIZE, &limit) != 0 || limit.rlim_cur != 1024 || limit.rlim_max != 1024) return 42; + if (getrlimit(RLIMIT_NOFILE, &limit) != 0 || limit.rlim_cur != 16 || limit.rlim_max != 16) return 42; + } + return fputs("1", stdout) < 0 ? 2 : 0; +} diff --git a/tests/fixtures/snmpd.conf b/tests/fixtures/snmpd.conf index 12e03eeb..253d737c 100644 --- a/tests/fixtures/snmpd.conf +++ b/tests/fixtures/snmpd.conf @@ -4,7 +4,7 @@ agentaddress udp:127.0.0.1:1161 rocommunity regression 127.0.0.1 rocommunity regression-community-wider-than-the-old-fifty-byte-batch-key 127.0.0.1 -sysLocation isolated-regression-agent +sysLocation 42 watts sysContact regression # Octets that normalize to 266, for output_regex ordering. sysName 00 00 01 0A diff --git a/tests/regression/test_spine_faults.c b/tests/regression/test_spine_faults.c index 83201faf..601bd4c3 100644 --- a/tests/regression/test_spine_faults.c +++ b/tests/regression/test_spine_faults.c @@ -10,6 +10,7 @@ #define _GNU_SOURCE #include "internal/common.h" #include "app/spine.h" +#include "platform/worker_set.h" #include #include #include @@ -64,6 +65,7 @@ static MYSQL *interrupted_connection; * without a database. While fake_database is set every client call below * answers from this script and nothing touches the network. */ static bool fake_database; +static bool cancel_fake_poll; static int fake_errors[64]; static int fake_error_count; static int fake_error_next; @@ -125,8 +127,9 @@ static int completion_writes_locked; int __wrap_pthread_create(pthread_t *thread, const pthread_attr_t *attributes, void *(*start)(void *), void *arg) { - if (!launch_case_active || start != &child) return __real_pthread_create(thread, attributes, start, arg); - const poller_thread_t *work = arg; + if (!launch_case_active || start != &spine_worker_entry) return __real_pthread_create(thread, attributes, start, arg); + const spine_worker_slot_t *slot = arg; + const poller_thread_t *work = slot->argument; size_t stack_size = 0; assert(work != NULL && work->host_id == 902); assert(attributes != NULL && pthread_attr_getstacksize(attributes, &stack_size) == 0); @@ -206,6 +209,10 @@ size_t __wrap_strftime(char *output, size_t capacity, const char *format, const int __wrap_mysql_query(MYSQL *mysql, const char *query) { if (fake_database) { fake_queries++; + if (cancel_fake_poll) { + cancel_fake_poll = FALSE; + assert(pthread_cancel(pthread_self()) == 0); + } if (strncmp(query, "UPDATE poller_item", strlen("UPDATE poller_item")) == 0) fake_schedule_writes++; if (strncmp(query, "SELECT ", 7) == 0 && strstr(query, " FROM poller_item ") != NULL) fake_item_selects++; /* Pause the first partition's error write while another partition @@ -1138,6 +1145,7 @@ static void *fake_poll_worker(void *argument) { assert(mysql_thread_init() == 0); int errors = 0; poll_host(argument, &errors); + pthread_testcancel(); return NULL; } @@ -1179,6 +1187,7 @@ static void test_fake_database_poll_host(void) { db_pool_local = calloc(2, sizeof(*db_pool_local)); assert(db_pool_local != NULL); fake_script(NULL, 0); + assert(db_pool_ownership_init(db_pool_local, 2) == 0); assert(db_connect(LOCAL, &db_pool_local[0].mysql)); assert(db_connect(LOCAL, &db_pool_local[1].mysql)); db_pool_local[1].id = 1; @@ -1241,6 +1250,17 @@ static void test_fake_database_poll_host(void) { run_fake_poll(&device, 0); assert(!device.poll_failed && device.complete && set.exit.exit_code == EXIT_SUCCESS); assert(fake_queries == 3 && db_pool_local[0].free); + /* Cancellation requested during a database operation is delivered only + * after the common cleanup has returned the lease and thread state. */ + { + pthread_t cancelled; + void *status; + fake_script(NULL, 0); + cancel_fake_poll = TRUE; + start_test_worker(&cancelled, fake_poll_worker, &device); + assert(pthread_join(cancelled, &status) == 0 && status == PTHREAD_CANCELED); + assert(!cancel_fake_poll && db_pool_local[0].free && !db_pool_local[0].leased); + } /* A completion write that fails must leave the device incomplete and * fail the run. One invalid script result makes a host_errors write. */ @@ -1300,6 +1320,7 @@ static void test_fake_database_poll_host(void) { db_disconnect(&db_pool_local[1].mysql); db_disconnect(&db_pool_local[0].mysql); + assert(db_pool_ownership_destroy(db_pool_local) == 0); free(db_pool_local); fake_database = FALSE; db_pool_local = previous_local; @@ -1482,6 +1503,7 @@ static void test_ping_only_session_lifetime(void) { assert(db_pool_local != NULL); db_pool_local[0].free = TRUE; db_connect(LOCAL, &db_pool_local[0].mysql); + assert(db_pool_ownership_init(db_pool_local, 1) == 0); assert(spine_permits_init(&available_threads, 2) == 0); assert(spine_permits_init(&available_scripts, 3) == 0); char escaped_agent[BUFSIZE]; @@ -1579,6 +1601,7 @@ static void test_snmp_batch_key_width(void) { assert(db_pool_local != NULL); db_pool_local[0].free = TRUE; db_connect(LOCAL, &db_pool_local[0].mysql); + assert(db_pool_ownership_init(db_pool_local, 1) == 0); assert(spine_permits_init(&available_scripts, 3) == 0); char escaped_agent[BUFSIZE]; db_escape(&administrator, escaped_agent, sizeof(escaped_agent), agent); diff --git a/tests/regression/test_spine_regressions.c b/tests/regression/test_spine_regressions.c index c508ec7f..57e8f8b7 100644 --- a/tests/regression/test_spine_regressions.c +++ b/tests/regression/test_spine_regressions.c @@ -13,6 +13,8 @@ #include "app/runtime.h" extern void test_output_write_contracts(MYSQL *mysql); +extern void test_database_tls_contracts(void); +extern void run_script_limit_contracts(void); extern void test_additional_contracts(void); extern void test_cli_alias_contracts(void); extern void test_privilege_contracts(void); @@ -2156,6 +2158,25 @@ static void test_snmp_flush_contracts(MYSQL *mysql, test_poll_work_t *work, cons assert(database_count(mysql, query) == 1); } } + /* Units-bearing strings must become numeric before validation, through + * full, credential-change and final batch flushes. */ + for (int scenario = 0; scenario < 3; scenario++) { + pthread_t units_worker; + assert(db_insert(mysql, LOCAL, "DELETE FROM poller_reindex")); + assert(db_insert(mysql, LOCAL, "DELETE FROM poller_output")); + assert(db_insert(mysql, LOCAL, "DELETE FROM poller_output_boost")); + spine_snprintf(query, sizeof(query), "UPDATE host SET max_oids=%d WHERE id=44", scenario == 1 ? 1 : 5); + assert(db_insert(mysql, LOCAL, query)); + assert(db_insert(mysql, LOCAL, "UPDATE poller_item SET snmp_version=2,arg1='.1.3.6.1.2.1.1.6.0',output_regex='^[0-9][0-9]*' WHERE host_id=44")); + if (scenario == 0) assert(db_insert(mysql, LOCAL, "UPDATE poller_item SET snmp_version=1 WHERE local_data_id=602")); + work->thread.complete = FALSE; + work->thread.threads_complete = 0; + work->errors = 0; + start_test_worker(&units_worker, test_poll_worker, work); + assert(pthread_join(units_worker, NULL) == 0); + assert(database_count(mysql, "SELECT COUNT(*) FROM poller_output WHERE local_data_id IN (601,602) AND output='42'") == 2); + assert(database_count(mysql, "SELECT COUNT(*) FROM poller_output_boost WHERE local_data_id IN (601,602) AND output='42'") == 2); + } /* Existing regexes were written against the normalized value: a * Hex-STRING becomes 266 first, then the regex keeps "266", not "00". */ assert(db_insert(mysql, LOCAL, "DELETE FROM poller_reindex")); @@ -2180,7 +2201,7 @@ static void test_snmp_flush_contracts(MYSQL *mysql, test_poll_work_t *work, cons static void assert_polled_host_statistics(MYSQL *mysql, int host_id) { if (host_id == 44) { - assert(database_count(mysql, "SELECT COUNT(*) FROM host WHERE id=44 AND total_polls=1 AND failed_polls=0 AND status_last_error='' AND snmp_sysUpTimeInstance>0 AND snmp_sysDescr!='' AND snmp_sysObjectID!='' AND snmp_sysContact='regression' AND snmp_sysName!='' AND snmp_sysLocation='isolated-regression-agent'") == 1); + assert(database_count(mysql, "SELECT COUNT(*) FROM host WHERE id=44 AND total_polls=1 AND failed_polls=0 AND status_last_error='' AND snmp_sysUpTimeInstance>0 AND snmp_sysDescr!='' AND snmp_sysObjectID!='' AND snmp_sysContact='regression' AND snmp_sysName!='' AND snmp_sysLocation='42 watts'") == 1); } else if (host_id != 0) { char query[256]; spine_snprintf(query, sizeof(query), "SELECT COUNT(*) FROM host WHERE id=%d AND total_polls=1 AND failed_polls=0 AND min_time=0 AND status_last_error=''", host_id); @@ -2911,6 +2932,11 @@ static void test_error_id_buffer_boundaries(void) { int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "--database-tls") == 0) { + init_mutexes(); + test_database_tls_contracts(); + return 0; + } if (argc == 3 && strcmp(argv[1], "--script-stream") == 0) return run_script_stream_fixture(argv[2]); if (argc > 1 && strcmp(argv[1], "-q") == 0) return run_test_script_server(argc, argv); extern int *debug_devices; @@ -2922,6 +2948,11 @@ int main(int argc, char **argv) { puts("production ICMP loopback regression passed"); return 0; } + if (argc == 2 && strcmp(argv[1], "--script-limits") == 0) { + init_mutexes(); + run_script_limit_contracts(); + return 0; + } if (argc == 2 && strcmp(argv[1], "--privilege-drop") == 0) { test_privilege_contracts(); return 0; @@ -3006,6 +3037,7 @@ int main(int argc, char **argv) { test_php_owned_shutdown(); test_host_status_transitions(); test_script_execution(); + run_script_limit_contracts(); test_script_timeout_kills_descendants(); test_pclose_kills_descendants(); test_script_stream_contracts(); diff --git a/tests/tools/ownership-coverage-sources.txt b/tests/tools/ownership-coverage-sources.txt new file mode 100644 index 00000000..821c1fdd --- /dev/null +++ b/tests/tools/ownership-coverage-sources.txt @@ -0,0 +1,2 @@ +src/platform/worker_set.c +src/database/lease.c diff --git a/tests/tools/run_coverage.sh b/tests/tools/run_coverage.sh index 14b47d6f..421e5f00 100755 --- a/tests/tools/run_coverage.sh +++ b/tests/tools/run_coverage.sh @@ -54,7 +54,7 @@ fi gcc --version > "$coverage_dir/compiler.txt" gcc -dumpmachine >> "$coverage_dir/compiler.txt" if command -v dpkg-query >/dev/null; then - dpkg-query -W gcc libc6-dev libmariadb-dev libsnmp-dev libssl-dev snmpd lcov autoconf automake libtool > "$coverage_dir/dependencies.txt" + dpkg-query -W gcc libc6-dev "${SPINE_CLIENT_PACKAGE:-libmariadb-dev}" libsnmp-dev libssl-dev snmpd lcov autoconf automake libtool > "$coverage_dir/dependencies.txt" else printf '%s\n' 'Package version inventory unavailable: dpkg-query is not installed.' > "$coverage_dir/dependencies.txt" fi @@ -62,7 +62,9 @@ gcov --version > "$coverage_dir/gcov.txt" lcov --version > "$coverage_dir/lcov.txt" mkdir -p config m4 autoreconf -fi > "$coverage_dir/build.log" 2>&1 -./configure CC=gcc CFLAGS="-std=gnu17 -g -O0 -UNDEBUG --coverage -fprofile-update=atomic -include $source_dir/tests/tools/coverage_process_exit.h" LDFLAGS='--coverage' >> "$coverage_dir/build.log" 2>&1 +client_arguments=() +if [[ -n "${SPINE_CLIENT_CHOICE:-}" ]]; then client_arguments+=("--with-mysql-client=$SPINE_CLIENT_CHOICE"); fi +./configure "${client_arguments[@]}" CC=gcc CFLAGS="-std=gnu17 -g -O0 -UNDEBUG --coverage -fprofile-update=atomic -include $source_dir/tests/tools/coverage_process_exit.h" LDFLAGS='--coverage' >> "$coverage_dir/build.log" 2>&1 make --no-print-directory coverage-source-list > "$coverage_dir/production-sources.txt" make --no-print-directory coverage-test-source-list > "$coverage_dir/test-sources.txt" make --no-print-directory coverage-fault-source-list > "$coverage_dir/fault-sources.txt" @@ -130,6 +132,8 @@ for program in "${unit_programs[@]}"; do done (cd "$coverage_dir" && sha256sum unit-logs/*.log) > "$coverage_dir/unit-logs.sha256" ./test_spine_regressions > "$coverage_dir/default.log" 2>&1 +bash tests/tools/run_script_limits.sh > "$coverage_dir/script-limits.log" 2>&1 +grep -Fq 'production child CPU, memory, file-size and descriptor limit contracts passed' "$coverage_dir/script-limits.log" grep -Fq 'production regression tests passed' "$coverage_dir/default.log" grep -Fq 'production SNMPv3 key and timeout boundary regressions passed' "$coverage_dir/default.log" grep -Fq 'production CLI alias regressions passed' "$coverage_dir/default.log" @@ -143,6 +147,7 @@ grep -Fq 'production settings write outcome regressions passed' "$coverage_dir/d grep -Fq 'production configuration option bounds and defaults passed' "$coverage_dir/database.log" grep -Fq 'production database configuration regressions passed' "$coverage_dir/database.log" grep -Fq 'production output write failure and retry regressions passed' "$coverage_dir/database.log" +grep -Fq 'production destination-aware duplicate-key upsert regressions passed' "$coverage_dir/database.log" grep -Fq 'production output recollection retains incomplete historical samples passed' "$coverage_dir/database.log" grep -Fq 'production simultaneous output failure ordering regressions passed' "$coverage_dir/database.log" grep -Fq 'production remote output destination failure and recollection regressions passed' "$coverage_dir/database.log" @@ -190,7 +195,7 @@ python3 tests/tools/normalize_entry_coverage.py "$coverage_dir/production.info" "$coverage_dir/entry-gcov.json" "$source_dir/src/app/main.c" python3 tests/tools/test_entry_coverage.py > "$coverage_dir/entry-verifier-self-test.log" 2>&1 tests/tools/test_coverage_verifier.sh > "$coverage_dir/verifier-self-test.log" -tests/tools/verify_coverage.sh "$coverage_dir/production.info" "$source_dir" "$coverage_dir/production-sources.txt" +tests/tools/verify_coverage.sh "$coverage_dir/production.info" "$source_dir" "$coverage_dir/production-sources.txt" tests/tools/ownership-coverage-sources.txt lcov --summary "$coverage_dir/production.info" --rc branch_coverage=1 > "$coverage_dir/summary.txt" 2>&1 # Bind every producer and scenario to this immutable tested source snapshot. sha256sum "${profiles[@]}" > "$coverage_dir/profiles.sha256" diff --git a/tests/tools/run_database_tls.sh b/tests/tools/run_database_tls.sh new file mode 100644 index 00000000..1e65c52e --- /dev/null +++ b/tests/tools/run_database_tls.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: LGPL-2.1-or-later +# Disposable certificates and databases; no production endpoints or credentials. +set -euo pipefail +tls_binary="${SPINE_TLS_TEST_BINARY:-./test_spine_regressions}" +tls_image="${SPINE_TLS_DATABASE_IMAGE:-mirror.gcr.io/library/mariadb:10.11}" +tls_evidence="${SPINE_TLS_EVIDENCE_DIR:-tls-evidence}" +mkdir -p "$tls_evidence" +tls_directory="$(mktemp -d "${TMPDIR:-/tmp}/spine-database-tls.XXXXXX")" +tls_names=("spine-tls-trusted-$$" "spine-tls-wrong-name-$$" "spine-tls-plaintext-$$") +cleanup_tls() { + status=$? + for name in "${tls_names[@]}"; do + docker logs "$name" > "$tls_evidence/$name.log" 2>&1 || true + docker rm -f "$name" >/dev/null 2>&1 || true + done + rm -rf -- "$tls_directory" + exit "$status" +} +trap cleanup_tls EXIT +# These keys are generated solely for the isolated test, never checked in. +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=Spine-Test-CA \ + -keyout "$tls_directory/ca.key" -out "$tls_directory/ca.pem" > "$tls_evidence/certificates.log" 2>&1 +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=Spine-Untrusted-CA \ + -keyout "$tls_directory/untrusted.key" -out "$tls_directory/untrusted.pem" >> "$tls_evidence/certificates.log" 2>&1 +for identity in trusted wrong-name; do + openssl req -new -newkey rsa:2048 -nodes -subj /CN=spine-test.invalid \ + -keyout "$tls_directory/$identity.key" -out "$tls_directory/$identity.csr" >> "$tls_evidence/certificates.log" 2>&1 + if [[ "$identity" == trusted ]]; then + printf 'subjectAltName=IP:127.0.0.1\n' > "$tls_directory/extensions" + else + printf 'subjectAltName=DNS:wrong-name.invalid\n' > "$tls_directory/extensions" + fi + openssl x509 -req -days 1 -in "$tls_directory/$identity.csr" \ + -CA "$tls_directory/ca.pem" -CAkey "$tls_directory/ca.key" -CAcreateserial \ + -extfile "$tls_directory/extensions" -out "$tls_directory/$identity.pem" >> "$tls_evidence/certificates.log" 2>&1 +done +# The database image drops privileges; expose only disposable server material. +chmod 755 "$tls_directory" +chmod 644 "$tls_directory/trusted.key" "$tls_directory/wrong-name.key" +ports=() +for index in 0 1 2; do + options=(--skip-ssl) + if [[ "$index" -lt 2 ]]; then + identity=trusted + [[ "$index" == 0 ]] || identity=wrong-name + options=(--ssl-ca=/certs/ca.pem "--ssl-cert=/certs/$identity.pem" + "--ssl-key=/certs/$identity.key" --tls-version=TLSv1.2 + --ssl-cipher=ECDHE-RSA-AES128-GCM-SHA256) + fi + docker run -d --name "${tls_names[index]}" -p 127.0.0.1::3306 \ + -e MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 -e MARIADB_DATABASE=spine_regressions \ + -v "$tls_directory:/certs:ro" "$tls_image" "${options[@]}" >/dev/null +done +for index in 0 1 2; do + ready=0 + for attempt in {1..60}; do + if docker exec "${tls_names[index]}" mariadb --protocol=TCP -h127.0.0.1 -uroot -e 'SELECT 1' >/dev/null 2>&1; then + ready=1 + break + fi + if [[ "$attempt" == 60 ]]; then + printf 'TLS fixture did not become ready: %s\n' "${tls_names[index]}" >&2 + fi + sleep 1 + done + [[ "$ready" == 1 ]] + mapping="$(docker port "${tls_names[index]}" 3306/tcp)" + ports+=("${mapping##*:}") +done +SPINE_TEST_TLS_CA="$tls_directory/ca.pem" \ +SPINE_TEST_TLS_UNTRUSTED_CA="$tls_directory/untrusted.pem" \ +SPINE_TEST_TLS_PORT="${ports[0]}" \ +SPINE_TEST_TLS_WRONG_NAME_PORT="${ports[1]}" \ +SPINE_TEST_TLS_PLAINTEXT_PORT="${ports[2]}" \ + "$tls_binary" --database-tls | tee "$tls_evidence/contracts.log" +grep -Fq 'production database TLS trust, identity, protocol, cipher and downgrade contracts passed' "$tls_evidence/contracts.log" diff --git a/tests/tools/run_script_limits.sh b/tests/tools/run_script_limits.sh new file mode 100644 index 00000000..0dad8e0a --- /dev/null +++ b/tests/tools/run_script_limits.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 The Cacti Group +# SPDX-License-Identifier: GPL-2.0-or-later +set -euo pipefail +probe_directory=$(mktemp -d) +trap 'rm -rf -- "$probe_directory"' EXIT +# This is an external workload, like /usr/bin/printf. ASan reserves enormous +# virtual address ranges and cannot serve as the memory-limited child probe. +"${CC:-cc}" -std=gnu17 -O0 -g0 tests/fixtures/script_resource_probe.c -o "$probe_directory/probe" +SPINE_SCRIPT_RESOURCE_PROBE="$probe_directory/probe" ./test_spine_regressions --script-limits diff --git a/tests/tools/test_coverage_verifier.sh b/tests/tools/test_coverage_verifier.sh index 498aa592..b89fc2c1 100755 --- a/tests/tools/test_coverage_verifier.sh +++ b/tests/tools/test_coverage_verifier.sh @@ -13,6 +13,19 @@ for source in one.c two.c; do printf 'SF:/source/%s\nLF:2\nLH:1\nBRF:2\nBRH:1\nFNF:1\nFNH:1\nend_of_record\n' "$source" >> "$owned_dir/complete.info" done "$verifier" "$owned_dir/complete.info" /source "$owned_dir/sources.txt" +printf '%s\n' src/worker.c > "$owned_dir/ownership.txt" +printf '%s\n' src/worker.c > "$owned_dir/ownership-sources.txt" +printf 'SF:/source/src/worker.c\nLF:2\nLH:2\nBRF:2\nBRH:2\nFNF:1\nFNH:1\nend_of_record\n' > "$owned_dir/ownership.info" +"$verifier" "$owned_dir/ownership.info" /source "$owned_dir/ownership-sources.txt" "$owned_dir/ownership.txt" +for metric in LH BRH; do + sed "s/$metric:2/$metric:1/" "$owned_dir/ownership.info" > "$owned_dir/ownership-gap.info" + if "$verifier" "$owned_dir/ownership-gap.info" /source "$owned_dir/ownership-sources.txt" "$owned_dir/ownership.txt" >/dev/null 2>&1; then + printf 'Ownership coverage gap was incorrectly admitted\n' >&2 + exit 1 + fi +done +printf '%s\n' src/absent.c > "$owned_dir/absent-ownership.txt" +if "$verifier" "$owned_dir/ownership.info" /source "$owned_dir/ownership-sources.txt" "$owned_dir/absent-ownership.txt" >/dev/null 2>&1; then exit 1; fi reject() { local profile="$1" if "$verifier" "$profile" /source "$owned_dir/sources.txt" > /dev/null 2>&1; then diff --git a/tests/tools/verify_coverage.sh b/tests/tools/verify_coverage.sh index 6de8950a..497848e4 100755 --- a/tests/tools/verify_coverage.sh +++ b/tests/tools/verify_coverage.sh @@ -23,8 +23,21 @@ fi : "${3:?Usage: verify_coverage.sh PROFILE SOURCE_DIRECTORY PRODUCTION_MANIFEST}" [[ -s "$1" ]] [[ -s "$3" ]] -awk -F: -v source_dir="$2" ' +if [[ -n "${4:-}" ]]; then [[ -s "$4" ]]; fi +awk -F: -v source_dir="$2" -v ownership_manifest="${4:-}" ' function fail(message) { print message > "/dev/stderr"; failed=1; exit 1 } + BEGIN { + if (ownership_manifest!="") { + while ((manifest_status=getline source < ownership_manifest)>0) { + if (source=="" || source !~ /^src\/[a-zA-Z0-9_\/-]+\.c$/) fail("Invalid ownership source manifest") + identity="SF:" source_dir "/" source + if (identity in ownership) fail("Duplicate ownership source") + ownership[identity]=1 + } + if (manifest_status<0) fail("Cannot read ownership source manifest") + close(ownership_manifest) + } + } FNR==NR { if ($0=="" || $0 !~ /\.c$/) fail("Invalid production source manifest") identity="SF:" source_dir "/" $0 @@ -51,6 +64,7 @@ awk -F: -v source_dir="$2" ' if (!open_record) fail("Coverage terminator without a source record") if (!("LF" in fields) || !("LH" in fields) || !("BRF" in fields) || !("BRH" in fields) || !("FNF" in fields) || !("FNH" in fields)) fail("Incomplete coverage metrics") if (fields["LF"]<=0 || fields["FNF"]<=0 || fields["LH"]>fields["LF"] || fields["BRH"]>fields["BRF"] || fields["FNH"]>fields["FNF"]) fail("Inconsistent coverage metrics") + if ((identity in ownership) && (fields["LH"]!=fields["LF"] || fields["BRH"]!=fields["BRF"])) fail("Ownership source requires 100% line and branch coverage: " identity) total_lines_hit+=fields["LH"] total_branches_hit+=fields["BRH"] produced[identity]=1 @@ -61,6 +75,7 @@ awk -F: -v source_dir="$2" ' if (failed) exit 1 if (open_record) fail("Unterminated final coverage record") for (identity in expected) if (!(identity in produced)) fail("Absent production coverage: " identity) + for (identity in ownership) if (!(identity in expected)) fail("Ownership source absent from production manifest: " identity) if (total_lines_hit<=0 || total_branches_hit<=0) fail("No measured line/branch execution") } ' "$3" "$1" diff --git a/tests/unit/test_db_lease.c b/tests/unit/test_db_lease.c new file mode 100644 index 00000000..18d163f7 --- /dev/null +++ b/tests/unit/test_db_lease.c @@ -0,0 +1,155 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include +#include +#include +#include +#include "database/lease.h" +#include + +#ifdef SPINE_LEASE_TEST_WRAP +static int fail_allocate; +static int fail_initialize; +static int fail_destroy; +extern void *__real_calloc(size_t, size_t); +extern int __real_pthread_mutex_init(pthread_mutex_t *, const pthread_mutexattr_t *); +extern int __real_pthread_mutex_destroy(pthread_mutex_t *); +void *__wrap_calloc(size_t count, size_t size) { + if (fail_allocate) { + fail_allocate = 0; + return NULL; + } + return __real_calloc(count, size); +} +int __wrap_pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attributes) { + if (fail_initialize) { + fail_initialize = 0; + return EAGAIN; + } + return __real_pthread_mutex_init(mutex, attributes); +} +int __wrap_pthread_mutex_destroy(pthread_mutex_t *mutex) { + if (fail_destroy) { + fail_destroy = 0; + return EBUSY; + } + return __real_pthread_mutex_destroy(mutex); +} +#endif + +typedef struct { + pool_t *pool; + db_lease_t lease; +} foreign_lease_t; + +static void *foreign_release(void *argument) { + foreign_lease_t *foreign = argument; + return (void *) (intptr_t) db_pool_return_owned(foreign->pool, &foreign->lease); +} + +static void test_lease_bounds_generations_and_thread_ownership(void **state) { + pool_t slots[2] = {0}; + db_lease_t lease = {0}; + db_lease_t next = {0}; + db_lease_t stale; + db_lease_t invalid; + foreign_lease_t foreign; + pthread_t thread; + void *result; + (void) state; + assert_int_equal(db_pool_ownership_init(NULL, 1), EINVAL); + assert_int_equal(db_pool_ownership_init(slots, 0), EINVAL); + assert_int_equal(db_pool_ownership_init(slots, (size_t) INT_MAX + 1), EINVAL); + assert_int_equal(db_pool_owned_size(NULL), 0); + assert_int_equal(db_pool_owned_size(slots), 0); + assert_int_equal(db_pool_ownership_destroy(NULL), EINVAL); + assert_int_equal(db_pool_ownership_destroy(slots), EINVAL); + assert_null(db_pool_take_owned(NULL, &lease)); + assert_null(db_pool_take_owned(slots, &lease)); + assert_int_equal(db_pool_return_owned(NULL, &lease), EINVAL); + assert_int_equal(db_pool_return_owned(slots, &lease), EINVAL); + lease.connection = &slots[0]; + assert_int_equal(db_pool_return_owned(slots, &lease), EINVAL); + lease.connection = NULL; + assert_int_equal(db_pool_ownership_init(slots, 2), 0); + assert_int_equal(db_pool_ownership_init(slots, 2), EINVAL); + assert_int_equal(db_pool_owned_size(slots), 2); + assert_int_equal(db_pool_owned_size(&slots[1]), 0); + assert_int_equal(db_pool_ownership_destroy(&slots[1]), EINVAL); + assert_null(db_pool_take_owned(&slots[1], &lease)); + assert_null(db_pool_take_owned(slots, NULL)); + assert_int_equal(db_pool_return_owned(slots, NULL), EINVAL); + assert_int_equal(db_pool_return_owned(slots, &lease), EINVAL); + slots[0].free = FALSE; + slots[1].free = TRUE; + assert_ptr_equal(db_pool_take_owned(slots, &lease), &slots[1]); + assert_int_equal(lease.index, 1); + assert_int_equal(lease.generation, 1); + assert_null(db_pool_take_owned(slots, &lease)); + assert_null(db_pool_take_owned(slots, &next)); + assert_int_equal(db_pool_ownership_destroy(slots), EBUSY); + /* An inconsistent legacy free marker cannot admit a second borrower. */ + slots[1].free = TRUE; + assert_null(db_pool_take_owned(slots, &next)); + slots[1].free = FALSE; + invalid = lease; + invalid.index = SIZE_MAX; + assert_int_equal(db_pool_return_owned(slots, &invalid), EINVAL); + invalid = lease; + invalid.connection = &slots[0]; + assert_int_equal(db_pool_return_owned(slots, &invalid), EINVAL); + assert_int_equal(db_pool_return_owned(&slots[1], &lease), EINVAL); + foreign.pool = slots; + foreign.lease = lease; + assert_int_equal(pthread_create(&thread, NULL, foreign_release, &foreign), 0); + assert_int_equal(pthread_join(thread, &result), 0); + assert_int_equal((intptr_t) result, EPERM); + assert_true(slots[1].leased); + stale = lease; + assert_int_equal(db_pool_return_owned(slots, &lease), 0); + assert_null(lease.connection); + assert_int_equal(db_pool_return_owned(slots, &stale), EPERM); + assert_ptr_equal(db_pool_take_owned(slots, &next), &slots[1]); + assert_int_equal(next.generation, 2); + assert_int_equal(db_pool_return_owned(slots, &stale), EPERM); + assert_true(slots[1].leased); + assert_int_equal(db_pool_return_owned(slots, &next), 0); + /* Refuse reuse at generation exhaustion rather than wrap a stale token. */ + slots[0].free = TRUE; + slots[0].generation = UINT64_MAX; + assert_ptr_equal(db_pool_take_owned(slots, &lease), &slots[1]); + assert_int_equal(db_pool_return_owned(slots, &lease), 0); + assert_int_equal(db_pool_ownership_destroy(slots), 0); + assert_null(slots[0].ownership); + assert_null(slots[1].ownership); +} + +#ifdef SPINE_LEASE_TEST_WRAP +static void test_lease_initialization_and_destruction_failures(void **state) { + pool_t slots[1] = {0}; + (void) state; + fail_allocate = 1; + assert_int_equal(db_pool_ownership_init(slots, 1), ENOMEM); + assert_null(slots[0].ownership); + fail_initialize = 1; + assert_int_equal(db_pool_ownership_init(slots, 1), EAGAIN); + assert_null(slots[0].ownership); + assert_int_equal(db_pool_ownership_init(slots, 1), 0); + fail_destroy = 1; + assert_int_equal(db_pool_ownership_destroy(slots), EBUSY); + assert_non_null(slots[0].ownership); + assert_int_equal(db_pool_ownership_destroy(slots), 0); +} +#endif + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_lease_bounds_generations_and_thread_ownership), +#ifdef SPINE_LEASE_TEST_WRAP + cmocka_unit_test(test_lease_initialization_and_destruction_failures), +#endif + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/tests/unit/test_linked.c b/tests/unit/test_linked.c index 21d8b66f..5f27b4c7 100644 --- a/tests/unit/test_linked.c +++ b/tests/unit/test_linked.c @@ -288,6 +288,28 @@ static void test_add_slashes_passes_plain_text_through(void **state) { free(out); } +static void test_upsert_dialect_uses_destination_connection(void **state) { + static const char *const banners[] = {"5.7.44", "8.0.19", "8.0.20", "8.4.0", "9.1.0", + "10.11.6-MariaDB", "5.5.5-10.11.6-MariaDB-log", "", NULL}; + MYSQL destination = {0}; + size_t index; + int cached_local = set.database.onupdate; + int cached_remote = set.remote_database.onupdate; + + (void) state; + assert_non_null(strstr(db_output_upsert_suffix(NULL), "VALUES(output)")); + for (index = 0; index < sizeof(banners) / sizeof(banners[0]); index++) { + bool alias = index >= 2 && index <= 4; + destination.server_version = (char *) banners[index]; + set.database.onupdate = !alias; + set.remote_database.onupdate = !alias; + assert_non_null(strstr(db_output_upsert_suffix(&destination), + alias ? "AS rs" : "VALUES(output)")); + } + set.database.onupdate = cached_local; + set.remote_database.onupdate = cached_remote; +} + static void test_row_alias_upsert_version_gate(void **state) { (void) state; assert_false(db_row_alias_upsert_supported(NULL, 80020)); @@ -334,6 +356,123 @@ static void test_get_checksum_accepts_unaligned_bytes(void **state) { } } +static void test_local_and_remote_database_lease_adapters(void **state) { + pool_t local[1] = {0}; + pool_t remote[1] = {0}; + pool_t *previous_local = db_pool_local; + pool_t *previous_remote = db_pool_remote; + int previous_exit = set.exit.exit_code; + db_lease_t local_lease = {0}; + db_lease_t remote_lease = {0}; + db_lease_t stale; + (void) state; + assert_int_equal(db_pool_ownership_init(local, 1), 0); + assert_int_equal(db_pool_ownership_init(remote, 1), 0); + local[0].free = remote[0].free = TRUE; + db_pool_local = local; + db_pool_remote = remote; + assert_false(db_acquire_lease(99, &local_lease)); + assert_false(db_acquire_lease(LOCAL, NULL)); + assert_false(db_release_lease(NULL)); + assert_true(db_acquire_lease(LOCAL, &local_lease)); + assert_true(db_acquire_lease(REMOTE, &remote_lease)); + assert_ptr_equal(local_lease.connection, &local[0]); + assert_ptr_equal(remote_lease.connection, &remote[0]); + assert_false(db_acquire_lease(LOCAL, &local_lease)); + stale = local_lease; + stale.type = REMOTE; + assert_false(db_release_lease(&stale)); + assert_false(local[0].free); + assert_false(remote[0].free); + assert_true(db_release_lease(&local_lease)); + stale = remote_lease; + assert_true(db_release_lease(&remote_lease)); + assert_true(db_acquire_lease(REMOTE, &remote_lease)); + assert_false(db_release_lease(&stale)); + assert_false(remote[0].free); + assert_true(db_release_lease(&remote_lease)); + assert_int_equal(db_pool_ownership_destroy(local), 0); + assert_int_equal(db_pool_ownership_destroy(remote), 0); + db_pool_local = previous_local; + db_pool_remote = previous_remote; + set.exit.exit_code = previous_exit; +} + +static void test_database_tls_policy(void **state) { + MYSQL mysql; + spine_database_config_t configuration = {0}; + int mode; + const char *version = NULL; + (void) state; + + assert_non_null(mysql_init(&mysql)); + assert_false(db_apply_tls_policy(NULL, &configuration)); + assert_false(db_apply_tls_policy(&mysql, NULL)); + assert_true(db_apply_tls_policy(&mysql, &configuration)); + configuration.tls_min_version = 12; + assert_false(db_apply_tls_policy(&mysql, &configuration)); + configuration.tls_mode = SPINE_TLS_PREFERRED; + assert_false(db_apply_tls_policy(&mysql, &configuration)); + configuration.tls_mode = SPINE_TLS_REQUIRED; + configuration.tls_min_version = 11; + assert_false(db_apply_tls_policy(&mysql, &configuration)); + configuration.tls_min_version = 0; + configuration.tls_mode = (spine_tls_mode_t) 99; + assert_false(db_apply_tls_policy(&mysql, &configuration)); + configuration.tls_mode = SPINE_TLS_VERIFY_FULL; + assert_false(db_apply_tls_policy(&mysql, &configuration)); + STRNCOPY(configuration.ssl_ca, "/fixture/ca.pem"); + for (mode = SPINE_TLS_DISABLED; mode <= SPINE_TLS_VERIFY_FULL; mode++) { + bool supported; + configuration.tls_mode = (spine_tls_mode_t) mode; +#if HAVE_DECL_MYSQL_OPT_SSL_MODE + supported = TRUE; +#elif HAVE_DECL_MYSQL_OPT_SSL_ENFORCE && HAVE_DECL_MYSQL_OPT_SSL_VERIFY_SERVER_CERT +#ifdef HAVE_MARIADB_TLS_VERIFICATION_CALLBACK + supported = mode != SPINE_TLS_DISABLED; +#elif HAVE_DECL_MARIADB_OPT_TLS_VERIFICATION_CALLBACK + supported = mode == SPINE_TLS_PREFERRED; +#else + supported = mode == SPINE_TLS_PREFERRED || mode == SPINE_TLS_VERIFY_FULL; +#endif +#else + supported = FALSE; +#endif + assert_int_equal(db_apply_tls_policy(&mysql, &configuration), supported); + } + configuration.tls_mode = SPINE_TLS_VERIFY_FULL; + configuration.tls_min_version = 12; +#if HAVE_DECL_MYSQL_OPT_TLS_VERSION && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) + assert_true(db_apply_tls_policy(&mysql, &configuration)); + assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); + assert_string_equal(version, "TLSv1.2,TLSv1.3"); + configuration.tls_min_version = 13; + assert_true(db_apply_tls_policy(&mysql, &configuration)); + assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); + assert_string_equal(version, "TLSv1.3"); +#else + (void) version; + assert_false(db_apply_tls_policy(&mysql, &configuration)); +#endif + configuration.tls_min_version = 0; + STRNCOPY(configuration.tls_cipher, "ECDHE-RSA-AES128-GCM-SHA256"); +#if HAVE_DECL_MYSQL_OPT_SSL_CIPHER && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) + assert_true(db_apply_tls_policy(&mysql, &configuration)); + assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_SSL_CIPHER, &version), 0); + assert_string_equal(version, configuration.tls_cipher); +#else + assert_false(db_apply_tls_policy(&mysql, &configuration)); +#endif + configuration.tls_cipher[0] = '\0'; + STRNCOPY(configuration.tls_ciphersuites, "TLS_AES_128_GCM_SHA256"); +#if HAVE_DECL_MYSQL_OPT_TLS_CIPHERSUITES && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) + assert_true(db_apply_tls_policy(&mysql, &configuration)); +#else + assert_false(db_apply_tls_policy(&mysql, &configuration)); +#endif + mysql_close(&mysql); +} + static void test_rtrim_empty_and_fully_trimmed(void **state) { char empty[] = ""; char trimmed[] = " \t\r\n\\\"'"; @@ -1142,6 +1281,9 @@ int main(void) { cmocka_unit_test(test_add_slashes_doubles_a_backslash), cmocka_unit_test(test_add_slashes_passes_plain_text_through), cmocka_unit_test(test_row_alias_upsert_version_gate), + cmocka_unit_test(test_upsert_dialect_uses_destination_connection), + cmocka_unit_test(test_database_tls_policy), + cmocka_unit_test(test_local_and_remote_database_lease_adapters), cmocka_unit_test(test_file_exists), cmocka_unit_test(test_get_time_as_double_advances), cmocka_unit_test(test_get_checksum_is_stable), diff --git a/tests/unit/test_log_config.c b/tests/unit/test_log_config.c index ee714a57..379bac24 100644 --- a/tests/unit/test_log_config.c +++ b/tests/unit/test_log_config.c @@ -341,6 +341,7 @@ static void test_json_log_file_and_config(void **state) { assert_non_null(strstr(line, "quote\\\" slash\\\\ value\\u000anext")); assert_non_null(fgets(line, sizeof(line), file)); assert_non_null(strstr(line, "\"severity\":\"ERROR\"")); + assert_non_null(strstr(line, "\"timestamp\":\"unknown\"")); assert_null(strstr(line, "\xff")); assert_null(fgets(line, sizeof(line), file)); assert_int_equal(fclose(file), 0); @@ -405,8 +406,88 @@ static void test_invalid_log_format_is_refused(void **state) { assert_int_not_equal(WEXITSTATUS(status), 0); } +static void test_local_and_remote_tls_configuration(void **state) { + (void) state; + write_config("DB_SSL_Mode required\nDB_SSL_MinVersion TLSv1.2\n" + "DB_SSL_Cipher ECDHE-RSA-AES128-GCM-SHA256\n" + "RDB_SSL_Mode verify-full\nRDB_SSL_MinVersion TLSv1.3\n" + "RDB_SSL_CA /fixture/ca.pem\nRDB_SSL_Ciphersuites TLS_AES_128_GCM_SHA256\n"); + assert_int_equal(read_spine_config(config_path), 0); + assert_int_equal(set.database.tls_mode, SPINE_TLS_REQUIRED); + assert_int_equal(set.database.tls_min_version, 12); + assert_string_equal(set.database.tls_cipher, "ECDHE-RSA-AES128-GCM-SHA256"); + assert_string_equal(set.database.tls_ciphersuites, ""); + assert_int_equal(set.remote_database.tls_mode, SPINE_TLS_VERIFY_FULL); + assert_int_equal(set.remote_database.tls_min_version, 13); + assert_string_equal(set.remote_database.tls_cipher, ""); + assert_string_equal(set.remote_database.ssl_ca, "/fixture/ca.pem"); + assert_string_equal(set.remote_database.tls_ciphersuites, "TLS_AES_128_GCM_SHA256"); + config_defaults(); + assert_int_equal(set.database.tls_mode, SPINE_TLS_LEGACY); + assert_int_equal(set.remote_database.tls_min_version, 0); + assert_string_equal(set.database.tls_cipher, ""); +} + +static void test_invalid_policy_configuration_is_refused(void **state) { + char overlong_policy[BUFSIZE + 64]; + const char *invalid[] = {"DB_SSL_Mode pretend\n", "RDB_SSL_MinVersion TLSv1.0\n", + "Script_OutputBytes 9999999999999999999999999999999999999999999999999\n", + "Script_DeadlineSeconds 86401\n", "Script_CPUSeconds -1\n", + "Script_CPUSeconds\n", "DB_SSL_Mode\n", + "DB_SSL_Cipher\n", "RDB_SSL_Ciphersuites\n", overlong_policy, + "Script_OpenFiles +16\n", "Script_MemoryKiB 1x\n", "Script_FileKiB 2147483648\n"}; + size_t index; + size_t prefix; + (void) state; + spine_snprintf(overlong_policy, sizeof(overlong_policy), "Script_OutputBytes "); + prefix = strlen(overlong_policy); + memset(overlong_policy + prefix, '1', sizeof(overlong_policy) - prefix - 2); + overlong_policy[sizeof(overlong_policy) - 2] = '\n'; + overlong_policy[sizeof(overlong_policy) - 1] = '\0'; + for (index = 0; index < sizeof(invalid) / sizeof(invalid[0]); index++) { + pid_t child; + int status; + write_config(invalid[index]); + fflush(NULL); + child = fork(); + assert_true(child >= 0); + if (child == 0) { + read_spine_config(config_path); + _exit(0); + } + assert_int_equal(waitpid(child, &status, 0), child); + assert_true(WIFEXITED(status)); + assert_int_not_equal(WEXITSTATUS(status), 0); + assert_int_equal(unlink(config_path), 0); + config_path[0] = '\0'; + } +} + +static void test_script_limit_configuration_and_reset(void **state) { + (void) state; + write_config("Script_DeadlineSeconds 2\nScript_OutputBytes 3\nScript_CPUSeconds 1\n" + "Script_MemoryKiB 16384\nScript_FileKiB 1\nScript_OpenFiles 16\n"); + assert_int_equal(read_spine_config(config_path), 0); + assert_int_equal(set.php.limits.deadline_seconds, 2); + assert_int_equal(set.php.limits.output_bytes, 3); + assert_int_equal(set.php.limits.cpu_seconds, 1); + assert_int_equal(set.php.limits.memory_kib, 16384); + assert_int_equal(set.php.limits.file_kib, 1); + assert_int_equal(set.php.limits.open_files, 16); + config_defaults(); + assert_int_equal(set.php.limits.deadline_seconds, 0); + assert_int_equal(set.php.limits.output_bytes, 0); + assert_int_equal(set.php.limits.cpu_seconds, 0); + assert_int_equal(set.php.limits.memory_kib, 0); + assert_int_equal(set.php.limits.file_kib, 0); + assert_int_equal(set.php.limits.open_files, 0); +} + int main(void) { const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown(test_local_and_remote_tls_configuration, config_setup, config_teardown), + cmocka_unit_test_setup_teardown(test_invalid_policy_configuration_is_refused, config_setup, config_teardown), + cmocka_unit_test_setup_teardown(test_script_limit_configuration_and_reset, config_setup, config_teardown), cmocka_unit_test(test_spine_log_fits_a_musl_thread_stack), cmocka_unit_test(test_new_log_permissions), cmocka_unit_test(test_json_record_escaping_and_capacity), diff --git a/tests/unit/test_poll_host.c b/tests/unit/test_poll_host.c index f28e6b1c..71d1de3f 100644 --- a/tests/unit/test_poll_host.c +++ b/tests/unit/test_poll_host.c @@ -40,6 +40,9 @@ static struct { const char *engine_uptime; const char *legacy_uptime; int session_opens; + int session_closes; + int lease_acquires; + int lease_releases; char *queries[MAX_QUERIES]; int query_count; int completion_queries; @@ -152,14 +155,18 @@ void __wrap_db_escape(MYSQL *mysql, char *output, int max_size, const char *inpu snprintf(output, (size_t) max_size, "%s", input); } -pool_t *__wrap_db_get_connection(int type) { - (void) type; - return &fake_pool; +bool __wrap_db_acquire_lease(int type, db_lease_t *lease) { + fake.lease_acquires++; + lease->type = type; + lease->connection = &fake_pool; + return TRUE; } -void __wrap_db_release_connection(int type, int id) { - (void) type; - (void) id; +bool __wrap_db_release_lease(db_lease_t *lease) { + assert_ptr_equal(lease->connection, &fake_pool); + fake.lease_releases++; + lease->connection = NULL; + return TRUE; } void __wrap_db_free_result(MYSQL_RES *opaque) { @@ -190,6 +197,7 @@ void *__wrap_snmp_host_init(const snmp_connection_t *options) { void __wrap_snmp_host_cleanup(void *session) { assert_ptr_equal(session, &fake_session); + fake.session_closes++; } /* Mirrors snmp_get_base(): a failure is "" when allowed and U otherwise. */ @@ -316,6 +324,17 @@ static int setup(void **state) { /* Each item leaves through a different flush. 601 goes out when the batch is * full (max_oids=1) or when 602's credentials differ; 602 is the final flush. * All three apply the same regex, to the normalized value, and spike kill. */ +static void test_empty_device_releases_session_and_lease(void **state) { + (void) state; + set_host("regression", "5", AVAIL_NONE); + assert_int_equal(fake.item_count, 0); + run_poll(); + assert_int_equal(fake.session_opens, 1); + assert_int_equal(fake.session_closes, 1); + assert_int_equal(fake.lease_acquires, 1); + assert_int_equal(fake.lease_releases, 1); +} + static void test_flush_paths_agree(void **state) { (void) state; for (int scenario = 0; scenario < 4; scenario++) { @@ -449,6 +468,7 @@ static void test_system_uptime(void **state) { int main(void) { const struct CMUnitTest tests[] = { + cmocka_unit_test_setup(test_empty_device_releases_session_and_lease, setup), cmocka_unit_test_setup(test_flush_paths_agree, setup), cmocka_unit_test_setup(test_long_community_batches, setup), cmocka_unit_test_setup(test_snmpv3_batches_on_profile, setup), diff --git a/tests/unit/test_worker_set.c b/tests/unit/test_worker_set.c new file mode 100644 index 00000000..0316f769 --- /dev/null +++ b/tests/unit/test_worker_set.c @@ -0,0 +1,275 @@ +/* + * SPDX-FileCopyrightText: 2026 The Cacti Group + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include "config/config.h" +#include "platform/worker_set.h" + +#ifdef SPINE_WORKER_TEST_WRAP +static int fail_allocate; +static int fail_initialize; +static int fail_create; +static int fail_join; +static int fail_destroy; +static int fail_attributes; +static _Thread_local int cancel_when_publishing; +extern void *__real_calloc(size_t, size_t); +extern int __real_pthread_mutex_init(pthread_mutex_t *, const pthread_mutexattr_t *); +extern int __real_pthread_mutex_destroy(pthread_mutex_t *); +extern int __real_pthread_mutex_lock(pthread_mutex_t *); + +int __wrap_pthread_mutex_lock(pthread_mutex_t *mutex) { + int error = __real_pthread_mutex_lock(mutex); + if (error == 0 && cancel_when_publishing) { + cancel_when_publishing = 0; + pthread_cancel(pthread_self()); + } + return error; +} +extern int __real_pthread_create(pthread_t *, const pthread_attr_t *, void *(*) (void *), void *); +extern int __real_pthread_join(pthread_t, void **); +extern int __real_pthread_attr_getdetachstate(const pthread_attr_t *, int *); + +void *__wrap_calloc(size_t count, size_t size) { + if (fail_allocate) { + fail_allocate = 0; + return NULL; + } + return __real_calloc(count, size); +} +int __wrap_pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attributes) { + if (fail_initialize) { + fail_initialize = 0; + return EAGAIN; + } + return __real_pthread_mutex_init(mutex, attributes); +} +int __wrap_pthread_mutex_destroy(pthread_mutex_t *mutex) { + if (fail_destroy) { + fail_destroy = 0; + return EBUSY; + } + return __real_pthread_mutex_destroy(mutex); +} +int __wrap_pthread_create(pthread_t *thread, const pthread_attr_t *attributes, void *(*function)(void *), void *argument) { + if (fail_create) { + fail_create = 0; + return EPERM; + } + return __real_pthread_create(thread, attributes, function, argument); +} +int __wrap_pthread_join(pthread_t thread, void **result) { + if (fail_join) { + fail_join = 0; + return EINVAL; + } + return __real_pthread_join(thread, result); +} +int __wrap_pthread_attr_getdetachstate(const pthread_attr_t *attributes, int *state) { + if (fail_attributes) { + fail_attributes = 0; + return EINVAL; + } + return __real_pthread_attr_getdetachstate(attributes, state); +} +#endif + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + bool release; + bool entered; +} worker_gate_t; + +static void unlock_gate(void *argument) { + pthread_mutex_unlock(argument); +} + +static void *gated_worker(void *argument) { + worker_gate_t *gate = argument; + pthread_mutex_lock(&gate->mutex); + pthread_cleanup_push(unlock_gate, &gate->mutex); + gate->entered = true; + pthread_cond_broadcast(&gate->condition); + while (!gate->release) pthread_cond_wait(&gate->condition, &gate->mutex); + pthread_cleanup_pop(1); + return argument; +} + +static void await_entry(worker_gate_t *gate) { + pthread_mutex_lock(&gate->mutex); + while (!gate->entered) pthread_cond_wait(&gate->condition, &gate->mutex); + pthread_mutex_unlock(&gate->mutex); +} + +static void release_gate(worker_gate_t *gate) { + pthread_mutex_lock(&gate->mutex); + gate->release = true; + pthread_cond_broadcast(&gate->condition); + pthread_mutex_unlock(&gate->mutex); +} + +static void await_finished(spine_worker_set_t *workers, size_t index) { + bool finished; + unsigned int attempts; + for (attempts = 0; attempts < 5000; attempts++) { + pthread_mutex_lock(&workers->mutex); + finished = workers->slots[index].finished; + pthread_mutex_unlock(&workers->mutex); + if (finished) return; + usleep(1000); + } + fail_msg("worker did not publish completion"); +} + +static void test_registry_bounds_and_order(void **state) { + spine_worker_set_t workers = {0}; + pthread_attr_t attributes; + worker_gate_t first = {PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER, false, false}; + worker_gate_t second = {PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER, false, false}; + size_t remaining; + (void) state; + assert_int_equal(spine_worker_set_init(NULL, 1), EINVAL); + assert_int_equal(spine_worker_set_init(&workers, 0), EINVAL); + assert_int_equal(spine_worker_set_init(&workers, SIZE_MAX), EINVAL); + assert_int_equal(spine_worker_set_reap(NULL, &remaining), EINVAL); + assert_int_equal(spine_worker_set_destroy(&workers), EINVAL); + assert_int_equal(spine_worker_set_init(&workers, 2), 0); + assert_int_equal(spine_worker_set_init(&workers, 2), EINVAL); + assert_int_equal(spine_worker_set_reap(&workers, NULL), EINVAL); + assert_int_equal(pthread_attr_init(&attributes), 0); + assert_int_equal(spine_worker_set_start(&workers, NULL, gated_worker, &first), EINVAL); + assert_int_equal(spine_worker_set_start(&workers, &attributes, NULL, &first), EINVAL); + assert_int_equal(pthread_attr_setdetachstate(&attributes, PTHREAD_CREATE_DETACHED), 0); + assert_int_equal(spine_worker_set_start(&workers, &attributes, gated_worker, &first), EINVAL); + assert_int_equal(pthread_attr_setdetachstate(&attributes, PTHREAD_CREATE_JOINABLE), 0); + assert_int_equal(spine_worker_set_start(NULL, &attributes, gated_worker, &first), EINVAL); + assert_int_equal(spine_worker_set_start(&workers, &attributes, gated_worker, &first), 0); + await_entry(&first); + assert_int_equal(spine_worker_set_start(&workers, &attributes, gated_worker, &second), 0); + await_entry(&second); + assert_int_equal(spine_worker_set_start(&workers, &attributes, gated_worker, &second), EAGAIN); + assert_int_equal(spine_worker_set_destroy(&workers), EBUSY); + /* The second worker finishes first. Reusing it must not join/block on + * the first worker, which still waits on its gate. */ + release_gate(&second); + await_finished(&workers, 1); + assert_int_equal(spine_worker_set_start(&workers, &attributes, gated_worker, &second), 0); + await_finished(&workers, 1); + assert_int_equal(spine_worker_set_reap(&workers, &remaining), 0); + assert_int_equal(remaining, 1); + release_gate(&first); + await_finished(&workers, 0); + assert_int_equal(spine_worker_set_destroy(&workers), 0); + assert_null(workers.slots); + assert_int_equal(workers.capacity, 0); + assert_int_equal(pthread_attr_destroy(&attributes), 0); + assert_int_equal(pthread_mutex_destroy(&first.mutex), 0); + assert_int_equal(pthread_mutex_destroy(&second.mutex), 0); + assert_int_equal(pthread_cond_destroy(&first.condition), 0); + assert_int_equal(pthread_cond_destroy(&second.condition), 0); +} + +static void *exiting_worker(void *argument) { + pthread_exit(argument); +} + +static void test_exit_and_cancellation_are_joined(void **state) { + spine_worker_set_t workers = {0}; + pthread_attr_t attributes; + worker_gate_t gate = {PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER, false, false}; + (void) state; + assert_int_equal(pthread_attr_init(&attributes), 0); + assert_int_equal(spine_worker_set_init(&workers, 1), 0); + assert_int_equal(spine_worker_set_start(&workers, &attributes, exiting_worker, &gate), 0); + await_finished(&workers, 0); + assert_int_equal(spine_worker_set_start(&workers, &attributes, gated_worker, &gate), 0); + await_entry(&gate); + assert_int_equal(pthread_cancel(workers.slots[0].thread), 0); + await_finished(&workers, 0); + assert_int_equal(spine_worker_set_destroy(&workers), 0); + assert_int_equal(pthread_attr_destroy(&attributes), 0); + assert_int_equal(pthread_mutex_destroy(&gate.mutex), 0); + assert_int_equal(pthread_cond_destroy(&gate.condition), 0); +} + +#ifdef SPINE_WORKER_TEST_WRAP +static void *cancel_during_publication(void *argument) { + (void) argument; + pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL); + cancel_when_publishing = 1; + return NULL; +} + +static void test_async_cancellation_cannot_strand_publication_lock(void **state) { + spine_worker_set_t workers = {0}; + pthread_attr_t attributes; + (void) state; + assert_int_equal(pthread_attr_init(&attributes), 0); + assert_int_equal(spine_worker_set_init(&workers, 1), 0); + assert_int_equal(spine_worker_set_start(&workers, &attributes, cancel_during_publication, NULL), 0); + await_finished(&workers, 0); + /* The same slot and mutex must be usable after the cancelled finisher. */ + assert_int_equal(spine_worker_set_start(&workers, &attributes, exiting_worker, NULL), 0); + await_finished(&workers, 0); + assert_int_equal(spine_worker_set_destroy(&workers), 0); + assert_int_equal(pthread_attr_destroy(&attributes), 0); +} + +static void test_registry_failures_preserve_ownership(void **state) { + spine_worker_set_t workers = {0}; + pthread_attr_t attributes; + size_t remaining; + (void) state; + fail_allocate = 1; + assert_int_equal(spine_worker_set_init(&workers, 1), ENOMEM); + assert_null(workers.slots); + fail_initialize = 1; + assert_int_equal(spine_worker_set_init(&workers, 1), EAGAIN); + assert_null(workers.slots); + assert_int_equal(spine_worker_set_init(&workers, 1), 0); + assert_int_equal(pthread_attr_init(&attributes), 0); + fail_attributes = 1; + assert_int_equal(spine_worker_set_start(&workers, &attributes, exiting_worker, NULL), EINVAL); + fail_create = 1; + assert_int_equal(spine_worker_set_start(&workers, &attributes, exiting_worker, NULL), EPERM); + assert_false(workers.slots[0].occupied); + assert_null(workers.slots[0].argument); + assert_int_equal(spine_worker_set_start(&workers, &attributes, exiting_worker, NULL), 0); + await_finished(&workers, 0); + fail_join = 1; + assert_int_equal(spine_worker_set_start(&workers, &attributes, exiting_worker, NULL), EINVAL); + assert_true(workers.slots[0].occupied); + fail_join = 1; + assert_int_equal(spine_worker_set_destroy(&workers), EINVAL); + assert_true(workers.slots[0].occupied); + assert_int_equal(spine_worker_set_reap(&workers, &remaining), 0); + assert_int_equal(remaining, 0); + fail_destroy = 1; + assert_int_equal(spine_worker_set_destroy(&workers), EBUSY); + assert_non_null(workers.slots); + assert_int_equal(spine_worker_set_destroy(&workers), 0); + assert_int_equal(pthread_attr_destroy(&attributes), 0); +} +#endif + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_registry_bounds_and_order), + cmocka_unit_test(test_exit_and_cancellation_are_joined), +#ifdef SPINE_WORKER_TEST_WRAP + cmocka_unit_test(test_async_cancellation_cannot_strand_publication_lock), + cmocka_unit_test(test_registry_failures_preserve_ownership), +#endif + }; + alarm(20); + return cmocka_run_group_tests(tests, NULL, NULL); +} From e5a4943997914c68b2c653ae1de61cbda75ded6d Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 02:20:11 -0700 Subject: [PATCH 05/10] test(coverage): make privilege fixtures and feature declarations deterministic Signed-off-by: Thomas Vincent --- tests/tools/coverage_process_exit.h | 5 +++++ tests/unit/test_ping_icmp.c | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/tools/coverage_process_exit.h b/tests/tools/coverage_process_exit.h index 656ec9f2..9439bfb4 100644 --- a/tests/tools/coverage_process_exit.h +++ b/tests/tools/coverage_process_exit.h @@ -7,6 +7,11 @@ */ #ifndef SPINE_COVERAGE_PROCESS_EXIT_H #define SPINE_COVERAGE_PROCESS_EXIT_H +/* This header is force-included before common.h. Feature selection must + * precede the first system header, just as it does in production builds. */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE 1 +#endif /* Instrumentation only: flush owned subprocess counters before POSIX _exit. * Preserve _exit's descriptor and stdio behavior; do not replace it with exit. */ #include diff --git a/tests/unit/test_ping_icmp.c b/tests/unit/test_ping_icmp.c index 9658db10..e30aebd7 100644 --- a/tests/unit/test_ping_icmp.c +++ b/tests/unit/test_ping_icmp.c @@ -546,8 +546,8 @@ static void test_socket_retry_can_succeed_after_one_failure(void **state) { own owner. That wedged the thread at euid 0 and every other thread behind it. The elevation is gone; the retry must still give up rather than hang. - This runs exactly where the tests above skip: with no privilege, socket() - fails with EPERM and the retry loop is what executes. An alarm turns the + Inject EPERM independently of the runner's privilege so the retry loop + executes under both root and unprivileged coverage runs. An alarm turns the deadlock into a named failure instead of a CI job that hangs until the runner's own timeout kills it with nothing to read. */ static sigjmp_buf ping_deadlock_env; @@ -578,6 +578,7 @@ static void test_socket_retry_does_not_deadlock_on_seteuid(void **state) { make_host(&host, "127.0.0.1"); memset(&ping, 0, sizeof(ping)); + use_controlled_socket = 1; socket_failures_remaining = 5; /* five attempts at 500ms is about 2s; 15 leaves room on a loaded runner */ From 87a3941125cbee20270b682a2a8391069fff4465 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 02:50:54 -0700 Subject: [PATCH 06/10] test(ci): align SNMP fixtures and probe-aware TLS assertions Signed-off-by: Thomas Vincent --- .github/workflows/ci.yml | 20 +++++++++++-- .../contracts/test_spine_reindex_contracts.c | 4 +-- tests/regression/test_spine_regressions.c | 8 +++--- tests/tools/coverage_process_exit.h | 2 +- tests/unit/test_linked.c | 28 ++++++++++++------- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6b6c13a..6ab71459 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,9 @@ jobs: set -euo pipefail sudo apt-get update sudo apt-get install -y cppcheck + # Raw checkouts have no generated configure probes. Analyze the + # supported MySQL mode API, then both MariaDB verification APIs; + # treating every declaration probe as zero erases all TLS setters. # Project data structures are consumed across translation units; # checking headers in isolation otherwise reports every member. A # few externally visible helpers are deliberate unit-test seams. @@ -187,7 +190,20 @@ jobs: --suppress=toomanyconfigs \ --library=scripts/cppcheck/spine.cfg \ -I. -Isrc -Ivendor/uthash \ + -DHAVE_DECL_MYSQL_OPT_SSL_MODE=1 \ src + for callback in 0 1; do + callback_flags=() + if [[ "$callback" == 1 ]]; then + callback_flags=(-DHAVE_MARIADB_TLS_VERIFICATION_CALLBACK=1 -DHAVE_DECL_MARIADB_OPT_TLS_VERIFICATION_CALLBACK=1) + fi + cppcheck --enable=warning,style,performance,portability --std=c11 \ + --error-exitcode=1 --suppress=missingInclude --suppress=missingIncludeSystem \ + --library=scripts/cppcheck/spine.cfg -I. -Isrc -Ivendor/uthash \ + -DHAVE_DECL_MYSQL_OPT_SSL_ENFORCE=1 -DHAVE_DECL_MYSQL_OPT_SSL_VERIFY_SERVER_CERT=1 \ + -DHAVE_DECL_MYSQL_OPT_TLS_VERSION=1 -DHAVE_DECL_MYSQL_OPT_SSL_CIPHER=1 \ + "${callback_flags[@]}" src/database/tls.c + done # GCC 15 and later default to gnu23. Build with the newest Fedora GCC to # show that configure still selects gnu17 there. @@ -222,7 +238,7 @@ jobs: runuser -u builder -- ./configure --enable-warnings grep "^CFLAGS = .*-std=gnu17" Makefile runuser -u builder -- make --jobs "$(nproc)" - runuser -u builder -- make check + runuser -u builder -- make check || { cat test-suite.log; exit 1; } ' # The Clang counterpart of newest-gcc. Pin the major version so a Fedora @@ -259,7 +275,7 @@ jobs: runuser -u builder -- ./configure CC=clang --enable-warnings grep "^CFLAGS = .*-std=gnu17" Makefile runuser -u builder -- make --jobs "$(nproc)" - runuser -u builder -- make check + runuser -u builder -- make check || { cat test-suite.log; exit 1; } ' shellcheck: diff --git a/tests/contracts/test_spine_reindex_contracts.c b/tests/contracts/test_spine_reindex_contracts.c index f68741b3..f219de21 100644 --- a/tests/contracts/test_spine_reindex_contracts.c +++ b/tests/contracts/test_spine_reindex_contracts.c @@ -149,8 +149,8 @@ void test_additional_reindex_contracts(MYSQL *mysql) { assert(db_insert(mysql, LOCAL, query)); assert(db_insert(mysql, LOCAL, "INSERT INTO poller_item(local_data_id,host_id,poller_id,action,arg1,rrd_name) VALUES(801,45,1,1,'/usr/bin/printf 123','reindex_valid')")); snmp_spine_init(); - run_reindex_case(mysql, POLLER_ACTION_SNMP, ".1.3.6.1.2.1.1.6.0", "=", "isolated-regression-agent", "isolated-regression-agent", 0); - run_reindex_case(mysql, POLLER_ACTION_SNMP, ".1.3.6.1.2.1.1.6.0", "=", "previous", "isolated-regression-agent", 1); + run_reindex_case(mysql, POLLER_ACTION_SNMP, ".1.3.6.1.2.1.1.6.0", "=", "42 watts", "42 watts", 0); + run_reindex_case(mysql, POLLER_ACTION_SNMP, ".1.3.6.1.2.1.1.6.0", "=", "previous", "42 watts", 1); run_reindex_case(mysql, POLLER_ACTION_SNMP, "invalid-regression-oid", "=", "previous", "previous", 0); run_reindex_case(mysql, POLLER_ACTION_SNMP_COUNT, ".1.3.6.1.2.1.1.6", "=", "0", "1", 1); run_uptime_cache_case(mysql); diff --git a/tests/regression/test_spine_regressions.c b/tests/regression/test_spine_regressions.c index 57e8f8b7..589b0bb2 100644 --- a/tests/regression/test_spine_regressions.c +++ b/tests/regression/test_spine_regressions.c @@ -2496,7 +2496,7 @@ static void test_snmp_multi_responses(host_t *host) { } snmp_get_multi(host, items, oids, 4); assert(!host->ignore_host); - assert(strstr(oids[0].result, "isolated-regression-agent") != NULL); + assert(strstr(oids[0].result, "42 watts") != NULL); assert(strcmp(oids[1].result, "U") == 0); /* v1 reports the missing instance as a PDU error and v2c as an inline * exception varbind; neither is data, so both store U. */ @@ -2536,7 +2536,7 @@ static void test_snmp_multi_responses(host_t *host) { static void test_snmp_scalar_responses(host_t *host) { char *result = snmp_get(host, ".1.3.6.1.2.1.1.6.0"); - assert(strstr(result, "isolated-regression-agent") != NULL && !host->ignore_host); + assert(strstr(result, "42 watts") != NULL && !host->ignore_host); free(result); /* A malformed configured OID must not poison later requests or leak a PDU. */ for (int count = 0; count < 50; count++) { @@ -2553,7 +2553,7 @@ static void test_snmp_scalar_responses(host_t *host) { else assert(strcmp(result, "") == 0); free(result); result = snmp_get(host, ".1.3.6.1.2.1.1.6.0"); - assert(strstr(result, "isolated-regression-agent") != NULL && !host->ignore_host); + assert(strstr(result, "42 watts") != NULL && !host->ignore_host); free(result); if (host->snmp.profile.version == 2) { result = snmp_get(host, ".1.3.6.1.2.1.1.1.999"); @@ -2578,7 +2578,7 @@ static void test_system_information(host_t *host) { for (int explicit_update = 0; explicit_update <= 1; explicit_update++) { set.snmp.mibs = !explicit_update; get_system_information(host, &mysql, explicit_update); - assert(strcmp(host->system.snmp_sysLocation, "isolated-regression-agent") == 0); + assert(strcmp(host->system.snmp_sysLocation, "42 watts") == 0); assert(strcmp(host->system.snmp_sysContact, "regression") == 0); assert(host->system.snmp_sysDescr[0] != '\0' && host->system.snmp_sysObjectID[0] != '\0'); assert(host->system.snmp_sysName[0] != '\0' && !host->ignore_host); diff --git a/tests/tools/coverage_process_exit.h b/tests/tools/coverage_process_exit.h index 9439bfb4..a2d13aab 100644 --- a/tests/tools/coverage_process_exit.h +++ b/tests/tools/coverage_process_exit.h @@ -10,7 +10,7 @@ /* This header is force-included before common.h. Feature selection must * precede the first system header, just as it does in production builds. */ #ifndef _GNU_SOURCE -#define _GNU_SOURCE 1 +#define _GNU_SOURCE #endif /* Instrumentation only: flush owned subprocess counters before POSIX _exit. * Preserve _exit's descriptor and stdio behavior; do not replace it with exit. */ diff --git a/tests/unit/test_linked.c b/tests/unit/test_linked.c index 5f27b4c7..c57d16d8 100644 --- a/tests/unit/test_linked.c +++ b/tests/unit/test_linked.c @@ -402,6 +402,7 @@ static void test_database_tls_policy(void **state) { MYSQL mysql; spine_database_config_t configuration = {0}; int mode; + bool full_supported = FALSE; const char *version = NULL; (void) state; @@ -439,17 +440,22 @@ static void test_database_tls_policy(void **state) { supported = FALSE; #endif assert_int_equal(db_apply_tls_policy(&mysql, &configuration), supported); + if (mode == SPINE_TLS_VERIFY_FULL) full_supported = supported; } configuration.tls_mode = SPINE_TLS_VERIFY_FULL; configuration.tls_min_version = 12; #if HAVE_DECL_MYSQL_OPT_TLS_VERSION && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) - assert_true(db_apply_tls_policy(&mysql, &configuration)); - assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); - assert_string_equal(version, "TLSv1.2,TLSv1.3"); + assert_int_equal(db_apply_tls_policy(&mysql, &configuration), full_supported); + if (full_supported) { + assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); + assert_string_equal(version, "TLSv1.2,TLSv1.3"); + } configuration.tls_min_version = 13; - assert_true(db_apply_tls_policy(&mysql, &configuration)); - assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); - assert_string_equal(version, "TLSv1.3"); + assert_int_equal(db_apply_tls_policy(&mysql, &configuration), full_supported); + if (full_supported) { + assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); + assert_string_equal(version, "TLSv1.3"); + } #else (void) version; assert_false(db_apply_tls_policy(&mysql, &configuration)); @@ -457,16 +463,18 @@ static void test_database_tls_policy(void **state) { configuration.tls_min_version = 0; STRNCOPY(configuration.tls_cipher, "ECDHE-RSA-AES128-GCM-SHA256"); #if HAVE_DECL_MYSQL_OPT_SSL_CIPHER && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) - assert_true(db_apply_tls_policy(&mysql, &configuration)); - assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_SSL_CIPHER, &version), 0); - assert_string_equal(version, configuration.tls_cipher); + assert_int_equal(db_apply_tls_policy(&mysql, &configuration), full_supported); + if (full_supported) { + assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_SSL_CIPHER, &version), 0); + assert_string_equal(version, configuration.tls_cipher); + } #else assert_false(db_apply_tls_policy(&mysql, &configuration)); #endif configuration.tls_cipher[0] = '\0'; STRNCOPY(configuration.tls_ciphersuites, "TLS_AES_128_GCM_SHA256"); #if HAVE_DECL_MYSQL_OPT_TLS_CIPHERSUITES && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) - assert_true(db_apply_tls_policy(&mysql, &configuration)); + assert_int_equal(db_apply_tls_policy(&mysql, &configuration), full_supported); #else assert_false(db_apply_tls_policy(&mysql, &configuration)); #endif From 071cf85ef251e24f601d2e2d4a346952c7af5e29 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 02:59:09 -0700 Subject: [PATCH 07/10] fix(script): retain cleanup ownership across cancellation unwinding Signed-off-by: Thomas Vincent --- src/script/external.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/script/external.c b/src/script/external.c index 09f8756a..2a10f385 100644 --- a/src/script/external.c +++ b/src/script/external.c @@ -66,7 +66,7 @@ typedef struct { } script_cleanup_t; static void cleanup_script_poll(void *argument) { - script_cleanup_t *owned = argument; + volatile script_cleanup_t *owned = argument; int pid; pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); if (owned->fd >= 0) { @@ -182,7 +182,9 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) { bool valid_result; unsigned int execution_seconds; bool limited = script_limits_enabled(); - script_cleanup_t owned = {NULL, NULL, -1, current_host->id, FALSE}; + /* POSIX cleanup may return through setjmp after cancellation. Automatic + * ownership fields changed inside that scope must retain their values. */ + volatile script_cleanup_t owned = {NULL, NULL, -1, current_host->id, FALSE}; /* Deferred cancellation allows ownership to be registered between APIs. * Cancellation remains supported while waiting for a permit or output. */ pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &previous_state); @@ -214,7 +216,8 @@ char *exec_poll(host_t *current_host, char *command, int id, const char *type) { char executable[BUFSIZE]; char *saveptr = NULL; - pthread_cleanup_push(cleanup_script_poll, &owned); + /* pthread's opaque payload has no qualifier; the handler restores it. */ + pthread_cleanup_push(cleanup_script_poll, (void *) &owned); pthread_setcancelstate(previous_state, NULL); sem_err = acquire_script_permit(current_host); From dc423d4f9bc6f4bb6199a4136318fb8e73b136ae Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 03:02:21 -0700 Subject: [PATCH 08/10] test(database): cover common cleanup and older connector getters Signed-off-by: Thomas Vincent --- tests/regression/test_spine_faults.c | 2 +- tests/unit/test_linked.c | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/regression/test_spine_faults.c b/tests/regression/test_spine_faults.c index 601bd4c3..9dd28462 100644 --- a/tests/regression/test_spine_faults.c +++ b/tests/regression/test_spine_faults.c @@ -1473,7 +1473,7 @@ static void *run_ping_only_worker(void *argument) { assert(mysql_thread_init() == 0); int errors = -1; poll_host(argument, &errors); - assert(errors == -1); /* Early ping-only return preserves this output. */ + assert(errors == 0); /* Common cleanup returns an explicit no-error count. */ return NULL; } diff --git a/tests/unit/test_linked.c b/tests/unit/test_linked.c index c57d16d8..1047898f 100644 --- a/tests/unit/test_linked.c +++ b/tests/unit/test_linked.c @@ -398,12 +398,20 @@ static void test_local_and_remote_database_lease_adapters(void **state) { set.exit.exit_code = previous_exit; } +static void assert_client_option_when_readable(MYSQL *mysql, enum mysql_option option, const char *expected) { + const char *value = NULL; + int result = mysql_get_option(mysql, option, &value); + /* Connector/C 3.1 accepts TLS setters but lacks the corresponding getters. + * Real handshake contracts, rather than readback alone, prove enforcement. */ + if (result == 0) assert_string_equal(value, expected); + else assert_int_equal(result, 1); +} + static void test_database_tls_policy(void **state) { MYSQL mysql; spine_database_config_t configuration = {0}; int mode; bool full_supported = FALSE; - const char *version = NULL; (void) state; assert_non_null(mysql_init(&mysql)); @@ -447,17 +455,14 @@ static void test_database_tls_policy(void **state) { #if HAVE_DECL_MYSQL_OPT_TLS_VERSION && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) assert_int_equal(db_apply_tls_policy(&mysql, &configuration), full_supported); if (full_supported) { - assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); - assert_string_equal(version, "TLSv1.2,TLSv1.3"); + assert_client_option_when_readable(&mysql, MYSQL_OPT_TLS_VERSION, "TLSv1.2,TLSv1.3"); } configuration.tls_min_version = 13; assert_int_equal(db_apply_tls_policy(&mysql, &configuration), full_supported); if (full_supported) { - assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_TLS_VERSION, &version), 0); - assert_string_equal(version, "TLSv1.3"); + assert_client_option_when_readable(&mysql, MYSQL_OPT_TLS_VERSION, "TLSv1.3"); } #else - (void) version; assert_false(db_apply_tls_policy(&mysql, &configuration)); #endif configuration.tls_min_version = 0; @@ -465,8 +470,7 @@ static void test_database_tls_policy(void **state) { #if HAVE_DECL_MYSQL_OPT_SSL_CIPHER && (HAVE_DECL_MYSQL_OPT_SSL_MODE || HAVE_DECL_MYSQL_OPT_SSL_ENFORCE) assert_int_equal(db_apply_tls_policy(&mysql, &configuration), full_supported); if (full_supported) { - assert_int_equal(mysql_get_option(&mysql, MYSQL_OPT_SSL_CIPHER, &version), 0); - assert_string_equal(version, configuration.tls_cipher); + assert_client_option_when_readable(&mysql, MYSQL_OPT_SSL_CIPHER, configuration.tls_cipher); } #else assert_false(db_apply_tls_policy(&mysql, &configuration)); From dc6f95ccbbeaa6d16d67c481e9ebf06075f06069 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 03:11:19 -0700 Subject: [PATCH 09/10] test(coverage): exclude temporary suite logs from source evidence Signed-off-by: Thomas Vincent --- tests/tools/run_coverage.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/tools/run_coverage.sh b/tests/tools/run_coverage.sh index 421e5f00..06918699 100755 --- a/tests/tools/run_coverage.sh +++ b/tests/tools/run_coverage.sh @@ -37,7 +37,9 @@ case "$coverage_base" in "$source_dir"/*) coverage_find_path="./${coverage_base#"$source_dir"/}";; *) ;; esac -find . -path "$coverage_find_path" -prune -o -type f \( -name '*.c' -o -name '*.h' -o -name 'Makefile.am' -o -name 'configure.ac' -o -path './scripts/copyright_year.sh' -o -path './tests/fixtures/*' -o -path './tests/tools/*' -o -path './.github/workflows/*.yml' \) -not -path './.git/*' -not -path './config/config.h' -print0 | sort -z > "$coverage_dir/source-inputs.nul" +# Automake leaves .log/.trs files beside shell suites after make check. They +# are build outputs, and make clean removes them; hash only tool inputs here. +find . -path "$coverage_find_path" -prune -o -type f \( -name '*.c' -o -name '*.h' -o -name 'Makefile.am' -o -name 'configure.ac' -o -path './scripts/copyright_year.sh' -o -path './tests/fixtures/*' -o \( -path './tests/tools/*' -a \( -name '*.sh' -o -name '*.py' -o -name '*.txt' \) \) -o -path './.github/workflows/*.yml' \) -not -path './.git/*' -not -path './config/config.h' -print0 | sort -z > "$coverage_dir/source-inputs.nul" xargs -0 sha256sum < "$coverage_dir/source-inputs.nul" > "$coverage_dir/source.sha256" tar -czf "$coverage_dir/source-inputs.tar.gz" --null -T "$coverage_dir/source-inputs.nul" # Autoreconf replaces this tracked template. Preserve its original bytes and From 663d82927561eed8918e91d977a7c557862460b7 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 03:17:38 -0700 Subject: [PATCH 10/10] docs(perf): record paired runtime build measurements Signed-off-by: Thomas Vincent --- Makefile.am | 10 +++ Makefile.in | 10 +++ docs/benchmarks/README.md | 4 ++ .../2026-10-11-runtime-ownership/README.md | 55 +++++++++++++++ .../build-settings.txt | 5 ++ .../builds.sha256 | 4 ++ .../2026-10-11-runtime-ownership/compiler.txt | 4 ++ .../cpu-quota.txt | 1 + .../2026-10-11-runtime-ownership/cycles.csv | 45 +++++++++++++ .../dependencies.txt | 9 +++ .../metadata.json | 25 +++++++ .../revisions.txt | 2 + .../2026-10-11-runtime-ownership/summary.json | 67 +++++++++++++++++++ tests/tools/test_benchmark_record.py | 8 +++ 14 files changed, 249 insertions(+) create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/README.md create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/build-settings.txt create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/builds.sha256 create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/compiler.txt create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/cpu-quota.txt create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/cycles.csv create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/dependencies.txt create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/metadata.json create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/revisions.txt create mode 100644 docs/benchmarks/results/2026-10-11-runtime-ownership/summary.json diff --git a/Makefile.am b/Makefile.am index d4af94e5..bddb72e0 100644 --- a/Makefile.am +++ b/Makefile.am @@ -86,6 +86,16 @@ EXTRA_DIST += docs/benchmarks/README.md \ docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt \ docs/benchmarks/results/2026-10-11-default-logging/revisions.txt \ docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt +EXTRA_DIST += docs/benchmarks/results/2026-10-11-runtime-ownership/README.md \ + docs/benchmarks/results/2026-10-11-runtime-ownership/cycles.csv \ + docs/benchmarks/results/2026-10-11-runtime-ownership/metadata.json \ + docs/benchmarks/results/2026-10-11-runtime-ownership/summary.json \ + docs/benchmarks/results/2026-10-11-runtime-ownership/compiler.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/dependencies.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/builds.sha256 \ + docs/benchmarks/results/2026-10-11-runtime-ownership/cpu-quota.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/revisions.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/build-settings.txt test_spine_regressions_SOURCES = $(spine_runtime_sources) tests/regression/test_spine_regressions.c tests/contracts/test_spine_contracts.c tests/contracts/test_spine_reindex_contracts.c tests/contracts/test_spine_snmpv3_contracts.c tests/contracts/test_spine_settings_contracts.c tests/contracts/test_spine_cli_contracts.c tests/contracts/test_spine_stream_contracts.c tests/contracts/test_spine_output_contracts.c tests/contracts/test_spine_privilege_contracts.c test_spine_regressions_SOURCES += tests/contracts/test_spine_tls_contracts.c test_spine_regressions_SOURCES += tests/contracts/test_spine_script_limits_contracts.c diff --git a/Makefile.in b/Makefile.in index e1a4ebc1..f61d5e36 100644 --- a/Makefile.in +++ b/Makefile.in @@ -1192,6 +1192,16 @@ EXTRA_DIST = tests/tools/check_nightly_tests.sh \ docs/benchmarks/results/2026-10-11-default-logging/cpu-quota.txt \ docs/benchmarks/results/2026-10-11-default-logging/revisions.txt \ docs/benchmarks/results/2026-10-11-default-logging/build-settings.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/README.md \ + docs/benchmarks/results/2026-10-11-runtime-ownership/cycles.csv \ + docs/benchmarks/results/2026-10-11-runtime-ownership/metadata.json \ + docs/benchmarks/results/2026-10-11-runtime-ownership/summary.json \ + docs/benchmarks/results/2026-10-11-runtime-ownership/compiler.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/dependencies.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/builds.sha256 \ + docs/benchmarks/results/2026-10-11-runtime-ownership/cpu-quota.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/revisions.txt \ + docs/benchmarks/results/2026-10-11-runtime-ownership/build-settings.txt \ tests/tools/run_script_limits.sh \ tests/fixtures/script_resource_probe.c \ docs/operations/script-limits.md \ diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index 40492de0..7e8f20cd 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -44,6 +44,10 @@ without measurements. ## Recording results +Checked-in measurements are available for [default text logging](results/2026-10-11-default-logging/README.md) +and [the complete runtime changes](results/2026-10-11-runtime-ownership/README.md). +Each record preserves the raw cycles, exact build identities and interpretation. + After a completed workflow run, download its artifact and add a directory under `docs/benchmarks/results/` containing `cycles.csv`, `summary.json`, `metadata.json` and the build revision/toolchain records. Add a short Markdown interpretation diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/README.md b/docs/benchmarks/results/2026-10-11-runtime-ownership/README.md new file mode 100644 index 00000000..94c01c0a --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/README.md @@ -0,0 +1,55 @@ + + + +# Runtime ownership comparison, 2026-10-11 + +This is a completed local run of the implemented paired-build harness, not a +GitHub Actions run. The baseline is +`24e105fc1a4002c334b5cffd78d571fbb10388ce`; the candidate is +`dc423d4f9bc6f4bb6199a4136318fb8e73b136ae`. The candidate includes the +destination-specific upsert fix, explicit database TLS policy, checked database +leases, bounded joinable workers, common polling cleanup, optional script limits, +opt-in JSON logging and accompanying tests. JSON logging and script limits were +disabled. Subsequent coverage-script and documentation changes are excluded. + +Both exact source archives were built with GCC 13.3.0, `-O2 -g0`, empty +`LDFLAGS` and `--with-mysql-client=mariadb`, using unchanged dependency versions. +The baseline binary was reused from the first comparison; its hash was verified +unchanged. Compiler, package, source-archive and binary hashes are recorded here. + +Both builds ran in the same ARM64 Ubuntu 24.04 container under OrbStack, reporting +14 CPUs and an unlimited cgroup CPU quota (`max 100000`). The database was the +same unchanged MariaDB 10.11.14 server. The workload used one owned Net-SNMP +agent, four threads, 64 logical loopback devices and 16 SNMPv2c items per device. +Database TLS was disabled on both builds and the server. All 1,024 stored numeric +results were verified after every cycle. Mutable database state was reset before +each cycle, outside the measurement. Two warmup pairs preceded 20 measured pairs +in alternating AB/BA order. Warmups remain in the CSV but are excluded from the +summary. No other local validation build ran during measurement. + +| Measurement | Baseline median (observed range) | Candidate median (observed range) | Median paired candidate minus baseline | +| --- | --- | --- | --- | +| Polling cycle wall time, seconds | 0.746001 (0.738826–0.763041) | 0.752204 (0.740908–0.855073) | +0.006495 | +| Process CPU time, seconds | 0.022048 (0.019588–0.045952) | 0.022403 (0.019180–0.049824) | −0.000320 | +| Process CPU usage, percent | 2.940351 (2.625428–6.182113) | 2.996671 (2.498811–5.826869) | +0.003860 percentage points | + +The candidate had a higher observed median wall time in this run. CPU +measurements were similar, and the observed ranges overlap. These measurements +do not demonstrate a speedup or establish a general performance regression. +The difference between independent medians need not equal the median paired +difference. This was a shared development machine, not a dedicated performance +host. CPU measurements cover Spine and its children, excluding the database and +agent. The short loopback workload includes startup, connection and worker wait +time; it does not represent production network latency, SNMPv3, encrypted +database traffic or limited external scripts. Those workloads need separate +controlled comparisons. No percentage improvement is claimed. + +`cycles.csv` preserves all 44 original cycle samples. `summary.json` retains +full precision, and `metadata.json` records configuration/fixture hashes, +binary identities, server version and the complete measurement status. +Independently check this record from the repository root: + +```sh +python3 tests/tools/verify_benchmark_record.py \ + docs/benchmarks/results/2026-10-11-runtime-ownership +``` diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/build-settings.txt b/docs/benchmarks/results/2026-10-11-runtime-ownership/build-settings.txt new file mode 100644 index 00000000..122ad370 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/build-settings.txt @@ -0,0 +1,5 @@ +CC=gcc +CFLAGS=-O2 -g0 +LDFLAGS= +configure=--with-mysql-client=mariadb +sources=git archive of each exact revision; no working-tree changes diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/builds.sha256 b/docs/benchmarks/results/2026-10-11-runtime-ownership/builds.sha256 new file mode 100644 index 00000000..f621e879 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/builds.sha256 @@ -0,0 +1,4 @@ +c8c7627d1ee4d05049b6efada786ad3d3082810b0c8ae52a71bee40a78e4ce72 /tmp/benchmark-baseline.tar +9810a0f2fd9ff5d11ba9200a1b5a7f85175b40af13e596e509c2e96a336d46d0 /tmp/benchmark-runtime-candidate.tar +e38ab215ecfe3dcf4a49ee488c2bbd4d73e2675e79c24a8775a89c7e3bcb022b /tmp/benchmark-baseline/spine +21c34a05e1b5eceef1668edd9beac4ff337f4ab8ad04d3c0c1b4141d0eda9aff /tmp/benchmark-runtime-candidate/spine diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/compiler.txt b/docs/benchmarks/results/2026-10-11-runtime-ownership/compiler.txt new file mode 100644 index 00000000..9a3b6633 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/compiler.txt @@ -0,0 +1,4 @@ +gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 +Copyright (C) 2023 Free Software Foundation, Inc. +This is free software; see the source for copying conditions. There is NO +warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/cpu-quota.txt b/docs/benchmarks/results/2026-10-11-runtime-ownership/cpu-quota.txt new file mode 100644 index 00000000..1c1d3e7c --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/cpu-quota.txt @@ -0,0 +1 @@ +max 100000 diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/cycles.csv b/docs/benchmarks/results/2026-10-11-runtime-ownership/cycles.csv new file mode 100644 index 00000000..ff700a71 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/cycles.csv @@ -0,0 +1,45 @@ +pair,position,build,warmup,elapsed_seconds,user_seconds,system_seconds,cpu_seconds,cpu_percent +0,0,baseline,True,0.7470941430074163,0.009148999999999997,0.014377999999999995,0.023526999999999992,3.1491345796518235 +0,1,candidate,True,0.736776141973678,0.013637999999999997,0.011366000000000001,0.025004,3.3937038098192502 +1,0,candidate,True,0.7495427879621275,0.012084999999999999,0.010742000000000002,0.022827,3.045456559199579 +1,1,baseline,True,0.74330715398537,0.012479000000000018,0.036187,0.048666000000000015,6.5472261014936874 +2,0,baseline,False,0.7443374540307559,0.009069999999999995,0.01713199999999998,0.026201999999999975,3.5201775563100055 +2,1,candidate,False,0.747262310993392,0.005886999999999976,0.014718000000000009,0.020604999999999984,2.7573985328670205 +3,0,candidate,False,0.7545350780128501,0.00844499999999998,0.015951999999999966,0.024396999999999947,3.233381814965063 +3,1,baseline,False,0.7388258260325529,0.010261999999999993,0.01306099999999999,0.023322999999999983,3.156765664952861 +4,0,baseline,False,0.7454093800042756,0.0071570000000000245,0.012722999999999984,0.01988000000000001,2.666990855398946 +4,1,candidate,False,0.7675651430035941,0.005047999999999997,0.014131999999999978,0.019179999999999975,2.498810710052028 +5,0,candidate,False,0.7524250189890154,0.010986999999999969,0.010987000000000025,0.021973999999999994,2.920423888818188 +5,1,baseline,False,0.7630406889948063,0.0069310000000000205,0.015247999999999984,0.022179000000000004,2.906660197795948 +6,0,baseline,False,0.758453901973553,0.008013999999999966,0.012021999999999977,0.020035999999999943,2.641689883572989 +6,1,candidate,False,0.742105727957096,0.013284999999999991,0.00959500000000002,0.02288000000000001,3.0831186363411 +7,0,candidate,False,0.744033534952905,0.008965,0.01304099999999997,0.02200599999999997,2.957662385660195 +7,1,baseline,False,0.7474241459858604,0.024383000000000044,0.02156899999999995,0.04595199999999999,6.148048634338514 +8,0,baseline,False,0.752003307978157,0.010306000000000037,0.012881999999999949,0.023187999999999986,3.0834970742806247 +8,1,candidate,False,0.7472781029646285,0.011310000000000042,0.007917000000000063,0.019227000000000105,2.5729376953134397 +9,0,candidate,False,0.7462481359834783,0.00922400000000001,0.0115289999999999,0.02075299999999991,2.78097847074011 +9,1,baseline,False,0.744988460035529,0.007602999999999915,0.01411999999999991,0.021722999999999826,2.915884092884317 +10,0,baseline,False,0.7588615720160306,0.010303000000000062,0.011238999999999999,0.02154200000000006,2.838725901322224 +10,1,candidate,False,0.7451239429647103,0.010051000000000032,0.011055999999999955,0.021106999999999987,2.8326830991390692 +11,0,candidate,False,0.757055324036628,0.01353099999999996,0.017396999999999996,0.030927999999999956,4.0853024895316095 +11,1,baseline,False,0.7403169720200822,0.00968800000000003,0.011624999999999996,0.021313000000000026,2.878901984624753 +12,0,baseline,False,0.7391324199852534,0.01783200000000007,0.027862000000000053,0.04569400000000012,6.182112807460371 +12,1,candidate,False,0.7519834049744532,0.0067789999999999795,0.014912999999999954,0.021691999999999934,2.884638125855566 +13,0,candidate,False,0.792519126960542,0.009603000000000028,0.011523999999999979,0.021127000000000007,2.6658031688176376 +13,1,baseline,False,0.7392361289821565,0.013149999999999995,0.00876700000000008,0.021917000000000075,2.9648172134358846 +14,0,baseline,False,0.7600867669680156,0.011348000000000025,0.012765999999999944,0.02411399999999997,3.1725325381193863 +14,1,candidate,False,0.8550732470466755,0.008943000000000034,0.040880999999999945,0.04982399999999998,5.826869238639653 +15,0,candidate,False,0.752770496008452,0.01580400000000004,0.010866000000000042,0.026670000000000083,3.5429125000803214 +15,1,baseline,False,0.7438125439803116,0.009312000000000098,0.014631999999999978,0.023944000000000076,3.219090642364034 +16,0,baseline,False,0.7464120249496773,0.010660000000000003,0.010660000000000114,0.021320000000000117,2.8563312603970306 +16,1,candidate,False,0.7409075180185027,0.011403000000000052,0.01254299999999997,0.023946000000000023,3.2319823213620054 +17,0,candidate,False,0.7440463789971545,0.006947999999999954,0.014888000000000012,0.021835999999999967,2.9347632911581543 +17,1,baseline,False,0.7465532339992933,0.007997000000000032,0.020564999999999944,0.028561999999999976,3.8258490753556917 +18,0,baseline,False,0.7460878969868645,0.012464999999999948,0.0071229999999999905,0.01958799999999994,2.625427926000092 +18,1,candidate,False,0.7583100430201739,0.012313000000000018,0.012312999999999796,0.024625999999999815,3.2474843537506293 +19,0,candidate,False,0.7666276980307885,0.011394999999999822,0.012660999999999811,0.024055999999999633,3.1378986256029484 +19,1,baseline,False,0.7443419639603235,0.006567999999999907,0.02080000000000015,0.02736800000000006,3.6768046576853872 +20,0,baseline,False,0.7497563870274462,0.005711000000000022,0.015705999999999998,0.02141700000000002,2.8565278496541855 +20,1,candidate,False,0.7575927449506707,0.01036999999999999,0.013826000000000116,0.024196000000000106,3.193800384344439 +21,0,candidate,False,0.7510674389777705,0.008769000000000027,0.014030999999999905,0.02279999999999993,3.035679463222576 +21,1,baseline,False,0.7459137290134095,0.010286999999999935,0.011430999999999969,0.021717999999999904,2.911596764511285 diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/dependencies.txt b/docs/benchmarks/results/2026-10-11-runtime-ownership/dependencies.txt new file mode 100644 index 00000000..1589af15 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/dependencies.txt @@ -0,0 +1,9 @@ +autoconf 2.71-3 +automake 1:1.16.5-1.3ubuntu1 +gcc 4:13.2.0-7ubuntu1 +libc6-dev:arm64 2.39-0ubuntu8.9 +libmariadb-dev 1:10.11.14-0ubuntu0.24.04.1 +libsnmp-dev 5.9.4+dfsg-1.1ubuntu3.2 +libssl-dev:arm64 3.0.13-0ubuntu3.16 +libtool 2.4.7-7build1 +snmpd 5.9.4+dfsg-1.1ubuntu3.2 diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/metadata.json b/docs/benchmarks/results/2026-10-11-runtime-ownership/metadata.json new file mode 100644 index 00000000..2570169c --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/metadata.json @@ -0,0 +1,25 @@ +{ + "status": "complete", + "timestamp_utc": "2026-10-11T10:10:36Z", + "platform": "Linux-7.0.14-orbstack-00380-ga7e0a2dc9535-aarch64-with-glibc2.39", + "machine": "aarch64", + "cpu_count": 14, + "threads": 4, + "devices": 64, + "items_per_device": 16, + "pairs": 20, + "warmup_pairs": 2, + "tls": "disabled on both builds and the loopback disposable server", + "server_tls": "have_ssl\tDISABLED", + "order": "alternating AB/BA", + "fixture_sha256": "2f76368129cfc0203c138eb4373d912c2d1b8c9f28931a742d9b1acbf7651078", + "config_sha256": "7ea05c3f04207e493bcfe696470e6ea836e5b1cf6693cb829a922e25684568b1", + "agent_config_sha256": "84b6c01407a1f72e727b0e97f2761164c27cff7ffc9eafd99852269753a22209", + "binary_sha256": { + "baseline": "e38ab215ecfe3dcf4a49ee488c2bbd4d73e2675e79c24a8775a89c7e3bcb022b", + "candidate": "21c34a05e1b5eceef1668edd9beac4ff337f4ab8ad04d3c0c1b4141d0eda9aff" + }, + "database_version": "10.11.14-MariaDB-0ubuntu0.24.04.1", + "snmpd_version": "NET-SNMP version: 5.9.4.pre2\nWeb: http://www.net-snmp.org/\nEmail: net-snmp-coders@lists.sourceforge.net", + "cpu_scope": "Spine process and its children; server and SNMP agent CPU excluded" +} diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/revisions.txt b/docs/benchmarks/results/2026-10-11-runtime-ownership/revisions.txt new file mode 100644 index 00000000..7be5e21c --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/revisions.txt @@ -0,0 +1,2 @@ +baseline 24e105fc1a4002c334b5cffd78d571fbb10388ce +candidate dc423d4f9bc6f4bb6199a4136318fb8e73b136ae diff --git a/docs/benchmarks/results/2026-10-11-runtime-ownership/summary.json b/docs/benchmarks/results/2026-10-11-runtime-ownership/summary.json new file mode 100644 index 00000000..7fc55e92 --- /dev/null +++ b/docs/benchmarks/results/2026-10-11-runtime-ownership/summary.json @@ -0,0 +1,67 @@ +{ + "elapsed_seconds": { + "baseline": { + "median": 0.746000813000137, + "minimum": 0.7388258260325529, + "maximum": 0.7630406889948063 + }, + "candidate": { + "median": 0.7522042119817343, + "minimum": 0.7409075180185027, + "maximum": 0.8550732470466755 + }, + "median_paired_delta": 0.006495033943792805 + }, + "user_seconds": { + "baseline": { + "median": 0.009975000000000012, + "minimum": 0.005711000000000022, + "maximum": 0.024383000000000044 + }, + "candidate": { + "median": 0.00982700000000003, + "minimum": 0.005047999999999997, + "maximum": 0.01580400000000004 + }, + "median_paired_delta": -0.00020199999999997997 + }, + "system_seconds": { + "baseline": { + "median": 0.01297149999999997, + "minimum": 0.0071229999999999905, + "maximum": 0.027862000000000053 + }, + "candidate": { + "median": 0.01285099999999989, + "minimum": 0.007917000000000063, + "maximum": 0.040880999999999945 + }, + "median_paired_delta": -0.0021469999999999267 + }, + "cpu_seconds": { + "baseline": { + "median": 0.02204800000000004, + "minimum": 0.01958799999999994, + "maximum": 0.04595199999999999 + }, + "candidate": { + "median": 0.02240299999999995, + "minimum": 0.019179999999999975, + "maximum": 0.04982399999999998 + }, + "median_paired_delta": -0.00032000000000004247 + }, + "cpu_percent": { + "baseline": { + "median": 2.9403506531601007, + "minimum": 2.625427926000092, + "maximum": 6.182112807460371 + }, + "candidate": { + "median": 2.9966709244413856, + "minimum": 2.498810710052028, + "maximum": 5.826869238639653 + }, + "median_paired_delta": 0.0038604444195424747 + } +} diff --git a/tests/tools/test_benchmark_record.py b/tests/tools/test_benchmark_record.py index 6a1aa72f..20b9e6b2 100644 --- a/tests/tools/test_benchmark_record.py +++ b/tests/tools/test_benchmark_record.py @@ -35,6 +35,14 @@ def change_rows(self, transform): def test_measured_record(self): verify(self.record) + def test_all_checked_in_records(self): + records = sorted(RECORD.parent.iterdir()) + self.assertGreaterEqual(len(records), 2) + for record in records: + if record.is_dir(): + with self.subTest(record=record.name): + verify(record) + def test_missing_cycle(self): self.change_rows(lambda rows: rows.pop()) with self.assertRaisesRegex(ValueError, "missing cycle"):