diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c044f907..92790e09 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -62,7 +62,8 @@ plugin_syslog/ - **Read:** `syslog_db_fetch_assoc($sql)`, `syslog_db_fetch_cell($sql)` - **Write:** `syslog_db_execute($sql)`, `syslog_db_execute_prepared($sql, $params)` - **Connection:** Managed via `$syslog_cnn` global. -- **Schema:** Tables are defined/updated in `setup.php` (`syslog_setup_table_new`). +- **Schema:** Table creation is defined in `includes/schema.php` (`syslog_setup_table_new`); upgrade orchestration is in `setup.php`. +- **New Tables:** Whenever adding a Syslog table, you MUST update `cli/syslog_schema.php` in the same change. Include the table in its required-table list with the correct main/remote collector scope so `--audit` detects its absence, and ensure `--repair` and `--upgrade` can create it. Update the CLI schema regression checks accordingly. ### Cacti Integration Patterns - **Hooks:** Register hooks in `plugin_syslog_install()` in `setup.php`, but diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 04a32f9c..965f9e53 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -206,6 +206,10 @@ jobs: include/vendor/bin/pest --configuration=plugins/syslog/phpunit.xml \ --coverage-clover=plugins/syslog/coverage/clover.xml + - name: Run Syslog schema CLI regression checks + run: php tests/regression/cli_schema_upgrade_test.php + working-directory: ${{ github.workspace }}/cacti/plugins/syslog + # Whole-file coverage is meaningless here: most of the plugin only runs # inside a live Cacti. What is enforceable is that a change covers the # lines it adds. diff --git a/cli/syslog_schema.php b/cli/syslog_schema.php new file mode 100644 index 00000000..2abb82f1 --- /dev/null +++ b/cli/syslog_schema.php @@ -0,0 +1,173 @@ + 'upgrade', + 'engine' => read_config_option('syslog_install_engine') ?: 'InnoDB', + 'db_type' => 'part', + 'days' => read_config_option('syslog_install_days') + ], true); + if (!syslog_check_upgrade(true)) { + fwrite(STDERR, 'ERROR: Syslog schema upgrade failed; check the Cacti log.' . PHP_EOL); + return false; + } + + $success = syslog_schema_report_audit_results(false); + if ($success) { + print 'Syslog schema upgrade completed; all required tables exist.' . PHP_EOL; + } + + return $success; +} + +/** + * Report missing Syslog tables for the configured Data Collector without repairing them. + * + * @param bool $audit Print the audit success message for read-only runs. + * + * @return bool Whether all required tables exist and the audit queries succeeded. + */ +function syslog_schema_report_audit_results(bool $audit = true): bool { + global $config, $database_last_error; + + $tables = [ + 'syslog', 'syslog_alert', 'syslog_alert_suppression', 'syslog_incoming', + 'syslog_remove', 'syslog_reports', 'syslog_saved_searches', 'syslog_status', + 'syslog_programs', 'syslog_hosts', 'syslog_facilities', 'syslog_priorities', + 'syslog_host_facilities', 'syslog_removed', 'syslog_logs', + 'syslog_dashboards', 'syslog_dashboard_panels', 'syslog_dashboards_perm', + 'syslog_saved_searches_perm', 'syslog_device_rule', 'syslog_replication_output', + 'syslog_replication_recovery' + ]; + if ((int) $config['poller_id'] <= 1) { + $tables[] = 'syslog_replication_receipts'; + $tables[] = 'syslog_replication_collectors'; + } + + $missing = []; + foreach ($tables as $table) { + if (!syslog_db_table_exists($table, false)) { + $missing[] = $table; + } + } + + if (!empty($database_last_error)) { + fwrite(STDERR, 'ERROR: Schema check encountered a database error; check the Cacti log.' . PHP_EOL); + return false; + } + if ($missing) { + fwrite(STDERR, 'ERROR: Missing tables: ' . implode(', ', $missing) . PHP_EOL); + return false; + } + + if ($audit) { + print 'Syslog schema audit passed; all required tables exist.' . PHP_EOL; + } + return true; +} + +/** + * Display the Syslog schema utility usage and options. + * + * @return void + */ +function syslog_schema_display_help(): void { + print 'Usage: php syslog_schema.php --audit|--repair|--upgrade' . PHP_EOL; + print ' --audit Report missing tables without changing the schema.' . PHP_EOL; + print ' --repair Repair missing tables and rerun schema migrations.' . PHP_EOL; + print ' --upgrade Upgrade the schema, including missing-table repair.' . PHP_EOL; + print 'Repair and upgrade preserve existing data and preferences.' . PHP_EOL; +} diff --git a/includes/schema.php b/includes/schema.php index 39027f7d..6025938d 100644 --- a/includes/schema.php +++ b/includes/schema.php @@ -771,6 +771,8 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { /** Complete an interrupted install or upgrade without dropping existing data. */ function syslog_ensure_table_structures(): bool { + global $config; + require_once(__DIR__ . '/settings.php'); $tables = [ @@ -778,9 +780,12 @@ function syslog_ensure_table_structures(): bool { 'syslog_remove', 'syslog_reports', 'syslog_status', 'syslog_programs', 'syslog_hosts', 'syslog_facilities', 'syslog_priorities', 'syslog_host_facilities', 'syslog_removed', 'syslog_logs', 'syslog_device_rule', - 'syslog_replication_output', 'syslog_replication_receipts', - 'syslog_replication_collectors', 'syslog_replication_recovery' + 'syslog_replication_output', 'syslog_replication_recovery' ]; + if ((int) $config['poller_id'] <= 1) { + $tables[] = 'syslog_replication_receipts'; + $tables[] = 'syslog_replication_collectors'; + } $rebuilt = false; foreach ($tables as $table) { diff --git a/setup.php b/setup.php index 13d6d409..57041791 100644 --- a/setup.php +++ b/setup.php @@ -829,7 +829,7 @@ function syslog_upgrade_create_permission_realms(): bool { } /** Upgrade the Syslog schema and permissions at install/upgrade time. */ -function syslog_check_upgrade(): bool { +function syslog_check_upgrade(bool $force = false): bool { global $config, $syslogdb_default, $syslog_levels, $syslog_upgrade, $syslog_cnn; require_once(__DIR__ . '/includes/schema.php'); @@ -864,7 +864,7 @@ function syslog_check_upgrade(): bool { $version = plugin_syslog_version(); $installed = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', ['syslog']); - if ($installed !== false && version_compare((string) $installed, $version['version'], '>=')) { + if (!$force && $installed !== false && version_compare((string) $installed, $version['version'], '>=')) { syslog_notice_traditional_tables(true); include_once($config['base_path'] . '/lib/poller.php'); api_plugin_replicate_config(); diff --git a/syslog_process.php b/syslog_process.php index 333be3f2..f718014e 100644 --- a/syslog_process.php +++ b/syslog_process.php @@ -214,6 +214,7 @@ } // replicate in syslog tables sync is enabled + require_once(__DIR__ . '/includes/processing.php'); syslog_replicate_in(); } diff --git a/tests/Unit/SetupModuleBoundaryTest.php b/tests/Unit/SetupModuleBoundaryTest.php index 2d1c07f5..abfb284f 100644 --- a/tests/Unit/SetupModuleBoundaryTest.php +++ b/tests/Unit/SetupModuleBoundaryTest.php @@ -122,6 +122,14 @@ } }); +it('loads the processing module before remote CLI replication', function () { + $source = plugin_test_read_source('syslog_process.php'); + $include = strpos($source, "require_once(__DIR__ . '/includes/processing.php');"); + $call = strpos($source, 'syslog_replicate_in();'); + + expect($include)->not->toBeFalse()->toBeLessThan($call); +}); + it('registers extracted callbacks against their owning module', function () { $setup = plugin_test_read_source('setup.php'); diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index bd2376a3..d3f80b62 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -176,6 +176,9 @@ function changed_lines($base_ref) { 'syslog_counter.php', 'syslog_process.php', 'syslog_recovery.php', + // CLI bootstrap and top-level execution require Cacti; behavior is checked + // by tests/regression/cli_schema_upgrade_test.php in an isolated CLI fixture. + 'cli/syslog_schema.php', // Setup modules are loaded by Cacti hook paths rather than directly by the // isolated Pest bootstrap. Their behavior is covered by targeted tests, but // they are intentionally absent from Clover's source list. diff --git a/tests/regression/cli_schema_upgrade_test.php b/tests/regression/cli_schema_upgrade_test.php new file mode 100644 index 00000000..90b63964 --- /dev/null +++ b/tests/regression/cli_schema_upgrade_test.php @@ -0,0 +1,167 @@ + 1]; +$options_saved = []; +$settings = ['syslog' => ['syslog_rows' => ['default' => 750]]]; +$syslogdb_default = 'syslog'; +function syslog_connect() { return true; } +function cacti_sizeof($value) { return count($value); } +function read_config_option($name) { return ''; } +function set_config_option($name, $value, $force = false) { + global $options_saved; + $options_saved[$name] = $value; +} +function syslog_install_storage_engine($engine, $options) { return $engine; } +function syslog_db_fetch_row($sql) { return []; } +function syslog_db_execute($sql) { + global $queries; + $queries[] = $sql; + if (preg_match('/CREATE TABLE IF NOT EXISTS `syslog`\.`([^`]+)`/', $sql, $match)) { + $GLOBALS['existing_tables'][] = $match[1]; + } + return true; +} +function syslog_create_partitioned_syslog_table($engine, $days, $ahead) { + check($days === 0, 'Repair must preserve indefinite retention'); +} +function syslog_create_replication_output_table() {} +function syslog_create_replication_receipts_table() {} +function syslog_create_replication_collectors_table() {} +function syslog_create_replication_recovery_table() {} +function syslog_ensure_share_tables() {} +function syslog_ensure_saved_search_tables() {} +function syslog_ensure_dashboard_tables() {} +function syslog_ensure_status_table() {} +function syslog_create_device_rule_table() {} +function db_update_table($table, $definition, $create, $upgrade, $connection) { return true; } +function syslog_db_table_exists($table, $log = true) { + if (($GLOBALS['config']['poller_id'] ?? 1) > 1 && in_array($table, ['syslog_replication_receipts', 'syslog_replication_collectors'], true)) { + throw new RuntimeException('Remote schema repair required a main-only table'); + } + return !empty($GLOBALS['all_tables_exist']) || in_array($table, $GLOBALS['existing_tables'], true); +} + +for ($run = 0; $run < 2; $run++) { + syslog_setup_table_new(['upgrade_type' => 'truncate', 'engine' => 'InnoDB', 'days' => 0], true); +} +foreach ($queries as $sql) { + check(!preg_match('/\b(DROP|TRUNCATE|DELETE)\b/i', $sql), 'Repair issued destructive SQL'); +} +check(!isset($options_saved['syslog_rows']), 'Repair reset an existing preference'); +check(substr_count(implode("\n", $queries), 'INSERT INTO') === 2, 'Lookup tables must only be seeded when missing'); +$config['poller_id'] = 2; +$all_tables_exist = true; +check(syslog_ensure_table_structures(), 'Remote schema repair failed'); + +// The real configuration hook must not migrate during schema CLI bootstrap. +function syslog_config_safe() { throw new RuntimeException('CLI bootstrap ran the migration hook'); } +preg_match('/function syslog_config_insert\(\): void \{.*?^\}/ms', file_get_contents($root . '/includes/settings.php'), $hook); +eval($hook[0]); +syslog_config_insert(); + +// Run the actual CLI in an isolated Cacti layout with bootstrap/schema fixtures. +$fixture = sys_get_temp_dir() . '/syslog-cli-' . bin2hex(random_bytes(6)); +mkdir($fixture . '/include', 0700, true); +mkdir($fixture . '/plugins/syslog/cli', 0700, true); +mkdir($fixture . '/plugins/syslog/includes', 0700, true); +copy($root . '/cli/syslog_schema.php', $fixture . '/plugins/syslog/cli/syslog_schema.php'); +file_put_contents($fixture . '/include/cli_check.php', ' getenv("SYSLOG_TEST_REMOTE") ? 2 : 1];'); +file_put_contents($fixture . '/plugins/syslog/setup.php', <<<'STUB' + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [proc_close($process), $output]; +} +try { + putenv('SYSLOG_TEST_AUDIT=1'); + [$status, $output] = run_cli($fixture, ['--audit']); + check($status === 0 && strpos($output, 'audit passed') !== false, 'Read-only audit failed'); + putenv('SYSLOG_TEST_MISSING=syslog_dashboards'); + [$status, $output] = run_cli($fixture, ['--audit']); + check($status === 1 && strpos($output, 'syslog_dashboards') !== false, 'Audit did not report missing table'); + putenv('SYSLOG_TEST_MISSING'); + check(run_cli($fixture, ['--audit', '--repair'])[0] === 1, 'Audit accepted repair'); + check(run_cli($fixture, ['--audit', '--upgrade'])[0] === 1, 'Audit accepted upgrade'); + putenv('SYSLOG_TEST_REMOTE=1'); + check(run_cli($fixture, ['--audit'])[0] === 0, 'Remote audit required main-only tables'); + putenv('SYSLOG_TEST_REMOTE'); + putenv('SYSLOG_TEST_AUDIT'); + check(run_cli($fixture)[0] === 0, 'CLI repair failed'); + check(run_cli($fixture, ['--upgrade'])[0] === 0, 'CLI upgrade failed'); + check(run_cli($fixture, ['--repair', '--upgrade'])[0] === 0, 'Combined switches failed'); + check(run_cli($fixture, [])[0] === 1, 'CLI ran without an explicit action'); + foreach (['--help', '-h', '-H'] as $flag) { + [$status, $output] = run_cli($fixture, [$flag]); + check($status === 0 && strpos($output, 'Usage: php syslog_schema.php') !== false, 'CLI help failed: ' . $flag); + } + check(run_cli($fixture, ['--truncate'])[0] === 1, 'CLI accepted destructive option'); + putenv('SYSLOG_TEST_MISSING=syslog_dashboards'); + [$status, $output] = run_cli($fixture); + check($status === 1 && strpos($output, 'syslog_dashboards') !== false, 'CLI did not report missing table'); + putenv('SYSLOG_TEST_MISSING'); + putenv('SYSLOG_TEST_SQL_ERROR=1'); + check(run_cli($fixture)[0] === 1, 'CLI did not report migration SQL error'); + putenv('SYSLOG_TEST_SQL_ERROR'); + putenv('SYSLOG_TEST_UPGRADE_FAILURE=1'); + check(run_cli($fixture)[0] === 1, 'CLI ignored a failed upgrade result'); + putenv('SYSLOG_TEST_UPGRADE_FAILURE'); + putenv('SYSLOG_TEST_REMOTE=1'); + check(run_cli($fixture)[0] === 0, 'Remote CLI required main-only tables'); +} finally { + putenv('SYSLOG_TEST_UPGRADE_FAILURE'); + putenv('SYSLOG_TEST_AUDIT'); + putenv('SYSLOG_TEST_REMOTE'); + putenv('SYSLOG_TEST_SQL_ERROR'); + putenv('SYSLOG_TEST_MISSING'); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($fixture); +} +print "CLI schema upgrade regression checks passed.\n";