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

import lib.args
import lib.base
import lib.kvm
import lib.lftest
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Lists the virtual machines of a libvirt host and checks the state of each
one, together with the reason libvirt gives for it. Alerts if a machine is paused, idle
or suspended by guest power management, if a machine that is configured to start
together with the host is not running, and if a machine did not end the way somebody
asked it to: frozen on a storage error, killed off the host, or never started at all.
Runs without root or sudo."""

DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_TIMEOUT = lib.kvm.DEFAULT_TIMEOUT
DEFAULT_URL = lib.kvm.DEFAULT_URI

# How each of libvirt's domain states is judged. Everything this does not name is
# reported WARN, which covers the states below and any state libvirt appends to its
# enumeration later: an unrecognised state is worth a look, and saying so beats
# passing it over in silence.
CRIT_STATES = ('crashed',)
OK_STATES = ('in shutdown', 'running', 'shut off')

# The states above say where a machine ended up, and for `shut off` that is the same
# answer for a machine somebody switched off and for one that died. How it got there
# is in the reason libvirt records next to the state, and these are the reasons that
# say it ended badly. Judged by state and reason together, so a machine whose state
# reads normal is still reported when the reason does not.
#
# `crashed` is the reason that matters most, and it is not the guest panicking. Only
# a guest with a panic device reports a panic at all, and `on_crash` then defaults to
# `destroy`, so even that ends here rather than in the `crashed` state. What libvirt
# really records this way is the hypervisor process disappearing without having
# announced a shutdown first: "Monitor connection closed without SHUTDOWN event;
# assuming the domain crashed" (`processMonitorEOFEvent()` in qemu_driver.c). That is
# the out-of-memory killer, a segfault, or anything else that takes the process off
# the host. Verified against libvirt 12.0.0 by killing a running machine's process:
# the machine came back as `shut off` with reason `crashed`, and a plain state check
# reported it OK next to the machines that had been switched off properly.
#
# Every reason libvirt knows is accounted for. The ones absent from this mapping are
# routine and keep the verdict their state already carries: a machine is `shut off`
# after `shutdown`, `destroyed`, `migrated`, `saved` or `from snapshot`, and `unknown`
# is what libvirt answers for every machine it has no history for, which is all of
# them after the daemon restarts. `paused` stays WARN for `user`, `migrating`,
# `saving`, `dumping`, `from snapshot`, `shutting down`, `creating snapshot`,
# `starting up` and `post-copy`, all of which are somebody at work rather than a
# fault.
BAD_REASONS = {
    # A machine that is running again but whose migration broke on the way. It
    # serves, and the pair of hosts it is spread over is not in a state to leave.
    ('running', 'post-copy failed'): STATE_WARN,
    # Frozen, and only these five are a fault rather than an operation in progress.
    # Frozen is as bad as gone for whoever depends on the machine, and none of the
    # five resolves itself.
    ('paused', 'I/O error'): STATE_CRIT,
    ('paused', 'watchdog'): STATE_CRIT,
    ('paused', 'crashed'): STATE_CRIT,
    ('paused', 'post-copy failed'): STATE_CRIT,
    ('paused', 'api error'): STATE_CRIT,
    # Gone, and not because anybody asked. WARN rather than CRIT: the machine is
    # already down, so nothing is saved by waking somebody at three in the morning
    # who cannot do more at that hour than in the morning.
    ('shut off', 'crashed'): STATE_WARN,
    ('shut off', 'failed'): STATE_WARN,
    ('shut off', 'daemon'): STATE_WARN,
}


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=False,
    )

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

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

    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(
        '--url',
        help='libvirt connection URI, passed to `virsh --connect`. '
        'Use `qemu+ssh://user@host/system` to check a host that runs no local '
        'monitoring agent. '
        'Only QEMU/KVM connections report the data this check needs. '
        'Default: %(default)s',
        dest='URL',
        default=DEFAULT_URL,
    )

    args, _ = parser.parse_known_args()
    return args


def get_severity(domain_state, reason=None):
    """Return the state to report for one of libvirt's domain states.

    The reason is optional, because a caller summarising a state across several
    machines has no single one to pass.
    """
    state = STATE_OK if domain_state in OK_STATES else STATE_WARN
    if domain_state in CRIT_STATES:
        state = STATE_CRIT
    return lib.base.get_worst(state, BAD_REASONS.get((domain_state, reason), STATE_OK))


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
    if args.TEST is None:
        # `domstats` reports every domain, running or not, and quotes the domain
        # name, so a name containing a space stays readable. Autostart and
        # persistence are not part of it and are asked for separately.
        domstats = lib.base.coe(
            lib.kvm.get_domstats(
                uri=args.URL,
                groups=['state'],
                running_only=False,
                timeout=args.TIMEOUT,
            )
        )
        autostart = set(
            lib.base.coe(
                lib.kvm.get_domains(
                    uri=args.URL,
                    filters=['autostart'],
                    timeout=args.TIMEOUT,
                )
            )
        )
        persistent = set(
            lib.base.coe(
                lib.kvm.get_domains(
                    uri=args.URL,
                    filters=['persistent'],
                    timeout=args.TIMEOUT,
                )
            )
        )
    else:
        stdout, _, _ = lib.lftest.test(args.TEST)
        domstats = lib.kvm.parse_domstats(stdout)
        autostart = set()
        persistent = set()

    # init some vars
    counts = {}
    ended_badly = []
    ended_badly_state_worst = STATE_OK
    msg = ''
    not_running = []
    perfdata = ''
    state = STATE_OK
    table_data = []
    compiled_ignore = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.IGNORE, '--ignore')
    ]
    compiled_match = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.MATCH, '--match')
    ]

    # analyze data
    for name in sorted(domstats):
        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

        # A state libvirt does not name is reported by its number instead of being
        # dropped, so a value added to the enumeration later still shows up. The same
        # goes for the reason, which libvirt numbers within its state.
        state_id = domstats[name].get('state.state')
        reason_id = domstats[name].get('state.reason')
        domain_state = lib.kvm.DOMAIN_STATES.get(state_id, f'state {state_id}')
        reason = lib.kvm.DOMAIN_STATE_REASONS.get(state_id, {}).get(reason_id)
        if reason is None and reason_id is not None:
            reason = f'reason {reason_id}'
        counts[domain_state] = counts.get(domain_state, 0) + 1

        item_state = get_severity(domain_state, reason)
        state = lib.base.get_worst(state, item_state)

        # A machine whose state reads normal and whose reason does not. This is the
        # one thing a state count cannot show, and the reason it is checked at all:
        # a machine whose process was killed off the host sits in `shut off` next to
        # the ones somebody switched off.
        if (domain_state, reason) in BAD_REASONS:
            ended_badly.append(f'{name} ({domain_state}, {reason})')
            ended_badly_state_worst = lib.base.get_worst(
                ended_badly_state_worst, BAD_REASONS[(domain_state, reason)]
            )

        # A machine set to start with the host and yet not running is the one case
        # a plain state count cannot show: "shut off" is a perfectly normal state
        # for every other machine on the host.
        autostart_down = name in autostart and domain_state != 'running'
        if autostart_down:
            not_running.append(name)
            item_state = lib.base.get_worst(item_state, STATE_WARN)
            state = lib.base.get_worst(state, STATE_WARN)

        table_data.append(
            {
                '_state': item_state,
                'autostart': 'yes' if name in autostart else 'no',
                'name': name,
                'persistent': 'yes' if name in persistent else 'no',
                'state': f'{domain_state}{f" ({reason})" if reason else ""} '
                f'{lib.base.state2str(item_state, empty_ok=False)}',
            }
        )

    # A host that has no machines at all and a host whose machines were all filtered
    # out look the same in the table, but they are different situations and only the
    # second one is what `--no-match-severity` is about.
    if not domstats:
        lib.base.oao('No virtual machines found.', STATE_OK, always_ok=args.ALWAYS_OK)
    if not table_data:
        lib.base.oao(
            f'Nothing checked. {len(domstats)} virtual machines are filtered out by '
            f'`--match` or `--ignore`.',
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
        )

    # build the message
    total = len(table_data)
    summary = ', '.join(
        f'{counts[item]} {item}{lib.base.state2str(get_severity(item), prefix=" ")}'
        for item in sorted(counts)
    )
    msg += f'{total} VM{"s" if total != 1 else ""}: {summary}'
    if ended_badly:
        msg += (
            f'. Did not end the way somebody asked: {", ".join(ended_badly)}'
            f'{lib.base.state2str(ended_badly_state_worst, prefix=" ")}'
        )
    if not_running:
        msg += (
            f'. Set to start with the host but not running: {", ".join(not_running)}'
            f'{lib.base.state2str(STATE_WARN, prefix=" ")}'
        )

    # Every state libvirt knows is reported, including the ones nobody is in, so a
    # dashboard keeps a stable set of metrics instead of losing a line whenever the
    # host happens to have no machine in that state. A state outside that
    # enumeration deliberately gets no metric of its own, which would appear and
    # disappear with the libvirt release; it is named in the message instead.
    for domain_state in sorted(lib.kvm.DOMAIN_STATES.values()):
        # A count that is a problem the moment it leaves zero carries the range `0`,
        # so a dashboard draws the alerting line where the check draws it.
        severity = get_severity(domain_state)
        warn = '0' if severity == STATE_WARN else None
        crit = '0' if severity == STATE_CRIT else None
        perfdata += lib.base.get_perfdata(
            f'vm_{domain_state.replace(" ", "_")}',
            counts.get(domain_state, 0),
            uom=None,
            warn=warn,
            crit=crit,
            _min=0,
        )
    perfdata += lib.base.get_perfdata(
        'vm_autostart_down',
        len(not_running),
        uom=None,
        warn='0',
        _min=0,
    )
    # The states above count where the machines are, this counts how many of them got
    # there badly. A machine whose process was killed is `shut off` like any other, so
    # the state counters alone never move for it.
    perfdata += lib.base.get_perfdata(
        'vm_ended_badly',
        len(ended_badly),
        uom=None,
        warn='0',
        _min=0,
    )

    # build table output

    # --brief only reshapes the table. Every item above has already emitted its
    # performance data and has already driven the overall state, so hiding a row
    # here changes nothing but what a reader has to scroll past.
    display_data = (
        [row for row in table_data if row['_state'] != STATE_OK]
        if args.BRIEF
        else table_data
    )
    if display_data:
        msg += '\n\n' + lib.base.get_table(
            display_data,
            ['name', 'autostart', 'persistent', 'state'],
            header=['VM Name', 'Autostart', 'Persistent', 'State'],
        )

    # 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()
