ci: enforce cross-platform quality gates and connector coverage (#618)

## Summary

Consolidates the connector/build work from #613 and the broader
test-infrastructure work extracted from #597 into one review:

- discover MariaDB Connector/C first while retaining MySQL and
Percona-compatible support
- test MariaDB and MySQL Connector/C with strict GCC and Clang builds on
Ubuntu 24.04
- add a real macOS 15 build and unit-test lane with portable Autotools
platform detection
- run unit tests under ASan/UBSan and ThreadSanitizer
- enforce the measured 7% global coverage baseline and 70% changed-line
coverage
- add libFuzzer coverage for output parsing alongside the existing
hostname parser
- exercise the SNMPv3 integration suite against MariaDB and MySQL 8.4
- add cppcheck, shellcheck, DCO verification, CODEOWNERS, distcheck, and
Linux-capability checks while retaining the repository-managed default
CodeQL setup
- expose a stable `CI / required` aggregate check for branch protection
- pin runners and third-party actions and keep workflow permissions
read-only by default

## Correctness fixes found by the new gates

- reject truncated capability strings and poller-output SQL tuples
instead of publishing partial data
- make `bootstrap` fail closed and select `libtoolize`/`glibtoolize`
portably
- use Autoconf's canonical host for Darwin detection instead of an empty
native-build alias
- remove stale initialization/name-shadow findings and make read-only
interfaces const-correct
- make the ICMP parser test portable across Linux and BSD header names

## Verification

- strict GCC and Clang builds: 4/4 unit targets passed
- ASan/UBSan: 4/4 passed
- ThreadSanitizer: 4/4 passed
- macOS Clang/Homebrew build: 4/4 passed
- MariaDB integration: passed
- MySQL 8.4 integration: passed
- both libFuzzer targets: passed sustained local runs
- coverage: 7.2% global and 84% on changed lines
- cppcheck, shellcheck, actionlint, DCO verification, and `git diff
--check`: passed locally

## Post-merge repository settings

After these workflows exist on `develop`, an administrator should
require `CI / required`, Fuzz, Integration, Rocky Linux, and the
repository-managed CodeQL/code-scanning result in the active ruleset;
enable code-owner approval; and require the branch to be current (or use
a merge queue). Enabling those settings before this PR merges would
create required checks that the base branch cannot yet emit.

Consolidates and supersedes #613. Closes #612.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: TheWitness <thewitness@cacti.net>
Signed-off-by: TheWitness <copilot@users.noreply.github.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
Co-authored-by: TheWitness <copilot@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-24 05:52:56 -04:00
1 parent dbbccc5263
commit 86a062dd09
42 files changed
+1423 -268

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# Default review routing.
* @Cacti/cacti-developers
# CI, release and dependency policy changes need both implementation and
# security review. These requirements take effect when the repository ruleset
# enables code-owner approval.
/.github/ @Cacti/cacti-developers @Cacti/cacti-security
/configure.ac @Cacti/cacti-developers @Cacti/cacti-security
/Makefile.am @Cacti/cacti-developers @Cacti/cacti-security
/Dockerfile @Cacti/cacti-developers @Cacti/cacti-security
# High-risk runtime boundaries.
/sql.c @Cacti/cacti-developers @Cacti/cacti-security
/php.c @Cacti/cacti-developers @Cacti/cacti-security
/nft_popen.c @Cacti/cacti-developers @Cacti/cacti-security
/snmp.c @Cacti/cacti-developers @Cacti/cacti-security
/ping.c @Cacti/cacti-developers @Cacti/cacti-security
/poller.c @Cacti/cacti-developers @Cacti/cacti-security
+64
View File
@@ -0,0 +1,64 @@
name: Build Spine
description: Install the build dependencies, then bootstrap and configure Spine.
inputs:
mysql-client-package:
description: MySQL-compatible client development package to install.
required: false
default: libmysqlclient-dev
extra-packages:
description: Additional apt packages, beyond the set every Spine build needs.
required: false
default: ''
configure-args:
description: Arguments passed through to ./configure.
required: false
default: ''
cflags:
description: CFLAGS for ./configure.
required: false
default: ''
ldflags:
description: LDFLAGS for ./configure.
required: false
default: ''
runs:
using: composite
steps:
# mysql-server is deliberately absent: configure only needs the selected
# client headers and library, and no job starts or connects to a server.
- name: Install build dependencies
shell: bash
env:
MYSQL_CLIENT_PACKAGE: ${{ inputs.mysql-client-package }}
EXTRA_PACKAGES: ${{ inputs.extra-packages }}
run: |
set -euo pipefail
sudo apt-get update
# shellcheck disable=SC2086
sudo apt-get install -y \
autoconf automake libtool build-essential help2man dos2unix \
"$MYSQL_CLIENT_PACKAGE" libsnmp-dev libssl-dev libcmocka-dev \
$EXTRA_PACKAGES
- name: Bootstrap and configure
shell: bash
env:
CONFIGURE_ARGS: ${{ inputs.configure-args }}
IN_CFLAGS: ${{ inputs.cflags }}
IN_LDFLAGS: ${{ inputs.ldflags }}
run: |
set -euo pipefail
./bootstrap
args=()
if [ -n "$CONFIGURE_ARGS" ]; then
read -ra args <<< "$CONFIGURE_ARGS"
fi
if [ -n "$IN_CFLAGS" ]; then
args+=("CFLAGS=$IN_CFLAGS")
fi
if [ -n "$IN_LDFLAGS" ]; then
args+=("LDFLAGS=$IN_LDFLAGS")
fi
./configure ${args[@]+"${args[@]}"}
+275 -87
View File
@@ -14,33 +14,90 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
dco:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# pull_request normally checks out GitHub's synthetic merge commit,
# which cannot carry the contributor's DCO trailer.
ref: ${{ github.event.pull_request.head.sha }}
- name: Verify Developer Certificate of Origin trailers
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-dco.sh "origin/${BASE_REF}..HEAD"
build: build:
runs-on: ubuntu-latest name: Build (${{ matrix.compiler }}, ${{ matrix.client.name }})
runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
compiler: [gcc, clang] compiler: [gcc, clang]
client:
- name: MariaDB Connector/C
package: libmariadb-dev
config: mariadb_config
- name: MySQL Connector/C
package: libmysqlclient-dev
config: mysql_config
env: env:
CC: ${{ matrix.compiler }} CC: ${{ matrix.compiler }}
MYSQL_CONFIG: ${{ matrix.client.config }}
# get_date_format() deliberately constructs the bounded strftime
# format; keep every other warning fatal.
STRICT_CFLAGS: -Wall -Wextra -Wformat=2 -Wno-format-nonliteral
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install build dependencies - uses: ./.github/actions/build-spine
run: | with:
sudo apt-get update mysql-client-package: ${{ matrix.client.package }}
sudo apt-get install -y \ cflags: ${{ env.STRICT_CFLAGS }}
mysql-server libmysqlclient-dev \
libsnmp-dev libssl-dev build-essential \
help2man autoconf automake libtool dos2unix libcmocka-dev
- name: Prepare for Spine Build
run: |
./bootstrap
./configure --enable-warnings
- name: Build Spine - name: Build Spine
run: | run: |
set -euo pipefail
configured_cflags=$(sed -n 's/^CFLAGS = //p' Makefile)
make -j"$(nproc)" CFLAGS="$configured_cflags -Werror"
- name: Run the unit tests
run: |
set -euo pipefail
configured_cflags=$(sed -n 's/^CFLAGS = //p' Makefile)
# Test doubles and cmocka callbacks intentionally leave parameters
# unused; tests/unit/test_build_fixes.c also inlines strncpy-based
# helpers specifically to exercise their truncation behavior, which
# -Wstringop-truncation flags on principle even though it's the
# scenario under test. Every other test-source warning stays fatal.
# -Wstringop-truncation is GCC-only; -Wno-unknown-warning-option
# keeps clang from erroring on it under -Werror.
make check CFLAGS="$configured_cflags -Werror -Wno-unused-parameter -Wno-unknown-warning-option -Wno-stringop-truncation"
# Linux capabilities are opt-in at configure time. Keep one build on that
# path so hasCaps() and the CAP_NET_RAW handling cannot silently rot behind
# an otherwise-green default build.
linux-capabilities:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/build-spine
with:
extra-packages: libcap-dev
# Exercise the supported low-buffer configuration as well as the
# Linux-capability path; formatter tests must not assume defaults.
configure-args: --enable-lcap --enable-warnings --with-results-buffer=512
- name: Build Spine with Linux capabilities
run: |
set -euo pipefail
make -j"$(nproc)" make -j"$(nproc)"
- name: Run the unit tests - name: Run the unit tests
@@ -48,58 +105,108 @@ jobs:
set -euo pipefail set -euo pipefail
make check make check
# cppcheck: macos:
# runs-on: ubuntu-latest runs-on: macos-15
# steps: timeout-minutes: 25
# - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
#
# - name: Install cppcheck
# run: |
# sudo apt-get update
# sudo apt-get install -y cppcheck build-essential
#
# - name: Run cppcheck
# run: |
# cppcheck \
# --enable=all \
# --std=c11 \
# --error-exitcode=1 \
# --suppress=missingIncludeSystem \
# --suppress=unusedFunction \
# --suppress=checkersReport \
# --suppress=variableScope \
# --suppress=unreadVariable \
# --suppress=shadowVariable \
# --suppress=constVariablePointer \
# --suppress=redundantAssignment \
# --suppress=toomanyconfigs \
# *.c *.h
# Coverage is reported, not gated. The number is only meaningful once
# something measures it; the threshold conversation comes after that.
coverage:
runs-on: ubuntu-latest
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install build dependencies - name: Install build dependencies
run: |
set -euo pipefail
brew install autoconf automake libtool help2man \
mariadb-connector-c net-snmp openssl@3 cmocka
- name: Build and test
env:
CC: clang
run: |
set -euo pipefail
mariadb_prefix=$(brew --prefix mariadb-connector-c)
openssl_prefix=$(brew --prefix openssl@3)
net_snmp_prefix=$(brew --prefix net-snmp)
cmocka_prefix=$(brew --prefix cmocka)
export MYSQL_CONFIG="$mariadb_prefix/bin/mariadb_config"
export CPPFLAGS="-I$openssl_prefix/include -I$net_snmp_prefix/include -I$cmocka_prefix/include"
export LDFLAGS="-L$mariadb_prefix/lib -L$openssl_prefix/lib -L$net_snmp_prefix/lib -L$cmocka_prefix/lib"
./bootstrap
./configure --enable-warnings --with-snmp="$net_snmp_prefix"
make -j"$(sysctl -n hw.ncpu)"
make check
cppcheck:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run cppcheck
run: | run: |
set -euo pipefail set -euo pipefail
sudo apt-get update sudo apt-get update
sudo apt-get install -y mysql-server libmysqlclient-dev libsnmp-dev \ sudo apt-get install -y cppcheck
libssl-dev build-essential help2man autoconf automake libtool \ # Project data structures are consumed across translation units;
dos2unix libcmocka-dev gcovr # checking headers in isolation otherwise reports every member. A
# few externally visible helpers are deliberate unit-test seams.
cppcheck \
--enable=all \
--std=c11 \
--error-exitcode=1 \
--suppress=missingInclude \
--suppress=missingIncludeSystem \
--suppress=normalCheckLevelMaxBranches \
--suppress=checkLevelNormal \
--suppress=unmatchedSuppression \
--suppress=unusedFunction \
--suppress=checkersReport \
--suppress=variableScope \
--suppress=unreadVariable \
--suppress=unusedStructMember \
--suppress=staticFunction \
--suppress=constVariablePointer \
--suppress=redundantAssignment \
--suppress=toomanyconfigs \
./*.c ./*.h
shellcheck:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run shellcheck
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y shellcheck
find scripts tests -type f -name '*.sh' -print0 \
| xargs -0 shellcheck
# Preserve the measured baseline and require stronger coverage on changed
# lines. Raise both thresholds as integration coverage grows.
coverage:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: ./.github/actions/build-spine
with:
extra-packages: gcovr python3-pip
cflags: -g -O0 --coverage
ldflags: --coverage
- name: Build instrumented - name: Build instrumented
run: | run: |
set -euo pipefail set -euo pipefail
./bootstrap
./configure CFLAGS="-g -O0 --coverage" LDFLAGS="--coverage"
make -j"$(nproc)" make -j"$(nproc)"
- name: Run the unit tests - name: Run the unit tests
run: make check run: |
set -euo pipefail
make check
- name: Report coverage - name: Report coverage
run: | run: |
@@ -107,15 +214,42 @@ jobs:
# configure leaves instrumented conftest objects behind and gcovr # configure leaves instrumented conftest objects behind and gcovr
# cannot resolve their sources # cannot resolve their sources
rm -f ./*conftest*.gcno ./*conftest*.gcda rm -f ./*conftest*.gcno ./*conftest*.gcda
gcovr -r . --exclude 'tests/.*' --txt --print-summary gcovr -r . \
--exclude 'tests/.*' \
--txt --print-summary \
--xml coverage.xml \
--html-details coverage.html \
--fail-under-line 7
- name: Enforce changed-line coverage
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
python3 -m pip install --break-system-packages diff-cover==10.5.1
diff-cover coverage.xml \
--compare-branch="origin/${BASE_REF}" \
--fail-under=70
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.3
with:
name: coverage-report
path: |
coverage.xml
coverage.html
coverage.*.html
if-no-files-found: error
flawfinder: flawfinder:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: "3.x" python-version: "3.x"
@@ -127,6 +261,7 @@ jobs:
# informational so we have a baseline to chip away at. # informational so we have a baseline to chip away at.
- name: Run flawfinder - name: Run flawfinder
run: | run: |
set -euo pipefail
flawfinder \ flawfinder \
--minlevel=3 \ --minlevel=3 \
--error-level=5 \ --error-level=5 \
@@ -134,64 +269,117 @@ jobs:
--context \ --context \
. | tee flawfinder-report.txt . | tee flawfinder-report.txt
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() if: always()
with: with:
name: flawfinder-report name: flawfinder-report
path: flawfinder-report.txt path: flawfinder-report.txt
# Spine is a threaded network daemon; ASan and UBSan are the checks most # Spine is a threaded network daemon; ASan and UBSan are the checks most
# likely to catch a real defect here. # likely to catch a real defect here. The unit tests are what actually
# exercises the code, so they are what runs instrumented.
sanitizers: sanitizers:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
env:
CC: clang
ASAN_OPTIONS: detect_leaks=1:exitcode=1
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install build dependencies - uses: ./.github/actions/build-spine
run: | with:
set -euo pipefail extra-packages: clang
sudo apt-get update cflags: -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1
sudo apt-get install -y clang mysql-server libmysqlclient-dev libsnmp-dev \ ldflags: -fsanitize=address,undefined
libssl-dev build-essential help2man autoconf automake libtool dos2unix libcmocka-dev
- name: Build with ASan and UBSan - name: Build with ASan and UBSan
env:
CC: clang
run: | run: |
set -euo pipefail set -euo pipefail
./bootstrap
./configure \
CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1" \
LDFLAGS="-fsanitize=address,undefined"
make -j"$(nproc)" make -j"$(nproc)"
- name: Run the binary under the sanitizers - name: Run the unit tests under the sanitizers
env: run: |
ASAN_OPTIONS: detect_leaks=1:exitcode=1 set -euo pipefail
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 make check
- name: Smoke-test the binary under the sanitizers
run: | run: |
set -euo pipefail set -euo pipefail
./spine --version ./spine --version
./spine --help > /dev/null ./spine --help > /dev/null
# Spine is highly concurrent. Keep race detection separate from ASan/UBSan
# because the sanitizer runtimes cannot be combined.
thread-sanitizer:
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
CC: clang
TSAN_OPTIONS: halt_on_error=1:second_deadlock_stack=1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/build-spine
with:
extra-packages: clang
cflags: -fsanitize=thread -fno-omit-frame-pointer -g -O1
ldflags: -fsanitize=thread
- name: Build with ThreadSanitizer
run: |
set -euo pipefail
make -j"$(nproc)"
- name: Run the unit tests under ThreadSanitizer
run: |
set -euo pipefail
make check
# A release tarball that cannot be compiled is a release blocker, and the # A release tarball that cannot be compiled is a release blocker, and the
# only way to catch it is to build from the tarball the way a packager does. # only way to catch it is to build from the tarball the way a packager does.
distcheck: distcheck:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install build dependencies - uses: ./.github/actions/build-spine
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y mysql-server libmysqlclient-dev libsnmp-dev \
libssl-dev build-essential help2man autoconf automake libtool dos2unix libcmocka-dev
- name: make distcheck - name: make distcheck
run: | run: |
set -euo pipefail set -euo pipefail
./bootstrap
./configure
make distcheck make distcheck
# Branch protection should require this stable name rather than every matrix
# child. Any failed, cancelled or unexpectedly skipped dependency fails it.
required:
name: CI / required
if: always()
needs:
- dco
- build
- linux-capabilities
- macos
- cppcheck
- shellcheck
- coverage
- flawfinder
- sanitizers
- thread-sanitizer
- distcheck
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify every required job passed
env:
RESULTS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
failures=$(jq -r 'to_entries[] | select(.value.result != "success") | "\(.key)=\(.value.result)"' <<<"$RESULTS")
if [ -n "$failures" ]; then
echo "Required CI jobs did not pass:"
echo "$failures"
exit 1
fi
+12 -16
View File
@@ -3,7 +3,12 @@ name: Fuzz
on: on:
pull_request: pull_request:
branches: [develop] branches: [develop]
paths: ['**.c', '**.h', 'tests/fuzz/**', '.github/workflows/fuzz.yml'] paths:
- '**.c'
- '**.h'
- 'tests/fuzz/**'
- '.github/actions/build-spine/**'
- '.github/workflows/fuzz.yml'
schedule: schedule:
- cron: '0 3 * * 0' - cron: '0 3 * * 0'
workflow_dispatch: workflow_dispatch:
@@ -17,23 +22,14 @@ concurrency:
jobs: jobs:
fuzz: fuzz:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install build dependencies - uses: ./.github/actions/build-spine
run: | with:
set -euo pipefail extra-packages: clang pkg-config
sudo apt-get update
sudo apt-get install -y clang libmysqlclient-dev libsnmp-dev libssl-dev \
autoconf automake libtool help2man pkg-config
- name: Generate config.h
run: |
set -euo pipefail
./bootstrap
./configure
# A pull request gets a short regression run against the committed corpus. # A pull request gets a short regression run against the committed corpus.
# The weekly schedule runs long enough to explore. # The weekly schedule runs long enough to explore.
@@ -47,7 +43,7 @@ jobs:
fi fi
make -C tests/fuzz FUZZ_SECONDS="$seconds" run make -C tests/fuzz FUZZ_SECONDS="$seconds" run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure() if: failure()
with: with:
name: fuzz-crashes name: fuzz-crashes
+52 -5
View File
@@ -3,7 +3,7 @@ name: Integration
on: on:
pull_request: pull_request:
branches: [develop] branches: [develop]
paths: ['**.c', '**.h', 'Dockerfile', '.dockerignore', 'tests/snmpv3/**', 'tests/integration/test_script_server_reap.sh', '.github/workflows/integration.yml'] paths: ['**.c', '**.h', 'Dockerfile', '.dockerignore', 'tests/integration/**', 'tests/test-harness.sh', 'tests/snmpv3/**', '.github/workflows/integration.yml']
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -15,21 +15,68 @@ concurrency:
jobs: jobs:
snmpv3: snmpv3:
runs-on: ubuntu-latest name: SNMPv3 integration (${{ matrix.database }})
runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
strategy:
fail-fast: false
matrix:
database: [mariadb, mysql]
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Brings up MariaDB and an snmpd, then polls a real device through # Brings up MariaDB and an snmpd, then polls a real device through
# poll_host() and checks the value lands in poller_output. # poll_host() and checks the value lands in poller_output.
- name: Poll an SNMPv3 device end to end - name: Poll an SNMPv3 device end to end
run: tests/snmpv3/scripts/run-integration.sh env:
SPINE_TEST_DATABASE: ${{ matrix.database }}
run: |
set -euo pipefail
tests/snmpv3/scripts/run-integration.sh
- name: Exercise concurrent host polling
if: matrix.database == 'mariadb'
env:
SPINE_TEST_DATABASE: mariadb
SPINE_TEST_HOSTS: 24
SPINE_TEST_THREAD_COUNTS: "1 4 12"
SPINE_TEST_REPEATS: 1
SPINE_TEST_TIMEOUT_SECONDS: 120
SPINE_TEST_ARTIFACT_DIR: test-artifacts/concurrency
run: |
set -euo pipefail
tests/integration/test_concurrency.sh
- name: Exercise SNMP packet loss and recovery
if: matrix.database == 'mariadb'
env:
SPINE_TEST_TIMEOUT_SECONDS: 120
SPINE_TEST_ARTIFACT_DIR: test-artifacts/network-faults
run: |
set -euo pipefail
tests/integration/test_network_faults.sh
- name: Reap a real PHP script-server process - name: Reap a real PHP script-server process
run: tests/integration/test_script_server_reap.sh run: tests/integration/test_script_server_reap.sh
- name: Show container logs on failure - name: Show container logs on failure
if: failure() if: failure()
env:
SPINE_TEST_DATABASE: ${{ matrix.database }}
run: | run: |
set -euo pipefail
cd tests/snmpv3 cd tests/snmpv3
docker compose logs --no-color || true compose=(docker compose)
if [[ "$SPINE_TEST_DATABASE" == mysql ]]; then
compose+=(-f docker-compose.yml -f docker-compose.mysql.yml)
fi
"${compose[@]}" logs --no-color || true
- name: Upload integration diagnostics
if: always() && matrix.database == 'mariadb'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.3
with:
name: integration-diagnostics-${{ matrix.database }}
path: test-artifacts
if-no-files-found: warn
retention-days: 14
+41
View File
@@ -0,0 +1,41 @@
name: Concurrency soak
on:
schedule:
- cron: '23 5 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: spine-concurrency-soak
cancel-in-progress: false
jobs:
concurrency-soak:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run repeated many-host polling
env:
SPINE_TEST_DATABASE: mariadb
SPINE_TEST_HOSTS: 100
SPINE_TEST_THREAD_COUNTS: "1 8 32"
SPINE_TEST_REPEATS: 5
SPINE_TEST_TIMEOUT_SECONDS: 180
SPINE_TEST_ARTIFACT_DIR: test-artifacts/concurrency-soak
run: |
set -euo pipefail
tests/integration/test_concurrency.sh
- name: Upload soak diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.3
with:
name: concurrency-soak
path: test-artifacts/concurrency-soak
if-no-files-found: error
retention-days: 30
+1
View File
@@ -32,3 +32,4 @@ libtool
*.o *.o
*.m4 *.m4
tests/unit/bin/ tests/unit/bin/
test-artifacts/
+1
View File
@@ -1,6 +1,7 @@
The Cacti Group | spine The Cacti Group | spine
1.3.0 1.3.0
-feature#612: Prefer MariaDB Connector/C discovery while retaining MySQL Connector/C compatibility
-issue#60: Warn at startup when the spine and Cacti versions come from different release lines -issue#60: Warn at startup when the spine and Cacti versions come from different release lines
-issue#263: Unify the debug message prefix so a single poll no longer logs two orders -issue#263: Unify the debug message prefix so a single poll no longer logs two orders
-issue#360: If a Remote Data Collector has not devices, spine does not properly register itself -issue#360: If a Remote Data Collector has not devices, spine does not properly register itself
+1 -2
View File
@@ -72,8 +72,7 @@ CYGWIN Prerequisite
* gcc-debuginfo * gcc-debuginfo
* gzip * gzip
* help2man * help2man
* libmysqlclient * libmariadb-devel (preferred) or libmysqlclient
* libmariadb-devel
* libtool * libtool
* m4 * m4
* make * make
+5 -1
View File
@@ -53,7 +53,11 @@ noinst_HEADERS = common.h error.h src/core/keywords.h src/core/locks.h \
nft_popen.h php.h ping.h poller.h snmp.h spine.h spine_sem.h sql.h \ nft_popen.h php.h ping.h poller.h snmp.h spine.h spine_sem.h sql.h \
uthash.h util.h uthash.h util.h
EXTRA_DIST = spine.1 spine.conf.dist EXTRA_DIST = spine.1 spine.conf.dist \
tests/README.md \
tests/test-harness.sh \
tests/integration/test_concurrency.sh \
tests/integration/test_network_faults.sh
# Docker targets — require Dockerfile and Dockerfile.dev (from PR #401) # Docker targets — require Dockerfile and Dockerfile.dev (from PR #401)
.PHONY: docker docker-dev verify cppcheck .PHONY: docker docker-dev verify cppcheck
+4 -1
View File
@@ -622,6 +622,7 @@ LT_SYS_LIBRARY_PATH = @LT_SYS_LIBRARY_PATH@
MAKEINFO = @MAKEINFO@ MAKEINFO = @MAKEINFO@
MANIFEST_TOOL = @MANIFEST_TOOL@ MANIFEST_TOOL = @MANIFEST_TOOL@
MKDIR_P = @MKDIR_P@ MKDIR_P = @MKDIR_P@
MYSQL_CONFIG = @MYSQL_CONFIG@
NM = @NM@ NM = @NM@
NMEDIT = @NMEDIT@ NMEDIT = @NMEDIT@
OBJDUMP = @OBJDUMP@ OBJDUMP = @OBJDUMP@
@@ -711,7 +712,9 @@ noinst_HEADERS = common.h error.h src/core/keywords.h src/core/locks.h \
nft_popen.h php.h ping.h poller.h snmp.h spine.h spine_sem.h sql.h \ nft_popen.h php.h ping.h poller.h snmp.h spine.h spine_sem.h sql.h \
uthash.h util.h uthash.h util.h
EXTRA_DIST = spine.1 spine.conf.dist \ EXTRA_DIST = spine.1 spine.conf.dist tests/README.md \
tests/test-harness.sh tests/integration/test_concurrency.sh \
tests/integration/test_network_faults.sh \
tests/regression/test_remote_push.sh \ tests/regression/test_remote_push.sh \
tests/integration/test_alloc_failure.sh \ tests/integration/test_alloc_failure.sh \
tests/regression/test_child_process_safety.sh tests/regression/test_child_process_safety.sh
+1 -2
View File
@@ -64,8 +64,7 @@ chmod +s /usr/local/spine/bin/spine
* gzip * gzip
* help2man * help2man
* inetutils-src * inetutils-src
* libmysqlclient * libmariadb-devel (preferred) or libmysqlclient
* libmariadb-devel
* libssl-devel * libssl-devel
* libtool * libtool
* m4 * m4
+18 -10
View File
@@ -22,6 +22,8 @@
# #
# ---------------------------------------------------------- # ----------------------------------------------------------
set -eu
# Name: bootstrap # Name: bootstrap
# #
# Function: build spine from scratch # Function: build spine from scratch
@@ -49,7 +51,7 @@ display_help () {
} }
# Check for parameters # Check for parameters
if [ "${1}" = "--help" -o "${1}" = "-h" ]; then if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
display_help display_help
exit 0 exit 0
fi fi
@@ -61,22 +63,28 @@ echo "INFO: Removing cache directories"
rm -rf autom4te.cache .deps rm -rf autom4te.cache .deps
# Make sure all files are unix formatted files # Make sure all files are unix formatted files
which dos2unix > /dev/null 2>&1 if command -v dos2unix > /dev/null 2>&1; then
if [ $? -eq 0 ]; then for e in ac am c h in md mdlrc rb sh yml; do
for e in $(echo "ac am c h in md mdlrc rb sh yml"); do
echo "INFO: Ensuring UNIX format for *.$e" echo "INFO: Ensuring UNIX format for *.$e"
find . -type f -name \*.$e -exec dos2unix --d2u \{\} \; > /dev/null 2>&1 find . -type f -name "*.${e}" -exec dos2unix --d2u {} \; > /dev/null 2>&1
done done
fi fi
# Prepare a build state # Prepare a build state
echo "INFO: Running auto-tools to verify buildability" echo "INFO: Running auto-tools to verify buildability"
aclocal --install mkdir -p config m4
libtoolize
autoheader if command -v libtoolize > /dev/null 2>&1; then
automake --add-missing LIBTOOLIZE=libtoolize
elif command -v glibtoolize > /dev/null 2>&1; then
LIBTOOLIZE=glibtoolize
else
echo "ERROR: GNU libtoolize (or glibtoolize on macOS) is required" >&2
exit 1
fi
export LIBTOOLIZE
autoreconf --force --install autoreconf --force --install
[ $? -ne 0 ] && echo "ERROR: 'autoreconf' exited with errors" && exit -1
# Provide some meaningful notes # Provide some meaningful notes
+61 -91
View File
@@ -57,15 +57,10 @@ AC_ARG_WITH(snmp,
[SNMP_DIR=$withval] [SNMP_DIR=$withval]
) )
# if host_alias is empty, ac_cv_host_alias may still have the info
if test -z "$host_alias"; then
host_alias=$ac_cv_host_alias
fi
# Platform-specific tweaks # Platform-specific tweaks
ShLib="so" ShLib="so"
case $host_alias in case $host in
*sparc-sun-solaris2.8) *sparc-sun-solaris2.8)
CPPFLAGS="$CPPFLAGS -D_POSIX_PTHREAD_SEMANTICS" CPPFLAGS="$CPPFLAGS -D_POSIX_PTHREAD_SEMANTICS"
AC_DEFINE(SOLAR_THREAD, 1, [Correct issue around Solaris threading model]);; AC_DEFINE(SOLAR_THREAD, 1, [Correct issue around Solaris threading model]);;
@@ -217,98 +212,73 @@ else
AC_MSG_RESULT([no]) AC_MSG_RESULT([no])
fi fi
# ****************** MySQL Checks *********************** # ****************** MySQL-compatible client checks ***********************
AC_DEFUN([MYSQL_LIB_CHK], AC_ARG_VAR([MYSQL_CONFIG], [path to mariadb_config or mysql_config])
[ str="$1/libmysqlclient.*"
for j in `echo $str`; do if test -n "$MYSQL_DIR" && test -z "$MYSQL_CONFIG"; then
if test -r $j; then for mysql_config_candidate in mariadb_config mysql_config; do
MYSQL_LIB_DIR=$1 if test -x "$MYSQL_DIR/bin/$mysql_config_candidate"; then
MYSQL_CONFIG="$MYSQL_DIR/bin/$mysql_config_candidate"
break
fi
done
fi
if test -z "$MYSQL_CONFIG"; then
AC_PATH_PROGS([MYSQL_CONFIG], [mariadb_config mysql_config])
fi
if test -n "$MYSQL_CONFIG"; then
AC_MSG_NOTICE([using $MYSQL_CONFIG for database client flags])
if ! MYSQL_CFLAGS=`"$MYSQL_CONFIG" --cflags`; then
AC_MSG_ERROR([$MYSQL_CONFIG --cflags failed])
fi
if ! MYSQL_LIBS=`"$MYSQL_CONFIG" --libs`; then
AC_MSG_ERROR([$MYSQL_CONFIG --libs failed])
fi
CFLAGS="$MYSQL_CFLAGS $CFLAGS"
LIBS="$MYSQL_LIBS $LIBS"
else
AC_MSG_NOTICE([no client config tool found; using header and library discovery])
mysql_header_prefix=
for mysql_prefix in $MYSQL_DIR /usr /usr/local /opt /opt/mysql /usr/pkg /usr/local/mysql; do
for mysql_include_dir in include include/mysql include/mariadb mysql; do
if test -f "$mysql_prefix/$mysql_include_dir/mysql.h"; then
CFLAGS="-I$mysql_prefix/$mysql_include_dir $CFLAGS"
mysql_header_prefix=$mysql_prefix
break 2 break 2
fi fi
done done
]
)
# Determine MySQL installation paths
MYSQL_SUB_DIR="include include/mysql include/mariadb mysql";
for i in $MYSQL_DIR /usr /usr/local /opt /opt/mysql /usr/pkg /usr/local/mysql; do
for d in $MYSQL_SUB_DIR; do
if [[ -f $i/$d/mysql.h ]]; then
MYSQL_INC_DIR=$i/$d
break;
fi
done done
if [[ ! -z $MYSQL_INC_DIR ]]; then dnl The header can turn up under a prefix the caller never named, so search
break; dnl for the library under that same prefix. Adding only the MYSQL_DIR paths
dnl leaves mysql_init unlinkable when the client lives anywhere else.
mysql_library_prefixes="$MYSQL_DIR"
if test -n "$mysql_header_prefix" && test "x$mysql_header_prefix" != "x$MYSQL_DIR"; then
mysql_library_prefixes="$mysql_library_prefixes $mysql_header_prefix"
fi fi
# test -f $i/include/mysql.h && MYSQL_INC_DIR=$i/include && break
# test -f $i/include/mysql/mysql.h && MYSQL_INC_DIR=$i/include/mysql && break
# test -f $i/include/mariadb/mysql.h && MYSQL_INC_DIR=$i/include/mariadb && break
# test -f $i/mysql/include/mysql.h && MYSQL_INC_DIR=$i/mysql/include && break
done
if test -z "$MYSQL_INC_DIR"; then for mysql_prefix in $mysql_library_prefixes; do
if test "x$MYSQL_DIR" != "x"; then for mysql_library_dir in lib64 lib64/mysql lib lib/mysql; do
AC_MSG_ERROR(Cannot find MySQL header files under $MYSQL_DIR) if test -d "$mysql_prefix/$mysql_library_dir"; then
else LDFLAGS="-L$mysql_prefix/$mysql_library_dir $LDFLAGS"
AC_MSG_ERROR(Cannot find MySQL headers. Use --with-mysql= to specify non-default path.)
fi
fi
for i in $MYSQL_DIR /usr /usr/local /opt /opt/mysql /usr/pkg /usr/local/mysql; do
MYSQL_LIB_CHK($i/lib64)
MYSQL_LIB_CHK($i/lib64/mysql)
MYSQL_LIB_CHK($i/lib/x86_64-linux-gnu)
MYSQL_LIB_CHK($i/lib/x86_64-linux-gnu/mysql)
MYSQL_LIB_CHK($i/lib)
MYSQL_LIB_CHK($i/lib/mysql)
done
if test -n "$MYSQL_LIB_DIR" ; then
LDFLAGS="-L$MYSQL_LIB_DIR $LDFLAGS"
fi
CFLAGS="-I$MYSQL_INC_DIR $CFLAGS"
unamestr=$(uname)
if test $unamestr = 'OpenBSD'; then
AC_CHECK_LIB(mysqlclient, mysql_init,
[ LIBS="-lmysqlclient -lexecinfo -lm $LIBS"
AC_DEFINE(HAVE_MYSQL, 1, MySQL Client API)
HAVE_MYSQL=yes ],
[ HAVE_MYSQL=no ]
)
else
AC_CHECK_LIB(mysqlclient, mysql_init,
[ LIBS="-lmysqlclient -lm -ldl $LIBS"
AC_DEFINE(HAVE_MYSQL, 1, MySQL Client API)
HAVE_MYSQL=yes ],
[ HAVE_MYSQL=no ]
)
fi
if test -f $MYSQL_LIB_DIR/libmysqlclient_r.a -o -f $MYSQL_LIB_DIR/libmysqlclient_r.$ShLib; then
LIBS="-lmysqlclient_r -lm -ldl $LIBS"
else
if test -f $MYSQL_LIB_DIR/libmysqlclient_r.a -o -f $MYSQL_LIB_DIR/libmysqlclient_r.$ShLib ; then
LIBS="-lmysqlclient_r -lm -ldl $LIBS"
else
if test "$HAVE_MYSQL" = "yes"; then
if test $unamestr = 'OpenBSD'; then
LIBS="-lmysqlclient -lm $LIBS"
else
LIBS="-lmysqlclient -lm -ldl $LIBS"
fi fi
else done
if test -f $MYSQL_LIB_DIR/libperconaserverclient.a -o -f $MYSQL_LIB_DIR/libperconaserverclient.$ShLib; then done
LIBS="-lperconaserverclient -lm -ldl $LIBS"
else
LIBS="-lmariadbclient -lm -ldl $LIBS"
fi
fi
fi
fi fi
AC_CHECK_HEADER([mysql.h], [],
[AC_MSG_ERROR([Cannot find mysql.h. Install MariaDB Connector/C or MySQL Connector/C development files.])])
AC_SEARCH_LIBS([mysql_init], [mariadb mysqlclient perconaserverclient],
[AC_DEFINE([HAVE_MYSQL], [1], [MySQL-compatible Client API])
HAVE_MYSQL=yes],
[AC_MSG_ERROR([Cannot link a MySQL-compatible client library.])])
# ****************** Net-SNMP Checks *********************** # ****************** Net-SNMP Checks ***********************
if test "x$SNMP_DIR" != "x"; then if test "x$SNMP_DIR" != "x"; then
for i in / /net-snmp /include/net-snmp; do for i in / /net-snmp /include/net-snmp; do
@@ -501,7 +471,7 @@ fi
AC_MSG_CHECKING([if we can support mysql/mariadb retry count]) AC_MSG_CHECKING([if we can support mysql/mariadb retry count])
AC_LINK_IFELSE([AC_LANG_PROGRAM([[ AC_LINK_IFELSE([AC_LANG_PROGRAM([[
#include <stdlib.h> #include <stdlib.h>
#include "$MYSQL_INC_DIR/mysql.h" #include <mysql.h>
]], [[ ]], [[
if (MYSQL_OPT_RETRY_COUNT) { if (MYSQL_OPT_RETRY_COUNT) {
exit(0); exit(0);
@@ -516,7 +486,7 @@ AC_LINK_IFELSE([AC_LANG_PROGRAM([[
AC_MSG_CHECKING([if we mysql/mariadb supports verify certificates]) AC_MSG_CHECKING([if we mysql/mariadb supports verify certificates])
AC_LINK_IFELSE([AC_LANG_PROGRAM([[ AC_LINK_IFELSE([AC_LANG_PROGRAM([[
#include <stdlib.h> #include <stdlib.h>
#include "$MYSQL_INC_DIR/mysql.h" #include <mysql.h>
]], [[ ]], [[
if (MYSQL_OPT_SSL_VERIFY_SERVER_CERT) { if (MYSQL_OPT_SSL_VERIFY_SERVER_CERT) {
exit(0); exit(0);
@@ -532,7 +502,7 @@ AC_LINK_IFELSE([AC_LANG_PROGRAM([[
AC_MSG_CHECKING([if we can support mysql/mariadb ssl keys]) AC_MSG_CHECKING([if we can support mysql/mariadb ssl keys])
AC_LINK_IFELSE([AC_LANG_PROGRAM([[ AC_LINK_IFELSE([AC_LANG_PROGRAM([[
#include <stdlib.h> #include <stdlib.h>
#include "$MYSQL_INC_DIR/mysql.h" #include <mysql.h>
]], [[ ]], [[
if (MYSQL_OPT_SSL_KEY) { if (MYSQL_OPT_SSL_KEY) {
exit(0); exit(0);
+4 -4
View File
@@ -533,7 +533,7 @@ int php_init(int php_process) {
int cacti2php_pdes[2] = { -1, -1 }; int cacti2php_pdes[2] = { -1, -1 };
int php2cacti_pdes[2] = { -1, -1 }; int php2cacti_pdes[2] = { -1, -1 };
pid_t pid; pid_t pid;
char poller_id[TINY_BUFSIZE]; char poller_id[SMALL_BUFSIZE];
char *argv[7]; char *argv[7];
char arg_q[] = "-q"; char arg_q[] = "-q";
char arg_spine[] = "spine"; char arg_spine[] = "spine";
@@ -602,7 +602,7 @@ int php_init(int php_process) {
argv[1] = arg_q; argv[1] = arg_q;
argv[2] = set.path_php_server; argv[2] = set.path_php_server;
argv[3] = arg_spine; argv[3] = arg_spine;
snprintf(poller_id, TINY_BUFSIZE, "%d", set.poller_id); snprintf(poller_id, SMALL_BUFSIZE, "%d", set.poller_id);
argv[4] = poller_id; argv[4] = poller_id;
argv[5] = NULL; argv[5] = NULL;
} else if (set.poller_id > 1) { } else if (set.poller_id > 1) {
@@ -611,7 +611,7 @@ int php_init(int php_process) {
argv[2] = set.path_php_server; argv[2] = set.path_php_server;
argv[3] = arg_environ_spine; argv[3] = arg_environ_spine;
snprintf(poller_id, TINY_BUFSIZE, "--poller=%d", set.poller_id); snprintf(poller_id, SMALL_BUFSIZE, "--poller=%d", set.poller_id);
argv[4] = poller_id; argv[4] = poller_id;
if (set.mode == REMOTE_ONLINE) { if (set.mode == REMOTE_ONLINE) {
@@ -626,7 +626,7 @@ int php_init(int php_process) {
argv[1] = arg_q; argv[1] = arg_q;
argv[2] = set.path_php_server; argv[2] = set.path_php_server;
argv[3] = arg_environ_spine; argv[3] = arg_environ_spine;
snprintf(poller_id, TINY_BUFSIZE, "--poller=%d", set.poller_id); snprintf(poller_id, SMALL_BUFSIZE, "--poller=%d", set.poller_id);
argv[4] = poller_id; argv[4] = poller_id;
argv[5] = NULL; argv[5] = NULL;
+1 -4
View File
@@ -399,7 +399,6 @@ int ping_icmp(host_t *host, ping_t *ping) {
static unsigned int seq = 0; static unsigned int seq = 0;
struct icmp *icmp; struct icmp *icmp;
struct icmp *pkt;
unsigned char *packet = NULL; unsigned char *packet = NULL;
if (is_debug_device(host->id)) { if (is_debug_device(host->id)) {
@@ -611,8 +610,6 @@ int ping_icmp(host_t *host, ping_t *ping) {
goto keep_listening; goto keep_listening;
} }
pkt = (struct icmp *) reply_pkt;
if (fromname.sin_addr.s_addr == recvname.sin_addr.s_addr) { if (fromname.sin_addr.s_addr == recvname.sin_addr.s_addr) {
if (verdict == SPINE_ICMP_REPLY_OK) { if (verdict == SPINE_ICMP_REPLY_OK) {
@@ -1564,7 +1561,7 @@ name_t *get_namebyhost(char *hostname, name_t *name) {
if (tokens == 1) { if (tokens == 1) {
if (strlen(token) && token[0] == '[') { if (strlen(token) && token[0] == '[') {
SPINE_LOG_DEBUG(("DEBUG: get_namebyhost(%s) - Have TCPv6 method", hostname)); SPINE_LOG_DEBUG(("DEBUG: get_namebyhost(%s) - Have TCPv6 method", hostname));
strncpy(name->hostname, hostname, sizeof(name->hostname)); strncopy(name->hostname, hostname, sizeof(name->hostname));
break; break;
} else if (strlen(token) == 3) { } else if (strlen(token) == 3) {
if (strncasecmp(token, "TCP", 3) == 0) { if (strncasecmp(token, "TCP", 3) == 0) {
+54 -10
View File
@@ -34,6 +34,44 @@
#include "common.h" #include "common.h"
#include "spine.h" #include "spine.h"
int format_poller_output_row(char *output, size_t output_size,
int local_data_id, const char *escaped_rrd_name,
const char *host_time, const char *escaped_result) {
const char *timep;
int decimal_points = 0;
int digits = 0;
int written;
if (output == NULL || output_size == 0 || escaped_rrd_name == NULL ||
host_time == NULL || host_time[0] == '\0' || escaped_result == NULL) {
return FALSE;
}
/* host_time is emitted outside SQL quotes. Accept the integer timestamps
* Spine generates and a single fractional part, but no SQL syntax. */
for (timep = host_time; *timep != '\0'; timep++) {
if (*timep == '.') {
decimal_points++;
if (decimal_points > 1) {
return FALSE;
}
} else if (!isdigit((unsigned char)*timep)) {
return FALSE;
} else {
digits++;
}
}
if (digits == 0) {
return FALSE;
}
written = snprintf(output, output_size,
" (%i, '%s', FROM_UNIXTIME(%s), '%s')",
local_data_id, escaped_rrd_name, host_time, escaped_result);
return written >= 0 && (size_t)written < output_size;
}
void child_cleanup(void *arg) { void child_cleanup(void *arg) {
poller_thread_t poller_details = *(poller_thread_t*) arg; poller_thread_t poller_details = *(poller_thread_t*) arg;
@@ -158,14 +196,14 @@ int poller_store_hex_result(char *result, size_t result_size, const char *hex, i
return TRUE; return TRUE;
} }
/*! \fn void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, char *host_time, int *host_errors, double host_time_double) /*! \fn void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, const char *host_time, int *host_errors, double host_time_double)
* \brief core Spine function that polls a host * \brief core Spine function that polls a host
* \param host_id integer value for the host_id from the hosts table in Cacti * \param host_id integer value for the host_id from the hosts table in Cacti
* *
* This function is core to Spine. It takes a host_id and polls it, first * This function is core to Spine. It takes a host_id and polls it, first
* checking reachability and any required data-query reindexing. * checking reachability and any required data-query reindexing.
*/ */
void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, char *host_time, int *host_errors, double host_time_double) { void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, const char *host_time, int *host_errors, double host_time_double) {
char query1[BUFSIZE]; char query1[BUFSIZE];
char query2[BIG_BUFSIZE]; char query2[BIG_BUFSIZE];
char *query3 = NULL; char *query3 = NULL;
@@ -266,10 +304,16 @@ void poll_host(int device_counter, int host_id, int host_thread, int host_thread
//db_connect(LOCAL, &mysql); //db_connect(LOCAL, &mysql);
local_cnn = db_get_connection(LOCAL); local_cnn = db_get_connection(LOCAL);
if (local_cnn == NULL) { // GCOVR_EXCL_START: pool exhaustion is not reachable in tests
die("ERROR: Fatal error: poller.c could not obtain a local database connection!");
} // GCOVR_EXCL_STOP
mysql = local_cnn->mysql; mysql = local_cnn->mysql;
if (set.poller_id > 1 && set.mode == REMOTE_ONLINE) { if (set.poller_id > 1 && set.mode == REMOTE_ONLINE) {
remote_cnn = db_get_connection(REMOTE); remote_cnn = db_get_connection(REMOTE);
if (remote_cnn == NULL) { // GCOVR_EXCL_START: pool exhaustion is not reachable in tests
die("ERROR: Fatal error: poller.c could not obtain a remote database connection!");
} // GCOVR_EXCL_STOP
mysqlr = remote_cnn->mysql; mysqlr = remote_cnn->mysql;
} }
@@ -1964,17 +2008,17 @@ void poll_host(int device_counter, int host_id, int host_thread, int host_thread
db_escape(&mysqlt, escaped_result, sizeof(escaped_result), poller_items[i].result); db_escape(&mysqlt, escaped_result, sizeof(escaped_result), poller_items[i].result);
db_escape(&mysqlt, escaped_rrd_name, sizeof(escaped_rrd_name), poller_items[i].rrd_name); db_escape(&mysqlt, escaped_rrd_name, sizeof(escaped_rrd_name), poller_items[i].rrd_name);
/* escaped_result can now legitimately exceed result_string's capacity for a if (!format_poller_output_row(result_string, sizeof(result_string),
* very large result; snprintf's own bound below still truncates it safely.
* Silently-truncated large rows are the separate, already-tracked gap in #598. */
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wformat-truncation"
snprintf(result_string, RESULTS_BUFFER+SMALL_BUFSIZE, " (%i, '%s', FROM_UNIXTIME(%s), '%s')",
poller_items[i].local_data_id, poller_items[i].local_data_id,
escaped_rrd_name, escaped_rrd_name,
host_time, host_time,
escaped_result); escaped_result)) {
#pragma GCC diagnostic pop SPINE_LOG(("Device[%i] HT[%i] ERROR: Poller output for DS[%i] "
"exceeds the configured result buffer and was skipped",
host_id, host_thread, poller_items[i].local_data_id));
i++;
continue;
}
result_length = strlen(result_string); result_length = strlen(result_string);
+4 -1
View File
@@ -37,12 +37,15 @@ extern void *child(void *arg);
extern void child_cleanup(void *arg); extern void child_cleanup(void *arg);
extern void child_cleanup_thread(void *arg); extern void child_cleanup_thread(void *arg);
extern void child_cleanup_script(void *arg); extern void child_cleanup_script(void *arg);
extern void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, char *host_time, int *host_errors, double host_time_double); extern void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, const char *host_time, int *host_errors, double host_time_double);
extern int poller_store_hex_result(char *result, size_t result_size, const char *hex, int *errors); extern int poller_store_hex_result(char *result, size_t result_size, const char *hex, int *errors);
extern char *exec_poll(host_t *current_host, char *command, int id, const char *type); extern char *exec_poll(host_t *current_host, char *command, int id, const char *type);
extern void get_system_information(host_t *host, MYSQL *mysql, int system); extern void get_system_information(host_t *host, MYSQL *mysql, int system);
extern int is_multipart_output(char *result); extern int is_multipart_output(char *result);
extern int validate_result(char *result); extern int validate_result(char *result);
extern int format_poller_output_row(char *output, size_t output_size,
int local_data_id, const char *escaped_rrd_name,
const char *host_time, const char *escaped_result);
extern void buffer_output_errors(char * error_string, int * buf_size, int * buf_errors, int device_id, int thread_id, int local_data_id, bool flush); extern void buffer_output_errors(char * error_string, int * buf_size, int * buf_errors, int device_id, int thread_id, int local_data_id, bool flush);
#endif /* SPINE_POLLER_H */ #endif /* SPINE_POLLER_H */
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Verify that every commit in a revision range has a Signed-off-by trailer
# matching that commit's author. This enforces the Developer Certificate of
# Origin without granting a third-party action access to the repository.
set -euo pipefail
if [[ $# -ne 1 || -z $1 ]]; then
echo "usage: $0 <git-revision-range>" >&2
exit 2
fi
range=$1
failures=0
while IFS= read -r commit; do
author=$(git show -s --format='%an <%ae>' "$commit")
if ! git show -s --format='%B' "$commit" \
| git interpret-trailers --parse \
| grep -Fqi "Signed-off-by: $author"; then
echo "${commit}: missing Signed-off-by: ${author}" >&2
failures=$((failures + 1))
fi
done < <(git rev-list --reverse "$range")
if ((failures > 0)); then
echo "$failures commit(s) failed the DCO check." >&2
exit 1
fi
echo "All commits in $range have matching Signed-off-by trailers."
+2 -2
View File
@@ -214,8 +214,8 @@ void snmp_spine_close(void) {
* *
*/ */
void *snmp_host_init(int host_id, char *hostname, int snmp_version, char *snmp_community, void *snmp_host_init(int host_id, char *hostname, int snmp_version, char *snmp_community,
char *snmp_username, char *snmp_password, char *snmp_auth_protocol, char *snmp_username, const char *snmp_password, char *snmp_auth_protocol,
char *snmp_priv_passphrase, char *snmp_priv_protocol, const char *snmp_priv_passphrase, char *snmp_priv_protocol,
char *snmp_context, char *snmp_engine_id, int snmp_port, int snmp_timeout) { char *snmp_context, char *snmp_engine_id, int snmp_port, int snmp_timeout) {
void *sessp = NULL; void *sessp = NULL;
+1 -1
View File
@@ -37,7 +37,7 @@
extern void snmp_spine_init(void); extern void snmp_spine_init(void);
extern void snmp_spine_close(void); extern void snmp_spine_close(void);
extern void *snmp_host_init(int host_id, char *hostname, int snmp_version, char *snmp_community, char *snmp_username, char *snmp_password, char *snmp_auth_protocol, char *snmp_priv_passphrase, char *snmp_priv_protocol, char *snmp_context, char *snmp_engine_id, int snmp_port, int snmp_timeout); extern void *snmp_host_init(int host_id, char *hostname, int snmp_version, char *snmp_community, char *snmp_username, const char *snmp_password, char *snmp_auth_protocol, const char *snmp_priv_passphrase, char *snmp_priv_protocol, char *snmp_context, char *snmp_engine_id, int snmp_port, int snmp_timeout);
extern void snmp_host_cleanup(void *snmp_session); extern void snmp_host_cleanup(void *snmp_session);
extern char *snmp_get_base(host_t *current_host, const char *snmp_oid, bool should_fail); extern char *snmp_get_base(host_t *current_host, const char *snmp_oid, bool should_fail);
extern int snmp_varbind_is_exception(const struct variable_list *vars); extern int snmp_varbind_is_exception(const struct variable_list *vars);
+3 -3
View File
@@ -509,7 +509,7 @@ int main(int argc, char *argv[]) {
} }
for (i=0; i<CONFIG_PATHS; i++) { for (i=0; i<CONFIG_PATHS; i++) {
snprintf(conf_file, DBL_BUFSIZE, "%s%s", config_paths[i], DEFAULT_CONF_FILE); snprintf(conf_file, DBL_BUFSIZE, "%.*s%s", (int) sizeof(config_paths[i]) - 1, config_paths[i], DEFAULT_CONF_FILE);
if (read_spine_config(conf_file) >= 0) { if (read_spine_config(conf_file) >= 0) {
valid_conf_file = TRUE; valid_conf_file = TRUE;
@@ -517,7 +517,7 @@ int main(int argc, char *argv[]) {
} }
if (i == CONFIG_PATHS-1) { if (i == CONFIG_PATHS-1) {
snprintf(conf_file, DBL_BUFSIZE, "%s%s", config_paths[0], DEFAULT_CONF_FILE); snprintf(conf_file, DBL_BUFSIZE, "%.*s%s", (int) sizeof(config_paths[0]) - 1, config_paths[0], DEFAULT_CONF_FILE);
} }
} }
} }
@@ -851,7 +851,7 @@ int main(int argc, char *argv[]) {
snprintf(host_time, SMALL_BUFSIZE, "%lu", (unsigned long) time(NULL)); snprintf(host_time, SMALL_BUFSIZE, "%lu", (unsigned long) time(NULL));
host_time_double = get_time_as_double(); host_time_double = get_time_as_double();
} else if (host_time_double == 0 || host_time == 0 || host_time == NULL) { } else if (host_time_double == 0) {
snprintf(host_time, SMALL_BUFSIZE, "%lu", (unsigned long) time(NULL)); snprintf(host_time, SMALL_BUFSIZE, "%lu", (unsigned long) time(NULL));
host_time_double = get_time_as_double(); host_time_double = get_time_as_double();
} }
+49
View File
@@ -0,0 +1,49 @@
# Spine test harness
`make check` runs the cmocka unit tests. The Docker-backed integration fixture
uses a real Net-SNMP agent and MariaDB or MySQL to exercise the complete poll
and persistence path.
Run the standard end-to-end test with:
```sh
SPINE_TEST_DATABASE=mariadb tests/snmpv3/scripts/run-integration.sh
```
Run the concurrency contract with:
```sh
SPINE_TEST_HOSTS=24 \
SPINE_TEST_THREAD_COUNTS="1 4 12" \
SPINE_TEST_REPEATS=1 \
tests/integration/test_concurrency.sh
```
Exercise timeout and recovery behavior with real UDP packet loss:
```sh
tests/integration/test_network_faults.sh
```
The concurrency test creates an isolated Compose project, clones independent
SNMPv3 host records, sweeps worker counts, and verifies exact output and host
accounting after every run. It is intentionally transport-neutral so it can
also test a future libuv engine and reactor-count matrix without changing its
assertions. Fixture services communicate only on their project-local Compose
network, so independent test projects can run without competing for host ports.
The fault test uses `tc netem` inside the test-only SNMP agent container to
drop every response, verifies bounded completion without stale output, removes
the fault, and verifies the next poll recovers.
Useful controls:
- `SPINE_TEST_TIMEOUT_SECONDS`: per-Spine-run timeout (default `120`).
- `SPINE_TEST_ARTIFACT_DIR`: destination for logs and TAP results.
- `SPINE_TEST_KEEP_ENV_ON_FAILURE=1`: preserve containers after a failure.
- `SPINE_TEST_PROJECT`: explicit Compose project name for reproducibility.
- `SPINE_TEST_EXTRA_ARGS`: additional whitespace-separated Spine arguments.
This provides the hook for future `--snmp-engine` and reactor-count sweeps;
arguments are split but never evaluated as shell code.
Keep CI workloads bounded. Larger host counts and repeat counts belong in a
scheduled soak workflow rather than the pull-request critical path.
+9 -4
View File
@@ -13,6 +13,8 @@
# fuzzer is always built with a clang that supports -fsanitize=fuzzer. # fuzzer is always built with a clang that supports -fsanitize=fuzzer.
FUZZ_CC ?= clang FUZZ_CC ?= clang
FUZZ_SECONDS ?= 60 FUZZ_SECONDS ?= 60
FUZZ_TIMEOUT ?= 5
FUZZ_RSS_MB ?= 2048
SPINE_ROOT := ../.. SPINE_ROOT := ../..
BINDIR := bin BINDIR := bin
@@ -22,6 +24,7 @@ SANITIZERS := -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer -fno-l
# hardening options that clash with the sanitizer build. Keep only the includes. # hardening options that clash with the sanitizer build. Keep only the includes.
NETSNMP_CFLAGS := $(filter -I% -D%, $(shell net-snmp-config --base-cflags 2>/dev/null)) NETSNMP_CFLAGS := $(filter -I% -D%, $(shell net-snmp-config --base-cflags 2>/dev/null))
NETSNMP_LIBS := $(filter -l% -L%, $(shell net-snmp-config --libs 2>/dev/null)) NETSNMP_LIBS := $(filter -l% -L%, $(shell net-snmp-config --libs 2>/dev/null))
DB_CONFIG ?= $(shell command -v mariadb_config 2>/dev/null || command -v mysql_config 2>/dev/null)
SPINE_SRC := $(addprefix $(SPINE_ROOT)/, \ SPINE_SRC := $(addprefix $(SPINE_ROOT)/, \
ping.c util.c sql.c snmp.c src/core/locks.c poller.c nft_popen.c php.c \ ping.c util.c sql.c snmp.c src/core/locks.c poller.c nft_popen.c php.c \
@@ -30,11 +33,11 @@ SPINE_SRC := $(addprefix $(SPINE_ROOT)/, \
FUZZ_CFLAGS := $(SANITIZERS) -g -O1 -DHAVE_CONFIG_H \ FUZZ_CFLAGS := $(SANITIZERS) -g -O1 -DHAVE_CONFIG_H \
-I$(SPINE_ROOT) -I$(SPINE_ROOT)/src/core -I$(SPINE_ROOT)/config \ -I$(SPINE_ROOT) -I$(SPINE_ROOT)/src/core -I$(SPINE_ROOT)/config \
$(NETSNMP_CFLAGS) \ $(NETSNMP_CFLAGS) \
$(shell mysql_config --include 2>/dev/null) $(shell $(DB_CONFIG) --include 2>/dev/null)
FUZZ_LDLIBS := $(NETSNMP_LIBS) $(filter -l% -L%, $(shell mysql_config --libs 2>/dev/null)) FUZZ_LDLIBS := $(NETSNMP_LIBS) $(filter -l% -L%, $(shell $(DB_CONFIG) --libs 2>/dev/null))
TARGETS := $(BINDIR)/fuzz_namebyhost $(BINDIR)/fuzz_icmp_reply TARGETS := $(BINDIR)/fuzz_namebyhost $(BINDIR)/fuzz_output_parser $(BINDIR)/fuzz_icmp_reply
.PHONY: all run clean .PHONY: all run clean
@@ -51,7 +54,9 @@ run: $(TARGETS)
name=$$(basename $$t); \ name=$$(basename $$t); \
echo "== $$name ($(FUZZ_SECONDS)s)"; \ echo "== $$name ($(FUZZ_SECONDS)s)"; \
mkdir -p corpus/$$name; \ mkdir -p corpus/$$name; \
$$t corpus/$$name -max_total_time=$(FUZZ_SECONDS) -print_final_stats=1 || rc=1; \ $$t corpus/$$name -max_total_time=$(FUZZ_SECONDS) \
-timeout=$(FUZZ_TIMEOUT) -rss_limit_mb=$(FUZZ_RSS_MB) \
-print_final_stats=1 || rc=1; \
done; exit $$rc done; exit $$rc
clean: clean:
@@ -0,0 +1,2 @@
([unterminated
value
@@ -0,0 +1,2 @@
^[^ ]+$
in:1 out:2
@@ -0,0 +1,2 @@
[0-9]+
42
+54
View File
@@ -0,0 +1,54 @@
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU Lesser General Public License |
| as published by the Free Software Foundation; either version 2.1 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
*/
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "common.h"
#include "spine.h"
#include "poller.h"
#include "util.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
char value[1025];
char row[RESULTS_BUFFER + SMALL_BUFSIZE];
const char *result;
static const char *expressions[] = {
REGEX_NUMBER,
"[0-9][0-9]*",
"[a-zA-Z][a-zA-Z0-9_.:-]*",
".*"
};
size_t value_len;
if (data == NULL || size == 0 || size > sizeof(value)) {
return 0;
}
value_len = size - 1;
memcpy(value, data + 1, value_len);
value[value_len] = '\0';
result = regex_replace(expressions[data[0] %
(sizeof(expressions) / sizeof(expressions[0]))], value);
if (result == NULL) {
abort();
}
(void)format_poller_output_row(row, sizeof(row), 1,
"fuzz", "1700000000", result);
(void)is_multipart_output(value);
(void)validate_result(value);
return 0;
}
+25
View File
@@ -50,6 +50,31 @@ if [[ -u "$SPINE" ]]; then
exit 77 exit 77
fi fi
# ASan/UBSan/TSan install their own allocator interceptors ahead of anything
# LD_PRELOAD can add, so a preloaded calloc() never sees (and cannot fail)
# the process's real allocations under those runtimes. Detect a sanitized
# binary by its exported runtime-init symbols and skip rather than silently
# testing nothing (or, worse, forcing the sanitizer runtime's own bootstrap
# allocation to fail and hang instead of exercising the guard at all).
#
# The symbol table is captured into a variable before grep runs against it
# (rather than piping straight into `grep -q`) because grep -q exits as soon
# as it finds a match, which under `set -o pipefail` can SIGPIPE the still-
# writing nm/strings process and make the pipeline look like it failed even
# though the match was found.
if command -v nm >/dev/null 2>&1; then
spine_symbols="$(nm -D "$SPINE" 2>/dev/null || true)"
elif command -v strings >/dev/null 2>&1; then
spine_symbols="$(strings "$SPINE" 2>/dev/null || true)"
else
spine_symbols=""
fi
if grep -qE '__asan_init|__tsan_init|__msan_init|__ubsan_(handle|default_options)' <<<"$spine_symbols"; then
echo "the spine binary is built with a sanitizer runtime, which takes over calloc() ahead of LD_PRELOAD; skipping"
exit 77
fi
if ! command -v cc >/dev/null 2>&1; then if ! command -v cc >/dev/null 2>&1; then
echo "no compiler available for the interposer; skipping" echo "no compiler available for the interposer; skipping"
exit 77 exit 77
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# Exercise Spine against many independent hosts while sweeping worker counts.
# This is deliberately backend-neutral so the same contract can exercise the
# legacy pthread poller and the future sharded libuv reactor.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
HOSTS=${SPINE_TEST_HOSTS:-24}
THREADS=${SPINE_TEST_THREAD_COUNTS:-"1 4 12"}
REPEATS=${SPINE_TEST_REPEATS:-1}
TIMEOUT_SECONDS=${SPINE_TEST_TIMEOUT_SECONDS:-120}
DATABASE=${SPINE_TEST_DATABASE:-mariadb}
PROJECT=${SPINE_TEST_PROJECT:-spine-concurrency-${DATABASE}-$$}
ARTIFACT_ROOT=${SPINE_TEST_ARTIFACT_DIR:-"$REPO_ROOT/test-artifacts/concurrency"}
EXTRA_ARGS=()
if [[ -n "${SPINE_TEST_EXTRA_ARGS:-}" ]]; then
# This is argument splitting, not evaluation: shell metacharacters remain data.
read -r -a EXTRA_ARGS <<< "$SPINE_TEST_EXTRA_ARGS"
fi
for numeric_value in "$HOSTS" "$REPEATS" "$TIMEOUT_SECONDS"; do
[[ "$numeric_value" =~ ^[0-9]+$ ]] || {
echo "host, repeat, and timeout values must be positive integers" >&2
exit 2
}
done
(( HOSTS >= 2 && HOSTS <= 500 )) || { echo "SPINE_TEST_HOSTS must be between 2 and 500" >&2; exit 2; }
(( REPEATS >= 1 && REPEATS <= 100 )) || { echo "SPINE_TEST_REPEATS must be between 1 and 100" >&2; exit 2; }
(( TIMEOUT_SECONDS >= 10 )) || { echo "SPINE_TEST_TIMEOUT_SECONDS must be at least 10" >&2; exit 2; }
export COMPOSE_PROJECT_NAME=$PROJECT
COMPOSE=(docker compose -f "$REPO_ROOT/tests/snmpv3/docker-compose.yml")
case "$DATABASE" in
mariadb) ;;
mysql) COMPOSE+=(-f "$REPO_ROOT/tests/snmpv3/docker-compose.mysql.yml") ;;
*) echo "unsupported SPINE_TEST_DATABASE: $DATABASE" >&2; exit 2 ;;
esac
mkdir -p "$ARTIFACT_ROOT"
export SPINE_TEST_ARTIFACT_DIR=$ARTIFACT_ROOT
# shellcheck source=tests/test-harness.sh
source "$REPO_ROOT/tests/test-harness.sh"
harness_init "Spine concurrency integration"
cleanup() {
local rc=$?
"${COMPOSE[@]}" ps --all > "$ARTIFACT_ROOT/compose-ps.txt" 2>&1 || true
"${COMPOSE[@]}" logs --no-color > "$ARTIFACT_ROOT/compose.log" 2>&1 || true
if [[ $rc -eq 0 || "${SPINE_TEST_KEEP_ENV_ON_FAILURE:-0}" != 1 ]]; then
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
else
printf 'Preserving failed Compose project %s\n' "$COMPOSE_PROJECT_NAME" >&2
fi
}
trap cleanup EXIT
db_query() {
"${COMPOSE[@]}" exec -T -e MYSQL_PWD=spine db \
"$DATABASE" -uspine cacti -N -B -e "$1"
}
wait_for_fixture() {
local elapsed=0
while (( elapsed < 120 )); do
if [[ "$(db_query 'SELECT COUNT(*) FROM host;' 2>/dev/null || true)" == 1 ]] && \
"${COMPOSE[@]}" exec -T snmpd snmpget -v3 -u testuser -l authPriv \
-a SHA-256 -A authpass1234 -x AES -X privpass1234 \
localhost:1161 .1.3.6.1.2.1.1.3.0 >/dev/null 2>&1; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
run_with_timeout() {
if command -v timeout >/dev/null 2>&1; then
timeout --signal=TERM --kill-after=10 "$TIMEOUT_SECONDS" "$@"
else
"$@"
fi
}
echo "=== Build and start the isolated fixture ==="
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
"${COMPOSE[@]}" build spine
"${COMPOSE[@]}" up -d db snmpd
if wait_for_fixture; then
harness_pass "database and SNMP agent became ready"
else
harness_fail "fixture did not become ready within 120 seconds"
harness_finish || true
exit 1
fi
# Clone the real v3 host and its SNMP item. All rows address the same agent but
# maintain independent host/session state, which exposes lost completions and
# host ownership errors without requiring hundreds of containers.
db_query "
DELIMITER //
CREATE PROCEDURE seed_stress_hosts(IN wanted INT)
BEGIN
DECLARE current_id INT DEFAULT 2;
WHILE current_id <= wanted DO
INSERT INTO host (
id, hostname, snmp_community, snmp_version, snmp_username, snmp_password,
snmp_auth_protocol, snmp_priv_passphrase, snmp_priv_protocol,
snmp_context, snmp_engine_id, snmp_port, snmp_timeout, max_oids,
availability_method, ping_method, ping_port, ping_timeout, ping_retries,
status, poller_id, device_threads, deleted
) SELECT
current_id, hostname, snmp_community, snmp_version, snmp_username, snmp_password,
snmp_auth_protocol, snmp_priv_passphrase, snmp_priv_protocol,
snmp_context, '', snmp_port, snmp_timeout, max_oids,
availability_method, ping_method, ping_port, ping_timeout, ping_retries,
status, poller_id, device_threads, deleted
FROM host WHERE id = 1;
INSERT INTO poller_item (
local_data_id, host_id, action, hostname, snmp_community,
snmp_version, snmp_username, snmp_password, snmp_auth_protocol,
snmp_priv_passphrase, snmp_priv_protocol, snmp_context, snmp_engine_id,
snmp_port, snmp_timeout, rrd_name, rrd_path, rrd_num, rrd_step,
arg1, deleted, poller_id
) SELECT
1000 + current_id, current_id, action, hostname, snmp_community,
snmp_version, snmp_username, snmp_password, snmp_auth_protocol,
snmp_priv_passphrase, snmp_priv_protocol, snmp_context, '',
snmp_port, snmp_timeout, rrd_name, rrd_path, rrd_num, rrd_step,
arg1, deleted, poller_id
FROM poller_item WHERE local_data_id = 1;
SET current_id = current_id + 1;
END WHILE;
END//
DELIMITER ;
CALL seed_stress_hosts($HOSTS);
DROP PROCEDURE seed_stress_hosts;"
harness_assert_eq "$HOSTS" "$(db_query 'SELECT COUNT(*) FROM host;')" \
"stress fixture has the requested host count"
for thread_count in $THREADS; do
if [[ ! "$thread_count" =~ ^[0-9]+$ ]] || \
(( thread_count < 1 || thread_count > 256 )); then
echo "invalid thread count: $thread_count" >&2
exit 2
fi
for (( iteration=1; iteration<=REPEATS; iteration++ )); do
case_id="threads-${thread_count}-iteration-${iteration}"
log_file="$ARTIFACT_ROOT/$case_id.log"
db_query "TRUNCATE poller_output; TRUNCATE host_errors;
UPDATE host SET total_polls=0, failed_polls=0, status=3;"
set +e
run_with_timeout "${COMPOSE[@]}" run --rm --no-deps --entrypoint spine spine \
--conf=/etc/spine/spine.conf -f 1 -l "$HOSTS" -S -t "$thread_count" \
"${EXTRA_ARGS[@]}" \
> "$log_file" 2>&1
rc=$?
set -e
output=$(<"$log_file")
harness_assert_eq 0 "$rc" "$case_id exits successfully"
harness_assert_not_contains "$output" \
'segmentation fault|SIGSEGV|Aborted|core dump|FATAL|Polling timed out while waiting' \
"$case_id has no fatal, crash, or shutdown-timeout diagnostics"
harness_assert_contains "$output" "Threads: ${thread_count}, Devices: ${HOSTS}" \
"$case_id reports every device completed"
harness_assert_eq "$HOSTS" \
"$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \
"$case_id writes exactly one SNMP result per host"
harness_assert_eq "$((HOSTS + 1))" \
"$(db_query 'SELECT COUNT(*) FROM poller_output;')" \
"$case_id neither loses nor duplicates SNMP and script results"
harness_assert_eq "$HOSTS" \
"$(db_query 'SELECT COUNT(*) FROM host WHERE total_polls > 0;')" \
"$case_id accounts for every host poll"
harness_assert_eq 0 \
"$(db_query 'SELECT COUNT(*) FROM host WHERE failed_polls > 0 OR status <> 3;')" \
"$case_id leaves every host healthy"
harness_assert_eq 0 "$(db_query 'SELECT COUNT(*) FROM host_errors;')" \
"$case_id records no host-level errors"
done
done
harness_finish
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Verify that complete SNMP packet loss is bounded and polling recovers after
# connectivity returns. This is a lifecycle contract for both the current
# synchronous implementation and the future libuv reactor.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TIMEOUT_SECONDS=${SPINE_TEST_TIMEOUT_SECONDS:-120}
PROJECT=${SPINE_TEST_PROJECT:-spine-network-faults-$$}
ARTIFACT_ROOT=${SPINE_TEST_ARTIFACT_DIR:-"$REPO_ROOT/test-artifacts/network-faults"}
EXTRA_ARGS=()
if [[ -n "${SPINE_TEST_EXTRA_ARGS:-}" ]]; then
read -r -a EXTRA_ARGS <<< "$SPINE_TEST_EXTRA_ARGS"
fi
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 10 )); then
echo "SPINE_TEST_TIMEOUT_SECONDS must be an integer of at least 10" >&2
exit 2
fi
export COMPOSE_PROJECT_NAME=$PROJECT
COMPOSE=(docker compose -f "$REPO_ROOT/tests/snmpv3/docker-compose.yml")
mkdir -p "$ARTIFACT_ROOT"
export SPINE_TEST_ARTIFACT_DIR=$ARTIFACT_ROOT
# shellcheck source=tests/test-harness.sh
source "$REPO_ROOT/tests/test-harness.sh"
harness_init "Spine SNMP network-fault integration"
cleanup() {
local rc=$?
"${COMPOSE[@]}" exec -T snmpd tc qdisc del dev eth0 root >/dev/null 2>&1 || true
"${COMPOSE[@]}" ps --all > "$ARTIFACT_ROOT/compose-ps.txt" 2>&1 || true
"${COMPOSE[@]}" logs --no-color > "$ARTIFACT_ROOT/compose.log" 2>&1 || true
if [[ $rc -eq 0 || "${SPINE_TEST_KEEP_ENV_ON_FAILURE:-0}" != 1 ]]; then
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
else
printf 'Preserving failed Compose project %s\n' "$COMPOSE_PROJECT_NAME" >&2
fi
}
trap cleanup EXIT
db_query() {
"${COMPOSE[@]}" exec -T -e MYSQL_PWD=spine db \
mariadb -uspine cacti -N -B -e "$1"
}
wait_for_fixture() {
local elapsed=0
while (( elapsed < 120 )); do
if [[ "$(db_query 'SELECT COUNT(*) FROM host;' 2>/dev/null || true)" == 1 ]] && \
"${COMPOSE[@]}" exec -T snmpd snmpget -v3 -u testuser -l authPriv \
-a SHA-256 -A authpass1234 -x AES -X privpass1234 \
localhost:1161 .1.3.6.1.2.1.1.3.0 >/dev/null 2>&1; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
run_spine() {
local log_file=$1
local rc
set +e
if command -v timeout >/dev/null 2>&1; then
timeout --signal=TERM --kill-after=10 "$TIMEOUT_SECONDS" \
"${COMPOSE[@]}" run --rm --no-deps --entrypoint spine spine \
--conf=/etc/spine/spine.conf -f 1 -l 1 -S "${EXTRA_ARGS[@]}" \
> "$log_file" 2>&1
else
"${COMPOSE[@]}" run --rm --no-deps --entrypoint spine spine \
--conf=/etc/spine/spine.conf -f 1 -l 1 -S "${EXTRA_ARGS[@]}" \
> "$log_file" 2>&1
fi
rc=$?
set -e
return "$rc"
}
reset_poll_state() {
db_query "TRUNCATE poller_output; TRUNCATE host_errors;
UPDATE host SET total_polls=0, failed_polls=0, status=3;"
}
echo "=== Build and start the isolated fixture ==="
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
"${COMPOSE[@]}" build spine snmpd
"${COMPOSE[@]}" up -d db snmpd
if wait_for_fixture; then
harness_pass "database and SNMP agent became ready"
else
harness_fail "fixture did not become ready within 120 seconds"
harness_finish || true
exit 1
fi
baseline_log="$ARTIFACT_ROOT/baseline.log"
reset_poll_state
if run_spine "$baseline_log"; then
harness_pass "baseline poll exits successfully"
else
harness_fail "baseline poll exits successfully"
fi
harness_assert_eq 1 \
"$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \
"baseline poll writes its SNMP result"
echo "=== Drop every response from the SNMP agent ==="
"${COMPOSE[@]}" exec -T snmpd tc qdisc replace dev eth0 root netem loss 100%
loss_log="$ARTIFACT_ROOT/packet-loss.log"
reset_poll_state
if run_spine "$loss_log"; then
harness_pass "poll under complete packet loss exits successfully"
else
harness_fail "poll under complete packet loss exits successfully"
fi
loss_output=$(<"$loss_log")
harness_assert_not_contains "$loss_output" \
'segmentation fault|SIGSEGV|Aborted|core dump|Polling timed out while waiting' \
"packet loss does not crash or deadlock the poller"
harness_assert_eq 0 \
"$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \
"complete packet loss cannot produce a stale SNMP result"
echo "=== Restore connectivity and verify recovery ==="
"${COMPOSE[@]}" exec -T snmpd tc qdisc del dev eth0 root
recovery_log="$ARTIFACT_ROOT/recovery.log"
reset_poll_state
if run_spine "$recovery_log"; then
harness_pass "recovery poll exits successfully"
else
harness_fail "recovery poll exits successfully"
fi
recovery_output=$(<"$recovery_log")
harness_assert_not_contains "$recovery_output" \
'segmentation fault|SIGSEGV|Aborted|core dump|FATAL' \
"recovery poll has no fatal diagnostics"
harness_assert_eq 1 \
"$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \
"SNMP output resumes after packet loss"
harness_assert_eq 3 "$(db_query 'SELECT status FROM host WHERE id = 1;')" \
"host is healthy after connectivity recovers"
harness_finish
+20
View File
@@ -0,0 +1,20 @@
services:
db:
image: mysql:8.4
# The Cacti 1.x schema intentionally uses zero-date sentinel values.
command: ["--sql-mode=NO_ENGINE_SUBSTITUTION"]
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: cacti
MYSQL_USER: spine
MYSQL_PASSWORD: spine
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
interval: 5s
timeout: 5s
retries: 20
verify:
image: mysql:8.4
environment:
DB_CLIENT: mysql
+1 -2
View File
@@ -19,8 +19,7 @@ services:
build: ./snmpd build: ./snmpd
cap_add: cap_add:
- SYS_TIME # allows date -s inside container for clock-skew test - SYS_TIME # allows date -s inside container for clock-skew test
ports: - NET_ADMIN # allows tc netem in the network-fault integration test
- "127.0.0.1:10161:1161/udp"
healthcheck: healthcheck:
test: ["CMD", "snmpget", "-v3", "-u", "testuser", "-l", "authPriv", test: ["CMD", "snmpget", "-v3", "-u", "testuser", "-l", "authPriv",
"-a", "SHA-256", "-A", "authpass1234", "-a", "SHA-256", "-A", "authpass1234",
+12 -5
View File
@@ -7,20 +7,27 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
cleanup() { docker compose down -v --remove-orphans >/dev/null 2>&1 || true; } compose=(docker compose)
case "${SPINE_TEST_DATABASE:-mariadb}" in
mariadb) ;;
mysql) compose+=(-f docker-compose.yml -f docker-compose.mysql.yml) ;;
*) echo "unsupported SPINE_TEST_DATABASE: ${SPINE_TEST_DATABASE}" >&2; exit 2 ;;
esac
cleanup() { "${compose[@]}" down -v --remove-orphans >/dev/null 2>&1 || true; }
trap cleanup EXIT trap cleanup EXIT
# Start from an empty database. Without this the verifier can pass on rows the # Start from an empty database. Without this the verifier can pass on rows the
# previous run wrote, so a poll that produced nothing still looks green. # previous run wrote, so a poll that produced nothing still looks green.
cleanup cleanup
docker compose up --build -d "${compose[@]}" up --build -d
verify_id=$(docker compose ps -qa verify) verify_id=$("${compose[@]}" ps -qa verify)
[ -n "$verify_id" ] || { echo "verify container was never created"; docker compose logs --no-color; exit 1; } [ -n "$verify_id" ] || { echo "verify container was never created"; "${compose[@]}" logs --no-color; exit 1; }
code=$(docker wait "$verify_id") code=$(docker wait "$verify_id")
docker compose logs --no-color spine verify "${compose[@]}" logs --no-color spine verify
exit "$code" exit "$code"
+2
View File
@@ -42,6 +42,8 @@ echo "=== Phase 2: skew snmpd clock +200s to trigger notInTimeWindow ==="
"${COMPOSE[@]}" ps snmpd 2>/dev/null | grep -qw "healthy" \ "${COMPOSE[@]}" ps snmpd 2>/dev/null | grep -qw "healthy" \
|| { echo " SKIP: snmpd not healthy, skipping clock-skew phase"; exit 0; } || { echo " SKIP: snmpd not healthy, skipping clock-skew phase"; exit 0; }
# Expansion must happen inside the container.
# shellcheck disable=SC2016
"${COMPOSE[@]}" exec -T snmpd /bin/sh -c \ "${COMPOSE[@]}" exec -T snmpd /bin/sh -c \
'date -s "$(date -d "+200 seconds" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -v+200S +%Y-%m-%dT%H:%M:%S)" 2>/dev/null' \ 'date -s "$(date -d "+200 seconds" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -v+200S +%Y-%m-%dT%H:%M:%S)" 2>/dev/null' \
|| { echo " SKIP: SYS_TIME capability not available, skipping clock-skew phase"; exit 0; } || { echo " SKIP: SYS_TIME capability not available, skipping clock-skew phase"; exit 0; }
+2 -2
View File
@@ -5,7 +5,8 @@
# to. Every check here is something a regression in poll_host() would break. # to. Every check here is something a regression in poll_host() would break.
set -eu set -eu
Q="mariadb -h db -u spine -pspine cacti -N -B -e" DB_CLIENT=${DB_CLIENT:-mariadb}
Q="$DB_CLIENT -h db -u spine -pspine cacti -N -B -e"
fail() { echo "FAIL: $1"; exit 1; } fail() { echo "FAIL: $1"; exit 1; }
ok() { echo "ok: $1"; } ok() { echo "ok: $1"; }
@@ -32,7 +33,6 @@ status=$($Q "SELECT status FROM host WHERE id = 1;")
ok "host 1 recorded as up" ok "host 1 recorded as up"
# 5. availability accounting ran # 5. availability accounting ran
fails=$($Q "SELECT status_fail_date FROM host WHERE id = 1;")
errs=$($Q "SELECT COUNT(*) FROM host_errors WHERE host_id = 1;") errs=$($Q "SELECT COUNT(*) FROM host_errors WHERE host_id = 1;")
[ "$errs" = "0" ] || fail "host_errors has $errs row(s) for host 1" [ "$errs" = "0" ] || fail "host_errors has $errs row(s) for host 1"
ok "no host errors recorded" ok "no host errors recorded"
+1
View File
@@ -1,6 +1,7 @@
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
iproute2 \
snmpd \ snmpd \
snmp \ snmp \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Shared assertions and TAP output for Spine's shell integration tests.
# shellcheck shell=bash
if [[ -n "${SPINE_TEST_HARNESS_LOADED:-}" ]]; then
return 0
fi
readonly SPINE_TEST_HARNESS_LOADED=1
HARNESS_PASS=0
HARNESS_FAIL=0
HARNESS_ASSERTIONS=0
HARNESS_SUITE=${HARNESS_SUITE:-spine}
HARNESS_ARTIFACT_DIR=${SPINE_TEST_ARTIFACT_DIR:-}
HARNESS_TAP_FILE=
harness_init() {
HARNESS_SUITE=$1
HARNESS_PASS=0
HARNESS_FAIL=0
HARNESS_ASSERTIONS=0
if [[ -n "$HARNESS_ARTIFACT_DIR" ]]; then
mkdir -p "$HARNESS_ARTIFACT_DIR"
HARNESS_TAP_FILE="$HARNESS_ARTIFACT_DIR/results.tap"
printf 'TAP version 13\n' > "$HARNESS_TAP_FILE"
fi
}
harness_tap_escape() {
local message=$1
message=${message//$'\n'/ }
message=${message//$'\r'/ }
printf '%s' "$message"
}
harness_record() {
local status=$1
shift
local message=$*
HARNESS_ASSERTIONS=$((HARNESS_ASSERTIONS + 1))
if [[ "$status" == pass ]]; then
HARNESS_PASS=$((HARNESS_PASS + 1))
printf ' PASS: %s\n' "$message"
if [[ -n "$HARNESS_TAP_FILE" ]]; then
printf 'ok %d - %s\n' "$HARNESS_ASSERTIONS" \
"$(harness_tap_escape "$message")" >> "$HARNESS_TAP_FILE"
fi
else
HARNESS_FAIL=$((HARNESS_FAIL + 1))
printf ' FAIL: %s\n' "$message" >&2
if [[ -n "$HARNESS_TAP_FILE" ]]; then
printf 'not ok %d - %s\n' "$HARNESS_ASSERTIONS" \
"$(harness_tap_escape "$message")" >> "$HARNESS_TAP_FILE"
fi
fi
}
harness_pass() { harness_record pass "$@"; }
harness_fail() { harness_record fail "$@"; }
harness_assert_eq() {
local expected=$1
local actual=$2
shift 2
if [[ "$actual" == "$expected" ]]; then
harness_pass "$* (got $actual)"
else
harness_fail "$* (expected $expected, got $actual)"
fi
}
harness_assert_contains() {
local value=$1
local pattern=$2
shift 2
if grep -Eq "$pattern" <<< "$value"; then
harness_pass "$*"
else
harness_fail "$* (pattern not found: $pattern)"
fi
}
harness_assert_not_contains() {
local value=$1
local pattern=$2
shift 2
if grep -Eiq "$pattern" <<< "$value"; then
harness_fail "$* (unexpected pattern: $pattern)"
else
harness_pass "$*"
fi
}
harness_finish() {
printf '\n=== %s: %d passed, %d failed ===\n' \
"$HARNESS_SUITE" "$HARNESS_PASS" "$HARNESS_FAIL"
if [[ -n "$HARNESS_TAP_FILE" ]]; then
printf '1..%d\n' "$HARNESS_ASSERTIONS" >> "$HARNESS_TAP_FILE"
printf 'TAP results: %s\n' "$HARNESS_TAP_FILE"
fi
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
{
printf '### %s\n\n' "$HARNESS_SUITE"
printf -- '- Passed: %d\n- Failed: %d\n' "$HARNESS_PASS" "$HARNESS_FAIL"
} >> "$GITHUB_STEP_SUMMARY"
fi
[[ $HARNESS_FAIL -eq 0 ]]
}
+69 -1
View File
@@ -25,6 +25,8 @@
#include "spine.h" #include "spine.h"
#include "util.h" #include "util.h"
#include "ping.h" #include "ping.h"
#include "poller.h"
#include "nft_popen.h" #include "nft_popen.h"
#include <fcntl.h> #include <fcntl.h>
@@ -124,6 +126,52 @@ static void test_spine_appendf_reports_truncation_and_guards(void **state) {
assert_false(spine_appendf(&cursor, &zero, "%s", "x")); assert_false(spine_appendf(&cursor, &zero, "%s", "x"));
} }
static void test_bounded_formatters(void **state) {
char capabilities[BUFSIZE];
char row[DBL_BUFSIZE + SMALL_BUFSIZE];
char small[32];
char long_value[DBL_BUFSIZE];
const char *auth_start;
const char *auth_end;
const char *priv_start;
const char *priv_end;
(void) state;
memset(long_value, '7', sizeof(long_value) - 1);
long_value[sizeof(long_value) - 1] = '\0';
assert_true(format_spine_capabilities(capabilities,
sizeof(capabilities), long_value, long_value));
assert_true(strlen(capabilities) < sizeof(capabilities));
auth_start = strstr(capabilities, "authProtocols: \"");
assert_non_null(auth_start);
auth_start += strlen("authProtocols: \"");
auth_end = strchr(auth_start, '"');
assert_non_null(auth_end);
priv_start = strstr(capabilities, "privProtocols: \"");
assert_non_null(priv_start);
priv_start += strlen("privProtocols: \"");
priv_end = strchr(priv_start, '"');
assert_non_null(priv_end);
assert_int_equal(auth_end - auth_start, CAPABILITY_PROTOCOL_LIST_MAX);
assert_int_equal(priv_end - priv_start, CAPABILITY_PROTOCOL_LIST_MAX);
assert_string_equal(priv_end, "\" }");
assert_false(format_spine_capabilities(small,
sizeof(small), long_value, long_value));
assert_false(format_spine_capabilities(NULL, 0, long_value, long_value));
assert_true(format_poller_output_row(row, sizeof(row), 17,
"traffic_in", "1700000000.25", long_value));
assert_non_null(strstr(row,
"(17, 'traffic_in', FROM_UNIXTIME(1700000000.25)"));
assert_false(format_poller_output_row(small, sizeof(small), 17,
"traffic_in", "1700000000.25", long_value));
assert_false(format_poller_output_row(row, sizeof(row), 17,
"traffic_in", "1700000000); DROP TABLE host", long_value));
assert_false(format_poller_output_row(NULL, 0, 17,
"traffic_in", "1700000000.25", long_value));
}
/* --- predicates ----------------------------------------------------------- */ /* --- predicates ----------------------------------------------------------- */
@@ -161,6 +209,9 @@ static void test_is_numeric(void **state) {
static void test_is_hexadecimal(void **state) { static void test_is_hexadecimal(void **state) {
(void) state; (void) state;
assert_int_equal(is_hexadecimal("AA BB CC", 0), TRUE); assert_int_equal(is_hexadecimal("AA BB CC", 0), TRUE);
assert_int_equal(is_hexadecimal("AA\tBB", 0), FALSE);
assert_int_equal(is_hexadecimal("AA\tBB", 1), FALSE);
assert_int_equal(is_hexadecimal("AA\tBB:CC", 1), TRUE);
assert_int_equal(is_hexadecimal("zz", 0), FALSE); assert_int_equal(is_hexadecimal("zz", 0), FALSE);
assert_int_equal(is_hexadecimal("", 0), FALSE); assert_int_equal(is_hexadecimal("", 0), FALSE);
} }
@@ -398,7 +449,10 @@ static void test_icmp_classify_rejects_a_non_echo(void **state) {
const struct icmp *out = NULL; const struct icmp *out = NULL;
(void) state; (void) state;
build_ip_icmp(buf, sizeof buf, 1, 1, ICMP_DEST_UNREACH); /* Type 3 is Destination Unreachable in RFC 792. BSD names the symbol
* ICMP_UNREACH while Linux names it ICMP_DEST_UNREACH, so keep the wire
* value explicit in this platform-independent parser test. */
build_ip_icmp(buf, sizeof buf, 1, 1, 3);
assert_int_equal(spine_icmp_classify_reply(buf, sizeof buf, 1, 1, &out), SPINE_ICMP_REPLY_NOT_ECHO); assert_int_equal(spine_icmp_classify_reply(buf, sizeof buf, 1, 1, &out), SPINE_ICMP_REPLY_NOT_ECHO);
assert_null(out); assert_null(out);
} }
@@ -450,6 +504,18 @@ static void test_namebyhost_is_reentrant_across_calls(void **state) {
free(nb); free(nb);
} }
static void test_namebyhost_tcpv6_method_keeps_the_full_hostname(void **state) {
char host[64];
name_t *n;
(void) state;
strcpy(host, "[fe80::1]:161");
n = get_namebyhost(host, NULL);
assert_non_null(n);
assert_string_equal(n->hostname, "[fe80::1]:161");
free(n);
}
/* --- configuration: defaults, the file parser, and set_option() ----------- */ /* --- configuration: defaults, the file parser, and set_option() ----------- */
@@ -1001,6 +1067,7 @@ int main(void) {
cmocka_unit_test(test_regex_replace_returns_the_match), cmocka_unit_test(test_regex_replace_returns_the_match),
cmocka_unit_test(test_regex_replace_passes_through_on_no_match), cmocka_unit_test(test_regex_replace_passes_through_on_no_match),
cmocka_unit_test(test_regex_replace_passes_through_on_bad_pattern), cmocka_unit_test(test_regex_replace_passes_through_on_bad_pattern),
cmocka_unit_test(test_bounded_formatters),
cmocka_unit_test(test_spine_appendf_reports_truncation_and_guards), cmocka_unit_test(test_spine_appendf_reports_truncation_and_guards),
cmocka_unit_test(test_all_digits), cmocka_unit_test(test_all_digits),
cmocka_unit_test(test_is_ipaddress), cmocka_unit_test(test_is_ipaddress),
@@ -1027,6 +1094,7 @@ int main(void) {
cmocka_unit_test(test_icmp_classify_rejects_a_null_buffer), cmocka_unit_test(test_icmp_classify_rejects_a_null_buffer),
cmocka_unit_test(test_namebyhost_plain_hostname), cmocka_unit_test(test_namebyhost_plain_hostname),
cmocka_unit_test(test_namebyhost_is_reentrant_across_calls), cmocka_unit_test(test_namebyhost_is_reentrant_across_calls),
cmocka_unit_test(test_namebyhost_tcpv6_method_keeps_the_full_hostname),
cmocka_unit_test(test_config_defaults_populates_the_set), cmocka_unit_test(test_config_defaults_populates_the_set),
cmocka_unit_test(test_read_spine_config_rejects_a_missing_file), cmocka_unit_test(test_read_spine_config_rejects_a_missing_file),
cmocka_unit_test(test_read_spine_config_reads_settings), cmocka_unit_test(test_read_spine_config_reads_settings),
+18
View File
@@ -495,6 +495,22 @@ static void test_full_script_server_lifecycle(void **state) {
assert_int_equal(php_processes[0].php_write_fd, -1); assert_int_equal(php_processes[0].php_write_fd, -1);
} }
static void test_legacy_cacti_version_uses_positional_poller_id(void **state) {
(void) state;
set.cacti_version = 1222;
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_READY);
php_close(0);
}
static void test_remote_poller_id_uses_environ_argument(void **state) {
(void) state;
set.poller_id = 2;
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_READY);
php_close(0);
}
static void test_php_child_restores_sigpipe_default(void **state) { static void test_php_child_restores_sigpipe_default(void **state) {
(void) state; (void) state;
snprintf(set.path_php_server, sizeof(set.path_php_server), "%s", "check-sigpipe"); snprintf(set.path_php_server, sizeof(set.path_php_server), "%s", "check-sigpipe");
@@ -1066,6 +1082,8 @@ int main(void) {
cmocka_unit_test_setup_teardown(test_preexisting_pending_sigpipe_is_preserved, php_setup, php_teardown), cmocka_unit_test_setup_teardown(test_preexisting_pending_sigpipe_is_preserved, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_spawnattr_sigpipe_failure_destroys_initialized_attr, php_setup, php_teardown), cmocka_unit_test_setup_teardown(test_spawnattr_sigpipe_failure_destroys_initialized_attr, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_full_script_server_lifecycle, php_setup, php_teardown), cmocka_unit_test_setup_teardown(test_full_script_server_lifecycle, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_legacy_cacti_version_uses_positional_poller_id, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_remote_poller_id_uses_environ_argument, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_php_child_restores_sigpipe_default, php_setup, php_teardown), cmocka_unit_test_setup_teardown(test_php_child_restores_sigpipe_default, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_script_server_lifecycle_with_stdio_closed, php_setup, php_teardown), cmocka_unit_test_setup_teardown(test_script_server_lifecycle_with_stdio_closed, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_php_spawn_does_not_inherit_an_nft_collision_descriptor, php_setup, php_teardown), cmocka_unit_test_setup_teardown(test_php_spawn_does_not_inherit_an_nft_collision_descriptor, php_setup, php_teardown),
+47 -13
View File
@@ -37,6 +37,9 @@
#include <limits.h> #include <limits.h>
#define SPINE_STRINGIFY_INNER(value) #value
#define SPINE_STRINGIFY(value) SPINE_STRINGIFY_INNER(value)
static int nopts = 0; static int nopts = 0;
/*! Override Options Structure /*! Override Options Structure
@@ -537,7 +540,7 @@ void read_config_options(void) {
int mode; int mode;
char web_root[BUFSIZE]; char web_root[BUFSIZE];
char sqlbuf[HUGE_BUFSIZE]; char sqlbuf[HUGE_BUFSIZE];
char *sqlp = sqlbuf; char *sqlp;
size_t remaining; size_t remaining;
char *res; char *res;
char spine_priv[BUFSIZE]; char spine_priv[BUFSIZE];
@@ -939,9 +942,15 @@ void read_config_options(void) {
strcat(spine_priv, (strlen(spine_priv) > 0 ? ",AES256":"AES256")); strcat(spine_priv, (strlen(spine_priv) > 0 ? ",AES256":"AES256"));
#endif #endif
snprintf(spine_capabilities, BUFSIZE, "{ authProtocols: \"%s\", privProtocols: \"%s\" }", spine_auth, spine_priv); /* Each source buffer can be BUFSIZE bytes. Bound both fields so the
* combined capability document always fits in its destination. */
if (!format_spine_capabilities(spine_capabilities,
sizeof(spine_capabilities), spine_auth, spine_priv)) {
SPINE_LOG(("ERROR: Unable to format Spine SNMP capabilities"));
spine_capabilities[0] = '\0';
}
if (set.poller_id == 1) { if (set.poller_id == 1 && spine_capabilities[0] != '\0') {
putsetting(&mysql, LOCAL, "spine_capabilities", spine_capabilities); putsetting(&mysql, LOCAL, "spine_capabilities", spine_capabilities);
} }
@@ -961,13 +970,13 @@ void read_config_options(void) {
checking, so a statement cut inside a quoted value reached the server. */ checking, so a statement cut inside a quoted value reached the server. */
#define PUSH_ROW_MAX (24 * DBL_BUFSIZE) #define PUSH_ROW_MAX (24 * DBL_BUFSIZE)
/*! \fn static void push_flush_batch(MYSQL *mysqlr, char *sqlbuf, char **sqlp, const char *suffix) /*! \fn static void push_flush_batch(MYSQL *mysqlr, const char *sqlbuf, char **sqlp, const char *suffix)
* \brief terminate the accumulated batch and send it to the main server * \brief terminate the accumulated batch and send it to the main server
* *
* Refuses to send a statement that did not fit rather than shipping a * Refuses to send a statement that did not fit rather than shipping a
* truncated one. * truncated one.
*/ */
static void push_flush_batch(MYSQL *mysqlr, char *sqlbuf, char **sqlp, const char *suffix) { static void push_flush_batch(MYSQL *mysqlr, const char *sqlbuf, char **sqlp, const char *suffix) {
size_t remaining = HUGE_BUFSIZE - (*sqlp - sqlbuf); size_t remaining = HUGE_BUFSIZE - (*sqlp - sqlbuf);
if (!spine_appendf(sqlp, &remaining, "%s", suffix)) { if (!spine_appendf(sqlp, &remaining, "%s", suffix)) {
@@ -1571,8 +1580,15 @@ int spine_log(const char *format, ...) {
ulog_len = LOGSIZE - flog_len - prefix_len - 1; ulog_len = LOGSIZE - flog_len - prefix_len - 1;
} }
strncat(flogmessage, logprefix, prefix_len); /* strncat() here trips -Wstringop-truncation: prefix_len/ulog_len are
strncat(flogmessage, ulogmessage, ulog_len); * runtime-clamped just above to fit, but GCC can't prove that across the
* branches. memcpy() plus an explicit terminator is exactly as safe and
* doesn't trigger the false positive. */
memcpy(flogmessage + flog_len, logprefix, (size_t) prefix_len);
flog_len += prefix_len;
memcpy(flogmessage + flog_len, ulogmessage, (size_t) ulog_len);
flog_len += ulog_len;
flogmessage[flog_len] = '\0';
/* output to syslog/eventlog */ /* output to syslog/eventlog */
if (IS_LOGGING_TO_SYSLOG()) { if (IS_LOGGING_TO_SYSLOG()) {
@@ -1815,6 +1831,7 @@ int is_hexadecimal(const char * str, const short ignore_special) {
if (ignore_special) { if (ignore_special) {
break; break;
} }
/* fall through */
default: default:
return FALSE; return FALSE;
} }
@@ -1872,14 +1889,14 @@ char *strip_alpha(char *string) {
return string; return string;
} }
/*! \fn char *add_slashes(char *string) /*! \fn char *add_slashes(const char *string)
* \brief add escaping to back slashes on for Windows type commands. * \brief add escaping to back slashes on for Windows type commands.
* \param string the string to replace slashes * \param string the string to replace slashes
* *
* \return a pointer to the modified string. Variable must be freed by parent. * \return a pointer to the modified string. Variable must be freed by parent.
* *
*/ */
char *add_slashes(char *string) { char *add_slashes(const char *string) {
int length; int length;
int position; int position;
int new_position; int new_position;
@@ -1986,14 +2003,14 @@ char *trim(char *str) {
*/ */
char *rtrim(char *str) { char *rtrim(char *str) {
char *end; char *end;
const char *trim = " \"\'\\\t\n\r"; const char *trim_chars = " \"\'\\\t\n\r";
if (!str) return NULL; if (!str) return NULL;
end = str + strlen(str); end = str + strlen(str);
while (end-- > str) { while (end-- > str) {
if (!strchr(trim, *end)) return str; if (!strchr(trim_chars, *end)) return str;
*end = 0; *end = 0;
} }
@@ -2008,12 +2025,12 @@ char *rtrim(char *str) {
* \return the trimmed string. * \return the trimmed string.
*/ */
char *ltrim(char *str) { char *ltrim(char *str) {
const char *trim = " \"\'\\\t\n\r"; const char *trim_chars = " \"\'\\\t\n\r";
if (!str) return NULL; if (!str) return NULL;
while (*str) { while (*str) {
if (!strchr(trim, *str)) return str; if (!strchr(trim_chars, *str)) return str;
++str; ++str;
} }
@@ -2337,6 +2354,23 @@ const char *regex_replace(const char *exp, const char *value) {
return (reti) ? value : msgbuf; return (reti) ? value : msgbuf;
} }
int format_spine_capabilities(char *output, size_t output_size,
const char *auth_protocols, const char *priv_protocols) {
int written;
if (output == NULL || output_size == 0 ||
auth_protocols == NULL || priv_protocols == NULL) {
return FALSE;
}
written = snprintf(output, output_size,
"{ authProtocols: \"%." SPINE_STRINGIFY(CAPABILITY_PROTOCOL_LIST_MAX)
"s\", privProtocols: \"%." SPINE_STRINGIFY(CAPABILITY_PROTOCOL_LIST_MAX) "s\" }",
auth_protocols, priv_protocols);
return written >= 0 && (size_t)written < output_size;
}
/*! \fn int spine_appendf(char **cursor, size_t *remaining, const char *fmt, ...) /*! \fn int spine_appendf(char **cursor, size_t *remaining, const char *fmt, ...)
* \brief append to a bounded buffer without walking off the end * \brief append to a bounded buffer without walking off the end
* *
+5 -1
View File
@@ -59,7 +59,7 @@ extern int is_hexadecimal(const char * str, const short ignore_special);
extern int is_debug_device(int device_id); extern int is_debug_device(int device_id);
/* string and file functions */ /* string and file functions */
extern char *add_slashes(char *string); extern char *add_slashes(const char *string);
extern int file_exists(const char *filename); extern int file_exists(const char *filename);
extern char *strip_alpha(char *string); extern char *strip_alpha(char *string);
extern char *strncopy(char *dst, const char *src, size_t n); extern char *strncopy(char *dst, const char *src, size_t n);
@@ -75,7 +75,11 @@ int hex2dec(const char *str, unsigned long long *result);
/* custom regex replace to return a value if matches */ /* custom regex replace to return a value if matches */
#define MAX_MATCHES 5 #define MAX_MATCHES 5
#define REGEX_NUMBER "([-+]*)([0-9]*)([.][0-9]+)"
#define CAPABILITY_PROTOCOL_LIST_MAX 480
const char *regex_replace(const char *exp, const char *value); const char *regex_replace(const char *exp, const char *value);
int format_spine_capabilities(char *output, size_t output_size,
const char *auth_protocols, const char *priv_protocols);
/* macro to copy string to string with an ending null */ /* macro to copy string to string with an ending null */
#define STRNCOPY(dst, src) strncopy((dst), (src), sizeof(dst)) #define STRNCOPY(dst, src) strncopy((dst), (src), sizeof(dst))