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

import lib.args
import lib.base
import lib.human
import lib.lftest
import lib.lvm
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN

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

DESCRIPTION = """Reports the LVM volume groups on this host, how many physical volumes
each of them is built from, and how many of those it can no longer find. A volume group
that is missing a physical volume carries volumes that are incomplete, and it refuses
to activate them at the next boot unless that is asked for explicitly, so a reboot in
this state comes back without them. The free space of a group is reported as well, and
graded only where thresholds are given: a group with every extent handed out is a
perfectly normal and often deliberate state, and alerting on it by default would put a
permanent warning on most hosts. Set the thresholds where free space has to be kept for
a thin pool or a snapshot to grow into.
Alerts when a volume group is missing a physical volume, and when its free space is
outside the thresholds, if any are given.
Requires root or sudo."""

# Named here so the same words reach the plugin output and the README.
MISSING_PV_HELP = (
    'Find the missing physical volume before anything else: `pvs` lists it as '
    '`[unknown]`, and `lsblk` plus the logs of the storage transport say whether the '
    'device is gone or merely unplugged. Once it is back, `vgchange --refresh` picks '
    'it up again; where it is gone for good, `vgreduce --removemissing` takes it out '
    'of the volume group, and whatever sat on it has to be restored from a backup.'
)

DEFAULT_BRIEF = False
DEFAULT_CRIT = None
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_TIMEOUT = 8
DEFAULT_WARN = None


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    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(
        '--brief',
        help=lib.args.help('--brief'),
        dest='BRIEF',
        action='store_true',
        default=DEFAULT_BRIEF,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for how much of a volume group is handed out to logical '
        'volumes, in percent. '
        'Not set by default, because a volume group with every extent in use is a '
        'normal state and not a fault; set it where free space has to be kept for a '
        'thin pool or a snapshot to grow into. '
        'Supports Nagios ranges. '
        'Example: `--critical=95` keeps a twentieth of the group free for a pool or a '
        'snapshot to grow into. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

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

    parser.add_argument(
        '--match',
        help=lib.args.help('--match'),
        action='append',
        default=None,
        dest='MATCH',
    )

    parser.add_argument(
        '--no-match-severity',
        help=lib.args.help('--no-match-severity') + ' Default: %(default)s',
        dest='NO_MATCH_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_NO_MATCH_SEVERITY,
    )

    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

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

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for how much of a volume group is handed out to logical '
        'volumes, in percent. '
        'Not set by default, because a volume group with every extent in use is a '
        'normal state and not a fault; set it where free space has to be kept for a '
        'thin pool or a snapshot to grow into. '
        'Supports Nagios ranges. '
        'Example: `--warning=90` keeps a tenth of the group free for a pool or a '
        'snapshot to grow into. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def get_groups(args):
    """
    Ask LVM for the volume groups of this host.

    A host whose LVM stopped answering, and one without the LVM tools although
    something is asking it about its volume groups, both have a problem worth putting
    on the list of things to fix instead of on the UNKNOWN pile nobody reads.
    Everything else is the reading that broke and says nothing about the groups.
    """
    if args.TEST is not None:
        stdout, _, _ = lib.lftest.test(args.TEST)
        return lib.base.coe(lib.lvm.parse_report(stdout, 'vg'))
    success, result = lib.lvm.get_volume_groups(timeout=args.TIMEOUT)
    if not success:
        lib.base.oao(
            result,
            lib.lvm.failure_state(result),
            always_ok=args.ALWAYS_OK,
        )
    return result


def analyze(vg, args):
    """Work out how one volume group is doing and how bad that is."""
    name = vg.get('vg_name') or '?'
    size = lib.lvm.to_number(vg.get('vg_size')) or 0
    free = lib.lvm.to_number(vg.get('vg_free')) or 0
    missing = int(lib.lvm.to_number(vg.get('vg_missing_pv_count')) or 0)
    usage = None if size <= 0 else (size - free) / size * 100
    state = STATE_OK

    if usage is not None and (args.WARN is not None or args.CRIT is not None):
        state = lib.base.get_worst(
            state,
            lib.base.get_state(usage, args.WARN, args.CRIT, _operator='range'),
        )
    # A volume group that cannot find one of its physical volumes carries incomplete
    # volumes, and it leaves them deactivated at the next boot. That is data gone until
    # somebody acts, which is what CRIT is for.
    if missing:
        state = lib.base.get_worst(state, STATE_CRIT)

    return {
        'extent_size': lib.lvm.to_number(vg.get('vg_extent_size')),
        'free': free,
        'lv_count': int(lib.lvm.to_number(vg.get('lv_count')) or 0),
        'missing': missing,
        'name': name,
        'pv_count': int(lib.lvm.to_number(vg.get('pv_count')) or 0),
        'size': size,
        'snap_count': int(lib.lvm.to_number(vg.get('snap_count')) or 0),
        'state': state,
        'usage': usage,
    }


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

    # 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 is None:
        args.IGNORE = []
    if args.MATCH is None:
        args.MATCH = []

    # fetch data
    groups = get_groups(args)

    # init some vars
    msg = ''
    msg_body = ''
    perfdata = ''
    state = STATE_OK
    table_data = []
    headline = []
    checked = 0
    missing_total = 0
    free_total = 0
    compiled_match = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.MATCH, '--match')
    ]
    compiled_ignore = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.IGNORE, '--ignore')
    ]

    # analyze data
    for vg in sorted(groups, key=lambda item: item.get('vg_name') or ''):
        name = vg.get('vg_name') or '?'
        if compiled_match and not any(item.search(name) for item in compiled_match):
            continue
        if any(item.search(name) for item in compiled_ignore):
            continue

        report = analyze(vg, args)
        checked += 1
        state = lib.base.get_worst(state, report['state'])
        missing_total += report['missing']
        free_total += report['free']

        if report['missing']:
            # The noun belongs to the total and not to the number that is missing, so
            # "1 of its 2 physical volumes" reads right.
            total = report['pv_count']
            headline.append(
                f'{name} is missing {report["missing"]} of its {total} physical '
                f'{lib.txt.pluralize("volume", total, ",s")}'
            )
        elif report['state'] != STATE_OK and report['usage'] is not None:
            headline.append(
                f'{name} has {lib.human.bytes2human(report["free"])} free '
                f'({report["usage"]:.0f}% in use)'
            )

        usage_text = (
            '-' if report['usage'] is None else f'{report["usage"]:.2f}% in use'
        )
        row = {
            'free': lib.human.bytes2human(report['free']),
            'group': report['name'],
            'lv_count': report['lv_count'],
            'pv_count': (
                f'{report["pv_count"]}'
                if not report['missing']
                else f'{report["pv_count"]} ({report["missing"]} missing)'
            ),
            'size': lib.human.bytes2human(report['size']),
            'snap_count': report['snap_count'],
            'state': (f'{usage_text}{lib.base.state2str(report["state"], prefix=" ")}'),
        }
        if not (args.BRIEF and report['state'] == STATE_OK):
            table_data.append(row)

        # Perfdata stays complete: --brief hides rows, it does not drop metrics.
        label = re.sub(r'\W+', '_', report['name'])
        if report['usage'] is not None:
            perfdata += lib.base.get_perfdata(
                f'{label}_usage',
                f'{report["usage"]:.2f}',
                uom='%',
                warn=args.WARN,
                crit=args.CRIT,
                _min=0,
                _max=100,
            )
        perfdata += lib.base.get_perfdata(
            f'{label}_free',
            int(report['free']),
            uom='B',
            _min=0,
            _max=int(report['size']),
        )

    # build the message
    if not groups:
        msg = 'No LVM volume group on this host.'
    elif not checked:
        count = len(groups)
        msg = (
            f'{count} LVM volume {lib.txt.pluralize("group", count, ",s")} on this '
            f'host, filtered out by --match or --ignore.'
        )
        state = lib.base.get_worst(state, lib.base.str2state(args.NO_MATCH_SEVERITY))
    elif headline:
        msg = f'{". ".join(headline)}.'
    else:
        msg = (
            f'{checked} LVM volume {lib.txt.pluralize("group", checked, ",s")}, '
            f'{lib.human.bytes2human(free_total)} free in total.'
        )
    if missing_total:
        msg_body += f'{MISSING_PV_HELP}\n'
    if msg_body:
        msg += f'\n{msg_body.rstrip()}'

    perfdata += lib.base.get_perfdata('volume_groups', checked, uom=None, _min=0)
    perfdata += lib.base.get_perfdata(
        'physical_volumes_missing',
        missing_total,
        uom=None,
        warn='0',
        _min=0,
    )

    # build table output
    if table_data:
        keys = ['group', 'size', 'free', 'pv_count', 'lv_count', 'snap_count', 'state']
        headers = ['Group', 'Size', 'Free', 'PVs', 'LVs', 'Snapshots', 'Usage']
        msg += '\n\n' + lib.base.get_table(table_data, keys, header=headers)

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


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