mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-26 13:08:09 +09:00
* fix: reset loop-scoped $oid and $script_path when building the poller cache Both output loops assigned inside a guard but read after it, so an output with no mapping was cached against the previous output's OID or script. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * docs: append the changelog entry at the end of the issue block Matches .github/copilot-instructions.md, which appends rather than inserting in numeric order. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: use the full licence header on the new test files Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: treat CACTI_PATH_LOG as a directory and pass ctor args to the statement class Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix: reset data-source output loop state * test(poller): isolate SQL dialect in cache regression --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: TheWitness <thewitness@cacti.net>
358 lines
14 KiB
PHP
358 lines
14 KiB
PHP
<?php
|
|
/*
|
|
+-------------------------------------------------------------------------+
|
|
| 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 General Public License |
|
|
| as published by the Free Software Foundation; either version 2 |
|
|
| of the License, or (at your option) any later version. |
|
|
| |
|
|
| This program is distributed in the hope that it will be useful, |
|
|
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
|
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
|
| GNU General Public License for more details. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/*
|
|
* Integration coverage for issue #7135. Both output loops in
|
|
* update_poller_cache() assign their loop state inside a guard but read it
|
|
* after, so an output with no 'oid' (or no 'query_name') mapping inherited the
|
|
* previous iteration's value and was cached against another output's OID or
|
|
* script. The poller then collected the wrong value under that data source's
|
|
* name.
|
|
*
|
|
* Asserting that unset() is present would not prove anything about the cache
|
|
* that gets built, so this runs the real update_poller_cache() against a real
|
|
* (sqlite-backed) connection with a two-output data query whose second field
|
|
* deliberately has no oid, and reads back the poller items it produced.
|
|
*/
|
|
|
|
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
|
|
require_once dirname(__DIR__, 2) . '/include/global_constants.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/database.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/functions.php';
|
|
|
|
/* After lib/functions.php, so its guarded definitions stay no-ops; it is here
|
|
for __()/__esc(), which the data query loader calls and functions.php does
|
|
not define. */
|
|
require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
|
|
require_once dirname(__DIR__) . '/Helpers/FakeMySQLPDO.php';
|
|
|
|
/* include/global.php resolves these from config and the settings table, which
|
|
would need a live install. update_poller_cache() only uses them to build the
|
|
RRD path and to locate the data query XML. */
|
|
foreach ([
|
|
'CACTI_WEB' => false,
|
|
'CACTI_SERVER_OS' => 'unix',
|
|
'CACTI_PATH_BASE' => dirname(__DIR__, 2),
|
|
'CACTI_PATH_LIBRARY' => dirname(__DIR__, 2) . '/lib',
|
|
'CACTI_PATH_RRA' => dirname(__DIR__, 2) . '/rra',
|
|
/* a directory, as include/global_path.php defines it, so anything that
|
|
appends a filename to it still resolves */
|
|
'CACTI_PATH_LOG' => sys_get_temp_dir(),
|
|
] as $name => $value) {
|
|
if (!defined($name)) {
|
|
define($name, $value);
|
|
}
|
|
}
|
|
|
|
require_once dirname(__DIR__, 2) . '/lib/utility.php';
|
|
|
|
// the script query path expands |host_*| tokens, which fires a plugin hook
|
|
require_once dirname(__DIR__, 2) . '/lib/plugins.php';
|
|
|
|
/**
|
|
* sqlite reports rowCount() as 0 for SELECT, and db_fetch_row_return() guards
|
|
* on it, so without this every row read in update_poller_cache() comes back
|
|
* empty.
|
|
*/
|
|
class PollerCacheStatement extends PDOStatement {
|
|
/** @var array<int,array<string,mixed>>|null rows of a SELECT, null otherwise */
|
|
private ?array $rows = null;
|
|
private int $cursor = 0;
|
|
|
|
protected function __construct() {
|
|
}
|
|
|
|
public function execute(?array $params = null) : bool {
|
|
$executed = parent::execute($params);
|
|
|
|
$this->rows = $this->columnCount() > 0 ? parent::fetchAll(PDO::FETCH_ASSOC) : null;
|
|
$this->cursor = 0;
|
|
|
|
return $executed;
|
|
}
|
|
|
|
public function rowCount() : int {
|
|
return $this->rows === null ? parent::rowCount() : count($this->rows);
|
|
}
|
|
|
|
public function fetchAll(int $mode = PDO::FETCH_DEFAULT, mixed ...$args) : array {
|
|
return $this->rows ?? parent::fetchAll($mode, ...$args);
|
|
}
|
|
|
|
public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0) : mixed {
|
|
if ($this->rows === null) {
|
|
return parent::fetch($mode, $cursorOrientation, $cursorOffset);
|
|
}
|
|
|
|
$row = $this->rows[$this->cursor++] ?? null;
|
|
|
|
if ($row === null) {
|
|
return false;
|
|
}
|
|
|
|
return match ($mode) {
|
|
PDO::FETCH_NUM => array_values($row),
|
|
PDO::FETCH_BOTH => $row + array_values($row),
|
|
PDO::FETCH_COLUMN => reset($row),
|
|
PDO::FETCH_OBJ => (object) $row,
|
|
default => $row,
|
|
};
|
|
}
|
|
|
|
public function fetchColumn(int $column = 0) : mixed {
|
|
if ($this->rows === null) {
|
|
return parent::fetchColumn($column);
|
|
}
|
|
|
|
$row = $this->rows[$this->cursor++] ?? null;
|
|
|
|
return $row === null ? false : (array_values($row)[$column] ?? false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A FakeMySQLPDO that hands out counting statements.
|
|
*/
|
|
class PollerCachePDO extends FakeMySQLPDO {
|
|
public function __construct() {
|
|
parent::__construct();
|
|
$this->setAttribute(PDO::ATTR_STATEMENT_CLASS, [PollerCacheStatement::class, []]);
|
|
}
|
|
|
|
public function prepare(string $query, array $options = []): PDOStatement|false {
|
|
/* global_constants.php defines SQL_NO_CACHE from database_type at file
|
|
discovery time. A full test run can therefore inherit MySQL syntax
|
|
even though this test deliberately uses sqlite. */
|
|
$query = preg_replace('/\bSQL_NO_CACHE\b/', '', $query);
|
|
|
|
return parent::prepare($query, $options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Seeds one indexed data source with two outputs. Both fixtures give the second
|
|
* output no mapping, which is the case the leaked loop state papered over.
|
|
*
|
|
* @param int $type_id The data input type, which picks the loop under test.
|
|
* @param string $fixture The data query XML under tests/fixtures to point at.
|
|
* @param string[] $fields The two output field names, in the fixture's order.
|
|
*
|
|
* @return PollerCachePDO A connection holding the seeded schema.
|
|
*/
|
|
function poller_cache_seed(int $type_id = DATA_INPUT_TYPE_SNMP_QUERY, string $fixture = 'issue7135_data_query.xml', array $fields = ['withOid', 'withoutOid']) : PollerCachePDO {
|
|
$conn = new PollerCachePDO();
|
|
|
|
$conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)');
|
|
|
|
// substitute_script_query_path() expands these into the script command line
|
|
$conn->exec("INSERT INTO settings (name, value) VALUES
|
|
('path_php_binary', '/usr/bin/php'),
|
|
('path_snmpget', '/usr/bin/snmpget')");
|
|
|
|
$conn->exec('CREATE TABLE data_local (id INTEGER PRIMARY KEY, host_id INTEGER,
|
|
snmp_query_id INTEGER, snmp_index TEXT, data_template_id INTEGER)');
|
|
$conn->exec("INSERT INTO data_local VALUES (1, 1, 1, '3', 7)");
|
|
|
|
$conn->exec('CREATE TABLE data_input (id INTEGER PRIMARY KEY, type_id INTEGER, name TEXT, hash TEXT)');
|
|
$conn->exec("INSERT INTO data_input VALUES (5, $type_id, 'Indexed Query', 'h5')");
|
|
|
|
$conn->exec('CREATE TABLE data_template_data (id INTEGER PRIMARY KEY, local_data_id INTEGER,
|
|
data_template_id INTEGER, data_input_id INTEGER, rrd_step INTEGER, active TEXT,
|
|
name TEXT, data_source_path TEXT)');
|
|
$conn->exec("INSERT INTO data_template_data VALUES
|
|
(11, 1, 7, 5, 300, 'on', 'ds', '<path_rra>/ds.rrd'),
|
|
(12, 0, 7, 5, 300, 'on', 'tmpl', '')");
|
|
|
|
$conn->exec('CREATE TABLE snmp_query (id INTEGER PRIMARY KEY, xml_path TEXT)');
|
|
$conn->exec("INSERT INTO snmp_query VALUES (1, '<path_cacti>/tests/fixtures/$fixture')");
|
|
|
|
$conn->exec('CREATE TABLE data_template_rrd (id INTEGER PRIMARY KEY,
|
|
local_data_template_rrd_id INTEGER, local_data_id INTEGER, data_source_name TEXT)');
|
|
$conn->exec("INSERT INTO data_template_rrd VALUES (21, 31, 1, '$fields[0]'), (22, 32, 1, '$fields[1]')");
|
|
|
|
$conn->exec('CREATE TABLE snmp_query_graph_rrd (snmp_query_graph_id INTEGER,
|
|
data_template_id INTEGER, data_template_rrd_id INTEGER, snmp_field_name TEXT)');
|
|
$conn->exec("INSERT INTO snmp_query_graph_rrd VALUES (1, 7, 31, '$fields[0]'), (1, 7, 32, '$fields[1]')");
|
|
|
|
$conn->exec('CREATE TABLE data_input_fields (id INTEGER PRIMARY KEY, data_input_id INTEGER,
|
|
type_code TEXT, input_output TEXT)');
|
|
$conn->exec('CREATE TABLE data_input_data (data_input_field_id INTEGER,
|
|
data_template_data_id INTEGER, value TEXT)');
|
|
|
|
/* The full host column list from cacti.sql: substitute_host_data() expands
|
|
|host_*| tokens straight off SELECT *, so a trimmed table shows up as
|
|
undefined-key warnings rather than as a failure. */
|
|
$conn->exec('CREATE TABLE host (
|
|
id INTEGER PRIMARY KEY, poller_id INTEGER, site_id INTEGER, host_template_id INTEGER,
|
|
description TEXT, hostname TEXT, location TEXT, notes TEXT, external_id TEXT,
|
|
snmp_options TEXT, snmp_community TEXT, snmp_version INTEGER, snmp_username TEXT,
|
|
snmp_password TEXT, snmp_auth_protocol TEXT, snmp_priv_passphrase TEXT,
|
|
snmp_priv_protocol TEXT, snmp_context TEXT, snmp_engine_id TEXT, snmp_port INTEGER,
|
|
snmp_timeout INTEGER, snmp_retries INTEGER, snmp_sysDescr TEXT, snmp_sysObjectID TEXT,
|
|
snmp_sysUpTimeInstance TEXT, snmp_sysContact TEXT, snmp_sysName TEXT,
|
|
snmp_sysLocation TEXT, availability_method INTEGER, ping_method INTEGER,
|
|
ping_port INTEGER, ping_timeout INTEGER, ping_retries INTEGER, max_oids INTEGER,
|
|
bulk_walk_size INTEGER, device_threads INTEGER, deleted TEXT, disabled TEXT, graphs TEXT,
|
|
data_sources TEXT, status INTEGER, status_event_count INTEGER, status_fail_date TEXT,
|
|
status_rec_date TEXT, status_options_date TEXT, status_last_error TEXT, min_time TEXT,
|
|
max_time TEXT, cur_time TEXT, avg_time TEXT, polling_time TEXT, current_errors INTEGER,
|
|
total_polls INTEGER, failed_polls INTEGER, availability TEXT, last_updated TEXT,
|
|
created TEXT
|
|
)');
|
|
|
|
$conn->exec("INSERT INTO host (id, poller_id, site_id, description, hostname,
|
|
snmp_community, snmp_version, snmp_port, snmp_timeout, snmp_retries, ping_retries,
|
|
max_oids, bulk_walk_size, availability_method, disabled, deleted)
|
|
VALUES (1, 1, 0, 'dev', 'dev.example.net', 'public', 2, 161, 500, 3, 3, 10, 10, 1, '', '')");
|
|
|
|
// the script query path expands |host_*| tokens, which joins the device's site
|
|
$conn->exec('CREATE TABLE sites (id INTEGER PRIMARY KEY, name TEXT)');
|
|
|
|
return $conn;
|
|
}
|
|
|
|
/**
|
|
* Points lib/database.php's default connection at the seeded handle.
|
|
*
|
|
* @param PDO $conn The connection update_poller_cache() should read and write.
|
|
*
|
|
* @return void
|
|
*/
|
|
function poller_cache_wire(PDO $conn) : void {
|
|
$GLOBALS['database_hostname'] = 'unittest';
|
|
$GLOBALS['database_port'] = 0;
|
|
$GLOBALS['database_default'] = 'unittest';
|
|
$GLOBALS['database_total_queries'] = 0;
|
|
$GLOBALS['config'] = $GLOBALS['config'] ?? [];
|
|
$GLOBALS['database_sessions'] = ['unittest:0:unittest' => $conn];
|
|
}
|
|
|
|
beforeEach(function () {
|
|
$this->global_state = [];
|
|
|
|
foreach (['database_sessions', 'database_hostname', 'database_port', 'database_default', 'database_total_queries', 'data_query_xml_arrays', 'config'] as $name) {
|
|
$this->global_state[$name] = [array_key_exists($name, $GLOBALS), $GLOBALS[$name] ?? null];
|
|
}
|
|
|
|
/* get_data_query_array() memoizes by snmp_query_id, so without this the
|
|
second fixture would be served the first one's parsed XML. */
|
|
$GLOBALS['data_query_xml_arrays'] = [];
|
|
});
|
|
|
|
/* Left in place, the fake handle answers every later read_config_option() in
|
|
the run and throws on Cacti's MySQL SQL, aborting the suite. */
|
|
afterEach(function () {
|
|
foreach ($this->global_state as $name => [$existed, $value]) {
|
|
if ($existed) {
|
|
$GLOBALS[$name] = $value;
|
|
} else {
|
|
unset($GLOBALS[$name]);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('the counting statement preserves fetch and fetchColumn after execute', function () {
|
|
$conn = new PollerCachePDO();
|
|
|
|
$statement = $conn->prepare("SELECT SQL_NO_CACHE 'alpha' AS value");
|
|
$statement->execute();
|
|
expect($statement->fetch(PDO::FETCH_ASSOC))->toBe(['value' => 'alpha']);
|
|
|
|
$statement = $conn->prepare("SELECT 'beta' AS value");
|
|
$statement->execute();
|
|
expect($statement->fetchColumn())->toBe('beta');
|
|
});
|
|
|
|
test('test_data_source resets output-specific state on every iteration', function () {
|
|
$source = file_get_contents(dirname(__DIR__, 2) . '/lib/functions.php');
|
|
expect($source)->not->toBeFalse('lib/functions.php must be readable');
|
|
|
|
$start = strpos($source, 'function test_data_source(');
|
|
expect($start)->not->toBeFalse('test_data_source() must exist');
|
|
|
|
$end = strpos($source, "\nfunction ", $start + 1);
|
|
expect($end)->not->toBeFalse('test_data_source() must have a following function');
|
|
|
|
$body = substr($source, $start, $end - $start);
|
|
|
|
expect($body)->toContain("foreach (\$outputs as \$output) {\n\t\t\t\t\t\$oid = null;")
|
|
->and($body)->toContain("foreach (\$outputs as \$output) {\n\t\t\t\t\t\$script_path = null;");
|
|
});
|
|
|
|
test('an output with no oid mapping produces no poller item at all', function () {
|
|
poller_cache_wire(poller_cache_seed());
|
|
|
|
$items = update_poller_cache(1, false);
|
|
|
|
// pre-fix this was 2: withoutOid inherited withOid's $oid and was cached
|
|
expect($items)->toHaveCount(1);
|
|
});
|
|
|
|
test('the one poller item built is the field that actually has an oid', function () {
|
|
poller_cache_wire(poller_cache_seed());
|
|
|
|
$items = update_poller_cache(1, false);
|
|
|
|
expect($items[0])->toContain('withOid')
|
|
->and($items[0])->not->toContain('withoutOid');
|
|
});
|
|
|
|
test('the cached oid is the mapped one with the data source index appended', function () {
|
|
poller_cache_wire(poller_cache_seed());
|
|
|
|
$items = update_poller_cache(1, false);
|
|
|
|
// .10 is withOid's oid in the fixture, .3 is data_local.snmp_index
|
|
expect($items[0])->toContain('.1.3.6.1.4.1.9999.1.1.10.3');
|
|
});
|
|
|
|
test('no poller item carries an oid belonging to a different output', function () {
|
|
poller_cache_wire(poller_cache_seed());
|
|
|
|
$items = update_poller_cache(1, false);
|
|
|
|
$oids = [];
|
|
|
|
foreach ($items as $item) {
|
|
// the tuple ends with the oid followed by three trailing fields
|
|
preg_match('/\'(\.[0-9.]+)\'/', $item, $match);
|
|
|
|
$oids[] = $match[1] ?? '';
|
|
}
|
|
|
|
// the leak showed up as the same oid appearing under two data source names
|
|
expect($oids)->toBe(array_unique($oids));
|
|
});
|
|
|
|
test('a script query output with no query_name mapping is not cached either', function () {
|
|
poller_cache_wire(poller_cache_seed(
|
|
DATA_INPUT_TYPE_SCRIPT_QUERY,
|
|
'issue7135_script_query.xml',
|
|
['withQueryName', 'withoutQueryName']
|
|
));
|
|
|
|
$items = update_poller_cache(1, false);
|
|
|
|
// same leak on the script side: pre-fix withoutQueryName inherited
|
|
// withQueryName's $script_path and was cached against another output's script
|
|
expect($items)->toHaveCount(1)
|
|
->and($items[0])->toContain('withQueryName')
|
|
->and($items[0])->not->toContain('withoutQueryName');
|
|
});
|