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

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'
# A single GET of /disk on a Dorado 6000 V6 with 384 disks took 30 to 36 seconds end
# to end (measured twice), so 30 seconds left no margin. An array that does not answer
# within 60 seconds ends UNKNOWN.
DEFAULT_TIMEOUT = 60
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

# `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.',
        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-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-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-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(
        '--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-insecure',
        help=lib.args.help('--no-insecure'),
        dest='INSECURE',
        action='store_false',
        default=DEFAULT_INSECURE,
    )

    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(
        '--proxy',
        help=lib.args.help('--proxy'),
        dest='PROXY',
        default=None,
    )

    parser.add_argument(
        '--scope',
        help='Huawei OceanStor Dorado 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(
        '--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(
        '-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(
        '-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,
    )

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

    args, _ = parser.parse_known_args()
    return args


def days2seconds_range(spec):
    """Return a Nagios range in days as the same range in seconds.

    Every number in a range is a bound, so scaling each of them scales the range, and
    `@`, `~` and `:` stay as they are. An unset range stays unset.
    """
    if not spec:
        return None
    return re.sub(
        r'\d+(?:\.\d+)?',
        lambda match: str(round(float(match.group()) * 24 * 60 * 60)),
        spec,
    )


def get_remaining_life_days(disk):
    """Return a disk's remaining life in days, or `None` if it does not report one."""
    # A Dorado carries flash media only (SSD, SED, NVMe and SCM disk types), all of
    # which wear out, so a remaining life of 0 is a real reading: the disk is at its
    # end and the thresholds have to fire. Only a negative value is the "not
    # reported" marker. The appliance caps the value at 3660 days, which means "ten
    # years or more". Verified against V700R001C10SPH128 on a Dorado 6000 V6: all 384
    # NVMe disks (DISKTYPE 14) report 3660.
    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 = ''
    capacity_usage_max = None
    disks_not_ok = 0
    remaining_life_min = None
    temperature_max = None
    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)

        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

        # The performance data summarizes the disks instead of graphing each one: an
        # array holds hundreds of them, and their status codes are judged above
        # already. Per-disk detail is in the table. No performance data for the wear
        # level: the appliance reports values above 100 (132 to 187 on a Dorado 6000
        # V6, V700R001C10SPH128), so its scale is unresolved. None for RUNTIME
        # either, which only ever counts up
        # ([#320](https://github.com/Linuxfabrik/monitoring-plugins/issues/320)).
        capacity_usage = lib.huawei_dorado.as_code(disk.get('CAPACITYUSAGE'))
        if capacity_usage is not None:
            capacity_usage_max = (
                capacity_usage
                if capacity_usage_max is None
                else max(capacity_usage_max, capacity_usage)
            )
        if remaining_life_days is not None:
            remaining_life_min = (
                remaining_life_days
                if remaining_life_min is None
                else min(remaining_life_min, remaining_life_days)
            )
        if temperature is not None:
            temperature_max = (
                temperature
                if temperature_max is None
                else max(temperature_max, temperature)
            )

        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
                ),
            ),
        )
        if disk['row_state'] != STATE_OK:
            disks_not_ok += 1
        disk['health'] = lib.huawei_dorado.get_health_status(disk.get('HEALTHSTATUS'))
        disk['running'] = lib.huawei_dorado.get_running_status(
            disk.get('RUNNINGSTATUS')
        )
        # The row's State column is the worst of everything evaluated for the disk,
        # so a row never reads [OK] while it raises the overall state. A threshold
        # that fired is also marked on the value that crossed it, the way Health and
        # Running name the code that decided them.
        disk['state'] = lib.base.state2str(disk['row_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 f'{temperature}{lib.base.state2str(temperature_state, prefix=" ")}'
        )
        # A disk that does not report its remaining life gets a dash, as everywhere
        # else in the family. `seconds2human()` has no unit for zero, so a disk at its
        # end reads "0D" rather than "0s".
        if remaining_life_days is None:
            disk['remaining_life'] = '--'
        else:
            disk['remaining_life'] = (
                lib.human.seconds2human(remaining_life_days * 24 * 60 * 60)
                if remaining_life_days
                else '0D'
            ) + lib.base.state2str(life_state, prefix=' ')
        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}{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'
    perfdata += lib.base.get_perfdata(
        'disks',
        len(table_data),
        uom=None,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'disks_not_ok',
        disks_not_ok,
        uom=None,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'temperature_max',
        temperature_max,
        uom=None,
        warn=args.WARN_TEMPERATURE or None,
        crit=args.CRIT_TEMPERATURE or None,
        _min=0,
    )
    if remaining_life_min is not None:
        perfdata += lib.base.get_perfdata(
            'remaining_life_min',
            remaining_life_min * 24 * 60 * 60,
            uom='s',
            # The thresholds are ranges in days, the value is in seconds.
            warn=days2seconds_range(args.WARN),
            crit=days2seconds_range(args.CRIT),
            _min=0,
        )
    perfdata += lib.base.get_perfdata(
        'capacity_usage_max',
        capacity_usage_max,
        uom='%',
        _min=0,
        _max=100,
    )

    # 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',
                'remaining_life',
                'TEMPERATURE',
                'health',
                'running',
                'state',
            ]
            headers = [
                'UUID',
                'Location',
                'Manufacturer',
                'Model',
                'SerialNumber',
                'Usage',
                'Wear%',
                'Health#',
                'Progress%',
                'Runtime',
                'Remain',
                'Temp',
                'Health',
                'Running',
                'State',
            ]
        else:
            keys = [
                'UUID',
                'LOCATION',
                'LOGICTYPE',
                'ABRASIONRATE',
                'health_mark',
                'remaining_life',
                'TEMPERATURE',
                'health',
                'running',
                'state',
            ]
            headers = [
                'UUID',
                'Location',
                'Usage',
                'Wear%',
                'Health#',
                'Remain',
                '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()
