#!/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  # pylint: disable=C0413
import os  # pylint: disable=C0413
import re  # pylint: disable=C0413
import sys  # pylint: disable=C0413

import lib.base  # pylint: disable=C0413
import lib.cache  # pylint: disable=C0413
import lib.db_mysql  # pylint: disable=C0413
import lib.disk  # pylint: disable=C0413
import lib.human  # pylint: disable=C0413
import lib.shell  # pylint: disable=C0413
import lib.txt  # pylint: disable=C0413
from lib.globals import (STATE_CRIT, STATE_OK,  # pylint: disable=C0413
                          STATE_UNKNOWN, STATE_WARN)

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

DESCRIPTION = """Checks MySQL/MariaDB log content the same way MySQLTuner does, but also
                 in case the DB is down."""

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='{0}: v{1} by {2}'.format('%(prog)s', __version__, __author__)
    )

    parser.add_argument(
        '--always-ok',
        help='Always returns OK.',
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--cache-expire',
        help='The amount of time after which the cached data expires, in minutes. Default: %(default)s',
        dest='CACHE_EXPIRE',
        type=int,
        default=DEFAULT_CACHE_EXPIRE,
    )

    parser.add_argument(
        '--defaults-file',
        help='Specifies a cnf file to read parameters like user, host and password from '
             '(instead of specifying them on the command line), '
             'for example `/var/spool/icinga2/.my.cnf`. Default: %(default)s',
        dest='DEFAULTS_FILE',
        default=DEFAULT_DEFAULTS_FILE,
    )

    parser.add_argument(
        '--defaults-group',
        help='Group/section to read from in the cnf file. Default: %(default)s',
        dest='DEFAULTS_GROUP',
        default=DEFAULT_DEFAULTS_GROUP,
    )

    parser.add_argument(
        '-H', '--hostname',
        help='MySQL/MariaDB hostname. 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; repeating).',
        action='append',
        default=[],
        dest='IGNORE_PATTERN',
    )

    parser.add_argument(
        '--ignore-regex',
        help='Any line matching this python regex will be ignored.',
        action='append',
        default=[],
        dest='IGNORE_REGEX',
    )

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

    parser.add_argument(
        '--server-log',
        help='''One of: 
                Path to error log file (including filename); 
                docker:CONTAINER; 
                podman:CONTAINER; 
                kubectl:CONTAINER; 
                systemd:UNITNAME. 
                If ommitted, this check tries to fetch the logfile location automatically.
            ''',
        dest='SERVER_LOG',
    )

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

    return parser.parse_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('{}.log'.format(hostname)):
        return '{}.log'.format(hostname)
    if os.path.isfile('{}.err'.format(hostname)):
        return '{}.err'.format(hostname)
    if os.path.isfile(os.path.join(datadir, '{}.err'.format(hostname))):
        return os.path.join(datadir, '{}.err'.format(hostname))
    if os.path.isfile(os.path.join(datadir, '{}.log'.format(hostname))):
        return os.path.join(datadir, '{}.log'.format(hostname))
    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('/var/log/mysql/{}.err'.format(hostname)):
        return '/var/log/mysql/{}.err'.format(hostname)
    if os.path.isfile('/var/log/mysql/{}.log'.format(hostname)):
        return '/var/log/mysql/{}.log'.format(hostname)
    if os.path.isfile('/var/log/mysql/mysql_error.log'):
        return '/var/log/mysql/mysql_error.log'
    return file


def main():
    """The main function. Hier spielt die Musik.
    """

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

    # parse the command line, exit with UNKNOWN if it fails
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    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('{}-{}'.format(
                    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('{}-{}'.format(
            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 = "{} logs --tail={} '{}'".format(
            match.group(1).strip(),
            MAXLINES,
            match.group(2).strip(),
        )
        logfile, stderr, retc = lib.base.coe(lib.shell.shell_exec(cmd))
        if retc != 0:
            lib.base.cu('{} exited with error ({}, {}).'.format(cmd, retc, stderr.strip()))
        msg += 'Src: {}, '.format(log_error)

    match = re.search(r'^systemd:(.*)', log_error)
    if match:
        cmd = "journalctl --lines {} --boot --unit '{}'".format(MAXLINES, match.group(1).strip())
        logfile, stderr, retc = lib.base.coe(lib.shell.shell_exec(cmd))
        if retc != 0:
            lib.base.cu('`{}` exited with error ({}, {}).'.format(cmd, retc, stderr.strip()))
        msg += 'Src: {}, '.format(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 = 'Logging seems to be configured, but `{}` does not seem to be an existing regular file. '.format(log_error)
            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('Log file {} is empty. Assuming log-rotation. Use `--server-log` for explicit file.'.format(log_error))
            else:
                lib.base.oao('Log file {} is empty. `--server-log` is set explicitely, so assuming log-rotation.'.format(log_error), STATE_OK)
        msg += 'Src: Log file {} (size: {}), '.format(
            log_error,
            lib.human.bytes2human(size),
        )
        perfdata += lib.base.get_perfdata('mysql_logfile_size', size, 'B', None, None, 0, None)
        if size >= 32 * 1024 * 1024:
            size_state = STATE_WARN
            state = lib.base.get_worst(state, size_state)
            msg += 'bigger than 32 MiB{} (you should analyze why or implement a rotation log strategy such as logrotate), '.format(
                lib.base.state2str(size_state, prefix=' '),
            )
        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 += '{} {}{} (last: {}), '.format(
            cnt,
            lib.txt.pluralize('error', len(lastErrs)),
            lib.base.state2str(errline_state, prefix=' '),
            lastErrs[-1]
        )
        if cnt > 10:
            # shorten the message
            lastErrs = lastErrs[0:5] + ['...'] + lastErrs[-5:]
        msg_body += '\nErrors:\n* {}\n'.format('\n* '.join(lastErrs))
    perfdata += lib.base.get_perfdata('mysql_error_lines', cnt, None, None, None, 0, None)

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

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

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

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


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