#!/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 json
import re
import sys

import lib.args
import lib.base
import lib.huawei_dorado
import lib.human
import lib.lftest
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Checks the health status of all disks on a Huawei OceanStor Dorado storage system
via the REST API (/disk endpoint). Alerts when any disk reports a non-normal health
state or runs out of remaining service life, and optionally when its health score drops,
when it has worn through most of its service life, or when it is running hot.
Supports extended reporting via --lengthy."""

DEFAULT_BRIEF = False
DEFAULT_CACHE_EXPIRE = 15  # minutes; default session timeout period is 20 minutes
DEFAULT_CRIT = '30:'
DEFAULT_CRIT_HEALTH_MARK = ''
DEFAULT_CRIT_TEMPERATURE = ''
DEFAULT_CRIT_WEAR = ''
DEFAULT_DEVICE_ID = ''  # the appliance reports its own at login
DEFAULT_INSECURE = True
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_NO_PROXY = False
DEFAULT_SCOPE = '0'
DEFAULT_TIMEOUT = 3
DEFAULT_UNUSED_DISK_SEVERITY = 'ok'
DEFAULT_WARN = '180:'
DEFAULT_WARN_HEALTH_MARK = ''
DEFAULT_WARN_TEMPERATURE = ''
DEFAULT_WARN_WEAR = ''

# Highest health score a disk reports. A disk whose media does not report one answers
# with 255, which is a "not applicable" marker rather than a score.
HEALTH_MARK_MAX = 100

# Highest `HEALTHSTATUS` code the REST Interface References document (offline). It
# bounds the metric so a graph scales to the whole enumeration; the thresholds stay
# out of the performance data, because whether a code is a fault depends on the
# object and is decided in `lib.huawei_dorado.get_health_status_state()`.
HEALTH_STATUS_MAX = 18

# `LOGICTYPE` of a disk that is in the chassis but not in a pool. The rest of the
# enumeration is 2 member disk, 3 hot spare disk and 4 cache disk, all of which are
# disks doing a job.
FREE_DISK = 1

# Fields `--match` is applied to.
MATCH_FIELDS = ('UUID', 'LOCATION')

# RUNNINGSTATUS codes a healthy disk reports: normal (1) and online (27).
OK_RUNNING_STATUS = (1, 27)


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')
        + ' Worth setting on an array with many disks. '
        'Default: %(default)s',
        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 the remaining life of a disk, as a Nagios range in days. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-temperature',
        help=lib.args.help('--critical-temperature')
        + ' Off by default, because a healthy operating temperature depends on the drive '
        'model and on where the array stands. Example: `--critical-temperature=50`',
        dest='CRIT_TEMPERATURE',
        default=DEFAULT_CRIT_TEMPERATURE,
    )

    parser.add_argument(
        '--critical-health-mark',
        help='CRIT threshold for the health score of a disk, as a Nagios range. '
        'The appliance scores a disk from 0 to 100, where 100 is a disk with nothing '
        'wrong with it. Flash media report 255 instead, which is a "not applicable" '
        'marker and is never compared. '
        'Off by default, so an update cannot start alerting on a fleet nobody has '
        'looked at yet. 65: is what field practice suggests. '
        'Example: `--critical-health-mark=65:`',
        dest='CRIT_HEALTH_MARK',
        default=DEFAULT_CRIT_HEALTH_MARK,
    )

    parser.add_argument(
        '--critical-wear',
        help='CRIT threshold for the wear of a disk, as a Nagios range in percent of its '
        'service life used up. Spinning media report -1 instead and are never compared. '
        'Off by default. '
        'Example: `--critical-wear=90`',
        dest='CRIT_WEAR',
        default=DEFAULT_CRIT_WEAR,
    )

    parser.add_argument(
        '--device-id',
        help='Huawei OceanStor Dorado API device ID. '
        'Optional: the appliance reports its own at login, so this is only '
        'needed to override that answer.',
        dest='DEVICE_ID',
        default=DEFAULT_DEVICE_ID,
    )

    parser.add_argument(
        '--ignore',
        help='Skip disks. '
        + lib.args.help('--ignore-regex')
        + ' The regex is anchored at the start of the string (Python `re.match`) and is '
        'matched against `UUID`, `LOCATION`, so prefix with `.*` to match anywhere.',
        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=False,
    )

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

    parser.add_argument(
        '--match',
        help='Limit to disks. '
        + lib.args.help('--match')
        + ' The regex is anchored at the start of the string (Python `re.match`) and is '
        'matched against `UUID`, `LOCATION`, so prefix with `.*` to match anywhere.',
        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=DEFAULT_NO_PROXY,
    )

    parser.add_argument(
        '--password',
        help='Huawei OceanStor Dorado 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 Dorado API scope.',
        dest='SCOPE',
        default=DEFAULT_SCOPE,
    )

    parser.add_argument(
        '--unused-disk-severity',
        help='State to report for a disk that sits in the chassis without belonging to a '
        'pool. Worth raising on an array where every disk is meant to be in use, so a '
        'disk that silently dropped out of its pool is noticed. '
        'Default: %(default)s',
        dest='UNUSED_DISK_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_UNUSED_DISK_SEVERITY,
    )

    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 Dorado API URL.',
        dest='URL',
        required=True,
    )

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

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the remaining life of a disk, as a Nagios range in days. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-health-mark',
        help='WARN threshold for the health score of a disk, as a Nagios range. '
        'The appliance scores a disk from 0 to 100, where 100 is a disk with nothing '
        'wrong with it. Flash media report 255 instead, which is a "not applicable" '
        'marker and is never compared. '
        'Off by default, so an update cannot start alerting on a fleet nobody has '
        'looked at yet. 75: is what field practice suggests. '
        'Example: `--warning-health-mark=75:`',
        dest='WARN_HEALTH_MARK',
        default=DEFAULT_WARN_HEALTH_MARK,
    )

    parser.add_argument(
        '--warning-temperature',
        help=lib.args.help('--warning-temperature')
        + ' Off by default, because a healthy operating temperature depends on the drive '
        'model and on where the array stands. Example: `--warning-temperature=45`',
        dest='WARN_TEMPERATURE',
        default=DEFAULT_WARN_TEMPERATURE,
    )

    parser.add_argument(
        '--warning-wear',
        help='WARN threshold for the wear of a disk, as a Nagios range in percent of its '
        'service life used up. Spinning media report -1 instead and are never compared. '
        'Off by default. '
        'Example: `--warning-wear=80`',
        dest='WARN_WEAR',
        default=DEFAULT_WARN_WEAR,
    )

    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_remaining_life_days(disk):
    """Return a disk's remaining life in days, or `None` if it does not report one."""
    # The appliance reports the remaining life only for media that wear out. A spinning
    # disk has nothing to report and answers with zero, which must not be read as "this
    # disk is at its end" - that would alert on every HDD forever.
    try:
        days = int(disk.get('REMAINLIFE'))
    except (TypeError, ValueError):
        return None
    if days <= 0:
        return None
    return days


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.')

    # 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 not args.URL.startswith('http'):
        lib.base.cu('--url parameter has to start with "http://" or https://".')

    # fetch data
    if args.TEST is None:
        result = lib.huawei_dorado.get_data('disk', args)
    else:
        # do not call the command, put in test data
        stdout, _stderr, _retc = lib.lftest.test(args.TEST)
        result = json.loads(stdout)

    # no valuable result?
    lib.huawei_dorado.assert_ok(result, 'the disks')

    # An appliance always has disks, so an empty list is a query that
    # never reached them rather than an inventory that is genuinely empty.
    # Reporting OK here would hide the fault behind a green check.
    if not result.get('data'):
        lib.base.oao(
            f'{args.URL} reported no disks.'
            ' Verify that the API user is allowed to query them.',
            STATE_UNKNOWN,
        )

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    compiled_match_regex = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.MATCH, '--match')
    ]
    compiled_ignore_regex = [
        lib.base.coe(item)
        for item in lib.txt.compile_regex(args.IGNORE, '--ignore')
    ]
    unused_disk_state = lib.base.str2state(args.UNUSED_DISK_SEVERITY)

    # analyze data
    table_data = []
    for disk in result.get('data') or []:
        disk['UUID'] = lib.huawei_dorado.get_uuid(disk)
        # Perfdata labels carry the object's own TYPE:ID, which stays the same
        # when hardware is moved between slots, unlike its location.
        label = re.sub(r'\W+', '_', disk['UUID'])

        if args.MATCH and not any(
            lib.base.coe(lib.txt.match_regex(pattern, str(disk.get(field, ''))))
            for pattern in compiled_match_regex
            for field in MATCH_FIELDS
        ):
            continue

        if args.IGNORE and any(
            lib.base.coe(lib.txt.match_regex(pattern, str(disk.get(field, ''))))
            for pattern in compiled_ignore_regex
            for field in MATCH_FIELDS
        ):
            continue

        health_state = lib.huawei_dorado.get_health_status_state(
            disk.get('HEALTHSTATUS')
        )
        state = lib.base.get_worst(state, health_state)

        running_state = lib.huawei_dorado.get_running_status_state(
            disk.get('RUNNINGSTATUS'), OK_RUNNING_STATUS
        )
        state = lib.base.get_worst(state, running_state)

        remaining_life_days = get_remaining_life_days(disk)
        life_state = STATE_OK
        if remaining_life_days is not None:
            life_state = lib.base.get_state(
                remaining_life_days, args.WARN, args.CRIT, _operator='range'
            )
            state = lib.base.get_worst(state, life_state)

        # Temperature thresholds are off unless the operator sets them: what counts as hot
        # depends on the drive model and on where the array stands, so there is no default
        # that would be right on every appliance. A drive without a temperature sensor
        # reports a placeholder rather than a reading, which is neither compared against a
        # threshold nor graphed.
        temperature = lib.huawei_dorado.as_temperature(disk.get('TEMPERATURE'))
        temperature_state = STATE_OK
        if temperature and (args.WARN_TEMPERATURE or args.CRIT_TEMPERATURE):
            temperature_state = lib.base.get_state(
                temperature,
                args.WARN_TEMPERATURE or None,
                args.CRIT_TEMPERATURE or None,
                _operator='range',
            )
            state = lib.base.get_worst(state, temperature_state)

        # A spinning disk does not wear out the way flash does and reports -1, which
        # is a "not applicable" marker rather than a negative wear level.
        abrasion_rate = lib.huawei_dorado.as_code(disk.get('ABRASIONRATE'))
        if abrasion_rate is not None and abrasion_rate < 0:
            abrasion_rate = None
        wear_state = STATE_OK
        if abrasion_rate is not None and (args.WARN_WEAR or args.CRIT_WEAR):
            wear_state = lib.base.get_state(
                abrasion_rate,
                args.WARN_WEAR or None,
                args.CRIT_WEAR or None,
                _operator='range',
            )
            state = lib.base.get_worst(state, wear_state)

        # The health score runs from 0 to 100. A disk whose media does not report one
        # answers with 255, which the vendor's own response example shows and which
        # would otherwise be graphed as a perfect score two and a half times over.
        health_mark = lib.huawei_dorado.as_code(disk.get('HEALTHMARK'))
        if health_mark is not None and not 0 <= health_mark <= HEALTH_MARK_MAX:
            health_mark = None
        health_mark_state = STATE_OK
        if health_mark is not None and (
            args.WARN_HEALTH_MARK or args.CRIT_HEALTH_MARK
        ):
            health_mark_state = lib.base.get_state(
                health_mark,
                args.WARN_HEALTH_MARK or None,
                args.CRIT_HEALTH_MARK or None,
                _operator='range',
            )
            state = lib.base.get_worst(state, health_mark_state)

        # A disk that belongs to no pool is not serving anything. On an array where every
        # disk is meant to be in use that is a disk which dropped out of its pool, so how
        # loud it should be is the operator's call.
        unused_state = STATE_OK
        if lib.huawei_dorado.as_code(disk.get('LOGICTYPE')) == FREE_DISK:
            unused_state = unused_disk_state
            state = lib.base.get_worst(state, unused_state)

        runtime_days = lib.huawei_dorado.as_code(disk.get('RUNTIME'))
        disk['RUNTIME'] = None if runtime_days is None else runtime_days * 24 * 60 * 60

        perfdata += lib.base.get_perfdata(
            f'{label}_health_status',
            disk.get('HEALTHSTATUS'),
            uom=None,
            _min=0,
            _max=HEALTH_STATUS_MAX,
        )
        perfdata += lib.base.get_perfdata(
            f'{label}_running_status',
            disk.get('RUNNINGSTATUS'),
            _min=0,
        )
        perfdata += lib.base.get_perfdata(
            f'{label}_abrasion_rate',
            abrasion_rate,
            uom='%',
            warn=args.WARN_WEAR or None,
            crit=args.CRIT_WEAR or None,
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            f'{label}_capacity_usage',
            disk.get('CAPACITYUSAGE'),
            uom='%',
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            f'{label}_health_mark',
            health_mark,
            uom=None,
            warn=args.WARN_HEALTH_MARK or None,
            crit=args.CRIT_HEALTH_MARK or None,
            _min=0,
            _max=HEALTH_MARK_MAX,
        )
        perfdata += lib.base.get_perfdata(
            f'{label}_progress',
            disk.get('PROGRESS'),
            uom='%',
            _min=0,
            _max=100,
        )
        if remaining_life_days is not None:
            perfdata += lib.base.get_perfdata(
                f'{label}_remaining_life',
                remaining_life_days * 24 * 60 * 60,
                uom='s',
                # No thresholds in the performance data: they are ranges in days and the
                # value is in seconds. huawei-pacific-disk reports it the same way.
                _min=0,
            )
        # No performance data for RUNTIME. It only ever counts up, and a cumulative
        # counter aggregates wrong in every Grafana panel that touches it
        # ([#320](https://github.com/Linuxfabrik/monitoring-plugins/issues/320)).
        # The value stays in the table, where it reads as the age of the disk.
        perfdata += lib.base.get_perfdata(
            f'{label}_temperature',
            temperature,
            uom=None,
            warn=args.WARN_TEMPERATURE or None,
            crit=args.CRIT_TEMPERATURE or None,
            _min=0,
        )

        disk['row_state'] = lib.base.get_worst(
            lib.base.get_worst(health_state, running_state),
            lib.base.get_worst(
                lib.base.get_worst(life_state, temperature_state),
                lib.base.get_worst(
                    lib.base.get_worst(wear_state, health_mark_state), unused_state
                ),
            ),
        )
        disk['health'] = lib.huawei_dorado.get_health_status(
            disk.get('HEALTHSTATUS')
        )
        disk['running'] = lib.huawei_dorado.get_running_status(
            disk.get('RUNNINGSTATUS')
        )
        # One state per row, in the last column. IcingaWeb replaces a state with an
        # icon, which shifts everything to the right of it, so a second one mid-row
        # would break the table. What decided it stays readable in the two columns
        # in front of it.
        disk['state'] = lib.base.state2str(
            lib.base.get_worst(health_state, running_state), empty_ok=False
        )
        # The performance data is written above, so what is left here is the display
        # value. A drive without a temperature sensor prints the appliance's own
        # placeholder rather than the raw one it happens to use.
        disk['TEMPERATURE'] = '--' if temperature is None else temperature
        disk['ABRASIONRATE'] = (
            '--'
            if abrasion_rate is None
            else f'{abrasion_rate}{lib.base.state2str(wear_state, prefix=" ")}'
        )
        disk['health_mark'] = (
            '--'
            if health_mark is None
            else f'{health_mark}'
            f'{lib.base.state2str(health_mark_state, prefix=" ")}'
        )
        disk['LOGICTYPE'] = (
            f'free{lib.base.state2str(unused_state, prefix=" ")}'
            if lib.huawei_dorado.as_code(disk.get('LOGICTYPE')) == FREE_DISK
            else 'in use'
        )
        disk['RUNTIME'] = (
            '--'
            if disk['RUNTIME'] is None
            else lib.human.seconds2human(disk['RUNTIME'])
        )

        table_data.append(disk)

    # The appliance listed disks and the filter selected none of them.
    if not table_data:
        lib.base.oao(
            f'No disks matched `{", ".join(args.MATCH)}`.',
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
            no_perfdata=args.NO_PERFDATA,
        )

    # build the message
    if state == STATE_CRIT:
        msg += 'There are critical errors.'
    elif state == STATE_WARN:
        msg += 'There are warnings.'
    else:
        msg += 'Everything is ok.'
    msg += '\n\n'

    # build table output
    display_rows = table_data
    if args.BRIEF:
        display_rows = [row for row in table_data if row['row_state'] != STATE_OK]
    if display_rows:
        if args.LENGTHY:
            keys = [
                'UUID',
                'LOCATION',
                'MANUFACTURER',
                'MODEL',
                'SERIALNUMBER',
                'LOGICTYPE',
                'ABRASIONRATE',
                'health_mark',
                'PROGRESS',
                'RUNTIME',
                'TEMPERATURE',
                'health',
                'running',
                'state',
            ]
            headers = [
                'UUID',
                'Location',
                'Manufacturer',
                'Model',
                'SerialNumber',
                'Usage',
                'Wear%',
                'Health#',
                'Progress%',
                'Runtime',
                'Temp',
                'Health',
                'Running',
                'State',
            ]
        else:
            keys = [
                'UUID',
                'LOCATION',
                'LOGICTYPE',
                'ABRASIONRATE',
                'health_mark',
                'TEMPERATURE',
                'health',
                'running',
                'state',
            ]
            headers = [
                'UUID',
                'Location',
                'Usage',
                'Wear%',
                'Health#',
                'Temp',
                'Health',
                'Running',
                'State',
            ]

        msg += lib.base.get_table(
            display_rows, keys, header=headers, missing='--', hide_empty=True
        )

    if args.VERBOSE:
        msg += '\n\n' + lib.huawei_dorado.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()
