#!/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.time
import lib.txt
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Monitors the LVM snapshots on this host. A classic snapshot holds the
blocks its origin has overwritten since it was taken, in a store of a fixed size, and
the kernel throws the whole snapshot away the moment that store is full: the origin
keeps running, while the snapshot silently stops being readable and whatever was
backing up from it has lost its source. This check reports how full each store is
before that happens, and says so when it already has. Thin snapshots draw on their
pool instead of on a store of their own and are reported without a fill level, because
what can run out for them is the pool.
Alerts when the store of a classic snapshot is fuller than the thresholds allow, when
the kernel has thrown a snapshot away, and when merging a snapshot back into its origin
has failed. Optionally alerts on the age of a snapshot, which is what usually precedes
a full one.
Requires root or sudo."""

# Named here so the same words reach the plugin output and the README.
INVALID_HELP = (
    'A snapshot the kernel has thrown away cannot be brought back; whatever read from '
    'it has to run again from a new one. Remove it with `lvremove`, and give the next '
    'one a store that covers the writes its origin takes while it lives '
    '(`lvcreate --size`). `snapshot_autoextend_threshold` in `lvm.conf` is off by '
    'default and lets LVM grow the store before it fills, as long as the volume group '
    'has room to grow it into.'
)

MERGE_FAILED_HELP = (
    'A merge that failed leaves the origin as it was and the snapshot in place. '
    '`journalctl --dmesg` says what device-mapper made of it; the merge is retried '
    'with `lvconvert --merge` once the cause is gone.'
)

DEFAULT_BRIEF = False
DEFAULT_CRIT = '90'
DEFAULT_CRIT_AGE = '0D'
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_TIMEOUT = 8
DEFAULT_WARN = '80'
DEFAULT_WARN_AGE = '0D'


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 full the store of a classic snapshot is, in '
        'percent. '
        'Does not apply to thin snapshots, which have no store of their own. '
        'Supports Nagios ranges. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-age',
        help='CRIT threshold for the age of a snapshot. '
        'A snapshot is meant to live for as long as something reads from it, and one '
        'that outlives its purpose keeps collecting the writes of its origin until its '
        'store is full. '
        'A duration such as `12h`, `8D` or `2W`; `0D` disables the age check. '
        'Example: `--critical-age=7D` alerts on a snapshot that has been around for a '
        'week. '
        'Default: %(default)s',
        dest='CRIT_AGE',
        type=lib.args.duration,
        default=DEFAULT_CRIT_AGE,
    )

    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 full the store of a classic snapshot is, in '
        'percent. '
        'Does not apply to thin snapshots, which have no store of their own. '
        'Supports Nagios ranges. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-age',
        help='WARN threshold for the age of a snapshot. '
        'A snapshot is meant to live for as long as something reads from it, and one '
        'that outlives its purpose keeps collecting the writes of its origin until its '
        'store is full. '
        'A duration such as `12h`, `8D` or `2W`; `0D` disables the age check. '
        'Example: `--warning-age=2D` alerts on a snapshot that outlived the nightly '
        'job that took it. '
        'Default: %(default)s',
        dest='WARN_AGE',
        type=lib.args.duration,
        default=DEFAULT_WARN_AGE,
    )

    args, _ = parser.parse_known_args()
    return args


def get_volumes(args):
    """
    Ask LVM for the logical volumes of this host.

    A host whose LVM stopped answering, and one without the LVM tools although
    something is asking it about its volumes, 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 volumes.
    """
    if args.TEST is not None:
        stdout, _, _ = lib.lftest.test(args.TEST)
        return lib.base.coe(lib.lvm.parse_report(stdout, 'lv'))
    success, result = lib.lvm.get_logical_volumes(timeout=args.TIMEOUT)
    if not success:
        lib.base.oao(
            result,
            lib.lvm.failure_state(result),
            always_ok=args.ALWAYS_OK,
        )
    return result


def analyze(snapshot, now, args):
    """Work out how one snapshot is doing and how bad that is."""
    name = snapshot.get('lv_full_name') or snapshot.get('lv_name') or '?'
    thin = lib.lvm.is_thin(snapshot)
    invalid = lib.lvm.is_snapshot_invalid(snapshot)
    # A thin snapshot draws on its pool, so what `data_percent` reports for it is the
    # share of its virtual size that carries data, not a store that can run out.
    # Grading that against a fill threshold would alert on a snapshot doing its job.
    usage = None if thin else lib.lvm.to_number(snapshot.get('data_percent'))
    state = STATE_OK
    problems = []

    # The thresholds exist to warn while there is still something to save. A snapshot
    # the kernel has already thrown away reports 100% for as long as it is left lying
    # around, and grading that would report the aftermath at the severity of an
    # emergency, on every run, for a snapshot that is beyond saving either way.
    if usage is not None and not invalid:
        usage_state = lib.base.get_state(usage, args.WARN, args.CRIT, _operator='range')
        state = lib.base.get_worst(state, usage_state)
        if usage_state != STATE_OK:
            problems.append(f'{name} is {usage:.0f}% full')

    created = lib.lvm.created(snapshot)
    age = None if created is None else max(0, now - created)
    if age is not None and (args.WARN_AGE or args.CRIT_AGE):
        # A disabled threshold is a zero, which as a Nagios range would alert on
        # everything, so each of them only takes part once it is set.
        # int() drops the duration's text form (`2D`): the range parser needs the
        # plain second count as its bound, not the string the operator typed.
        age_state = lib.base.get_state(
            age,
            int(args.WARN_AGE) if args.WARN_AGE else None,
            int(args.CRIT_AGE) if args.CRIT_AGE else None,
            _operator='range',
        )
        state = lib.base.get_worst(state, age_state)
        if age_state != STATE_OK:
            problems.append(f'{name} is {lib.human.seconds2human(age)} old')

    problem = lib.lvm.health(snapshot)
    if problem:
        state = lib.base.get_worst(state, STATE_WARN)
        # What is wrong with the snapshot replaces what is merely above a threshold:
        # a store that ran full is why the snapshot reads 100%, not a second finding.
        problems = [f'{name}: {problem}']

    return {
        'age': age,
        'invalid': invalid,
        'merge_failed': snapshot.get('lv_merge_failed') == 'merge failed',
        'name': name,
        'origin': snapshot.get('origin') or '-',
        'pool': snapshot.get('pool_lv') or '-',
        'problem': problem,
        'problems': problems,
        'size': lib.lvm.to_number(snapshot.get('lv_size')),
        'state': state,
        'thin': thin,
        '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
    volumes = get_volumes(args)

    # init some vars
    msg = ''
    msg_body = ''
    perfdata = ''
    state = STATE_OK
    table_data = []
    headline = []
    now = lib.time.now()
    checked = 0
    fullest = None
    invalid_count = 0
    merge_failed_count = 0
    oldest = None
    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')
    ]

    snapshots = [item for item in volumes if lib.lvm.is_snapshot(item)]

    # analyze data
    for snapshot in sorted(snapshots, key=lambda item: item.get('lv_full_name') or ''):
        name = snapshot.get('lv_full_name') or snapshot.get('lv_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(snapshot, now, args)
        checked += 1
        state = lib.base.get_worst(state, report['state'])
        if report['invalid']:
            invalid_count += 1
        if report['merge_failed']:
            merge_failed_count += 1
        if report['usage'] is not None and (
            fullest is None or report['usage'] > fullest
        ):
            fullest = report['usage']
        if report['age'] is not None and (oldest is None or report['age'] > oldest):
            oldest = report['age']

        headline.extend(report['problems'])

        usage_text = '-' if report['usage'] is None else f'{report["usage"]:.2f}%'
        row = {
            'age': '-'
            if report['age'] is None
            else lib.human.seconds2human(report['age']),
            'origin': report['origin'],
            'pool': report['pool'],
            'size': lib.human.bytes2human(report['size'] or 0),
            'snapshot': report['name'],
            'state': (
                f'{"thin" if report["thin"] else "classic"}'
                f'{lib.base.state2str(report["state"], prefix=" ")}'
            ),
            'usage': usage_text,
        }
        if args.BRIEF and report['state'] == STATE_OK:
            pass
        else:
            table_data.append(row)

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

    # build the message
    if not snapshots:
        msg = 'No LVM snapshot on this host.'
    elif not checked:
        count = len(snapshots)
        msg = (
            f'{count} LVM {lib.txt.pluralize("snapshot", count, ",s")} on this host, '
            f'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 {lib.txt.pluralize("snapshot", checked, ",s")}'
            f'{"" if fullest is None else f", the fullest at {fullest:.0f}%"}'
            f'{"" if oldest is None else f", the oldest {lib.human.seconds2human(oldest)} old"}'
            '.'
        )
    if invalid_count:
        msg_body += f'{INVALID_HELP}\n'
    if merge_failed_count:
        msg_body += f'{MERGE_FAILED_HELP}\n'
    if msg_body:
        msg += f'\n{msg_body.rstrip()}'

    perfdata += lib.base.get_perfdata('snapshots', checked, uom=None, _min=0)
    perfdata += lib.base.get_perfdata(
        'snapshots_invalid',
        invalid_count,
        uom=None,
        warn='0',
        _min=0,
    )

    # build table output
    if table_data:
        keys = ['snapshot', 'origin', 'pool', 'size', 'usage', 'age', 'state']
        headers = ['Snapshot', 'Origin', 'Pool', 'Size', 'Usage', 'Age', 'Type']
        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()
