#!/usr/lib64/linuxfabrik-monitoring-plugins/venv/bin/python
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import sys

import lib.args
import lib.base
import lib.huawei_pacific
import lib.human
import lib.lftest
from lib.globals import STATE_OK, STATE_UNKNOWN

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

DESCRIPTION = """Reports the product model, system version and cluster name of a Huawei OceanStor
Pacific storage system via the REST API (/cluster/product, /system_capacity and
/cluster/servers/count endpoints).
Alerts when the used cluster capacity in percent reaches the warning or critical threshold."""

DEFAULT_CACHE_EXPIRE = 15  # minutes
# A cluster of this class is measured in petabytes, where the usual 80/90 would alert
# with a petabyte still free. At 92 and 95 percent of five usable petabytes there are
# still around 400 and 250 TiB left, which is enough runway to order and rack more
# hardware and little enough to be worth acting on.
DEFAULT_CRIT = '95'
DEFAULT_INSECURE = True
DEFAULT_NO_PROXY = False
DEFAULT_SCOPE = '0'
DEFAULT_TIMEOUT = 3
DEFAULT_WARN = '92'

# The capacity fields the appliance really fills in, counted in mebibytes. Verified on two
# clusters: `sata_total_capacity_converged` times one MiB matches, to the rounding of the
# MiB value, the `tier_perf_cap` that a dtree of the same cluster reports in bytes.
# The fields without a service suffix are deliberately not summed: both clusters leave
# them at zero, and the firmware that does fill them counts in a unit that does not match
# the cluster totals next to them.
CAPACITY_MEDIA = ('sas', 'sata', 'ssd')
CAPACITY_SERVICES = ('converged', 'file', 'hdfs', 'object')
MIB = 1024 * 1024


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(
        '--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=lib.args.help('--critical')
        + ' Supports Nagios ranges. Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

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

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

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

    parser.add_argument(
        '--password',
        help='Huawei OceanStor Pacific API password.',
        dest='PASSWORD',
    )

    parser.add_argument(
        '--password-file',
        help=lib.args.help('--password-file'),
        dest='PASSWORD_FILE',
    )

    parser.add_argument(
        '--scope',
        help='Huawei OceanStor Pacific API scope.',
        dest='SCOPE',
        default=DEFAULT_SCOPE,
    )

    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(
        '-u',
        '--url',
        help='Huawei OceanStor Pacific API URL.',
        dest='URL',
        required=True,
    )

    parser.add_argument(
        '--username',
        help='Huawei OceanStor Pacific API username.',
        dest='USERNAME',
        required=True,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help=lib.args.help('--warning')
        + ' Supports Nagios ranges. Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '-v',
        '--verbose',
        help=lib.args.help('--verbose')
        + ' Appends what every API request returned, so the appliance\'s own answers '
        'can be read while working out how it reports something. Session tokens are '
        'redacted. The output is as long as those answers are, so this is a debugging '
        'aid rather than something to leave switched on.',
        dest='VERBOSE',
        action='store_true',
        default=False,
    )

    args, _ = parser.parse_known_args()
    return args


def get_product_string(data):
    """Build the model and version part of the summary line."""
    model = data.get('product_model') or 'Unknown'
    # The OEM name is what the appliance is branded as, and it can differ from the
    # model Huawei builds. Report both when they differ, so the model an engineer
    # opens a support case with and the name on the front bezel both appear.
    oem = data.get('oem_product_model')
    if oem and oem != model:
        model = f'{model} ({oem})'
    version = data.get('version') or 'Unknown'
    # 'patch_version' is not returned on non-patch versions.
    patch = data.get('patch_version')
    if patch:
        version = f'{version} {patch}'
    # The full version carries the hotpatch level, which is the string a support case is
    # opened with. It repeats the base version, so it only earns its place next to it.
    full = data.get('full_version')
    if full and full not in (data.get('version'), version):
        version = f'{version} ({full})'
    return f'{model} {version}'


def get_usable_capacity(data, kind):
    """
    Sum the per-service capacity fields of every storage medium, in bytes.

    ### Parameters
    - **data** (`dict`): The `system_capacity` response as the API returned it.
    - **kind** (`str`): Either `'used'` or `'total'`.

    ### Returns
    - **float**: The capacity in bytes, `0` where the appliance fills none of the fields.

    ### Example
    >>> get_usable_capacity({'sata_used_capacity_converged': 1}, 'used')
    1048576.0
    """
    total = 0.0
    for medium in CAPACITY_MEDIA:
        for service in CAPACITY_SERVICES:
            value = get_capacity(data, f'{medium}_{kind}_capacity_{service}')
            if value and value > 0:
                total += value
    return total * MIB


def get_capacity(data, field):
    """
    Return one raw cluster capacity, or `None` where the appliance reports none.

    ### Parameters
    - **data** (`dict`): The `system_capacity` response as the API returned it.
    - **field** (`str`): The field to read.

    ### Returns
    - **float** or **None**: The capacity, or `None` for a missing field and for the
      placeholders a firmware sends in place of a number.

    ### Notes
    - A cluster that has not finished reporting its capacity answers with `null` or with
      a dash rather than with a number. Converting either used to end the check in a
      message-less UNKNOWN instead of in a reading it simply does not have.

    ### Example
    >>> get_capacity({'used_raw_capacity': '1048576'}, 'used_raw_capacity')
    1048576.0

    >>> get_capacity({'used_raw_capacity': '--'}, 'used_raw_capacity') is None
    True
    """
    try:
        return float(data.get(field))
    except (TypeError, ValueError):
        return None


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)

    if args.PASSWORD_FILE:
        args.PASSWORD = lib.args.load_secret(args.PASSWORD_FILE)
    if not args.PASSWORD:
        lib.base.cu('Provide the API password via --password or --password-file.')

    if not args.URL.startswith('http'):
        lib.base.cu('--url parameter has to start with "http://" or https://".')

    # fetch data
    if args.TEST is None:
        product = lib.huawei_pacific.get_data('cluster/product', args)
        capacity = lib.huawei_pacific.get_data('system_capacity', args)
        count = lib.huawei_pacific.get_data('cluster/servers/count', args)
    else:
        # do not call the API, put in test data. Each API call has its own fixture
        # suffix, so the fixture file names describe what they contain.
        test_base = args.TEST[0]
        product = lib.lftest.test_json(args.TEST, f'{test_base}-product')
        capacity = lib.lftest.test_json(args.TEST, f'{test_base}-capacity')
        count = lib.lftest.test_json(args.TEST, f'{test_base}-count')

    # no valuable result?
    lib.huawei_pacific.assert_ok(product, 'the product information')
    lib.huawei_pacific.assert_ok(capacity, 'the cluster capacity')
    # The node count is decoration next to the model and the fill level, so a firmware
    # that does not serve the endpoint must not take the whole check down with it.

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    # A firmware that has nothing to say sends a JSON null rather than an empty object.
    capacity_data = capacity.get('data') or {}
    product_data = product.get('data') or {}

    # analyze data
    # `used_raw_capacity` and `totalClusterCapacity` are the pair the vendor documents
    # for the cluster as a whole: used raw disk capacity against the total capacity of
    # all disk types. The per-media fields next to them are not in the same unit, so
    # summing those and dividing by this total would compare two different scales.
    # What the appliance itself accounts for, which is the capacity behind the erasure
    # coding rather than the raw disks. `used_raw_capacity` reads zero on a healthy
    # cluster of this firmware generation, which left the check reporting 0% for ever
    # and put its thresholds out of reach.
    raw_total = get_capacity(capacity_data, 'totalClusterCapacity')
    total = get_usable_capacity(capacity_data, 'total')
    used = get_usable_capacity(capacity_data, 'used')
    usable = total > 0
    if not usable:
        # A firmware that fills none of them: fall back to the documented raw pair.
        total = raw_total
        used = get_capacity(capacity_data, 'used_raw_capacity')
    # A cluster that has not reported its capacity yet still has a model and a
    # version worth printing, so this is not a reason to abort the check.
    used_percent = None
    if total and total > 0 and used is not None and used >= 0:
        used_percent = round(used / total * 100)
        state = lib.base.get_worst(
            state,
            lib.base.get_state(
                used_percent, args.WARN, args.CRIT, _operator='range'
            ),
        )

    # build the message
    node_count = lib.huawei_pacific.as_code((count.get('data') or {}).get('count'))
    nodes = '' if node_count is None else f', {node_count} nodes'
    msg += (
        f'{get_product_string(product_data)},'
        f' Cluster Name: {product_data.get("cluster_name")}{nodes}\n'
    )
    if used_percent is None:
        msg += 'Capacity: no capacity data available yet\n'
    else:
        # The raw figure is named alongside so the difference between what the disks
        # hold and what the cluster can store on them is visible at a glance.
        scale = ''
        if usable:
            scale = ' usable'
            if raw_total and raw_total > 0:
                scale += f', {lib.human.bytes2human(raw_total)} raw'
        msg += (
            f'Capacity: {used_percent}% used'
            f' ({lib.human.bytes2human(used)}/{lib.human.bytes2human(total)}{scale})'
            f'{lib.base.state2str(state, prefix=" ")}\n'
        )
        perfdata += lib.base.get_perfdata(
            'usage_percent',
            used_percent,
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            'used_capacity', int(used), uom='B', _min=0, _max=int(total)
        )
        perfdata += lib.base.get_perfdata('total_capacity', int(total), uom='B', _min=0)

    if args.VERBOSE:
        msg += '\n\n' + lib.huawei_pacific.format_responses()

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