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

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

DESCRIPTION = """Reports how much CPU each virtual machine of a libvirt host consumes, and how
much CPU its guests ask for but do not get because the host is busy elsewhere. Alerts if a
machine uses more of its assigned virtual CPUs than the thresholds allow, and if the host
makes a machine wait for CPU for too large a share of its time. Also reports how many
virtual CPUs the running machines were promised against the cores the host really has.
Supports extended reporting via --lengthy. Runs without root or sudo."""

DEFAULT_COUNT = (
    5  # measurements; if the check runs once per minute, this is a 5 minute average
)
DEFAULT_CRIT = '90'
DEFAULT_CRIT_COMMITMENT = None
DEFAULT_CRIT_STEAL = '25'
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_TIMEOUT = lib.kvm.DEFAULT_TIMEOUT
DEFAULT_URL = lib.kvm.DEFAULT_URI
DEFAULT_WARN = '80'
DEFAULT_WARN_COMMITMENT = None
DEFAULT_WARN_STEAL = '10'

# Where the previous measurements are kept, so cumulative nanosecond counters can be
# reported as the rates a dashboard can aggregate (see CONTRIBUTING.md, #320). One
# file per connection: two hypervisors may well run a machine of the same name, and
# mixing their counters into one history would produce a rate out of two unrelated
# measurements.
DB = 'linuxfabrik-monitoring-plugins-kvm-cpu-usage-{}.db'

# Where the per-virtual-CPU history is kept, next to the per-machine one in the
# same file. Two kinds of sensor, two schemas, two tables.
VCPU_TABLE = 'vcpu'
VCPU_DEFINITION = """
    name        TEXT NOT NULL,
    vcpu_time   INT NOT NULL,
    delay       INT NOT NULL,
    timestamp   REAL NOT NULL
"""

VCPU_DELAY_REGEX = re.compile(r'^vcpu\.(\d+)\.delay$')
VCPU_TIME_REGEX = re.compile(r'^vcpu\.(\d+)\.time$')

NANOSECONDS_PER_SECOND = 10**9

# The host's core count, out of `virsh nodeinfo`. libvirt prints it as a `Key: value`
# block, and this is the only line of it worth having: a machine's virtual CPUs mean
# nothing without the number of real ones they are promised out of.
NODEINFO_CPUS_REGEX = re.compile(r'^CPU\(s\)\s*:\s*([0-9]+)\s*$', re.MULTILINE)


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(
        '--count',
        help='Number of measurements the reported values are averaged over. '
        'A machine has to stay above a threshold for the whole span to alert, '
        'so a single busy minute does not. '
        'Default: %(default)s',
        dest='COUNT',
        type=int,
        default=DEFAULT_COUNT,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for the CPU usage of a machine, in percent of the '
        'virtual CPUs assigned to it. '
        'Supports Nagios ranges. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-commitment',
        help='CRIT threshold for the virtual CPUs handed out to the running machines, in percent of the cores the host has. '
        'Above 100 the host has promised more virtual CPUs than it has cores, which is normal while the machines stay idle and is the number to look at once the steal column starts moving. '
        'Supports Nagios ranges. '
        'Default: unset, the figure is reported but does not alert. '
        'Example: `400`',
        dest='CRIT_COMMITMENT',
        default=DEFAULT_CRIT_COMMITMENT,
    )

    parser.add_argument(
        '--critical-steal',
        help='CRIT threshold for the share of its time a machine spends waiting '
        'for CPU the host is using elsewhere, in percent. '
        'Supports Nagios ranges. '
        'Default: %(default)s',
        dest='CRIT_STEAL',
        default=DEFAULT_CRIT_STEAL,
    )

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

    parser.add_argument(
        '--lengthy',
        help=lib.args.help('--lengthy'),
        dest='LENGTHY',
        action='store_true',
        default=False,
    )

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

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the CPU usage of a machine, in percent of the '
        'virtual CPUs assigned to it. '
        'Supports Nagios ranges. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-commitment',
        help='WARN threshold for the virtual CPUs handed out to the running machines, in percent of the cores the host has. '
        'Above 100 the host has promised more virtual CPUs than it has cores, which is normal while the machines stay idle and is the number to look at once the steal column starts moving. '
        'Supports Nagios ranges. '
        'Default: unset, the figure is reported but does not alert. '
        'Example: `400`',
        dest='WARN_COMMITMENT',
        default=DEFAULT_WARN_COMMITMENT,
    )

    parser.add_argument(
        '--warning-steal',
        help='WARN threshold for the share of its time a machine spends waiting '
        'for CPU the host is using elsewhere, in percent. '
        'Supports Nagios ranges. '
        'Default: %(default)s',
        dest='WARN_STEAL',
        default=DEFAULT_WARN_STEAL,
    )

    args, _ = parser.parse_known_args()
    return args


def get_host_cores(stdout):
    """Pick the host's core count out of `virsh nodeinfo` output.

    Returns 0 when the line is missing, which leaves the share of the host's cores the
    machines were promised unreported rather than divided by zero.
    """
    match = NODEINFO_CPUS_REGEX.search(stdout)
    return int(match.group(1)) if match else 0


def get_counters(stats):
    """Pick the cumulative counters and the virtual CPU count out of one machine's
    statistics.

    Returns a `(cpu_time, vcpu_time, delay, vcpu_count)` tuple, all nanosecond
    counters except the count.

    `cpu.time` covers the whole hypervisor process, so it includes the emulator and
    I/O threads working on the machine's behalf and not only its virtual CPUs. That
    makes it the right measure of what the machine costs the host, and the wrong one
    for how saturated the machine itself is: measured against the virtual CPU count
    it exceeds 100% for a perfectly healthy machine. Verified against libvirt 12.0.0
    on a guest saturating both of its virtual CPUs, where `cpu.time` came to 100.5%
    and the virtual CPUs themselves to 99.8%.

    libvirt numbers the virtual CPUs sparsely after a hotplug, so the per-CPU
    entries are collected by their key rather than by counting up to `vcpu.current`.
    """
    delay = 0
    vcpu_time = 0
    vcpus = 0
    for key, value in stats.items():
        if VCPU_DELAY_REGEX.match(key):
            delay += value
            vcpus += 1
        elif VCPU_TIME_REGEX.match(key):
            vcpu_time += value
    # `vcpu.current` is what the machine is assigned; the number of per-CPU entries
    # is only what libvirt happened to report. Prefer the former, fall back to the
    # latter, and never end up dividing by zero.
    vcpu_count = stats.get('vcpu.current') or vcpus or 1
    return stats.get('cpu.time', 0), vcpu_time, delay, vcpu_count


def get_vcpu_counters(stats):
    """Pick the counters of each virtual CPU out of one machine's statistics.

    Returns `{index: {'delay': int, 'vcpu_time': int}}`, both nanosecond counters.

    Kept apart from the sums `get_counters()` returns, because they answer different
    questions. The sum says how busy the machine is; the individual figures say
    whether the work is spread over the virtual CPUs it was given or is sitting on
    one of them, which is what a single-threaded job inside the guest looks like and
    what the sum hides completely: four virtual CPUs of which one is saturated
    average out at 25%.

    libvirt numbers them sparsely after a hotplug, so they are collected by their key
    rather than by counting up to `vcpu.current`.
    """
    counters = {}
    for key, value in stats.items():
        match = VCPU_TIME_REGEX.match(key)
        if match:
            counters.setdefault(int(match.group(1)), {})['vcpu_time'] = value
            continue
        match = VCPU_DELAY_REGEX.match(key)
        if match:
            counters.setdefault(int(match.group(1)), {})['delay'] = value
    # A virtual CPU that reported only one of the two is padded, so the history table
    # it feeds keeps a stable set of columns.
    return {
        index: {'delay': item.get('delay', 0), 'vcpu_time': item.get('vcpu_time', 0)}
        for index, item in counters.items()
    }


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:
        # Only running machines. A machine that is shut off still reports a
        # `cpu.time`, but that value is shared by all shut-off machines and grows
        # between two runs, so a rate computed from it would be invented.
        domstats = lib.base.coe(
            lib.kvm.get_domstats(
                uri=args.URL,
                groups=['cpu-total', 'vcpu'],
                running_only=True,
                timeout=args.TIMEOUT,
            )
        )
    else:
        domstats = lib.kvm.parse_domstats(lib.lftest.test_text(args.TEST))

    if not domstats:
        lib.base.oao('No running virtual machines found.', STATE_OK)

    if args.TEST is None:
        nodeinfo = lib.base.coe(
            lib.kvm.virsh(['nodeinfo'], uri=args.URL, timeout=args.TIMEOUT)
        )
    else:
        nodeinfo = lib.lftest.test_text(args.TEST, f'{args.TEST[0]}-nodeinfo')

    # Record this measurement and read back the rates. `compute_load()` returns a
    # rate over the last two samples and one over the whole `--count` span; the
    # long one is what the thresholds judge, so a single busy minute cannot alert.
    # A machine that has not been measured `--count` times yet is simply absent
    # from the result, which is how the ones that have been running all along keep
    # being reported while a freshly started machine warms up.
    if args.TEST is None:
        conn = lib.base.coe(
            lib.db_sqlite.connect(filename=DB.format(re.sub(r'\W+', '_', args.URL)))
        )
        # The timestamp is stored with sub-second resolution. Rounded to whole
        # seconds, a window of five samples taken a few seconds apart is recorded
        # as spanning up to two seconds less than it really did, and every rate
        # computed from it comes out that much too high. The error grows the
        # shorter the check interval is, and a manual run next to the scheduled
        # one shortens it further.
        definition = """
            name        TEXT NOT NULL,
            cpu_time    INT NOT NULL,
            vcpu_time   INT NOT NULL,
            delay       INT NOT NULL,
            timestamp   REAL NOT NULL
        """
        lib.base.coe(
            lib.db_sqlite.create_table(conn, definition, drop_table_first=False)
        )
        lib.base.coe(lib.db_sqlite.create_index(conn, 'name'))
        now = lib.time.now(as_type='float')
        for name, stats in domstats.items():
            cpu_time, vcpu_time, delay, _ = get_counters(stats)
            lib.base.coe(
                lib.db_sqlite.insert(
                    conn,
                    {
                        'cpu_time': cpu_time,
                        'delay': delay,
                        'name': name,
                        'timestamp': now,
                        'vcpu_time': vcpu_time,
                    },
                )
            )
        # Prune per machine. Trimming the table to a total row count lets whichever
        # machine is sampled most often evict the history of the others, which
        # happens as soon as the check runs twice over the same connection.
        lib.base.coe(
            lib.db_sqlite.cut_per_sensor(conn, sensorcol='name', _max=args.COUNT)
        )

        # The virtual CPUs of every machine, one sensor each, in a table of their
        # own. Sharing the table above would mean padding every row with the columns
        # the other kind of sensor needs, and a padded counter reads as a real zero.
        lib.base.coe(
            lib.db_sqlite.create_table(
                conn,
                VCPU_DEFINITION,
                table=VCPU_TABLE,
                drop_table_first=False,
            )
        )
        lib.base.coe(lib.db_sqlite.create_index(conn, 'name', table=VCPU_TABLE))
        for name, stats in domstats.items():
            for index, counters in get_vcpu_counters(stats).items():
                lib.base.coe(
                    lib.db_sqlite.insert(
                        conn,
                        {
                            'delay': counters['delay'],
                            'name': f'{name}/vcpu{index}',
                            'timestamp': now,
                            'vcpu_time': counters['vcpu_time'],
                        },
                        table=VCPU_TABLE,
                    )
                )
        lib.base.coe(
            lib.db_sqlite.cut_per_sensor(
                conn, sensorcol='name', _max=args.COUNT, table=VCPU_TABLE
            )
        )
        lib.base.coe(lib.db_sqlite.commit(conn))
        loads = lib.base.coe(
            lib.db_sqlite.compute_load(
                conn,
                sensorcol='name',
                datacols=['cpu_time', 'vcpu_time', 'delay'],
                count=args.COUNT,
            )
        )
        vcpu_loads = lib.base.coe(
            lib.db_sqlite.compute_load(
                conn,
                sensorcol='name',
                datacols=['vcpu_time', 'delay'],
                count=args.COUNT,
                table=VCPU_TABLE,
            )
        )
        lib.db_sqlite.close(conn)
        rates = {item['name']: item for item in (loads or [])}
        vcpu_rates = {item['name']: item for item in (vcpu_loads or [])}
    else:
        # In test mode there is no history to average over, so the fixture values
        # are taken as the per-second rates themselves, for both spans. A fixture
        # saying `cpu.time=900000000` on one virtual CPU is 90%.
        rates = {}
        vcpu_rates = {}
        for name, stats in domstats.items():
            cpu_time, vcpu_time, delay, _ = get_counters(stats)
            rates[name] = {
                'cpu_time1': cpu_time,
                'cpu_timen': cpu_time,
                'delay1': delay,
                'delayn': delay,
                'vcpu_time1': vcpu_time,
                'vcpu_timen': vcpu_time,
            }
            for index, counters in get_vcpu_counters(stats).items():
                vcpu_rates[f'{name}/vcpu{index}'] = {
                    'delay1': counters['delay'],
                    'delayn': counters['delay'],
                    'vcpu_time1': counters['vcpu_time'],
                    'vcpu_timen': counters['vcpu_time'],
                }

    # init some vars
    cores_used = 0.0
    host_cores = get_host_cores(nodeinfo)
    msg = ''
    perfdata = ''
    state = STATE_OK
    steal_offenders = []
    vcpus_assigned = 0
    steal_state_worst = STATE_OK
    table_data = []
    vcpu_data = []
    waiting = []
    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

        # Not measured often enough yet: the first runs after an install, after the
        # cache was wiped, or after this machine was started. Name the machine
        # instead of dropping it, so nobody wonders why it is missing.
        if name not in rates:
            waiting.append(name)
            continue

        _, _, _, vcpu_count = get_counters(domstats[name])
        vcpus_assigned += vcpu_count
        load = rates[name]

        # A rate of nanoseconds of CPU per second of wall clock is the number of
        # host cores the machine kept busy. Divided by the virtual CPUs it was
        # given, that is how saturated the machine itself is.
        #
        # `delay` is the same kernel counter the guest sees as its steal time,
        # not an approximation of it. libvirt reads field 2 of
        # /proc/<pid>/task/<tid>/schedstat (qemu_driver.c:qemuGetSchedstatDelay),
        # which the kernel fills from `task->sched_info.run_delay`
        # (fs/proc/base.c:proc_pid_schedstat), the time a thread was queued and
        # not running (kernel/sched/stats.h:sched_info_arrive). KVM builds the
        # steal counter it hands to the guest from that very field
        # (arch/x86/kvm/x86.c:record_steal_time). Checked against Linux 7.1 and
        # libvirt 12.0.0. The counter needs CONFIG_SCHED_INFO, which the KVM
        # Kconfig of x86, arm64, riscv and loongarch selects, so it is there on
        # every architecture a Linuxfabrik host is likely to run. s390 and powerpc
        # do not select it, and libvirt then reports the delay as 0 rather than
        # leaving it out (qemuGetSchedstatDelay() returns without writing to an
        # already zeroed buffer when the schedstat file is absent), so on those two
        # the steal column reads 0.0% instead of saying that nobody measured it.
        # Two different questions, two different counters. `cpu.time` is what the
        # machine costs the host, emulation included, and is reported as cores.
        # The saturation of the machine itself is measured against its own virtual
        # CPUs only, which is what keeps the percentage inside 0..100.
        cores = load['cpu_timen'] / NANOSECONDS_PER_SECOND
        cores_used += cores
        cpu_percent = round(
            load['vcpu_timen'] / NANOSECONDS_PER_SECOND / vcpu_count * 100, 1
        )
        steal_percent = round(
            load['delayn'] / NANOSECONDS_PER_SECOND / vcpu_count * 100, 1
        )
        cpu_percent_now = round(
            load['vcpu_time1'] / NANOSECONDS_PER_SECOND / vcpu_count * 100, 1
        )
        steal_percent_now = round(
            load['delay1'] / NANOSECONDS_PER_SECOND / vcpu_count * 100, 1
        )

        cpu_state = lib.base.get_state(
            cpu_percent, args.WARN, args.CRIT, _operator='range'
        )
        steal_state = lib.base.get_state(
            steal_percent, args.WARN_STEAL, args.CRIT_STEAL, _operator='range'
        )
        item_state = lib.base.get_worst(cpu_state, steal_state)
        state = lib.base.get_worst(state, item_state)

        if steal_state != STATE_OK:
            steal_offenders.append(f'{name} ({steal_percent}%)')
            steal_state_worst = lib.base.get_worst(steal_state_worst, steal_state)

        table_data.append(
            {
                '_state': item_state,
                'cpu': f'{cpu_percent}%',
                'name': name,
                'steal': f'{steal_percent}%',
                # The verdict of the whole row, kept in its own column at the end
                # of the line: IcingaWeb replaces the marker with an icon and would
                # break the table alignment anywhere else.
                'state': lib.base.state2str(item_state, empty_ok=False),
                'vcpus': vcpu_count,
            }
        )

        # One row per virtual CPU, for `--lengthy`. No performance data of its own:
        # the machine's figures above already carry the trend, and a metric per
        # virtual CPU would multiply the line by the vCPU count of the whole host.
        for index in sorted(get_vcpu_counters(domstats[name])):
            vcpu_load = vcpu_rates.get(f'{name}/vcpu{index}')
            if vcpu_load is None:
                continue
            vcpu_percent = round(
                vcpu_load['vcpu_timen'] / NANOSECONDS_PER_SECOND * 100, 1
            )
            vcpu_steal = round(vcpu_load['delayn'] / NANOSECONDS_PER_SECOND * 100, 1)
            vcpu_data.append(
                {
                    'cpu': f'{vcpu_percent}%',
                    'name': name,
                    'steal': f'{vcpu_steal}%',
                    'vcpu': f'vcpu{index}',
                }
            )

        perfdata_name = re.sub(r'\W+', '_', name)
        perfdata += lib.base.get_perfdata(
            f'{perfdata_name}_cpu_usage',
            cpu_percent,
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            f'{perfdata_name}_cpu_steal',
            steal_percent,
            uom='%',
            warn=args.WARN_STEAL,
            crit=args.CRIT_STEAL,
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            f'{perfdata_name}_cpu_cores',
            round(cores, 3),
            uom=None,
            _min=0,
        )
        # The value of the last interval alone, carrying no thresholds: it is what
        # a live graph shows, and comparing it with the averaged one above is how a
        # spike is told apart from a machine that has been busy all along.
        perfdata += lib.base.get_perfdata(
            f'{perfdata_name}_cpu_usage1',
            cpu_percent_now,
            uom='%',
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            f'{perfdata_name}_cpu_steal1',
            steal_percent_now,
            uom='%',
            _min=0,
            _max=100,
        )

    # nothing left to report on. A host with no running machine has already left
    # through the same answer up in "fetch data", before a state file was touched.
    if not table_data and not waiting:
        lib.base.oao(
            f'Nothing checked. {len(domstats)} running virtual machines are '
            f'filtered out by `--match` or `--ignore`.',
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
        )

    # build the message
    checked = len(table_data)
    if not checked:
        # Every machine is still without a baseline, so there is nothing to report
        # yet. Naming that plainly beats a summary of zeroes.
        msg += f'Waiting for more data: {", ".join(waiting)}'
    else:
        msg += f'{checked} VM{"s" if checked != 1 else ""}, {vcpus_assigned} vCPUs'
        if host_cores:
            # What the machines were promised against what the host has. Handing out
            # more virtual CPUs than there are cores is normal, which is why the
            # thresholds on it are unset by default; it is the number to look at when
            # the steal column starts moving. The host actually running out of CPU is
            # what `check_cpu_usage` reports, and is not repeated here.
            commitment = round(vcpus_assigned / host_cores * 100, 1)
            commitment_state = lib.base.get_state(
                commitment,
                args.WARN_COMMITMENT,
                args.CRIT_COMMITMENT,
                _operator='range',
            )
            state = lib.base.get_worst(state, commitment_state)
            msg += (
                f" ({commitment}% of the host's {host_cores} cores"
                f'{lib.base.state2str(commitment_state, prefix=" ")})'
            )
        msg += f', {cores_used:.2f} cores used, averaged over {args.COUNT} measurements'
        if steal_offenders:
            msg += (
                f'. Waiting for host CPU: {", ".join(steal_offenders)}'
                f'{lib.base.state2str(steal_state_worst, prefix=" ")}'
            )
        if waiting:
            msg += f'. Waiting for more data: {", ".join(waiting)}'
    perfdata += lib.base.get_perfdata(
        'cpu_cores_used',
        round(cores_used, 2),
        uom=None,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'vcpus_assigned',
        vcpus_assigned,
        uom=None,
        _min=0,
    )
    if host_cores:
        perfdata += lib.base.get_perfdata(
            'cpu_cores_total',
            host_cores,
            uom=None,
            _min=0,
        )
        perfdata += lib.base.get_perfdata(
            'vcpu_commitment',
            round(vcpus_assigned / host_cores * 100, 1),
            uom='%',
            warn=args.WARN_COMMITMENT,
            crit=args.CRIT_COMMITMENT,
            _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:
        # Stripped, because `get_table()` ends in a newline of its own and the table
        # below opens with a blank line.
        msg += (
            '\n\n'
            + lib.base.get_table(
                display_data,
                ['name', 'vcpus', 'cpu', 'steal', 'state'],
                header=['VM Name', 'vCPUs', 'CPU', 'Steal', 'State'],
            ).rstrip()
        )

    # Every virtual CPU on its own line, which is what tells a machine that is busy
    # from one that has a single thread pinning one of its virtual CPUs while the
    # rest idle. The averaged figure above cannot show the difference: four virtual
    # CPUs of which one is saturated come out at 25%.
    if args.LENGTHY and vcpu_data:
        msg += '\n\n' + lib.base.get_table(
            vcpu_data,
            ['name', 'vcpu', 'cpu', 'steal'],
            header=['VM Name', 'vCPU', 'CPU', 'Steal'],
        )

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