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

import lib.args
import lib.base
import lib.container
import lib.disk
import lib.lftest
import lib.time
import lib.version
from lib.globals import STATE_UNKNOWN

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

DESCRIPTION = """Checks the installed Mastodon version against the endoflife.date API and alerts if the
version is end-of-life or if newer major, minor, or patch releases are available. By
default, alerts 30 days before the official EOL date. The offset is configurable. Reads
the version of a Docker Compose installation, or of the running Mastodon container of a
rootless Podman installation. Requires root or sudo."""

DEFAULT_CHECK_MAJOR = False
DEFAULT_CHECK_MINOR = False
DEFAULT_CHECK_PATCH = False
DEFAULT_INSECURE = False
DEFAULT_NO_PROXY = False
DEFAULT_OFFSET_EOL = -30  # days
DEFAULT_PATH = '/home/mastodon'
DEFAULT_TIMEOUT = 8
DEFAULT_UNREACHABLE_SEVERITY = 'ok'
DEFAULT_USER = 'mastodon'

# The `image:` line of the web and sidekiq services in `live/docker-compose.yml`. Upstream
# pins it as `tootsuite/mastodon:vX.Y.Z` (v3.3.1 to v3.5.5) and as
# `ghcr.io/mastodon/mastodon:vX.Y.Z` (since v3.5.7), pre-releases as `vX.Y.Z-beta.N` /
# `vX.Y.Z-rc.N`; some releases ship the image untagged, which is left to the container
# lookup. Anchored on the start of the line so a commented-out image is skipped, and the
# version ends at the tag, so a trailing quote, comment, CR or digest is not part of it.
# The streaming service runs `mastodon/mastodon-streaming:` and does not match. Verified
# against Mastodon v1.2 to v4.7.3 (docker-compose.yml).
COMPOSE_IMAGE_REGEX = re.compile(
    r'^[ \t]*image:[ \t]*[\'"]?(?:\S*/)?(?:mastodon|tootsuite)/mastodon:v'
    r'(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)(?![\w.+-])',
    re.MULTILINE,
)
# The web and sidekiq containers run this image, published as `tootsuite/mastodon` and
# `ghcr.io/mastodon/mastodon` like in COMPOSE_IMAGE_REGEX; the streaming server has an
# image of its own (`mastodon/mastodon-streaming`) without the Rails code. The image is
# matched after the space that separates it from the container ID in `podman ps`, or
# after the registry host.
MASTODON_IMAGE_REGEX = re.compile(r'[ /](?:mastodon|tootsuite)/mastodon:')
# The image carries no version label, and its tag is often `latest`. The version lives in
# the Rails code only, one method per part, each returning a bare integer literal. A
# pre-release lives in `flags` (until v4.2) or `default_prerelease` (since v4.3), both
# overridable by environment, and is not read. Verified against Mastodon v1.3 to v4.7.3
# (lib/mastodon/version.rb) and ghcr.io/mastodon/mastodon:latest.
VERSION_FILE = '/opt/mastodon/lib/mastodon/version.rb'
VERSION_PART_REGEX = re.compile(r'def (major|minor|patch)\s+(\d+)\s+end')


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(
        '--check-major',
        help=lib.args.help('--check-major'),
        dest='CHECK_MAJOR',
        action='store_true',
        default=DEFAULT_CHECK_MAJOR,
    )

    parser.add_argument(
        '--check-minor',
        help=lib.args.help('--check-minor'),
        dest='CHECK_MINOR',
        action='store_true',
        default=DEFAULT_CHECK_MINOR,
    )

    parser.add_argument(
        '--check-patch',
        help=lib.args.help('--check-patch'),
        dest='CHECK_PATCH',
        action='store_true',
        default=DEFAULT_CHECK_PATCH,
    )

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

    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(
        '--offset-eol',
        help=lib.args.help('--offset-eol') + ' Default: %(default)s days',
        dest='OFFSET_EOL',
        type=int,
        default=DEFAULT_OFFSET_EOL,
    )

    parser.add_argument(
        '--path',
        help='Local path to your Mastodon installation. Default: %(default)s',
        dest='PATH',
        default=DEFAULT_PATH,
    )

    parser.add_argument(
        '--proxy',
        help=lib.args.help('--proxy'),
        dest='PROXY',
        default=None,
    )

    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(
        '--unreachable-severity',
        help=lib.args.help('--unreachable-severity') + ' Default: %(default)s',
        dest='UNREACHABLE_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_UNREACHABLE_SEVERITY,
    )

    parser.add_argument(
        '--user',
        help='Owner of the rootless Podman containers Mastodon runs in, asked when '
        'there is no Docker Compose installation at `--path`. '
        "Podman keeps each user's rootless containers in that user's own storage, so "
        'the check runs podman as that user. '
        'Requires the right to `sudo -u <user>` (root has this by default), and the '
        'user needs a subordinate UID range in /etc/subuid, as rootless Podman does. '
        'Default: %(default)s',
        dest='USER',
        default=DEFAULT_USER,
    )

    args, _ = parser.parse_known_args()
    return args


def get_compose_version(path):
    """Return the version a Docker Compose installation at `path` pins its image to.

    Returns `(True, version)`, or `(True, False)` where there is no such installation.
    """
    # We won't tinker with the ultra-slow tootctl command, which runs as the mastodon user and
    # requires a perfectly working ruby environment, including some extra environment vars,
    # which will cause problems when running this plugin in various nagios or icinga environments.
    # Instead, we do a low-level hack and just check the docker-compose file for the current
    # version - until someone has a robust solution.
    compose_file = os.path.join(path, 'live', 'docker-compose.yml')
    # This check runs as root, so the file is read only if it really lies inside
    # --path, not through a symlink placed there. A file that is not there, or
    # not one that may be read, means Mastodon is not installed here, which the
    # caller says in one sentence; the two cases answer alike, so the check does
    # not tell which files exist elsewhere.
    success, content = lib.disk.read_file(
        compose_file, allowed_roots=[path], nofollow=True, max_bytes=1024 * 1024
    )
    if not success:
        return (True, False)
    version = COMPOSE_IMAGE_REGEX.search(content)
    if version:
        return (True, version.group(1))
    return (True, False)


def get_container_version_file(user, timeout):
    """Read the version file out of the running Mastodon container of `user`.

    Returns `(True, content)`, `(True, False)` where `user` runs no Mastodon container,
    or `(False, (message, state))` where Podman could not be asked.
    """
    started = lib.time.now(as_type='float')
    success, result = lib.container.run(
        ['podman', 'ps', '--no-trunc', '--format', '{{.ID}} {{.Image}}'],
        timeout,
        started,
        run_as=user,
    )
    if not success:
        return (False, result)
    stdout, stderr, retc = result
    # A version check cannot say anything about the version when Podman does not
    # answer, and an engine outage is not what it alerts on, so a failure that is
    # not a refused permission is UNKNOWN as well, not the CRIT of the podman-* checks.
    if retc != 0:
        return (
            False,
            lib.container.get_engine_error(
                stderr, stdout, fallback_state=STATE_UNKNOWN
            ),
        )
    container_id = next(
        (
            line.split()[0]
            for line in stdout.splitlines()
            if MASTODON_IMAGE_REGEX.search(line)
        ),
        None,
    )
    if container_id is None:
        return (True, False)
    success, result = lib.container.run(
        ['podman', 'exec', container_id, 'cat', VERSION_FILE],
        timeout,
        started,
        run_as=user,
    )
    if not success:
        return (False, result)
    stdout, stderr, retc = result
    if retc != 0:
        return (
            False,
            lib.container.get_engine_error(
                stderr, stdout, fallback_state=STATE_UNKNOWN
            ),
        )
    return (True, stdout)


def parse_version_file(content):
    """Return `major.minor.patch` from Mastodon's version file, or `False`."""
    parts = dict(VERSION_PART_REGEX.findall(content or ''))
    if not all(part in parts for part in ('major', 'minor', 'patch')):
        return False
    return f'{parts["major"]}.{parts["minor"]}.{parts["patch"]}'


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)

    # fetch data
    if args.TEST is None:
        installed_version = lib.base.coe(get_compose_version(args.PATH))
        if not installed_version:
            success, result = get_container_version_file(args.USER, args.TIMEOUT)
            if not success:
                # --always-ok suppresses what would alert somebody. An UNKNOWN (no
                # permission, client missing) says the check is deployed wrong and
                # stays.
                lib.base.oao(
                    *result, always_ok=args.ALWAYS_OK and result[1] != STATE_UNKNOWN
                )
            installed_version = parse_version_file(result)
    else:
        # do not call podman, put in test data
        stdout, _, _ = lib.lftest.test(args.TEST)
        installed_version = parse_version_file(stdout)
    if not installed_version:
        lib.base.cu(
            f'Mastodon not found: no Docker Compose installation at `{args.PATH}`, '
            f'and no running Mastodon container of user `{args.USER}`. '
            'Use --path or --user to point at the installation.'
        )

    # init some vars
    perfdata = ''
    # lib.version reads a suffix as version parts (4.5rc1 -> 4.51), use the core
    installed_core = re.match(r'\d+(?:\.\d+)*', installed_version).group()

    # analyze data
    state, eol = lib.version.check_eol(
        'https://endoflife.date/api/mastodon.json',
        installed_core,
        offset_eol=args.OFFSET_EOL,
        check_major=args.CHECK_MAJOR,
        check_minor=args.CHECK_MINOR,
        check_patch=args.CHECK_PATCH,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        proxy=args.PROXY,
        timeout=args.TIMEOUT,
        unreachable_severity=args.UNREACHABLE_SEVERITY,
    )

    # build the message
    msg = f'Mastodon v{installed_version} ({eol})'

    perfdata += lib.base.get_perfdata(
        'mastodon-version',
        lib.version.version2float(installed_core),
        uom=None,
        _min=0,
    )

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