mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
362 lines
18 KiB
PHP
362 lines
18 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. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/*
|
|
* Hand-off tests for #7521/#7524: a data source's ownership moving from one
|
|
* remote Data Collector to another (or being removed/converted) is a
|
|
* hand-off between two physically separate poller databases. The bug in
|
|
* all three fixed sites was the same shape: the "new" side got written but
|
|
* the "old" side's poller_item row was left behind, so the old Data
|
|
* Collector kept polling an item that no longer belonged to it.
|
|
*
|
|
* These tests exercise the real, unmodified production functions --
|
|
* api_data_source_change_host(), data_query_remove_disabled_items(), and
|
|
* data_source_to_data_template() -- against two live database connections
|
|
* standing in for the central database and a remote Data Collector's
|
|
* database, per the project's established two-database stand-in pattern
|
|
* (see tests/Unit/DbExecutePreparedTest.php and
|
|
* tests/Unit/DbReplaceSqlSaveTest.php for the same style applied to
|
|
* lower-level db_* functions).
|
|
*
|
|
* poller_push_to_remote_db_connect()/poller_connect_to_remote() are the
|
|
* real, unmodified functions, exercised two different ways here:
|
|
*
|
|
* - poller_connect_to_remote() looks up the `poller` row via
|
|
* db_fetch_row_prepared(), which gates its result on
|
|
* PDOStatement::rowCount() (lib/database.php:910). The PDO sqlite driver
|
|
* always reports 0 for a SELECT's rowCount(), so that lookup silently
|
|
* returns [] against sqlite regardless of how the fixture is seeded --
|
|
* the same limitation already documented in
|
|
* tests/Unit/DbExecutePreparedTest.php's skipped case. The central/
|
|
* default connection therefore has to speak real MySQL protocol; this
|
|
* file connects to a MariaDB container (see "Docker fixture" below) and
|
|
* skips itself cleanly if one isn't reachable, the same graceful
|
|
* fall-back tests/HandOff/CactiSettingsHandOffTest.php uses when its
|
|
* target feature isn't present on the branch.
|
|
* - Reaching the "old poller" branch without a live TCP connection to a
|
|
* second MySQL server exploits db_connect_real()'s own connection cache
|
|
* (lib/database.php:55 - `$database_sessions["$device:$port:$db_name"]`):
|
|
* pre-seeding that cache with the "remote" PDO handle, keyed by the
|
|
* exact host/port/dbname the fixture's `poller` row carries, makes
|
|
* db_connect_real() return the fake connection on the first cache
|
|
* lookup -- before it ever builds a real DSN. DELETE statements don't
|
|
* depend on rowCount(), so a plain sqlite::memory: handle is fine for
|
|
* this side.
|
|
*
|
|
* Neither of these depends on POLLER_ID, which the shared Pest process may
|
|
* already have pinned to 1 via a prior test file's UnitStubs.php include
|
|
* (HandOff runs after Unit per tests/phpunit.xml's testsuite order, so it always
|
|
* has been in a full-suite run).
|
|
*
|
|
* Docker fixture: set CACTI_HANDOFF_MYSQL_HOST/_PORT/_USER/_PASS/_DB to
|
|
* point at an already-running MySQL/MariaDB instance. With no env set, the
|
|
* fixture tries 127.0.0.1:33071 (root/root, db "handoff_central") -- start
|
|
* one locally with:
|
|
* docker run -d --rm --name cacti-handoff-poller-test \
|
|
* -e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=handoff_central \
|
|
* -p 33071:3306 mariadb:11
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
|
|
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/database.php';
|
|
|
|
// push_out_host() (exercised via api_data_source_change_host()) needs
|
|
// CACTI_PATH_LIBRARY to include_once() a sibling lib/ file. This file never
|
|
// requires include/global.php (which normally defines it via
|
|
// include/global_path.php), so define it directly rather than depending on
|
|
// some other test file in the suite having bootstrapped it first as a
|
|
// process-wide side effect -- that ordering isn't guaranteed when this file
|
|
// runs standalone or alongside a different subset of tests.
|
|
if (!defined('CACTI_PATH_LIBRARY')) {
|
|
define('CACTI_PATH_LIBRARY', dirname(__DIR__, 2) . '/lib');
|
|
}
|
|
|
|
function handoffMysqlDsnParts(): array {
|
|
return [
|
|
'host' => getenv('CACTI_HANDOFF_MYSQL_HOST') ?: '127.0.0.1',
|
|
'port' => (int) (getenv('CACTI_HANDOFF_MYSQL_PORT') ?: 33071),
|
|
'user' => getenv('CACTI_HANDOFF_MYSQL_USER') ?: 'root',
|
|
'pass' => getenv('CACTI_HANDOFF_MYSQL_PASS') ?: 'root',
|
|
'db' => getenv('CACTI_HANDOFF_MYSQL_DB') ?: 'handoff_central',
|
|
];
|
|
}
|
|
|
|
function handoffTryConnectMysql(): ?PDO {
|
|
$parts = handoffMysqlDsnParts();
|
|
|
|
try {
|
|
return new PDO(
|
|
"mysql:host={$parts['host']};port={$parts['port']};dbname={$parts['db']};charset=utf8",
|
|
$parts['user'],
|
|
$parts['pass'],
|
|
[PDO::ATTR_TIMEOUT => 2, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
|
);
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (handoffTryConnectMysql() === null) {
|
|
$parts = handoffMysqlDsnParts();
|
|
|
|
test("Hand-off: {$parts['host']}:{$parts['port']} not reachable, skipping poller_item remote-purge hand-off tests")
|
|
->skip('no MySQL/MariaDB reachable for the central-connection fixture -- see file header for how to start one');
|
|
|
|
return;
|
|
}
|
|
|
|
if (!defined('MESSAGE_LEVEL_WARN')) {
|
|
define('MESSAGE_LEVEL_WARN', 2);
|
|
}
|
|
|
|
require_once dirname(__DIR__, 2) . '/lib/poller.php';
|
|
|
|
// api_data_source_change_host()/data_source_to_data_template() call these;
|
|
// neither touches poller_item or anything this test asserts on, so they are
|
|
// stubbed to no-ops rather than pulling in lib/variables.php + lib/utility.php
|
|
// and their own dependency trees.
|
|
if (!function_exists('push_out_host')) {
|
|
function push_out_host(int $host_id, int $local_data_id = 0, int $data_template_id = 0) : void {
|
|
}
|
|
}
|
|
|
|
if (!function_exists('update_data_source_title_cache')) {
|
|
function update_data_source_title_cache(int $local_data_id) : void {
|
|
}
|
|
}
|
|
|
|
// data_source_to_data_template() calls get_hash_data_template(), which
|
|
// lives in lib/functions.php alongside hundreds of unrelated functions
|
|
// (several of which collide with tests/Helpers/UnitStubs.php's stubs, e.g.
|
|
// read_user_setting()). Stub the one function this test path needs instead
|
|
// of pulling in that whole file.
|
|
if (!function_exists('get_hash_data_template')) {
|
|
function get_hash_data_template(int $data_template_id, string $sub_type = 'data_template') : string {
|
|
return md5($sub_type . ':' . $data_template_id . ':' . random_int(0, PHP_INT_MAX));
|
|
}
|
|
}
|
|
|
|
// data_query_remove_disabled_items() calls array_rekey(); also lives in
|
|
// lib/functions.php, same collision concern as get_hash_data_template()
|
|
// above.
|
|
if (!function_exists('array_rekey')) {
|
|
function array_rekey(mixed $array, string $key, mixed $key_value) : array {
|
|
$ret_array = [];
|
|
|
|
if (is_array($array)) {
|
|
foreach ($array as $item) {
|
|
$ret_array[$item[$key]] = $item[$key_value];
|
|
}
|
|
}
|
|
|
|
return $ret_array;
|
|
}
|
|
}
|
|
|
|
require_once dirname(__DIR__, 2) . '/lib/api_data_source.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/data_query.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/template.php';
|
|
|
|
const HANDOFF_OLD_POLLER_ID = 7;
|
|
const HANDOFF_NEW_POLLER_ID = 1; // central; keeps the (unrelated, already-correct) "push to new" side inert
|
|
|
|
// Fake connection-string parts for the old remote poller. Must not be
|
|
// 'localhost'/'127.0.0.1' -- poller_connect_to_remote() treats those as a
|
|
// self-referential loop and bails out with false before ever calling
|
|
// db_connect_real().
|
|
const HANDOFF_OLD_POLLER_DBHOST = 'remote-poller-a.fixture';
|
|
const HANDOFF_OLD_POLLER_DBPORT = 3306;
|
|
const HANDOFF_OLD_POLLER_DBNAME = 'remote_a';
|
|
|
|
/*
|
|
* Builds the central database on the real MySQL/MariaDB fixture: host,
|
|
* data_local, poller, and poller_item tables with just the columns the
|
|
* fixed functions actually read. Dropped and recreated per test so state
|
|
* never leaks between cases.
|
|
*/
|
|
function handoffCentralConn(): PDO {
|
|
$conn = handoffTryConnectMysql();
|
|
|
|
foreach (['poller_item', 'poller', 'data_template_rrd', 'data_template', 'data_template_data', 'data_local', 'host', 'poller_output_boost'] as $table) {
|
|
$conn->exec("DROP TABLE IF EXISTS $table");
|
|
}
|
|
|
|
$conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY, poller_id INTEGER)');
|
|
$conn->exec('CREATE TABLE data_local (id INTEGER PRIMARY KEY, host_id INTEGER)');
|
|
$conn->exec('CREATE TABLE data_template_data (id INTEGER PRIMARY KEY AUTO_INCREMENT, local_data_id INTEGER, local_data_template_data_id INTEGER, data_template_id INTEGER, name VARCHAR(255), active VARCHAR(3))');
|
|
$conn->exec('CREATE TABLE data_template (id INTEGER PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255), hash VARCHAR(32))');
|
|
$conn->exec('CREATE TABLE data_template_rrd (id INTEGER PRIMARY KEY AUTO_INCREMENT, local_data_id INTEGER, local_data_template_rrd_id INTEGER, data_template_id INTEGER, hash VARCHAR(32))');
|
|
$conn->exec('CREATE TABLE poller (
|
|
id INTEGER PRIMARY KEY, name VARCHAR(255), dbhost VARCHAR(255), dbuser VARCHAR(64), dbpass VARCHAR(64), dbdefault VARCHAR(64),
|
|
dbport INTEGER, dbretries INTEGER, dbssl TINYINT, dbsslkey VARCHAR(255), dbsslcert VARCHAR(255), dbsslca VARCHAR(255),
|
|
dbsslcapath VARCHAR(255), dbsslverifyservercert TINYINT, last_status DATETIME, disabled VARCHAR(3))');
|
|
$conn->exec('CREATE TABLE poller_item (local_data_id INTEGER, host_id INTEGER, poller_id INTEGER)');
|
|
$conn->exec('CREATE TABLE poller_output_boost (local_data_id INTEGER)');
|
|
|
|
// The row poller_connect_to_remote() looks up for HANDOFF_OLD_POLLER_ID.
|
|
// remote_poller_up() additionally needs a recent heartbeat + disabled=''.
|
|
// tests/Helpers/UnitStubs.php stubs read_config_option() to always
|
|
// return false, so remote_poller_up()'s $gone_time (poller_interval * 2)
|
|
// is always 0 -- "UNIX_TIMESTAMP() - UNIX_TIMESTAMP(last_status) < 0"
|
|
// can then only be true if last_status is in the future. NOW() + 60s
|
|
// keeps the check satisfied without needing to touch that shared stub.
|
|
$stmt = $conn->prepare('INSERT INTO poller
|
|
(id, name, dbhost, dbuser, dbpass, dbdefault, dbport, dbretries, dbssl,
|
|
dbsslkey, dbsslcert, dbsslca, dbsslcapath, dbsslverifyservercert, last_status, disabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL 60 SECOND, \'\')');
|
|
$stmt->execute([
|
|
HANDOFF_OLD_POLLER_ID, 'Remote Poller A', HANDOFF_OLD_POLLER_DBHOST, 'user', 'pass',
|
|
HANDOFF_OLD_POLLER_DBNAME, HANDOFF_OLD_POLLER_DBPORT, 0, 0, '', '', '', '', 0,
|
|
]);
|
|
|
|
return $conn;
|
|
}
|
|
|
|
function handoffRemoteConn(): PDO {
|
|
$conn = new PDO('sqlite::memory:');
|
|
$conn->exec('CREATE TABLE poller_item (local_data_id INTEGER, host_id INTEGER, poller_id INTEGER)');
|
|
// mirrors the subset of central's schema that the (pre-existing,
|
|
// unmodified) "push to new device" side writes to when the new device
|
|
// also happens to live on this same poller.
|
|
$conn->exec('CREATE TABLE data_local (id INTEGER PRIMARY KEY, host_id INTEGER)');
|
|
|
|
return $conn;
|
|
}
|
|
|
|
function handoffPollerItemCount(PDO $conn, string $where = '1=1'): int {
|
|
return (int) $conn->query("SELECT COUNT(*) FROM poller_item WHERE $where")->fetchColumn();
|
|
}
|
|
|
|
beforeEach(function () {
|
|
global $database_hostname, $database_port, $database_default, $database_sessions;
|
|
|
|
$this->db_globals = [$database_sessions, $database_hostname, $database_port, $database_default];
|
|
|
|
$parts = handoffMysqlDsnParts();
|
|
|
|
$database_hostname = $parts['host'];
|
|
$database_port = $parts['port'];
|
|
$database_default = $parts['db'];
|
|
|
|
$this->central = handoffCentralConn();
|
|
$this->oldRemote = handoffRemoteConn();
|
|
|
|
// db_connect_real() checks this cache by "$device:$port:$db_name" before
|
|
// building a DSN or opening a socket -- see file header.
|
|
$database_sessions = [
|
|
"$database_hostname:$database_port:$database_default" => $this->central,
|
|
HANDOFF_OLD_POLLER_DBHOST . ':' . HANDOFF_OLD_POLLER_DBPORT . ':' . HANDOFF_OLD_POLLER_DBNAME => $this->oldRemote,
|
|
];
|
|
});
|
|
|
|
// Put the default db_* connection back. Left in place, the test handles answer
|
|
// every later read_config_option() in the run and throw on Cacti's MySQL SQL,
|
|
// aborting the suite.
|
|
afterEach(function () {
|
|
global $database_sessions, $database_hostname, $database_port, $database_default;
|
|
|
|
[$database_sessions, $database_hostname, $database_port, $database_default] = $this->db_globals;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// #7521: api_data_source_change_host() moving a data source off a
|
|
// remote-managed device must purge the old remote poller's row.
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('purges the stale poller_item row from the old remote poller when a data source changes host (#7521 fix)', function () {
|
|
// old device is managed by the remote poller; new device is central
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (200, ' . HANDOFF_NEW_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
|
|
// the row as it exists on the old poller today, mid-flight
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
api_data_source_change_host([500], 200);
|
|
|
|
expect(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(0)
|
|
->and((int) $this->central->query('SELECT host_id FROM data_local WHERE id = 500')->fetchColumn())->toBe(200);
|
|
});
|
|
|
|
it('demonstrates the pre-#7521 bug: the unfixed logic leaves the old poller row behind', function () {
|
|
// Faithful reproduction of api_data_source_change_host() as filed in
|
|
// #7521 -- pushes to the new device's remote poller but never purges
|
|
// the old one. Kept local to this test file (not sourced from
|
|
// lib/api_data_source.php) purely as a regression pin: if this
|
|
// "legacy" shape ever again matched the real function, this test
|
|
// would start failing for the wrong reason and flag the drift.
|
|
$legacyChangeHost = function (array $data_sources, int $device_id) {
|
|
foreach ($data_sources as $data_source) {
|
|
db_execute_prepared('UPDATE data_local SET host_id = ? WHERE id = ?', [$device_id, $data_source]);
|
|
|
|
if (($rcnn_id = poller_push_to_remote_db_connect($device_id)) !== false) {
|
|
db_execute_prepared('UPDATE data_local SET host_id = ? WHERE id = ?', [$device_id, $data_source], true, $rcnn_id);
|
|
}
|
|
}
|
|
};
|
|
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (200, ' . HANDOFF_NEW_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
$legacyChangeHost([500], 200);
|
|
|
|
// this is the bug: the old Data Collector keeps polling item 500 forever
|
|
expect(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(1);
|
|
});
|
|
|
|
it('leaves the remote row alone when the data source stays on the same poller', function () {
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (101, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
// reassigned to a different device that is on the SAME remote poller
|
|
api_data_source_change_host([500], 101);
|
|
|
|
expect(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(1);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// #7524a: data_query_remove_disabled_items() orphan removal.
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('purges orphaned poller_item rows from the remote poller during data query re-index (#7524 fix)', function () {
|
|
$this->central->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
data_query_remove_disabled_items([500]);
|
|
|
|
expect(handoffPollerItemCount($this->central, 'local_data_id = 500'))->toBe(0)
|
|
->and(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// #7524b: data_source_to_data_template() conversion delete.
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('purges the remote poller_item row when converting a data source to a template (#7524 fix)', function () {
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
$this->central->exec("INSERT INTO data_template_data (id, local_data_id, name, active) VALUES (1, 500, 'ds', 'on')");
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
data_source_to_data_template(500, '<ds_title>');
|
|
|
|
expect((int) $this->central->query('SELECT COUNT(*) FROM data_local WHERE id = 500')->fetchColumn())->toBe(0)
|
|
->and(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(0);
|
|
});
|