#!/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 json
import sys
import urllib.parse

import lib.args
import lib.base
import lib.disk
import lib.human
import lib.lftest
import lib.openstack
import lib.time
import lib.txt
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Lists the OpenStack Nova compute instances (virtual servers) of a project and
reports the status of every one of them. Alerts when an instance sits in a status that needs
attention, for example ERROR, or when the Nova API cannot be reached in time. Also alerts on an
instance Nova lists as ACTIVE while the hypervisor last reported it as anything but running,
which the Nova status itself never shows. The state reported per Nova status is configurable, so
a cloud on which powered-off instances are a problem can say so. Supports extended reporting via
--lengthy."""

# The Compute API microversion to ask for. 2.1 is the baseline every Nova release
# since Kilo speaks. Instances whose cell is unreachable are silently left out
# of the listing below microversion 2.69, so a listing is only ever as complete
# as the cells that answered.
API_HEADER = {'OpenStack-API-Version': 'compute 2.1'}

# The state to report per Nova server status. Every status the Compute API can
# report is listed, so a status never falls through to a guessed default.
# Verified against nova/api/openstack/common.py:_STATE_MAP (19 values) plus the
# `UNKNOWN` fallback of status_from_state().
DEFAULT_SEVERITY = {
    'ACTIVE': 'ok',
    'BUILD': 'warn',
    'DELETED': 'crit',
    'ERROR': 'crit',
    'HARD_REBOOT': 'warn',
    'MIGRATING': 'ok',
    # `PASSWORD` is a Nova server status name, not a secret. Nova only accepts
    # a password reset on an ACTIVE instance, so the instance is healthy and an
    # authorized user is changing its root password right now.
    'PASSWORD': 'ok',  # nosec B105
    'PAUSED': 'warn',
    'REBOOT': 'ok',
    'REBUILD': 'warn',
    # Rescue is a deliberate administrative action, but the instance runs a
    # rescue image instead of its workload, which is worth noticing.
    'RESCUE': 'warn',
    'RESIZE': 'warn',
    'REVERT_RESIZE': 'warn',
    'SHELVED': 'ok',
    'SHELVED_OFFLOADED': 'ok',
    'SHUTOFF': 'ok',
    'SOFT_DELETED': 'warn',
    'SUSPENDED': 'ok',
    'UNKNOWN': 'crit',
    'VERIFY_RESIZE': 'warn',
}

# The instance fields `--match-*` and `--ignore-*` can filter on, mapped to the
# key the Compute API puts them under, plus the wording used when the field is
# missing from the response. Availability zone and VM state are handed to every
# user; the compute host sits behind the policy
# `os_compute_api:os-extended-server-attributes`, which defaults to admin, so a
# project-scoped account gets no such key at all. Verified against
# nova/api/openstack/compute/views/servers.py and
# nova/policies/extended_server_attributes.py.
FILTER_FIELDS = {
    'host': (
        'OS-EXT-SRV-ATTR:host',
        'compute host',
        ' Nova reports it only to a project with administrative rights.',
    ),
    'vm-state': ('OS-EXT-STS:vm_state', 'VM state', ''),
    'zone': ('OS-EXT-AZ:availability_zone', 'availability zone', ''),
}

# What Nova is doing to an instance right now, empty whenever nothing is going
# on. That is also the only case in which the VM state would repeat the server
# status: with no task running the status determines the VM state exactly,
# because _STATE_MAP maps each of the twelve VM states to a distinct default
# status. So this is the field that says something the status does not, and it
# drops out of the table by itself on a quiet cloud.
TASK_STATE_KEY = 'OS-EXT-STS:task_state'

# What the hypervisor last reported about the instance, and what Nova wrote into
# its database from that. This is the field the Nova status is NOT derived from,
# which is why it is read here at all: `status_from_state()` builds the status
# out of the VM state and the task state alone, so an instance whose domain is
# paused, shut down or gone keeps saying ACTIVE.
POWER_STATE_KEY = 'OS-EXT-STS:power_state'

# The one power state that belongs to an instance Nova calls ACTIVE. Nova puts it
# that way itself: "The only rational power state should be RUNNING".
POWER_STATE_RUNNING = 1

# What each power state index means, taken whole from
# nova/objects/fields.py:InstancePowerState.ALL, which is ordered by index and
# lists `_unused` at 2 and 5. Those two are carried rather than dropped: a cloud
# that ever reports one must not fall through to a missing key.
# Index 0 is spelled `pending` upstream, which says the opposite of what it means
# for an instance that is already ACTIVE. Nova sets it when the driver answers
# `InstanceNotFound` and logs "Instance is unexpectedly not found", so it is
# worded for the administrator here.
POWER_STATES = {
    0: 'not found',
    1: 'running',
    2: 'unused',
    3: 'paused',
    4: 'shutdown',
    5: 'unused',
    6: 'crashed',
    7: 'suspended',
}

# How much of an opaque identifier the table shows, the way a short commit hash
# stands in for the full one. The instance ID is a UUID, and `hostId` is a
# SHA-224 over the project ID and the compute host, so ten characters still
# tell apart the handful of compute hosts and instances a single project holds,
# while the full values push the columns that follow off the screen. Verified
# against nova/utils.py:generate_hostid().
SHORT_ID_LENGTH = 10

DEFAULT_BRIEF = False
DEFAULT_CACHE_EXPIRE = 50  # minutes; a Keystone token commonly lives 60
DEFAULT_INSECURE = False
DEFAULT_LENGTHY = False
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_POWER_MISMATCH_SEVERITY = 'warn'
DEFAULT_RC_FILE = '/var/spool/icinga2/.openstack.cnf'
DEFAULT_TIMEOUT = 8


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(
        '--cache-expire',
        help=lib.args.help('--cache-expire') + ' Default: %(default)s',
        dest='CACHE_EXPIRE',
        type=int,
        default=DEFAULT_CACHE_EXPIRE,
    )

    parser.add_argument(
        '--ignore',
        help=lib.args.help('--ignore-regex') + ' Matched against the instance name.',
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--ignore-host',
        help=lib.args.help('--ignore-regex')
        + ' Matched against the compute host the instance runs on.',
        dest='IGNORE_HOST',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--ignore-vm-state',
        help=lib.args.help('--ignore-regex')
        + ' Matched against the VM state, which is the lower-case stable state '
        'the server status is derived from, for example `active` or `stopped`.',
        dest='IGNORE_VM_STATE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--ignore-zone',
        help=lib.args.help('--ignore-regex')
        + ' Matched against the availability zone of the instance.',
        dest='IGNORE_ZONE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

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

    parser.add_argument(
        '--match',
        help=lib.args.help('--match') + ' Matched against the instance name.',
        dest='MATCH',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match-host',
        help=lib.args.help('--match')
        + ' Matched against the compute host the instance runs on.',
        dest='MATCH_HOST',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match-vm-state',
        help=lib.args.help('--match')
        + ' Matched against the VM state, which is the lower-case stable state '
        'the server status is derived from, for example `active` or `stopped`.',
        dest='MATCH_VM_STATE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match-zone',
        help=lib.args.help('--match')
        + ' Matched against the availability zone of the instance.',
        dest='MATCH_ZONE',
        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(
        '--no-proxy',
        help=lib.args.help('--no-proxy'),
        dest='NO_PROXY',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--power-mismatch-severity',
        help='State to report for an instance Nova lists as ACTIVE while the '
        'hypervisor last reported it as anything but running, for example '
        'paused, shut down or gone. The Nova status never shows this, and Nova '
        'does not correct all of these cases by itself. '
        'Use `crit` on a cloud where such an instance is an outage. '
        'Default: %(default)s',
        dest='POWER_MISMATCH_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_POWER_MISMATCH_SEVERITY,
    )

    parser.add_argument(
        '--proxy',
        help=lib.args.help('--proxy'),
        dest='PROXY',
        default=None,
    )

    parser.add_argument(
        '--rc-file',
        help='Path to a rc file containing OpenStack connection parameters like '
        'OS_USERNAME (instead of specifying them on the command line). '
        'Example: `--rc-file=/var/spool/icinga2/.openstack.cnf`. '
        'Default: %(default)s',
        dest='RC_FILE',
        default=DEFAULT_RC_FILE,
    )

    parser.add_argument(
        '--severity',
        help='State to report for instances in a given Nova status, as '
        '`STATUS,STATE`. '
        'STATUS is a Nova server status such as `SHUTOFF`, case-insensitive. '
        'STATE is one of `ok`, `warn`, `crit` or `unknown`. '
        'Overrides the built-in state for that status only, every other status '
        'keeps its default. '
        'Can be specified multiple times. '
        'Example: `--severity=SHUTOFF,warn --severity=BUILD,ok`',
        dest='SEVERITY',
        action='append',
        type=lib.args.csv,
        default=None,
    )

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

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout')
        + ' Applies to the whole run, not to a single request. '
        'Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    args, _ = parser.parse_known_args()
    return args


def get_severity_map(severity_args):
    """Merge the `--severity` overrides into the built-in state map.

    Returns (True, dict) with the effective status to state mapping, or
    (False, errormessage) if an override names a status or a state that does
    not exist.
    """
    severity_map = dict(DEFAULT_SEVERITY)
    for override in severity_args:
        if len(override) != 2:
            return (
                False,
                f'Cannot read `--severity={",".join(override)}`. Expected '
                f'`STATUS,STATE`, for example `--severity=SHUTOFF,warn`.',
            )
        status, state = override[0].strip().upper(), override[1].strip().lower()
        if status not in severity_map:
            known = ', '.join(sorted(severity_map))
            return (
                False,
                f'`--severity` names the unknown Nova status "{status}". '
                f'Known statuses are: {known}.',
            )
        if lib.base.str2state(state, ignore_error=False) is None:
            return (
                False,
                f'`--severity` names the unknown state "{override[1].strip()}" for '
                f'status {status}. Use one of ok, warn, crit or unknown.',
            )
        severity_map[status] = state
    return (True, severity_map)


def get_data(args, env):
    """Fetch the server listing, reusing a cached token where possible.

    Returns (True, list) or (False, errormessage). A timeout or a refused
    connection is reported as a warning rather than as a plugin failure,
    because it says something about the cloud and not about the check.
    """
    success, conn = lib.openstack.connect(
        env,
        ['compute'],
        timeout=args.TIMEOUT,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        proxy=args.PROXY,
        cache_expire=args.CACHE_EXPIRE,
        cache_name='openstack-nova-list',
    )
    if not success:
        return (False, conn)
    if 'compute' not in conn['endpoints']:
        return (
            False,
            'The service catalog of this cloud holds no compute endpoint, so it '
            'runs no Nova this account may talk to.',
        )

    servers = []
    path = '/servers/detail'
    while path:
        success, result = lib.openstack.fetch_json(
            conn, 'compute', path, header=API_HEADER
        )
        if not success:
            return (False, f'Cannot list the instances: {result}.')
        servers += result.get('servers') or []
        # Nova caps a page at its `[api] max_limit`, which defaults to 1000 instances, and
        # says so by handing out a link to the next one. Only the marker of that link is
        # used, not its URL: a URL from a response would send the token of this account
        # wherever that response says.
        path = ''
        if any(link.get('rel') == 'next' for link in result.get('servers_links') or []):
            marker = (servers[-1].get('id') or '') if servers else ''
            if not marker:
                break
            path = f'/servers/detail?marker={urllib.parse.quote(marker, safe="")}'
    return (True, servers)


def get_filters(args):
    """Compile the `--match-*` / `--ignore-*` patterns of every filter field.

    Returns (True, dict) mapping the field to its (match, ignore) pattern
    lists, or (False, errormessage) for a pattern that does not compile.
    """
    filters = {}
    for field in FILTER_FIELDS:
        dest = field.replace('-', '_').upper()
        compiled = []
        for kind in ('match', 'ignore'):
            patterns = getattr(args, f'{kind.upper()}_{dest}')
            results = lib.txt.compile_regex(patterns, f'--{kind}-{field}')
            for success, result in results:
                if not success:
                    return (False, result)
            compiled.append([result for _, result in results])
        filters[field] = tuple(compiled)
    return (True, filters)


def is_filtered_out(server, filters):
    """Return True if a `--match-*` or `--ignore-*` pattern drops this instance.

    `--match-*` includes and is applied first, `--ignore-*` excludes and wins,
    the same precedence the name filters use.
    """
    for field, (match, ignore) in filters.items():
        value = server.get(FILTER_FIELDS[field][0]) or ''
        if match and not any(item.search(value) for item in match):
            return True
        if any(item.search(value) for item in ignore):
            return True
    return False


def get_addresses(server):
    """Return the IP addresses of a server as a comma-separated string.

    The Compute API nests them per network, and it reports no addresses at all
    while an instance is still building.
    """
    addresses = server.get('addresses') or {}
    ips = [
        address.get('addr')
        for network in addresses.values()
        for address in network
        if address.get('addr')
    ]
    return ', '.join(ips)


def get_power_mismatch(server, status):
    """Return the power state of an instance Nova calls ACTIVE without it running.

    Returns the wording for that power state, or an empty string when there is
    nothing to report.

    Nova compares the two itself in `_sync_instance_power_state()` and does not
    settle the disagreement in every case: `paused` and a domain the driver
    cannot find are logged and left alone, and the rest is handed to the stop
    API, which the status only follows once that call gets through. The status
    is therefore the last thing to move, and on two of the five power states it
    never moves at all. Measured against nova 33.1.0.dev367 with
    `vm_state=active` and no task: every power state other than `running`
    leaves the reported status at ACTIVE.

    Only ACTIVE without a running task is compared, which is the same guard
    Nova applies before it looks at the power state. Otherwise a reboot or a
    live migration, both of which legitimately stop or pause the domain for a
    while, would read as a mismatch.
    """
    if status != 'ACTIVE' or server.get(TASK_STATE_KEY):
        return ''
    power = server.get(POWER_STATE_KEY)
    # A cloud whose cell did not answer reports no power state at all. Nothing
    # is known then, which is not the same as something being wrong.
    if power is None or power == POWER_STATE_RUNNING:
        return ''
    return POWER_STATES.get(power, f'power state {power}')


def get_timestamp(server, key):
    """Return an API timestamp as `YYYY-MM-DD hh:mm:ss`, or an empty string.

    An instance whose cell did not answer is reported without these fields.
    """
    value = server.get(key) or ''
    return value.replace('T', ' ').replace('Z', '')


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 = []
    if args.SEVERITY is None:
        args.SEVERITY = []
    for field in FILTER_FIELDS:
        dest = field.replace('-', '_').upper()
        for kind in ('MATCH', 'IGNORE'):
            if getattr(args, f'{kind}_{dest}') is None:
                setattr(args, f'{kind}_{dest}', [])

    severity_map = lib.base.coe(get_severity_map(args.SEVERITY))

    # fetch data
    if args.TEST is None:
        env = lib.base.coe(lib.disk.read_env(args.RC_FILE))
        success, result = get_data(args, env)
        if not success:
            # The cloud did not answer in time or refused us. That is a
            # statement about the cloud, so warn instead of going unknown.
            lib.base.oao(result, STATE_WARN, always_ok=args.ALWAYS_OK)
        servers = result
    else:
        # do not call the endpoint, put in test data
        stdout, _, _ = lib.lftest.test(args.TEST)
        servers = json.loads(stdout).get('servers', [])

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    table_data = []
    status_count = dict.fromkeys(severity_map, 0)
    # Counted per power state rather than as one number, because `paused` and a
    # domain the hypervisor cannot find are different problems.
    power_count = {}
    last_update = ''
    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')
    ]
    filters = lib.base.coe(get_filters(args))

    # A filter on a field the Compute API withholds would drop every instance
    # without saying why, which reads like an empty project. Name the gap
    # instead, before anything is filtered.
    for field, (match, ignore) in sorted(filters.items()):
        key, label, hint = FILTER_FIELDS[field]
        if (match or ignore) and servers and not any(s.get(key) for s in servers):
            lib.base.cu(
                f'The Compute API did not report the {label} of any instance, so '
                f'`--match-{field}` and `--ignore-{field}` have nothing to filter '
                f'on.{hint}'
            )

    # analyze data
    for server in servers:
        name = server.get('name') or server.get('id', '')
        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
        if is_filtered_out(server, filters):
            continue

        status = server.get('status', 'UNKNOWN')
        # A status Nova gained after this plugin was written must not crash the
        # check. Report it, and say that it is unrated.
        if status not in status_count:
            status_count[status] = 0
            severity_map[status] = 'unknown'
        status_count[status] += 1

        server_state = lib.base.str2state(severity_map[status])

        # The status alone would call this instance healthy, so the power state
        # gets its own say and the reason travels in the status cell: a bare
        # `ACTIVE [WARNING]` would leave an administrator guessing.
        power = get_power_mismatch(server, status)
        if power:
            power_count[power] = power_count.get(power, 0) + 1
            server_state = lib.base.get_worst(
                server_state,
                lib.base.str2state(args.POWER_MISMATCH_SEVERITY),
            )
        state = lib.base.get_worst(state, server_state)

        updated = get_timestamp(server, 'updated')
        last_update = max(last_update, updated)
        item = {
            'addresses': get_addresses(server),
            'host': server.get(FILTER_FIELDS['host'][0]) or '',
            # Which compute host an instance sits on, obfuscated by Nova into a
            # hash over the project and the host. Unlike the compute host it
            # needs no administrative rights, so this is the only placement
            # signal an ordinary project account gets, and it is what says that
            # two instances share a host and would fall over together. Empty
            # while an instance is not scheduled yet.
            'host_id': (server.get('hostId') or '')[:SHORT_ID_LENGTH],
            'id': server.get('id', '')[:SHORT_ID_LENGTH],
            'name': name,
            'status': (
                f'{status}{f" ({power})" if power else ""}'
                f'{lib.base.state2str(server_state, prefix=" ")}'
            ),
            'task': server.get(TASK_STATE_KEY) or '',
            'zone': server.get(FILTER_FIELDS['zone'][0]) or '',
        }
        for key in ('created', 'updated'):
            stamp = get_timestamp(server, key)
            item[key] = stamp
            if stamp:
                ago = lib.human.seconds2human(
                    lib.time.timestrdiff(lib.time.now(as_type='iso'), stamp),
                )
                item[key] = f'{stamp} ({ago} ago)'
        if args.BRIEF and server_state == STATE_OK:
            continue
        table_data.append(item)

    # every instance was filtered out, so there is nothing to report on
    if compiled_match or compiled_ignore:
        if not sum(status_count.values()):
            lib.base.oao(
                f'{len(servers)} {lib.txt.pluralize("instance", len(servers))} in this '
                f'project, all filtered out by `--match` or `--ignore`.',
                lib.base.str2state(args.NO_MATCH_SEVERITY),
                always_ok=args.ALWAYS_OK,
            )

    # build the message
    checked = sum(status_count.values())
    msg = f'{checked} {lib.txt.pluralize("instance", checked)} checked'
    # Worst status first, so the problem is inside the first 80 characters an
    # alerting channel shows, and alphabetical within one state.
    reported = [
        f'{cnt} {status}'
        for status, cnt in sorted(
            status_count.items(),
            key=lambda item: (
                -lib.base.str2state(severity_map[item[0]]),
                item[0],
            ),
        )
        if cnt
    ]
    if reported:
        msg += f': {", ".join(reported)}'
    msg += '.'
    # Ahead of the timestamp, because the status counts above read as healthy
    # while this is what is actually wrong.
    if power_count:
        mismatched = sum(power_count.values())
        detail = ', '.join(
            f'{cnt} {power}' for power, cnt in sorted(power_count.items())
        )
        msg += (
            f' {mismatched} ACTIVE '
            f'{lib.txt.pluralize("instance", mismatched)} not running '
            f'on the hypervisor: {detail}.'
        )
    if last_update:
        ago = lib.human.seconds2human(
            lib.time.timestrdiff(lib.time.now(as_type='iso'), last_update),
        )
        msg += f' Last status change {last_update} UTC ({ago} ago).'

    perfdata += lib.base.get_perfdata('total', checked, uom=None, _min=0)
    # A problem counter, so it alerts from the first instance on.
    perfdata += lib.base.get_perfdata(
        'power_mismatch',
        sum(power_count.values()),
        uom=None,
        warn='0',
        _min=0,
        _max=checked,
    )
    for status, cnt in sorted(status_count.items()):
        perfdata += lib.base.get_perfdata(
            status,
            cnt,
            uom=None,
            _min=0,
            _max=checked,
        )

    # build table output
    if table_data:
        if args.LENGTHY:
            # `status` carries the state marker, so it stays the last column:
            # IcingaWeb replaces `[WARNING]` with an icon and breaks every
            # table where the state is not at the end of the row.
            keys = [
                'name',
                'id',
                'host',
                'host_id',
                'zone',
                'addresses',
                'created',
                'updated',
                'task',
                'status',
            ]
            headers = [
                'Name',
                'ID',
                'Host',
                'Host ID',
                'Zone',
                'Addresses',
                'Created (UTC)',
                'Updated (UTC)',
                'Task',
                'Status',
            ]
        else:
            keys = ['name', 'updated', 'status']
            headers = ['Name', 'Updated (UTC)', 'Status']
        msg += '\n\n' + lib.base.get_table(
            table_data,
            keys,
            header=headers,
            # By name: that is how an administrator looks a instance up, and it puts the
            # ones belonging together next to each other. What needs attention is named in
            # the summary line above anyway.
            sort_by_key='name',
            # A column nothing filled in is noise. The compute host is the
            # usual one: Nova withholds it from a project without admin rights.
            hide_empty=True,
        )

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