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, ''); 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); });