#!/usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import os
import re
import sys

import lib.args
import lib.base
import lib.cache
import lib.db_mysql
import lib.disk
import lib.human
import lib.shell
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026040801'

DESCRIPTION = """Scans the MySQL/MariaDB error log for warnings and errors, similar to how MySQLTuner
analyzes the log. Works even when the database is down by reading the log file
directly. Uses a cache to avoid re-reading already processed entries.
Requires root or sudo."""

DEFAULT_CACHE_EXPIRE = 5 * 24 * 60  # in minutes (= 5 days)
DEFAULT_DEFAULTS_FILE = '/var/spool/icinga2/.my.cnf'
DEFAULT_DEFAULTS_GROUP = 'client'
DEFAULT_HOSTNAME = '127.0.0.1'
DEFAULT_PORT = '3306'
DEFAULT_TIMEOUT = 3

MAXLINES = 30000  # Maximum lines of log output to read from end


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(description=DESCRIPTION)

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--cache-expire',
        help=lib.args.help('--cache-expire') + ' Default: %(default)s',
        dest='CACHE_EXPIRE',
        type=int,
        default=DEFAULT_CACHE_EXPIRE,
    )

    parser.add_argument(
        '--defaults-file',
        help='MySQL/MariaDB cnf file to read user, host and password from. '
        'Example: `--defaults-file=/var/spool/icinga2/.my.cnf`. '
        'Default: %(default)s',
        dest='DEFAULTS_FILE',
        default=DEFAULT_DEFAULTS_FILE,
    )

    parser.add_argument(
        '--defaults-group',
        help=lib.args.help('--defaults-group') + ' Default: %(default)s',
        dest='DEFAULTS_GROUP',
        default=DEFAULT_DEFAULTS_GROUP,
    )

    parser.add_argument(
        '-H',
        '--hostname',
        help='MySQL/MariaDB hostname or IP address. Default: %(default)s',
        dest='HOSTNAME',
        default=DEFAULT_HOSTNAME,
    )

    parser.add_argument(
        '--ignore-pattern',
        help='Any line containing this pattern will be ignored. '
        'Must be lowercase. '
        'Can be specified multiple times.',
        action='append',
        default=None,
        dest='IGNORE_PATTERN',
    )

    parser.add_argument(
        '--ignore-regex',
        help=lib.args.help('--ignore-regex'),
        action='append',
        default=None,
        dest='IGNORE_REGEX',
    )

    parser.add_argument(
        '--port',
        help='MySQL/MariaDB port number. Default: %(default)s',
        dest='PORT',
        type=int,
        default=DEFAULT_PORT,
    )

    parser.add_argument(
        '--server-log',
        help='Log source to read from. '
        'Accepts a file path, `docker:CONTAINER`, `podman:CONTAINER`, '
        '`kubectl:CONTAINER` or `systemd:UNITNAME`. '
        'If omitted, the check tries to fetch the logfile location automatically.',
        dest='SERVER_LOG',
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    args, _ = parser.parse_known_args()
    return args


def get_vars(conn):
    # Do not implement `get_all_vars()`, just fetch the ones we need for this check.
    # Without the GLOBAL modifier, SHOW VARIABLES displays the values that are used for
    # the current connection to MariaDB.
    sql = """
        show global variables
        where variable_name like 'datadir'
            or variable_name like 'hostname'
            or variable_name like 'log_error';
          """
    return lib.base.coe(lib.db_mysql.select(conn, sql))


def get_log_file_real_path(file, hostname, datadir):
    if file and os.path.isfile(file):
        return file
    if os.path.isfile(f'{hostname}.log'):
        return f'{hostname}.log'
    if os.path.isfile(f'{hostname}.err'):
        return f'{hostname}.err'
    if os.path.isfile(os.path.join(datadir, f'{hostname}.err')):
        return os.path.join(datadir, f'{hostname}.err')
    if os.path.isfile(os.path.join(datadir, f'{hostname}.log')):
        return os.path.join(datadir, f'{hostname}.log')
    if os.path.isfile(os.path.join(datadir, 'mysql_error.log')):
        return os.path.join(datadir, 'mysql_error.log')
    if os.path.isfile('/var/log/mysql.log'):
        return '/var/log/mysql.log'
    if os.path.isfile('/var/log/mysqld.log'):
        return '/var/log/mysqld.log'
    if os.path.isfile(f'/var/log/mysql/{hostname}.err'):
        return f'/var/log/mysql/{hostname}.err'
    if os.path.isfile(f'/var/log/mysql/{hostname}.log'):
        return f'/var/log/mysql/{hostname}.log'
    if os.path.isfile('/var/log/mysql/mysql_error.log'):
        return '/var/log/mysql/mysql_error.log'
    return file


def main():
    """The main function. This is where the magic happens."""

    # logic taken from mysqltuner.pl:log_file_recommendations(), v2.2.8
    # including variable names

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    # set default values for append parameters that were not specified
    if args.IGNORE_PATTERN is None:
        args.IGNORE_PATTERN = []
    if args.IGNORE_REGEX is None:
        args.IGNORE_REGEX = []

    if args.SERVER_LOG:
        # user told us to use this log source, so don't try to find anything else
        log_error = args.SERVER_LOG
    else:
        # first ask the DB where to find the logfile
        mysql_connection = {
            'defaults_file': args.DEFAULTS_FILE,
            'defaults_group': args.DEFAULTS_GROUP,
            'timeout': args.TIMEOUT,
        }
        success, conn = lib.db_mysql.connect(mysql_connection)
        if not success:
            # DB seems to be down
            # Try to get the "log_error" setting and save it to cache, so that we know
            #  where to get the log info from in case the DB is down again.
            myvar = {}
            log_error = lib.cache.get(
                f'{args.HOSTNAME}-{args.PORT}',
                filename='linuxfabrik-monitoring-plugins-mysql-logfile.db',
            )
        else:
            lib.base.coe(lib.db_mysql.check_select_privileges(conn))
            myvar = lib.db_mysql.lod2dict(get_vars(conn))
            lib.db_mysql.close(conn)
            log_error = myvar['log_error']
        if not log_error:
            log_error = get_log_file_real_path(
                myvar.get('log_error', ''),
                myvar.get('hostname', ''),
                myvar.get('datadir', ''),
            )
        if not log_error:
            lib.base.cu(
                "No log file set (set `log_error` in MySQL/MariaDB config or use the check's `--server-log` parameter)."
            )

    lib.cache.set(
        f'{args.HOSTNAME}-{args.PORT}',
        log_error,
        lib.time.now() + args.CACHE_EXPIRE * 60,
        filename='linuxfabrik-monitoring-plugins-mysql-logfile.db',
    )

    # init some vars
    msg, msg_body = '', ''
    state = STATE_OK
    perfdata = ''

    if log_error == 'stderr':
        lib.base.cu("log_error is set to STDERR, so this check can't read stderr.")

    match = re.search(r'^(docker|podman|kubectl):(.*)', log_error)
    if match:
        cmd = f"{match.group(1).strip()} logs --tail={MAXLINES} '{match.group(2).strip()}'"
        logfile, stderr, retc = lib.base.coe(lib.shell.shell_exec(cmd))
        if retc != 0:
            lib.base.cu(f'{cmd} exited with error ({retc}, {stderr.strip()}).')

        # build the message
        msg += f'Src: {log_error}, '

    match = re.search(r'^systemd:(.*)', log_error)
    if match:
        cmd = f"journalctl --lines {MAXLINES} --boot --unit '{match.group(1).strip()}'"
        logfile, stderr, retc = lib.base.coe(lib.shell.shell_exec(cmd))
        if retc != 0:
            lib.base.cu(f'`{cmd}` exited with error ({retc}, {stderr.strip()}).')
        msg += f'Src: {log_error}, '
    else:
        # good old logfile
        if not os.path.isabs(log_error):
            log_error = os.path.join(myvar.get('datadir', ''), log_error)
        if not os.path.isfile(log_error):
            msg = f'Logging seems to be configured, but `{log_error}` does not seem to be an existing regular file. '
            msg += 'Check the path and file permissions, or provide the `--server-log` parameter.'
            lib.base.oao(msg, STATE_WARN)
        size = os.path.getsize(log_error)
        if size == 0:
            if not args.SERVER_LOG:
                lib.base.cu(
                    f'Log file {log_error} is empty. Assuming log-rotation. Use `--server-log` for explicit file.'
                )
            else:
                lib.base.oao(
                    f'Log file {log_error} is empty. `--server-log` is set explicitely, so assuming log-rotation.',
                    STATE_OK,
                )
        msg += f'Src: Log file {log_error} (size: {lib.human.bytes2human(size)}), '
        perfdata += lib.base.get_perfdata(
            'mysql_logfile_size',
            size,
            uom='B',
            _min=0,
        )
        if size >= 32 * 1024 * 1024:
            size_state = STATE_WARN
            state = lib.base.get_worst(state, size_state)
            msg += (
                f'bigger than 32 MiB'
                f'{lib.base.state2str(size_state, prefix=" ")}'
                f' (you should analyze why or implement a'
                f' rotation log strategy such as logrotate), '
            )
        logfile = lib.base.coe(lib.disk.read_file(log_error))

    # parse the log (slightly extended compared to MySQLTuner)
    lastErrs, lastWarns, lastShutdowns, lastStarts = [], [], [], []
    compiled_ignore_regex = [re.compile(item) for item in args.IGNORE_REGEX]
    for logLi in logfile.splitlines():
        haystack = logLi.lower()
        if any(
            ignore_pattern.lower() in haystack for ignore_pattern in args.IGNORE_PATTERN
        ) or any(item.search(haystack) for item in compiled_ignore_regex):
            continue
        if (
            'error' in haystack
            and 'logging to' not in haystack
            and ' [warning] ' not in haystack
        ):
            lastErrs.append(logLi)
        if 'warning' in haystack:
            lastWarns.append(logLi)
        if 'shutdown complete' in haystack and 'innodb' not in haystack:
            lastShutdowns.append(logLi)
        if 'ready for connections' in logLi:
            lastStarts.append(logLi)

    cnt = len(lastErrs)
    if lastErrs:
        errline_state = STATE_CRIT
        state = lib.base.get_worst(state, errline_state)
        msg += (
            f'{cnt}'
            f' {lib.txt.pluralize("error", len(lastErrs))}'
            f'{lib.base.state2str(errline_state, prefix=" ")}'
            f' (last: {lastErrs[-1]}), '
        )
        if cnt > 10:
            # shorten the message
            lastErrs = [*lastErrs[0:5], '...', *lastErrs[-5:]]
        msg_body += '\nErrors:\n* ' + '\n* '.join(lastErrs) + '\n'
    perfdata += lib.base.get_perfdata(
        'mysql_error_lines',
        cnt,
        _min=0,
    )

    cnt = len(lastWarns)
    if lastWarns:
        warnline_state = STATE_WARN
        state = lib.base.get_worst(state, warnline_state)
        msg += (
            f'{cnt}'
            f' {lib.txt.pluralize("warning", len(lastWarns))}'
            f'{lib.base.state2str(warnline_state, prefix=" ")}'
            f' (last: {lastWarns[-1]}), '
        )
        if cnt > 10:
            # shorten the message
            lastWarns = [*lastWarns[0:5], '...', *lastWarns[-5:]]
        msg_body += '\nWarnings:\n* ' + '\n* '.join(lastWarns) + '\n'
    perfdata += lib.base.get_perfdata(
        'mysql_warning_lines',
        cnt,
        _min=0,
    )

    cnt = len(lastStarts)
    if lastStarts:
        msg += f'{cnt} {lib.txt.pluralize("start", len(lastStarts))} (last: {lastStarts[-1]}), '
        if cnt > 10:
            # shorten the message
            lastStarts = [*lastStarts[0:5], '...', *lastStarts[-5:]]
        msg_body += '\nStarts:\n* ' + '\n* '.join(lastStarts) + '\n'
    perfdata += lib.base.get_perfdata(
        'mysql_startups',
        cnt,
        _min=0,
    )

    cnt = len(lastShutdowns)
    if lastShutdowns:
        msg += (
            f'{cnt}'
            f' {lib.txt.pluralize("shutdown", len(lastShutdowns))}'
            f' (last: {lastShutdowns[-1]}), '
        )
        if cnt > 10:
            # shorten the message
            lastShutdowns = [*lastShutdowns[0:5], '...', *lastShutdowns[-5:]]
        msg_body += '\nShutdowns:\n* ' + '\n* '.join(lastShutdowns) + '\n'
    perfdata += lib.base.get_perfdata(
        'mysql_shutdowns',
        cnt,
        _min=0,
    )

    # over and out
    lib.base.oao(f'{msg[:-2]}\n{msg_body}', state, perfdata, always_ok=args.ALWAYS_OK)


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
