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

import lib.args
import lib.base
import lib.disk
import lib.human
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN

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

DESCRIPTION = """Reports the health of the Linux software RAID arrays (md) on this host: how
many member devices each array is running on, which of them the kernel has thrown out,
what it says about each of the remaining ones, whether a resync, recovery or reshape is
under way or frozen, and how many inconsistent sectors the last consistency check found.
Every array the kernel knows is reported, so an array nobody remembers is on the list as
well.
Alerts when an array has lost the redundancy it was built for, when the kernel declares
it failed, when a parity array has lost the write journal it needs in order to accept
writes at all, when the kernel says a member wants replacing or has seen a write error,
and when a consistency check found differences between the members of a parity array.
With --spares it also alerts when an array holds fewer spare devices than it is supposed
to.
Supports extended reporting via --lengthy."""

# The kernel writes one block per array here. It exists on every kernel that has md
# support, and it is what an administrator looks at, which is why the array list comes
# from this file and not from a directory listing.
MDSTAT = '/proc/mdstat'

# Per-array attributes the kernel publishes next to it. They carry three things
# /proc/mdstat does not: the number of inconsistent sectors the last check found, a
# machine-readable array state, and the array state on kernels that do not yet spell
# "broken" into /proc/mdstat.
SYSFS_BLOCK = '/sys/block'

# Where an array carries a name, udev puts a symlink to it here, named after the
# `MD_DEVNAME` that mdadm derives from its own map file. That is the name the array was
# created under (`mdadm --create /dev/md/raid5`), and it is the only route to it that
# does not need root: /run/mdadm is mode 0700 and `mdadm --detail` refuses a non-root
# caller. It is reported next to the kernel device rather than instead of it, because
# neither of the two is reliably stable: the kernel hands out `md127` from a pool at
# assembly time, and the name gains a `<homehost>:` prefix as soon as mdadm stops
# trusting the array as local, which is what happens on a host still called
# localhost.localdomain. Measured on Rocky 9.8 / kernel 5.14.0-687.41.1.el9_8 / mdadm
# 4.4, where the same array was `/dev/md/raid5` after creation and
# `/dev/md/localhost.localdomain:raid5` after the next boot.
DEV_MD_DIR = '/dev/md'

# A device line names its members as `<device>[<index>]` followed by zero or more flags.
# drivers/md/md.c:md_seq_show() prints exactly five of them, in this order, and a faulty
# member gets nothing but its own flag because the function stops there:
#
#   (W)  write-mostly, reads avoid this member (a mirror over a slow link)
#   (J)  the write journal of a raid4/5/6 array
#   (F)  the kernel has thrown this member out
#   (S)  spare, waiting to be pulled in
#   (R)  replacement, being built up while the member it replaces still serves reads
#
# Several flags on one member do occur, `vda[3](W)(S)` for instance, which is why the
# flag group is read as a whole. Read from the source of v7.1 and measured on kernel 7.1.
FLAG_FAULTY = 'F'
FLAG_JOURNAL = 'J'
FLAG_REPLACEMENT = 'R'
FLAG_SPARE = 'S'
FLAG_WRITE_MOSTLY = 'W'

FLAG_NAMES = {
    FLAG_FAULTY: 'faulty',
    FLAG_JOURNAL: 'journal',
    FLAG_REPLACEMENT: 'replacement',
    FLAG_SPARE: 'spare',
    FLAG_WRITE_MOSTLY: 'write-mostly',
}

# What the kernel says about a single member, in `md/dev-<device>/state` next to the
# array. drivers/md/md.c:state_show() prints eleven of these words where /proc/mdstat
# prints five flags, and these three are the ones that say a member is on its way out
# while the array itself still reads as complete:
#
#   blocked           the member has bad blocks the metadata handler has not
#                     acknowledged yet, and writes to it wait for that
#   want_replacement  the kernel wants this member replaced and pulls a spare in for it
#                     as soon as one is there
#   write_error       a write to this member failed, and the kernel absorbed it into the
#                     bad block list instead of throwing the member out
#
# The remaining words are either already in /proc/mdstat (faulty, in_sync, journal,
# replacement, spare, write_mostly) or say nothing about the member's health
# (external_bbl, failfast). Read from the source of v7.1 and measured on kernel 7.1,
# where a raid1 whose three members carried one of these each still printed
# `[3/3] [UUU]`.
MEMBER_STATE_BLOCKED = 'blocked'
MEMBER_STATE_WANT_REPLACEMENT = 'want_replacement'
MEMBER_STATE_WRITE_ERROR = 'write_error'

# The kernel's own word goes into the table, because that is what an administrator greps
# for in /sys and in the kernel log. The message says the same thing in a sentence.
MEMBER_STATE_NAMES = {
    MEMBER_STATE_BLOCKED: 'has unacknowledged bad blocks, so writes to it wait',
    MEMBER_STATE_WANT_REPLACEMENT: 'is marked for replacement',
    MEMBER_STATE_WRITE_ERROR: 'has seen a write error',
}

# `consistency_policy` says how a parity array protects itself against the write hole.
# `journal` means it does so through a write journal on a device of its own, and a
# raid4/5/6 in that state without a working journal cannot be written to at all:
# raid5-cache.c:r5l_log_disk_error() returns true both while the journal member is faulty
# and once it is gone (it falls back to MD_HAS_JOURNAL there), and raid5.c:handle_stripe()
# then fails the stripe. Measured on kernel 6.12 / mdadm 4.4: reads still ran at full
# speed, a direct write answered `Input/output error`, and a buffered write plus `sync`
# both reported success while the kernel logged `lost async page write`. /proc/mdstat
# says `[3/3] [UUU]` throughout.
CONSISTENCY_POLICY_JOURNAL = 'journal'

# `sync_action` while nothing is running, and the one value there that does not simply
# mean "nothing to do": it forbids resync, recovery and reshape, so a degraded array
# stays degraded however many spares sit in it. /proc/mdstat prints no line for it, so
# the array looks merely idle. drivers/md/md.c:action_name[].
SYNC_ACTION_FROZEN = 'frozen'

# The array states md_seq_show() and the sysfs `array_state` attribute agree on. "broken"
# is the kernel's own word for an array that has lost so many members that it can no
# longer serve its data.
STATE_BROKEN = 'broken'
STATE_INACTIVE = 'inactive'

# RAID levels whose redundancy comes from parity, and where md(4) says that any mismatch
# found by a consistency check "should indicate a hardware problem at some level".
PARITY_LEVELS = ('raid4', 'raid5', 'raid6')

# RAID levels whose redundancy comes from copies. md(4) is explicit that a mismatch found
# here does not have to mean corruption: a page written while the check reads it, and
# above all a swap area, produce differences between the copies that nothing ever reads
# back. Hence "the mismatch_cnt value can not be interpreted very reliably on RAID1 or
# RAID10, especially when the device is used for swap".
MIRROR_LEVELS = ('raid1', 'raid10')

# RAID levels that rebuild, and therefore the only ones a spare device is any use to.
# raid0 and linear have nothing to rebuild onto and the kernel publishes no `degraded`
# for them, so a spare count means nothing there and they are not graded against one.
REDUNDANT_LEVELS = MIRROR_LEVELS + PARITY_LEVELS

# `<device>[<index>]` plus the flag group. The flags are matched as a group of complete
# `(X)` items instead of as a character class, so a member carrying more than one of them
# keeps all of them.
COMPONENT_PATTERN = re.compile(
    r'^(?P<device>.+)\[(?P<index>\d+)\](?P<flags>(?:\([A-Z]\))*)$'
)

# `[4/3] [UUU_]`: how many members the array wants, how many are in sync, and which slot
# each of them sits in. raid0 and linear print no such line at all, because they have no
# redundancy to report on.
DEVICE_COUNT_PATTERN = re.compile(
    r'\[(?P<total>\d+)/(?P<working>\d+)\]\s+\[(?P<map>[U_]+)\]'
)

# `[====>................]  recovery = 31.8% (1241578496/3895351808) finish=746.8min
# speed=59224K/sec`. The four actions are the wording of md_seq_show(); note that it says
# "recovery" where the sysfs attribute says "recover", and that "repair" reaches this
# line as "resync".
PROGRESS_PATTERN = re.compile(
    r'\b(?P<action>check|recovery|reshape|resync)\s*=\s*(?P<percent>[\d.]+)%'
    r'\s*\((?P<done>\d+)/(?P<total>\d+)\)'
    r'(?:\s+finish=(?P<finish>[\d.]+)min)?'
    r'(?:\s+speed=(?P<speed>\d+)K/sec)?'
)

# `resync=PENDING`, `resync=DELAYED`, and the three REMOTE forms of a clustered array.
# They stand in for the progress line while nothing is running yet.
PENDING_PATTERN = re.compile(r'^(?P<action>recover|reshape|resync)=(?P<state>[A-Z]+)$')

SIZE_PATTERN = re.compile(r'^(?P<blocks>\d+) blocks\b')
SUPER_PATTERN = re.compile(r'\bsuper (?P<metadata>\S+)')

DEFAULT_JOURNAL_SEVERITY = 'crit'
DEFAULT_LENGTHY = False
DEFAULT_MEMBER_SEVERITY = 'warn'
DEFAULT_MIRROR_MISMATCH_SEVERITY = 'ok'
DEFAULT_MISMATCH_SEVERITY = 'warn'
DEFAULT_NO_ARRAYS_SEVERITY = 'warn'
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_SEVERITY = 'warn'
DEFAULT_SPARES = None
DEFAULT_SPARES_SEVERITY = 'warn'

# What an administrator is supposed to do next, built from the names this run actually
# read. A command in the output is there to be copied, so it has to name this host's
# array and this host's member: an example device would put `/dev/md0` in front of
# somebody whose array is `md127`, and adding a disk to the wrong array is not a mistake
# worth inviting. A placeholder in angle brackets is no way out either, because it
# reaches a web interface as an escaped entity, so where a name is genuinely unknown, as
# with the replacement disk, the text says so in words instead.
MIRROR_MISMATCH_HELP = (
    'On a mirrored array this does not have to mean corruption. A page written while '
    'the check reads it, and above all a swap area, leave the copies different in a '
    'place nothing ever reads back, which is why md(4) says the count cannot be '
    'interpreted reliably here. Where the count keeps growing from one check to the '
    'next, go looking for the disks behind it all the same, with `smartctl --all` on '
    'each of them and `journalctl --dmesg --grep=md`.'
)

NO_MD_HELP = (
    'The `md` code reaches the kernel with the first array that is assembled, so a host '
    'that never had one does not carry /proc/mdstat at all. Where this host is supposed '
    'to run an array, `mdadm --examine --scan` reads the superblocks that are still on '
    'the disks; where it is not, this check does not belong on it. The `mdadm` package '
    'on its own says nothing either way, because the initial ramdisk pulls it in on '
    'hosts that never assemble an array.'
)

NO_ARRAYS_HELP = (
    'Either every array was dismantled, in which case this check no longer belongs on '
    'this host, or the members did not turn up at boot. `mdadm --examine --scan` reads '
    'the superblocks that are still on the disks.'
)


def readwrite_first(array, read_only):
    """
    Say to make an array writable again where adding a member would fail without it.

    drivers/md/md.c:md_ioctl() refuses every superblock-changing ioctl, and adding a
    member is one, with -EROFS while the array is not read-write. The one exception is an
    array that is only *auto* read-only, which the kernel switches over by itself at that
    point, so that state needs nothing said about it. Measured on Rocky 9.8: `mdadm --add`
    on a read-only array answers "add new device failed [...]: Read-only file system",
    while `mdadm --remove` on the same array goes through.
    """
    if read_only != 'read-only':
        return ''
    return (
        f' {array} is read-only, and a member cannot be added to it in that state, so '
        f'`mdadm --readwrite /dev/{array}` comes first.'
    )


def remediation(report):
    """
    Say what to do about an array that is running on fewer members than it was built for.

    Takes the whole report, because what to say depends on five things about the array at
    once. `faulty` is empty where a slot is simply unfilled, which is a different thing
    from a member the kernel threw out and gets a different instruction. An array that is
    already rebuilding needs neither: asking for another device there would only put a
    spare in beside the replacement the kernel is busy building up. Nor does an array
    that already has a spare sitting in it, which is what a degraded array with a frozen
    sync looks like: the disk is there, the kernel is just not allowed to use it.
    """
    array = report['name']
    faulty = report['faulty_members']
    frozen = report['frozen']
    read_only = report['read_only']
    spare = report['spare']
    if report['rebuilding']:
        text = f'{array} is already rebuilding onto a replacement, so let it finish. '
        if faulty:
            removals = ', '.join(
                f'`mdadm /dev/{array} --remove /dev/{item}`' for item in faulty
            )
            text += (
                f'The {lib.txt.pluralize("member", len(faulty), ",s")} the kernel threw '
                f'out ({", ".join(faulty)}) can be taken out at any point with '
                f'{removals}.'
            )
        else:
            text += 'Adding another device now would only put a spare in beside it.'
        return f'{text} Until the rebuild is done, the array has no redundancy left to lose.'
    if faulty:
        removals = ', '.join(
            f'`mdadm /dev/{array} --remove /dev/{item}`' for item in faulty
        )
        text = (
            f'{array} lost {", ".join(faulty)}. Look at what the kernel said about it '
            f'(`journalctl --dmesg --grep=md`) and at the health of the disk carrying '
            f'that member (`smartctl --all` on the disk it sits on) before writing it '
            f'off. Then take it out with {removals} and replace the hardware.'
        )
        if spare:
            text += (
                f' A spare is attached to {array} already ({", ".join(spare)}), so '
                f'nothing has to be added for the rebuild itself.'
            )
        else:
            text += (
                f' The replacement goes in with `mdadm /dev/{array} --add` followed by '
                f'its device.'
            )
    else:
        text = (
            f'{array} is short of a member without the kernel having thrown one out, so '
            f'one never turned up. `mdadm --detail /dev/{array}` names the slot that is '
            f'empty'
        )
        if spare:
            text += (
                f', and a spare is attached to {array} already ({", ".join(spare)}), so '
                f'nothing has to be added to fill it.'
            )
        else:
            text += f', and `mdadm /dev/{array} --add` followed by a device fills it.'
    if frozen:
        tail = (
            f' Nothing rebuilds while the sync of {array} is frozen, so '
            f'`echo idle > /sys/block/{array}/md/sync_action` comes before any of it.'
        )
    else:
        tail = (
            ' The array rebuilds on its own from there. Until it is done, it has no '
            'redundancy left to lose.'
        )
    return f'{text}{readwrite_first(array, read_only)}{tail}'


def broken_help(array):
    """Say what to do about an array the kernel has stopped serving."""
    return (
        f'The kernel has stopped serving {array}. Do not write to it and do not '
        f'recreate it: `mdadm --assemble --force /dev/{array}` on the members that are '
        f'still readable is the way back, and it needs those members left untouched. '
        f'`mdadm --examine` on each of them says which ones are still usable.'
    )


def frozen_help(array):
    """
    Say what a frozen sync keeps an array from doing.

    Freezing forbids resync, recovery and reshape, and it is not a state an array reaches
    on its own: somebody wrote it, or something wrote it on their behalf. /proc/mdstat
    prints no line for it, so the array looks merely idle until the day it is supposed to
    rebuild and does not.
    """
    return (
        f'The sync of {array} is frozen, which forbids resync, recovery and reshape. '
        f'That is not a state an array reaches by itself, so somebody set it and left '
        f'it set. `echo idle > /sys/block/{array}/md/sync_action` lets it run again.'
    )


def inactive_help(array):
    """Say what to do about an array that was assembled but never started."""
    return (
        f'{array} was assembled but never started, which is what happens when not all '
        f'of its members turned up at boot. `mdadm --detail /dev/{array}` names the '
        f'ones that are missing, and `mdadm --run /dev/{array}` starts it where they '
        f'are merely late.'
    )


def journal_help(array, faulty):
    """
    Say what to do about a parity array whose write journal is gone.

    The array keeps reporting its full width and every member in sync, and it cannot be
    written to. `faulty` names the dead journal where one is still attached; it is empty
    once somebody has taken it out, which does not help and is the state that hides the
    problem completely.
    """
    text = (
        f'{array} keeps its write journal on a device of its own, and that device is '
        f'{"dead" if faulty else "gone"}. Reads still work and writes do not: a direct '
        f'write answers with an I/O error, and a buffered write is reported as having '
        f'succeeded while the kernel throws it away, so an application on top of this '
        f'array is losing data right now without being told. Nothing in /proc/mdstat '
        f'says so, it keeps reporting every member in sync. '
    )
    if faulty:
        removals = ', '.join(
            f'`mdadm /dev/{array} --remove /dev/{item}`' for item in faulty
        )
        text += f'Take the dead journal out with {removals}, then '
    else:
        text += 'Taking the journal out was not enough, so '
    text += (
        f'put a new one in: `mdadm --readonly /dev/{array}` first, because '
        f'`--add-journal` is refused on a running array, then `mdadm /dev/{array} '
        f'--add-journal` followed by the device. mdadm switches the array back to '
        f'read-write by itself at that point, so no `--readwrite` is needed afterwards. '
        f'Where there is no disk to spare, `echo resync > '
        f'/sys/block/{array}/md/consistency_policy` gives up journalling instead and '
        f'makes the array writable again immediately, at the price of the write hole '
        f'the journal was there to close.'
    )
    return text


def replaced_help(array, faulty):
    """Say what to do about a dead member still attached to an array that is whole again."""
    removals = ', '.join(
        f'`mdadm /dev/{array} --remove /dev/{item}`' for item in faulty
    )
    return (
        f'{array} is back to its full width, so a spare has stepped in or the member '
        f'was replaced, and the device the kernel threw out is still attached. Take it '
        f'out with {removals} and put a new spare in, because the next failure has '
        f'nothing left to fall back on.'
    )


def member_help(array, members):
    """
    Say what to do about members carrying a state only the kernel's own attributes show.

    None of the three reaches /proc/mdstat, so the array's own line says nothing while a
    disk behind it is on its way out. What to look at is the same for all of them; what
    to do next is not, so the two states that have an instruction of their own get one.
    """
    devices = ', '.join(item['device'] for item in members)
    text = (
        f'The kernel publishes this next to {array} and not in /proc/mdstat, so the '
        f'line the array itself gets says nothing about it. Look at the health of the '
        f'{lib.txt.pluralize("disk", len(members), ",s")} behind {devices} with '
        f'`smartctl --all` on '
        f'{lib.txt.pluralize("", len(members), "it,each of them")} '
        f'and at what the kernel said with `journalctl --dmesg --grep=md`.'
    )
    if any(MEMBER_STATE_WANT_REPLACEMENT in item['states'] for item in members):
        text += (
            f' A member the kernel wants replaced is replaced by putting a spare in '
            f'with `mdadm /dev/{array} --add` followed by a device: the kernel builds '
            f'the spare up and takes the old member out by itself.'
        )
    blocked = [
        item['device'] for item in members if MEMBER_STATE_BLOCKED in item['states']
    ]
    if blocked:
        listings = ', '.join(
            f'`cat /sys/block/{array}/md/dev-{item}/unacknowledged_bad_blocks`'
            for item in blocked
        )
        text += (
            f' Writes to a blocked member wait until the bad blocks behind it are '
            f'acknowledged, and {listings} lists them.'
        )
    return text


def parity_mismatch_help(arrays):
    """Say what to do about inconsistent sectors found on a parity array."""
    repairs = ', '.join(
        f'`echo repair > /sys/block/{item}/md/sync_action`' for item in arrays
    )
    return (
        'On a parity array md(4) says this points at a hardware problem at some level, '
        'because software alone does not produce it. Look for the disk behind it with '
        '`smartctl --all` on each member and `journalctl --dmesg --grep=md`, then '
        f'rewrite the parity with {repairs} and run another check afterwards to confirm '
        'the count went back to zero.'
    )


def spares_help(array, present, expected):
    """Say what to do about an array holding fewer spares than it is expected to."""
    return (
        f'{array} holds {present} of the {expected} spare '
        f'{lib.txt.pluralize("device", expected, ",s")} it is expected to, so the next '
        f'member the kernel throws out has nothing to be rebuilt onto and the array '
        f'stays degraded until somebody puts a disk in. `mdadm /dev/{array} --add` '
        f'followed by a device puts one in now.'
    )


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

    # hidden test hook: prefix every path the check reads, so a fixture tree can
    # stand in for the host's filesystem without touching the host
    parser.add_argument(
        '--config-root',
        help=argparse.SUPPRESS,
        dest='CONFIG_ROOT',
        default='/',
    )

    parser.add_argument(
        '--ignore',
        help=lib.args.help('--ignore-regex'),
        action='append',
        default=None,
        dest='IGNORE',
    )

    parser.add_argument(
        '--journal-severity',
        help='State to report when a parity array (RAID 4, RAID 5, RAID 6) that keeps a '
        'write journal has lost it. '
        'Such an array reports every member in sync and cannot be written to, and a '
        'buffered write to it is reported as having succeeded and then thrown away. '
        'Default: %(default)s',
        dest='JOURNAL_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_JOURNAL_SEVERITY,
    )

    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'),
        action='append',
        default=None,
        dest='MATCH',
    )

    parser.add_argument(
        '--member-severity',
        help='State to report when the kernel says a member device wants replacing, has '
        'seen a write error, or has bad blocks that are not acknowledged yet. '
        'None of the three reaches /proc/mdstat, so an array carrying one of them still '
        'reads as complete. '
        'Default: %(default)s',
        dest='MEMBER_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_MEMBER_SEVERITY,
    )

    parser.add_argument(
        '--mirror-mismatch-severity',
        help='State to report when a consistency check found inconsistent sectors on a '
        'mirrored array (RAID 1, RAID 10). '
        'A page written while the check reads it, and above all a swap area, produce '
        'differences between the copies that nothing ever reads back, which is why '
        'this defaults to not alerting. '
        'Default: %(default)s',
        dest='MIRROR_MISMATCH_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_MIRROR_MISMATCH_SEVERITY,
    )

    parser.add_argument(
        '--mismatch-severity',
        help='State to report when a consistency check found inconsistent sectors on a '
        'parity array (RAID 4, RAID 5, RAID 6). '
        'On such an array the difference points at hardware rather than at the way the '
        'data was written. '
        'Default: %(default)s',
        dest='MISMATCH_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_MISMATCH_SEVERITY,
    )

    parser.add_argument(
        '--no-arrays-severity',
        help='State to report when the host runs no software RAID array at all. '
        'An array is assembled from the superblocks on its members at every boot, so '
        'an array that used to be here and is gone is worth looking at. '
        'Default: %(default)s',
        dest='NO_ARRAYS_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_NO_ARRAYS_SEVERITY,
    )

    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(
        '--severity',
        help=lib.args.help('--severity')
        + ' Applies to an array that is running on fewer members than it was built '
        'for, so it has lost the redundancy it is meant to provide, and to a member '
        'the kernel threw out that is still attached to an array a spare has already '
        'brought back to its full width. '
        'Default: %(default)s',
        dest='SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_SEVERITY,
    )

    parser.add_argument(
        '--spares',
        help='Number of spare devices an array is expected to hold. '
        'A bare count applies to every array, `array=count` pins one array and wins '
        'over the bare count. The array is named by its kernel device or by the name it '
        'was created under. '
        'Can be specified multiple times. '
        'Example: `--spares=1` on a host where every array carries one hot spare. '
        'Example: `--spares=md0=1 --spares=md1=0` where only the first one does. '
        'Default: %(default)s, which grades no array against a spare count.',
        dest='SPARES',
        action='append',
        default=DEFAULT_SPARES,
    )

    parser.add_argument(
        '--spares-severity',
        help='State to report when an array holds fewer spare devices than --spares '
        'says it should. '
        'Without a spare the next member the kernel throws out leaves the array '
        'degraded until somebody puts a disk in. '
        'Default: %(default)s',
        dest='SPARES_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_SPARES_SEVERITY,
    )

    args, _ = parser.parse_known_args()
    return args


def parse_spare_specs(specs):
    """
    Turn the --spares values into (count for every array, {array: count}).

    A bare number applies to every array, `array=count` pins one array. Ends the check on
    a malformed or negative count, so a typo does not silently disable the expectation.
    """
    default = None
    pinned = {}
    for spec in specs:
        name, separator, count = spec.partition('=')
        try:
            value = int(count if separator else name)
            if value < 0:
                raise ValueError
        except ValueError:
            # A typo in a parameter is the expected answer here, not a defect, so it
            # gets the sentence that says so and no Python stack trace.
            lib.base.cu(
                f'Invalid --spares value "{spec}", expected a count of 0 or more, '
                f'optionally prefixed with the array and an equals sign.',
                traceback=False,
            )
        if separator:
            pinned[name.strip()] = value
        else:
            default = value
    return (default, pinned)


def parse_components(fields):
    """
    Read the member devices out of the tail of an array's first line.

    Returns a list of dicts with the device name, its index in the superblock and the
    flags the kernel prints behind it.
    """
    components = []
    for field in fields:
        match = COMPONENT_PATTERN.match(field)
        if not match:
            continue
        components.append(
            {
                'device': match.group('device'),
                'flags': re.findall(r'\(([A-Z])\)', match.group('flags')),
                'index': int(match.group('index')),
            }
        )
    return components


def parse_mdstat(content):
    """
    Read /proc/mdstat into one dict per array.

    The file is a sequence of blocks: a line starting in the first column names the
    array and its members, and every line indented below it adds to it. Only the
    "Personalities" and "unused devices" lines share the first column with an array.
    """
    arrays = []
    current = None
    for line in content.splitlines():
        if not line.strip():
            continue
        if not line[0].isspace():
            if line.startswith(('Personalities', 'unused devices')):
                current = None
                continue
            name, _, rest = line.partition(':')
            if not rest:
                current = None
                continue
            fields = rest.split()
            if not fields:
                current = None
                continue
            current = {
                'blocks': None,
                'components': [],
                'level': None,
                'map': None,
                'metadata': None,
                'name': name.strip(),
                'progress': None,
                'read_only': None,
                'status': fields[0],
                'total_devices': None,
                'working_devices': None,
            }
            # `active (read-only) raid1 sda6[0] sdb6[1]`: the qualifier sits between
            # the status and the personality, and an inactive array has no personality
            # at all.
            index = 1
            if index < len(fields) and fields[index].startswith('('):
                current['read_only'] = fields[index].strip('()')
                index += 1
            if index < len(fields) and not COMPONENT_PATTERN.match(fields[index]):
                current['level'] = fields[index]
                index += 1
            current['components'] = parse_components(fields[index:])
            arrays.append(current)
            continue

        if current is None:
            continue
        stripped = line.strip()
        match = SIZE_PATTERN.match(stripped)
        if match:
            current['blocks'] = int(match.group('blocks'))
            match = SUPER_PATTERN.search(stripped)
            if match:
                current['metadata'] = match.group('metadata')
        match = DEVICE_COUNT_PATTERN.search(stripped)
        if match:
            current['map'] = match.group('map')
            current['total_devices'] = int(match.group('total'))
            current['working_devices'] = int(match.group('working'))
        match = PROGRESS_PATTERN.search(stripped)
        if match:
            current['progress'] = {
                'action': match.group('action'),
                'finish': match.group('finish'),
                'percent': match.group('percent'),
                'speed': match.group('speed'),
            }
            continue
        match = PENDING_PATTERN.match(stripped)
        if match:
            current['progress'] = {
                'action': match.group('action'),
                'finish': None,
                'percent': None,
                'speed': None,
                'state': match.group('state'),
            }
    return arrays


def read_attr(path):
    """
    Read one attribute below /sys, or None where it does not exist.

    Files below /sys report a size of zero, so their presence has to be probed allowing
    an empty file. An attribute that does not apply to the RAID level of the array is
    simply absent: raid0 and linear have no `degraded`, no `sync_action` and no
    `mismatch_cnt`.
    """
    if not lib.disk.file_exists(path, allow_empty=True):
        return None
    success, content = lib.disk.read_file(path)
    if not success:
        return None
    content = content.strip()
    return content if content else None


def read_members(md_dir):
    """
    Read what the kernel says about each member device of one array.

    Returns a dict of member device to the list of words below `dev-<device>/state`.
    Empty where the array publishes no member directories at all, which is what an
    inactive array does.
    """
    try:
        entries = sorted(os.listdir(md_dir))
    except OSError:
        return {}
    members = {}
    for entry in entries:
        if not entry.startswith('dev-'):
            continue
        state = read_attr(os.path.join(md_dir, entry, 'state'))
        if state is None:
            continue
        members[entry[len('dev-') :]] = [item for item in state.split(',') if item]
    return members


def read_sysfs(root, name):
    """
    Read the attributes the kernel publishes next to an array.

    Returns a dict, empty where the array has no sysfs directory. Every value is either
    the attribute's text or None, except `members`, which is one list of state words per
    member device.
    """
    # The name comes out of a file, so it never gets to leave the directory it is looked
    # up in, however that file came to say what it says.
    if not name or '/' in name or name in ('.', '..'):
        return {}
    md_dir = os.path.join(root, SYSFS_BLOCK.lstrip('/'), name, 'md')
    if not lib.disk.dir_exists(md_dir):
        return {}
    attrs = {}
    for attr in (
        'array_state',
        'consistency_policy',
        'degraded',
        'level',
        'mismatch_cnt',
        'raid_disks',
        'sync_action',
    ):
        attrs[attr] = read_attr(os.path.join(md_dir, attr))
    attrs['members'] = read_members(md_dir)
    return attrs


def get_names(root):
    """
    Map each kernel device to the name its array was created under.

    Returns a dict, empty where the directory does not exist, which is the case on a host
    whose arrays carry no name and inside a container or an initial ramdisk that never
    ran udev. The link target is read without being followed, and only its last element
    is used, so nothing outside the directory can be reached through it.
    """
    path = os.path.join(root, DEV_MD_DIR.lstrip('/'))
    if not lib.disk.dir_exists(path):
        return {}
    try:
        entries = sorted(os.listdir(path))
    except OSError:
        return {}
    names = {}
    for entry in entries:
        try:
            target = os.readlink(os.path.join(path, entry))
        except OSError:
            continue
        device = os.path.basename(target)
        if device.startswith('md'):
            names[device] = entry
    return names


def get_arrays(root):
    """
    Read every software RAID array the kernel knows about.

    Returns (True, (arrays, has_md)). `has_md` is False where the kernel has no md
    support loaded at all, which is a different thing from a host whose arrays are gone:
    the file only exists once the md code is in the kernel.
    """
    path = os.path.join(root, MDSTAT.lstrip('/'))
    if not lib.disk.file_exists(path, allow_empty=True):
        return (True, ([], False))
    success, content = lib.disk.read_file(path)
    if not success:
        return (False, content)
    arrays = parse_mdstat(content)
    names = get_names(root)
    for array in arrays:
        array['sysfs'] = read_sysfs(root, array['name'])
        # the kernel device stays the identifier everywhere, because it is what
        # /proc/mdstat, /sys and every mdadm command take; the name is reported beside it
        array['label'] = names.get(array['name'], '-')
    return (True, (arrays, True))


def to_int(value):
    """Read an integer out of a sysfs attribute, or None where it is not one.

    `raid_disks` reads `4 (3)` while an array is being reshaped, so only the leading
    number is taken.
    """
    if value is None:
        return None
    try:
        return int(value.split()[0])
    except (IndexError, ValueError):
        return None


def describe_progress(progress, frozen=False):
    """Put an array's resync, recovery, reshape or check into one readable phrase."""
    if not progress:
        return SYNC_ACTION_FROZEN if frozen else 'idle'
    action = progress['action']
    if progress.get('state'):
        # `resync=PENDING` on an array that has not been written to yet, `DELAYED`
        # behind another array that syncs first, `REMOTE` on a clustered array.
        return f'{action} {progress["state"].lower()}'
    text = f'{action} {progress["percent"]}%'
    if progress['finish']:
        # the kernel prints its estimate in minutes with one decimal, so anything under
        # six seconds rounds to zero, and "0s left" reads like a defect rather than like
        # an estimate that ran out of resolution
        seconds = int(float(progress['finish']) * 60)
        if seconds:
            text += f', {lib.human.seconds2human(seconds)} left'
    if progress['speed']:
        text += f', {lib.human.bytes2human(int(progress["speed"]) * 1024)}/s'
    return text


def analyze(array, expected_spares, args):
    """
    Work out what one array is doing and how bad that is.

    Returns a dict describing the array plus its state. `expected_spares` is the number
    of spare devices this array is supposed to hold, or None where nothing said.
    """
    sysfs = array['sysfs']
    level = array['level'] or sysfs.get('level')
    array_state = sysfs.get('array_state')
    # A kernel below 6.12 keeps writing "active" into /proc/mdstat for an array it has
    # already given up on, while the sysfs attribute has carried "broken" since 5.4.
    # Either of the two saying so is enough.
    broken = STATE_BROKEN in (array['status'], array_state)
    inactive = STATE_INACTIVE in (array['status'], array_state)

    total = array['total_devices']
    working = array['working_devices']
    if total is None:
        total = to_int(sysfs.get('raid_disks'))
    degraded = to_int(sysfs.get('degraded'))
    if degraded is None and total is not None and working is not None:
        degraded = total - working
    if degraded is None:
        # raid0 and linear have no redundancy and report neither, which is not a gap in
        # the reading but the whole truth about them.
        degraded = 0

    faulty = [
        item['device'] for item in array['components'] if FLAG_FAULTY in item['flags']
    ]
    # The write journal of a parity array is not one of the members the array is built
    # from, so it is kept out of everything that talks about redundancy: it fills no
    # slot, a spare does not replace it, and losing it costs writes rather than
    # redundancy. `faulty` keeps counting it, because the kernel did throw a device out.
    journal = [
        item['device'] for item in array['components'] if FLAG_JOURNAL in item['flags']
    ]
    faulty_members = [item for item in faulty if item not in journal]
    # A parity array whose write journal is dead, and one whose journal was taken out
    # afterwards, are the same thing to the kernel and neither reaches /proc/mdstat. The
    # second is the quieter of the two: nothing is flagged anywhere any more.
    journal_faulty = [item for item in faulty if item in journal]
    journal_lost = bool(journal_faulty) or (
        sysfs.get('consistency_policy') == CONSISTENCY_POLICY_JOURNAL and not journal
    )
    spare = [
        item['device'] for item in array['components'] if FLAG_SPARE in item['flags']
    ]
    mismatch = to_int(sysfs.get('mismatch_cnt')) or 0
    frozen = sysfs.get('sync_action') == SYNC_ACTION_FROZEN

    # What the kernel says about the members it has not thrown out. A member carrying
    # `(F)` already has an instruction of its own, and repeating it here would only say
    # the same thing twice.
    member_attrs = sysfs.get('members') or {}
    member_states = []
    for device in sorted(member_attrs):
        if device in faulty:
            continue
        states = [item for item in member_attrs[device] if item in MEMBER_STATE_NAMES]
        if states:
            member_states.append({'device': device, 'states': states})

    # An array is graded against a number of spares only where one was given. This is
    # what `mdadm --monitor` calls SparesMissing, which it takes from the `spares=` of
    # its own configuration file rather than from the array.
    spares_missing = 0
    if expected_spares is not None and level in REDUNDANT_LEVELS:
        spares_missing = max(0, expected_spares - len(spare))

    state = STATE_OK
    if broken:
        status = STATE_BROKEN
        state = STATE_CRIT
    elif inactive:
        status = STATE_INACTIVE
        state = STATE_CRIT
    elif journal_lost:
        # An array that cannot be written to leads over one that is merely short of a
        # member, and the row still shows the device counters next to it.
        status = 'journal lost'
        state = lib.base.str2state(args.JOURNAL_SEVERITY)
    elif degraded:
        status = 'degraded'
        state = lib.base.str2state(args.SEVERITY)
    elif faulty_members:
        # A spare that stepped in brings the array back to its full width while the
        # member the kernel threw out is still attached. Nothing is degraded any more,
        # and a disk has died all the same, which is what this says out loud.
        status = 'failed member attached'
        state = lib.base.str2state(args.SEVERITY)
    elif member_states:
        # The array is at its full width and the kernel is unhappy about a member all
        # the same, which is the whole point of reading them.
        status = 'member flagged'
    else:
        status = 'active'
    if array['read_only']:
        status += f' ({array["read_only"]})'

    if journal_lost:
        state = lib.base.get_worst(state, lib.base.str2state(args.JOURNAL_SEVERITY))
    if member_states:
        state = lib.base.get_worst(state, lib.base.str2state(args.MEMBER_SEVERITY))
    if spares_missing:
        state = lib.base.get_worst(state, lib.base.str2state(args.SPARES_SEVERITY))

    if mismatch:
        if level in PARITY_LEVELS:
            state = lib.base.get_worst(
                state, lib.base.str2state(args.MISMATCH_SEVERITY)
            )
        elif level in MIRROR_LEVELS:
            state = lib.base.get_worst(
                state, lib.base.str2state(args.MIRROR_MISMATCH_SEVERITY)
            )

    if total is not None and working is not None:
        devices = f'{working}/{total}'
        if array['map']:
            devices += f' [{array["map"]}]'
        counted = f'{working} of {total}'
    else:
        # raid0, linear and an inactive array print no such line, so all that can be
        # said is how many members the kernel currently attributes to the array.
        members = len(array['components'])
        devices = str(members)
        counted = f'{members} {lib.txt.pluralize("member", members, ",s")}'

    # Every member the kernel says something about, from both places it says it: the
    # flags in /proc/mdstat, spelled out, and the words next to the array, in the
    # kernel's own spelling so they can be grepped for. A plain member says nothing
    # beyond what the device counters already say, so only these are listed.
    annotations = {}
    for item in array['components']:
        if item['flags']:
            annotations.setdefault(item['device'], []).extend(
                FLAG_NAMES.get(flag, flag) for flag in item['flags']
            )
    for item in member_states:
        annotations.setdefault(item['device'], []).extend(item['states'])
    flagged = ', '.join(
        f'{device} ({", ".join(states)})'
        for device, states in sorted(annotations.items())
    )

    progress = array['progress']
    return {
        'broken': broken,
        'counted': counted,
        'degraded': degraded,
        'devices': devices,
        'expected_spares': expected_spares,
        'faulty': faulty,
        'faulty_members': faulty_members,
        'journal_faulty': journal_faulty,
        'journal_lost': journal_lost,
        'label': array.get('label', '-'),
        'flagged': flagged or '-',
        'frozen': frozen,
        'inactive': inactive,
        'level': level or 'unknown',
        'member_states': member_states,
        'mismatch': mismatch,
        'name': array['name'],
        'metadata': array['metadata'] or '-',
        'progress': describe_progress(progress, frozen),
        'read_only': array['read_only'],
        # a recovery is the kernel building a member up, which is what happens once a
        # replacement was added. A resync or a check on a degraded array is not that,
        # and telling somebody to add a device while one is being built up would only
        # put a spare in beside it.
        'rebuilding': bool(progress and progress.get('action') == 'recovery'),
        'spare': spare,
        'spares_missing': spares_missing,
        'state': state,
        'status': status,
        # The kernel prints its progress in whole percent with one decimal and its speed
        # in KiB/s. Both are only there while something is running, and a graph of an
        # idle array would have to invent a value to fill the gap.
        'sync_percent': (
            float(progress['percent']) if progress and progress.get('percent') else None
        ),
        'sync_speed': (
            int(progress['speed']) * 1024
            if progress and progress.get('speed')
            else None
        ),
        # An array waiting for a resync that has not started reports the action without
        # any progress behind it, which is not the same thing as one that is running.
        'syncing': bool(progress and progress.get('percent')),
    }


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 = []
    if args.SPARES is None:
        args.SPARES = []

    # fetch data
    if not lib.base.LINUX:
        lib.base.cu(
            'Software RAID arrays of this kind are a Linux kernel feature. '
            'This check belongs on a Linux host.'
        )
    arrays, has_md = lib.base.coe(get_arrays(args.CONFIG_ROOT))

    # init some vars
    msg = ''
    perfdata = ''
    state = STATE_OK
    table_data = []
    broken = []
    degraded = []
    flagged_members = []
    frozen = []
    inactive = []
    journal_lost = []
    mismatched = []
    replaced = []
    reports = []
    short_of_spares = []
    syncing = []
    faulty_devices = 0
    spare_devices = 0
    missing_spare_devices = 0
    default_spares, pinned_spares = parse_spare_specs(args.SPARES)
    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')
    ]

    # analyze data
    for array in arrays:
        # Filter arrays by their kernel device (`md0`, `md127`) and by the name they
        # were created under where they have one, so a filter written against either
        # keeps working. --match (include) is applied first, then --ignore (exclude), so
        # one hit by --ignore is dropped even if it also matches --match. Both use
        # case-sensitive Python regex.
        name = array['name']
        identifiers = [name]
        if array.get('label', '-') != '-':
            identifiers.append(array['label'])
        if compiled_match and not any(
            item.search(text) for item in compiled_match for text in identifiers
        ):
            continue
        if any(item.search(text) for item in compiled_ignore for text in identifiers):
            continue

        # A count pinned to this array wins over the one given for every array, and
        # the pin is looked up under both names the array answers to.
        expected_spares = default_spares
        for identifier in identifiers:
            if identifier in pinned_spares:
                expected_spares = pinned_spares[identifier]
                break

        report = analyze(array, expected_spares, args)
        reports.append(report)
        state = lib.base.get_worst(state, report['state'])
        faulty_devices += len(report['faulty'])
        spare_devices += len(report['spare'])
        missing_spare_devices += report['spares_missing']
        if report['broken']:
            broken.append(report)
        elif report['inactive']:
            inactive.append(report)
        elif report['journal_lost']:
            journal_lost.append(report)
        elif report['degraded']:
            degraded.append(report)
        elif report['faulty_members']:
            replaced.append(report)
        if report['member_states']:
            flagged_members.append(report)
        if report['frozen']:
            frozen.append(report)
        if report['mismatch']:
            mismatched.append(report)
        if report['spares_missing']:
            short_of_spares.append(report)
        if report['syncing']:
            syncing.append(report)
        table_data.append(
            {
                'array': name,
                'devices': report['devices'],
                'flagged': report['flagged'],
                'label': report['label'],
                'level': report['level'],
                'metadata': report['metadata'],
                'mismatch': str(report['mismatch']),
                'state': (
                    f'{report["status"]}'
                    f'{lib.base.state2str(report["state"], prefix=" ")}'
                ),
                'sync': report['progress'],
            }
        )

    # build the message
    if not arrays:
        if has_md:
            msg = (
                'No software RAID array on this host, so nothing could be checked.\n'
                f'{NO_ARRAYS_HELP}'
            )
        else:
            msg = (
                'This kernel has no software RAID support loaded, so there is no array '
                f'on this host.\n{NO_MD_HELP}'
            )
        state = lib.base.get_worst(state, lib.base.str2state(args.NO_ARRAYS_SEVERITY))
    elif not table_data:
        count = len(arrays)
        msg = (
            f'{count} software RAID '
            f'{lib.txt.pluralize("array", count, ",s")} on this host, '
            f'filtered out by --match or --ignore.'
        )
        state = lib.base.get_worst(state, lib.base.str2state(args.NO_MATCH_SEVERITY))
    else:
        checked = len(table_data)
        # Everything worth saying about one array goes into one clause, and a running
        # resync, recovery, reshape or check leads it: whether the array is already
        # healing decides what an administrator does next, and the first line is the
        # only part of the output a service list and a notification show.
        headline = []
        for report in reports:
            parts = []
            if report['syncing']:
                parts.append(report['progress'])
            elif report['frozen']:
                parts.append('sync frozen')
            # What the array itself is: one clause, because the four are exclusive.
            # A lost write journal is a second axis and gets its own clause, since an
            # array can be degraded and unwritable at the same time.
            if report['journal_lost']:
                parts.append(
                    'write journal lost, so writes to this array fail and buffered '
                    'ones are thrown away'
                )
            if report['broken']:
                parts.append('broken')
            elif report['inactive']:
                parts.append('inactive')
            elif report['degraded']:
                parts.append(f'degraded, running on {report["counted"]} devices')
            elif report['faulty_members']:
                count = len(report['faulty_members'])
                parts.append(
                    f'complete again but still carrying {count} failed '
                    f'{lib.txt.pluralize("member", count, ",s")} '
                    f'({", ".join(report["faulty_members"])})'
                )
            for item in report['member_states']:
                parts.append(
                    f'{item["device"]} '
                    f'{" and ".join(MEMBER_STATE_NAMES[word] for word in item["states"])}'
                )
            if report['spares_missing']:
                parts.append(
                    f'short of {report["spares_missing"]} spare '
                    f'{lib.txt.pluralize("device", report["spares_missing"], ",s")}'
                )
            if report['mismatch']:
                parts.append(
                    f'{report["mismatch"]} inconsistent '
                    f'{lib.txt.pluralize("sector", report["mismatch"], ",s")} '
                    f'from the last consistency check'
                )
            if parts:
                headline.append(f'{report["name"]}: {", ".join(parts)}')
        if headline:
            msg = f'{". ".join(headline)}.'
        else:
            msg = (
                f'{checked} software RAID '
                f'{lib.txt.pluralize("array", checked, ",s")}, '
                f'{lib.txt.pluralize("", checked, "intact,all of them intact")}.'
            )
        if any(item['level'] in MIRROR_LEVELS for item in mismatched):
            msg += f'\n{MIRROR_MISMATCH_HELP}'
        parity = [item for item in mismatched if item['level'] in PARITY_LEVELS]
        if parity:
            msg += '\n' + parity_mismatch_help([item['name'] for item in parity])
        for item in broken:
            msg += f'\n{broken_help(item["name"])}'
        for item in inactive:
            msg += f'\n{inactive_help(item["name"])}'
        for item in degraded:
            msg += f'\n{remediation(item)}'
        for item in journal_lost:
            msg += f'\n{journal_help(item["name"], item["journal_faulty"])}'
        for item in replaced:
            msg += f'\n{replaced_help(item["name"], item["faulty_members"])}'
        for item in flagged_members:
            msg += f'\n{member_help(item["name"], item["member_states"])}'
        for item in short_of_spares:
            msg += '\n' + spares_help(
                item['name'], len(item['spare']), item['expected_spares']
            )
        # A degraded array has already been told that its sync is frozen, as part of
        # what to do about the missing member.
        for item in frozen:
            if item not in degraded:
                msg += f'\n{frozen_help(item["name"])}'

    perfdata += lib.base.get_perfdata('arrays', len(table_data), uom=None, _min=0)
    perfdata += lib.base.get_perfdata(
        'arrays_broken',
        len(broken),
        uom=None,
        warn='0',
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'arrays_degraded',
        len(degraded),
        uom=None,
        warn='0',
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'arrays_inactive',
        len(inactive),
        uom=None,
        warn='0',
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'arrays_journal_lost',
        len(journal_lost),
        uom=None,
        warn='0',
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'arrays_sync_frozen',
        len(frozen),
        uom=None,
        _min=0,
    )
    perfdata += lib.base.get_perfdata('arrays_syncing', len(syncing), uom=None, _min=0)
    perfdata += lib.base.get_perfdata(
        'devices_faulty',
        faulty_devices,
        uom=None,
        warn='0',
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'devices_flagged',
        sum(len(item['member_states']) for item in reports),
        uom=None,
        warn='0',
        _min=0,
    )
    perfdata += lib.base.get_perfdata('devices_spare', spare_devices, uom=None, _min=0)
    perfdata += lib.base.get_perfdata(
        'devices_spare_missing',
        missing_spare_devices,
        uom=None,
        warn='0',
        _min=0,
    )
    for report in reports:
        label = re.sub(r'\W+', '_', report['name'])
        perfdata += lib.base.get_perfdata(
            f'{label}_mismatch_cnt',
            report['mismatch'],
            uom=None,
            _min=0,
        )
        # Only while something runs. An idle array has no progress and no speed, and a
        # line drawn through the gap would have to invent both.
        if report['sync_percent'] is not None:
            perfdata += lib.base.get_perfdata(
                f'{label}_sync_percent',
                report['sync_percent'],
                uom='%',
                _min=0,
                _max=100,
            )
        if report['sync_speed'] is not None:
            perfdata += lib.base.get_perfdata(
                f'{label}_sync_bytes_per_second',
                report['sync_speed'],
                uom='B',
                _min=0,
            )

    # build table output
    if table_data:
        if args.LENGTHY:
            keys = [
                'array',
                'label',
                'level',
                'devices',
                'metadata',
                'flagged',
                'mismatch',
                'sync',
                'state',
            ]
            headers = [
                'Array',
                'Name',
                'Level',
                'Devices',
                'Metadata',
                'Flagged Members',
                'Mismatches',
                'Sync',
                'State',
            ]
        else:
            keys = ['array', 'level', 'devices', 'state']
            headers = ['Array', 'Level', 'Devices', 'State']
        msg += '\n\n' + lib.base.get_table(table_data, keys, header=headers)

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