#!/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 sys

import lib.args
import lib.base
import lib.human
import lib.shell
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

try:
    import psutil

    HAVE_PSUTIL = True
except ImportError:
    HAVE_PSUTIL = False


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

DESCRIPTION = """Checks Linux kernel parameters that affect MySQL/MariaDB stability and
performance: `vm.swappiness`, the asynchronous-I/O event ceiling `fs.aio-max-nr`, the
per-process file-handle ceiling `fs.nr_open`, and (on hosts that mount NFS) the sunrpc
TCP slot-table size (`sunrpc.tcp_slot_table_entries`).
Optionally also flags hosts that listen on too many TCP ports.
Alerts on misconfigured settings."""

DEFAULT_MAXPORTSALLOWED = 0


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(
        '--maxportsallowed',
        help='Maximum number of TCP ports listening on this host. 0 disables the check. '
        'Default: %(default)s',
        dest='MAXPORTSALLOWED',
        type=int,
        default=DEFAULT_MAXPORTSALLOWED,
    )

    args, _ = parser.parse_known_args()
    return args


def get_listening_ports():
    """Return the count of distinct local TCP ports in LISTEN state, or `None` if `psutil`
    is not installed.
    """
    if not HAVE_PSUTIL:
        return None
    listening = {
        c.laddr.port
        for c in psutil.net_connections(kind='inet')
        if c.status == psutil.CONN_LISTEN and c.laddr
    }
    return len(listening)


def get_sysctl(key):
    """Read a sysctl key. Returns the stripped value, or `None` if empty/unreadable."""
    cmd = f'sysctl --values {key} 2> /dev/null'
    stdout, _stderr, _retc = lib.base.coe(lib.shell.shell_exec(cmd))
    value = stdout.strip()
    return value or None


def fmt_value(raw):
    """Render a numeric sysctl value as `"123"` or `"1048576 (1.0M)"`. The SI-prefixed
    form is only appended if `number2human()` actually produces a unit suffix; otherwise
    the parentheses would just repeat the raw number with a `.0` tacked on.
    """
    human = lib.human.number2human(int(raw))
    if human and human[-1].isalpha():
        return f'{raw} ({human})'
    return str(raw)


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

    # logic taken from mysqltuner.pl:get_kernel_info(),
    # verified in sync with MySQLTuner (vm.swappiness <= 10,
    # sunrpc.tcp_slot_table_entries > 100, fs.aio-max-nr >= 1M
    # and fs.nr_open >= 1M, with the same recommendations
    # mysqltuner emits).

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

    # init some vars
    state = STATE_OK
    perfdata = ''
    facts = ''
    # All recommendations from all WARN paths land here and render once at the
    # end as a `Recommendations:\n* ...` bulleted block. Keeps the structure
    # consistent regardless of which combinations of WARN paths fire.
    recommendations = []

    # count distinct local TCP ports in LISTEN state
    listening = get_listening_ports()
    if listening is None:
        facts += 'No ports checked (psutil missing). '
    else:
        if args.MAXPORTSALLOWED > 0 and listening > args.MAXPORTSALLOWED:
            state = lib.base.get_worst(state, STATE_WARN)
            facts += (
                f'{listening} listening TCP ports system-wide, exceeds'
                f' `--maxportsallowed` limit of {args.MAXPORTSALLOWED}'
                f'{lib.base.state2str(STATE_WARN, prefix=" ")}. '
            )
            recommendations.append(
                'Consider dedicating a server for your database installation'
                ' with less services running on'
            )
        else:
            facts += f'{listening} listening TCP ports system-wide. '
        perfdata += lib.base.get_perfdata(
            'mysql_listening_ports',
            listening,
            _min=0,
        )

    # get kernel settings on linux
    if lib.base.LINUX:
        # vm.swappiness — should be <= 10 to keep MySQL pages out of swap
        result = get_sysctl('vm.swappiness')
        if result is None:
            lib.base.cu('Permission denied reading `vm.swappiness`')
        if int(result) > 10:
            state = lib.base.get_worst(state, STATE_WARN)
            facts += (
                f'`vm.swappiness` is {result}, should be <= 10'
                f'{lib.base.state2str(STATE_WARN, prefix=" ")}. '
            )
            recommendations.append(
                '`echo 10 > /proc/sys/vm/swappiness`, or `vm.swappiness=10`'
                ' in /etc/sysctl.conf for persistence'
            )
        else:
            facts += f'`vm.swappiness` is {fmt_value(result)}. '
        perfdata += lib.base.get_perfdata(
            'mysql_kernel_vm_swappiness',
            result,
            uom='%',
            _min=0,
            _max=100,
        )

        # sunrpc.tcp_slot_table_entries — only if /proc/sys/sunrpc exists (i.e. NFS in use)
        if os.path.isdir('/proc/sys/sunrpc'):
            result = get_sysctl('sunrpc.tcp_slot_table_entries')
            if result is not None:
                if int(result) <= 100:
                    state = lib.base.get_worst(state, STATE_WARN)
                    facts += (
                        f'`sunrpc.tcp_slot_table_entries` is {result}, should be > 100'
                        f'{lib.base.state2str(STATE_WARN, prefix=" ")}. '
                    )
                    recommendations.append(
                        '`echo 128 > /proc/sys/sunrpc/tcp_slot_table_entries`,'
                        ' or `sunrpc.tcp_slot_table_entries=128` in /etc/sysctl.conf'
                        ' for persistence (recommended value: 128)'
                    )
                else:
                    facts += f'`sunrpc.tcp_slot_table_entries` is {fmt_value(result)}. '
                perfdata += lib.base.get_perfdata(
                    'mysql_kernel_sunrpc_tcp_slot_table_entries',
                    result,
                    _min=0,
                )

        # fs.aio-max-nr — InnoDB AIO event ceiling, recommend at least 1M
        if os.path.isfile('/proc/sys/fs/aio-max-nr'):
            result = get_sysctl('fs.aio-max-nr')
            if result is not None:
                if int(result) < 1000000:
                    state = lib.base.get_worst(state, STATE_WARN)
                    facts += (
                        f'`fs.aio-max-nr` is {result}, should be >= 1M'
                        f'{lib.base.state2str(STATE_WARN, prefix=" ")}. '
                    )
                    recommendations.append(
                        '`echo 1048576 > /proc/sys/fs/aio-max-nr`,'
                        ' or `fs.aio-max-nr=1048576` in /etc/sysctl.conf'
                        ' for persistence'
                    )
                else:
                    facts += f'`fs.aio-max-nr` is {fmt_value(result)}. '
                perfdata += lib.base.get_perfdata(
                    'mysql_kernel_fs_aio_max_nr',
                    result,
                    _min=0,
                )

        # fs.nr_open — per-process file-handle ceiling, recommend at least 1M
        if os.path.isfile('/proc/sys/fs/nr_open'):
            result = get_sysctl('fs.nr_open')
            if result is not None:
                if int(result) < 1000000:
                    state = lib.base.get_worst(state, STATE_WARN)
                    facts += (
                        f'`fs.nr_open` is {result}, should be >= 1M'
                        f'{lib.base.state2str(STATE_WARN, prefix=" ")}. '
                    )
                    recommendations.append(
                        '`echo 1048576 > /proc/sys/fs/nr_open`,'
                        ' or `fs.nr_open=1048576` in /etc/sysctl.conf'
                        ' for persistence'
                    )
                else:
                    facts += f'`fs.nr_open` is {fmt_value(result)}. '
                perfdata += lib.base.get_perfdata(
                    'mysql_kernel_fs_nr_open',
                    result,
                    _min=0,
                )

    sections = [facts.rstrip() or 'Everything is ok.']
    if recommendations:
        sections.append(
            'Recommendations:\n' + '\n'.join(f'* {r}' for r in recommendations)
        )
    msg = '\n\n'.join(sections)

    # over and out
    lib.base.oao(msg, state, perfdata, always_ok=args.ALWAYS_OK)


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