#!/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 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__ = '2026082504'

DESCRIPTION = """Lists the OpenStack Cinder block storage volumes of a project and reports the
status of every one of them. Alerts when a volume sits in a status that needs attention, for
example error or maintenance, or when the Block Storage API cannot be reached in time. The state
reported per volume status is configurable, so a cloud on which unattached volumes are a problem
can say so. Supports extended reporting via --lengthy."""

# The Block Storage API microversion to ask for. 3.0 is the baseline every Cinder release since
# Mitaka speaks, and the fields this check reads have been in the volume view since long before
# that. A later microversion adds fields (`consumes_quota` in 3.65, for instance) but changes
# none of these.
API_HEADER = {'OpenStack-API-Version': 'volume 3.0'}

# The state to report per volume status. Every status the Block Storage API can report is
# listed, so a status never falls through to a guessed default. Verified against
# cinder/objects/fields.py:VolumeStatus (22 values) on cinder cd3f3e6.
#
# The six `error*` states are the ones that stay until somebody acts on them, and `maintenance`
# is the volume the cloud has taken out of service, usually after a migration that did not
# finish. `awaiting-transfer` is an offer to another project that nobody accepted.
#
# `available` is a volume attached to nothing: it occupies storage and costs money without
# serving anything, which is worth reporting. A volume is also available for a moment after it
# was created and after it was detached, so `--grace-available` decides how long that is
# ordinary before it counts as forgotten.
#
# Everything else is either a healthy volume or a step on the way to one: unlike an instance, a
# volume passes through its transitional states in seconds to minutes, and alerting on them
# would fire on ordinary work rather than on a problem.
DEFAULT_SEVERITY = {
    'attaching': 'ok',
    'available': 'warn',
    'awaiting-transfer': 'warn',
    'backing-up': 'ok',
    'creating': 'ok',
    'deleting': 'ok',
    'detaching': 'ok',
    'downloading': 'ok',
    'error': 'crit',
    'error_backing-up': 'crit',
    'error_deleting': 'crit',
    'error_extending': 'crit',
    'error_managing': 'crit',
    'error_restoring': 'crit',
    'extending': 'ok',
    'in-use': 'ok',
    'maintenance': 'warn',
    'managing': 'ok',
    'reserved': 'ok',
    'restoring-backup': 'ok',
    'retyping': 'ok',
    'uploading': 'ok',
}

# The volume fields `--match-*` and `--ignore-*` can filter on, mapped to the key the Block
# Storage API puts them under, plus the wording used when the field is missing from the
# response. Both are handed to every user. The migration status and the host a volume lives on
# are admin-only and therefore not offered here: cinder/api/v3/views/volumes.py adds
# `migration_status` only `if ctxt.is_admin`.
FILTER_FIELDS = {
    'type': ('volume_type', 'volume type', ''),
    'zone': ('availability_zone', 'availability zone', ''),
}

DEFAULT_BRIEF = False
DEFAULT_CACHE_EXPIRE = 50  # minutes; a Keystone token commonly lives 60
DEFAULT_GRACE_AVAILABLE = '0D'
DEFAULT_INSECURE = False
DEFAULT_LENGTHY = False
DEFAULT_NO_MATCH_SEVERITY = 'ok'
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(
        '--grace-available',
        help='How long a volume is tolerated in the `available` status before it counts '
        'towards the state. A volume is available for a moment after it was created and '
        'after it was detached; one that has been available for weeks is a forgotten '
        'volume that keeps costing money. Measured from the last status change the API '
        'reports. '
        'A duration such as `12h`, `8D` or `2W`; `0D` disables the grace period. '
        'Only applies while `available` is rated as something other than ok. '
        'Default: %(default)s',
        dest='GRACE_AVAILABLE',
        type=lib.args.duration,
        default=DEFAULT_GRACE_AVAILABLE,
    )

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

    parser.add_argument(
        '--ignore-type',
        help=lib.args.help('--ignore-regex')
        + ' Matched against the volume type, for example `ssd`.',
        dest='IGNORE_TYPE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--ignore-zone',
        help=lib.args.help('--ignore-regex')
        + ' Matched against the availability zone of the volume.',
        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 volume name.',
        dest='MATCH',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match-type',
        help=lib.args.help('--match')
        + ' Matched against the volume type, for example `ssd`.',
        dest='MATCH_TYPE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match-zone',
        help=lib.args.help('--match')
        + ' Matched against the availability zone of the volume.',
        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(
        '--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 volumes in a given status, as `STATUS,STATE`. '
        'STATUS is a Cinder volume status such as `available`, 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=available,warn --severity=maintenance,crit`',
        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=available,warn`.',
            )
        status, state = override[0].strip().lower(), override[1].strip().lower()
        if status not in severity_map:
            known = ', '.join(sorted(severity_map))
            return (
                False,
                f'`--severity` names the unknown volume 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 volume 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,
        ['volumev3'],
        timeout=args.TIMEOUT,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        proxy=args.PROXY,
        cache_expire=args.CACHE_EXPIRE,
        cache_name='openstack-cinder-list',
    )
    if not success:
        return (False, conn)
    if 'volumev3' not in conn['endpoints']:
        return (
            False,
            'The service catalog of this cloud holds no volumev3 endpoint, so it '
            'runs no Cinder this account may talk to.',
        )

    volumes = []
    path = '/volumes/detail'
    while path:
        success, result = lib.openstack.fetch_json(
            conn, 'volumev3', path, header=API_HEADER
        )
        if not success:
            return (False, f'Cannot list the volumes: {result}.')
        volumes += result.get('volumes') or []
        # Cinder caps a page at its `osapi_max_limit`, which defaults to 1000 volumes, 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('volumes_links') or []):
            marker = (volumes[-1].get('id') or '') if volumes else ''
            if not marker:
                break
            path = f'/volumes/detail?marker={urllib.parse.quote(marker, safe="")}'
    return (True, volumes)


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(volume, filters):
    """Return True if a `--match-*` or `--ignore-*` pattern drops this volume.

    `--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 = volume.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_attached_to(volume):
    """Return what a volume is attached to, as `<server id>:<device>` per attachment.

    The Block Storage API lists an attachment only once it has reached the `attached` state,
    so a volume that is still attaching is reported as attached to nothing. A volume may carry
    several attachments where the cloud allows multiattach.
    """
    attachments = []
    for attachment in volume.get('attachments') or []:
        server = attachment.get('server_id') or ''
        device = attachment.get('device') or ''
        attachments.append(f'{server}:{device}' if device else server)
    return ', '.join(item for item in attachments if item)


def get_age(volume):
    """Return how long ago a volume last changed its status, in seconds.

    The Block Storage API dates every status change in `updated_at`, and leaves it empty for
    a volume that has not changed since it was created.
    """
    stamp = get_timestamp(volume, 'updated_at') or get_timestamp(volume, 'created_at')
    if not stamp:
        return None
    return lib.time.timestrdiff(lib.time.now(as_type='iso'), stamp)


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

    Cinder answers with microseconds and without a zone suffix, Nova the other way round, so
    both are trimmed to the second.
    """
    value = volume.get(key) or ''
    return value.replace('T', ' ').replace('Z', '').split('.')[0]


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)
        volumes = result
    else:
        # do not call the endpoint, put in test data
        volumes = lib.lftest.test_json(args.TEST).get('volumes', [])

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    table_data = []
    status_count = dict.fromkeys(severity_map, 0)
    total_size = 0
    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 Block Storage API withholds would drop every volume 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 volumes and not any(v.get(key) for v in volumes):
            lib.base.cu(
                f'The Block Storage API did not report the {label} of any volume, so '
                f'`--match-{field}` and `--ignore-{field}` have nothing to filter '
                f'on.{hint}'
            )

    # analyze data
    for volume in volumes:
        # A volume needs no name, and an unnamed one is only ever addressed by its id.
        name = volume.get('name') or volume.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(volume, filters):
            continue

        status = volume.get('status', 'unknown')
        # A status Cinder 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
        total_size += int(volume.get('size') or 0)

        volume_state = lib.base.str2state(severity_map[status])
        if status == 'available' and volume_state != STATE_OK:
            # A volume that was just created or just detached is available for a moment, and
            # only one that stays available is forgotten. An age the API does not date is
            # reported rather than excused.
            age = get_age(volume)
            if age is not None and age < args.GRACE_AVAILABLE:
                volume_state = STATE_OK
        state = lib.base.get_worst(state, volume_state)

        updated = get_timestamp(volume, 'updated_at')
        last_update = max(last_update, updated)
        item = {
            'attached-to': get_attached_to(volume),
            'bootable': volume.get('bootable', ''),
            'id': volume.get('id', ''),
            'name': name,
            'size': lib.human.bytes2human(int(volume.get('size') or 0) * 1024**3),
            'status': f'{status}{lib.base.state2str(volume_state, prefix=" ")}',
            'type': volume.get('volume_type') or '',
            'zone': volume.get('availability_zone') or '',
        }
        for key in ('created_at', 'updated_at'):
            stamp = get_timestamp(volume, 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 volume_state == STATE_OK:
            continue
        table_data.append(item)

    # every volume 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(volumes)} {lib.txt.pluralize("volume", len(volumes))} 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("volume", 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 += '.'
    if total_size:
        msg += f' {lib.human.bytes2human(total_size * 1024**3)} in total.'
    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)
    perfdata += lib.base.get_perfdata('size', total_size * 1024**3, uom='B', _min=0)
    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',
                'type',
                'zone',
                'size',
                'bootable',
                'attached-to',
                'created_at',
                'updated_at',
                'status',
            ]
            headers = [
                'Name',
                'ID',
                'Type',
                'Zone',
                'Size',
                'Bootable',
                'Attached to',
                'Created (UTC)',
                'Updated (UTC)',
                'Status',
            ]
        else:
            keys = ['name', 'type', 'size', 'bootable', 'updated_at', 'status']
            headers = [
                'Name',
                'Type',
                'Size',
                'Bootable',
                'Updated (UTC)',
                'Status',
            ]
        msg += '\n\n' + lib.base.get_table(
            table_data,
            keys,
            header=headers,
            # By name: that is how an administrator looks a volume 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. `Attached to` is the usual one: a project
            # whose volumes are all unattached fills none of it.
            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()
