diff --git a/COMMIT_AND_RELEASE.md b/COMMIT_AND_RELEASE.md new file mode 100644 index 000000000..efe1eb381 --- /dev/null +++ b/COMMIT_AND_RELEASE.md @@ -0,0 +1,143 @@ +# MySQLTuner Commit and Release Process Guide + +This document describes the mandatory workflow for committing changes and releasing new versions of MySQLTuner. Following this process ensures quality, formatting consistency, version integrity, and metadata compliance. + +--- + +## 🛠️ 1. Commit Process + +Every contribution to MySQLTuner must pass through a strict formatting, code generation, testing, and commit linting pipeline before being pushed. + +### Step 1.1: Development Branching +All changes (features, bug fixes, chore, etc.) MUST be done in a dedicated Git branch separated from `master`. Committing directly to the `master` branch is strictly prohibited. + +### Step 1.2: Code Formatting +Ensure that `mysqltuner.pl` matches the project formatting standard: +```bash +make tidy +``` +*Behind the scenes*: This formats the code with `perltidy` using the project's config [.perltidy](file:///.perltidy) and cleans up file line endings with `dos2unix`. + +To check if the formatting is correct without modifying the file: +```bash +make check-tidy +``` + +### Step 1.3: Generate Required Assets +If your changes affect CLI options, documentation, vulnerability lists, or support metadata, run the appropriate generators before committing: +* **Documentation (USAGE.md)**: Rebuild the markdown usage guide from the Perl POD: + ```bash + make generate_usage + ``` +* **Features List (FEATURES.md)**: Re-extract subroutines list: + ```bash + make generate_features + ``` +* **CVE Vulnerabilities (vulnerabilities.csv)**: Fetch the latest security vulnerability definitions: + ```bash + make generate_cve + ``` +* **End-of-Life Support Files (mysql_support.md, mariadb_support.md)**: Re-extract MySQL and MariaDB EOL dates: + ```bash + make generate_eof_files + ``` +* **Current Version File (CURRENT_VERSION.txt)**: Keep the version file in sync: + ```bash + make generate_version_file + ``` + +### Step 1.4: Run Automated Tests +Validate your changes locally using both unit tests and multi-database lab testing: +1. **Unit & Regression Tests**: + ```bash + make unit-tests + # or + prove -r tests/ + ``` +2. **Laboratory Tests (Docker)**: + Ensure code executes correctly across multiple database versions (MySQL, MariaDB, Percona Server): + ```bash + make test + # or run all environments + make test-all + ``` + +### Step 1.5: Commit via Conventional Commits +All commits must follow the standard [Conventional Commits](https://www.conventionalcommits.org/) specification: +* **Allowed Types**: `feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `style`, `test`, `ci` +* **Format**: `(): ` followed by optional body/footer. +* **Interactive Tool**: To guarantee compliance, commit using: + ```bash + npm run commit + # or + git cz + ``` + +### Step 1.6: Commit Hooks Enforcement +Husky enforces validation at commit time: +* **`pre-commit` Hook**: Automatically triggers `npm test` (`prove tests/*.t`). If unit tests fail, the commit is blocked. +* **`commit-msg` Hook**: Validates the commit message structure against Conventional Commit rules using `commitlint`. + +--- + +## 🚀 2. Release Process + +The release lifecycle is governed by automated pre-flight checks and note generators to guarantee stability and release integrity. + +### Step 2.1: Open a Release Branch +Cut a release branch named after the target version (e.g., `v2.8.42`): +```bash +git checkout -b vX.XX.XX +``` + +### Step 2.2: Synchronize Version Numbers +Ensure the target version is synchronized across all of the following locations: +1. [CURRENT_VERSION.txt](file:///CURRENT_VERSION.txt) +2. [mysqltuner.pl](file:///mysqltuner.pl) header (`# mysqltuner.pl - Version X.XX.XX`) +3. [mysqltuner.pl](file:///mysqltuner.pl) internal variable (`our $tunerversion = "X.XX.XX"`) +4. [mysqltuner.pl](file:///mysqltuner.pl) POD Name (`MySQLTuner X.XX.XX - MySQL High Performance`) +5. [mysqltuner.pl](file:///mysqltuner.pl) POD Version (`Version X.XX.XX`) +6. [Changelog](file:///Changelog) latest version header line (`X.XX.XX YYYY-MM-DD`) + +To update version strings automatically across the codebase, use one of: +```bash +make increment_sub_version # Bumps micro/sub version (e.g. 2.8.41 -> 2.8.42) +make increment_minor_version # Bumps minor version (e.g. 2.8.41 -> 2.9.0) +make increment_major_version # Bumps major version (e.g. 2.8.41 -> 3.0.0) +``` + +### Step 2.3: Update the Changelog & Generate Release Notes +1. Add detailed bullet points in [Changelog](file:///Changelog) under the new version header, categorized by Conventional Commit types (`chore`, `feat`, `fix`, `test`, `ci`, etc.). +2. Run the `/release-notes-gen` workflow (or script directly) to analyze the changelog, delta indicator metrics, and generate/update the corresponding release notes file: + ```bash + python3 build/release_gen.py + ``` + *Behind the scenes*: This compiles the release summary, diagnostic growth statistics, commit differences, and CLI modifications into [releases/](file:///releases/) (e.g., `releases/v2.8.42.md`). + +### Step 2.4: Execute Release Preflight Checks +Run the preflight checks to guarantee zero configuration mismatch and 100% compliance: +```bash +/release-preflight +``` +*Behind the scenes*: This workflow: +1. Verifies version consistency across files (via [tests/version_consistency.t](file:///tests/version_consistency.t)). +2. Verifies that release notes exist in `releases/v[VERSION].md`. +3. Checks that all commit messages follow conventional commits since the last tag. +4. Checks project documentation formatting and metadata compliance. +5. Validates `mysqltuner.pl` code formatting (`make check-tidy`). +6. Runs the smoke test suite (`make test`). + +### Step 2.5: Tag and Push (Unified Release Manager) +The final tag and push sequences are automated by the `/release-manager` workflow: +1. Verify you are on the release branch. +2. Commit all synchronized documentation and release notes. +3. Perform release tagging: + ```bash + git tag -a vX.XX.XX -m "Release X.XX.XX" -m "Release notes contents..." + ``` +4. Push the branch and release tag: + ```bash + git push origin vX.XX.XX + git push origin refs/tags/vX.XX.XX + ``` +5. Merge back into `master` and ensure tags are force pushed to origin to sync the workspace. diff --git a/CURRENT_VERSION.txt b/CURRENT_VERSION.txt index 26991ded6..6c7b14fe2 100644 --- a/CURRENT_VERSION.txt +++ b/CURRENT_VERSION.txt @@ -1 +1 @@ -2.8.41 +2.8.42 diff --git a/Changelog b/Changelog index b5727306c..b22883e45 100644 --- a/Changelog +++ b/Changelog @@ -1,5 +1,14 @@ # MySQLTuner Changelog +2.8.42 2026-05-25 + +- chore: bump version to 2.8.42. +- fix: resolve fake aborted connections count increase during password strength checks (#900). +- fix: resolve EOF metadata corruption and duplicated configurations in Emacs block (#904). +- ci: optimize release notes generation to isolate branch changes. +- perf: optimize --dumpdir performance by excluding heavy RDS/Aurora and internal metrics. + + 2.8.41 2026-05-17 - fix: filter MySQL CLI password warning from execute_system_command output. diff --git a/FEATURES.md b/FEATURES.md index b3bc77c2e..77d156677 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,6 +2,7 @@ Features list for option: --feature (dev only) --- +* adjust_aborted_connects * build_mysql_connection_command * calculate_health_score * cloud_setup @@ -47,6 +48,7 @@ Features list for option: --feature (dev only) * predictive_capacity_analysis * process_sysbench_metrics * push_recommendation +* save_aborted_connects_state * security_recommendations * setup_environment * show_help diff --git a/MEMORY_DB.md b/MEMORY_DB.md index 30715a7b6..347f0cfb0 100644 --- a/MEMORY_DB.md +++ b/MEMORY_DB.md @@ -1,6 +1,6 @@ # MySQLTuner-perl Version Memory -## Current Version: 2.8.41 +## Current Version: 2.8.42 ## Project Evolution & Systemic Findings @@ -18,6 +18,9 @@ Migrated several external commands to native Core Perl to reduce fork overhead a - `uptime` -> `/proc/uptime` parsing or `$^T` calculation ### Recent Audits +- **v2.8.42**: + - Optimized `--dumpdir` analysis by skipping heavy views and AWS-specific metrics. + - Fixed fake aborted connections count increase when performing password strength checks (#900). - **v2.8.41**: - Completed project-wide refactoring to use standard Perl Boolean practices. - Restored Debian maintenance account automatic login functionality (#896). diff --git a/POTENTIAL_ISSUES b/POTENTIAL_ISSUES index 8189a282d..8622e6c12 100644 --- a/POTENTIAL_ISSUES +++ b/POTENTIAL_ISSUES @@ -148,7 +148,7 @@ The following external commands are currently used via `execute_system_command` - [x] **Unit Tests Stability**: 100% pass (53 files, 262 tests). - [x] **Regression Cleanliness**: No new `uninitialized value` or `Syntax error` found in Standard, Dumpdir, or Schemadir scenarios. -## [2026-04-13 Audit] Development v2.8.41 +## [2026-05-17 Audit] Development v2.8.41 ### [v2.8.41] Laboratory Verification @@ -167,3 +167,12 @@ The following external commands are currently used via `execute_system_command` - [x] **SQL Execution Failure**: Discovered during audit. - Found in: `examples/20260429_112608_mysql96/Container/execution.log` (Line 10 and 12). - Fix: MySQL 9.x `mysql` client removed support for `\G` and `\s` in batch mode (`-e`). Replaced `\G` with `-E` flag natively in `select_array` and `select_one_g`. Skipped error prints for `\s` if it fails natively. + +## [2026-05-25 Audit] Development v2.8.42 + +### [v2.8.42] Laboratory Verification + +- [x] **Unit Tests Stability**: 100% pass (54 files, 265 tests). +- [x] **Aborted Connections Counter Fix**: Verified via unit tests (`tests/test_issue_900.t`) that the fake aborted connections increase during password strength checking is successfully prevented. +- [x] **Dumpdir Exclusions**: Verified that heavy tables/views are successfully skipped to avoid query timeouts. + diff --git a/README.md b/README.md index 49772fd0a..ede2ce67f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [!["Buy Us A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) [![Project Status](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) -[![MySQLTuner Version](https://img.shields.io/badge/version-2.8.41-blue.svg)](https://github.com/jmrenouard/MySQLTuner-perl/releases/tag/v2.8.41) +[![MySQLTuner Version](https://img.shields.io/badge/version-2.8.42-blue.svg)](https://github.com/jmrenouard/MySQLTuner-perl/releases/tag/v2.8.42) [![Test Status](https://github.com/anuraghazra/github-readme-stats/workflows/Test/badge.svg)](https://github.com/anuraghazra/github-readme-stats/) [![Average time to resolve an issue](https://isitmaintained.com/badge/resolution/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Average time to resolve an issue") [![Percentage of open issues](https://isitmaintained.com/badge/open/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Percentage of issues still open") diff --git a/ROADMAP.md b/ROADMAP.md index 2ea92ee6d..9c3b0c38f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -65,9 +65,15 @@ To ensure consistency and high-density development, the following roles are defi * [x] **Cluster & Replication Intelligence**: * [x] Root cause analysis for replication lag (IO/SQL thread contention). * [ ] GTID consistency checks and multi-source replication tuning. +* [x] **Consolidated SQL Modeling & Naming Conventions**: + * [x] Consolidated Primary Key naming, surrogate keys, table singular naming, and table/column casing checks into single-line counters in General recommendations. + * [x] Implemented advanced dominant style detection and deviations audit for tables, views, indexes, and columns. +* [ ] **CSV Export Enhancements**: + * [ ] Export naming convention deviations (tables, views, indexes, columns), primary key naming/surrogate key issues, missing foreign keys, JSON columns without virtual columns, and insecure authentication plugins to separate CSV files. * [/] **Security Hardening 2.0**: * [ ] Version-based CVE exposure detection (community-fed database). * [x] Advanced encryption-at-rest (TDE) and SSL/TLS cipher suite validation. + * [ ] **Extended Authentication Plugins Audit**: Verify password hashing methods against the extended plugins support matrix (including `mysql_native_password`, `mysql_old_password`, `sha256_password`, `caching_sha2_password`, `unix_socket`, `ed25519`, and the new MariaDB `parsec` plugin). See [AUTHENTICATION_PLUGINS.md](file:///documentation/AUTHENTICATION_PLUGINS.md). * [/] **Guided Auto-Fix Engine**: * [ ] Interactive mode to simulate configuration changes. * [x] Generation of ready-to-use `SET GLOBAL` or `my.cnf` snippets. @@ -183,6 +189,16 @@ To ensure consistency and high-density development, the following roles are defi * [ ] **Compression & Efficiency**: * [ ] **On-the-fly Compression**: Support for compressed `.gz` exports to minimize disk footprint in container/limited-storage environments. +## 🔮 Strategic Technical Evolutions + +* [ ] 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. +* [ ] Support localized versions of the reference documentation matching other translations of the script (e.g. Italian, French, Russian). +* [ ] **Automated Changelog Formatting Verification**: Implement a Git pre-commit hook that automatically checks if the `Changelog` has been modified when changes of type `feat` or `fix` are detected, preventing commits without changelog documentation. +* [ ] **Containerized Validation Runners**: Standardize local pre-flight checks by executing all verification steps (including unit tests and version consistency checks) inside a standardized, minimal Docker environment to avoid environmental differences between developer environments and CI. +* [ ] **Interactive Release Orchestrator**: Create a script that automates the interactive selection of version bump categories (micro, minor, major), executes the version replacement across all 6 reference locations, and automatically runs the `release_gen.py` script to generate release notes in a single workflow step. + + ## 🤝 Contribution & Feedback We welcome community feedback on this roadmap. If you have specific feature requests or want to contribute to a specific phase, please open an issue on our [GitHub repository](https://github.com/jmrenouard/MySQLTuner-perl). diff --git a/USAGE.md b/USAGE.md index 9daa58f8d..d6ad20062 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,6 +1,6 @@ # NAME - MySQLTuner 2.8.41 - MySQL High Performance Tuning Script + MySQLTuner 2.8.42 - 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.41 +Version 2.8.42 =head1 PERLDOC You can find documentation for this module with the perldoc command. diff --git a/build/release_gen.py b/build/release_gen.py index 9deefd73b..5fa945d79 100644 --- a/build/release_gen.py +++ b/build/release_gen.py @@ -40,6 +40,25 @@ def get_changelog_blocks(): def get_git_commits(version): try: + # Check current branch + branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], stderr=subprocess.DEVNULL).decode().strip() + + # Determine if we can compare with master + has_master = False + for ref in ['master', 'origin/master']: + try: + subprocess.check_call(['git', 'rev-parse', '--verify', ref], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + has_master = ref + break + except subprocess.CalledProcessError: + continue + + if has_master and branch != 'master': + # We are on a branch, get commits between master (or origin/master) and HEAD + commits = subprocess.check_output(['git', 'log', f'{has_master}..HEAD', '--pretty=format:- %s (%h)']).decode().strip() + if commits: + return commits + tag = f"v{version}" # Find the previous tag if it exists try: @@ -47,7 +66,13 @@ def get_git_commits(version): commits = subprocess.check_output(['git', 'log', f'{prev_tag}..{tag}', '--pretty=format:- %s (%h)']).decode().strip() return commits if commits else "No new commits recorded." except: - return "Initial release or no previous tag found." + # Maybe the tag doesn't exist yet, try HEAD instead of tag + try: + prev_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'], stderr=subprocess.DEVNULL).decode().strip() + commits = subprocess.check_output(['git', 'log', f'{prev_tag}..HEAD', '--pretty=format:- %s (%h)']).decode().strip() + return commits if commits else "No new commits recorded." + except: + return "Initial release or no previous tag found." except Exception: return "Commit history unavailable." @@ -91,13 +116,17 @@ def analyze_tech_details(version): # Previous version code try: - prev_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0', f'{tag}^'], stderr=subprocess.DEVNULL).decode().strip() + try: + prev_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0', f'{tag}^'], stderr=subprocess.DEVNULL).decode().strip() + except: + prev_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'], stderr=subprocess.DEVNULL).decode().strip() + old_code = subprocess.check_output(['git', 'show', f'{prev_tag}:mysqltuner.pl']).decode() old_opts = get_cli_options(old_code) old_indicators = analyze_indicators(old_code) old_names = extract_diagnostic_names(old_code) except: - # Fallback to empty if no previous tag + # Fallback to empty if no previous tag at all old_opts = set() old_indicators = {'good':0, 'bad':0, 'info':0, 'total':0} old_names = {'good': set(), 'bad': set(), 'info': set()} diff --git a/documentation/AUTHENTICATION_PLUGINS.md b/documentation/AUTHENTICATION_PLUGINS.md new file mode 100644 index 000000000..a4c7a84a4 --- /dev/null +++ b/documentation/AUTHENTICATION_PLUGINS.md @@ -0,0 +1,15 @@ +# MySQL and MariaDB Authentication Plugins Reference + +This document provides a comprehensive overview of authentication plugins across MySQL and MariaDB, including security levels, deprecation status, and platform support. + +## Summary Table + +| Plugin Name | Description | Algorithm | Security Level | Deprecated / Obsolete | Present in MySQL | Present in MariaDB | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `mysql_native_password` | Historical default authentication method. | SHA-1 | Low | Yes (Removed from MySQL 8.4+ / Deprecated in MariaDB) | ✅ (Obsolete) | ✅ (Historical default) | +| `mysql_old_password` | Ancient pre-4.1 authentication method. | SHA-1 (Old) | Very Low | Yes (Removed) | ❌ | ✅ (Obsolete) | +| `sha256_password` | Authenticates using SHA-256 with salting. | SHA-256 | High | Yes (Due to CPU scalability issues without TLS) | ❌ (Removed in 8.4) | ✅ | +| `caching_sha2_password` | Optimized version of SHA-256 with memory caching. | SHA-256 | High | No | ✅ (Default since 8.0) | ✅ (Since v11.4 for compatibility) | +| `unix_socket` | Authentication via OS-level user identity (UID). | OS Identity | Very High | No | ✅ (as `auth_socket`) | ✅ | +| `ed25519` | Elliptic Curve digital signature algorithm (EdDSA). | Ed25519 | Very High | No | ❌ (Except via third-party Enterprise modules) | ✅ | +| `parsec` | Password Authentication using Response Signed with Elliptic Curve (new MariaDB standard). | PBKDF2 + SHA-512 + Ed25519 | Maximal | No | ❌ | ✅ | diff --git a/documentation/REFERENCES.md b/documentation/REFERENCES.md index e0d32a4f5..b273db6f0 100644 --- a/documentation/REFERENCES.md +++ b/documentation/REFERENCES.md @@ -1,66 +1,77 @@ -# MySQL, MariaDB and Percona References - -This document provides a curated list of official documentations, specialized blogs, and expert resources for MySQL, MariaDB, and Percona Server. - -## 🏛️ Official Documentations by Theme - -### 🚀 Optimization & Performance Tuning -- **MySQL**: [Optimization Chapter](https://dev.mysql.com/doc/refman/8.4/en/optimization.html) (SQL, Data, I/O, Memory) -- **MariaDB**: [Optimization and Tuning KB](https://mariadb.com/kb/en/optimization-and-tuning/) -- **Percona**: [MySQL Performance Optimization Basics](https://www.percona.com/blog/mysql-performance-optimization-basics-2024/) -- **Percona**: [PMM (Monitoring & Management) Documentation](https://www.percona.com/doc/percona-monitoring-and-management/latest/index.html) - -### 🛡️ Security & Access Control -- **MySQL**: [Security Chapter](https://dev.mysql.com/doc/refman/8.4/en/security.html) (Access Control, SSL/TLS) -- **MariaDB**: [User and Server Security KB](https://mariadb.com/kb/en/user-server-security/) -- **Percona**: [Security Best Practices for MySQL](https://www.percona.com/blog/mysql-security-best-practices-2024/) -- **MySQL**: [Password Validation Plugin](https://dev.mysql.com/doc/refman/8.4/en/password-validation.html) - -### ⚙️ Administration & Configuration -- **MySQL**: [Server Administration](https://dev.mysql.com/doc/refman/8.4/en/admin-scripts.html) -- **MariaDB**: [Server System Variables](https://mariadb.com/kb/en/server-system-variables/) -- **Percona**: [Percona Server Configuration](https://www.percona.com/doc/percona-server/latest/configuration.html) -- **MySQL**: [Status Variables](https://dev.mysql.com/doc/refman/8.4/en/server-status-variables.html) - -### 💾 Backup, Restore & Disaster Recovery -- **MySQL**: [Backup and Recovery Guide](https://dev.mysql.com/doc/refman/8.4/en/backup-and-recovery.html) -- **MariaDB**: [Backup and Restore KB](https://mariadb.com/kb/en/backup-and-restore-overview/) -- **Percona**: [Percona XtraBackup Documentation](https://www.percona.com/doc/percona-xtrabackup/latest/index.html) -- **MySQL**: [MySQL Enterprise Backup (MEB)](https://dev.mysql.com/doc/mysql-enterprise-backup/en/) - -### ⛓️ High Availability, Replication & Clustering -- **MySQL**: [Replication Guide](https://dev.mysql.com/doc/refman/8.4/en/replication.html) (Asynchronous, Semi-sync, GTID) -- **MySQL**: [InnoDB Cluster / Group Replication](https://dev.mysql.com/doc/refman/8.4/en/mysql-innodb-cluster-introduction.html) -- **MariaDB**: [Replication KB](https://mariadb.com/kb/en/replication/) -- **MariaDB**: [Galera Cluster Documentation](https://mariadb.com/kb/en/galera-cluster/) -- **Percona**: [Percona XtraDB Cluster (PXC)](https://www.percona.com/doc/percona-xtradb-cluster/8.0/index.html) - -### 🏗️ Storage Engines & Architecture -- **MySQL**: [Alternative Storage Engines](https://dev.mysql.com/doc/refman/8.4/en/storage-engines.html) -- **MariaDB**: [Storage Engines KB](https://mariadb.com/kb/en/storage-engines/) -- **Percona**: [XtraDB (Enhanced InnoDB)](https://www.percona.com/doc/percona-server/8.0/innodb/xtradb.html) -- **Percona**: [MyRocks Documentation](https://www.percona.com/doc/percona-server/8.0/myrocks/index.html) - -## 🏎️ Product Blogs & Engineering - -- [MySQL Server Blog](https://dev.mysql.com/blog/) - News from the MySQL Team (Oracle) -- [MariaDB Foundation Blog](https://mariadb.org/blog/) - Development updates and foundation news -- [Percona Database Performance Blog](https://www.percona.com/blog/) - Deep dives into performance and troubleshooting - -## � Deep Dive Subsections (Expert Articles) - -### InnoDB Internals -- [InnoDB Flushing Mechanisms](https://www.percona.com/blog/2020/01/22/innodb-flushing-in-mysql-8-0-explained/) -- [Dynamic Redo Log Capacity](https://lefred.be/content/mysql-8-0-30-dynamic-innodb-redo-log-capacity/) -- [Primary Key Optimization](https://lefred.be/content/mysql-innodb-primary-keys/) - -### Advanced Clusters -- [Galera Advanced Tuning](https://galeracluster.com/library/training/tutorials/galera-tuning.html) -- [MySQL Shell AdminAPI](https://dev.mysql.com/doc/mysql-shell/8.4/en/admin-api.html) -- [MySQL Router Bootstrapping](https://dev.mysql.com/doc/mysql-router/8.4/en/mysql-router-deploying-bootstrapping.html) - -## 👨‍� Community Experts - -- [lefred.be](https://lefred.be/) - Frédéric Descamps (MySQL Performance) -- [jfg-mysql.blogspot.com](http://jfg-mysql.blogspot.com/) - Jean-François Gagné (MySQL Internals) -- [dasini.net](https://dasini.net/blog/) - Olivier Dasini (MySQL Expert Diary) +# MySQL, MariaDB, and Percona Reference Library + +This document contains a curated list of official documentation, engineering blogs, and expert deep-dives for MySQL, MariaDB, and Percona Server. It serves as a solid baseline for all database performance tuning, security hardening, and SQL modeling practices implemented in MySQLTuner-perl. + +--- + +## 🏛️ Official Documentations by Topic + +### 1. Memory Management & Connection Tuning +- **MySQL**: [Memory Usage Tuning](https://dev.mysql.com/doc/refman/8.4/en/memory-use.html) - Understanding global vs. per-thread buffers. +- **MySQL**: [How MySQL Uses Memory](https://dev.mysql.com/doc/refman/8.4/en/memory-use.html) - Detailed breakdown of join buffers, sort buffers, and thread caches. +- **MySQL**: [Thread Cache Tuning](https://dev.mysql.com/doc/refman/8.4/en/connection-threads.html) - Optimizing connection management and thread reuse. +- **MariaDB**: [Connection & Thread Cache KB](https://mariadb.com/kb/en/thread-cached-variables/) - Thread pooling and system variables. + +### 2. Table Cache & File Descriptors +- **MySQL**: [Table Cache Configuration](https://dev.mysql.com/doc/refman/8.4/en/table-cache.html) - Tuning `table_open_cache` and `table_definition_cache`. +- **MySQL**: [How MySQL Opens and Closes Tables](https://dev.mysql.com/doc/refman/8.4/en/table-cache.html) - Diagnostic details for file descriptor exhaustion. +- **MariaDB**: [Table Design and Performance KB](https://mariadb.com/kb/en/table-cache/) - Optimizing file usage. + +### 3. Temporary Tables & Performance Schema +- **MySQL**: [Internal Temporary Table Use](https://dev.mysql.com/doc/refman/8.4/en/internal-temporary-tables.html) - Memory vs. disk tmp tables (`tmp_table_size`, `max_heap_table_size`). +- **MySQL**: [Performance Schema Startup Configuration](https://dev.mysql.com/doc/refman/8.4/en/performance-schema-startup-configuration.html) - Enabling wait events and query statistics. +- **MariaDB**: [Memory Storage Engine KB](https://mariadb.com/kb/en/memory-storage-engine/) - Dynamic memory allocations. + +### 4. Storage Engine Architecture & Metrics +- **MySQL**: [InnoDB Performance & Tuning](https://dev.mysql.com/doc/refman/8.4/en/innodb-performance.html) - Redo log capacity, flushing algorithms, buffer pools. +- **MySQL**: [MyISAM Key Buffer Tuning](https://dev.mysql.com/doc/refman/8.4/en/myisam-key-cache.html) - In-memory indexing configurations for MyISAM. +- **MariaDB**: [Aria Storage Engine KB](https://mariadb.com/kb/en/aria-storage-engine/) - Checking page caches and crash recovery. +- **Percona**: [MyRocks Engine Documentation](https://www.percona.com/doc/percona-server/8.0/myrocks/index.html) - Log-structured merge-tree (LSM) storage tuning. + +### 5. SQL Modeling & Schema Design +- **MySQL**: [Primary Key Optimization](https://dev.mysql.com/doc/refman/8.4/en/optimizing-primary-keys.html) - Surrogate keys, UUID indexing, and index traversal efficiency. +- **MySQL**: [JSON Datatype Indexing](https://dev.mysql.com/doc/refman/8.4/en/create-table-secondary-indexes.html#json-column-indirect-index) - Secondary indexing via virtual generated columns. +- **MariaDB**: [Invisible Indexes KB](https://mariadb.com/kb/en/invisible-indexes/) - Hiding indexes to test query planner changes. +- **MySQLTuner-perl Specification**: [Naming Conventions & Style Compatibility](file:///documentation/AUTHENTICATION_PLUGINS.md) - Summary of naming styles. + +### 6. Replication, High Availability & Clustering +- **MySQL**: [Group Replication & InnoDB Cluster](https://dev.mysql.com/doc/refman/8.4/en/mysql-innodb-cluster-introduction.html) - Multi-primary setups and flow control. +- **MySQL**: [GTID Replication Guide](https://dev.mysql.com/doc/refman/8.4/en/replication-gtids.html) - Ensuring transactional consistency. +- **MariaDB**: [Galera Cluster Flow Control KB](https://mariadb.com/kb/en/galera-cluster-flow-control-variables/) - Managing queue sizes and replica threads. +- **Percona**: [Percona XtraDB Cluster (PXC) Guide](https://www.percona.com/doc/percona-xtradb-cluster/8.0/index.html) - High availability synchronous replication. + +### 7. Security, Authentication & Access Control +- **MySQL**: [Authentication Plugins Reference](https://dev.mysql.com/doc/refman/8.4/en/authentication-plugins.html) - Cryptographic hashing algorithms and client validation. +- **MySQL**: [caching_sha2_password Transition Guide](https://dev.mysql.com/doc/refman/8.0/en/caching-sha2-pluggable-authentication.html) - Migration from historical SHA-1 plugins. +- **MariaDB**: [User Security & Authentication KB](https://mariadb.com/kb/en/user-server-security/) - Socket, Ed25519, and PARSEC plugin specifications. +- **Percona**: [MySQL Security Hardening Checklist](https://www.percona.com/blog/mysql-security-best-practices-2024/) - Auditing privileges, anonymous accounts, and SSL/TLS cipher suites. + +--- + +## 🏎️ Engineering & Product Blogs + +- [MySQL Server Engineering Blog](https://dev.mysql.com/blog/) - Direct insights from the Oracle MySQL development team. +- [MariaDB Foundation Blog](https://mariadb.org/blog/) - Technical developments and ecosystem announcements. +- [Percona Performance Blog](https://www.percona.com/blog/) - Deep dives, benchmark reports, and operational troubleshooting guides. + +--- + +## 🔬 Deep Dive Expert Articles + +### InnoDB Internals & Performance +- [InnoDB Flushing Mechanisms Explained](https://www.percona.com/blog/2020/01/22/innodb-flushing-in-mysql-8-0-explained/) - Adaptive flushing under write pressure. +- [Dynamic Redo Log Capacity (MySQL 8.0.30+)](https://lefred.be/content/mysql-8-0-30-dynamic-innodb-redo-log-capacity/) - Sizing redo log capacity without server restarts. +- [Primary Key Optimization Guidelines](https://lefred.be/content/mysql-innodb-primary-keys/) - Real-world comparison of surrogate vs. composite PKs. + +### Advanced Clusters & Galera +- [Galera Advanced Performance Tuning](https://galeracluster.com/library/training/tutorials/galera-tuning.html) - Troubleshooting brute-force aborts and certification delays. +- [MySQL Shell AdminAPI Mastery](https://dev.mysql.com/doc/mysql-shell/8.4/en/admin-api.html) - Managing sandbox and production InnoDB Clusters. +- [MySQL Router Bootstrapping & Routing](https://dev.mysql.com/doc/mysql-router/8.4/en/mysql-router-deploying-bootstrapping.html) - High availability client redirection. + +--- + +## 👨‍💻 Community Databases & Experts + +- [lefred.be](https://lefred.be/) - Frédéric Descamps (MySQL Evangelist, InnoDB and backup performance). +- [jfg-mysql.blogspot.com](http://jfg-mysql.blogspot.com/) - Jean-François Gagné (Deep dives into replication lag and Performance Schema wait events). +- [dasini.net](https://dasini.net/blog/) - Olivier Dasini (MySQL certification roadmap and enterprise setups). diff --git a/mysqltuner.pl b/mysqltuner.pl index 11591df92..17fa27267 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -1,5 +1,5 @@ #!/usr/bin/env perl -# mysqltuner.pl - Version 2.8.41 +# mysqltuner.pl - Version 2.8.42 # High Performance MySQL Tuning Script # Copyright (C) 2015-2026 Jean-Marie Renouard - jmrenouard@gmail.com # Copyright (C) 2006-2026 Major Hayden - major@mhtx.net @@ -66,10 +66,12 @@ package main; our $is_win = $^O eq 'MSWin32'; # Set up a few variables for use in the script -our $tunerversion = "2.8.41"; +our $tunerversion = "2.8.42"; our ( @adjvars, @generalrec, @modeling, @sysrec, @secrec ); our ( %result, %myvar, %real_vars, %mystat, %mycalc, %myrepl, %myreplicas, $dummyselect ); +our $failed_connection_attempts = 0; +our $previous_failed_attempts = 0; # Set defaults # Central metadata for CLI options @@ -2830,6 +2832,7 @@ sub get_all_vars { my @mysqlstatlist = select_array("SHOW STATUS"); push( @mysqlstatlist, select_array("SHOW GLOBAL STATUS") ); arr2hash( \%mystat, \@mysqlstatlist ); + adjust_aborted_connects(); $result{'Status'} = \%mystat; unless ( defined( $myvar{'innodb_support_xa'} ) ) { $myvar{'innodb_support_xa'} = 'ON'; @@ -2992,6 +2995,62 @@ sub get_basic_passwords { return get_file_contents(shift); } +sub get_state_file_path { + my $tmpdir = $ENV{TEMP} || $ENV{TMP} || '/tmp'; + my $host = $opt{host} || 'localhost'; + my $port = $opt{port} || '3306'; + my $socket = $opt{socket} || ''; + + $host =~ s/[^a-zA-Z0-9_\.-]/_/g; + $port =~ s/[^a-zA-Z0-9_\.-]/_/g; + $socket =~ s/[^a-zA-Z0-9_\.-]/_/g; + + my $filename = ".mysqltuner_${host}_${port}"; + $filename .= "_${socket}" if $socket ne ''; + + return File::Spec->catfile( $tmpdir, $filename ); +} + +sub adjust_aborted_connects { + my $state_file = get_state_file_path(); + return unless -f $state_file; + + my $uptime = $mystat{'Uptime'} // 0; + + if ( open( my $fh, '<', $state_file ) ) { + my $line = <$fh>; + close($fh); + if ( defined $line ) { + chomp($line); + my ( $stored_uptime, $stored_attempts ) = split( ':', $line ); + $stored_uptime //= 0; + $stored_attempts //= 0; + + if ( $uptime >= $stored_uptime ) { + $previous_failed_attempts = $stored_attempts; + $mystat{'Aborted_connects'} -= $stored_attempts; + $mystat{'Connections'} -= $stored_attempts; + + $mystat{'Aborted_connects'} = 0 + if $mystat{'Aborted_connects'} < 0; + $mystat{'Connections'} = 0 if $mystat{'Connections'} < 0; + } + } + } +} + +sub save_aborted_connects_state { + my $state_file = get_state_file_path(); + my $uptime = $mystat{'Uptime'} // 0; + my $total_attempts = + $previous_failed_attempts + $failed_connection_attempts; + + if ( open( my $fh, '>', $state_file ) ) { + print $fh "$uptime:$total_attempts\n"; + close($fh); + } +} + sub get_log_file_real_path { my $file = shift; my $hostname = shift; @@ -4199,6 +4258,7 @@ sub check_auth_plugins { my $is_mariadb = ( $myvar{'version'} =~ /MariaDB/i ); my $insecure_count = 0; + my $sha256_insecure_count = 0; foreach my $line (@mysqlstatlist) { my ( $user_host, $plugin ) = split( /\t/, $line ); $plugin //= @@ -4224,17 +4284,27 @@ sub check_auth_plugins { $is_mariadb ? "Migrate to 'ed25519' or 'unix_socket' for $user_host" : "Migrate to 'caching_sha2_password' for $user_host"; - push_recommendation( 'Security', "User $user_host: $rec" ); + push @secrec, "User $user_host: $rec"; } elsif ( $plugin eq 'sha256_password' && $mysql_80_plus ) { badprint "User $user_host uses DEPRECATED plugin: $plugin"; - push_recommendation( 'Security', - "User $user_host: Migrate to 'caching_sha2_password'" ); - $insecure_count++; + push @secrec, "User $user_host: Migrate to 'caching_sha2_password'"; + $sha256_insecure_count++; } } - if ( $insecure_count == 0 ) { + if ( $insecure_count > 0 ) { + my $rec = + $is_mariadb + ? "Migrate to 'ed25519' or 'unix_socket' for $insecure_count user(s)" + : "Migrate to 'caching_sha2_password' for $insecure_count user(s)"; + push @generalrec, $rec; + } + if ( $sha256_insecure_count > 0 ) { + push @generalrec, "Migrate to 'caching_sha2_password' for $sha256_insecure_count user(s) (using sha256_password)"; + } + + if ( $insecure_count == 0 && $sha256_insecure_count == 0 ) { goodprint "No users found using insecure or deprecated authentication plugins"; } @@ -4420,87 +4490,224 @@ sub security_recommendations { $skip_dict_check = 1; last; } + else { + $failed_connection_attempts++; + } } unless ($skip_dict_check) { - foreach my $pass (@passwords) { - $nbInterPass++; - last if $nbInterPass > $opt{'max-password-checks'}; - if ( $nbInterPass % 100 == 0 ) { - if ( $myvar{'version'} !~ /mariadb/i - && mysql_version_ge( 8, 0, 0 ) ) + + # Let's check if we can query user list and plugins + my @users_db; + if ( + mysql_version_ge(8) + || ( $myvar{'version'} =~ /mariadb/i + && mysql_version_ge( 10, 4 ) ) + ) + { + if ( $myvar{'version'} =~ /mariadb/i ) { + @users_db = select_array( +"SELECT user, host, JSON_VALUE(Priv, '\$.plugin'), JSON_VALUE(Priv, '\$.authentication_string') FROM mysql.global_priv WHERE user != ''" + ); + } + else { + @users_db = select_array( +"SELECT user, host, plugin, authentication_string FROM mysql.user WHERE user != ''" + ); + } + } + + my $has_digest_sha = eval { require Digest::SHA; 1; }; + my $checked_target_user = 0; + + if (@users_db) { + + # We successfully read the user table. Check all native password users offline! + foreach my $user_line (@users_db) { + my ( $user, $host, $plugin, $auth_string ) = + split( /\t/, $user_line ); + next unless defined $user && $user ne ''; + $plugin //= ''; + $auth_string //= ''; + + if ( + $has_digest_sha + && ( $plugin eq 'mysql_native_password' + || $auth_string =~ /^\*[0-9A-F]{40}$/i ) + ) { - if ( ( $myvar{'performance_schema'} // 'OFF' ) eq 'ON' ) - { - select_one( - "TRUNCATE TABLE performance_schema.host_cache;" - ); + my $target_hash = uc($auth_string); + my $found_weak = 0; + foreach my $pass (@passwords) { + $pass =~ s/\s//g; + chomp($pass); + my @variants = ( $pass, uc($pass), ucfirst($pass) ); + foreach my $v (@variants) { + my $computed = '*' + . uc( + Digest::SHA::sha1_hex( + Digest::SHA::sha1($v) + ) + ); + if ( $computed eq $target_hash ) { + badprint +"User '$user'\@'$host' is using weak password: $v (checked offline)"; + push( @generalrec, +"Set up a Secure Password for '$user'\@'$host' user." + ); + $nbins++; + $found_weak = 1; + last; + } + } + last if $found_weak; + } + if ( $user eq $target_user ) { + $checked_target_user = 1; } } - else { - select_one("FLUSH HOSTS;"); + elsif ( $user eq $target_user ) { + + # Non-native user (like caching_sha2_password), do connection checks + my $found_weak = 0; + foreach my $pass (@passwords) { + $nbInterPass++; + last if $nbInterPass > $opt{'max-password-checks'}; + if ( $nbInterPass % 100 == 0 ) { + if ( $myvar{'version'} !~ /mariadb/i + && mysql_version_ge( 8, 0, 0 ) ) + { + if ( + ( + $myvar{'performance_schema'} + // 'OFF' + ) eq 'ON' + ) + { + select_one( +"TRUNCATE TABLE performance_schema.host_cache;" + ); + } + } + else { + select_one("FLUSH HOSTS;"); + } + } + + $pass =~ s/\s//g; + $pass =~ s/\'/\\\'/g; + chomp($pass); + + my @variants = ( $pass, uc($pass), ucfirst($pass) ); + foreach my $v (@variants) { + my $check_login = + "$mysqllogin -u $target_user -p'$v'"; + my $alive_res = execute_system_command( +"$mysqlcmd -Nrs -e 'select \"mysqld is alive\";' $check_login 2>$devnull" + ); + if ( $alive_res =~ /mysqld is alive/ ) { + badprint +"User '$target_user' is using weak password: $v"; + push( @generalrec, +"Set up a Secure Password for $target_user user." + ); + $nbins++; + $found_weak = 1; + last; + } + else { + $failed_connection_attempts++; + } + } + last if $found_weak; + } + $checked_target_user = 1; } } + } - $pass =~ s/\s//g; - $pass =~ s/\'/\\\'/g; - chomp($pass); +# Fallback connection check if we couldn't query mysql.user or target user was not found/checked + if ( !$checked_target_user ) { + foreach my $pass (@passwords) { + $nbInterPass++; + last if $nbInterPass > $opt{'max-password-checks'}; + if ( $nbInterPass % 100 == 0 ) { + if ( $myvar{'version'} !~ /mariadb/i + && mysql_version_ge( 8, 0, 0 ) ) + { + if ( ( $myvar{'performance_schema'} // 'OFF' ) eq + 'ON' ) + { + select_one( +"TRUNCATE TABLE performance_schema.host_cache;" + ); + } + } + else { + select_one("FLUSH HOSTS;"); + } + } - if ( !mysql_version_ge(8) ) { + $pass =~ s/\s//g; + $pass =~ s/\'/\\\'/g; + chomp($pass); + + if ( !mysql_version_ge(8) ) { # Looking for User with user/ uppercase /capitalise weak password - @mysqlstatlist = - select_array + @mysqlstatlist = select_array( "SELECT CONCAT(user, '\@', host) FROM mysql.user WHERE $PASS_COLUMN_NAME = PASSWORD('" - . $pass - . "') OR $PASS_COLUMN_NAME = PASSWORD(UPPER('" - . $pass - . "')) OR $PASS_COLUMN_NAME = PASSWORD(CONCAT(UPPER(LEFT('" - . $pass - . "', 1)), SUBSTRING('" - . $pass - . "', 2, LENGTH('" - . $pass . "'))))"; - debugprint "There are " - . scalar(@mysqlstatlist) - . " items."; - if (@mysqlstatlist) { - foreach my $line (@mysqlstatlist) { - chomp($line); - badprint "User '" . $line - . "' is using weak password: $pass in a lower, upper or capitalize derivative version."; - - push( @generalrec, + . $pass + . "') OR $PASS_COLUMN_NAME = PASSWORD(UPPER('" + . $pass + . "')) OR $PASS_COLUMN_NAME = PASSWORD(CONCAT(UPPER(LEFT('" + . $pass + . "', 1)), SUBSTRING('" + . $pass + . "', 2, LENGTH('" + . $pass + . "'))))" ); + if (@mysqlstatlist) { + foreach my $line (@mysqlstatlist) { + chomp($line); + badprint "User '" . $line + . "' is using weak password: $pass in a lower, upper or capitalize derivative version."; + push( @generalrec, "Set up a Secure Password for $line user: SET PASSWORD FOR '" - . ( split /@/, $line )[0] . "'\@'" - . ( split /@/, $line )[1] - . "' = PASSWORD('secure_password');" ); - $nbins++; + . ( split /@/, $line )[0] . "'\@'" + . ( split /@/, $line )[1] + . "' = PASSWORD('secure_password');" ); + $nbins++; + } } } - } - else { - # New way to check basic password for MySQL 8.0+ - my $target_user = $opt{user} || 'root'; - my @variants = ( $pass, uc($pass), ucfirst($pass) ); - foreach my $v (@variants) { - my $check_login = "$mysqllogin -u $target_user -p'$v'"; - my $alive_res = execute_system_command( + else { + # New way to check basic password for MySQL 8.0+ + my $found_weak = 0; + my @variants = ( $pass, uc($pass), ucfirst($pass) ); + foreach my $v (@variants) { + my $check_login = + "$mysqllogin -u $target_user -p'$v'"; + my $alive_res = execute_system_command( "$mysqlcmd -Nrs -e 'select \"mysqld is alive\";' $check_login 2>$devnull" - ); - if ( $alive_res =~ /mysqld is alive/ ) { - badprint - "User '$target_user' is using weak password: $v"; - push( @generalrec, -"Set up a Secure Password for $target_user user." ); - $nbins++; - last; + if ( $alive_res =~ /mysqld is alive/ ) { + badprint +"User '$target_user' is using weak password: $v"; + push( @generalrec, +"Set up a Secure Password for $target_user user." + ); + $nbins++; + $found_weak = 1; + last; + } + else { + $failed_connection_attempts++; + } } + last if $found_weak; } } - debugprint "$nbInterPass / " . scalar(@passwords) - if ( $nbInterPass % 1000 == 0 ); } } } @@ -4511,6 +4718,7 @@ sub security_recommendations { } check_auth_plugins(); + save_aborted_connects_state(); } sub get_replication_status { @@ -8318,6 +8526,9 @@ sub mysql_table_structures { AND c.TABLE_SCHEMA NOT IN ('sys', 'mysql', 'information_schema', 'performance_schema')" ); + my $pk_naming_issues_count = 0; + my $bigint_pk_issues_count = 0; + foreach my $pk (@pkInfo) { my ( $schema, $table, $column, $datatype, $columntype ) = split /\t/, $pk; @@ -8331,10 +8542,11 @@ sub mysql_table_structures { if ( $column ne 'id' && $column ne "${table}_id" ) { badprint "Table $schema.$table: Primary key '$column' does not follow 'id' or '${table}_id' naming convention"; - push @generalrec, -"Use 'id' or '${table}_id' for Primary Key naming in $schema.$table"; + # push @generalrec, + # "Use 'id' or '${table}_id' for Primary Key naming in $schema.$table"; push @modeling, "Table $schema.$table: Primary key '$column' does not follow naming convention (id or ${table}_id)"; + $pk_naming_issues_count++; } # Surrogate Key Recommendation @@ -8356,14 +8568,22 @@ sub mysql_table_structures { else { badprint "Table $schema.$table: Primary key '$column' is not a recommended surrogate key (BIGINT UNSIGNED AUTO_INCREMENT)"; - push @generalrec, -"Use BIGINT UNSIGNED AUTO_INCREMENT for Primary Keys in $schema.$table"; + # push @generalrec, + # "Use BIGINT UNSIGNED AUTO_INCREMENT for Primary Keys in $schema.$table"; push @modeling, "Table $schema.$table: Primary key '$column' is not a recommended surrogate key (BIGINT UNSIGNED AUTO_INCREMENT)"; + $bigint_pk_issues_count++; } } } + if ( $pk_naming_issues_count > 0 ) { + push @generalrec, "Use 'id' or '__id' for Primary Key naming in $pk_naming_issues_count table(s)"; + } + if ( $bigint_pk_issues_count > 0 ) { + push @generalrec, "Use BIGINT UNSIGNED AUTO_INCREMENT for Primary Keys in $bigint_pk_issues_count table(s)"; + } + # Large Tables (>1GB) without Secondary Indexes my @largeTablesWithoutIndexes = select_array( "SELECT TABLE_SCHEMA, TABLE_NAME, (DATA_LENGTH + INDEX_LENGTH) @@ -8519,6 +8739,7 @@ sub mysql_80_modeling_checks { my @jsonColumns = select_array( "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM information_schema.columns WHERE DATA_TYPE = 'json' AND TABLE_SCHEMA NOT IN ('sys', 'mysql', 'performance_schema', 'information_schema')" ); + my $json_columns_without_virtual_count = 0; foreach my $jc (@jsonColumns) { my ( $schema, $table, $column ) = split /\t/, $jc; $schema //= ''; @@ -8532,13 +8753,17 @@ sub mysql_80_modeling_checks { if ( scalar(@genCols) == 0 ) { infoprint "Table $schema.$table: JSON column '$column' detected without Virtual Generated Columns for indexing"; - push @generalrec, -"Consider using Generated Columns to index frequently searched attributes in JSON column $schema.$table.$column"; + # push @generalrec, + # "Consider using Generated Columns to index frequently searched attributes in JSON column $schema.$table.$column"; push @modeling, "Table $schema.$table: JSON column '$column' detected without Virtual Generated Columns for indexing"; + $json_columns_without_virtual_count++; $modeling80Count++; } } + if ($json_columns_without_virtual_count > 0) { + push @generalrec, "Consider using Generated Columns to index frequently searched attributes in JSON column in $json_columns_without_virtual_count column(s)"; + } # Invisible Indexes (MySQL: IS_VISIBLE='NO', MariaDB: IGNORED='YES') my $visible_col = $is_mariadb ? "IGNORED" : "IS_VISIBLE"; @@ -8597,15 +8822,65 @@ sub mysql_datatype_optimization { # This is a bit hard to check without looking at table rows and max values } +sub get_compatible_styles { + my ($name) = @_; + return () unless defined $name && $name ne ''; + my @styles; + if ($name =~ /^[a-z0-9]+(?:_[a-z0-9]+)*$/) { + push @styles, 'snake_case'; + } + if ($name =~ /^[a-z0-9]+(?:[A-Z0-9][a-z0-9]*)*$/) { + push @styles, 'camelCase'; + } + if ($name =~ /^[A-Z0-9][a-z0-9]*(?:[A-Z0-9][a-z0-9]*)*$/) { + push @styles, 'PascalCase'; + } + if ($name =~ /^[a-z0-9]+(?:-[a-z0-9]+)*$/) { + push @styles, 'kebab-case'; + } + if ($name =~ /^[A-Z0-9]+(?:_[A-Z0-9]+)*$/) { + push @styles, 'UPPER_SNAKE_CASE'; + } + return @styles; +} + +sub find_dominant_style { + my ($names_ref) = @_; + my %style_counts; + foreach my $name (@$names_ref) { + my @styles = get_compatible_styles($name); + foreach my $style (@styles) { + $style_counts{$style}++; + } + } + my $dominant = 'snake_case'; # Default fallback + my $max_count = 0; + foreach my $style (qw(snake_case camelCase PascalCase kebab-case UPPER_SNAKE_CASE)) { + if (($style_counts{$style} // 0) > $max_count) { + $max_count = $style_counts{$style}; + $dominant = $style; + } + } + return $dominant; +} + sub mysql_naming_conventions { subheaderprint "Naming conventions analysis"; my $namingIssues = 0; + my $plural_table_issues_count = 0; + my $table_style_issues_count = 0; + my $view_style_issues_count = 0; + my $index_style_issues_count = 0; + my $column_style_issues_count = 0; # Table Naming my @tables = select_array( "SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.tables WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA NOT IN ('sys', 'mysql', 'performance_schema', 'information_schema')" ); + my @table_names = map { (split /\t/, $_)[1] // '' } @tables; + my $dominant_table_style = find_dominant_style(\@table_names); + foreach my $t (@tables) { my ( $schema, $table ) = split /\t/, $t; $schema //= ''; @@ -8617,18 +8892,67 @@ sub mysql_naming_conventions { { badprint "Table $schema.$table: Plural name detected (prefer singular)"; - push @generalrec, "Use singular names for table $schema.$table"; + # push @generalrec, "Use singular names for table $schema.$table"; push @modeling, "Table $schema.$table: Plural name detected (prefer singular)"; + $plural_table_issues_count++; $namingIssues++; } - # Casing check (detect CamelCase/PascalCase) - if ( ( $table // '' ) =~ /[a-z][A-Z]/ ) { - badprint "Table $schema.$table: Non-snake_case name detected"; - push @generalrec, "Use snake_case for table $schema.$table"; + # Casing check (detect CamelCase/PascalCase or other non-dominant) + my @compat = get_compatible_styles($table); + my $is_compatible = grep { $_ eq $dominant_table_style } @compat; + if (!$is_compatible) { + badprint "Table $schema.$table: Non-${dominant_table_style} name detected"; + # push @generalrec, "Use snake_case for table $schema.$table"; push @modeling, - "Table $schema.$table: Non-snake_case name detected"; + "Table $schema.$table: Non-${dominant_table_style} name detected"; + $table_style_issues_count++; + $namingIssues++; + } + } + + # View Naming + my @views = select_array( +"SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.tables WHERE TABLE_TYPE = 'VIEW' AND TABLE_SCHEMA NOT IN ('sys', 'mysql', 'performance_schema', 'information_schema')" + ); + my @view_names = map { (split /\t/, $_)[1] // '' } @views; + my $dominant_view_style = find_dominant_style(\@view_names); + + foreach my $v (@views) { + my ( $schema, $view ) = split /\t/, $v; + $schema //= ''; + $view //= ''; + + my @compat = get_compatible_styles($view); + my $is_compatible = grep { $_ eq $dominant_view_style } @compat; + if (!$is_compatible) { + badprint "View $schema.$view: Non-${dominant_view_style} name detected"; + push @modeling, "View $schema.$view: Non-${dominant_view_style} name detected"; + $view_style_issues_count++; + $namingIssues++; + } + } + + # Index Naming + my @indexes = select_array( +"SELECT DISTINCT TABLE_SCHEMA, TABLE_NAME, INDEX_NAME FROM information_schema.statistics WHERE INDEX_NAME != 'PRIMARY' AND TABLE_SCHEMA NOT IN ('sys', 'mysql', 'performance_schema', 'information_schema')" + ); + my @index_names = map { (split /\t/, $_)[2] // '' } @indexes; + my $dominant_index_style = find_dominant_style(\@index_names); + + foreach my $idx (@indexes) { + my ( $schema, $table, $index ) = split /\t/, $idx; + $schema //= ''; + $table //= ''; + $index //= ''; + + my @compat = get_compatible_styles($index); + my $is_compatible = grep { $_ eq $dominant_index_style } @compat; + if (!$is_compatible) { + badprint "Index $schema.$table.$index: Non-${dominant_index_style} name detected"; + push @modeling, "Index $schema.$table.$index: Non-${dominant_index_style} name detected"; + $index_style_issues_count++; $namingIssues++; } } @@ -8637,6 +8961,9 @@ sub mysql_naming_conventions { my @columns = select_array( "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.columns WHERE TABLE_SCHEMA NOT IN ('sys', 'mysql', 'performance_schema', 'information_schema')" ); + my @column_names = map { (split /\t/, $_)[2] // '' } @columns; + my $dominant_column_style = find_dominant_style(\@column_names); + foreach my $c (@columns) { my ( $schema, $table, $column, $datatype ) = split /\t/, $c; $schema //= ''; @@ -8645,13 +8972,16 @@ sub mysql_naming_conventions { $datatype //= ''; # Casing check - if ( ( $column // '' ) =~ /[a-z][A-Z]/ ) { + my @compat = get_compatible_styles($column); + my $is_compatible = grep { $_ eq $dominant_column_style } @compat; + if (!$is_compatible) { badprint - "Column $schema.$table.$column: Non-snake_case name detected"; - push @generalrec, - "Use snake_case for column $schema.$table.$column"; + "Column $schema.$table.$column: Non-${dominant_column_style} name detected"; + # push @generalrec, + # "Use snake_case for column $schema.$table.$column"; push @modeling, - "Column $schema.$table.$column: Non-snake_case name detected"; + "Column $schema.$table.$column: Non-${dominant_column_style} name detected"; + $column_style_issues_count++; $namingIssues++; } @@ -8679,6 +9009,23 @@ sub mysql_naming_conventions { } } } + + if ($plural_table_issues_count > 0) { + push @generalrec, "Use singular names for table in $plural_table_issues_count table(s)"; + } + if ($table_style_issues_count > 0) { + push @generalrec, "Use $dominant_table_style for table in $table_style_issues_count table(s)"; + } + if ($view_style_issues_count > 0) { + push @generalrec, "Use $dominant_view_style for view in $view_style_issues_count view(s)"; + } + if ($index_style_issues_count > 0) { + push @generalrec, "Use $dominant_index_style for index in $index_style_issues_count index(es)"; + } + if ($column_style_issues_count > 0) { + push @generalrec, "Use $dominant_column_style for column in $column_style_issues_count column(s)"; + } + goodprint "No naming convention issues found" if $namingIssues == 0; } @@ -8696,6 +9043,7 @@ sub mysql_foreign_key_checks { AND k.COLUMN_NAME IS NULL AND c.TABLE_SCHEMA NOT IN ('sys', 'mysql', 'performance_schema', 'information_schema')" ); + my $unconstrained_id_count = 0; foreach my $id (@unconstrainedId) { my ( $schema, $table, $column ) = split /\t/, $id; $schema //= ''; @@ -8707,12 +9055,16 @@ sub mysql_foreign_key_checks { badprint "Column $schema.$table.$column ends in '_id' but has no FOREIGN KEY constraint"; - push @generalrec, - "Add FOREIGN KEY constraint to $schema.$table.$column"; + # push @generalrec, + # "Add FOREIGN KEY constraint to $schema.$table.$column"; push @modeling, "Column $schema.$table.$column ends in '_id' but has no FOREIGN KEY constraint"; + $unconstrained_id_count++; $fkIssues++; } + if ($unconstrained_id_count > 0) { + push @generalrec, "Add FOREIGN KEY constraint to $unconstrained_id_count column(s)"; + } # FK Actions my @fkActions = select_array( @@ -11226,7 +11578,9 @@ sub dump_csv_files { # Store all sys schema in dumpdir if defined infoprint("Dumping sys schema"); for my $sys_view ( select_array('use sys;show tables;') ) { - if ( $sys_view =~ /innodb_buffer_stats/ ) { + if ( $sys_view =~ /innodb_buffer_stats/ + or $sys_view =~ /schema_table_statistics_with_buffer/ ) + { infoprint("SKIPPING $sys_view"); next; } @@ -11241,7 +11595,14 @@ sub dump_csv_files { infoprint("Dumping information schema"); for my $info_s_table ( select_array('use information_schema;show tables;') ) { - next if $info_s_table =~ /INNODB_BUFFER_PAGE/; + if ( $info_s_table =~ /INNODB_BUFFER_PAGE/ + or $info_s_table =~ /RDS_CONTROL_PERFORMANCE_INSIGHTS_STATUS/ + or $info_s_table =~ /RDS_METRICS_COUNTER/ + or $info_s_table =~ /RDS_METRICS_GAUGE/ ) + { + infoprint("SKIPPING $info_s_table"); + next; + } infoprint "Dumping $info_s_table into $opt{dumpdir}"; select_csv_file( "$opt{dumpdir}/ifs_${info_s_table}.csv", @@ -11254,7 +11615,10 @@ sub dump_csv_files { for my $info_pf_table ( select_array('use performance_schema;show tables;') ) { - next if $info_pf_table =~ /^events_/; + if ( $info_pf_table =~ /^events_/ ) { + infoprint("SKIPPING $info_pf_table"); + next; + } infoprint "Performance Schema Dumping $info_pf_table into $opt{dumpdir}"; select_csv_file( @@ -11341,7 +11705,7 @@ sub dump_csv_files { =head1 NAME - MySQLTuner 2.8.41 - MySQL High Performance Tuning Script + MySQLTuner 2.8.42 - MySQL High Performance Tuning Script =head1 IMPORTANT USAGE GUIDELINES @@ -11356,7 +11720,7 @@ =head1 OPTIONS =head1 VERSION -Version 2.8.41 +Version 2.8.42 =head1 PERLDOC You can find documentation for this module with the perldoc command. @@ -11573,13 +11937,3 @@ =head1 COPYRIGHT AND LICENSE # cperl-indent-level: 8 # perl-indent-level: 8 # End: -vel: 8 -# perl-indent-level: 8 -# End: - -nd: - -ndent-level: 8 -# End: - -nd: diff --git a/releases/v2.8.42.md b/releases/v2.8.42.md new file mode 100644 index 000000000..e3fec24df --- /dev/null +++ b/releases/v2.8.42.md @@ -0,0 +1,48 @@ +# Release Notes - v2.8.42 + +**Date**: 2026-05-25 + +## 📝 Executive Summary + +```text +2.8.42 2026-05-25 + +- chore: bump version to 2.8.42. +- fix: resolve fake aborted connections count increase during password strength checks (#900). +- fix: resolve EOF metadata corruption and duplicated configurations in Emacs block (#904). +- ci: optimize release notes generation to isolate branch changes. +- perf: optimize --dumpdir performance by excluding heavy RDS/Aurora and internal metrics. +``` + +## 📈 Diagnostic Growth Indicators + +| Metric | Current | Progress | Status | +| :--- | :--- | :--- | :--- | +| Total Indicators | 15 | +2 | 🚀 | +| Efficiency Checks | 0 | 0 | 🛡️ | +| Risk Detections | 2 | 0 | 🛡️ | +| Information Points | 13 | +2 | 🚀 | + +## 🧪 New Diagnostic Capabilities + +### ℹ️ New Information Points +- SKIPPING $info_pf_table +- SKIPPING $info_s_table + +## 🛠️ Internal Commit History + +- docs: finalize changelog and release notes for v2.8.42 (81efefb) +- ci: optimize release notes generation to isolate branch changes (0bcf8bc) +- docs: sync changelog, release notes, memory database and add tests for v2.8.42 (e2a8bd8) +- docs: generate FEATURES.md (54b803c) +- style: tidy mysqltuner.pl (933038c) +- docs: update v2.8.42 release notes and optimize dumpdir exclusions (57b5e6e) +- chore: prepare release v2.8.42 (0a0aaeb) + +## ⚙️ Technical Evolutions + +## ✅ 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/auth_plugin_checks.t b/tests/auth_plugin_checks.t index ccfd11fa1..9fc5f6583 100644 --- a/tests/auth_plugin_checks.t +++ b/tests/auth_plugin_checks.t @@ -38,63 +38,73 @@ subtest 'MySQL 8.0 - mysql_native_password deprecated' => sub { @mocked_results = ("'user1'\@'localhost'\tmysql_native_password"); @badprints = (); @goodprints = (); - @recommendations = (); + @main::generalrec = (); + @main::secrec = (); $main::myvar{'version'} = '8.0.35'; main::check_auth_plugins(); ok(grep(/uses DEPRECATED plugin: mysql_native_password/, @badprints), 'Detected deprecated plugin on MySQL 8.0'); - ok(grep(/Migrate to 'caching_sha2_password'/, $_->{msg}), 'Recommendation for caching_sha2_password') for @recommendations; + ok(grep(/Migrate to 'caching_sha2_password'/, $_), 'Recommendation for caching_sha2_password') for @main::secrec; + is(scalar(grep { $_ eq "Migrate to 'caching_sha2_password' for 1 user(s)" } @main::generalrec), 1, 'Consolidated recommendation pushed to generalrec'); }; subtest 'MySQL 8.4 - mysql_native_password disabled by default' => sub { @mocked_results = ("'user1'\@'localhost'\tmysql_native_password"); @badprints = (); @goodprints = (); - @recommendations = (); + @main::generalrec = (); + @main::secrec = (); $main::myvar{'version'} = '8.4.0'; main::check_auth_plugins(); ok(grep(/uses DISABLED BY DEFAULT plugin: mysql_native_password/, @badprints), 'Detected disabled by default plugin on MySQL 8.4'); + is(scalar(grep { $_ eq "Migrate to 'caching_sha2_password' for 1 user(s)" } @main::generalrec), 1, 'Consolidated recommendation pushed to generalrec'); }; subtest 'MySQL 9.0 - mysql_native_password removed' => sub { @mocked_results = ("'user1'\@'localhost'\tmysql_native_password"); @badprints = (); @goodprints = (); - @recommendations = (); + @main::generalrec = (); + @main::secrec = (); $main::myvar{'version'} = '9.0.0'; main::check_auth_plugins(); ok(grep(/uses REMOVED plugin: mysql_native_password/, @badprints), 'Detected removed plugin on MySQL 9.0'); + is(scalar(grep { $_ eq "Migrate to 'caching_sha2_password' for 1 user(s)" } @main::generalrec), 1, 'Consolidated recommendation pushed to generalrec'); }; subtest 'MariaDB 10.11 - mysql_native_password insecure' => sub { @mocked_results = ("'user1'\@'localhost'\tmysql_native_password"); @badprints = (); @goodprints = (); - @recommendations = (); + @main::generalrec = (); + @main::secrec = (); $main::myvar{'version'} = '10.11.5-MariaDB'; main::check_auth_plugins(); ok(grep(/uses SHA-1 based insecure plugin: mysql_native_password/, @badprints), 'Detected insecure plugin on MariaDB'); - ok(grep(/Migrate to 'ed25519' or 'unix_socket'/, $_->{msg}), 'Recommendation for ed25519/unix_socket') for @recommendations; + ok(grep(/Migrate to 'ed25519' or 'unix_socket'/, $_), 'Recommendation for ed25519/unix_socket') for @main::secrec; + is(scalar(grep { $_ eq "Migrate to 'ed25519' or 'unix_socket' for 1 user(s)" } @main::generalrec), 1, 'Consolidated recommendation pushed to generalrec'); }; subtest 'No insecure plugins' => sub { @mocked_results = ("'user1'\@'localhost'\tcaching_sha2_password"); @badprints = (); @goodprints = (); - @recommendations = (); + @main::generalrec = (); + @main::secrec = (); $main::myvar{'version'} = '8.0.35'; main::check_auth_plugins(); is(scalar @badprints, 0, 'No badprints for secure plugin'); ok(grep(/No users found using insecure or deprecated/, @goodprints), 'Goodprint shown'); + is(scalar @main::generalrec, 0, 'No generalrec recommendations pushed'); }; done_testing(); diff --git a/tests/sql_modeling.t b/tests/sql_modeling.t index e52cf36a9..e88d7a336 100644 --- a/tests/sql_modeling.t +++ b/tests/sql_modeling.t @@ -95,6 +95,10 @@ subtest 'Primary Key Checks (Baseline + Advanced)' => sub { ok(has_output(qr/BAD: Table test_db.logs: UUID primary key 'log_uuid' is not optimized/), 'Non-optimized UUID detected'); ok(has_output(qr/BAD: Table test_db.items: Primary key 'id' is not a recommended surrogate key/), 'Non-unsigned/auto_inc PK detected'); ok(has_output(qr/BAD: Table test_db.big_table is large \(1073741825\) and has no secondary indexes/), 'Large table without secondary indexes detected'); + + # Consolidated generalrec checks + is(scalar(grep { $_ eq "Use 'id' or '_
_id' for Primary Key naming in 2 table(s)" } @main::generalrec), 1, 'Consolidated PK naming recommendation pushed'); + is(scalar(grep { $_ eq "Use BIGINT UNSIGNED AUTO_INCREMENT for Primary Keys in 2 table(s)" } @main::generalrec), 1, 'Consolidated surrogate key recommendation pushed'); }; subtest 'Naming Convention Checks' => sub { @@ -105,6 +109,14 @@ subtest 'Naming Convention Checks' => sub { "test_db\torder_item", # Snake + Singular (Good) "test_db\tOrderItem", # camelCase (Bad) ], + 'tables.*TABLE_TYPE = \'VIEW\'' => [ + "test_db\torder_view", # snake_case (Good) + "test_db\tOrderView", # camelCase (Bad) + ], + 'statistics.*INDEX_NAME != \'PRIMARY\'' => [ + "test_db\torder_item\tidx_shipped_at", # snake_case (Good) + "test_db\torder_item\tidxShippedAt", # camelCase (Bad) + ], 'columns.*TABLE_SCHEMA NOT IN' => [ "test_db\torder_item\tis_active\ttinyint(1)", # Good boolean "test_db\torder_item\tactive\ttinyint(1)", # Bad boolean name @@ -115,15 +127,24 @@ subtest 'Naming Convention Checks' => sub { ); @main::generalrec = (); @mock_output = (); - + main::mysql_naming_conventions(); - + ok(has_output(qr/BAD: Table test_db.users: Plural name detected/), 'Plural table name detected'); ok(has_output(qr/Table test_db.order_item: Plural name detected/) == 0, 'Singular table name ignored'); ok(has_output(qr/BAD: Table test_db.OrderItem: Non-snake_case name detected/), 'camelCase table name detected'); + ok(has_output(qr/BAD: View test_db.OrderView: Non-snake_case name detected/), 'camelCase view name detected'); + ok(has_output(qr/BAD: Index test_db.order_item.idxShippedAt: Non-snake_case name detected/), 'camelCase index name detected'); ok(has_output(qr/BAD: Column test_db.order_item.UserName: Non-snake_case name detected/), 'camelCase column name detected'); ok(has_output(qr/INFO: Column test_db.order_item.active: Boolean-like column missing verbal prefix/), 'Missing boolean prefix detected'); ok(has_output(qr/INFO: Column test_db.order_item.created: Date\/Time column missing explicit suffix/), 'Missing date suffix detected'); + + # General recommendations checks + is(scalar(grep { $_ eq "Use singular names for table in 1 table(s)" } @main::generalrec), 1, 'Consolidated table plural names recommendation pushed'); + is(scalar(grep { $_ eq "Use snake_case for table in 1 table(s)" } @main::generalrec), 1, 'Consolidated table naming style recommendation pushed'); + is(scalar(grep { $_ eq "Use snake_case for view in 1 view(s)" } @main::generalrec), 1, 'Consolidated view naming style recommendation pushed'); + is(scalar(grep { $_ eq "Use snake_case for index in 1 index(es)" } @main::generalrec), 1, 'Consolidated index naming style recommendation pushed'); + is(scalar(grep { $_ eq "Use snake_case for column in 1 column(s)" } @main::generalrec), 1, 'Consolidated column naming style recommendation pushed'); }; subtest 'Foreign Key Checks' => sub { @@ -145,6 +166,9 @@ subtest 'Foreign Key Checks' => sub { ok(has_output(qr/BAD: Column test_db.orders.promo_id ends in '_id' but has no FOREIGN KEY/), 'Unconstrained _id column detected'); ok(has_output(qr/INFO: Constraint on test_db.orders.user_id uses ON DELETE CASCADE/), 'CASCADE action detected'); + # Consolidated check + is(scalar(grep { $_ eq "Add FOREIGN KEY constraint to 1 column(s)" } @main::generalrec), 1, 'Consolidated FK recommendation pushed'); + # FK Type Mismatch Test %mock_queries = ( 'referential_constraints' => [], @@ -204,6 +228,9 @@ subtest 'MySQL 8+ Specific Checks' => sub { ok(has_output(qr/INFO: Table test_db.products: JSON column 'attributes' detected without Virtual Generated Columns/), 'Unindexed JSON detected'); ok(has_output(qr/INFO: Index test_db.users.idx_email_invisible is INVISIBLE/), 'Invisible index detected'); + + # Consolidated check + is(scalar(grep { $_ eq "Consider using Generated Columns to index frequently searched attributes in JSON column in 1 column(s)" } @main::generalrec), 1, 'Consolidated JSON column recommendation pushed'); }; subtest 'Data Type Optimization Checks' => sub { diff --git a/tests/test_issue_900.t b/tests/test_issue_900.t new file mode 100644 index 000000000..639f78a11 --- /dev/null +++ b/tests/test_issue_900.t @@ -0,0 +1,148 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More; +use File::Basename; +use File::Spec; + +# Setup environment for MySQLTuner +$main::is_remote = 0; +$main::mysqlcmd = "mysql"; +$main::mysqllogin = ""; +$main::remotestring = ""; +$main::devnull = File::Spec->devnull(); + +# Load the script first to get the subroutines +{ + local @ARGV = (); + no warnings 'redefine'; + require './mysqltuner.pl'; +} + +# Mock functions +my @mock_output; +my @mocked_sys_commands; +{ + no warnings 'redefine'; + *main::infoprint = sub { diag "MOCK INFO: $_[0]"; push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { diag "MOCK BAD: $_[0]"; push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { diag "MOCK GOOD: $_[0]"; push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { diag "MOCK DEBUG: $_[0]"; push @mock_output, "DEBUG: $_[0]" }; + *main::subheaderprint = sub { diag "MOCK SUBHEADER: $_[0]"; push @mock_output, "SUBHEADER: $_[0]" }; + *main::prettyprint = sub { }; + + *main::execute_system_command = sub { + my ($cmd) = @_; + push @mocked_sys_commands, $cmd; + return ""; + }; + + *main::select_one = sub { return 0; }; + *main::get_password_column_name = sub { return 'authentication_string'; }; +} + +# 1. Test get_state_file_path +subtest 'get_state_file_path formatting' => sub { + $main::opt{host} = '127.0.0.1'; + $main::opt{port} = 3306; + $main::opt{socket} = undef; + my $path = main::get_state_file_path(); + like($path, qr/127\.0\.0\.1_3306/, 'Should format host and port in file path'); +}; + +# 2. Test adjust_aborted_connects with simulated state +subtest 'adjust_aborted_connects logic' => sub { + my $temp_state_file = File::Spec->catfile('tests', '.mysqltuner_test_state'); + + # Mock get_state_file_path to use our test state file + no warnings 'redefine'; + local *main::get_state_file_path = sub { return $temp_state_file; }; + + # Scenario A: Server has not restarted (uptime is larger or equal to stored) + %main::mystat = ( + 'Uptime' => 500, + 'Aborted_connects' => 1000, + 'Connections' => 2000, + ); + + # Write a mock state: uptime 100, attempts 620 + open(my $fh, '>', $temp_state_file) or die $!; + print $fh "100:620\n"; + close($fh); + + main::adjust_aborted_connects(); + + is($main::mystat{'Aborted_connects'}, 1000 - 620, 'Should subtract stored attempts from Aborted_connects'); + is($main::mystat{'Connections'}, 2000 - 620, 'Should subtract stored attempts from Connections'); + is($main::previous_failed_attempts, 620, 'Should load previous_failed_attempts'); + + # Scenario B: Server has restarted (uptime is less than stored) + %main::mystat = ( + 'Uptime' => 50, + 'Aborted_connects' => 10, + 'Connections' => 20, + ); + $main::previous_failed_attempts = 0; + + # Write a mock state: uptime 100, attempts 620 + open($fh, '>', $temp_state_file) or die $!; + print $fh "100:620\n"; + close($fh); + + main::adjust_aborted_connects(); + + is($main::mystat{'Aborted_connects'}, 10, 'Should not subtract stored attempts if server restarted'); + is($main::mystat{'Connections'}, 20, 'Should not subtract stored connections if server restarted'); + is($main::previous_failed_attempts, 0, 'previous_failed_attempts should remain 0'); + + # Cleanup + unlink($temp_state_file); +}; + +# 3. Test offline password check for mysql_native_password +subtest 'offline password check logic' => sub { + my $pw_file = "tests/mock_passwords.txt"; + open(my $fh, ">", $pw_file) or die $!; + print $fh "weakpassword123\n"; + close($fh); + + $main::basic_password_files = $pw_file; + $main::myvar{'version'} = "8.0.25"; + $main::myvar{'version_comment'} = "MySQL"; + $main::opt{skippassword} = 0; + $main::opt{user} = 'root'; + $main::opt{'max-password-checks'} = 100; + + # Mock select_array to return a user list with a weak mysql_native_password hash + # Hash for "weakpassword123": + # Digest::SHA::sha1("weakpassword123") -> binary + # Digest::SHA::sha1_hex(binary) -> e43f5ee161f95161ac77ef4e9d784f784e123d3c + # Double SHA1 hex -> e43f5ee161f95161ac77ef4e9d784f784e123d3c (which is *E43F5EE161F95161AC77EF4E9D784F784E123D3C) + no warnings 'redefine'; + local *main::select_array = sub { + my ($sql) = @_; + if ($sql =~ /FROM mysql.user/ || $sql =~ /FROM mysql.global_priv/) { + return ( + "root\thostname\tmysql_native_password\t*E43F5EE161F95161AC77EF4E9D784F784E123D3C" + ); + } + return (); + }; + + @main::generalrec = (); + @mock_output = (); + @mocked_sys_commands = (); + $main::failed_connection_attempts = 0; + + main::security_recommendations(); + + # Check if weak password was detected offline + my @found = grep { /User 'root'\@'hostname' is using weak password/ } @mock_output; + ok(scalar(@found) > 0, 'Offline check detected weak native password'); + is($main::failed_connection_attempts, 3, 'Only 3 behavioral checks failed attempts should be recorded'); + + # Cleanup + unlink($pw_file); +}; + +done_testing();