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

DESCRIPTION = """Checks OpenStack Swift object storage account statistics, including total container
count, object count, and bytes used. Alerts when the free space left in a container with a quota
falls to or below the thresholds, or when the Swift API cannot be reached in time. Containers
without a quota are listed but cannot be alerted on. Supports extended reporting via --lengthy."""

# What an account answers with: its own numbers in the response headers and its containers in
# the body, so one request covers both.
ACCOUNT_PATH = '/?format=json'

DEFAULT_BRIEF = False
DEFAULT_CACHE_EXPIRE = 50  # minutes; a Keystone token commonly lives 60
DEFAULT_CRIT = 10  # GiB free space left
DEFAULT_CRIT_COUNT = 1000  # objects a container may still take
DEFAULT_INSECURE = False
DEFAULT_LENGTHY = False
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_RC_FILE = '/var/spool/icinga2/.openstack.cnf'
# One request per container makes this check slower than most, which is why
# its shipped check command allows 60 seconds. The default leaves the plugin
# room to give up and report on its own before the monitoring server kills it.
DEFAULT_TIMEOUT = 50
DEFAULT_WARN = 50  # GiB free space left
DEFAULT_WARN_COUNT = 10000  # objects a container may still take


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(
        '-c',
        '--critical',
        help='CRIT threshold for remaining free space, in GiB. '
        'Only applies to containers that have a quota set. '
        'Default: <= %(default)s',
        dest='CRIT',
        type=int,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-count',
        help='CRIT threshold for the remaining number of objects a container '
        'may still take. '
        'Only applies to containers that have an object count quota set. '
        'Default: <= %(default)s',
        dest='CRIT_COUNT',
        type=int,
        default=DEFAULT_CRIT_COUNT,
    )

    parser.add_argument(
        '--ignore',
        help=lib.args.help('--ignore-regex') + ' Matched against the container name.',
        dest='IGNORE',
        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 container name.',
        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(
        '--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(
        '--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,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for remaining free space, in GiB. '
        'Only applies to containers that have a quota set. '
        'Default: <= %(default)s',
        dest='WARN',
        type=int,
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-count',
        help='WARN threshold for the remaining number of objects a container '
        'may still take. '
        'Only applies to containers that have an object count quota set. '
        'Default: <= %(default)s',
        dest='WARN_COUNT',
        type=int,
        default=DEFAULT_WARN_COUNT,
    )

    args, _ = parser.parse_known_args()
    return args


def get_free_state(args, quota, used):
    """Rate the headroom left against the thresholds.

    Returns (state, free) for a quota that is set, or (STATE_OK, None) for one
    that is not: without a quota there is no headroom to run out of.
    """
    if not quota:
        return (STATE_OK, None)
    free = max(0, quota - used)
    state = lib.base.get_state(
        quota - used,
        args.WARN * 1024 * 1024 * 1024,
        args.CRIT * 1024 * 1024 * 1024,
        'le',
    )
    return (state, free)


def get_count_state(args, quota, used):
    """Rate the object headroom left against the count thresholds.

    Returns (state, free), or (STATE_OK, None) for a container without an
    object count quota.
    """
    if not quota:
        return (STATE_OK, None)
    free = max(0, quota - used)
    state = lib.base.get_state(quota - used, args.WARN_COUNT, args.CRIT_COUNT, 'le')
    return (state, free)


def is_wanted(name, match, ignore):
    """Return whether a container passes the filters.

    `--match` includes and is applied first, `--ignore` excludes and wins, so a container hit
    by `--ignore` is dropped even if it also matches `--match`.
    """
    if match and not any(item.search(name) for item in match):
        return False
    return not any(item.search(name) for item in ignore)


def get_data(args, env, match, ignore):
    """Fetch the account, its container listing and the headers of every container.

    Only the containers the filters keep are read: their headers cost one request each, and
    an account can hold hundreds of them. What is filtered out here would have been dropped
    in the analysis anyway.

    Returns (True, (account, containers, truncated, unreadable)) where `truncated` says
    whether the run ran out of time before every container was read and `unreadable` names
    the containers the store refused, or (False, errormessage).
    """
    success, conn = lib.openstack.connect(
        env,
        ['object-store'],
        timeout=args.TIMEOUT,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        proxy=args.PROXY,
        cache_expire=args.CACHE_EXPIRE,
        cache_name='openstack-swift-stat',
    )
    if not success:
        return (False, conn)
    if 'object-store' not in conn['endpoints']:
        return (
            False,
            'The service catalog of this cloud holds no object-store endpoint, so it '
            'runs no Swift this account may talk to.',
        )

    success, result = lib.openstack.fetch_json(
        conn, 'object-store', ACCOUNT_PATH, extended=True
    )
    if not success:
        return (False, f'Cannot read the account: {result}.')
    account = result['response_header']

    # A deadline of its own for the per-container phase, so what it cuts off is reported as
    # cut off instead of being passed off as a complete picture. The connection carries the
    # hard limit on top of it and stops the run once the budget is spent either way.
    deadline = lib.time.now() + args.TIMEOUT
    containers = {}
    truncated = False
    unreadable = []
    for entry in result['response_json'] or []:
        name = entry.get('name')
        if not name or not is_wanted(name, match, ignore):
            continue
        if lib.time.now() >= deadline:
            truncated = True
            break
        # A container name may carry anything a URL gives a meaning to, so it is encoded
        # rather than pasted into the path.
        success, container = lib.openstack.fetch(
            conn,
            'object-store',
            '/' + urllib.parse.quote(name, safe=''),
            method='HEAD',
        )
        if not success:
            unreadable.append(name)
            continue
        containers[name] = container['response_header']
    return (True, (account, containers, truncated, unreadable))


def get_test_data(args, match, ignore):
    """Read the account and the containers from fixtures, one file per request.

    The filters apply here as well, so a fixture for a container that is filtered out is
    never opened, exactly as the store is never asked for it.
    """
    document = lib.lftest.test_json(args.TEST, f'{args.TEST[0]}-account')
    containers = {}
    for entry in document.get('listing', []):
        if not is_wanted(entry['name'], match, ignore):
            continue
        container = lib.lftest.test_json(
            args.TEST, f'{args.TEST[0]}-container-{entry["name"]}'
        )
        containers[entry['name']] = container.get('headers', {})
    return (document.get('headers', {}), containers, False, [])


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
    # The filters decide which containers are read at all, so they are compiled before
    # anything is fetched rather than in the analysis below.
    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')
    ]
    if args.TEST is None:
        env = lib.base.coe(lib.disk.read_env(args.RC_FILE))
        success, result = get_data(args, env, compiled_match, compiled_ignore)
        if not success:
            # The store did not answer in time or refused us. That is a
            # statement about the store, so warn instead of going unknown.
            lib.base.oao(result, STATE_WARN, always_ok=args.ALWAYS_OK)
        account, containers, truncated, unreadable = result
    else:
        # do not call the endpoint, put in test data
        account, containers, truncated, unreadable = get_test_data(
            args, compiled_match, compiled_ignore
        )

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    table_data = []
    checked = 0

    # analyze data
    for name, container in containers.items():
        checked += 1
        item = {
            'name': name,
            'accept-ranges': container.get('accept-ranges', None),
            'content-length': container.get('content-length', None),
            'content-type': container.get('content-type', None),
            'date': container.get('date', None),
            'last-modified': container.get('last-modified', None),
            'strict-transport-security': container.get(
                'strict-transport-security', None
            ),
            'vary': container.get('vary', None),
            'x-container-bytes-used': int(container.get('x-container-bytes-used', 0)),
            'x-container-meta-quota-bytes': int(
                container.get('x-container-meta-quota-bytes', 0)
            ),
            'x-container-object-count': lib.human.number2human(
                int(container.get('x-container-object-count', 0))
            ),
            'x-openstack-request-id': container.get('x-openstack-request-id', None),
            'x-storage-policy': container.get('x-storage-policy', None),
            'x-timestamp': container.get('x-timestamp', None),
            'x-trans-id': container.get('x-trans-id', None),
            'used': lib.human.bytes2human(
                int(container.get('x-container-bytes-used', 0))
            ),
            # An empty cell says "no quota". A rendered 0.0B would read like a
            # quota of zero, and it would keep the column in the table even
            # when not a single container carries one.
            'quota': (
                lib.human.bytes2human(
                    int(container.get('x-container-meta-quota-bytes', 0))
                )
                if int(container.get('x-container-meta-quota-bytes', 0))
                else ''
            ),
        }
        # Swift enforces two quotas per container, bytes and object count, and a
        # container can sit against either one. See the container_quotas
        # middleware; both are ordinary metadata and reach the client.
        object_count = int(container.get('x-container-object-count', 0))
        count_quota = int(container.get('x-container-meta-quota-count', 0))
        byte_state, free_bytes = get_free_state(
            args,
            item['x-container-meta-quota-bytes'],
            item['x-container-bytes-used'],
        )
        count_state, free_items = get_count_state(args, count_quota, object_count)
        container_state = lib.base.get_worst(byte_state, count_state)
        state = lib.base.get_worst(state, container_state)

        item['items-quota'] = lib.human.number2human(count_quota) if count_quota else ''
        if count_quota:
            percent = round(float(object_count) / float(count_quota) * 100, 1)
            item['x-container-object-count'] += f' ({percent}%)'
        item['free-items'] = lib.human.number2human(free_items) if count_quota else ''
        if item['x-container-meta-quota-bytes']:
            percent = round(
                float(item['x-container-bytes-used'])
                / float(item['x-container-meta-quota-bytes'])
                * 100,
                1,
            )
            item['used'] += f' ({percent}%)'
            item['free'] = lib.human.bytes2human(free_bytes)
        else:
            item['free'] = ''
        # Only the last column may carry a state marker, because IcingaWeb
        # replaces it with an icon and breaks the table everywhere else. So the
        # marker states the verdict for the whole row, whichever quota caused
        # it, and the percentages in the columns before it say which one.
        item['verdict'] = (
            f'{lib.base.state2str(container_state, prefix="").strip()}'
            if container_state != STATE_OK
            else ''
        )

        perfdata += lib.base.get_perfdata(
            f'{name}_items',
            container.get('x-container-object-count', 0),
            uom=None,
            _min=0,
        )
        perfdata += lib.base.get_perfdata(
            f'{name}_used',
            container.get('x-container-bytes-used', 0),
            uom='B',
            _min=0,
        )

        # `--brief` is a display filter only: the row above already fed the
        # state and the perfdata.
        if args.BRIEF and container_state == STATE_OK:
            continue
        table_data.append(item)

    # every container was filtered out, so there is nothing to report on
    if (compiled_match or compiled_ignore) and not checked:
        total = int(account.get('x-account-container-count') or len(containers))
        lib.base.oao(
            f'{total} {lib.txt.pluralize("container", total)} in '
            f'this account, all filtered out by `--match` or `--ignore`.',
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
        )

    # build the message
    msg += 'Account: '
    headers = account
    if 'x-account-container-count' in headers and int(
        headers['x-account-container-count']
    ):
        cnt = headers['x-account-container-count']
        msg += f'{cnt} {lib.txt.pluralize("container", int(cnt))}, '
    if 'x-account-object-count' in headers and int(headers['x-account-object-count']):
        obj_cnt = headers['x-account-object-count']
        msg += (
            f'{lib.human.number2human(obj_cnt)}'
            f' {lib.txt.pluralize("object", int(obj_cnt))}, '
        )
    # The account carries a quota of its own, and it can run out while every
    # single container is still well inside its own. Swift enforces it in the
    # account_quotas middleware, so it is a real limit and not just a number to
    # print. Only the `meta` form reaches a client: a reseller may set the same
    # quota as `sysmeta`, which takes precedence server-side but is stripped
    # from every response by the gatekeeper middleware, and an account object
    # count quota is `sysmeta` only. Those cannot be reported on from here.
    account_quota = int(headers.get('x-account-meta-quota-bytes') or 0)
    account_used = int(headers.get('x-account-bytes-used') or 0)
    account_state, account_free = get_free_state(args, account_quota, account_used)
    if account_quota:
        # One statement about one thing: what is used, of how much, and what that leaves.
        state = lib.base.get_worst(state, account_state)
        percent = round(float(account_used) / float(account_quota) * 100, 1)
        msg += (
            f'{lib.human.bytes2human(account_used)} of '
            f'{lib.human.bytes2human(account_quota)} used ({percent}%), '
            f'{lib.human.bytes2human(account_free)} free'
            f'{lib.base.state2str(account_state, prefix=" ")}, '
        )
    elif account_used:
        # Without a quota there is nothing to relate it to, so it stands on its own.
        msg += f'{lib.human.bytes2human(account_used)} used, '
    if msg.endswith(', '):
        msg = msg[:-2]

    if unreadable:
        # A container the store refused is a container nobody is watching. Say so instead of
        # leaving a gap in the table.
        state = lib.base.get_worst(state, STATE_WARN)
        msg += (
            f'. {len(unreadable)} '
            f'{lib.txt.pluralize("container", len(unreadable))} could not be read'
            f'{lib.base.state2str(STATE_WARN, prefix=" ")}'
        )
    if truncated:
        # Say it, instead of presenting a partial listing as the whole picture.
        state = lib.base.get_worst(state, STATE_WARN)
        msg += (
            f'. Only {checked} of them read within {args.TIMEOUT}s'
            f'{lib.base.state2str(STATE_WARN, prefix=" ")},'
            f' raise `--timeout` or narrow the check down with `--match`'
        )

    # build table output
    if table_data:
        if args.LENGTHY:
            # `free` 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',
                'x-storage-policy',
                'last-modified',
                'x-container-object-count',
                'items-quota',
                'free-items',
                'quota',
                'used',
                'free',
                'verdict',
            ]
            header = [
                'Container',
                'Policy',
                'Last Modified',
                'Items',
                'Items Quota',
                'Free Items',
                'Quota',
                'Used',
                'Free',
                'State',
            ]
        else:
            keys = [
                'name',
                'x-container-object-count',
                'items-quota',
                'free-items',
                'quota',
                'used',
                'free',
                'verdict',
            ]
            header = [
                'Container',
                'Items',
                'Items Quota',
                'Free Items',
                'Quota',
                'Used',
                'Free',
                'State',
            ]
        # A column nothing filled in is noise. `Free` is the usual one: it
        # stays empty for every container that carries no quota.
        msg += '\n\n' + lib.base.get_table(
            table_data, keys, header=header, hide_empty=True
        )
    elif not checked and not truncated:
        # Only when there really was nothing to look at. `--brief` hiding every
        # row means the containers were checked and are fine, so the summary
        # above already says everything there is to say.
        msg += '. Nothing checked.'

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