From 37f12140ec53ff41f0d088da894b1997d12688f1 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 19:37:34 -0700 Subject: [PATCH 1/7] test: add the PHP 8.1 unit-test harness Same harness as #773 and #788, with gmp added to the image so the 64-bit counter arithmetic can be tested exactly. Signed-off-by: Thomas Vincent --- tests/Support/CactiStub.php | 143 ++++++++++++ tests/bootstrap.php | 384 ++++++++++++++++++++++++++++++++ tests/docker/Dockerfile | 29 +++ tests/docker/docker-compose.yml | 15 ++ 4 files changed, 571 insertions(+) create mode 100644 tests/Support/CactiStub.php create mode 100644 tests/bootstrap.php create mode 100644 tests/docker/Dockerfile create mode 100644 tests/docker/docker-compose.yml diff --git a/tests/Support/CactiStub.php b/tests/Support/CactiStub.php new file mode 100644 index 00000000..17a5ab48 --- /dev/null +++ b/tests/Support/CactiStub.php @@ -0,0 +1,143 @@ +}> + */ + public static $calls = []; + + /** + * Queued return values, keyed by function name. Each call shifts one value + * off the front; an exhausted queue falls back to the type default. + * + * @var array> + */ + public static $returns = []; + + /** + * Values handed back by the get_*_request_var() family, keyed by var name. + * + * @var array + */ + public static $requestVars = []; + + /** + * Values handed back by read_config_option(), keyed by option name. + * + * @var array + */ + public static $configOptions = []; + + /** + * Messages passed to cacti_log(), in order. + * + * @var array + */ + public static $log = []; + + /** + * Clear all recorded and programmed state. + * + * @return void + */ + public static function reset() { + self::$calls = []; + self::$returns = []; + self::$requestVars = []; + self::$configOptions = []; + self::$log = []; + } + + /** + * Record one Cacti function call. + * + * @param string $fn Cacti function name. + * @param string $sql SQL text, or '' for non-query calls. + * @param array $params Bound parameters, if any. + * + * @return void + */ + public static function record($fn, $sql = '', array $params = []) { + self::$calls[] = ['fn' => $fn, 'sql' => $sql, 'params' => $params]; + } + + /** + * Queue one return value for the next call to $fn. + * + * @param string $fn Cacti function name. + * @param mixed $value Value to hand back. + * + * @return void + */ + public static function willReturn($fn, $value) { + self::$returns[$fn][] = $value; + } + + /** + * Take the next queued return value for $fn, or $default when none is left. + * + * @param string $fn Cacti function name. + * @param mixed $default Fallback when the queue is empty. + * + * @return mixed + */ + public static function nextReturn($fn, $default) { + if (!empty(self::$returns[$fn])) { + return array_shift(self::$returns[$fn]); + } + + return $default; + } + + /** + * All recorded calls to $fn. + * + * @param string $fn Cacti function name. + * + * @return array}> + */ + public static function callsTo($fn) { + return array_values(array_filter(self::$calls, function ($call) use ($fn) { + return $call['fn'] === $fn; + })); + } + + /** + * The recorded call log reduced to function names, in order. Useful for + * asserting transaction sequencing. + * + * @return array + */ + public static function callSequence() { + return array_column(self::$calls, 'fn'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..e9d4d5ee --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,384 @@ + dirname(dirname(dirname(__DIR__))), + 'url_path' => '/cacti/', + 'cacti_version' => '1.2.31', + 'cacti_server_os' => 'unix', +]; + +// thold_expand_string() include_once()s library_path/variables.php at call time. +$GLOBALS['config']['library_path'] = __DIR__ . '/fixtures/cacti-lib'; + +// thold reads and writes this on every RPN evaluation. +$GLOBALS['rpn_error'] = false; + +if (!function_exists('db_execute')) { + function db_execute($sql, $log = true, $db_conn = false) { + CactiStub::record('db_execute', $sql); + + return CactiStub::nextReturn('db_execute', true); + } +} + +if (!function_exists('db_execute_prepared')) { + function db_execute_prepared($sql, $params = [], $log = true, $db_conn = false) { + CactiStub::record('db_execute_prepared', $sql, $params); + + return CactiStub::nextReturn('db_execute_prepared', true); + } +} + +if (!function_exists('db_fetch_assoc')) { + function db_fetch_assoc($sql, $log = true, $db_conn = false) { + CactiStub::record('db_fetch_assoc', $sql); + + return CactiStub::nextReturn('db_fetch_assoc', []); + } +} + +if (!function_exists('db_fetch_assoc_prepared')) { + function db_fetch_assoc_prepared($sql, $params = [], $log = true, $db_conn = false) { + CactiStub::record('db_fetch_assoc_prepared', $sql, $params); + + return CactiStub::nextReturn('db_fetch_assoc_prepared', []); + } +} + +if (!function_exists('db_fetch_row')) { + function db_fetch_row($sql, $log = true, $db_conn = false) { + CactiStub::record('db_fetch_row', $sql); + + return CactiStub::nextReturn('db_fetch_row', []); + } +} + +if (!function_exists('db_fetch_row_prepared')) { + function db_fetch_row_prepared($sql, $params = [], $log = true, $db_conn = false) { + CactiStub::record('db_fetch_row_prepared', $sql, $params); + + return CactiStub::nextReturn('db_fetch_row_prepared', []); + } +} + +if (!function_exists('db_fetch_cell')) { + function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false) { + CactiStub::record('db_fetch_cell', $sql); + + return CactiStub::nextReturn('db_fetch_cell', ''); + } +} + +if (!function_exists('db_fetch_cell_prepared')) { + function db_fetch_cell_prepared($sql, $params = [], $col_name = '', $log = true, $db_conn = false) { + CactiStub::record('db_fetch_cell_prepared', $sql, $params); + + return CactiStub::nextReturn('db_fetch_cell_prepared', ''); + } +} + +if (!function_exists('db_qstr')) { + function db_qstr($string) { + return "'" . str_replace("'", "''", (string) $string) . "'"; + } +} + +if (!function_exists('db_begin_transaction')) { + function db_begin_transaction() { + CactiStub::record('db_begin_transaction'); + + return CactiStub::nextReturn('db_begin_transaction', true); + } +} + +if (!function_exists('db_commit_transaction')) { + function db_commit_transaction() { + CactiStub::record('db_commit_transaction'); + + return CactiStub::nextReturn('db_commit_transaction', true); + } +} + +if (!function_exists('db_rollback_transaction')) { + function db_rollback_transaction() { + CactiStub::record('db_rollback_transaction'); + + return CactiStub::nextReturn('db_rollback_transaction', true); + } +} + +if (!function_exists('html_escape')) { + function html_escape($string) { + return htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8'); + } +} + +/* + * Mirrors Cacti 1.2 lib/functions.php. KEEP IN SYNC: if core tightens its + * checks, tests here would otherwise keep passing while production diverges. + */ +if (!function_exists('sanitize_unserialize_selected_items')) { + function sanitize_unserialize_selected_items($items) { + if (empty($items)) { + return false; + } + + $data = unserialize($items, ['allowed_classes' => false]); // nosemgrep: php.lang.security.unserialize-use.unserialize-use -- test stub mirroring Cacti core; allowed_classes:false blocks object injection + + if (!is_array($data)) { + return false; + } + + foreach ($data as $value) { + if (!is_numeric($value)) { + return false; + } + } + + return $data; + } +} + +if (!function_exists('read_config_option')) { + function read_config_option($name, $force = false) { + return isset(CactiStub::$configOptions[$name]) ? CactiStub::$configOptions[$name] : ''; + } +} + +if (!function_exists('set_config_option')) { + function set_config_option($name, $value) { + CactiStub::$configOptions[$name] = $value; + } +} + +if (!function_exists('__')) { + function __($text) { + $args = array_slice(func_get_args(), 1); + + // Cacti's __() accepts sprintf arguments after the format string. + return $args === [] ? $text : vsprintf($text, $args); + } +} + +if (!function_exists('__esc')) { + function __esc($text) { + return htmlspecialchars(call_user_func_array('__', func_get_args()), ENT_QUOTES, 'UTF-8'); + } +} + +if (!function_exists('cacti_log')) { + function cacti_log($message, $output = false, $environ = 'CMDPHP', $level = 0) { + CactiStub::$log[] = $message; + } +} + +if (!function_exists('cacti_sizeof')) { + function cacti_sizeof($array) { + return (is_array($array) || $array instanceof Countable) ? count($array) : 0; + } +} + +if (!function_exists('cacti_count')) { + function cacti_count($array) { + return cacti_sizeof($array); + } +} + +if (!function_exists('get_request_var')) { + function get_request_var($name, $default = '') { + return isset(CactiStub::$requestVars[$name]) ? CactiStub::$requestVars[$name] : $default; + } +} + +if (!function_exists('get_nfilter_request_var')) { + function get_nfilter_request_var($name, $default = '') { + return get_request_var($name, $default); + } +} + +if (!function_exists('get_filter_request_var')) { + function get_filter_request_var($name, $filter = FILTER_VALIDATE_INT, $options = []) { + return get_request_var($name); + } +} + +if (!function_exists('isset_request_var')) { + function isset_request_var($name) { + return isset(CactiStub::$requestVars[$name]); + } +} + +if (!function_exists('cacti_escapeshellarg')) { + function cacti_escapeshellarg($string, $quote = true) { + return escapeshellarg((string) $string); + } +} + +if (!function_exists('api_plugin_hook_function')) { + function api_plugin_hook_function($name, $data = '') { + return $data; + } +} + +if (!function_exists('get_simple_graph_perms')) { + function get_simple_graph_perms($user_id) { + return CactiStub::nextReturn('get_simple_graph_perms', true); + } +} + +if (!function_exists('get_policies')) { + function get_policies($user_id) { + return CactiStub::nextReturn('get_policies', []); + } +} + +if (!function_exists('get_policy_where')) { + function get_policy_where($graph_auth_method, $policies, $sql_where) { + CactiStub::record('get_policy_where', $sql_where); + + return CactiStub::nextReturn('get_policy_where', $sql_where); + } +} + +if (!function_exists('expand_title')) { + function expand_title($host_id, $snmp_query_id, $snmp_index, $title) { + CactiStub::record('expand_title', $title); + + return CactiStub::nextReturn('expand_title', $title); + } +} + +if (!function_exists('get_graph_title')) { + function get_graph_title($local_graph_id) { + return CactiStub::nextReturn('get_graph_title', 'Traffic - eth0'); + } +} + +if (!function_exists('rrdtool_function_fetch')) { + function rrdtool_function_fetch($local_data_id, $start_time, $end_time, $resolution = 0, $show_unknown = false, $rrdtool_file = null) { + CactiStub::record('rrdtool_function_fetch', (string) $local_data_id); + + return CactiStub::nextReturn('rrdtool_function_fetch', []); + } +} + +if (!function_exists('get_data_source_path')) { + function get_data_source_path($local_data_id, $expand_paths = true) { + return '/var/lib/cacti/rra/test_' . (int) $local_data_id . '.rrd'; + } +} + +if (!function_exists('rrdtool_execute')) { + function rrdtool_execute($command, $log_to_stdout = false, $output_flag = 1, $rrdtool_pipe = false, $logopt = 'WEBLOG') { + CactiStub::record('rrdtool_execute', $command); + + return CactiStub::nextReturn('rrdtool_execute', ''); + } +} + +if (!function_exists('rrdtool_function_interface_speed')) { + function rrdtool_function_interface_speed($data_local) { + return CactiStub::nextReturn('rrdtool_function_interface_speed', 0); + } +} + +if (!function_exists('get_timeinstate')) { + function get_timeinstate($host) { + return CactiStub::nextReturn('get_timeinstate', '1 day'); + } +} + +if (!function_exists('get_daysfromtime')) { + function get_daysfromtime($timestamp) { + return CactiStub::nextReturn('get_daysfromtime', '1 day'); + } +} + +if (!function_exists('number_format_i18n')) { + function number_format_i18n($number, $decimals = 0, $baseu = 1000) { + return number_format((float) $number, $decimals < 0 ? 0 : (int) $decimals); + } +} + +if (!defined('FILTER_VALIDATE_IS_REGEX')) { + define('FILTER_VALIDATE_IS_REGEX', 99999); +} + +// Device states, from Cacti include/global_constants.php. +foreach (['HOST_UNKNOWN' => 0, 'HOST_DOWN' => 1, 'HOST_RECOVERING' => 2, 'HOST_UP' => 3, 'HOST_ERROR' => 4] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } +} + +if (!defined('RRDTOOL_OUTPUT_STDOUT')) { + define('RRDTOOL_OUTPUT_STDOUT', 1); +} + +if (!defined('CACTI_DATE_TIME_FORMAT')) { + define('CACTI_DATE_TIME_FORMAT', 'Y-m-d H:i:s'); +} + +if (!defined('CACTI_PATH_BASE')) { + define('CACTI_PATH_BASE', $GLOBALS['config']['base_path']); +} + +/** + * Load a plugin source file at global scope. + * + * Several plugin files (includes/arrays.php in particular) define their data + * as file-scope variables that the rest of the plugin reads as globals, and + * they read $config while doing so. Requiring them from inside a method would + * make both halves of that method-local, so the require happens here and any + * variable the file introduced is published to $GLOBALS. + * + * @param string $path Absolute path to the file. + * + * @return void + */ +function thold_test_load($path) { + global $config; + + $__before = get_defined_vars(); + + require_once $path; + + foreach (get_defined_vars() as $__name => $__value) { + if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) { + $GLOBALS[$__name] = $__value; + } + } +} diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 00000000..5c0a414d --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,29 @@ +# Test runner for the Thold plugin. +# +# Pinned to PHP 8.1 because that is the oldest interpreter the CI matrix +# covers; what passes here passes on 8.2-8.4. pcov rather than Xdebug: line +# coverage is the only debug feature the suite needs and pcov is far cheaper. +FROM php:8.1-cli-alpine@sha256:7949370448b0b4d9787776dc5968e0fd8d48763292344b5fbf21539441228a98 + +# git is needed by the changed-line coverage gate, which diffs against the +# base branch. +RUN apk add --no-cache git gmp-dev \ + && docker-php-ext-install gmp \ + && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && apk del .build-deps + +COPY --from=composer:2@sha256:4d71c3c2109c61d5415544264b59ad4087e4c5b7244481723664138fd36d5040 /usr/bin/composer /usr/bin/composer + +# The plugin lives where Cacti would put it, because thold_functions.php +# resolves its own includes through $config['base_path'] . '/plugins/thold'. +# No network or database is involved; the Cacti framework functions themselves +# are stubbed in tests/bootstrap.php. +WORKDIR /cacti/plugins/thold + +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_NO_INTERACTION=1 \ + COMPOSER_CACHE_DIR=/tmp/composer-cache + +CMD ["sh", "-c", "composer install --no-progress --no-ansi && vendor/bin/phpunit"] diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml new file mode 100644 index 00000000..99d38b47 --- /dev/null +++ b/tests/docker/docker-compose.yml @@ -0,0 +1,15 @@ +# Local mirror of the unit-test CI job. `docker compose -f +# tests/docker/docker-compose.yml run --rm phpunit` runs exactly what CI runs. +services: + phpunit: + build: + context: . + dockerfile: Dockerfile + image: cacti-thold-test:php8.1 + working_dir: /cacti/plugins/thold + volumes: + - ../..:/cacti/plugins/thold + - composer-cache:/tmp/composer-cache + +volumes: + composer-cache: From 6fee2cf833c7116a0c379e2a7ef15e1c975922ee Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 19:37:43 -0700 Subject: [PATCH 2/7] fix(thold): correct the counter delta and the percent denominator A previous counter reading of exactly zero was treated as no reading at all, so the first interval after a device reboot reported a rate of zero. The wrap modulus was 2^32-1 and 2^64-1 rather than 2^32 and 2^64, losing one count per wrap, and the 64-bit literal exceeded PHP_INT_MAX so it was parsed as a float and lost about eleven bits before the subtraction. The percent-of denominator was cast to int, so a denominator below one truncated to zero and forced the result to zero, keeping any configured low threshold in permanent breach. Refs #785 Signed-off-by: Thomas Vincent --- tests/Unit/TholdCalculatePercentTest.php | 100 ++++++++++++++ tests/Unit/TholdGetCurrentvalTest.php | 164 +++++++++++++++++++++++ thold_functions.php | 39 ++++-- 3 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/TholdCalculatePercentTest.php create mode 100644 tests/Unit/TholdGetCurrentvalTest.php diff --git a/tests/Unit/TholdCalculatePercentTest.php b/tests/Unit/TholdCalculatePercentTest.php new file mode 100644 index 00000000..4266fa39 --- /dev/null +++ b/tests/Unit/TholdCalculatePercentTest.php @@ -0,0 +1,100 @@ + + */ + private function threshold() { + return ['percent_ds' => 'total', 'local_data_id' => 4]; + } + + /** + * @param float|int|string $denominator + * @param float|int|string $reading + * + * @return mixed + */ + private function percent($denominator, $reading = 50) { + return thold_calculate_percent($this->threshold(), $reading, [4 => ['total' => $denominator]]); + } + + /** + * @return void + */ + public function testReadingIsExpressedAsAPercentageOfTheReference(): void { + $this->assertEqualsWithDelta(25, $this->percent(200), 1.0e-9); + } + + /** + * A denominator below one used to truncate to zero, forcing the result to + * zero and keeping any configured low threshold in permanent breach. + * + * @return void + */ + public function testFractionalDenominatorIsNotTruncated(): void { + $this->assertEqualsWithDelta(1000, $this->percent(0.5, 5), 1.0e-9); + } + + /** + * @return void + */ + public function testNegativeDenominatorGivesANegativePercentage(): void { + $this->assertEqualsWithDelta(-25, $this->percent(-200), 1.0e-9); + } + + /** + * @return void + */ + public function testZeroDenominatorGivesZeroRatherThanDividingByZero(): void { + $this->assertSame(0, $this->percent(0)); + } + + /** + * @return void + */ + public function testNonNumericDenominatorGivesZero(): void { + $this->assertSame(0, $this->percent('U')); + } + + /** + * @return void + */ + public function testNonNumericReadingYieldsTheNoValueSentinel(): void { + $this->assertSame('', $this->percent(200, 'U')); + } + + /** + * @return void + */ + public function testMissingReferenceDataSourceYieldsTheNoValueSentinel(): void { + $this->assertSame('', thold_calculate_percent($this->threshold(), 50, [4 => ['other' => 200]])); + } +} diff --git a/tests/Unit/TholdGetCurrentvalTest.php b/tests/Unit/TholdGetCurrentvalTest.php new file mode 100644 index 00000000..35c79620 --- /dev/null +++ b/tests/Unit/TholdGetCurrentvalTest.php @@ -0,0 +1,164 @@ + $overrides + * + * @return array + */ + private function threshold(array $overrides = []) { + return $overrides + [ + 'local_data_id' => 4, + 'name' => 'traffic_in', + 'data_source_type_id' => self::COUNTER, + 'rrd_step' => 300, + 'rrd_maximum' => 0, + 'lasttime' => 0, + 'oldvalue' => 100, + ]; + } + + /** + * @param array $thold + * @param float|int|string $reading + * + * @return mixed + */ + private function currentValue(array $thold, $reading) { + $reindexed = [4 => ['traffic_in' => $reading]]; + $time_reindexed = [4 => 1700000300]; + $item = []; + $currenttime = 0; + + return thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime); + } + + /** + * @return void + */ + public function testGaugeReturnsTheReadingUnchanged(): void { + $thold = $this->threshold(['data_source_type_id' => self::GAUGE]); + + $this->assertSame(42, $this->currentValue($thold, 42)); + } + + /** + * @return void + */ + public function testAbsoluteDividesTheReadingByTheStep(): void { + $thold = $this->threshold(['data_source_type_id' => self::ABSOLUTE]); + + $this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9); + } + + /** + * @return void + */ + public function testCounterReturnsTheDeltaOverTheStep(): void { + $thold = $this->threshold(['oldvalue' => 100]); + + $this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9); + } + + /** + * A counter that legitimately read zero last cycle is not the same as + * having no previous reading. Treating it as absent reports a rate of zero + * for the first interval after a device reboot. + * + * @return void + */ + public function testCounterTreatsAPreviousReadingOfZeroAsReal(): void { + $thold = $this->threshold(['oldvalue' => 0]); + + $this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9); + } + + /** + * @return void + */ + public function testCounterWithNoPreviousReadingYieldsZero(): void { + $thold = $this->threshold(['oldvalue' => '']); + + $this->assertSame(0, $this->currentValue($thold, 600)); + } + + /** + * A 32-bit counter that wraps has advanced by (2^32 - old) + new. Using + * 2^32-1 as the modulus loses exactly one count per wrap. + * + * @return void + */ + public function testThirtyTwoBitWrapUsesTheCorrectModulus(): void { + $thold = $this->threshold(['oldvalue' => 4294967290, 'rrd_step' => 1]); + + $this->assertEqualsWithDelta(11, $this->currentValue($thold, 5), 1.0e-9); + } + + /** + * @return void + */ + public function testSixtyFourBitWrapUsesTheCorrectModulus(): void { + $thold = $this->threshold(['oldvalue' => '18446744073709551610', 'rrd_step' => 1]); + + $this->assertEqualsWithDelta(11, $this->currentValue($thold, 5), 1.0e-9); + } + + /** + * @return void + */ + public function testDeriveDividesTheDeltaByTheStep(): void { + $thold = $this->threshold(['data_source_type_id' => self::DERIVE, 'oldvalue' => 100]); + + $this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9); + } + + /** + * @return void + */ + public function testNonNumericReadingYieldsTheNoValueSentinel(): void { + $this->assertSame('', $this->currentValue($this->threshold(), 'U')); + } + + /** + * @return void + */ + public function testMissingDataSourceYieldsTheNoValueSentinel(): void { + $thold = $this->threshold(); + $reindexed = []; + $time_reindexed = [4 => 1700000300]; + $item = []; + $currenttime = 0; + + $this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime)); + } +} diff --git a/thold_functions.php b/thold_functions.php index 018cf99b..3097109a 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -772,6 +772,30 @@ function thold_expression_specialtype_rpn($operator, &$stack, $local_data_id, $c } } +/** + * Counts a wrapped counter has advanced by, given the previous and current + * readings. + * + * The modulus is 2^32 or 2^64, not one less than it, so the previous code lost + * exactly one count per wrap. 2^64 is above PHP_INT_MAX and would be parsed as + * a float, losing about eleven bits at that magnitude, so the 64-bit case goes + * through GMP. Cacti already requires ext-gmp. + * + * @param float|int|string $oldvalue Previous reading. + * @param float|int|string $newvalue Current reading. + * + * @return float|int + */ +function thold_counter_wrap_delta($oldvalue, $newvalue) { + if ($oldvalue > 4294967295) { + $delta = gmp_add(gmp_sub(gmp_pow(2, 64), gmp_init((string) $oldvalue, 10)), gmp_init((string) $newvalue, 10)); + + return (float) gmp_strval($delta); + } + + return (4294967296 - $oldvalue) + $newvalue; +} + function thold_get_currentval(&$thold_data, &$rrd_reindexed, &$rrd_time_reindexed, &$item, &$currenttime) { // adjust the polling interval by the last read, if applicable $currenttime = $rrd_time_reindexed[$thold_data['local_data_id']]; @@ -798,17 +822,14 @@ function thold_get_currentval(&$thold_data, &$rrd_reindexed, &$rrd_time_reindexe if (isset($item[$thold_data['name']]) && is_numeric($item[$thold_data['name']])) { switch ($thold_data['data_source_type_id']) { case 2: // COUNTER - if ($thold_data['oldvalue'] != 0 && is_numeric($thold_data['oldvalue'])) { + // A previous reading of zero is a real reading, not a missing one. + if (is_numeric($thold_data['oldvalue']) && $thold_data['oldvalue'] !== '') { if ($item[$thold_data['name']] >= $thold_data['oldvalue']) { // Everything is Normal $currentval = $item[$thold_data['name']] - $thold_data['oldvalue']; } else { // Possible overflow, see if its 32bit or 64bit - if ($thold_data['oldvalue'] > 4294967295) { - $currentval = (18446744073709551615 - $thold_data['oldvalue']) + $item[$thold_data['name']]; - } else { - $currentval = (4294967295 - $thold_data['oldvalue']) + $item[$thold_data['name']]; - } + $currentval = thold_counter_wrap_delta($thold_data['oldvalue'], $item[$thold_data['name']]); } if (strpos($thold_data['rrd_maximum'], '|query_') !== false) { @@ -1201,9 +1222,11 @@ function thold_calculate_percent($thold, $currentval, $rrd_reindexed) { } if (isset($rrd_reindexed[$thold['local_data_id']][$ds])) { - $t = (int) $rrd_reindexed[$thold['local_data_id']][$thold['percent_ds']]; + // Not cast to int: a denominator below one truncated to zero, which + // forced the percentage to zero and kept a low threshold alerting. + $t = $rrd_reindexed[$thold['local_data_id']][$thold['percent_ds']]; - if ($t > 0) { + if (is_numeric($t) && $t != 0) { $currentval = ($currentval / $t) * 100; } else { $currentval = 0; From c92a4908fc8120ac9170c98eca72c108daa0aaa8 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 19:37:43 -0700 Subject: [PATCH 3/7] fix(daemon): store the previous reading in oldvalue, not a timestamp When a data source produced no sample this cycle the daemon wrote $currenttime - $rrd_step into oldvalue, so the next poll computed a delta against a Unix timestamp, took the overflow branch and fabricated a rate in the billions. The non-daemon path already carries the previous oldvalue forward; this matches it. Refs #785 Signed-off-by: Thomas Vincent --- thold_process.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/thold_process.php b/thold_process.php index b04857eb..d6094b28 100644 --- a/thold_process.php +++ b/thold_process.php @@ -207,10 +207,13 @@ $currentval = ''; } + // Counters, where calculating the difference is important. + // The unset case is problematic and may lead to false triggering + // events. So, in those cases, we will store the 'oldvalue'. if (isset($item[$thold_data['name']])) { - $lasttime = $item[$thold_data['name']]; + $rawvalue = $item[$thold_data['name']]; } else { - $lasttime = $currenttime - $thold_data['rrd_step']; + $rawvalue = $thold_data['oldvalue']; } thold_daemon_debug(sprintf('Checked Name:%s, Graph:%s, Value:%s, Time:%s', $thold_data['thold_name'], $thold_data['local_graph_id'], $currentval, $currenttime), $thread); @@ -219,7 +222,7 @@ SET tcheck = 1, lastread = ?, lasttime = FROM_UNIXTIME(?), oldvalue = ? WHERE id = ?', - [$currentval, $currenttime, $lasttime, $thold_data['thold_id']] + [$currentval, $currenttime, $rawvalue, $thold_data['thold_id']] ); } From c8daad8f2335f3e2b6fb64b8136b3261766837cd Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 16 Aug 2026 20:18:33 -0700 Subject: [PATCH 4/7] test: follow Cacti's test layout and composer scripts Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers for the stubs, a phpunit.xml carrying error_reporting -1 and CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI runs the same commands a developer does. The dev toolchain is Cacti's, pinned to the same platform php 8.1.0. Cacti core runs Pest and this suite does not, because pest ^2 does not resolve on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve, requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this plugin's CI matrix targets. The tests are written in the plain PHPUnit class style that Cacti's tests/Pest.php explicitly supports, so they run unchanged under Pest wherever it is installable. Signed-off-by: Thomas Vincent --- tests/Support/CactiStub.php | 143 -------------- tests/bootstrap.php | 384 ------------------------------------ tests/docker/Dockerfile | 2 +- 3 files changed, 1 insertion(+), 528 deletions(-) delete mode 100644 tests/Support/CactiStub.php delete mode 100644 tests/bootstrap.php diff --git a/tests/Support/CactiStub.php b/tests/Support/CactiStub.php deleted file mode 100644 index 17a5ab48..00000000 --- a/tests/Support/CactiStub.php +++ /dev/null @@ -1,143 +0,0 @@ -}> - */ - public static $calls = []; - - /** - * Queued return values, keyed by function name. Each call shifts one value - * off the front; an exhausted queue falls back to the type default. - * - * @var array> - */ - public static $returns = []; - - /** - * Values handed back by the get_*_request_var() family, keyed by var name. - * - * @var array - */ - public static $requestVars = []; - - /** - * Values handed back by read_config_option(), keyed by option name. - * - * @var array - */ - public static $configOptions = []; - - /** - * Messages passed to cacti_log(), in order. - * - * @var array - */ - public static $log = []; - - /** - * Clear all recorded and programmed state. - * - * @return void - */ - public static function reset() { - self::$calls = []; - self::$returns = []; - self::$requestVars = []; - self::$configOptions = []; - self::$log = []; - } - - /** - * Record one Cacti function call. - * - * @param string $fn Cacti function name. - * @param string $sql SQL text, or '' for non-query calls. - * @param array $params Bound parameters, if any. - * - * @return void - */ - public static function record($fn, $sql = '', array $params = []) { - self::$calls[] = ['fn' => $fn, 'sql' => $sql, 'params' => $params]; - } - - /** - * Queue one return value for the next call to $fn. - * - * @param string $fn Cacti function name. - * @param mixed $value Value to hand back. - * - * @return void - */ - public static function willReturn($fn, $value) { - self::$returns[$fn][] = $value; - } - - /** - * Take the next queued return value for $fn, or $default when none is left. - * - * @param string $fn Cacti function name. - * @param mixed $default Fallback when the queue is empty. - * - * @return mixed - */ - public static function nextReturn($fn, $default) { - if (!empty(self::$returns[$fn])) { - return array_shift(self::$returns[$fn]); - } - - return $default; - } - - /** - * All recorded calls to $fn. - * - * @param string $fn Cacti function name. - * - * @return array}> - */ - public static function callsTo($fn) { - return array_values(array_filter(self::$calls, function ($call) use ($fn) { - return $call['fn'] === $fn; - })); - } - - /** - * The recorded call log reduced to function names, in order. Useful for - * asserting transaction sequencing. - * - * @return array - */ - public static function callSequence() { - return array_column(self::$calls, 'fn'); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index e9d4d5ee..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,384 +0,0 @@ - dirname(dirname(dirname(__DIR__))), - 'url_path' => '/cacti/', - 'cacti_version' => '1.2.31', - 'cacti_server_os' => 'unix', -]; - -// thold_expand_string() include_once()s library_path/variables.php at call time. -$GLOBALS['config']['library_path'] = __DIR__ . '/fixtures/cacti-lib'; - -// thold reads and writes this on every RPN evaluation. -$GLOBALS['rpn_error'] = false; - -if (!function_exists('db_execute')) { - function db_execute($sql, $log = true, $db_conn = false) { - CactiStub::record('db_execute', $sql); - - return CactiStub::nextReturn('db_execute', true); - } -} - -if (!function_exists('db_execute_prepared')) { - function db_execute_prepared($sql, $params = [], $log = true, $db_conn = false) { - CactiStub::record('db_execute_prepared', $sql, $params); - - return CactiStub::nextReturn('db_execute_prepared', true); - } -} - -if (!function_exists('db_fetch_assoc')) { - function db_fetch_assoc($sql, $log = true, $db_conn = false) { - CactiStub::record('db_fetch_assoc', $sql); - - return CactiStub::nextReturn('db_fetch_assoc', []); - } -} - -if (!function_exists('db_fetch_assoc_prepared')) { - function db_fetch_assoc_prepared($sql, $params = [], $log = true, $db_conn = false) { - CactiStub::record('db_fetch_assoc_prepared', $sql, $params); - - return CactiStub::nextReturn('db_fetch_assoc_prepared', []); - } -} - -if (!function_exists('db_fetch_row')) { - function db_fetch_row($sql, $log = true, $db_conn = false) { - CactiStub::record('db_fetch_row', $sql); - - return CactiStub::nextReturn('db_fetch_row', []); - } -} - -if (!function_exists('db_fetch_row_prepared')) { - function db_fetch_row_prepared($sql, $params = [], $log = true, $db_conn = false) { - CactiStub::record('db_fetch_row_prepared', $sql, $params); - - return CactiStub::nextReturn('db_fetch_row_prepared', []); - } -} - -if (!function_exists('db_fetch_cell')) { - function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false) { - CactiStub::record('db_fetch_cell', $sql); - - return CactiStub::nextReturn('db_fetch_cell', ''); - } -} - -if (!function_exists('db_fetch_cell_prepared')) { - function db_fetch_cell_prepared($sql, $params = [], $col_name = '', $log = true, $db_conn = false) { - CactiStub::record('db_fetch_cell_prepared', $sql, $params); - - return CactiStub::nextReturn('db_fetch_cell_prepared', ''); - } -} - -if (!function_exists('db_qstr')) { - function db_qstr($string) { - return "'" . str_replace("'", "''", (string) $string) . "'"; - } -} - -if (!function_exists('db_begin_transaction')) { - function db_begin_transaction() { - CactiStub::record('db_begin_transaction'); - - return CactiStub::nextReturn('db_begin_transaction', true); - } -} - -if (!function_exists('db_commit_transaction')) { - function db_commit_transaction() { - CactiStub::record('db_commit_transaction'); - - return CactiStub::nextReturn('db_commit_transaction', true); - } -} - -if (!function_exists('db_rollback_transaction')) { - function db_rollback_transaction() { - CactiStub::record('db_rollback_transaction'); - - return CactiStub::nextReturn('db_rollback_transaction', true); - } -} - -if (!function_exists('html_escape')) { - function html_escape($string) { - return htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8'); - } -} - -/* - * Mirrors Cacti 1.2 lib/functions.php. KEEP IN SYNC: if core tightens its - * checks, tests here would otherwise keep passing while production diverges. - */ -if (!function_exists('sanitize_unserialize_selected_items')) { - function sanitize_unserialize_selected_items($items) { - if (empty($items)) { - return false; - } - - $data = unserialize($items, ['allowed_classes' => false]); // nosemgrep: php.lang.security.unserialize-use.unserialize-use -- test stub mirroring Cacti core; allowed_classes:false blocks object injection - - if (!is_array($data)) { - return false; - } - - foreach ($data as $value) { - if (!is_numeric($value)) { - return false; - } - } - - return $data; - } -} - -if (!function_exists('read_config_option')) { - function read_config_option($name, $force = false) { - return isset(CactiStub::$configOptions[$name]) ? CactiStub::$configOptions[$name] : ''; - } -} - -if (!function_exists('set_config_option')) { - function set_config_option($name, $value) { - CactiStub::$configOptions[$name] = $value; - } -} - -if (!function_exists('__')) { - function __($text) { - $args = array_slice(func_get_args(), 1); - - // Cacti's __() accepts sprintf arguments after the format string. - return $args === [] ? $text : vsprintf($text, $args); - } -} - -if (!function_exists('__esc')) { - function __esc($text) { - return htmlspecialchars(call_user_func_array('__', func_get_args()), ENT_QUOTES, 'UTF-8'); - } -} - -if (!function_exists('cacti_log')) { - function cacti_log($message, $output = false, $environ = 'CMDPHP', $level = 0) { - CactiStub::$log[] = $message; - } -} - -if (!function_exists('cacti_sizeof')) { - function cacti_sizeof($array) { - return (is_array($array) || $array instanceof Countable) ? count($array) : 0; - } -} - -if (!function_exists('cacti_count')) { - function cacti_count($array) { - return cacti_sizeof($array); - } -} - -if (!function_exists('get_request_var')) { - function get_request_var($name, $default = '') { - return isset(CactiStub::$requestVars[$name]) ? CactiStub::$requestVars[$name] : $default; - } -} - -if (!function_exists('get_nfilter_request_var')) { - function get_nfilter_request_var($name, $default = '') { - return get_request_var($name, $default); - } -} - -if (!function_exists('get_filter_request_var')) { - function get_filter_request_var($name, $filter = FILTER_VALIDATE_INT, $options = []) { - return get_request_var($name); - } -} - -if (!function_exists('isset_request_var')) { - function isset_request_var($name) { - return isset(CactiStub::$requestVars[$name]); - } -} - -if (!function_exists('cacti_escapeshellarg')) { - function cacti_escapeshellarg($string, $quote = true) { - return escapeshellarg((string) $string); - } -} - -if (!function_exists('api_plugin_hook_function')) { - function api_plugin_hook_function($name, $data = '') { - return $data; - } -} - -if (!function_exists('get_simple_graph_perms')) { - function get_simple_graph_perms($user_id) { - return CactiStub::nextReturn('get_simple_graph_perms', true); - } -} - -if (!function_exists('get_policies')) { - function get_policies($user_id) { - return CactiStub::nextReturn('get_policies', []); - } -} - -if (!function_exists('get_policy_where')) { - function get_policy_where($graph_auth_method, $policies, $sql_where) { - CactiStub::record('get_policy_where', $sql_where); - - return CactiStub::nextReturn('get_policy_where', $sql_where); - } -} - -if (!function_exists('expand_title')) { - function expand_title($host_id, $snmp_query_id, $snmp_index, $title) { - CactiStub::record('expand_title', $title); - - return CactiStub::nextReturn('expand_title', $title); - } -} - -if (!function_exists('get_graph_title')) { - function get_graph_title($local_graph_id) { - return CactiStub::nextReturn('get_graph_title', 'Traffic - eth0'); - } -} - -if (!function_exists('rrdtool_function_fetch')) { - function rrdtool_function_fetch($local_data_id, $start_time, $end_time, $resolution = 0, $show_unknown = false, $rrdtool_file = null) { - CactiStub::record('rrdtool_function_fetch', (string) $local_data_id); - - return CactiStub::nextReturn('rrdtool_function_fetch', []); - } -} - -if (!function_exists('get_data_source_path')) { - function get_data_source_path($local_data_id, $expand_paths = true) { - return '/var/lib/cacti/rra/test_' . (int) $local_data_id . '.rrd'; - } -} - -if (!function_exists('rrdtool_execute')) { - function rrdtool_execute($command, $log_to_stdout = false, $output_flag = 1, $rrdtool_pipe = false, $logopt = 'WEBLOG') { - CactiStub::record('rrdtool_execute', $command); - - return CactiStub::nextReturn('rrdtool_execute', ''); - } -} - -if (!function_exists('rrdtool_function_interface_speed')) { - function rrdtool_function_interface_speed($data_local) { - return CactiStub::nextReturn('rrdtool_function_interface_speed', 0); - } -} - -if (!function_exists('get_timeinstate')) { - function get_timeinstate($host) { - return CactiStub::nextReturn('get_timeinstate', '1 day'); - } -} - -if (!function_exists('get_daysfromtime')) { - function get_daysfromtime($timestamp) { - return CactiStub::nextReturn('get_daysfromtime', '1 day'); - } -} - -if (!function_exists('number_format_i18n')) { - function number_format_i18n($number, $decimals = 0, $baseu = 1000) { - return number_format((float) $number, $decimals < 0 ? 0 : (int) $decimals); - } -} - -if (!defined('FILTER_VALIDATE_IS_REGEX')) { - define('FILTER_VALIDATE_IS_REGEX', 99999); -} - -// Device states, from Cacti include/global_constants.php. -foreach (['HOST_UNKNOWN' => 0, 'HOST_DOWN' => 1, 'HOST_RECOVERING' => 2, 'HOST_UP' => 3, 'HOST_ERROR' => 4] as $name => $value) { - if (!defined($name)) { - define($name, $value); - } -} - -if (!defined('RRDTOOL_OUTPUT_STDOUT')) { - define('RRDTOOL_OUTPUT_STDOUT', 1); -} - -if (!defined('CACTI_DATE_TIME_FORMAT')) { - define('CACTI_DATE_TIME_FORMAT', 'Y-m-d H:i:s'); -} - -if (!defined('CACTI_PATH_BASE')) { - define('CACTI_PATH_BASE', $GLOBALS['config']['base_path']); -} - -/** - * Load a plugin source file at global scope. - * - * Several plugin files (includes/arrays.php in particular) define their data - * as file-scope variables that the rest of the plugin reads as globals, and - * they read $config while doing so. Requiring them from inside a method would - * make both halves of that method-local, so the require happens here and any - * variable the file introduced is published to $GLOBALS. - * - * @param string $path Absolute path to the file. - * - * @return void - */ -function thold_test_load($path) { - global $config; - - $__before = get_defined_vars(); - - require_once $path; - - foreach (get_defined_vars() as $__name => $__value) { - if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) { - $GLOBALS[$__name] = $__value; - } - } -} diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile index 5c0a414d..518f7321 100644 --- a/tests/docker/Dockerfile +++ b/tests/docker/Dockerfile @@ -26,4 +26,4 @@ ENV COMPOSER_ALLOW_SUPERUSER=1 \ COMPOSER_NO_INTERACTION=1 \ COMPOSER_CACHE_DIR=/tmp/composer-cache -CMD ["sh", "-c", "composer install --no-progress --no-ansi && vendor/bin/phpunit"] +CMD ["sh", "-c", "composer install --no-progress --no-ansi && composer test"] From b42283a9804f6e1df543371a4461c7de307e8269 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:47:13 -0700 Subject: [PATCH 5/7] ci: keep plugin PR integration checks on pinned Cacti --- .github/workflows/plugin-ci-workflow.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 5e4f3db6..79b16b7b 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -35,21 +35,12 @@ jobs: integration-test: runs-on: ${{ matrix.os }} - # A failure against the pinned release is a real failure. The develop entry - # is advisory: it is how a core regression becomes visible here, but it must - # not turn the plugin's own pull requests red. - continue-on-error: ${{ matrix.cacti != 'release/1.2.31' }} - strategy: fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4'] os: [ubuntu-latest] cacti: ['release/1.2.31'] - include: - - php: '8.4' - os: ubuntu-latest - cacti: 'develop' services: mariadb: From 2031fc7833374b16b67a2b92e555d0a86f3dac68 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:52:50 -0700 Subject: [PATCH 6/7] fix(counter): handle non-integer 64-bit readings safely --- tests/Unit/TholdGetCurrentvalTest.php | 12 ++++++++++++ thold_functions.php | 9 ++++++++- thold_process.php | 5 ++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/Unit/TholdGetCurrentvalTest.php b/tests/Unit/TholdGetCurrentvalTest.php index 35c79620..4c1472cf 100644 --- a/tests/Unit/TholdGetCurrentvalTest.php +++ b/tests/Unit/TholdGetCurrentvalTest.php @@ -133,6 +133,18 @@ public function testSixtyFourBitWrapUsesTheCorrectModulus(): void { $this->assertEqualsWithDelta(11, $this->currentValue($thold, 5), 1.0e-9); } + /** + * RRD values can arrive in scientific notation. GMP accepts only integer + * strings, so these values must use the non-fatal floating-point fallback. + * + * @return void + */ + public function testSixtyFourBitWrapAcceptsScientificNotation(): void { + $thold = $this->threshold(['oldvalue' => '1.8446744073709552E+19', 'rrd_step' => 1]); + + $this->assertEqualsWithDelta(5, $this->currentValue($thold, 5), 1.0e-9); + } + /** * @return void */ diff --git a/thold_functions.php b/thold_functions.php index 3097109a..0439775c 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -788,7 +788,14 @@ function thold_expression_specialtype_rpn($operator, &$stack, $local_data_id, $c */ function thold_counter_wrap_delta($oldvalue, $newvalue) { if ($oldvalue > 4294967295) { - $delta = gmp_add(gmp_sub(gmp_pow(2, 64), gmp_init((string) $oldvalue, 10)), gmp_init((string) $newvalue, 10)); + $old_integer = trim((string) $oldvalue); + $new_integer = trim((string) $newvalue); + + if (!preg_match('/^\d+$/D', $old_integer) || !preg_match('/^\d+$/D', $new_integer)) { + return (18446744073709551616.0 - (float) $oldvalue) + (float) $newvalue; + } + + $delta = gmp_add(gmp_sub(gmp_pow(2, 64), gmp_init($old_integer, 10)), gmp_init($new_integer, 10)); return (float) gmp_strval($delta); } diff --git a/thold_process.php b/thold_process.php index d6094b28..40078c24 100644 --- a/thold_process.php +++ b/thold_process.php @@ -207,9 +207,8 @@ $currentval = ''; } - // Counters, where calculating the difference is important. - // The unset case is problematic and may lead to false triggering - // events. So, in those cases, we will store the 'oldvalue'. + // Carry the previous value forward when this cycle has no reading; + // storing a timestamp here corrupts the next delta calculation. if (isset($item[$thold_data['name']])) { $rawvalue = $item[$thold_data['name']]; } else { From e3fd9d0adfcac1c635f5e6c8836126c2c29ee456 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 17 Aug 2026 14:59:50 -0700 Subject: [PATCH 7/7] ci: bound package index refreshes --- .github/workflows/plugin-ci-workflow.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 79b16b7b..e17554e8 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -86,7 +86,24 @@ jobs: echo "PHP_BINARY=$(command -v php)" >> "$GITHUB_ENV" - name: Run apt-get update - run: sudo apt-get update + run: | + for attempt in 1 2 3; do + if sudo timeout 3m apt-get \ + -o Dpkg::Lock::Timeout=60 \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + update; then + exit 0 + fi + + if [ "$attempt" -lt 3 ]; then + sleep 10 + fi + done + + echo 'apt-get update failed after three bounded attempts.' >&2 + exit 1 - name: Install System Dependencies run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping