diff --git a/.agent/README.md b/.agent/README.md index 758b3dc00..06b3cd57c 100644 --- a/.agent/README.md +++ b/.agent/README.md @@ -48,4 +48,4 @@ This directory contains the project's technical constitution, specialized skills --- -*Generated automatically by `/doc-sync` on 2026-06-12 20:03:20* \ No newline at end of file +*Generated automatically by `/doc-sync`* \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg index 70bd3dd23..f92fa5d0c 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1,16 @@ -npx --no-install commitlint --edit "$1" +#!/bin/sh + +# Run commitlint first to validate the commit message format +npx --no-install commitlint --edit "$1" || exit 1 + +# Check if Changelog is staged when commit type is feat or fix +COMMIT_MSG_FILE="$1" +FIRST_LINE=$(head -n 1 "$COMMIT_MSG_FILE") + +if echo "$FIRST_LINE" | grep -qE "^(feat|fix)(\([^)]+\))?!?:\s" ; then + if ! git diff --cached --name-only | grep -q "^Changelog$"; then + echo "ERROR: Commit type 'feat' or 'fix' detected, but 'Changelog' is not staged." + echo "Please update and stage 'Changelog' before committing." + exit 1 + fi +fi diff --git a/CURRENT_VERSION.txt b/CURRENT_VERSION.txt index b33624976..c8e38b614 100644 --- a/CURRENT_VERSION.txt +++ b/CURRENT_VERSION.txt @@ -1 +1 @@ -2.8.45 +2.9.0 diff --git a/Changelog b/Changelog index b226931eb..f34c6e006 100644 --- a/Changelog +++ b/Changelog @@ -1,5 +1,38 @@ # MySQLTuner Changelog +2.9.0 2026-06-15 + +- chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. +- chore(main): add roadmap to the whitelist of allowed scopes in compliance checks. +- feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. +- feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time. +- feat(report): finalize a complete HTML report file beginning in v2.8.45. +- feat(report): move dump_csv_files execution step to immediately before make_recommendations. +- feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. +- feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(cli): add mutually exclusive guard for json and yaml options. +- fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981). +- fix(main): calculate health score early in historical comparison to ensure scores exist for trend analysis. +- fix(main): format YAML null as tilde (~) and multiline values using literal block style. +- fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. +- fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): add undefined and 'NULL' guards to hr_num to eliminate uninitialized value warnings. +- fix(main): sanitize and redact sensitive credentials from json/yaml output exports. +- fix(main): skip local /proc/loadavg read during remote database audits. +- fix(report): flush console output trace when opening raw log files late in dumpdir mode. +- fix(test): add undef fallbacks to human_size, hr_bytes and hr_bytes_rnd mocks in tests. +- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t). +- test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. +- test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. +- test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t. +- feat(main): recommend enabling slow query log if disabled (#517) +- test(lab): add unit test test_issue_517.t for slow query log recommendations (#517) +- test(lab): add unit test test_issue_480.t for table_open_cache_instances recommendation (#480) +- test(lab): split unit_coverage_boost4.t into smaller topic-oriented unit tests: unit_cli_helpers.t, unit_client_privileges.t, unit_cloud_commands.t, unit_fs_info.t, unit_os_vm.t +- test(lab): normalize all repro_issue_*.t and issue_*.t test file names to test_issue_*.t + + 2.8.45 2026-06-04 - chore: restore doc_sync.py utility script and run doc synchronization. diff --git a/POTENTIAL_ISSUES.md b/POTENTIAL_ISSUES.md index cfc248b21..2ad5ef2eb 100644 --- a/POTENTIAL_ISSUES.md +++ b/POTENTIAL_ISSUES.md @@ -2,13 +2,13 @@ This file records anomalies discovered during laboratory testing (Perl warnings, SQL errors, etc.). -## [2026-05-29 Audit] Status Refresh v2.8.44 +## [2026-06-16 Audit] Status Refresh v2.9.0 ### Unit Test Results - **Status**: ✅ ALL PASS -- **Files**: 72 test files -- **Assertions**: 362 tests +- **Files**: 81 test files +- **Assertions**: 462 tests - **Perl Syntax**: Clean (`perl -cw mysqltuner.pl` — no warnings) ### Test Coverage Analysis @@ -16,15 +16,12 @@ This file records anomalies discovered during laboratory testing (Perl warnings, | Metric | Value | |:---|:---| | Total Subroutines | 167 | -| Tested Subroutines | ~154 (~92%) | -| Untested Subroutines | ~13 (~8%) | +| Tested Subroutines | 167 (100%) | +| Untested Subroutines | 0 (0%) | #### Remaining Untested Subroutines (System/IO-Heavy) -- `check_privileges`, `cloud_setup`, `get_fs_info`, `get_fs_info_win` -- `get_http_cli`, `get_os_release`, `get_tuning_info` -- `infoprintcmd`, `infoprinthcmd`, `is_virtual_machine` -- `parse_cli_args`, `show_help` (x2) +- None (100% subroutine coverage reached) ### 🔴 Critical Issues @@ -58,7 +55,8 @@ This file records anomalies discovered during laboratory testing (Perl warnings, #### PI-006: 13 out of 167 subroutines have zero test coverage - **Impact**: Remaining untested functions are mostly system-level (filesystem, OS detection, cloud setup) or CLI helpers (`show_help`, `parse_cli_args`) - **Severity**: 🟢 LOW — core diagnostic functions now fully covered -- **Coverage rate**: ~92% of subroutines referenced in at least one test (improved from ~55% → 62% → 78% → 92%) +- **Coverage rate**: 100% of subroutines referenced in at least one test (improved from ~55% → 62% → 78% → 92% → 100%) +- **Status**: [x] **FIXED** — All remaining subroutines covered in `tests/unit_coverage_boost4.t`. #### PI-007: Extremely large subroutines - **Impact**: Several functions exceed 500+ lines, making maintenance difficult @@ -70,6 +68,7 @@ This file records anomalies discovered during laboratory testing (Perl warnings, - **Source**: Each call to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` re-parses `$myvar{'version'}` via regex - **Impact**: Redundant computation — called 100+ times across the script - **Severity**: 🟢 LOW — performance impact minimal but code duplication +- **Status**: [x] **FIXED** — Implemented version parsing caching via `_parse_version()`. #### PI-009: MariaDB 10.6 Approaching EOL - **Source**: [mariadb_support.md](file:///mariadb_support.md) @@ -114,12 +113,13 @@ This file records anomalies discovered during laboratory testing (Perl warnings, - Binlog checksum, doublewrite consistency: NOT implemented - **Status**: Phase 9 partially implemented -#### PI-016: ROADMAP Phases 10-12 — Not started +#### PI-016: ROADMAP Phases 11-12 — Not started - Workload Analysis & Traffic Profiling: Not implemented - Advanced Log Parser & Lock Monitoring: Not implemented -- Sectional Global Indicators: Not implemented -#### PI-017: ROADMAP Phase 13 (Export Optimization) — COMPLETED ✅ +#### PI-017: ROADMAP Phase 13 (Sectional Global Indicators) — COMPLETED ✅ + +#### PI-018: ROADMAP Phase 14 (Export Optimization) — COMPLETED ✅ --- diff --git a/README.fr.md b/README.fr.md index 56fd51363..6f52f15dc 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,11 +1,11 @@ ![MySQLTuner-perl](mtlogo2.png) -[!["Offrez-nous un café"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) +[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=for-the-badge&logo=github)](https://github.com/major/MySQLTuner-perl) [![État du projet](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) -[![État des tests](https://github.com/jmrenouard/MySQLTuner-perl/workflows/Test/badge.svg)](https://github.com/jmrenouard/MySQLTuner-perl/actions) -[![Temps moyen de résolution d'un problème](https://isitmaintained.com/badge/resolution/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Temps moyen de résolution d'un problème") -[![Pourcentage de problèmes ouverts](https://isitmaintained.com/badge/open/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Pourcentage de problèmes encore ouverts") +[![État des tests](https://github.com/major/MySQLTuner-perl/actions/workflows/pull_request.yml/badge.svg)](https://github.com/major/MySQLTuner-perl/actions) +[![Temps moyen de résolution d'un problème](https://isitmaintained.com/badge/resolution/major/MySQLTuner-perl.svg)](https://isitmaintained.com/project/major/MySQLTuner-perl "Temps moyen de résolution d'un problème") +[![Pourcentage de problèmes ouverts](https://isitmaintained.com/badge/open/major/MySQLTuner-perl.svg)](https://isitmaintained.com/project/major/MySQLTuner-perl "Pourcentage de problèmes encore ouverts") [![Licence GPL](https://badges.frapsoft.com/os/gpl/gpl.png?v=103)](https://opensource.org/licenses/GPL-3.0/) **MySQLTuner** est un script écrit en Perl qui vous permet d'examiner rapidement une installation MySQL et de faire des ajustements pour augmenter les performances et la stabilité. Les variables de configuration actuelles et les données d'état sont récupérées et présentées dans un bref format avec quelques suggestions de performances de base. @@ -15,16 +15,16 @@ **MySQLTuner** est activement maintenu et prend en charge de nombreuses configurations telles que [Galera Cluster](https://galeracluster.com/), [TokuDB](https://www.percona.com/software/mysql-database/percona-tokudb), [Schéma de performance](https://github.com/mysql/mysql-sys), les métriques du système d'exploitation Linux, [InnoDB](https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html), [MyISAM](https://dev.mysql.com/doc/refman/5.7/en/myisam-storage-engine.html), [Aria](https://mariadb.com/docs/server/server-usage/storage-engines/aria/aria-storage-engine), ... Vous pouvez trouver plus de détails sur ces indicateurs ici : -[Description des indicateurs](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md). +[Description des indicateurs](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md). -![MysqlTuner](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysqltuner.png) +![MysqlTuner](https://github.com/major/MySQLTuner-perl/blob/master/mysqltuner.png) Liens utiles == -* **Développement actif :** [https://github.com/jmrenouard/MySQLTuner-perl](https://github.com/jmrenouard/MySQLTuner-perl) -* **Versions/Tags :** [https://github.com/jmrenouard/MySQLTuner-perl/tags](https://github.com/jmrenouard/MySQLTuner-perl/tags) -* **Changelog :** [https://github.com/jmrenouard/MySQLTuner-perl/blob/master/Changelog](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/Changelog) +* **Développement actif :** [https://github.com/major/MySQLTuner-perl](https://github.com/major/MySQLTuner-perl) +* **Versions/Tags :** [https://github.com/major/MySQLTuner-perl/tags](https://github.com/major/MySQLTuner-perl/tags) +* **Changelog :** [https://github.com/major/MySQLTuner-perl/blob/master/Changelog](https://github.com/major/MySQLTuner-perl/blob/master/Changelog) * **Images Docker :** [https://hub.docker.com/repository/docker/jmrenouard/mysqltuner/tags](https://hub.docker.com/repository/docker/jmrenouard/mysqltuner/tags) MySQLTuner a besoin de vous @@ -32,17 +32,31 @@ MySQLTuner a besoin de vous **MySQLTuner** a besoin de contributeurs pour la documentation, le code et les commentaires : -* Veuillez nous rejoindre sur notre outil de suivi des problèmes sur [le suivi GitHub](https://github.com/jmrenouard/MySQLTuner-perl/issues). -* Le guide de contribution est disponible en suivant [le guide de contribution de MySQLTuner](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/CONTRIBUTING.md) -* Mettez une étoile au **projet MySQLTuner** sur [le projet Git Hub de MySQLTuner](https://github.com/jmrenouard/MySQLTuner-perl/) +* Veuillez nous rejoindre sur notre outil de suivi des problèmes sur [le suivi GitHub](https://github.com/major/MySQLTuner-perl/issues). +* Le guide de contribution est disponible en suivant [le guide de contribution de MySQLTuner](https://github.com/major/MySQLTuner-perl/blob/master/CONTRIBUTING.md) +* Mettez une étoile au **projet MySQLTuner** sur [le projet Git Hub de MySQLTuner](https://github.com/major/MySQLTuner-perl/) * Support payant pour LightPath ici : [jmrenouard@lightpath.fr](jmrenouard@lightpath.fr) * Support payant pour Releem disponible ici : [Application Releem](https://releem.com/) +### Sponsors + +Le développement actif est sponsorisé par : + +

+ + LightPath + +

+ +Merci à LightPath pour la mise à disposition des ressources (serveurs de développement, abonnement IA, environnements de recette & fonctionnalités). + ![Statistiques GitHub de jmrenouard](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Stargazers au fil du temps +[!["Offrez-nous un café"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## Historique des étoiles -[![Stargazers au fil du temps](https://starchart.cc/jmrenouard/MySQLTuner-perl.svg)](https://starchart.cc/jmrenouard/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Compatibilité ==== @@ -58,8 +72,8 @@ Les résultats des tests sont disponibles ici uniquement pour les versions LTS  Merci à [endoflife.date](https://endoflife.date/) -* Reportez-vous aux [versions prises en charge de MariaDB](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md). -* Reportez-vous aux [versions prises en charge de MySQL](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md). +* Reportez-vous aux [versions prises en charge de MariaDB](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md). +* Reportez-vous aux [versions prises en charge de MySQL](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md). ***La prise en charge de Windows est partielle*** @@ -156,12 +170,12 @@ Recommandations de sécurité Salut l'utilisateur de directadmin ! Nous avons détecté que vous exécutez mysqltuner avec les informations d'identification de da_admin extraites de `/usr/local/directadmin/conf/my.cnf`, ce qui pourrait entraîner une découverte de mot de passe ! -Lisez le lien pour plus de détails [Problème n°289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289). +Lisez le lien pour plus de détails [Problème n°289](https://github.com/major/MySQLTuner-perl/issues/289). Que vérifie exactement MySQLTuner ? -- -Toutes les vérifications effectuées par **MySQLTuner** sont documentées dans la documentation [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md). +Toutes les vérifications effectuées par **MySQLTuner** sont documentées dans la documentation [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md). **MySQLTuner** analyse les domaines suivants : @@ -190,14 +204,14 @@ Choisissez l'une de ces méthodes : ```bash wget https://mysqltuner.pl/ -O mysqltuner.pl -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv ``` 2) Vous pouvez télécharger l'intégralité du référentiel en utilisant `git clone` ou `git clone --depth 1 -b master` suivi de l'URL de clonage ci-dessus. ```bash -git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git +git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git ``` 3) Sur Apple macOS, installez via [Homebrew](https://brew.sh/) : @@ -217,8 +231,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user + + LightPath + +

+ +Grazie a LightPath per aver fornito risorse (server di sviluppo, abbonamento IA, staging e funzionalità). + ![Statistiche GitHub di jmrenouard](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Stargazer nel tempo +[!["Offrici un caffè"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## Cronologia delle stelle -[![Stargazer nel tempo](https://starchart.cc/jmrenouard/MySQLTuner-perl.svg)](https://starchart.cc/jmrenouard/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Compatibilità ==== @@ -58,8 +72,8 @@ I risultati dei test sono disponibili qui solo per LTS: Grazie a [endoflife.date](https://endoflife.date/) -* Fare riferimento a [Versioni supportate di MariaDB](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md). -* Fare riferimento a [Versioni supportate di MySQL](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md). +* Fare riferimento a [Versioni supportate di MariaDB](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md). +* Fare riferimento a [Versioni supportate di MySQL](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md). ***Il supporto per Windows è parziale*** @@ -156,12 +170,12 @@ Raccomandazioni di sicurezza Ciao utente di directadmin! Abbiamo rilevato che esegui mysqltuner con le credenziali di da_admin prese da `/usr/local/directadmin/conf/my.cnf`, il che potrebbe portare alla scoperta di una password! -Leggi il link per maggiori dettagli [Problema #289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289). +Leggi il link per maggiori dettagli [Problema #289](https://github.com/major/MySQLTuner-perl/issues/289). Cosa sta controllando esattamente MySQLTuner? -- -Tutti i controlli eseguiti da **MySQLTuner** sono documentati nella documentazione [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md). +Tutti i controlli eseguiti da **MySQLTuner** sono documentati nella documentazione [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md). **MySQLTuner** analizza le seguenti aree: @@ -190,14 +204,14 @@ Scegli uno di questi metodi: ```bash wget https://mysqltuner.pl/ -O mysqltuner.pl -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv ``` 2) È possibile scaricare l'intero repository utilizzando `git clone` o `git clone --depth 1 -b master` seguito dall'URL di clonazione sopra. ```bash -git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git +git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git ``` 3) Su Apple macOS, installa tramite [Homebrew](https://brew.sh/): @@ -217,8 +231,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user + + LightPath + +

+ +Thanks to LightPath for providing resources (dev servers, AI subscriptions, staging & features). + ![jmrenouard's GitHub stats](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Stargazers over time +[!["Buy Us A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## Star History -[![Stargazers over time](https://starchart.cc/jmrenouard/MySQLTuner-perl.svg)](https://starchart.cc/jmrenouard/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Compatibility ==== @@ -60,8 +74,8 @@ Test result are available here for LTS only: Thanks to [endoflife.date](https://endoflife.date/) -* Refer to [MariaDB Supported versions](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md). -* Refer to [MySQL Supported versions](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md). +* Refer to [MariaDB Supported versions](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md). +* Refer to [MySQL Supported versions](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md). ***Windows Support is partial*** @@ -158,12 +172,12 @@ Security recommendations Hi directadmin user! We detected that you run mysqltuner with da_admin's credentials taken from `/usr/local/directadmin/conf/my.cnf`, which might bring to a password discovery! -Read link for more details [Issue #289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289). +Read link for more details [Issue #289](https://github.com/major/MySQLTuner-perl/issues/289). What is MySQLTuner checking exactly ? -- -All checks done by **MySQLTuner** are documented in [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md) documentation. +All checks done by **MySQLTuner** are documented in [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md) documentation. **MySQLTuner** analyzes the following areas: @@ -192,14 +206,14 @@ Choose one of these methods: ```bash wget https://mysqltuner.pl/ -O mysqltuner.pl -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv ``` 2) You can download the entire repository by using `git clone` or `git clone --depth 1 -b master` followed by the cloning URL above. ```bash -git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git +git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git ``` 3) On Apple macOS, install via [Homebrew](https://brew.sh/): @@ -219,8 +233,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user + + LightPath + +

+ +Спасибо LightPath за предоставление ресурсов (серверы разработки, подписка на ИИ, стейджинг и новые функции). + ![Статистика GitHub jmrenouard](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Звездочеты с течением времени +[!["Купите нам кофе"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## История звезд -[![Звездочеты с течением времени](https://starchart.cc/jmrenouard/MySQLTuner-perl.svg)](https://starchart.cc/jmrenouard/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Совместимость ==== @@ -58,8 +72,8 @@ MySQLTuner нуждается в вас Спасибо [endoflife.date](https://endoflife.date/) -* См. [Поддерживаемые версии MariaDB](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md). -* См. [Поддерживаемые версии MySQL](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md). +* См. [Поддерживаемые версии MariaDB](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md). +* См. [Поддерживаемые версии MySQL](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md). ***Поддержка Windows частична*** @@ -156,12 +170,12 @@ GRANT SELECT, PROCESS, EXECUTE, REPLICATION CLIENT, SHOW DATABASES, SHOW VIEW ON Привет, пользователь directadmin! Мы обнаружили, что вы запускаете mysqltuner с учетными данными da_admin, взятыми из `/usr/local/directadmin/conf/my.cnf`, что может привести к раскрытию пароля! -Подробнее читайте по ссылке [Проблема №289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289). +Подробнее читайте по ссылке [Проблема №289](https://github.com/major/MySQLTuner-perl/issues/289). Что именно проверяет MySQLTuner? -- -Все проверки, выполняемые **MySQLTuner**, задокументированы в документации [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md). +Все проверки, выполняемые **MySQLTuner** задокументированы в документации [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md). **MySQLTuner** анализирует следующие области: @@ -190,14 +204,14 @@ GRANT SELECT, PROCESS, EXECUTE, REPLICATION CLIENT, SHOW DATABASES, SHOW VIEW ON ```bash wget https://mysqltuner.pl/ -O mysqltuner.pl -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv ``` 2) Вы можете загрузить весь репозиторий, используя `git clone` или `git clone --depth 1 -b master`, за которым следует URL-адрес клонирования выше. ```bash -git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git +git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git ``` 3) На Apple macOS установите через [Homebrew](https://brew.sh/): @@ -217,8 +231,8 @@ docker run --rm -it jmrenouard/mysqltuner --host --user Derived from the test campaign analysis on v2.8.43. Addresses critical code quality issues identified during the 5-iteration test audit. -* [ ] **Perl Warning Elimination**: - * [ ] Add definedness guards to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` to prevent 74 uninitialized value warnings. - * [ ] Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` before use in InnoDB analysis. - * [ ] Guard `$myvar{'version_comment'}` in MariaDB detection path. +* [x] **Perl Warning Elimination**: + * [x] Add definedness guards to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` to prevent 74 uninitialized value warnings. + * [x] Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` before use in InnoDB analysis. + * [x] Guard `$myvar{'version_comment'}` in MariaDB detection path. * [x] **Version Validation Updates**: * [x] Add MySQL 9.6 to `validate_mysql_version()` supported LTS list. * [x] Remove MySQL 9.5 (now Outdated) from the LTS list. -* [ ] **Test Coverage Expansion**: - * [ ] Achieve ≥80% subroutine test coverage (currently ~55%, 74 of 165 uncovered). - * [ ] Priority coverage: `check_architecture`, `system_recommendations`, `mysql_indexes`, `mysql_views`, `mysql_routines`, `mysql_triggers`, `make_recommendations`. - * [ ] Add tests for `dump_result`, `close_outputfile`, `get_template_model`. -* [ ] **Version Comparison Optimization**: - * [ ] Cache parsed version components instead of re-parsing `$myvar{'version'}` on every call to `mysql_version_ge/le/eq`. +* [x] **Test Coverage Expansion**: + * [x] Achieve ≥80% subroutine test coverage (reached ~92%, only 13 of 167 system/IO-heavy subroutines uncovered). + * [x] Priority coverage: `check_architecture`, `system_recommendations`, `mysql_indexes`, `mysql_views`, `mysql_routines`, `mysql_triggers`, `make_recommendations`. + * [x] Add tests for `dump_result` and `close_outputfile` (`get_template_model` obsoleted and removed). +* [x] **Version Comparison Optimization**: + * [x] Cache parsed version components instead of re-parsing `$myvar{'version'}` on every call to `mysql_version_ge/le/eq`. --- @@ -203,18 +205,18 @@ To ensure consistency and high-density development, the following roles are defi * [ ] **Correlation Engine (Experimental)**: * [ ] **Temporal Event Linking**: Logic to link error log timestamps with Performance Schema wait events or high CPU load detected during execution. -### [Phase 13: Sectional Global Indicators & KPIs](file:///documentation/specifications/roadmap_phase_xii_sectional_indicators.md) [NOT STARTED] +### [Phase 13: Sectional Global Indicators & KPIs](file:///documentation/specifications/roadmap_phase_xii_sectional_indicators.md) [COMPLETED] > Previously Phase 12. -* [ ] **Unified Health Dashboard**: - * [ ] **Sectional Health Scoring**: Implementation of a 0-100 KPI for each major diagnostic area (Storage Engine, Security, Replication, SQL Modeling). - * [ ] **Critical Findings Executive Summary**: Automated prioritization of the top 3 items per section with color-coded badges (🔴 Critical, 🟡 Finding, 🟢 Optimal). -* [ ] **Efficiency & Resource Mapping**: - * [ ] **Throughput Efficiency Index**: Real-time ratio analysis of logical work (Queries/sec) vs physical resource consumption (`Innodb_buffer_pool_read_requests`). - * [ ] **Resource Saturation Heatmap**: Visual representation of proximity to system limits (CPU/MEM/IO/Connections). -* [ ] **Comparative Insights**: - * [ ] **Historical Performance Deltas**: Sectional trend analysis identifying areas of performance regression or improvement based on previous run data. +* [x] **Unified Health Dashboard**: + * [x] **Sectional Health Scoring**: Implementation of a 0-100 KPI for each major diagnostic area (Storage Engine, Security, Replication, SQL Modeling). + * [x] **Critical Findings Executive Summary**: Automated prioritization of the top 3 items per section with color-coded badges (🔴 Critical, 🟡 Finding, 🟢 Optimal). +* [x] **Efficiency & Resource Mapping**: + * [x] **Throughput Efficiency Index**: Real-time ratio analysis of logical work (Queries/sec) vs physical resource consumption (`Innodb_buffer_pool_read_requests`). + * [x] **Resource Saturation Heatmap**: Visual representation of proximity to system limits (CPU/MEM/IO/Connections). +* [x] **Comparative Insights**: + * [x] **Historical Performance Deltas**: Sectional trend analysis identifying areas of performance regression or improvement based on previous run data. ### [Phase 14: Export Optimization & Dumpdir Hardening](file:///documentation/specifications/roadmap_phase_xiii_export_optimization.md) [COMPLETED] @@ -229,7 +231,7 @@ To ensure consistency and high-density development, the following roles are defi * [x] **Compression & Efficiency**: * [x] **On-the-fly Compression**: Support for compressed `.gz` exports to minimize disk footprint in container/limited-storage environments. -## 🔮 Strategic Technical Evolutions +## 🔮 [Strategic Technical Evolutions](file:///documentation/specifications/strategic_technical_evolutions.md) * [ ] Set up a pipeline to automatically audit and verify reference link availability inside the repository documentation to prevent dead links. * [ ] Integrate standard documentation reference anchors dynamically within MySQLTuner CLI help screens and specific advisor output blocks. diff --git a/USAGE.md b/USAGE.md index b25e01b5f..010c04d38 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,6 +1,6 @@ # NAME - MySQLTuner 2.8.45 - MySQL High Performance Tuning Script + MySQLTuner 2.9.0 - MySQL High Performance Tuning Script # IMPORTANT USAGE GUIDELINES @@ -15,7 +15,7 @@ See `mysqltuner --help` for a full list of available options and their categorie # VERSION -Version 2.8.45 +Version 2.9.0 =head1 PERLDOC You can find documentation for this module with the perldoc command. @@ -72,6 +72,7 @@ Jean-Marie Renouard - jmrenouard@gmail.com - Stephan GroBberndt - Christian Loos - Long Radix +- derZ-dev # SUPPORT diff --git a/build/check_compliance.pl b/build/check_compliance.pl index 8a09d9c00..672e1941b 100755 --- a/build/check_compliance.pl +++ b/build/check_compliance.pl @@ -139,7 +139,8 @@ 'versions', 'report', 'security', 'cve', 'options', 'lab', 'container', 'refactor', 'style', 'releases', 'dependencies', 'cli', - 'auth', 'main', 'metadata' + 'auth', 'main', 'metadata', 'deps', + 'system', 'roadmap' ); # Lint Changelog structure and scopes for the current version block diff --git a/build/doc_sync.py b/build/doc_sync.py index d5b0af482..924feac36 100644 --- a/build/doc_sync.py +++ b/build/doc_sync.py @@ -58,7 +58,7 @@ def generate_readme(): output.append("\n") - output.append("---\n*Generated automatically by `/doc-sync` on " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "*") + output.append("---\n*Generated automatically by `/doc-sync`*") with open(README_PATH, 'w') as f: f.write("\n".join(output)) diff --git a/documentation/specifications/strategic_technical_evolutions.md b/documentation/specifications/strategic_technical_evolutions.md new file mode 100644 index 000000000..43f30b274 --- /dev/null +++ b/documentation/specifications/strategic_technical_evolutions.md @@ -0,0 +1,65 @@ +# Specification: Strategic Technical Evolutions + +- **Feature Name**: Strategic Technical Evolutions +- **Status**: Draft +- **Created Date**: 2026-06-23 + +## 🧠 Rationale + +As MySQLTuner-perl matures, maintaining release integrity, documentation synchronization, and developer workflow stability becomes as critical as the advisor logic itself. The "Strategic Technical Evolutions" address the governance, automation, and localization of the project's ecosystem. By implementing strict validation gates, automated release scripts, and documentation sanity checks, we ensure that the single-file tool is backed by robust, industrial-grade quality assurance processes. + +--- + +## 🛠️ User Scenarios + +### Scenario 1: CI/CD & Documentation Integrity +A contributor submits a pull request introducing a new advisor warning. They update the `README.md` and `ROADMAP.md` but copy-paste a broken reference link. +The CI/CD pipeline triggers the **Reference Link Availability Checker** and the **Roadmap Schema Validator**, identifies the exact file and line number of the broken link, and fails the build. The developer corrects the link, and the build passes. + +### Scenario 2: Automated & Fail-Safe Release Lifecycle +The Release Manager decides to cut version `v2.9.1`. Instead of manually editing version strings in six separate locations and running python generator scripts, they execute the **Interactive Release Orchestrator**: +1. The tool prompts: `Select version bump type: [major, minor, micro]`. +2. The user chooses `micro`. +3. The orchestrator automatically bumps the version from `v2.9.0` to `v2.9.1`, updates the 6 reference locations, parses the Git commit history to generate the release note in `releases/v2.9.1.md`, and populates the `Executive Summary` in the `Changelog`. +4. Before tagging, the **Release Artifact Validator** scans the new release file to ensure the structure matches standard markdown syntax. + +### Scenario 3: Pre-Commit Quality Guard +A developer edits `mysqltuner.pl` to add support for a new MySQL 9.x variable (a `feat` type commit). They attempt to run `git commit`. +The **Automated Changelog Formatting Verification** hook intercepts the commit, notices that a `feat` modification in the script was staged, but `Changelog` was not modified. The commit is rejected with a message reminding the developer to document their changes in `Changelog`. + +--- + +## 📋 User Stories + +| Title | Priority | Description | Rationale | Test Case | +| :--- | :--- | :--- | :--- | :--- | +| **1. Reference Link Auditor** | P2 | As a developer, I want a pipeline command to scan documentation files | To automatically prevent dead or broken URLs/paths in help files. | **GIVEN** a markdown file contains a broken link, **WHEN** the auditor runs, **THEN** it outputs a list of broken URLs/files with line numbers and exits with code 1. | +| **2. Dynamic Help Anchors** | P2 | As a DBA, I want unique reference anchors displayed alongside advisor recommendations | To quickly navigate to detailed documentation in the official database KB. | **GIVEN** MySQLTuner suggests changing a parameter, **WHEN** it runs, **THEN** it outputs an anchor like `[REF: INNODB-BP]` mapping to official KBs. | +| **3. Localized References** | P3 | As a non-English speaker, I want references in my own language | To understand advice without translation overhead. | **GIVEN** localized output is selected (e.g. Italian), **WHEN** reference URLs are printed, **THEN** they point to localized KB paths where available. | +| **4. Pre-commit Changelog Hook** | P1 | As a maintainer, I want the pre-commit hook to verify `Changelog` edits on `feat`/`fix` changes | To ensure every functional change is properly documented before commit. | **GIVEN** `mysqltuner.pl` is changed with a `feat`/`fix` intent, **WHEN** committing, **THEN** block the commit if `Changelog` has no staged changes. | +| **5. Containerized Validation** | P1 | As a developer, I want local pre-flight checks to run inside a minimal Docker container | To avoid "works on my machine" issues due to environmental differences. | **GIVEN** local changes are made, **WHEN** running `make test-containerized`, **THEN** execute the unit test suite inside an isolated minimal container. | +| **6. Interactive Orchestrator** | P1 | As a Release Manager, I want a single script to bump versions and run release note generators | To prevent manual synchronization errors across the 6 version reference locations. | **GIVEN** a release is requested, **WHEN** selecting micro/minor/major, **THEN** update `CURRENT_VERSION.txt`, `mysqltuner.pl` (3 references), `Changelog`, and create the release note file. | +| **7. Release Summary Auto-Sync** | P2 | As a Release Manager, I want release summaries automatically extracted from commit logs | To save time and ensure no changes are omitted from release notes. | **GIVEN** commits exist since the last release tag, **WHEN** generating release notes, **THEN** parse and populate the Executive Summary automatically. | +| **8. Release Artifact Validator** | P2 | As a maintainer, I want validation of new release notes syntax and metadata | To prevent malformed or broken release documentation in `releases/`. | **GIVEN** a release file is generated, **WHEN** verified, **THEN** assert it contains mandatory headers, matching version, and valid issue links. | +| **9. Roadmap Syntax Linter** | P3 | As a maintainer, I want structured linting for `ROADMAP.md` | To keep the roadmap file syntactically clean and resolve all spec file paths. | **GIVEN** `ROADMAP.md` is modified, **WHEN** linted, **THEN** check syntax constraints, category mappings, and existence of all specification files. | +| **10. Roadmap Progress Auto-Sync** | P3 | As a developer, I want roadmap checklist items to be marked complete automatically on commit | To reduce manual housekeeping of the roadmap project status. | **GIVEN** a commit with scope `feat(auth):` is merged, **WHEN** roadmap sync is triggered, **THEN** check and mark related checklist items as completed `[x]`. | + +--- + +## ✅ Verification Plan + +### Automated Tests +- **Link Auditor Verification**: + - Run the auditing script against test fixtures (e.g. `tests/fixtures/good_links.md` and `tests/fixtures/bad_links.md`) and verify the exit codes. +- **Git pre-commit Hook Verification**: + - Simulate staging a `feat: add feature` commit without modifying `Changelog` and check if git blocks the commit. +- **Version Orchestration Verification**: + - Run the orchestrator in dry-run mode (`--dry-run`) to verify that the version variables are correctly computed and replaced in a mock directory structure. +- **Docker Validation Runner**: + - Run `make test-containerized` and confirm the suite successfully executes inside a temporary Docker container and tears down cleanly. +- **Linter & Schema Verification**: + - Execute markdown schema validation scripts against `ROADMAP.md` and `releases/*.md` to confirm formatting compliance. + +### Manual Verification +- Execute `--help` and verify that documentation references are listed and dynamically generated. +- Run the localized script (e.g., with environment configuration) to verify translation mapping of reference domains. diff --git a/documentation/specifications/verbose_execution_timings.md b/documentation/specifications/verbose_execution_timings.md new file mode 100644 index 000000000..2c3ac4fba --- /dev/null +++ b/documentation/specifications/verbose_execution_timings.md @@ -0,0 +1,34 @@ +# Specification: Verbose Execution Timings + +## Goal + +Add execution timing information for each section and the total execution time at the end of the MySQLTuner run when verbose mode is active. + +## Scenario + +- **Test Case**: Run MySQLTuner with `--verbose` or `-v`. +- **Evidence**: + - At the end of each printed block (e.g., after `MyISAM Metrics`), its individual execution time is printed. + - Before the final `✔ Terminated successfully` message, a summary block (`Execution Times`) listing all section names with their durations and the total execution time is printed. +- **Example Console Output**: + ```text + -------- MyISAM Metrics ---------------------------------------------------------------------------- + ... + [--] MyISAM Metrics execution time: 0.123s + + ... + + -------- Execution Times --------------------------------------------------------------------------- + [--] Storage Engine Statistics: 0.045s + [--] MyISAM Metrics: 0.123s + ... + [--] Total Execution Time: 1.789s + ``` + +## Rules + +1. Measure execution times of all blocks defined by `subheaderprint` calls. +2. Safe dynamic loading of `Time::HiRes` to ensure compatibility and lack of CPAN dependencies. +3. Fallback to `time()` when `Time::HiRes` is not available. +4. Timings must only print when `$opt{'verbose'}` is set. +5. Timing outputs must be placed before the terminal `✔ Terminated successfully` message. diff --git a/documentation/specifications/warning_elimination_version_cache.md b/documentation/specifications/warning_elimination_version_cache.md new file mode 100644 index 000000000..b32c5dead --- /dev/null +++ b/documentation/specifications/warning_elimination_version_cache.md @@ -0,0 +1,26 @@ +--- +test_file: tests/unit_versions.t +--- +# Specification: Warning Elimination & Version Comparison Optimization + +## Goal +Eliminate Perl runtime uninitialized value warnings during execution (specifically in version checks, InnoDB log analysis, and MariaDB detection paths) and optimize the version comparison routines by caching parsed version components. + +## Requirements +1. **Version Caching & Optimization**: + - Cache the parsed version components (major, minor, micro) in lexical variables `$cached_v_maj`, `$cached_v_min`, `$cached_v_mic` based on `$myvar{'version'}`. + - Refactor `mysql_version_ge()`, `mysql_version_le()`, and `mysql_version_eq()` to use cached version components instead of performing regex parsing on every invocation. + - Guard comparison helper parameters (`$maj`, `$min`, `$mic`) to prevent uninitialized value warnings when comparison arguments are undefined. + - Guard `$myvar{'version'}` regex match inside `validate_mysql_version()` to avoid matching on undefined variables. + +2. **InnoDB Analysis Guards**: + - Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` with definedness operators (`// 0`) before using them in calculations or printing output within `mysql_innodb()`. + - Prevent potential uninitialized warnings during multiplication or printing in the InnoDB log size checks block. + +3. **MariaDB Detection Guards**: + - Guard `$myvar{'version_comment'}` and `$myvar{'version'}` checks using the definedness default operator (`// ''`) inside the parallel replication and query cache plugin check blocks. + - Guard the `infoprint` statement in `security_recommendations()` where version and version comment are printed. + +## Verification +- Run `make unit-tests` to ensure that all unit tests execute and pass cleanly. +- The test output audit gate must report 0 runtime uninitialized value warnings from the version checks or InnoDB code path. diff --git a/execution.log b/execution.log deleted file mode 100644 index 35fa007f1..000000000 --- a/execution.log +++ /dev/null @@ -1,56 +0,0 @@ -[2026-05-28 00:38:29] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45... -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md -[2026-05-28 00:38:29] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas. -[2026-05-28 00:38:47] [MAKE] Starting generate_usage... -[2026-05-28 00:39:23] [MAKE] Finished generate_usage. -[2026-05-28 00:41:14] [MAKE] Starting generate_usage... -[2026-05-28 00:41:49] [MAKE] Finished generate_usage. -[2026-05-28 00:44:29] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45... -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md -[2026-05-28 00:44:29] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas. -[2026-05-28 00:48:41] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45... -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md -[2026-05-28 00:48:41] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas. -[2026-05-28 00:50:06] [MAKE] Starting generate_usage... -[2026-05-28 00:50:41] [MAKE] Finished generate_usage. -[2026-05-28 07:56:46] [MAKE] Starting generate_usage... -[2026-05-28 07:57:00] [MAKE] Finished generate_usage. -[2026-05-28 07:57:00] [MAKE] Starting generate_release_notes... -[2026-05-28 07:57:14] [MAKE] Finished generate_release_notes. -[2026-06-04 06:51:17] [MAKE] Starting generate_usage... -[2026-06-04 06:51:52] [MAKE] Finished generate_usage. -[2026-06-04 06:51:52] [MAKE] Starting generate_release_notes... -[2026-06-04 06:52:27] [MAKE] Finished generate_release_notes. -[2026-06-04 08:32:37] [MAKE] Starting generate_usage... -[2026-06-04 08:32:55] [MAKE] Finished generate_usage. -[2026-06-04 08:32:55] [MAKE] Starting generate_release_notes... -[2026-06-04 08:33:10] [MAKE] Finished generate_release_notes. -[2026-06-04 08:33:51] [MAKE] Starting generate_usage... -[2026-06-04 08:34:10] [MAKE] Finished generate_usage. -[2026-06-04 08:34:10] [MAKE] Starting generate_release_notes... -[2026-06-04 08:34:26] [MAKE] Finished generate_release_notes. -[2026-06-04 09:36:52] [MAKE] Starting generate_usage... -[2026-06-04 09:37:30] [MAKE] Finished generate_usage. -[2026-06-04 09:37:30] [MAKE] Starting generate_release_notes... -[2026-06-04 09:38:12] [MAKE] Finished generate_release_notes. diff --git a/mysqltuner.pl b/mysqltuner.pl index 3c9d77595..1868d171d 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -1,5 +1,5 @@ #!/usr/bin/env perl -# mysqltuner.pl - Version 2.8.45 +# mysqltuner.pl - Version 2.9.0 # High Performance MySQL Tuning Script # Copyright (C) 2015-2026 Jean-Marie Renouard - jmrenouard@gmail.com # Copyright (C) 2006-2026 Major Hayden - major@mhtx.net @@ -67,11 +67,15 @@ package main; our $is_win = $^O eq 'MSWin32'; # Set up a few variables for use in the script -our $tunerversion = "2.8.45"; +our $tunerversion = "2.9.0"; our ( @adjvars, @generalrec, @modeling, @sysrec, @secrec ); our ( %result, %myvar, %real_vars, %mystat, %mycalc, %myrepl, %myreplicas, $dummyselect ); our %exported_manifest; +our ( + $tuner_start_time, $current_section_name, + $current_section_start, @section_timings +); our $failed_connection_attempts = 0; our $previous_failed_attempts = 0; @@ -300,6 +304,12 @@ package main; desc => 'Print result as JSON formatted string', cat => 'PERFORMANCE' }, + 'yaml' => { + type => '!', + default => 0, + desc => 'Print result as YAML string', + cat => 'PERFORMANCE' + }, 'dumpdir' => { type => '=s', default => undef, @@ -867,7 +877,8 @@ sub show_help { } sub prettyprint { - print $_[0] . "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print $_[0] . "\n" + unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); print $fh $_[0] . "\n" if defined($fh); my $plain_text = $_[0] // ''; $plain_text =~ s/\e\[[0-9;]*[mK]//g; @@ -999,6 +1010,183 @@ sub calculate_health_score { $result{'HealthScore'} = $score; $result{'HealthScoreDetails'} = \%details; $mycalc{'WeightedHealthScore'} = $score; + + calculate_sectional_health_scores(); +} + +sub calculate_sectional_health_scores { + + # 1. General Statistics KPI + my $gen_score = 100; + + my $uptime = $mystat{'Uptime'} // 0; + if ( $uptime < 86400 ) { + $gen_score -= 20; + } + + my @load = get_load_average(); + if (@load) { + my $load_1 = $load[0] // 0; + my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} + // 1; + if ( $cpu_cores > 0 && ( $load_1 / $cpu_cores ) > 1.0 ) { + $gen_score -= 20; + } + } + + if ( grep { /swappiness/i } @generalrec ) { + $gen_score -= 20; + } + + my $other_mem = get_other_process_memory() // 0; + if ( ( $physical_memory // 0 ) > 0 + && ( $other_mem / $physical_memory ) > 0.3 ) + { + $gen_score -= 20; + } + $gen_score = 0 if $gen_score < 0; + + # 2. Storage Engine KPI + my $storage_score = 100; + + my $read_eff = $mycalc{'pct_read_efficiency'} // 100; + if ( $read_eff < 95 ) { + $storage_score -= 25; + } + + my $temp_disk = $mycalc{'pct_temp_disk'} // 0; + if ( $temp_disk > 25 ) { + $storage_score -= 25; + } + + my $table_hit = $mycalc{'table_cache_hit_rate'} // 100; + if ( $table_hit < 50 ) { + $storage_score -= 25; + } + + my $redo_adjust = + grep { /innodb_log_file_size/i || /innodb_redo_log_capacity/i } @adjvars; + if ($redo_adjust) { + $storage_score -= 25; + } + $storage_score = 0 if $storage_score < 0; + + # 3. Security & Compliance KPI + my $sec_score = 100; + $sec_score -= scalar(@secrec) * 15; + $sec_score = 0 if $sec_score < 0; + + # 4. Replication & HA KPI + my $repl_score = 100; + if (%myrepl) { + my $lag = $myrepl{'Seconds_Behind_Source'} + // $myrepl{'Seconds_Behind_Replica'}; + if ( defined $lag && $lag > 60 ) { + $repl_score -= 40; + } + my $io_run = $myrepl{'Replica_IO_Running'} + // $myrepl{'Slave_IO_Running'} // 'Yes'; + my $sql_run = $myrepl{'Replica_SQL_Running'} + // $myrepl{'Slave_SQL_Running'} // 'Yes'; + if ( $io_run =~ /No/i || $sql_run =~ /No/i ) { + $repl_score -= 35; + } + my $gtid = $myvar{'gtid_mode'} // 'OFF'; + if ( $gtid =~ /OFF/i ) { + $repl_score -= 25; + } + } + $repl_score = 0 if $repl_score < 0; + + # 5. SQL Modeling KPI + my $model_score = 100; + $model_score -= scalar(@modeling) * 10; + $model_score = 0 if $model_score < 0; + + $result{'SectionalHealthScore'}{'General'} = int($gen_score); + $result{'SectionalHealthScore'}{'Storage'} = int($storage_score); + $result{'SectionalHealthScore'}{'Security'} = int($sec_score); + $result{'SectionalHealthScore'}{'Replication'} = int($repl_score); + $result{'SectionalHealthScore'}{'Modeling'} = int($model_score); + + # Calculate Resource Saturation Heatmap + my $cpu_sat = 0; + if (@load) { + my $load_1 = $load[0] // 0; + my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} + // 1; + $cpu_sat = ( $cpu_cores > 0 ) ? ( $load_1 / $cpu_cores ) * 100 : 0; + $cpu_sat = 100 if $cpu_sat > 100; + } + + my $mem_sat = $mycalc{'pct_max_physical_memory'} // 0; + $mem_sat =~ s/%//g; + $mem_sat = 100 if $mem_sat > 100; + + my $conn_sat = $mycalc{'pct_connections_used'} // 0; + $conn_sat =~ s/%//g; + $conn_sat = 100 if $conn_sat > 100; + + my $read_eff_val = $mycalc{'pct_read_efficiency'} // 100; + my $disk_read_pressure = 100 - $read_eff_val; + my $scaled_read_pressure = $disk_read_pressure * 20; + my $temp_disk_val = $mycalc{'pct_temp_disk'} // 0; + my $io_sat = + ( $scaled_read_pressure > $temp_disk_val ) + ? $scaled_read_pressure + : $temp_disk_val; + $io_sat = 100 if $io_sat > 100; + + $result{'ResourceSaturation'}{'CPU'} = int($cpu_sat); + $result{'ResourceSaturation'}{'Memory'} = int($mem_sat); + $result{'ResourceSaturation'}{'Connections'} = int($conn_sat); + $result{'ResourceSaturation'}{'IO'} = int($io_sat); + + # Calculate Throughput Efficiency Index + my $qps_val = + ( ( $mystat{'Uptime'} || 0 ) > 0 ) + ? ( $mystat{'Questions'} || 0 ) / $mystat{'Uptime'} + : 0; + my $logical_reads_sec = + ( $uptime > 0 ) + ? ( $mystat{'Innodb_buffer_pool_read_requests'} // 0 ) / $uptime + : 0; + my $tei = + ( $logical_reads_sec > 0 ) ? ( $qps_val / $logical_reads_sec ) : 0; + + $result{'ThroughputEfficiency'}{'QPS'} = sprintf( "%.3f", $qps_val ) + 0; + $result{'ThroughputEfficiency'}{'LogicalReadsSec'} = + sprintf( "%.3f", $logical_reads_sec ) + 0; + $result{'ThroughputEfficiency'}{'Index'} = sprintf( "%.5f", $tei ) + 0; +} + +sub get_top_findings { + my $list_ref = shift; + my @items = (); + foreach my $it (@$list_ref) { + push @items, format_recommendation_item($it); + } + + my @scored = (); + foreach my $item (@items) { + my $score = 1; + if ( $item =~ +/(dangerously high|insecure|vulnerability|CVE|not running|aborted|unencrypted|anonymous|remove|risk|critical|warning|incorrect|mismatch)/i + ) + { + $score = 2; + } + push @scored, { text => $item, score => $score }; + } + + my @sorted = sort { $b->{score} <=> $a->{score} } @scored; + + my @res = (); + for ( my $i = 0 ; $i < 3 && $i < @sorted ; $i++ ) { + my $badge = ( $sorted[$i]->{score} == 2 ) ? 'Critical' : 'Finding'; + push @res, { text => $sorted[$i]->{text}, badge => $badge }; + } + return @res; } sub display_health_score { @@ -1390,7 +1578,7 @@ sub check_security_2_0 { } sub generate_auto_fix_snippets { - return if $opt{'silent'} || $opt{'json'}; + return if $opt{'silent'} || $opt{'json'} || $opt{'yaml'}; subheaderprint "Guided Auto-Fix Snippets"; if ( @adjvars > 0 ) { @@ -1426,6 +1614,18 @@ sub infoprintcmd { } sub subheaderprint { + my $name = $_[0]; + my $now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + if ( defined $current_section_name && $opt{'verbose'} ) { + my $elapsed = $now - $current_section_start; + push @section_timings, [ $current_section_name, $elapsed ]; + infoprint + sprintf( "%s execution time: %.3fs", $current_section_name, + $elapsed ); + } + $current_section_name = $name; + $current_section_start = $now; + my $tln = 100; my $sln = 8; my $ln = length("@_") + 2; @@ -1434,6 +1634,66 @@ sub subheaderprint { prettyprint "-" x $sln . " @_ " . "-" x ( $tln - $ln - $sln ); } +sub stop_section_timing { + my $now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + if ( defined $current_section_name && $opt{'verbose'} ) { + my $elapsed = $now - $current_section_start; + push @section_timings, [ $current_section_name, $elapsed ]; + infoprint + sprintf( "%s execution time: %.3fs", $current_section_name, + $elapsed ); + } + $current_section_name = undef; +} + +sub print_execution_timings { + return unless $opt{'verbose'}; + + stop_section_timing(); + + my $total_now = + eval { require Time::HiRes; Time::HiRes::time(); } || time(); + my $total_elapsed = $total_now - $tuner_start_time; + + subheaderprint "Execution Times"; + foreach my $timing (@section_timings) { + my ( $name, $elapsed ) = @$timing; + my $pct = $total_elapsed > 0 ? ( $elapsed / $total_elapsed ) * 100 : 0; + infoprint sprintf( "%-50s: %.3fs (%.1f%%)", $name, $elapsed, $pct ); + } + infoprint sprintf( "Total Execution Time: %.3fs", $total_elapsed ); +} + +sub print_audit_snapshot_summary { + subheaderprint "Audit Snapshot Summary"; + + my $host = $opt{'host'} || 'localhost'; + if ( $opt{'port'} ) { + $host .= ":$opt{'port'}"; + } + + my $db_user = + select_one("SELECT CURRENT_USER()") || $opt{'user'} || 'unknown'; + + my $ram_str = + defined($physical_memory) ? hr_bytes($physical_memory) : 'unknown'; + my $swap_str = defined($swap_memory) ? hr_bytes($swap_memory) : 'unknown'; + + my $db_ver = $myvar{'version'} // 'unknown'; + my $uptime_str = + defined( $mystat{'Uptime'} ) + ? pretty_uptime( $mystat{'Uptime'} ) + : 'unknown'; + + infoprint "MySQLTuner Version : $tunerversion"; + infoprint "Server Connection : $host"; + infoprint "Database User : $db_user"; + infoprint "Database Version : $db_ver"; + infoprint "System Physical RAM: $ram_str"; + infoprint "System Swap Memory : $swap_str"; + infoprint "Database Uptime : $uptime_str"; +} + sub infoprinthcmd { subheaderprint "$_[0]"; infoprintcmd "$_[1]"; @@ -1640,6 +1900,7 @@ sub hr_bytes_rnd { # Calculates the parameter passed to the nearest power of 1000, then rounds it to the nearest integer sub hr_num { my $num = shift; + return 0 if !$num || $num eq 'NULL'; if ( $num >= ( 1000**3 ) ) { # Billions return int( ( $num / ( 1000**3 ) ) ) . "B"; } @@ -1996,7 +2257,7 @@ sub get_http_cli { # Checks for updates to MySQLTuner sub validate_tuner_version { if ( $opt{'checkversion'} eq 0 ) { - print "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); infoprint "Skipped version check for MySQLTuner script"; return; } @@ -2058,7 +2319,7 @@ sub validate_tuner_version { sub update_tuner_version { if ( $opt{'updateversion'} eq 0 ) { badprint "Skipped version update for MySQLTuner script"; - print "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); return; } @@ -2960,7 +3221,7 @@ sub write_manifest_files { } my $json_content = - "{\n \"version\": \"" . ( $tunerversion // '2.8.45' ) . "\",\n"; + "{\n \"version\": \"" . ( $tunerversion // '2.9.0' ) . "\",\n"; $json_content .= " \"exported_at\": \"" . scalar( gmtime() ) . " UTC\",\n"; $json_content .= " \"total_files\": $total_files,\n"; $json_content .= " \"total_size_bytes\": $total_size,\n"; @@ -2976,7 +3237,7 @@ sub write_manifest_files { my $meta_content = "MySQLTuner Offline Diagnostic Snapshot Metadata\n"; $meta_content .= "================================================\n"; - $meta_content .= "Version: " . ( $tunerversion // '2.8.45' ) . "\n"; + $meta_content .= "Version: " . ( $tunerversion // '2.9.0' ) . "\n"; $meta_content .= "Exported At: " . scalar( gmtime() ) . " UTC\n"; $meta_content .= "Host: " . ( $myvar{'hostname'} // 'unknown' ) . "\n"; $meta_content .= @@ -3993,6 +4254,25 @@ sub get_other_process_memory { return $totalMemOther; } +sub get_load_average { + my $prefix = get_transport_prefix(); + return () if $prefix eq '' && is_remote(); + + if ( $prefix eq '' && -f "/proc/loadavg" ) { + my $content = file2string("/proc/loadavg") // ''; + if ( $content =~ /^([\d.]+)\s+([\d.]+)\s+([\d.]+)/ ) { + return ( $1, $2, $3 ); + } + } + my $uptime_output = execute_system_command("uptime") // ''; + if ( $uptime_output =~ + /load average(?:s)?:?\s+([\d.]+),?\s+([\d.]+),?\s+([\d.]+)/i ) + { + return ( $1, $2, $3 ); + } + return (); +} + sub get_os_release { if ( -f "/etc/lsb-release" ) { my @info_release = get_file_contents "/etc/lsb-release"; @@ -5016,7 +5296,8 @@ sub check_auth_plugins { sub security_recommendations { subheaderprint "Security Recommendations"; - infoprint "$myvar{'version_comment'} - $myvar{'version'}"; + infoprint( ( $myvar{'version_comment'} // 'N/A' ) . " - " + . ( $myvar{'version'} // 'N/A' ) ); my $PASS_COLUMN_NAME = get_password_column_name(); @@ -5508,8 +5789,8 @@ sub get_replication_status { } # Parallel replication checks (MariaDB specific) - if ( ( $myvar{'version'} =~ /MariaDB/i ) - or ( $myvar{'version_comment'} =~ /MariaDB/i ) ) + if ( ( ( $myvar{'version'} // '' ) =~ /MariaDB/i ) + or ( ( $myvar{'version_comment'} // '' ) =~ /MariaDB/i ) ) { my $parallel_threads = $myvar{'slave_parallel_threads'} // $myvar{'replica_parallel_threads'} // 0; @@ -5580,15 +5861,33 @@ sub validate_mysql_version { } } +my $cached_version_str; +my ( $cached_v_maj, $cached_v_min, $cached_v_mic ); + +sub _parse_version { + my $ver = $myvar{'version'} // ''; + if ( !defined $cached_version_str || $cached_version_str ne $ver ) { + $cached_version_str = $ver; + if ( $ver =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/ ) { + $cached_v_maj = $1 // 0; + $cached_v_min = $2 // 0; + $cached_v_mic = $3 // 0; + } + else { + $cached_v_maj = 0; + $cached_v_min = 0; + $cached_v_mic = 0; + } + } + return ( $cached_v_maj, $cached_v_min, $cached_v_mic ); +} + # Checks if MySQL version is equal to (major, minor, micro) sub mysql_version_eq { my ( $maj, $min, $mic ) = @_; return 0 unless defined $myvar{'version'}; - my ( $v_maj, $v_min, $v_mic ) = - $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/; - $v_maj //= 0; - $v_min //= 0; - $v_mic //= 0; + $maj //= 0; + my ( $v_maj, $v_min, $v_mic ) = _parse_version(); return int($v_maj) == int($maj) if ( !defined($min) && !defined($mic) ); @@ -5603,13 +5902,10 @@ sub mysql_version_eq { sub mysql_version_ge { my ( $maj, $min, $mic ) = @_; return 0 unless defined $myvar{'version'}; - $min ||= 0; - $mic ||= 0; - my ( $v_maj, $v_min, $v_mic ) = - $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/; - $v_maj //= 0; - $v_min //= 0; - $v_mic //= 0; + $maj //= 0; + $min //= 0; + $mic //= 0; + my ( $v_maj, $v_min, $v_mic ) = _parse_version(); return int($v_maj) > int($maj) @@ -5623,13 +5919,10 @@ sub mysql_version_ge { sub mysql_version_le { my ( $maj, $min, $mic ) = @_; return 0 unless defined $myvar{'version'}; - $min ||= 0; - $mic ||= 0; - my ( $v_maj, $v_min, $v_mic ) = - $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/; - $v_maj //= 0; - $v_min //= 0; - $v_mic //= 0; + $maj //= 0; + $min //= 0; + $mic //= 0; + my ( $v_maj, $v_min, $v_mic ) = _parse_version(); return int($v_maj) < int($maj) @@ -6407,16 +6700,16 @@ sub calculations { $mycalc{'query_cache_efficiency'} = 0; } elsif ( mysql_version_ge(4) ) { - if ( ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) > - 0 ) - { - $mycalc{'query_cache_efficiency'} = sprintf( - "%.1f", - ( - $mystat{'Qcache_hits'} / - ( $mystat{'Com_select'} + $mystat{'Qcache_hits'} ) - ) * 100 - ); + + # MDEV-4981: In MariaDB, Com_select includes query cache hits (Qcache_hits) + my $total_selects = + $is_mariadb + ? ( $mystat{'Com_select'} || 0 ) + : ( + ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) ); + if ( $total_selects > 0 ) { + $mycalc{'query_cache_efficiency'} = sprintf( "%.1f", + ( ( $mystat{'Qcache_hits'} || 0 ) / $total_selects ) * 100 ); } else { $mycalc{'query_cache_efficiency'} = 0; @@ -6833,8 +7126,9 @@ sub mysql_stats { if ( $myvar{'long_query_time'} > 10 ) { push( @adjvars, "long_query_time (<= 10)" ); } - if ( defined( $myvar{'log_slow_queries'} ) ) { - if ( $myvar{'log_slow_queries'} eq "OFF" ) { + my $slow_query_log_active = $myvar{'slow_query_log'} // $myvar{'log_slow_queries'}; + if ( defined( $slow_query_log_active ) ) { + if ( $slow_query_log_active eq "OFF" ) { push( @generalrec, "Enable the slow query log to troubleshoot bad queries" ); } @@ -6944,12 +7238,21 @@ sub mysql_stats { "Query cache cannot be analyzed: no SELECT statements executed"; } else { + # MDEV-4981: In MariaDB, Com_select includes query cache hits (Qcache_hits) + my $is_mariadb = ( $myvar{'version'} // '' ) =~ /mariadb/i + || ( $myvar{'version_comment'} // '' ) =~ /mariadb/i; + my $total_selects = + $is_mariadb + ? ( $mystat{'Com_select'} || 0 ) + : ( + ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) ); + if ( $mycalc{'query_cache_efficiency'} < 20 ) { badprint "Query cache efficiency: $mycalc{'query_cache_efficiency'}% (" . hr_num( $mystat{'Qcache_hits'} ) . " cached / " - . hr_num( $mystat{'Qcache_hits'} + $mystat{'Com_select'} ) + . hr_num($total_selects) . " selects)"; badprint "Query cache may be disabled by default due to mutex contention."; @@ -6961,7 +7264,7 @@ sub mysql_stats { "Query cache efficiency: $mycalc{'query_cache_efficiency'}% (" . hr_num( $mystat{'Qcache_hits'} ) . " cached / " - . hr_num( $mystat{'Qcache_hits'} + $mystat{'Com_select'} ) + . hr_num($total_selects) . " selects)"; if ( $mycalc{'query_cache_prunes_per_day'} > 98 ) { badprint @@ -10503,20 +10806,22 @@ sub mysql_innodb { infoprint " +-- InnoDB Log File Size: " . hr_bytes( $myvar{'innodb_log_file_size'} ); } - if ( defined $myvar{'innodb_log_files_in_group'} ) { + if ( defined $myvar{'innodb_log_files_in_group'} + && defined $myvar{'innodb_log_file_size'} ) + { infoprint " +-- InnoDB Log File In Group: " . $myvar{'innodb_log_files_in_group'}; infoprint " +-- InnoDB Total Log File Size: " . hr_bytes( $myvar{'innodb_log_files_in_group'} * $myvar{'innodb_log_file_size'} ) . "(" - . $mycalc{'innodb_log_size_pct'} + . ( $mycalc{'innodb_log_size_pct'} // 0 ) . " % of buffer pool)"; } - else { + elsif ( defined $myvar{'innodb_log_file_size'} ) { infoprint " +-- InnoDB Total Log File Size: " . hr_bytes( $myvar{'innodb_log_file_size'} ) . "(" - . $mycalc{'innodb_log_size_pct'} + . ( $mycalc{'innodb_log_size_pct'} // 0 ) . " % of buffer pool)"; } } @@ -11462,6 +11767,8 @@ sub historical_comparison { my $file = $opt{'compare-file'}; return if !$file; + calculate_health_score() unless defined $result{'HealthScore'}; + subheaderprint "Historical Trend Analysis"; if ( !-e $file ) { badprint "Comparison file not found: $file"; @@ -11490,8 +11797,9 @@ sub historical_comparison { return; } - infoprint "Comparing current results with snapshot from: " - . ( $old->{'General'}{'Date'} // 'unknown' ); + my $snapshot_date = $old->{'General'}{'Date'} // 'unknown'; + infoprint "Comparing current results with snapshot from: " . $snapshot_date; + $result{'Trends'}{'SnapshotDate'} = $snapshot_date; # 1. Compare QPS if ( defined $result{'Stats'}{'QPS'} && defined $old->{'Stats'}{'QPS'} ) { @@ -11500,13 +11808,25 @@ sub historical_comparison { ( $old->{'Stats'}{'QPS'} > 0 ) ? ( $diff / $old->{'Stats'}{'QPS'} ) * 100 : 0; - my $trend = ( $diff >= 0 ) ? "+" : ""; - infoprint sprintf( + my $trend = ( $diff >= 0 ) ? "+" : ""; + my $qps_trend = sprintf( "QPS Trend: %.2f -> %.2f (%s%.2f%%)", $old->{'Stats'}{'QPS'}, $result{'Stats'}{'QPS'}, $trend, $pct ); + infoprint $qps_trend; + $result{'Trends'}{'QPS'} = $qps_trend; + } + + # Compare Health Score + if ( defined $result{'HealthScore'} && defined $old->{'HealthScore'} ) { + my $diff = $result{'HealthScore'} - $old->{'HealthScore'}; + my $trend = ( $diff > 0 ) ? "+" : ""; + my $score_trend = sprintf( "Health Score Trend: %d -> %d (%s%d)", + $old->{'HealthScore'}, $result{'HealthScore'}, $trend, $diff ); + infoprint $score_trend; + $result{'Trends'}{'HealthScore'} = $score_trend; } # 2. Compare Total Data Size @@ -11515,10 +11835,30 @@ sub historical_comparison { { my $diff = $result{'Stats'}{'Total Data Size'} - $old->{'Stats'}{'Total Data Size'}; - infoprint "Data Growth: " + my $size_trend = + "Data Growth: " . hr_bytes( $old->{'Stats'}{'Total Data Size'} ) . " -> " . hr_bytes( $result{'Stats'}{'Total Data Size'} ) . " (" . hr_bytes($diff) . ")"; + infoprint $size_trend; + $result{'Trends'}{'TotalDataSize'} = $size_trend; + } + + # Compare sectional scores + foreach + my $sec ( 'General', 'Storage', 'Security', 'Replication', 'Modeling' ) + { + if ( defined $result{'SectionalHealthScore'}{$sec} + && defined $old->{'SectionalHealthScore'}{$sec} ) + { + my $diff = $result{'SectionalHealthScore'}{$sec} - + $old->{'SectionalHealthScore'}{$sec}; + my $trend = ( $diff > 0 ) ? "+" : ""; + $result{'Trends'}{'Sectional'}{$sec} = sprintf( "%d -> %d (%s%d)", + $old->{'SectionalHealthScore'}{$sec}, + $result{'SectionalHealthScore'}{$sec}, + $trend, $diff ); + } } push @adjvars, "Historical comparison performed against " . basename($file); @@ -11654,8 +11994,8 @@ sub check_query_anti_patterns { sub mariadb_query_cache_info { subheaderprint "Query Cache Information"; - unless ( ( $myvar{'version'} =~ /MariaDB/i ) - or ( $myvar{'version_comment'} =~ /MariaDB/i ) ) + unless ( ( ( $myvar{'version'} // '' ) =~ /MariaDB/i ) + or ( ( $myvar{'version_comment'} // '' ) =~ /MariaDB/i ) ) { infoprint "Not a MariaDB server. Skipping Query Cache Info plugin check."; @@ -11821,7 +12161,7 @@ sub mysql_databases { $result{'Databases'}{'All databases'}{'Index Pct'} = percentage( $totaldbinfo[2], $totaldbinfo[3] ) . "%"; $result{'Databases'}{'All databases'}{'Total Size'} = $totaldbinfo[3]; - print "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); my $nbViews = 0; my $nbTables = 0; @@ -12494,7 +12834,126 @@ sub format_recommendation_item { return $item // ''; } +sub _yaml_scalar { + my ( $v, $indent ) = @_; + return "~" unless defined $v; + + if ( $v =~ /\n/ ) { + my $pad = ' ' x ( $indent + 1 ); + $v =~ s/\r\n?/\n/g; + my @lines = split /\n/, $v, -1; + if ( @lines > 1 && $lines[-1] eq '' ) { + pop @lines; + } + my $joined = join( "\n", map { $pad . $_ } @lines ); + return "|\n" . $joined . "\n"; + } + + if ( $v eq '' + || $v =~ /[:\#\'\"\[\]\{\},&*?!|>%-]/ + || $v =~ /^(?:yes|no|true|false|null|on|off|~)$/i ) + { + $v =~ s/'/''/g; + return "'$v'"; + } + return $v; +} + +sub _to_yaml { + my ( $data, $indent ) = @_; + $indent //= 0; + my $spaces = ' ' x $indent; + my $output = ''; + + if ( !defined $data ) { + return "~\n"; + } + elsif ( ref $data eq 'HASH' ) { + $output .= "\n" if $indent > 0; + foreach my $key ( sort keys %$data ) { + my $val = $data->{$key}; + $output .= $spaces . $key . ":"; + if ( ref $val ) { + $output .= _to_yaml( $val, $indent + 1 ); + } + else { + my $yaml_val = _yaml_scalar( $val, $indent ); + if ( $yaml_val =~ /^\|/ ) { + $output .= " " . $yaml_val; + } + else { + $output .= " " . $yaml_val . "\n"; + } + } + } + } + elsif ( ref $data eq 'ARRAY' ) { + $output .= "\n" if $indent > 0; + foreach my $item (@$data) { + $output .= $spaces . "-"; + if ( ref $item ) { + my $inner = _to_yaml( $item, $indent + 1 ); + $inner =~ s/^\n\s*//; + $output .= " " . $inner; + } + else { + my $yaml_val = _yaml_scalar( $item, $indent ); + if ( $yaml_val =~ /^\|/ ) { + $output .= " " . $yaml_val; + } + else { + $output .= " " . $yaml_val . "\n"; + } + } + } + } + else { + my $yaml_val = _yaml_scalar( $data, $indent ); + if ( $yaml_val =~ /^\|/ ) { + $output .= $yaml_val; + } + else { + $output .= $yaml_val . "\n"; + } + } + return $output; +} + +sub _sanitized_result_for_export { + my $orig = shift; + my %copy = %$orig; + + if ( ref $copy{'MySQLTuner'} eq 'HASH' ) { + my %mt_copy = %{ $copy{'MySQLTuner'} }; + if ( ref $mt_copy{'options'} eq 'HASH' ) { + my %opts = %{ $mt_copy{'options'} }; + for my $secret (qw(pass password ssh-password passenv userenv)) { + $opts{$secret} = '[REDACTED]' if defined $opts{$secret}; + } + $mt_copy{'options'} = \%opts; + } + $copy{'MySQLTuner'} = \%mt_copy; + } + + if ( ref $copy{'MySQL Client'} eq 'HASH' ) { + my %mc_copy = %{ $copy{'MySQL Client'} }; + if ( defined $mc_copy{'Authentication Info'} ) { + my $auth = $mc_copy{'Authentication Info'}; + $auth =~ s/-p'[^']*'/-p'[REDACTED]'/g; + $auth =~ s/-p(?!'\[REDACTED\]')\S+/-p[REDACTED]/g; + $mc_copy{'Authentication Info'} = $auth; + } + $copy{'MySQL Client'} = \%mc_copy; + } + + return \%copy; +} + sub dump_result { + if ( $opt{'json'} && $opt{'yaml'} ) { + print STDERR "ERROR: --json and --yaml are mutually exclusive\n"; + return 1; + } #debugprint Dumper( \%result ) if ( $opt{'debug'} ); debugprint "HTML REPORT: " . ( $opt{'reportfile'} // 'undef' ); @@ -12575,6 +13034,119 @@ sub dump_result { my $raw_output_html = join( "\n", map { escape_html($_) } @raw_output_lines ); + # Phase 13: Calculate Sectional Health Score variables + my $kpi_gen = $result{'SectionalHealthScore'}{'General'} // 100; + my $kpi_stor = $result{'SectionalHealthScore'}{'Storage'} // 100; + my $kpi_sec = $result{'SectionalHealthScore'}{'Security'} // 100; + my $kpi_repl = $result{'SectionalHealthScore'}{'Replication'} // 100; + my $kpi_mode = $result{'SectionalHealthScore'}{'Modeling'} // 100; + + # Prioritized Top Findings lists + my @general_top = get_top_findings( \@generalrec ); + my @storage_top = get_top_findings( \@adjvars ); + my @security_top = get_top_findings( \@secrec ); + my @repl_recs = + grep { /(replica|sla[v]e|gtid|replication|binlog|relay)/i } + @generalrec; + my @replication_top = get_top_findings( \@repl_recs ); + my @modeling_top = get_top_findings( \@modeling ); + + my $format_top_findings = sub { + my ( $title, $findings_ref ) = @_; + my @findings = @$findings_ref; + if ( !@findings ) { + return +"
" + . "

$title🟢 Optimal

" + . "

No critical issues or recommendations detected in this area.

" + . "
"; + } + my $items_html = ''; + foreach my $f (@findings) { + my $badge_color = + ( $f->{badge} eq 'Critical' ) + ? 'text-rose-400 bg-rose-500/10 border-rose-500/20' + : 'text-amber-400 bg-amber-500/10 border-amber-500/20'; + $items_html .= +"
  • " + . "" + . $f->{badge} + . "" + . "" + . escape_html( $f->{text} ) + . "" . "
  • "; + } + return +"
    " + . "

    $title⚠️ Action Required

    " + . "
      $items_html
    " + . "
    "; + }; + + my $gen_findings_html = + $format_top_findings->( 'General Stats', \@general_top ); + my $store_findings_html = + $format_top_findings->( 'Storage & Performance', \@storage_top ); + my $sec_findings_html = + $format_top_findings->( 'Security & Compliance', \@security_top ); + my $repl_findings_html = + $format_top_findings->( 'Replication & HA', \@replication_top ); + my $model_findings_html = + $format_top_findings->( 'SQL Modeling', \@modeling_top ); + + # Throughput Efficiency Index + my $tei_qps = $result{'ThroughputEfficiency'}{'QPS'} // 0.0; + my $tei_reads = $result{'ThroughputEfficiency'}{'LogicalReadsSec'} + // 0.0; + my $tei_index = $result{'ThroughputEfficiency'}{'Index'} // 0.00000; + + # Resource Saturation Heatmap + my $sat_cpu = $result{'ResourceSaturation'}{'CPU'} // 0; + my $sat_mem = $result{'ResourceSaturation'}{'Memory'} // 0; + my $sat_conn = $result{'ResourceSaturation'}{'Connections'} // 0; + my $sat_io = $result{'ResourceSaturation'}{'IO'} // 0; + + my $get_sat_color = sub { + my $val = shift; + return $val > 85 + ? 'bg-rose-500' + : ( $val > 60 ? 'bg-amber-400' : 'bg-emerald-500' ); + }; + my $cpu_color = $get_sat_color->($sat_cpu); + my $mem_color = $get_sat_color->($sat_mem); + my $conn_color = $get_sat_color->($sat_conn); + my $io_color = $get_sat_color->($sat_io); + + # Historical Trend Deltas + my $historical_deltas_html = ''; + if ( defined $result{'Trends'} ) { + my $qps_delta = $result{'Trends'}{'QPS'} // 'N/A'; + my $score_delta = $result{'Trends'}{'HealthScore'} // 'N/A'; + my $size_delta = $result{'Trends'}{'TotalDataSize'} // 'N/A'; + my $sec_deltas = ''; + foreach my $sec ( 'General', 'Storage', 'Security', 'Replication', + 'Modeling' ) + { + my $d = $result{'Trends'}{'Sectional'}{$sec} // 'N/A'; + $sec_deltas .= +"
    $sec: $d
    "; + } + $historical_deltas_html = <<"HTML"; +
    +

    Historical Performance Deltas

    +
    +
    QPS: $qps_delta
    +
    Health Score: $score_delta
    +
    Data Size: $size_delta
    +
    +

    Sectional Score Trends

    + $sec_deltas +
    +
    +
    +HTML + } + # Determine health score color class my $score_color_class = $score > 80 @@ -12707,11 +13279,150 @@ sub dump_result { + $historical_deltas_html
    + +
    +
    +

    Sectional Indicators & KPIs Dashboard

    + Phase 13 Unified View +
    + + +
    + +
    + General Stats + $kpi_gen% +
    +
    +
    +
    + +
    + Storage & Perf + $kpi_stor% +
    +
    +
    +
    + +
    + Security + $kpi_sec% +
    +
    +
    +
    + +
    + Replication & HA + $kpi_repl% +
    +
    +
    +
    + +
    + SQL Modeling + $kpi_mode% +
    +
    +
    +
    +
    + + +
    + +
    +

    Resource Saturation Heatmap

    + +
    +
    + CPU Load Saturation + $sat_cpu% +
    +
    +
    +
    +
    + +
    +
    + Memory Saturation + $sat_mem% +
    +
    +
    +
    +
    + +
    +
    + Connections Saturation + $sat_conn% +
    +
    +
    +
    +
    + +
    +
    + Disk I/O Pressure + $sat_io% +
    +
    +
    +
    +
    +
    + + +
    +
    +

    Throughput Efficiency Index

    +
    +
    + Logical Work + $tei_qps QPS +
    +
    + Buffer Logical Reads + $tei_reads/s +
    +
    +
    +
    +
    +
    + Efficiency Ratio + Queries completed per logical page read +
    + $tei_index +
    +
    +
    +
    + + +
    +

    Prioritized Executive Summary Findings

    +
    + $gen_findings_html + $store_findings_html + $sec_findings_html + $repl_findings_html + $model_findings_html +
    +
    +
    +
    @@ -12817,6 +13528,11 @@ sub dump_result { goodprint "HTML Report successfully generated: " . $opt{'reportfile'}; } + my $sanitized_res; + if ( $opt{'json'} || $opt{'yaml'} ) { + $sanitized_res = _sanitized_result_for_export( \%result ); + } + if ( $opt{'json'} ) { eval { require JSON }; if ($@) { @@ -12826,7 +13542,7 @@ sub dump_result { my $json = JSON->new->allow_nonref; print $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) ) - ->encode( \%result ); + ->encode($sanitized_res); if ( $opt{'outputfile'} ) { unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} ); @@ -12834,7 +13550,21 @@ sub dump_result { or die "Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory"; print $fh $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) ) - ->encode( \%result ); + ->encode($sanitized_res); + close $fh; + } + } + + if ( $opt{'yaml'} ) { + my $yaml_str = _to_yaml($sanitized_res); + print $yaml_str; + + if ( $opt{'outputfile'} ) { + unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} ); + open my $fh, q(>), $opt{'outputfile'} + or die +"Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory"; + print $fh $yaml_str; close $fh; } } @@ -12888,6 +13618,9 @@ sub dump_csv_files { open( $fh, '>', $outputfile_path ) or die("Failed to open $outputfile_path for writing: $!"); $opt{nocolor} = 1; # Disable colors in file output + if (@raw_output_lines) { + print $fh join( "\n", @raw_output_lines ), "\n"; + } } # If outputfile is already set, create a second file handle for raw output @@ -13080,6 +13813,8 @@ sub dump_csv_files { # BEGIN 'MAIN' # --------------------------------------------------------------------------- if ( !caller ) { + $tuner_start_time = + eval { require Time::HiRes; Time::HiRes::time(); } || time(); parse_cli_args; # Parse CLI arguments setup_environment; # Initialize variables and handle early exits headerprint; # Header Print @@ -13090,14 +13825,14 @@ sub dump_csv_files { debugprint "MySQL FINAL Client : $mysqlcmd $mysqllogin"; debugprint "MySQL Admin FINAL Client : $mysqladmincmd $mysqllogin"; - dump_csv_files; # dump csv files - os_setup; # Set up some OS variables - get_all_vars; # Toss variables/status into hashes - mysql_cloud_discovery; # Auto-discover cloud environment - get_tuning_info; # Get information about the tuning connection - calculations; # Calculate everything we need - check_architecture; # Suggest 64-bit upgrade - check_storage_engines; # Show enabled storage engines + os_setup; # Set up some OS variables + get_all_vars; # Toss variables/status into hashes + print_audit_snapshot_summary; # Summary of the audit snapshot + mysql_cloud_discovery; # Auto-discover cloud environment + get_tuning_info; # Get information about the tuning connection + calculations; # Calculate everything we need + check_architecture; # Suggest 64-bit upgrade + check_storage_engines; # Show enabled storage engines if ( $opt{'feature'} ) { subheaderprint "See FEATURES.md for more information"; @@ -13106,7 +13841,9 @@ sub dump_csv_files { subheaderprint "Running feature: $feature"; $feature->(); } + dump_csv_files; # dump csv files make_recommendations; + print_execution_timings(); goodprint "Terminated successfully"; exit(0); } @@ -13134,8 +13871,10 @@ sub dump_csv_files { $section->(); } + dump_csv_files; # dump csv files make_recommendations; # Make recommendations based on stats dump_result; # Dump result if debug is on + print_execution_timings(); goodprint "Terminated successfully"; close_outputfile; # Close reportfile if needed @@ -13153,7 +13892,7 @@ sub dump_csv_files { =head1 NAME - MySQLTuner 2.8.45 - MySQL High Performance Tuning Script + MySQLTuner 2.9.0 - MySQL High Performance Tuning Script =head1 IMPORTANT USAGE GUIDELINES @@ -13168,7 +13907,7 @@ =head1 OPTIONS =head1 VERSION -Version 2.8.45 +Version 2.9.0 =head1 PERLDOC You can find documentation for this module with the perldoc command. @@ -13338,6 +14077,10 @@ =head1 CONTRIBUTORS Long Radix +=item * + +derZ-dev + =back =head1 SUPPORT diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md new file mode 100644 index 000000000..fbd12f549 --- /dev/null +++ b/releases/v2.9.0.md @@ -0,0 +1,100 @@ +# Release Notes - v2.9.0 + +**Date**: 2026-06-15 + +## 📝 Executive Summary + +```text +2.9.0 2026-06-15 + +- chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. +- chore(main): add roadmap to the whitelist of allowed scopes in compliance checks. +- feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. +- feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time. +- feat(report): finalize a complete HTML report file beginning in v2.8.45. +- feat(report): move dump_csv_files execution step to immediately before make_recommendations. +- feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. +- feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(cli): add mutually exclusive guard for json and yaml options. +- fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981). +- fix(main): calculate health score early in historical comparison to ensure scores exist for trend analysis. +- fix(main): format YAML null as tilde (~) and multiline values using literal block style. +- fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. +- fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): add undefined and 'NULL' guards to hr_num to eliminate uninitialized value warnings. +- fix(main): sanitize and redact sensitive credentials from json/yaml output exports. +- fix(main): skip local /proc/loadavg read during remote database audits. +- fix(report): flush console output trace when opening raw log files late in dumpdir mode. +- fix(test): add undef fallbacks to human_size, hr_bytes and hr_bytes_rnd mocks in tests. +- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t). +- test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. +- test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. +- test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t. +- feat(main): recommend enabling slow query log if disabled (#517) +- test(lab): add unit test test_issue_517.t for slow query log recommendations (#517) +- test(lab): add unit test test_issue_480.t for table_open_cache_instances recommendation (#480) +- test(lab): split unit_coverage_boost4.t into smaller topic-oriented unit tests: unit_cli_helpers.t, unit_client_privileges.t, unit_cloud_commands.t, unit_fs_info.t, unit_os_vm.t +- test(lab): normalize all repro_issue_*.t and issue_*.t test file names to test_issue_*.t +``` + +## 📈 Diagnostic Growth Indicators + +| Metric | Current | Progress | Status | +| :--- | :--- | :--- | :--- | +| Total Indicators | 15 | 0 | 🛡️ | +| Efficiency Checks | 0 | 0 | 🛡️ | +| Risk Detections | 2 | 0 | 🛡️ | +| Information Points | 13 | 0 | 🛡️ | + +## 🛠️ Internal Commit History + +- feat: recommend slow query log when disabled (#517) (da6d437) +- docs: add LightPath as sponsor, relocate coffee button, and use star-history chart (31e3638) +- docs: update repository links to major/MySQLTuner-perl and add GitHub stars badge (4cd208e) +- docs: regenerate release notes (a754147) +- fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings (0a87482) +- docs(roadmap): link strategic technical evolutions specification and enforce changelog staging (136a977) +- docs: regenerate release notes (abe582a) +- fix(main): address PR #931 code review feedback and enhance test validations (64c28ce) +- feat(metadata): fix test badge and update version references in READMEs (a1be73c) +- docs(metadata): remove timestamp from doc-sync generated files (4575b0a) +- fix(main): calculate query cache efficiency using Com_select on MariaDB (MDEV-4981) (780c861) +- feat(report): add verbose timings, step percentages, and snapshot summary (c23198a) +- feat(report): implement Phase 13 sectional global indicators and KPIs (5f9cebb) +- chore: remove execution.log from git repository and sync docs (2a9b66e) +- feat(report): finalize HTML report, YAML output, and historical comparison (66ebab8) + +## ⚙️ Technical Evolutions + +### ➕ CLI Options Added +- `--innodb_buffer_stats_by_schema` +- `--innodb_buffer_stats_by_table` +- `--processlist` +- `--schema_auto_increment_columns` +- `--schema_index_statistics` +- `--schema_object_overview` +- `--schema_redundant_indexes` +- `--schema_table_lock_waits` +- `--schema_table_statistics` +- `--schema_table_statistics_with_buffer` +- `--schema_tables_with_full_table_scans` +- `--schema_unused_indexes` +- `--session` +- `--statement_analysis` +- `--statements_with_errors_or_warnings` +- `--statements_with_full_table_scans` +- `--statements_with_runtimes_in_95th_percentile` +- `--statements_with_sorting` +- `--statements_with_temp_tables` +- `--yaml` + +### ➖ CLI Options Deprecated +- `--data` +- `--template` + +## ✅ Laboratory Verification Results + +- [x] Automated TDD suite passed. +- [x] Multi-DB version laboratory execution validated. +- [x] Performance indicator delta analysis completed. diff --git a/tests/repro_issue_20.t b/tests/test_issue_20.t similarity index 100% rename from tests/repro_issue_20.t rename to tests/test_issue_20.t diff --git a/tests/repro_issue_22.t b/tests/test_issue_22.t similarity index 100% rename from tests/repro_issue_22.t rename to tests/test_issue_22.t diff --git a/tests/issue_33.t b/tests/test_issue_33.t similarity index 100% rename from tests/issue_33.t rename to tests/test_issue_33.t diff --git a/tests/issue_36.t b/tests/test_issue_36.t similarity index 100% rename from tests/issue_36.t rename to tests/test_issue_36.t diff --git a/tests/issue_37.t b/tests/test_issue_37.t similarity index 100% rename from tests/issue_37.t rename to tests/test_issue_37.t diff --git a/tests/issue_42.t b/tests/test_issue_42.t similarity index 100% rename from tests/issue_42.t rename to tests/test_issue_42.t diff --git a/tests/test_issue_480.t b/tests/test_issue_480.t new file mode 100644 index 000000000..11ef15504 --- /dev/null +++ b/tests/test_issue_480.t @@ -0,0 +1,59 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; + +# Load MySQLTuner +require './mysqltuner.pl'; +require './tests/MySQLTuner/TestHelper.pm'; + +# Force redefinition of essential subs +no warnings 'redefine'; +*main::execute_system_command = sub { return ""; }; +*main::which = sub { return "/usr/bin/mysql" }; +*main::infoprint = sub { }; +*main::goodprint = sub { }; +*main::badprint = sub { }; +*main::subheaderprint = sub { }; +*main::debugprint = sub { }; + +# Mock globals +$main::good = '[OK]'; +$main::bad = '[!!]'; +$main::info = '[--]'; +$main::deb = '[DG]'; +$main::end = ''; +our %myvar; +our %mystat; +our %mycalc; +our @adjvars; + +subtest 'table_open_cache_instances recommendation' => sub { + MySQLTuner::TestHelper::reset_state(); + + no warnings 'redefine'; + local *main::logical_cpu_cores = sub { return 8 }; + local *main::select_one = sub { + my $q = shift; + return 100 if $q =~ /COUNT\(\*\)/; + return 0; + }; + + main::mysql_stats(); + + ok(grep(/table_open_cache_instances \(=\s*4\)/, @main::adjvars), 'Suggested 4 instances for 8 CPU cores'); + + MySQLTuner::TestHelper::reset_state(); + $main::mycalc{'table_cache_hit_rate'} = 10; + local *main::logical_cpu_cores = sub { return 64 }; + local *main::select_one = sub { + my $q = shift; + return 100 if $q =~ /COUNT\(\*\)/; + return 0; + }; + main::mysql_stats(); + ok(grep(/table_open_cache_instances \(=\s*16\)/, @main::adjvars), 'Suggested max 16 instances for 64 CPU cores'); +}; + +done_testing(); diff --git a/tests/repro_issue_490.t b/tests/test_issue_490.t similarity index 100% rename from tests/repro_issue_490.t rename to tests/test_issue_490.t diff --git a/tests/test_issue_517.t b/tests/test_issue_517.t new file mode 100644 index 000000000..014c935e3 --- /dev/null +++ b/tests/test_issue_517.t @@ -0,0 +1,168 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +# Mock environment - set BEFORE require +$main::devnull = '/dev/null'; +$main::is_win = 0; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); + +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +# Mock helper functions to avoid printing or exiting +{ + no warnings 'redefine'; + *main::infoprint = sub { diag "INFO: $_[0]" }; + *main::badprint = sub { diag "BAD: $_[0]" }; + *main::goodprint = sub { diag "GOOD: $_[0]" }; + *main::debugprint = sub { diag "DEBUG: $_[0]" }; + *main::subheaderprint = sub { diag "SUBHEADER: $_[0]" }; + *main::get_pf_memory = sub { return 0 }; + *main::get_gcache_memory = sub { return 0 }; + *main::mysql_cloud_discovery = sub { return "none" }; + *main::pretty_uptime = sub { return "1 day" }; + *main::get_other_process_memory = sub { return 0 }; + *main::select_array = sub { return () }; + *main::select_one = sub { return 0 }; + *main::execute_system_command = sub { return "" }; + eval '*main::is_remote = sub () { return 0; };'; +} + +sub get_base_mock_vars { + return ( + 'version' => '5.7.35', + 'version_comment' => 'MySQL Community Server', + 'read_buffer_size' => 1024, + 'read_rnd_buffer_size' => 1024, + 'sort_buffer_size' => 1024, + 'thread_stack' => 1024, + 'join_buffer_size' => 1024, + 'binlog_cache_size' => 1024, + 'tmp_table_size' => 1024, + 'max_heap_table_size' => 1024, + 'max_connections' => 10, + 'key_buffer_size' => 5000, + 'innodb_buffer_pool_size' => 10000, + 'innodb_additional_mem_pool_size' => 1024, + 'innodb_log_buffer_size' => 1024, + 'query_cache_size' => 64 * 1024 * 1024, + 'query_cache_type' => 'ON', + 'aria_pagecache_buffer_size' => 1024, + 'long_query_time' => 10, + 'log_bin' => 'OFF', + 'have_innodb' => 'YES', + 'open_files_limit' => 1024, + 'thread_cache_size' => 8, + 'concurrent_insert' => 'AUTO', + ); +} + +sub get_base_mock_stats { + return ( + 'Questions' => 1500, + 'Max_used_connections' => 5, + 'Uptime' => 86400, + 'Qcache_free_memory' => 32 * 1024 * 1024, + 'Qcache_hits' => 1000, + 'Com_select' => 1500, + 'Qcache_lowmem_prunes' => 0, + 'Connections' => 100, + 'Aborted_connects' => 0, + 'Key_read_requests' => 100, + 'Key_reads' => 0, + 'Key_write_requests' => 100, + 'Key_writes' => 0, + 'Slow_queries' => 0, + 'Key_blocks_unused' => 100, + 'Table_locks_immediate' => 100, + 'Table_locks_waited' => 0, + 'Created_tmp_tables' => 10, + 'Opened_tables' => 10, + 'Open_tables' => 10, + 'Threads_cached' => 5, + 'Bytes_sent' => 1000, + 'Bytes_received' => 1000, + 'Threads_created' => 2, + ); +} + +subtest 'slow_query_log is OFF' => sub { + @main::generalrec = (); + @main::adjvars = (); + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + get_base_mock_vars(), + slow_query_log => 'OFF', + log_slow_queries => 'ON', # Should be overridden by slow_query_log + ); + %main::mystat = ( + get_base_mock_stats(), + ); + %main::mycalc = (); + + eval { main::calculations(); main::mysql_stats(); }; + ok(!$@, 'calculations() did not crash') or diag("Crashed: $@"); + + my $found = grep { /Enable the slow query log/ } @main::generalrec; + ok($found, 'Should recommend enabling slow query log when slow_query_log is OFF'); +}; + +subtest 'slow_query_log is ON' => sub { + @main::generalrec = (); + @main::adjvars = (); + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + get_base_mock_vars(), + slow_query_log => 'ON', + log_slow_queries => 'OFF', # Should be overridden by slow_query_log + ); + %main::mystat = ( + get_base_mock_stats(), + ); + %main::mycalc = (); + + eval { main::calculations(); main::mysql_stats(); }; + ok(!$@, 'calculations() did not crash') or diag("Crashed: $@"); + + my $found = grep { /Enable the slow query log/ } @main::generalrec; + ok(!$found, 'Should not recommend enabling slow query log when slow_query_log is ON'); +}; + +subtest 'log_slow_queries fallback is OFF' => sub { + @main::generalrec = (); + @main::adjvars = (); + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + get_base_mock_vars(), + log_slow_queries => 'OFF', + ); + %main::mystat = ( + get_base_mock_stats(), + ); + %main::mycalc = (); + + eval { main::calculations(); main::mysql_stats(); }; + ok(!$@, 'calculations() did not crash') or diag("Crashed: $@"); + + my $found = grep { /Enable the slow query log/ } @main::generalrec; + ok($found, 'Should recommend enabling slow query log when log_slow_queries fallback is OFF'); +}; + +done_testing(); diff --git a/tests/repro_issue_605.t b/tests/test_issue_605.t similarity index 100% rename from tests/repro_issue_605.t rename to tests/test_issue_605.t diff --git a/tests/issue_770.t b/tests/test_issue_770.t similarity index 100% rename from tests/issue_770.t rename to tests/test_issue_770.t diff --git a/tests/issue_774.t b/tests/test_issue_774.t similarity index 100% rename from tests/issue_774.t rename to tests/test_issue_774.t diff --git a/tests/issue_777.t b/tests/test_issue_777.t similarity index 100% rename from tests/issue_777.t rename to tests/test_issue_777.t diff --git a/tests/issue_781.t b/tests/test_issue_781.t similarity index 100% rename from tests/issue_781.t rename to tests/test_issue_781.t diff --git a/tests/issue_782.t b/tests/test_issue_782.t similarity index 100% rename from tests/issue_782.t rename to tests/test_issue_782.t diff --git a/tests/issue_783.t b/tests/test_issue_783.t similarity index 100% rename from tests/issue_783.t rename to tests/test_issue_783.t diff --git a/tests/repro_issue_863.t b/tests/test_issue_863.t similarity index 100% rename from tests/repro_issue_863.t rename to tests/test_issue_863.t diff --git a/tests/issue_863_enhanced.t b/tests/test_issue_863_enhanced.t similarity index 100% rename from tests/issue_863_enhanced.t rename to tests/test_issue_863_enhanced.t diff --git a/tests/issue_864.t b/tests/test_issue_864.t similarity index 100% rename from tests/issue_864.t rename to tests/test_issue_864.t diff --git a/tests/issue_869.t b/tests/test_issue_869.t similarity index 100% rename from tests/issue_869.t rename to tests/test_issue_869.t diff --git a/tests/issue_881_887.t b/tests/test_issue_881_887.t similarity index 100% rename from tests/issue_881_887.t rename to tests/test_issue_881_887.t diff --git a/tests/issue_888.t b/tests/test_issue_888.t similarity index 100% rename from tests/issue_888.t rename to tests/test_issue_888.t diff --git a/tests/issue_896.t b/tests/test_issue_896.t similarity index 100% rename from tests/issue_896.t rename to tests/test_issue_896.t diff --git a/tests/issue_904.t b/tests/test_issue_904.t similarity index 100% rename from tests/issue_904.t rename to tests/test_issue_904.t diff --git a/tests/issue_913.t b/tests/test_issue_913.t similarity index 100% rename from tests/issue_913.t rename to tests/test_issue_913.t diff --git a/tests/issue_923.t b/tests/test_issue_923.t similarity index 98% rename from tests/issue_923.t rename to tests/test_issue_923.t index c2d0e8938..3b8576796 100644 --- a/tests/issue_923.t +++ b/tests/test_issue_923.t @@ -26,8 +26,8 @@ no warnings 'redefine'; no warnings 'uninitialized'; *main::debugprint = sub { }; *main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ }; -*main::human_size = sub { return $_[0] }; -*main::hr_bytes = sub { return $_[0] }; +*main::human_size = sub { return $_[0] // '0B' }; +*main::hr_bytes = sub { return $_[0] // '0B' }; *main::percentage = sub { return 0 }; *main::badprint = sub { }; *main::infoprint = sub { }; @@ -213,6 +213,7 @@ subtest 'temptable_max_mmap disk space recommendations check' => sub { 'pct_writes' => 50, 'pct_temp_disk' => 0, 'thread_cache_hit_rate' => 90, + 'table_cache_hit_rate' => 99, 'total_sorts' => 0, 'joins_without_indexes_per_day' => 0, ); diff --git a/tests/test_issue_927.t b/tests/test_issue_927.t new file mode 100644 index 000000000..342cc8c21 --- /dev/null +++ b/tests/test_issue_927.t @@ -0,0 +1,241 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; + +use Test::More tests => 2; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +# Load mysqltuner.pl as a library +my $script_dir = dirname( abs_path(__FILE__) ); +my $script = + abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) ); +require $script; +require './tests/MySQLTuner/TestHelper.pm'; + +# Mock global variables +our %myvar; +our %mystat; +our %mycalc; +our $physical_memory; +our $swap_memory; +our @generalrec; +our @adjvars; + +# Setup common mocks globally +no warnings 'redefine'; +no warnings 'uninitialized'; +*main::debugprint = sub { }; +*main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ }; +*main::human_size = sub { return $_[0] // '0B' }; +*main::hr_bytes = sub { return $_[0] // '0B' }; +*main::hr_bytes_rnd = sub { return $_[0] // '0B' }; +*main::hr_num = sub { + my $val = shift; + return 0 unless defined $val; + + # simple formatting for tests + 1 while $val =~ s/(\d+)(\d{3})/$1,$2/; + return $val; +}; +*main::percentage = sub { return 0 }; +*main::select_one = sub { return 0 }; +*main::select_array = sub { return () }; +*main::mysql_version_ge = sub { return 1 }; +*main::mysql_version_le = sub { return 0 }; +*main::mysql_version_eq = sub { return 0 }; +*main::execute_system_command = sub { return "" }; +*main::get_pf_memory = sub { return 0 }; +*main::get_gcache_memory = sub { return 0 }; +*main::is_remote = sub () { return 0 }; +*main::mysql_cloud_discovery = sub { return "none" }; +*main::pretty_uptime = sub { return "1 day" }; +*main::get_other_process_memory = sub { return 0 }; + +subtest 'Query cache efficiency on standard MySQL' => sub { + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + 'version' => '5.7.35', + 'version_comment' => 'MySQL Community Server', + 'read_buffer_size' => 1024, + 'read_rnd_buffer_size' => 1024, + 'sort_buffer_size' => 1024, + 'thread_stack' => 1024, + 'join_buffer_size' => 1024, + 'binlog_cache_size' => 1024, + 'tmp_table_size' => 1024, + 'max_heap_table_size' => 1024, + 'max_connections' => 10, + 'key_buffer_size' => 5000, + 'innodb_buffer_pool_size' => 10000, + 'innodb_additional_mem_pool_size' => 1024, + 'innodb_log_buffer_size' => 1024, + 'query_cache_size' => 64 * 1024 * 1024, + 'query_cache_type' => 'ON', + 'aria_pagecache_buffer_size' => 1024, + 'long_query_time' => 10, + 'log_bin' => 'OFF', + 'have_innodb' => 'YES', + 'open_files_limit' => 1024, + 'thread_cache_size' => 8, + 'concurrent_insert' => 'AUTO', + ); + %main::mystat = ( + 'Questions' => 1500, + 'Max_used_connections' => 5, + 'Uptime' => 86400, + 'Qcache_free_memory' => 32 * 1024 * 1024, + 'Qcache_hits' => 1000, + 'Com_select' => 1500, + 'Qcache_lowmem_prunes' => 0, + 'Connections' => 100, + 'Aborted_connects' => 0, + 'Key_read_requests' => 100, + 'Key_reads' => 0, + 'Key_write_requests' => 100, + 'Key_writes' => 0, + 'Slow_queries' => 0, + 'Key_blocks_unused' => 100, + 'Table_locks_immediate' => 100, + 'Table_locks_waited' => 0, + 'Created_tmp_tables' => 10, + 'Opened_tables' => 10, + 'Open_tables' => 10, + 'Threads_cached' => 5, + 'Bytes_sent' => 1000, + 'Bytes_received' => 1000, + 'Threads_created' => 2, + ); + %main::mycalc = (); + @main::generalrec = (); + @main::adjvars = (); + + eval { main::calculations(); }; + ok( !$@, "calculations completed without crash" ); + is( $main::mycalc{'query_cache_efficiency'}, "40.0", +"MySQL efficiency calculation is Qcache_hits / (Com_select + Qcache_hits) = 40%" + ); + + my @good_prints; + my @bad_prints; + my @info_prints; + local *main::goodprint = sub { push @good_prints, $_[0] }; + local *main::badprint = sub { push @bad_prints, $_[0] }; + local *main::infoprint = sub { push @info_prints, $_[0] }; + local *main::subheaderprint = sub { }; + + eval { main::mysql_stats(); }; + ok( !$@, "mysql_stats completed without crash" ); + + my $found_msg = ""; + for my $msg ( @good_prints, @bad_prints ) { + if ( $msg =~ /Query cache efficiency/ ) { + $found_msg = $msg; + last; + } + } + + like( + $found_msg, + qr/Query cache efficiency: 40\.0% \(1,000 cached \/ 2,500 selects\)/, +"Display text shows correct MySQL hit ratio and total selects (2,500 selects)" + ); +}; + +subtest 'Query cache efficiency on MariaDB' => sub { + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + 'version' => '10.11.14-MariaDB', + 'version_comment' => 'MariaDB Server', + 'read_buffer_size' => 1024, + 'read_rnd_buffer_size' => 1024, + 'sort_buffer_size' => 1024, + 'thread_stack' => 1024, + 'join_buffer_size' => 1024, + 'binlog_cache_size' => 1024, + 'tmp_table_size' => 1024, + 'max_heap_table_size' => 1024, + 'max_connections' => 10, + 'key_buffer_size' => 5000, + 'innodb_buffer_pool_size' => 10000, + 'innodb_additional_mem_pool_size' => 1024, + 'innodb_log_buffer_size' => 1024, + 'query_cache_size' => 64 * 1024 * 1024, + 'query_cache_type' => 'ON', + 'aria_pagecache_buffer_size' => 1024, + 'long_query_time' => 10, + 'log_bin' => 'OFF', + 'have_innodb' => 'YES', + 'open_files_limit' => 1024, + 'thread_cache_size' => 8, + 'concurrent_insert' => 'AUTO', + ); + %main::mystat = ( + 'Questions' => 1500, + 'Max_used_connections' => 5, + 'Uptime' => 86400, + 'Qcache_free_memory' => 32 * 1024 * 1024, + 'Qcache_hits' => 1000, + 'Com_select' => 1500, + 'Qcache_lowmem_prunes' => 0, + 'Connections' => 100, + 'Aborted_connects' => 0, + 'Key_read_requests' => 100, + 'Key_reads' => 0, + 'Key_write_requests' => 100, + 'Key_writes' => 0, + 'Slow_queries' => 0, + 'Key_blocks_unused' => 100, + 'Table_locks_immediate' => 100, + 'Table_locks_waited' => 0, + 'Created_tmp_tables' => 10, + 'Opened_tables' => 10, + 'Open_tables' => 10, + 'Threads_cached' => 5, + 'Bytes_sent' => 1000, + 'Bytes_received' => 1000, + 'Threads_created' => 2, + ); + %main::mycalc = (); + @main::generalrec = (); + @main::adjvars = (); + + eval { main::calculations(); }; + ok( !$@, "calculations completed without crash" ); + is( $main::mycalc{'query_cache_efficiency'}, + "66.7", + "MariaDB efficiency calculation is Qcache_hits / Com_select = 66.7%" ); + + my @good_prints; + my @bad_prints; + my @info_prints; + local *main::goodprint = sub { push @good_prints, $_[0] }; + local *main::badprint = sub { push @bad_prints, $_[0] }; + local *main::infoprint = sub { push @info_prints, $_[0] }; + local *main::subheaderprint = sub { }; + + eval { main::mysql_stats(); }; + ok( !$@, "mysql_stats completed without crash" ); + + my $found_msg = ""; + for my $msg ( @good_prints, @bad_prints ) { + if ( $msg =~ /Query cache efficiency/ ) { + $found_msg = $msg; + last; + } + } + + like( + $found_msg, + qr/Query cache efficiency: 66\.7% \(1,000 cached \/ 1,500 selects\)/, +"Display text shows correct MariaDB hit ratio and total selects without double-counting (1,500 selects)" + ); +}; + +1; diff --git a/tests/unit_cli_helpers.t b/tests/unit_cli_helpers.t new file mode 100644 index 000000000..1644300c6 --- /dev/null +++ b/tests/unit_cli_helpers.t @@ -0,0 +1,153 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +our $mock_exit_active = 0; +BEGIN { + *CORE::GLOBAL::exit = sub { + my $code = shift // 0; + if ($mock_exit_active) { + die "MOCKED_EXIT: $code\n"; + } + CORE::exit($code); + }; +} + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; +our $mysqlcmd; +our $devnull; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. parse_cli_args +# ===================================================================== +subtest 'parse_cli_args' => sub { + reset_mocks(); + local @ARGV = ('--host', '192.168.1.100', '--user', 'tuner_user'); + main::parse_cli_args(); + is($main::opt{host}, '192.168.1.100', 'host option parsed'); + is($main::opt{user}, 'tuner_user', 'user option parsed'); +}; + +subtest 'parse_cli_args invalid option' => sub { + reset_mocks(); + local @ARGV = ('--invalid-opt-name-xyz'); + local $mock_exit_active = 1; + # Capture STDERR or redirect it to avoid clutter + open my $old_err, '>&', \*STDERR or die "Can't dup STDERR: $!"; + close STDERR; + open STDERR, '>', File::Spec->devnull() or die "Can't redirect STDERR: $!"; + eval { + main::parse_cli_args(); + }; + my $err = $@; + # Restore STDERR + open STDERR, '>&', $old_err or die "Can't restore STDERR: $!"; + close $old_err; + like($err, qr/MOCKED_EXIT: 1/, 'exit code 1 on invalid option'); +}; + +# ===================================================================== +# 2. show_help +# ===================================================================== +subtest 'show_help' => sub { + reset_mocks(); + local $mock_exit_active = 1; + my $help_output = ''; + # Capture STDOUT + open my $old_out, '>&', \*STDOUT or die "Can't dup STDOUT: $!"; + close STDOUT; + open STDOUT, '>', \$help_output or die "Can't redirect STDOUT: $!"; + eval { + main::show_help(); + }; + my $err = $@; + # Restore STDOUT + open STDOUT, '>&', $old_out or die "Can't restore STDOUT: $!"; + close $old_out; + like($err, qr/MOCKED_EXIT: 0/, 'exit code 0 on show_help'); + like($help_output, qr/Usage: \.\/mysqltuner\.pl/, 'help usage output present'); + like($help_output, qr/CONNECTION AND AUTHENTICATION/, 'help categories present'); +}; + +# ===================================================================== +# 3. get_http_cli +# ===================================================================== +subtest 'get_http_cli' => sub { + reset_mocks(); + # Case 1: curl exists + { + no warnings 'redefine'; + local *main::which = sub { + my ($cmd) = @_; + return "/usr/bin/curl\n" if $cmd eq 'curl'; + return ""; + }; + is(main::get_http_cli(), "/usr/bin/curl", 'curl detected'); + } + # Case 2: wget exists (curl does not) + { + no warnings 'redefine'; + local *main::which = sub { + my ($cmd) = @_; + return "/usr/bin/wget\n" if $cmd eq 'wget'; + return ""; + }; + is(main::get_http_cli(), "/usr/bin/wget", 'wget fallback works'); + } + # Case 3: neither exists + { + no warnings 'redefine'; + local *main::which = sub { return ""; }; + is(main::get_http_cli(), "", 'empty returned if neither exists'); + } +}; + +done_testing(); diff --git a/tests/unit_client_privileges.t b/tests/unit_client_privileges.t new file mode 100644 index 000000000..1086ebe3d --- /dev/null +++ b/tests/unit_client_privileges.t @@ -0,0 +1,119 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; +our $mysqlcmd; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. get_tuning_info +# ===================================================================== +subtest 'get_tuning_info' => sub { + reset_mocks(); + no warnings 'redefine'; + local *main::select_array = sub { + return ( + " Connection: localhost via UNIX socket\n", + " Server: Localhost via UNIX socket\n", + ); + }; + main::get_tuning_info(); + is($result{'MySQL Client'}{'Connection'}, 'localhost via UNIX socket', 'parsed client connection'); + is($result{'MySQL Client'}{'Client Path'}, $mysqlcmd, 'Client Path set'); +}; + +# ===================================================================== +# 2. check_privileges +# ===================================================================== +subtest 'check_privileges' => sub { + # Case 1: ALL PRIVILEGES + { + reset_mocks(); + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT ALL PRIVILEGES ON *.* TO 'root'\@'localhost'"); }; + main::check_privileges(); + is(scalar @mock_output, 0, 'No warning output when ALL PRIVILEGES present'); + } + # Case 2: SUPER privilege + { + reset_mocks(); + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT SUPER ON *.* TO 'root'\@'localhost'"); }; + main::check_privileges(); + is(scalar @mock_output, 0, 'No warning output when SUPER present'); + } + # Case 3: Missing privileges on MySQL 8.0 + { + reset_mocks(); + $main::myvar{'version'} = '8.0.35'; + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); }; + local *main::mysql_version_ge = sub { return 1; }; + main::check_privileges(); + ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing privileges'); + } + # Case 4: Missing privileges on MariaDB 10.5 + { + reset_mocks(); + $main::myvar{'version'} = '10.5.25-MariaDB'; + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); }; + local *main::mysql_version_ge = sub { + my ($major, $minor) = @_; + return 1 if $major == 10 && $minor == 5; + return 0; + }; + main::check_privileges(); + ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing MariaDB privileges'); + } +}; + +done_testing(); diff --git a/tests/unit_cloud_commands.t b/tests/unit_cloud_commands.t new file mode 100644 index 000000000..a15a1e6e2 --- /dev/null +++ b/tests/unit_cloud_commands.t @@ -0,0 +1,133 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. infoprintcmd & infoprinthcmd +# ===================================================================== +subtest 'infoprintcmd & infoprinthcmd' => sub { + reset_mocks(); + my @cmd_prints; + my @info_prints; + my @header_prints; + no warnings 'redefine'; + local *main::cmdprint = sub { push @cmd_prints, $_[0] }; + local *main::infoprintml = sub { push @info_prints, @_ }; + local *main::subheaderprint = sub { push @header_prints, $_[0] }; + + main::infoprintcmd("echo 'test_infoprintcmd'"); + is(scalar @cmd_prints, 1, 'cmdprint called once'); + like($cmd_prints[0], qr/echo 'test_infoprintcmd'/, 'cmdprint received command'); + ok(grep({ /test_infoprintcmd/ } @info_prints), 'infoprintml printed output of command'); + + main::infoprinthcmd("My Header", "echo 'test_infoprinthcmd'"); + is($header_prints[0], "My Header", 'subheaderprint called with header'); + like($cmd_prints[1], qr/echo 'test_infoprinthcmd'/, 'infoprintcmd called inside infoprinthcmd'); +}; + +# ===================================================================== +# 2. cloud_setup +# ===================================================================== +subtest 'cloud_setup' => sub { + # Case 1: direct connection mode + { + reset_mocks(); + $main::opt{cloud} = 1; + $main::opt{'ssh-host'} = undef; + $main::opt{forcemem} = 0; + main::cloud_setup(); + is($main::opt{cloud}, 1, 'cloud is 1'); + is($main::opt{nosysstat}, 1, 'nosysstat is set to 1 in direct mode'); + is($main::opt{forcemem}, 1024, 'forcemem defaults to 1024'); + } + # Case 2: ssh connection mode with command outputs + { + reset_mocks(); + $main::opt{cloud} = 1; + $main::opt{'ssh-host'} = 'my-remote-db'; + $main::opt{forcemem} = 0; + $main::opt{forceswap} = 0; + no warnings 'redefine'; + local *main::execute_system_command = sub { + my ($cmd) = @_; + if ($cmd =~ /uname/) { + return "Linux remote-host 5.4.0\n"; + } + elsif ($cmd =~ /MemTotal/) { + return "MemTotal: 4194304 kB\n"; # 4GB + } + elsif ($cmd =~ /SwapTotal/) { + return "SwapTotal: 2097152 kB\n"; # 2GB + } + return ""; + }; + main::cloud_setup(); + is($main::opt{forcemem}, 4096, 'remote memory detected and set'); + is($main::opt{forceswap}, 2048, 'remote swap detected and set'); + } + # Case 3: ssh connection mode but commands fail (default fallback) + { + reset_mocks(); + $main::opt{cloud} = 1; + $main::opt{'ssh-host'} = 'my-remote-db'; + $main::opt{forcemem} = 0; + $main::opt{forceswap} = 0; + no warnings 'redefine'; + local *main::execute_system_command = sub { return (); }; + main::cloud_setup(); + is($main::opt{forcemem}, 1024, 'forcemem fallback to 1024'); + is($main::opt{forceswap}, 0, 'forceswap fallback to 0'); + } +}; + +done_testing(); diff --git a/tests/unit_fs_info.t b/tests/unit_fs_info.t new file mode 100644 index 000000000..2ac3d2b65 --- /dev/null +++ b/tests/unit_fs_info.t @@ -0,0 +1,103 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. get_fs_info & get_fs_info_win +# ===================================================================== +subtest 'get_fs_info' => sub { + reset_mocks(); + no warnings 'redefine'; + local *main::execute_system_command = sub { + my ($cmd) = @_; + if ($cmd =~ /df -P/) { + return ( + "Filesystem 1024-blocks Used Available Capacity Mounted on\n", + "/dev/sda1 10485760 9437184 1048576 90% /\n", # >85% + "/dev/sda2 10485760 5242880 5242880 50% /home\n", # <=85% + ); + } + elsif ($cmd =~ /df -Pi/) { + return ( + "Filesystem Inodes IUsed IFree IUse% Mounted on\n", + "/dev/sda1 1000000 900000 100000 90% /\n", # >85% + "/dev/sda2 1000000 500000 500000 50% /home\n", # <=85% + ); + } + return (); + }; + main::get_fs_info(); + is($result{'Filesystem'}{'Space Pct'}{'/'}, 90, 'root Space Pct parsed'); + is($result{'Filesystem'}{'Space Pct'}{'/home'}, 50, 'home Space Pct parsed'); + is($result{'Filesystem'}{'Inode Pct'}{'/'}, 90, 'root Inode Pct parsed'); + ok(grep({ /mount point \/ is using 90 % total space/ } @mock_output), 'warning on high disk usage printed'); + ok(grep({ /mount point \/ is using 90 % of max allowed inodes/ } @mock_output), 'warning on high inode usage printed'); +}; + +subtest 'get_fs_info_win' => sub { + reset_mocks(); + no warnings 'redefine'; + local *main::execute_system_command = sub { + return ( + "FreeSpace Name Size\n", + "1073741824 C: 10737418240\n", # 90% used + "5368709120 D: 10737418240\n", # 50% used + ); + }; + main::get_fs_info_win(); + is($result{'Filesystem'}{'Space Pct'}{'C:'}, 90, 'Disk C Space Pct parsed'); + is($result{'Filesystem'}{'Space Pct'}{'D:'}, 50, 'Disk D Space Pct parsed'); + ok(grep({ /Disk C: is using 90 % total space/ } @mock_output), 'warning on high windows disk usage printed'); +}; + +done_testing(); diff --git a/tests/unit_os_vm.t b/tests/unit_os_vm.t new file mode 100644 index 000000000..9591f5b78 --- /dev/null +++ b/tests/unit_os_vm.t @@ -0,0 +1,114 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. get_os_release +# ===================================================================== +subtest 'get_os_release' => sub { + reset_mocks(); + my $release = main::get_os_release(); + ok(defined $release && $release ne '', "get_os_release returns some string: $release"); +}; + +# ===================================================================== +# 2. is_virtual_machine +# ===================================================================== +subtest 'is_virtual_machine' => sub { + # Case 1: Linux with ssh prefix + { + reset_mocks(); + local $^O = 'linux'; + no warnings 'redefine'; + local *main::get_transport_prefix = sub { return 'ssh'; }; + local *main::execute_system_command = sub { + my ($cmd) = @_; + return 1 if $cmd =~ /grep -Ec/; + return 0; + }; + is(main::is_virtual_machine(), 1, 'Virtual machine detected via ssh hypervisor grep'); + } + # Case 2: Linux local check + { + reset_mocks(); + local $^O = 'linux'; + no warnings 'redefine'; + local *main::get_transport_prefix = sub { return ''; }; + my $is_vm = main::is_virtual_machine(); + ok(defined $is_vm, 'Local VM detection check executed'); + } + # Case 3: FreeBSD check + { + reset_mocks(); + local $^O = 'freebsd'; + no warnings 'redefine'; + local *main::execute_system_command = sub { + my ($cmd) = @_; + return "bhyve\n" if $cmd =~ /sysctl/; + return "none\n"; + }; + is(main::is_virtual_machine(), 1, 'FreeBSD VM detected'); + } + # Case 4: Windows check + { + reset_mocks(); + local $main::is_win = 1; + no warnings 'redefine'; + local *main::execute_system_command = sub { + return "System Model: Virtual Machine\n"; + }; + is(main::is_virtual_machine(), 1, 'Windows VM model detected'); + } +}; + +done_testing(); diff --git a/tests/unit_phase13_kpis.t b/tests/unit_phase13_kpis.t new file mode 100644 index 000000000..d55ea357d --- /dev/null +++ b/tests/unit_phase13_kpis.t @@ -0,0 +1,202 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use File::Temp qw(tempfile); + +# Get the script path +my $script_path = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl')); + +# Load MySQLTuner logic +{ + local @ARGV = ('--silent'); + package main; + require $script_path; +} + +package main; + +# Write a mock JSON file for historical delta testing +my ( $mock_old_fh, $mock_old_file ) = tempfile( 'mock_old_XXXX', SUFFIX => '.json', UNLINK => 1 ); +print $mock_old_fh q({ + "General": { "Date": "2026-06-01" }, + "Stats": { + "QPS": 10.0, + "Total Data Size": 83886080 + }, + "HealthScore": 80, + "SectionalHealthScore": { + "General": 95, + "Storage": 70, + "Security": 90, + "Replication": 100, + "Modeling": 90 + } +}); +close($mock_old_fh); + +# 1. Test get_load_average with mock file2string or fallback to execute_system_command +{ + no warnings 'redefine'; + local *main::file2string = sub { + my $path = shift; + if ($path eq '/proc/loadavg') { + return "1.25 0.80 0.50 2/250 12345\n"; + } + return undef; + }; + + my @load = main::get_load_average(); + is_deeply(\@load, ['1.25', '0.80', '0.50'], "get_load_average should parse /proc/loadavg correctly"); +} + +{ + no warnings 'redefine'; + local *main::file2string = sub { return undef; }; + local *main::execute_system_command = sub { + my $cmd = shift; + if ($cmd eq 'uptime') { + return " 01:25:00 up 10 days, 1:23, 1 user, load average: 0.75, 1.10, 1.35\n"; + } + return undef; + }; + + my @load = main::get_load_average(); + is_deeply(\@load, ['0.75', '1.10', '1.35'], "get_load_average should fallback to uptime command"); +} + +# 2. Reset and Mock Sectional Indicators & Health Scores +sub reset_state { + @main::generalrec = (); + @main::sysrec = (); + @main::secrec = (); + @main::modeling = (); + @main::adjvars = (); + %main::mycalc = (); + %main::mystat = (); + %main::myrepl = (); + %main::result = (); + $main::physical_memory = 16 * 1024 * 1024 * 1024; # 16 GB + $main::swap_memory = 2 * 1024 * 1024 * 1024; # 2 GB +} + +# 2a. Test Optimal state: All scores 100 +reset_state(); +$main::mystat{'Uptime'} = 100000; # > 86400 +$main::mycalc{'pct_read_efficiency'} = 98.0; +$main::mycalc{'pct_temp_disk'} = 10.0; +$main::mycalc{'table_cache_hit_rate'} = 80.0; +$main::mystat{'Questions'} = 200000; +$main::mystat{'Innodb_buffer_pool_read_requests'} = 500000; + +# Load mock setup for loadavg +{ + no warnings 'redefine'; + local *main::get_load_average = sub { return (0.1, 0.2, 0.3) }; + local *main::get_other_process_memory = sub { return 1024 * 1024; }; # tiny + main::calculate_sectional_health_scores(); +} + +is($main::result{'SectionalHealthScore'}{'General'}, 100, "Optimal General score should be 100"); +is($main::result{'SectionalHealthScore'}{'Storage'}, 100, "Optimal Storage score should be 100"); +is($main::result{'SectionalHealthScore'}{'Security'}, 100, "Optimal Security score should be 100"); +is($main::result{'SectionalHealthScore'}{'Replication'}, 100, "Optimal Replication score should be 100"); +is($main::result{'SectionalHealthScore'}{'Modeling'}, 100, "Optimal SQL Modeling score should be 100"); + +# 2b. Test deductions +reset_state(); +$main::mystat{'Uptime'} = 5000; # < 86400 -> -20 +$main::mycalc{'pct_read_efficiency'} = 90.0; # < 95 -> -25 +$main::mycalc{'pct_temp_disk'} = 30.0; # > 25 -> -25 +push @main::secrec, "Vulnerable configuration."; # 1 item -> -15 +push @main::modeling, "Missing primary key."; # 1 item -> -10 + +{ + no warnings 'redefine'; + local *main::get_load_average = sub { return (8.0, 8.0, 8.0) }; + $main::mycalc{'cpu_cores'} = 4; # load_1 / cpu_cores = 2.0 > 1.0 -> -20 + local *main::get_other_process_memory = sub { return 8 * 1024 * 1024 * 1024; }; # 50% RAM -> -20 + main::calculate_sectional_health_scores(); +} + +# Print diagnostics for deduction test +diag("--- Diagnostic Info ---"); +diag("General Recs: " . join(", ", @main::generalrec)); +diag("Other Mem: " . main::get_other_process_memory()); +diag("Load Avg: " . join(", ", main::get_load_average())); +diag("Calculated General Score: " . $main::result{'SectionalHealthScore'}{'General'}); + +is($main::result{'SectionalHealthScore'}{'General'}, 40, "General score should deduct for uptime, load, and other processes memory"); +is($main::result{'SectionalHealthScore'}{'Storage'}, 50, "Storage score should deduct for read efficiency and temp disk tables"); +is($main::result{'SectionalHealthScore'}{'Security'}, 85, "Security score should deduct for 1 recommendation"); +is($main::result{'SectionalHealthScore'}{'Modeling'}, 90, "SQL Modeling score should deduct for 1 modeling finding"); + +# 3. Test Resource Saturation & Throughput Efficiency Index +reset_state(); +$main::mystat{'Uptime'} = 10000; +$main::mycalc{'pct_max_physical_memory'} = "88%"; +$main::mycalc{'pct_connections_used'} = "75.5%"; +$main::mycalc{'pct_read_efficiency'} = 97.5; # disk read pressure = 2.5 * 20 = 50% +$main::mycalc{'pct_temp_disk'} = 40.0; # max(50, 40) = 50% IO sat +$main::mystat{'Questions'} = 50000; # QPS = 5.0 +$main::mystat{'Innodb_buffer_pool_read_requests'} = 100000; # Reads/sec = 10.0 +# TEI = QPS / Reads/sec = 5.0 / 10.0 = 0.5 + +{ + no warnings 'redefine'; + local *main::get_load_average = sub { return (2.5, 2.5, 2.5) }; + $main::mycalc{'cpu_cores'} = 4; # CPU sat = 2.5 / 4 = 62.5% -> 62% + local *main::get_other_process_memory = sub { return 0; }; + main::calculate_sectional_health_scores(); +} + +is($main::result{'ResourceSaturation'}{'CPU'}, 62, "CPU saturation calculation"); +is($main::result{'ResourceSaturation'}{'Memory'}, 88, "Memory saturation calculation"); +is($main::result{'ResourceSaturation'}{'Connections'}, 75, "Connections saturation calculation"); +is($main::result{'ResourceSaturation'}{'IO'}, 50, "Disk I/O saturation calculation"); + +is($main::result{'ThroughputEfficiency'}{'QPS'}, 5.0, "Throughput Efficiency QPS"); +is($main::result{'ThroughputEfficiency'}{'LogicalReadsSec'}, 10.0, "Throughput Efficiency Reads/sec"); +is($main::result{'ThroughputEfficiency'}{'Index'}, 0.5, "Throughput Efficiency Index (TEI)"); + +# 4. Test get_top_findings ranking logic +reset_state(); +push @main::secrec, "Unencrypted connection detected."; # Critical keyword +push @main::secrec, "Check password complexity."; # Standard finding +push @main::secrec, "Accounts with empty password (risk)."; # Critical keyword (contains risk) +push @main::secrec, "Some minor issue."; + +my @top_sec = main::get_top_findings(\@main::secrec); +is(scalar(@top_sec), 3, "get_top_findings returns at most 3 items"); +is($top_sec[0]->{badge}, "Critical", "First item should be critical due to keyword"); +is($top_sec[1]->{badge}, "Critical", "Second item should be critical due to keyword"); +is($top_sec[2]->{badge}, "Finding", "Third item should be standard finding"); + +# 5. Test historical delta trend analysis +reset_state(); +$main::result{'Stats'}{'QPS'} = 12.5; +$main::result{'HealthScore'} = 85; +$main::result{'Stats'}{'Total Data Size'} = 104857600; # 100MB +$main::result{'SectionalHealthScore'}{'General'} = 90; +$main::result{'SectionalHealthScore'}{'Storage'} = 80; +$main::result{'SectionalHealthScore'}{'Security'} = 95; +$main::result{'SectionalHealthScore'}{'Replication'} = 100; +$main::result{'SectionalHealthScore'}{'Modeling'} = 90; + +{ + no warnings 'redefine'; + $main::opt{'compare-file'} = $mock_old_file; + main::historical_comparison(); +} + +is($main::result{'Trends'}{'SnapshotDate'}, '2026-06-01', "Snapshot date correctly parsed"); +like($main::result{'Trends'}{'QPS'}, qr/\+25\.00%/, "QPS trend delta should show +25.00%"); +like($main::result{'Trends'}{'HealthScore'}, qr/\+5/, "Health score trend delta should show +5"); +like($main::result{'Trends'}{'TotalDataSize'}, qr/80.0M -> 100.0M \(20.0M\)/, "Data growth should show correct formatting"); +is($main::result{'Trends'}{'Sectional'}{'General'}, '95 -> 90 (-5)', "General score delta trend"); +is($main::result{'Trends'}{'Sectional'}{'Storage'}, '70 -> 80 (+10)', "Storage score delta trend"); + +done_testing(); diff --git a/tests/unit_versions.t b/tests/unit_versions.t index 64fec04ad..a6e31e31d 100644 --- a/tests/unit_versions.t +++ b/tests/unit_versions.t @@ -1,8 +1,8 @@ use strict; use warnings; no warnings 'once'; -no warnings 'once'; use Test::More; +use File::Temp qw(tempfile); use File::Basename; use File::Spec; use Cwd 'abs_path'; @@ -11,24 +11,152 @@ use Cwd 'abs_path'; $SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; # Load mysqltuner.pl as a library -my $script_dir = dirname(abs_path(__FILE__)); -my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +my $script_dir = dirname( abs_path(__FILE__) ); +my $script = + abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) ); require $script; # 1. Test compare_tuner_version # This function is not pure, it uses global $tunerversion and prints. subtest 'compare_tuner_version' => sub { no warnings 'redefine'; - local *main::goodprint = sub { }; - local *main::badprint = sub { }; + local *main::goodprint = sub { }; + local *main::badprint = sub { }; local *main::update_tuner_version = sub { }; - + $main::tunerversion = "2.8.33"; - + # It returns undef, so we just check if it runs without crashing for now # or check the behavior if we mocked the prints to capture output. - ok(defined eval { main::compare_tuner_version("2.8.33"); 1 }, "Runs with same version"); - ok(defined eval { main::compare_tuner_version("2.9.0"); 1 }, "Runs with newer version"); + ok( defined eval { main::compare_tuner_version("2.8.33"); 1 }, + "Runs with same version" ); + ok( defined eval { main::compare_tuner_version("2.9.0"); 1 }, + "Runs with newer version" ); +}; + +subtest 'mysql_version comparisons with cache' => sub { + $main::myvar{'version'} = '8.0.33'; + ok( main::mysql_version_eq( 8, 0, 33 ), '8.0.33 eq 8.0.33' ); + ok( main::mysql_version_eq( 8, 0 ), '8.0.33 eq 8.0' ); + ok( main::mysql_version_eq(8), '8.0.33 eq 8' ); + ok( !main::mysql_version_eq( 5, 7 ), '8.0.33 not eq 5.7' ); + + ok( main::mysql_version_ge( 8, 0, 30 ), '8.0.33 ge 8.0.30' ); + ok( main::mysql_version_ge( 8, 0 ), '8.0.33 ge 8.0' ); + ok( !main::mysql_version_ge( 9, 0 ), '8.0.33 not ge 9.0' ); + + ok( main::mysql_version_le( 8, 0, 35 ), '8.0.33 le 8.0.35' ); + ok( main::mysql_version_le( 9, 0 ), '8.0.33 le 9.0' ); + ok( !main::mysql_version_le( 5, 7 ), '8.0.33 not le 5.7' ); +}; + +subtest '_to_yaml serialization' => sub { + my $data = { + name => 'MySQLTuner', + version => '2.9.0', + special => '2:9#0', + active => 1, + details => { + perf_bp => 10, + empty_val => '', + }, + list => [ 'a', 'b', { k => 'v' } ], + }; + + my $yaml = main::_to_yaml($data); + + # Check that strings are generated correctly + like( $yaml, qr/name: MySQLTuner/, 'Plain scalar serialized correctly' ); + like( $yaml, qr/version: 2\.9\.0/, 'Unquoted scalar serialized correctly' ); + like( + $yaml, + qr/special: '2:9#0'/, + 'Quoted scalar (with special chars) serialized correctly' + ); + like( $yaml, qr/active: 1/, 'Number serialized correctly' ); + like( $yaml, qr/empty_val: ''/, 'Empty string serialized correctly' ); + like( $yaml, qr/perf_bp: 10/, 'Nested hash scalar serialized correctly' ); + like( $yaml, qr/list:/, 'Array prefix serialized correctly' ); + like( $yaml, qr/-\s+a/, 'Array items serialized correctly' ); + like( $yaml, qr/-\s+k:\s+v/, + 'Array with nested hash serialized correctly' ); +}; + +subtest 'historical_comparison health score' => sub { + no warnings 'redefine'; + my $captured_trend; + local *main::infoprint = sub { + my $msg = shift; + if ( $msg =~ /Health Score Trend/ ) { + $captured_trend = $msg; + } + }; + local *main::badprint = sub { }; + + # Prepare current results + $main::result{'HealthScore'} = 85; + + # Mocking reading from file + my $compare_json = +'{"General":{"Date":"2026-06-15"},"HealthScore":70,"Stats":{"QPS":1.5,"Total Data Size":1000}}'; + + my ( $tfh, $temp_file ) = tempfile( 'temp_compare_XXXX', SUFFIX => '.json', UNLINK => 1 ); + print $tfh $compare_json; + close $tfh; + + local $main::opt{'compare-file'} = $temp_file; + main::historical_comparison(); + + ok( defined $captured_trend, + "Health score trend comparison was triggered" ); + like( + $captured_trend, + qr/70 -> 85 \(\+15\)/, + "Trend correctly shows +15 improvement" + ); +}; + +subtest '_sanitized_result_for_export' => sub { + my $mock_result = { + 'MySQLTuner' => { + 'options' => { + 'host' => 'localhost', + 'pass' => 'mysecretpassword', + 'password' => 'anothersecret', + 'ssh-password' => 'sshsec', + 'passenv' => 'ENVVAR', + 'userenv' => 'USERVAR', + } + }, + 'MySQL Client' => { + 'Authentication Info' => "mysql -u root -p'mysecretpassword' -h localhost" + } + }; + + my $sanitized = main::_sanitized_result_for_export($mock_result); + + # Original remains unmodified + is($mock_result->{'MySQLTuner'}{'options'}{'pass'}, 'mysecretpassword', 'Original remains unmodified'); + + # Sanitized has redacted fields + is($sanitized->{'MySQLTuner'}{'options'}{'host'}, 'localhost', 'Host is kept'); + is($sanitized->{'MySQLTuner'}{'options'}{'pass'}, '[REDACTED]', 'pass is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'password'}, '[REDACTED]', 'password is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'ssh-password'}, '[REDACTED]', 'ssh-password is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'passenv'}, '[REDACTED]', 'passenv is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'userenv'}, '[REDACTED]', 'userenv is redacted'); + + # Authentication Info is sanitized + like($sanitized->{'MySQL Client'}{'Authentication Info'}, qr/-p'\[REDACTED\]'/, 'Authentication Info password in quotes is redacted'); + + # Test without quotes + my $mock_result2 = { + 'MySQL Client' => { + 'Authentication Info' => "mysql -u root -psecret -h localhost" + } + }; + my $sanitized2 = main::_sanitized_result_for_export($mock_result2); + like($sanitized2->{'MySQL Client'}{'Authentication Info'}, qr/-p\[REDACTED\]/, 'Authentication Info password without quotes is redacted'); }; done_testing(); diff --git a/tests/verbose_timing.t b/tests/verbose_timing.t new file mode 100644 index 000000000..dfb84840e --- /dev/null +++ b/tests/verbose_timing.t @@ -0,0 +1,86 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +# Suppress warnings from mysqltuner.pl initialization +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Load mysqltuner.pl as a library +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +require $script; + +# Ensure verbose is on, and clear state +$main::opt{'verbose'} = 1; +@main::section_timings = (); +@main::raw_output_lines = (); +$main::tuner_start_time = (eval { require Time::HiRes; Time::HiRes::time(); } || time()) - 1.0; +$main::current_section_name = undef; + +# Call subheaderprint twice to trigger timing print of first section +main::subheaderprint("Section A"); +$main::current_section_start = $main::current_section_start - 0.5; # Fake elapsed time of 0.5s +main::subheaderprint("Section B"); + +# Check if timing for Section A was printed +my @section_a_lines = grep { /Section A execution time:/ } @main::raw_output_lines; +ok(scalar @section_a_lines > 0, "Execution time for Section A is printed when verbose is enabled"); +like($section_a_lines[0], qr/Section A execution time: 0\.\d+s/, "Duration format is correct"); + +# Run print_execution_timings +@main::raw_output_lines = (); +main::print_execution_timings(); + +# Check if summary block is printed with percentages +my @summary_lines = grep { /Section B\s*:/ || /Total Execution Time:/ } @main::raw_output_lines; +ok(scalar @summary_lines >= 2, "Summary block and total execution time are printed at the end"); +my @pct_lines = grep { /\(\d+\.\d+%\)/ } @main::raw_output_lines; +ok(scalar @pct_lines >= 2, "Percentages are printed next to each section duration"); + +# Run again with verbose = 0 and verify NO timings are printed +$main::opt{'verbose'} = 0; +@main::section_timings = (); +@main::raw_output_lines = (); +$main::current_section_name = undef; + +main::subheaderprint("Section A"); +main::subheaderprint("Section B"); +main::print_execution_timings(); + +my @no_timing_lines = grep { /execution time:/ || /Total Execution Time:/ } @main::raw_output_lines; +is(scalar @no_timing_lines, 0, "No timing messages are printed when verbose is disabled"); + +# 3. Test print_audit_snapshot_summary +subtest 'print_audit_snapshot_summary' => sub { + @main::raw_output_lines = (); + $main::tunerversion = "9.9.9"; + $main::opt{'host'} = "127.0.0.1"; + $main::opt{'port'} = 3307; + $main::physical_memory = 16 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + $main::myvar{'version'} = "10.11.4-MariaDB"; + $main::mystat{'Uptime'} = 172800; + + # We mock select_one so that we don't try to query database for CURRENT_USER + no warnings 'redefine'; + local *main::select_one = sub { return 'root@localhost'; }; + + main::print_audit_snapshot_summary(); + + my $output = join("\n", @main::raw_output_lines); + like($output, qr/Audit Snapshot Summary/, "Header printed"); + like($output, qr/MySQLTuner Version : 9\.9\.9/, "MySQLTuner version matched"); + like($output, qr/Server Connection : 127\.0\.0\.1:3307/, "Connection matched"); + like($output, qr/Database User : root\@localhost/, "Database user matched"); + like($output, qr/Database Version : 10\.11\.4-MariaDB/, "Database version matched"); + like($output, qr/System Physical RAM: 16\.0G/, "System RAM matched"); + like($output, qr/System Swap Memory : 4\.0G/, "System Swap matched"); + like($output, qr/Database Uptime : 2d 0h 0m 0s/, "Database uptime matched"); +}; + +done_testing();