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

import lib.args
import lib.base
import lib.cache
import lib.disk
import lib.time
import lib.url
import lib.version
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN

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

DESCRIPTION = """Checks the installed GitLab 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.
Optionally also asks the public GitLab Version Check service whether the installed
version has a security-relevant update available."""

DEFAULT_CHECK_MAJOR = False
DEFAULT_CHECK_MINOR = False
DEFAULT_CHECK_PATCH = False
DEFAULT_CHECK_SECURITY = False
DEFAULT_INSECURE = False
DEFAULT_NO_PROXY = False
DEFAULT_OFFSET_EOL = -30  # days
DEFAULT_PATH = '/opt/gitlab/version-manifest.txt'
DEFAULT_TIMEOUT = 8
DEFAULT_UNREACHABLE_SEVERITY = 'ok'


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(
        '--check-security',
        help=lib.args.help('--check-security'),
        dest='CHECK_SECURITY',
        action='store_true',
        default=DEFAULT_CHECK_SECURITY,
    )

    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="Full path to GitLab's `version-manifest.txt`. Default: %(default)s",
        dest='PATH',
        default=DEFAULT_PATH,
    )

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

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

    args, _ = parser.parse_known_args()
    return args


def get_installed_version(args):
    # The first line of `version-manifest.txt` is "<project name> <build version>",
    # written by omnibus-gitlab's config/software/version-manifest.rb. The project
    # name is `gitlab-ce`, `gitlab-ee`, `gitlab-fips` or `gitlab-jh` (`gitlab` before
    # 2015). Release builds carry a plain `17.3.1` (also for RC tags like
    # `17.3.0+rc42.ee.0`); nightly, internal and older builds append suffixes like
    # `+rnightly.<pipeline>.<sha>`, `.<pipeline>`, `+internal1` or `-ee.omnibus`,
    # which stay part of the captured version up to the end of the line. `\A` pins
    # the match to the first line, so the component table below it (`gitlab-rails`,
    # `gitlab-shell`, ...) is never read. Verified against the
    # published version-manifest.json of gitlab-ce/-ee 15.10.0, 17.3.1 and 19.4.1
    # and omnibus-gitlab 12a6ea3c3 (gitlab-org/omnibus 9.0.19.7).
    success, result = lib.disk.grep_file(
        args.PATH,
        r'\Agitlab(?:-(?:ce|ee|fips|jh))? (\d+\.\d+\S*)',
    )
    if not success:
        return ''
    return result


def check_security(
    installed_version, insecure, no_proxy, proxy, timeout, unreachable_severity
):
    """Ask the public GitLab Version Check service whether the installed version
    has a security-relevant update available. Cached for 4 hours.

    Returns `(state, note)`, where the note is empty while there is nothing to say. An
    unreachable service is named in the note either way and reported at
    `unreachable_severity`, the same way check_eol() treats endoflife.date: an operator
    who accepts that the lookup can fail still has to be able to see that it did.
    """
    cache_key = f'gitlab-security:{installed_version}'
    try:
        cached = lib.cache.get(cache_key, filename='linuxfabrik-lib-version.db')
        data = json.loads(cached) if cached else None
    except (json.JSONDecodeError, TypeError):
        data = None

    if not data:
        info = base64.b64encode(
            json.dumps({'version': installed_version}).encode('utf-8')
        ).decode('ascii')
        endpoint = f'https://version.gitlab.com/check.json?gitlab_info={info}'
        success, data = lib.url.fetch_json(
            endpoint,
            insecure=insecure,
            no_proxy=no_proxy,
            proxy=proxy,
            timeout=timeout,
        )
        if not success or not isinstance(data, dict):
            return (
                lib.base.str2state(unreachable_severity),
                'version.gitlab.com unreachable',
            )
        lib.cache.set(
            cache_key,
            json.dumps(data),
            expire=lib.time.now() + 4 * 3600,
            filename='linuxfabrik-lib-version.db',
        )

    # `severity` is one of `success` ("Up to date"), `warning` ("Update available") or
    # `danger` ("Update ASAP"), see GitLab's
    # app/assets/javascripts/gitlab_version_check/components/
    # gitlab_version_check_badge.vue. Only `danger` means a security release.
    # `warning` is also returned for the latest patch of an older but still
    # maintained minor (19.4.3 while 19.5.1 is out), so it is no alert here; newer
    # releases are what --check-minor and --check-patch are for.
    # Verified against version.gitlab.com on 2026-10-23: 19.5.1 `success`, 19.4.3
    # `warning`, 18.10.3 `danger`, 19.5.0 `danger` with `critical_vulnerability`.
    severity = data.get('severity', 'success')
    critical = bool(data.get('critical_vulnerability', False))
    if critical:
        return STATE_CRIT, 'critical security update available'
    if severity == 'danger':
        return STATE_CRIT, 'security update available'
    return STATE_OK, ''


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
    installed_version = get_installed_version(args)
    if not installed_version:
        lib.base.cu(
            f'GitLab not found at `{args.PATH}`. '
            'Use --path to point at the version manifest.'
        )

    # 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/gitlab.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,
    )

    sec_prefix = ''
    state_suffix = ''
    if args.CHECK_SECURITY:
        sec_state, sec_msg = check_security(
            installed_core,
            insecure=args.INSECURE,
            no_proxy=args.NO_PROXY,
            proxy=args.PROXY,
            timeout=args.TIMEOUT,
            unreachable_severity=args.UNREACHABLE_SEVERITY,
        )
        state = lib.base.get_worst(state, sec_state)
        if sec_msg:
            sec_prefix = f', {sec_msg}'
        # collapse all inline state markers into a single overall marker at the
        # end, so EOL and security do not each stamp their own [WARNING]
        eol = re.sub(r' \[(?:OK|WARNING|CRITICAL|UNKNOWN)\]', '', eol)
        state_suffix = lib.base.state2str(state, prefix=' ')

    # build the message
    msg = f'GitLab v{installed_version}{sec_prefix} ({eol}){state_suffix}'

    perfdata += lib.base.get_perfdata(
        'gitlab-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()
