#!/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_pacific
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 how full the quotas of a Huawei OceanStor Pacific storage system are via the
REST API (/file_service/fs_quota endpoint). Walks all file systems and their dtrees and reports the
used space of every quota relative to its configured hard quota. Quotas without a hard quota are
skipped, because there is no limit to compare against. Alerts when the used space in percent reaches
the warning or critical threshold. Supports extended reporting via --lengthy."""

DEFAULT_CACHE_EXPIRE = 15  # minutes
DEFAULT_CRIT = '90'
DEFAULT_INSECURE = True
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_NO_PROXY = False
DEFAULT_QUOTA_TYPE = ['directory']
DEFAULT_SCOPE = '0'
DEFAULT_TIMEOUT = 3
DEFAULT_WARN = '80'

# Hard stop on the number of requests per share. It bounds the runtime of a check
# against an appliance with far more quotas than anyone expected, and it keeps a
# firmware that ignores `range` from paging forever: such a firmware answers every
# request with the same full page, so the loop's own short-page condition never fires.
MAX_PAGES = 100

# Type number of the object a quota belongs to.
PARENT_TYPE_DTREE = 16445
PARENT_TYPE_FILESYSTEM = 40

# Maximum number of records the API returns per query.
RANGE_LIMIT = 100

# Quota types as returned in the quota_type field.
QUOTA_TYPES = {
    'directory': 1,
    'user': 2,
    'user-group': 3,
}
QUOTA_TYPE_NAMES = {code: name for name, code in QUOTA_TYPES.items()}


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='Hide table rows for quotas within the thresholds and show only those in '
        'WARN/CRIT state. Perfdata and alerting are unaffected: all quotas still emit '
        'perfdata and still drive the overall check state. '
        'Default: %(default)s',
        dest='BRIEF',
        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(
        '--ignore',
        help='Skip quotas. '
        + lib.args.help('--ignore-regex')
        + ' The regex is anchored at the start of the string (Python `re.match`) and is '
        'matched against the share name, including the owner of a user or user group '
        'quota, so prefix with `.*` to match anywhere.',
        dest='IGNORE',
        action='append',
        default=None,
    )

    # Superseded by --ignore, which anchors the regex the way every sibling check does.
    parser.add_argument(
        '--ignore-regex',
        help=argparse.SUPPRESS,
        action='append',
        default=None,
        dest='IGNORE_REGEX',
    )

    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 quotas. '
        + lib.args.help('--match')
        + ' The regex is anchored at the start of the string (Python `re.match`) and is '
        'matched against the share name, including the owner of a user or user group '
        'quota, 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 Pacific API password.',
        dest='PASSWORD',
    )

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

    parser.add_argument(
        '--quota-type',
        help='Type of quota to check. '
        'Can be specified multiple times. '
        'Example: `--quota-type=directory --quota-type=user`. '
        # An append parameter defaults to None so that user values do not pile
        # up on the default list, so %(default)s cannot be used here.
        f'Default: {", ".join(DEFAULT_QUOTA_TYPE)}',
        dest='QUOTA_TYPE',
        action='append',
        choices=sorted(QUOTA_TYPES),
        default=None,
    )

    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_file_systems(args, test_path=None):
    """Return all file systems of the appliance."""
    if test_path is None:
        result = lib.huawei_pacific.get_data(
            'file_service/file_systems',
            args,
            method='GET',
        )
    else:
        result = lib.lftest.test_json(args.TEST, test_path)
    lib.huawei_pacific.assert_ok(result, 'the file systems')
    return result.get('data', [])


def get_dtrees(args, file_system_id, test_path=None):
    """Return all dtrees of one file system."""
    if test_path is None:
        result = lib.huawei_pacific.get_data(
            'file_service/dtrees',
            args,
            payload={'file_system_id': file_system_id},
            method='GET',
        )
    else:
        result = lib.lftest.test_json(args.TEST, test_path)
    lib.huawei_pacific.assert_ok(result, f'the dtrees of file system {file_system_id}')
    return result.get('data', [])


def get_quotas(args, parent_type, parent_id, test_path=None):
    """Return all quotas of one file system or dtree, and whether the walk was cut short.

    The API caps a single query at RANGE_LIMIT records, so the quotas are
    fetched page by page until a short page shows that the last one is reached.
    This endpoint takes its parameters in the body of a GET, which is why it pages
    here rather than through `lib.huawei_pacific.get_all_data()`.
    """
    what = f'the quotas of {parent_id}'
    if test_path is not None:
        result = lib.lftest.test_json(args.TEST, test_path)
        lib.huawei_pacific.assert_ok(result, what)
        return result.get('data', []), False

    quotas = []
    truncated = True
    for page_number in range(MAX_PAGES):
        payload = {
            'parent_type': str(parent_type),
            'parent_id': str(parent_id),
            'range': json.dumps(
                {'offset': page_number * RANGE_LIMIT, 'limit': RANGE_LIMIT}
            ),
        }
        result = lib.huawei_pacific.get_data(
            'file_service/fs_quota',
            args,
            payload=payload,
            method='GET',
        )
        lib.huawei_pacific.assert_ok(result, what)
        page = result.get('data', [])
        quotas += page
        if len(page) < RANGE_LIMIT:
            truncated = False
            break
    return quotas, truncated


def collect_quotas(args):
    """Walk the file systems and their dtrees and return every quota found,
    tagged with the share it belongs to, and whether any walk was cut short.
    """
    quotas = []
    truncated = False
    test_base = args.TEST[0] if args.TEST else None

    file_systems = get_file_systems(
        args,
        test_path=f'{test_base}-file-systems' if test_base else None,
    )
    for i, file_system in enumerate(file_systems):
        file_system_id = file_system.get('id')
        file_system_name = file_system.get('name') or str(file_system_id)

        fs_quotas, fs_truncated = get_quotas(
            args,
            PARENT_TYPE_FILESYSTEM,
            file_system_id,
            test_path=f'{test_base}-quota-fs-{i}' if test_base else None,
        )
        truncated = truncated or fs_truncated
        for quota in fs_quotas:
            quota['share'] = file_system_name
            quotas.append(quota)

        dtrees = get_dtrees(
            args,
            file_system_id,
            test_path=f'{test_base}-dtrees-{i}' if test_base else None,
        )
        for j, dtree in enumerate(dtrees):
            # A dtree id already carries the "<file system id>@<dtree id>" form
            # the quota endpoint expects as its parent_id.
            dtree_name = dtree.get('name') or dtree.get('id')
            dtree_quotas, dtree_truncated = get_quotas(
                args,
                PARENT_TYPE_DTREE,
                dtree.get('id'),
                test_path=f'{test_base}-quota-dtree-{i}-{j}' if test_base else None,
            )
            truncated = truncated or dtree_truncated
            for quota in dtree_quotas:
                quota['share'] = f'{file_system_name}/{dtree_name}'
                quotas.append(quota)

    return quotas, truncated


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://".')

    # set default values for append parameters that were not specified
    if args.IGNORE is None:
        args.IGNORE = []
    # `--ignore-regex` is the name this check used before the family settled on
    # `--ignore`. Deployments still passing it keep working.
    if args.IGNORE_REGEX:
        args.IGNORE += args.IGNORE_REGEX
    if args.MATCH is None:
        args.MATCH = []
    if args.QUOTA_TYPE is None:
        args.QUOTA_TYPE = DEFAULT_QUOTA_TYPE

    # fetch data
    quotas, truncated = collect_quotas(args)

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

    # compile user-supplied regex patterns
    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')
    ]

    wanted_types = {QUOTA_TYPES[item] for item in args.QUOTA_TYPE}

    # analyze data
    for quota in quotas:
        quota_type = lib.huawei_pacific.as_code(quota.get('quota_type'))
        if quota_type not in wanted_types:
            continue

        share = quota['share']
        owner = quota.get('usr_grp_owner_name')
        if owner:
            # user and user group quotas repeat per share, so the owner is what
            # tells them apart
            share = f'{share} ({owner})'
        if args.MATCH and not any(
            lib.base.coe(lib.txt.match_regex(pattern, share))
            for pattern in compiled_match_regex
        ):
            continue

        if args.IGNORE and any(
            lib.base.coe(lib.txt.match_regex(pattern, share))
            for pattern in compiled_ignore_regex
        ):
            continue

        # The API sends these as strings on some firmware and as numbers on others,
        # and leaves them out entirely where there is nothing to report. The quota is
        # counted in the unit the quota itself names, so the two values only compare
        # once both are in bytes.
        space_unit_type = quota.get('space_unit_type')
        hard_quota = lib.huawei_pacific.get_quota_bytes(
            quota.get('space_hard_quota'), space_unit_type
        )
        used = lib.huawei_pacific.get_quota_bytes(quota.get('space_used'), 0)
        if not hard_quota or used is None:
            # no hard quota configured, or no usable measurement: there is
            # nothing to calculate a fill level from
            continue

        # The appliance computes the fill level itself and rounds it the way its own
        # GUI shows it. Preferring it keeps the check and the GUI in agreement, and it
        # is the only figure available where the two values are counted differently.
        used_percent = lib.huawei_pacific.as_code(quota.get('space_used_rate'))
        if used_percent is None or used_percent < 0 or used_percent > 100:
            used_percent = round(used / hard_quota * 100)
        quota_state = lib.base.get_state(
            used_percent,
            args.WARN,
            args.CRIT,
            _operator='range',
        )
        state = lib.base.get_worst(state, quota_state)

        label = re.sub(r'\W+', '_', share).strip('_')
        perfdata += lib.base.get_perfdata(
            f'{label}_usage_percent',
            used_percent,
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )

        # The file quota is reported for context only and does not alert: the
        # check is about space. It counts files, so it carries no unit of its own.
        files = '-'
        file_used = lib.huawei_pacific.as_code(quota.get('file_used'))
        file_hard_quota = lib.huawei_pacific.as_code(quota.get('file_hard_quota'))
        if (
            file_used is not None
            and file_hard_quota is not None
            and lib.huawei_pacific.QUOTA_INVALID_VALUE64
            not in (file_used, file_hard_quota)
        ):
            files = (
                f'{lib.human.number2human(file_used)}'
                f'/{lib.human.number2human(file_hard_quota)}'
            )

        table_data.append(
            {
                'share': share,
                'quota_type': QUOTA_TYPE_NAMES.get(quota_type, 'unknown'),
                'used': lib.human.bytes2human(used),
                'quota': lib.human.bytes2human(hard_quota),
                'used_percent': f'{used_percent}%',
                'files': files,
                'state': lib.base.state2str(quota_state, empty_ok=False),
                'quota_state': quota_state,
            }
        )

    # build the message
    thresholds = f'warn={args.WARN} crit={args.CRIT}'
    if truncated:
        # A walk hit its page cap, so this is a floor on the quota count, not the count.
        msg += (
            'A share reports more quotas than this check reads in one run;'
            ' the list below is incomplete.\n\n'
        )
        state = lib.base.get_worst(state, STATE_WARN)
    if not table_data:
        # An appliance whose shares simply carry no hard quota is a healthy appliance.
        # Only a filter that excluded everything is worth the configured severity.
        filtered = (
            bool(args.MATCH)
            or bool(args.IGNORE)
            or args.QUOTA_TYPE != DEFAULT_QUOTA_TYPE
        )
        lib.base.oao(
            'No quota matched the filters.'
            if filtered
            else 'No share carries a hard quota.',
            lib.base.str2state(args.NO_MATCH_SEVERITY) if filtered else STATE_OK,
            perfdata,
            always_ok=args.ALWAYS_OK,
            no_perfdata=args.NO_PERFDATA,
        )

    if state == STATE_CRIT:
        msg += f'There are critical errors. ({thresholds})'
    elif state == STATE_WARN:
        msg += f'There are warnings. ({thresholds})'
    else:
        msg += f'Everything is ok. ({thresholds})'
    msg += f' Checked {len(table_data)} {lib.txt.pluralize("quota", len(table_data))}.'

    # build table output
    display_rows = table_data
    if args.BRIEF:
        display_rows = [row for row in table_data if row['quota_state'] != STATE_OK]
    if args.LENGTHY:
        keys = [
            'share',
            'quota_type',
            'used',
            'quota',
            'used_percent',
            'files',
            'state',
        ]
        headers = ['Share', 'Type', 'Used', 'Quota', 'Use%', 'Files', 'State']
    else:
        keys = ['share', 'used', 'quota', 'used_percent', 'state']
        headers = ['Share', 'Used', 'Quota', 'Use%', 'State']
    if display_rows:
        msg += '\n\n' + lib.base.get_table(
            display_rows, keys, header=headers, hide_empty=True
        )

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