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

import lib.args
import lib.base
import lib.db_sqlite
import lib.disk
import lib.icinga
import lib.time
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Scans a logfile for matching patterns or regular expressions and alerts based on the
number of matches found. Only the lines added since the previous run are scanned, and the
whole file is rescanned whenever it was rotated, truncated or rewritten in place. Supports
Icinga acknowledgement integration to suppress repeated alerts for known issues.
Configurable alarm duration limits how long matches trigger alerts.

`--filename` accepts time macros, so logfiles whose name contains the current date
(`20260422.log`, `app-2026-04-22.log`, etc.) can be monitored directly. `{today}` /
`{yesterday}` resolve tolerantly: compact (`YYYYMMDD`) first, ISO 8601 (`YYYY-MM-DD`)
as fallback if the compact file does not exist. Read offset and pending matches carry
over when the filename changes on the next day, no wrapper script needed.

Requires root or sudo."""

# The plugin runs as root via sudo on many hosts, so it only opens files that
# resolve inside the system log directory. To monitor logs stored elsewhere,
# bind-mount that location under /var/log (see the README); a symlink is
# rejected because the confinement resolves symlinks.
ALLOWED_LOG_ROOTS = ['/var/log']

DEFAULT_ALARM_DURATION = 60  # minutes (1 hour)
DEFAULT_CRIT = 1
DEFAULT_ICINGA_CALLBACK = False
DEFAULT_INSECURE = True
DEFAULT_NO_PROXY = False
DEFAULT_NO_SUMMARY = False
DEFAULT_TIMEOUT = 5
DEFAULT_WARN = 1


def is_rewritten(filename, old_fingerprint):
    """Tell whether the head of a logfile still matches the fingerprint stored
    on the previous run.

    The head is the part of a logfile that appending does not touch, so it
    identifies the file: it only changes if the logfile has become a different
    one. Hashes exactly as many bytes as were hashed back then, so that
    appending to a logfile whose head was shorter than the fingerprint does not
    look like a rewrite.
    """
    length = old_fingerprint['length']
    if not length:
        # The logfile was empty on the previous run, so there is nothing to
        # compare against - and nothing to miss either, because a read offset
        # of 0 stays valid no matter what was written since.
        return (True, False)
    success, result = lib.disk.get_fingerprint(filename, length=length)
    if not success:
        return (False, result)
    fingerprint, hashed = result
    if hashed < length:
        # the file no longer holds the bytes we hashed last time, so it must
        # have been truncated
        return (True, True)
    return (True, fingerprint != old_fingerprint['fingerprint'])


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(
        '--alarm-duration',
        help='Duration in minutes for how long new matches trigger an alert. '
        'Overwritten by `--icinga-callback`. '
        'Default: %(default)s',
        dest='ALARM_DURATION',
        type=int,
        default=DEFAULT_ALARM_DURATION,
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for the number of found critical matches. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-pattern',
        help='Any line containing this pattern will count as a critical. '
        'Can be specified multiple times.',
        action='append',
        default=None,
        dest='CRIT_PATTERN',
    )

    parser.add_argument(
        '--critical-regex',
        help='Any line matching this Python regex will count as a critical. '
        'Can be specified multiple times.',
        action='append',
        dest='CRIT_REGEX',
        default=None,
    )

    parser.add_argument(
        '--filename',
        help='Path to the logfile. '
        'Supports time macros that are expanded on every run: '
        '`{today}` / `{yesterday}` first try the compact form `YYYYMMDD`, '
        'then fall back to `YYYY-MM-DD` if that file does not exist. '
        '`{%%Y}`, `{%%y}`, `{%%m}`, `{%%d}`, `{%%H}`, `{%%M}`, `{%%S}` '
        'render the matching strftime component of the current time. '
        'Example: `/var/log/app/{today}.log`. '
        'Example: `/var/log/app/app-{today}.log`. '
        'Example: `/var/log/app/{%%Y}{%%m}{%%d}.log`.',
        dest='FILENAME',
        required=True,
        type=str,
    )

    parser.add_argument(
        '--icinga-callback',
        help='Get the service acknowledgement from Icinga. '
        'Overwrites `--alarm-duration`. '
        'Default: %(default)s',
        dest='ICINGA_CALLBACK',
        action='store_true',
        default=DEFAULT_ICINGA_CALLBACK,
    )

    parser.add_argument(
        '--icinga-password',
        help='Icinga API password.',
        dest='ICINGA_PASSWORD',
    )

    parser.add_argument(
        '--icinga-service-name',
        help='Unique name of the service using this check within Icinga. '
        'Take it from the `__name` service attribute. '
        'Example: `icinga-server!my-service-name`.',
        dest='ICINGA_SERVICE_NAME',
    )

    parser.add_argument(
        '--icinga-url',
        help='Icinga API URL. Example: `https://icinga-server:5665`.',
        dest='ICINGA_URL',
    )

    parser.add_argument(
        '--icinga-username',
        help='Icinga API username.',
        dest='ICINGA_USERNAME',
    )

    parser.add_argument(
        '--ignore-pattern',
        help='Any line containing this pattern will be ignored. '
        'Case-sensitive. '
        'Can be specified multiple times.',
        action='append',
        default=None,
        dest='IGNORE_PATTERN',
    )

    parser.add_argument(
        '--ignore-regex',
        help=lib.args.help('--ignore-regex'),
        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(
        '--no-insecure',
        help=lib.args.help('--no-insecure'),
        dest='INSECURE',
        action='store_false',
        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(
        '--suppress-lines',
        help='Suppress the found lines in the output and only report the number of findings.',
        dest='SUPPRESS_OUTPUT',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the number of found warning matches. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-pattern',
        help='Any line containing this pattern will count as a warning. '
        'Can be specified multiple times.',
        action='append',
        default=None,
        dest='WARN_PATTERN',
    )

    parser.add_argument(
        '--warning-regex',
        help='Any line matching this Python regex will count as a warning. '
        'Can be specified multiple times.',
        action='append',
        dest='WARN_REGEX',
        default=None,
    )

    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.CRIT_PATTERN is None:
        args.CRIT_PATTERN = []
    if args.CRIT_REGEX is None:
        args.CRIT_REGEX = []
    if args.IGNORE_PATTERN is None:
        args.IGNORE_PATTERN = []
    if args.IGNORE_REGEX is None:
        args.IGNORE_REGEX = []
    if args.WARN_PATTERN is None:
        args.WARN_PATTERN = []
    if args.WARN_REGEX is None:
        args.WARN_REGEX = []

    # Expand time macros for filesystem access only. args.FILENAME stays the
    # DB key so offset and pending matches survive the daily filename change
    # (issue #678). For {today} / {yesterday}, try the compact form
    # (`20260422`) first because that is what most rotated logfiles use, and
    # fall back to ISO 8601 (`2026-04-22`) if no compact file exists.
    scan_path = lib.time.macro2timestr(args.FILENAME, format='%Y%m%d')
    if not os.path.exists(scan_path):
        iso_path = lib.time.macro2timestr(args.FILENAME, format='%Y-%m-%d')
        if iso_path != scan_path and os.path.exists(iso_path):
            scan_path = iso_path

    # Refuse to open anything outside the system log directory: the plugin runs
    # as root via sudo, so an unconfined path would be an arbitrary root file
    # read. is_within() resolves symlinks and `..`, so neither can escape. The
    # plugin's own unit-test/ directory is also allowed so the fixture-based
    # tests work; on a deployed host that directory does not exist next to the
    # flat, root-owned plugin, so it grants nothing (and needs no environment
    # flag that a misconfigured sudoers could leak).
    allowed_roots = [
        *ALLOWED_LOG_ROOTS,
        os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), 'unit-test'),
    ]
    if not lib.disk.is_within(scan_path, allowed_roots):
        lib.base.cu(
            f'Refusing to read "{scan_path}": resolved path is outside the allowed log '
            f'directory {ALLOWED_LOG_ROOTS[0]}; bind-mount it under {ALLOWED_LOG_ROOTS[0]} '
            f'if intended.'
        )

    try:
        file_stat = os.stat(scan_path)
    except FileNotFoundError as e:
        # no traceback wanted, so using oao() instead of cu()
        lib.base.oao(f'{e.strerror}: "{scan_path}"', STATE_UNKNOWN)

    if not any(
        (args.WARN_PATTERN, args.WARN_REGEX, args.CRIT_PATTERN, args.CRIT_REGEX)
    ):
        lib.base.cu('At least one pattern or regex is required.')

    if args.ICINGA_CALLBACK and not all(
        (
            args.ICINGA_URL,
            args.ICINGA_PASSWORD,
            args.ICINGA_USERNAME,
            args.ICINGA_SERVICE_NAME,
        )
    ):
        lib.base.cu(
            '`--icinga-callback` requires `--icinga-url`, `--icinga-password`, `--icinga-username` and `--icinga-service-name`.'
        )

    # Derive a short, stable hash over every argument that influences which
    # lines this check flags. This is used to give each unique combination
    # of patterns/regexes its own state DB, so two Icinga services on the
    # same logfile with different patterns no longer trample each other's
    # read offset or match history (issue #698).
    instance_payload = json.dumps(
        {
            'crit_pattern': sorted(args.CRIT_PATTERN),
            'crit_regex': sorted(args.CRIT_REGEX),
            'ignore_pattern': sorted(args.IGNORE_PATTERN),
            'ignore_regex': sorted(args.IGNORE_REGEX),
            'warn_pattern': sorted(args.WARN_PATTERN),
            'warn_regex': sorted(args.WARN_REGEX),
        },
        sort_keys=True,
    ).encode('utf-8')
    instance_hash = hashlib.sha256(instance_payload).hexdigest()[:10]

    basename = os.path.basename(args.FILENAME)
    db_filename = (
        f'linuxfabrik-monitoring-plugins-logfile-{basename}-{instance_hash}.db'
    )
    # create the db table
    conn = lib.base.coe(
        lib.db_sqlite.connect(
            filename=db_filename,
        )
    )
    definition = """
        filename TEXT NOT NULL PRIMARY KEY,
        offset INTEGER NOT NULL,
        inode TEXT NOT NULL
    """
    lib.base.coe(lib.db_sqlite.create_table(conn, definition, table='file_stats'))
    # The fingerprint lives in its own table instead of an additional file_stats
    # column: a DB written by an older release then simply has no fingerprint
    # row yet, and the stored read offset survives the upgrade. Extending
    # file_stats would require a schema migration, and rebuilding it from
    # scratch would re-report every match the logfile still holds.
    definition = """
        filename TEXT NOT NULL PRIMARY KEY,
        fingerprint TEXT NOT NULL,
        length INTEGER NOT NULL
    """
    lib.base.coe(
        lib.db_sqlite.create_table(conn, definition, table='file_fingerprints')
    )
    definition = """
        filename TEXT NOT NULL,
        state INTEGER NOT NULL,
        line TEXT NOT NULL,
        found_at TIMESTAMP NOT NULL
    """
    lib.base.coe(lib.db_sqlite.create_table(conn, definition, table='matching_lines'))

    current_inode = str(file_stat.st_ino)
    current_size = file_stat.st_size
    # Fingerprint the file before scanning it: if it gets rewritten in between,
    # the stored fingerprint is the older one, so the next run rescans the file
    # instead of missing what was written in the meantime.
    current_fingerprint, current_fingerprint_length = lib.base.coe(
        lib.disk.get_fingerprint(scan_path)
    )

    old_file_stats = lib.base.coe(
        lib.db_sqlite.select(
            conn,
            """
        SELECT *
        FROM file_stats
        WHERE filename = :filename
        """,
            {
                'filename': args.FILENAME,
            },
            fetchone=True,
        )
    )
    old_fingerprint = lib.base.coe(
        lib.db_sqlite.select(
            conn,
            """
        SELECT *
        FROM file_fingerprints
        WHERE filename = :filename
        """,
            {
                'filename': args.FILENAME,
            },
            fetchone=True,
        )
    )

    if old_file_stats:
        # this is not the first time we are scanning this logfile, try to continue where we left off
        offset = old_file_stats.get('offset', 0)
        # Compare the stored inode as string: legacy DBs created before
        # issue #1035 still hold the inode column with INTEGER affinity
        # and return int on read, while current_inode is a string. Without
        # the cast the comparison is always truthy and the offset is
        # reset to 0 on every run, making the plugin re-read the whole
        # file each time.
        if str(old_file_stats.get('inode')) != current_inode or current_size < offset:
            # this means the file has been rotated
            offset = 0
        elif old_fingerprint and lib.base.coe(is_rewritten(scan_path, old_fingerprint)):
            # An application that rewrites its logfile from the beginning
            # (`> logfile` instead of `>> logfile`) keeps the inode, and if the
            # new content is at least as long as what we read last time, the
            # size does not shrink either. Only the head of the file tells us
            # that everything after our offset is gone (issue #1330).
            offset = 0
    else:
        offset = 0

    compiled_warn_regex = [re.compile(item) for item in args.WARN_REGEX]
    compiled_crit_regex = [re.compile(item) for item in args.CRIT_REGEX]
    compiled_ignore_regex = [re.compile(item) for item in args.IGNORE_REGEX]

    # per-pattern counters, for the verbose "matched N lines" summary.
    # These are purely for reporting; the scan semantics (which lines are
    # classified as warn/crit) remain unchanged.
    warn_pattern_hits = dict.fromkeys(args.WARN_PATTERN, 0)
    warn_regex_hits = dict.fromkeys(args.WARN_REGEX, 0)
    crit_pattern_hits = dict.fromkeys(args.CRIT_PATTERN, 0)
    crit_regex_hits = dict.fromkeys(args.CRIT_REGEX, 0)

    warn_matches = []
    crit_matches = []
    line_counter = 0
    try:
        with open(scan_path) as logfile:
            logfile.seek(offset)
            for line in logfile:
                line_counter += 1
                is_ignored = any(
                    ignore_pattern in line for ignore_pattern in args.IGNORE_PATTERN
                ) or any(item.search(line) for item in compiled_ignore_regex)

                # due to lazy evaluation, the regex will only be executed
                # if the pattern does not match
                # see https://docs.python.org/3/reference/expressions.html#boolean-operations
                if any(
                    warn_pattern in line for warn_pattern in args.WARN_PATTERN
                ) or any(item.search(line) for item in compiled_warn_regex):
                    if not is_ignored:
                        warn_matches.append(line.strip())
                        for pat in args.WARN_PATTERN:
                            if pat in line:
                                warn_pattern_hits[pat] += 1
                        for idx, rgx in enumerate(compiled_warn_regex):
                            if rgx.search(line):
                                warn_regex_hits[args.WARN_REGEX[idx]] += 1

                if any(
                    crit_pattern in line for crit_pattern in args.CRIT_PATTERN
                ) or any(item.search(line) for item in compiled_crit_regex):
                    if not is_ignored:
                        crit_matches.append(line.strip())
                        for pat in args.CRIT_PATTERN:
                            if pat in line:
                                crit_pattern_hits[pat] += 1
                        for idx, rgx in enumerate(compiled_crit_regex):
                            if rgx.search(line):
                                crit_regex_hits[args.CRIT_REGEX[idx]] += 1
            offset = logfile.tell()
    except PermissionError:
        lib.base.cu(f"Permission denied opening '{scan_path}'.")
        # make sure conn is always closed
        lib.db_sqlite.close(conn)

    # Save the current position for the next run
    new_file_stats = {
        'filename': args.FILENAME,
        'offset': offset,
        'inode': current_inode,
    }
    lib.base.coe(lib.db_sqlite.replace(conn, new_file_stats, table='file_stats'))
    new_file_fingerprint = {
        'filename': args.FILENAME,
        'fingerprint': current_fingerprint,
        'length': current_fingerprint_length,
    }
    lib.base.coe(
        lib.db_sqlite.replace(conn, new_file_fingerprint, table='file_fingerprints')
    )

    # if we are using the alarm duration (aka not using the icinga callback),
    # remove all outdated matches from the db now
    now = lib.time.now(as_type='datetime')
    if not args.ICINGA_CALLBACK:
        outdated = now - datetime.timedelta(minutes=args.ALARM_DURATION)
        lib.base.coe(
            lib.db_sqlite.delete(
                conn,
                """
            DELETE
            FROM matching_lines
            WHERE filename = :filename and found_at <= :outdated
            """,
                {'filename': args.FILENAME, 'outdated': outdated},
            )
        )

    # get the old matches and take them into consideration for the state
    # for example, if there are no new lines we still want to alarm the old ones
    # until the service is acknowledged
    old_warn_matches = lib.base.coe(
        lib.db_sqlite.select(
            conn,
            """
        SELECT *
        FROM matching_lines
        WHERE filename = :filename and state = :state
        """,
            {'filename': args.FILENAME, 'state': STATE_WARN},
            fetchone=False,
        )
    )
    old_crit_matches = lib.base.coe(
        lib.db_sqlite.select(
            conn,
            """
        SELECT *
        FROM matching_lines
        WHERE filename = :filename and state = :state
        """,
            {'filename': args.FILENAME, 'state': STATE_CRIT},
            fetchone=False,
        )
    )

    state = lib.base.get_worst(
        lib.base.get_state(len(warn_matches) + len(old_warn_matches), args.WARN, None),
        lib.base.get_state(len(crit_matches) + len(old_crit_matches), None, args.CRIT),
    )

    msg_addendum = ''
    if args.ICINGA_CALLBACK and state != STATE_OK:
        # check if the service is acknowledged
        success, icinga = lib.icinga.get_service(
            args.ICINGA_URL,
            args.ICINGA_USERNAME,
            args.ICINGA_PASSWORD,
            servicename=args.ICINGA_SERVICE_NAME,
            attrs='state,acknowledgement',
            insecure=args.INSECURE,
            no_proxy=args.NO_PROXY,
            timeout=args.TIMEOUT,
        )
        if success:
            try:
                if icinga['results'][0]['attrs']['acknowledgement']:
                    # this means the service is acknowledged
                    state = STATE_OK
                    lib.base.coe(
                        lib.db_sqlite.delete(
                            conn,
                            """
                        DELETE
                        FROM matching_lines
                        WHERE filename = :filename
                        """,
                            {'filename': args.FILENAME},
                        )
                    )
                    old_warn_matches = []
                    old_crit_matches = []
                else:
                    msg_addendum += (
                        'Note: Acknowledge this service to reset the state to OK.'
                    )
            except IndexError:
                msg_addendum += 'Note: Could not determine the acknowledgement from the Icinga API, this could be due to an incorrect service name.'
        else:
            msg_addendum += f'Note: Could not determine the acknowledgement from the Icinga API:\n{icinga}.'

    # save to db, these lines will be alarmed until the service is acknowledged in icinga2
    for match in warn_matches:
        lib.base.coe(
            lib.db_sqlite.insert(
                conn,
                {
                    'filename': args.FILENAME,
                    'state': STATE_WARN,
                    'line': match.strip(),
                    'found_at': now,
                },
                table='matching_lines',
            )
        )
    for match in crit_matches:
        lib.base.coe(
            lib.db_sqlite.insert(
                conn,
                {
                    'filename': args.FILENAME,
                    'state': STATE_CRIT,
                    'line': match.strip(),
                    'found_at': now,
                },
                table='matching_lines',
            )
        )
    lib.base.coe(lib.db_sqlite.commit(conn))
    lib.db_sqlite.close(conn)

    # build the message: name the scanned file, every configured pattern /
    # regex with its per-pattern match count, the severity label if the
    # pattern produced hits, and the ignore patterns. See issue #547.
    def _fmt_pattern(pattern, count, severity):
        label = f' [{severity}]' if count > 0 else ''
        return (
            f"'{pattern}' (matched {count} {lib.txt.pluralize('line', count)}){label}"
        )

    def _join_and(items):
        if len(items) <= 1:
            return ''.join(items)
        if len(items) == 2:
            return f'{items[0]} and {items[1]}'
        return f'{", ".join(items[:-1])} and {items[-1]}'

    pattern_parts = []
    for pat in sorted(args.WARN_PATTERN):
        pattern_parts.append(_fmt_pattern(pat, warn_pattern_hits[pat], 'WARNING'))
    for rgx in sorted(args.WARN_REGEX):
        pattern_parts.append(_fmt_pattern(rgx, warn_regex_hits[rgx], 'WARNING'))
    for pat in sorted(args.CRIT_PATTERN):
        pattern_parts.append(_fmt_pattern(pat, crit_pattern_hits[pat], 'CRITICAL'))
    for rgx in sorted(args.CRIT_REGEX):
        pattern_parts.append(_fmt_pattern(rgx, crit_regex_hits[rgx], 'CRITICAL'))

    ignore_parts = [
        f"'{item}'" for item in sorted(args.IGNORE_PATTERN + args.IGNORE_REGEX)
    ]

    msg = (
        f'Scanned {scan_path} ({line_counter} '
        f'{lib.txt.pluralize("line", line_counter)}) '
        f'using patterns {_join_and(pattern_parts)}'
    )
    if ignore_parts:
        msg += f', ignoring {_join_and(ignore_parts)}'
    msg += '.'

    if old_warn_matches:
        msg += (
            f' {len(old_warn_matches)} unacknowledged warning'
            f' {lib.txt.pluralize("match", len(old_warn_matches), "es")}'
            f' from previous runs.'
        )

    if old_crit_matches:
        msg += (
            f' {len(old_crit_matches)} unacknowledged critical'
            f' {lib.txt.pluralize("match", len(old_crit_matches), "es")}'
            f' from previous runs.'
        )

    if not args.SUPPRESS_OUTPUT:
        if warn_matches:
            msg += '\n\nWarning matches:\n* ' + '\n* '.join(warn_matches)

        if crit_matches:
            msg += '\n\nCritical matches:\n* ' + '\n* '.join(crit_matches)

        if old_warn_matches:
            msg += (
                '\n\nUnacknowledged warning matches from previous runs:\n* '
                + '\n* '.join(match['line'] for match in old_warn_matches)
            )

        if old_crit_matches:
            msg += (
                '\n\nUnacknowledged critical matches from previous runs:\n* '
                + '\n* '.join(match['line'] for match in old_crit_matches)
            )

    perfdata = lib.base.get_perfdata(
        'scanned_lines',
        line_counter,
    )
    perfdata += lib.base.get_perfdata(
        'warn_matches',
        len(warn_matches),
        warn=args.WARN,
    )
    perfdata += lib.base.get_perfdata(
        'crit_matches',
        len(crit_matches),
        warn=args.WARN,
    )

    # over and out
    lib.base.oao(
        msg + '\n\n' + msg_addendum,
        state,
        perfdata,
        always_ok=args.ALWAYS_OK,
        no_perfdata=args.NO_PERFDATA,
    )


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