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

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

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

import argparse
import sys

import lib.args
import lib.base
import lib.disk
import lib.lftest
import lib.txt
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Checks for unexpectedly read-only mounted filesystems, such as a root filesystem
that switched to read-only due to disk errors. Ignores ramfs, squashfs (snapd),
and other pseudo-filesystems by default. Additional mount points can be excluded via
--extend-ignore, additional filesystem types via --extend-ignore-fstype.
Alerts when a read-only filesystem is detected that should be writable."""

DEFAULT_IGNORE = [
    '/dev/loop',
    '/proc',
    '/run/credentials',
    '/snap',
    '/sys/fs',
    # per-container mounts; SUSE's Docker mounts a read-only secrets tmpfs into every
    # container (verified with docker 26.1.5-ce on openSUSE Leap 15.5)
    '/var/lib/docker/containers',
]

# Read-only by design. iso9660 stays out on purpose: a mounted ISO image should be
# noticed and unmounted, especially after a reboot.
DEFAULT_IGNORE_FSTYPE = [
    'ramfs',
    'squashfs',
]


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(
        '--extend-ignore',
        help='Mount point prefix to ignore, '
        'appended to the default or the `--ignore` list. '
        'Case-sensitive. '
        'Can be specified multiple times. '
        'Example: `--extend-ignore=/cvmfs`.',
        dest='EXTEND_IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--extend-ignore-fstype',
        help='Filesystem type to ignore, '
        'appended to the default or the `--ignore-fstype` list. '
        'Case-sensitive. '
        'Can be specified multiple times. '
        'Example: `--extend-ignore-fstype=iso9660`.',
        dest='EXTEND_IGNORE_FSTYPE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--ignore',
        help='Mount point prefix to ignore. '
        'All mount points starting with this value will be skipped. '
        'Replaces the default list, use `--extend-ignore` to append to it. '
        'Case-sensitive. '
        'Can be specified multiple times. '
        'Example: `--ignore=/sys/fs` ignores `/sys/fs/cgroup` and similar. '
        f'Default: {", ".join(DEFAULT_IGNORE)}.',
        action='append',
        dest='IGNORE',
        default=None,
    )

    parser.add_argument(
        '--ignore-fstype',
        help='Filesystem type to ignore, as shown in the third column of '
        '`/proc/mounts`. '
        'Replaces the default list, use `--extend-ignore-fstype` to append to it. '
        'Case-sensitive. '
        'Can be specified multiple times. '
        'Example: `--ignore-fstype=squashfs --ignore-fstype=lustre`. '
        f'Default: {", ".join(DEFAULT_IGNORE_FSTYPE)}.',
        dest='IGNORE_FSTYPE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    args, _ = parser.parse_known_args()
    return args


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.EXTEND_IGNORE is None:
        args.EXTEND_IGNORE = []
    if args.IGNORE is None:
        args.IGNORE = DEFAULT_IGNORE
    # build a new list, `+=` would modify DEFAULT_IGNORE in place
    args.IGNORE = args.IGNORE + args.EXTEND_IGNORE
    if args.EXTEND_IGNORE_FSTYPE is None:
        args.EXTEND_IGNORE_FSTYPE = []
    if args.IGNORE_FSTYPE is None:
        args.IGNORE_FSTYPE = DEFAULT_IGNORE_FSTYPE
    # build a new list, `+=` would modify DEFAULT_IGNORE_FSTYPE in place
    args.IGNORE_FSTYPE = args.IGNORE_FSTYPE + args.EXTEND_IGNORE_FSTYPE

    # fetch data
    if args.TEST is None:
        # get the values the normal way
        mounts = lib.base.coe(lib.disk.read_file('/proc/mounts'))
    else:
        # do not call the command, put in test data
        stdout, _, _ = lib.lftest.test(args.TEST)
        mounts = stdout

    # init some vars
    cnt = 0
    ros = []  # read-only mount points

    # analyze data
    for mount in mounts.splitlines():
        mnt = mount.split()
        if len(mnt) != 6:
            lib.base.oao(
                'Unexpected format of /proc/mounts.',
                STATE_UNKNOWN,
            )
        mount_spec = mnt[0]
        mount_point = mnt[1]
        mount_type = mnt[2]

        if mount_type in args.IGNORE_FSTYPE:
            continue
        mount_options = mnt[3]
        if any(mount_point.startswith(item) for item in args.IGNORE):
            continue
        cnt += 1
        # Compare whole options, not a substring: an option value such as
        # `subvol=/rwdata` would otherwise hide a read-only mount. The kernel
        # always prints `ro` or `rw` first and escapes commas inside values
        # (fs/proc_namespace.c show_vfsmnt(), include/linux/seq_file.h
        # seq_show_option() in the Linux 7.3 source; verified with a read-only
        # btrfs subvolume on Fedora 44, kernel 6.19).
        if 'rw' in mount_options.split(','):
            continue
        ros.append(f'{mount_spec} on {mount_point} (type {mount_type})')

    # build the message
    if len(ros) > 0:
        msg = (
            f'{len(ros)} read-only mount {lib.txt.pluralize("point", len(ros))} found: '
        )
        if len(ros) == 1:
            # show mount point info on first line when there is only one hit
            msg += ros[0]
        else:
            for item in ros:
                msg += f'\n* {item}'
        state = STATE_WARN
    else:
        msg = (
            f'Everything is ok. {cnt} mount {lib.txt.pluralize("point", cnt)} checked.'
        )
        state = STATE_OK

    # over and out
    lib.base.oao(msg, state, always_ok=args.ALWAYS_OK)


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
