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

import lib.args
import lib.base
import lib.human
import lib.lftest
import lib.shell
import lib.time
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Checks the lifecycle and health of Docker containers: the container status
(running, exited, paused, ...), the result of the container health check (healthy,
unhealthy, starting), and the number of automatic restarts. Containers can be selected
or excluded by name using regular expressions, and the presence of expected containers
can be enforced. For per-container CPU and memory usage, use the docker-stats check. For
Podman, use the podman-container check instead.
Requires root or sudo."""

DEFAULT_NO_MATCH_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(
        '--critical-restarts',
        help='CRIT threshold for the number of automatic restarts a container has '
        'performed, compared as a Nagios range. '
        'Example: `--critical-restarts=5` alerts when a container has restarted more '
        'than 5 times. '
        'By default, the restart count is reported but not alerted on. '
        'Default: %(default)s',
        dest='CRIT_RESTARTS',
        default=None,
    )

    parser.add_argument(
        '--critical-uptime',
        help='CRIT threshold for the container uptime in a human-readable format '
        '(s = seconds, m = minutes, h = hours, D = days, W = weeks, M = months, '
        'Y = years). '
        'Supports Nagios ranges. '
        'Example: `5m:` alerts if a running container has been up for less than '
        '5 minutes (catches a crash-looping or flapping container). '
        'By default, the uptime is reported but not alerted on. '
        'Default: %(default)s',
        dest='CRIT_UPTIME',
        default=None,
    )

    parser.add_argument(
        '--full-name',
        help='Use the full container name, for example `traefik_traefik.2.1idw12p2yqp`. '
        'Without this flag, the name is shortened after the replica number.',
        dest='FULL_NAME',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--ignore',
        help='Ignore containers whose name matches this Python regular expression. '
        'Matched against the full container name, even when the displayed name is '
        'shortened (see --full-name). '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times. '
        'Example: `--ignore="^k8s_"` to skip Kubernetes pod infrastructure containers. '
        'Example: `--ignore="(?i)test"` (case-insensitive) to skip any container with '
        '"test" in its name. '
        'Default: %(default)s',
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match',
        help='Only check containers whose name matches this Python regular expression. '
        'Matched against the full container name, even when the displayed name is '
        'shortened (see --full-name). '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times. '
        + lib.args.MATCH_IGNORE_PRECEDENCE
        + ' Example: `--match="^traefik$"` to pin a service to one specific container. '
        'Example: `--match="(?i)^web"` (case-insensitive) to check every web container. '
        'Default: %(default)s',
        dest='MATCH',
        action='append',
        default=None,
    )

    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(
        '--status',
        help='Desired container status. Docker reports one of `created`, `running`, '
        '`paused`, `restarting`, `removing`, `exited` and `dead`. '
        'A container whose status differs is reported as CRITICAL. '
        'If not specified, the status is reported but not alerted on. '
        'Default: %(default)s',
        dest='STATUS',
        default=None,
    )

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

    parser.add_argument(
        '--warning-restarts',
        help='WARN threshold for the number of automatic restarts a container has '
        'performed, compared as a Nagios range. '
        'Example: `--warning-restarts=3` alerts when a container has restarted more '
        'than 3 times. '
        'By default, the restart count is reported but not alerted on. '
        'Default: %(default)s',
        dest='WARN_RESTARTS',
        default=None,
    )

    parser.add_argument(
        '--warning-uptime',
        help='WARN threshold for the container uptime in a human-readable format '
        '(s = seconds, m = minutes, h = hours, D = days, W = weeks, M = months, '
        'Y = years). '
        'Supports Nagios ranges. '
        'Example: `5m:` alerts if a running container has been up for less than '
        '5 minutes (catches a crash-looping or flapping container). '
        'By default, the uptime is reported but not alerted on. '
        'Default: %(default)s',
        dest='WARN_UPTIME',
        default=None,
    )

    args, _ = parser.parse_known_args()
    return args


def assert_docker_engine():
    """Exit UNKNOWN when `docker` on this host is Podman.

    A host carrying podman-docker answers every `docker` command through Podman, and
    `docker ps` and `docker inspect` then hand this check data it would report as if it
    came from Docker. Podman has its own check, so this one says where to go instead,
    the same way docker-info, docker-stats and docker-swarm do. Recognised the way
    docker-info recognises it: Podman reports no server version. An engine that cannot
    be asked at all is left to the caller, which has the better message for it.
    """
    stdout, _, retc = lib.base.coe(
        lib.shell.shell_exec(['docker', 'info', '--format', '{{json .}}']),
    )
    if retc != 0:
        return
    try:
        info = json.loads(stdout)
    except Exception:
        return
    if isinstance(info, dict) and not info.get('ServerVersion'):
        lib.base.cu(
            'The daemon did not report a server version.'
            ' If you are using Podman, use the podman-container check instead.'
        )


def get_engine_error(stderr, stdout=''):
    """Return `(message, state)` for a command that could not reach the container
    engine. A refused permission is a problem of how this check is deployed, not of
    the engine: the engine answers other callers just fine, this one is only not
    allowed to ask, so the check cannot say anything and reports UNKNOWN. Everything
    else, a socket that is not there or an engine that does not answer, is the
    outage this check exists to report.
    """
    text = f'{stderr}\n{stdout}'.strip()
    if 'permission denied' in text.lower():
        return (
            'No permission to talk to the container engine, so nothing can be said'
            ' about it. Run the check as root, or deploy the sudoers file that ships'
            f' with the plugins.\n{text}',
            STATE_UNKNOWN,
        )
    return (text, STATE_CRIT)


def keep_container(name, match_patterns, ignore_patterns):
    """Return True if `name` should be kept by the --match / --ignore filter pair,
    False if it should be dropped. Include first, then exclude: a name passes if it
    matches any `match_patterns` entry (or if `match_patterns` is empty) AND does not
    match any `ignore_patterns` entry. Same semantics as the lib.args canonical
    --match / --ignore convention.
    """
    if match_patterns and not any(p.search(name) for p in match_patterns):
        return False
    return not any(p.search(name) for p in ignore_patterns)


# A swarm task container is named `<service>.<slot or node id>.<task id>`, and the
# task id is 25 base36 characters (swarmkit identity.NewID). Only that suffix is cut
# off, so a container an admin named `backup.daily` keeps the name they gave it, and
# two such containers do not collapse into one row.
TASK_ID_REGEX = re.compile(r'\.[0-9a-z]{25}$')


def shorten(name):
    """
    >>> shorten('traefik_traefik.2.1idw12p2yqpxutlzkcwign4at')
    traefik_traefik.2
    >>> shorten('backup.daily')
    backup.daily
    """
    return TASK_ID_REGEX.sub('', name)


def get_health(state):
    """Return the container health check status (healthy, unhealthy, starting) or
    None if the container image defines no health check. Docker exposes it under
    `State.Health`, some Podman versions under `State.Healthcheck`.
    """
    health = (state.get('Health') or state.get('Healthcheck') or {}).get('Status')
    return health or None


def get_health_state(health):
    """Map the container health check status to a monitoring state. A missing health
    check is neutral (OK), so containers without a `HEALTHCHECK` do not raise alerts.
    """
    if health == 'unhealthy':
        return STATE_CRIT
    if health == 'starting':
        return STATE_WARN
    return STATE_OK


def get_uptime(container_state, status, now):
    """Return the running container's uptime in seconds, or None if it is not running
    or carries no usable start timestamp. `StartedAt` is an RFC 3339 string; a container
    that never started carries the zero value `0001-01-01T00:00:00...`. The offset the
    timestamp carries is kept: Docker stamps it in UTC, Podman in the local time of the
    host, so reading it as UTC would move the uptime by the offset of the host.
    """
    if status != 'running':
        return None
    started = container_state.get('StartedAt', '')
    if not started or started.startswith('0001'):
        return None
    try:
        started_epoch = lib.time.timestr2epoch(started, pattern='iso8601')
    except ValueError:
        return None
    # whole seconds: the timestamp carries nanoseconds, and an uptime rendered down
    # to microseconds ("2m 348ms") says nothing an admin acts on
    return int(now - started_epoch)


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 = []

    # compile --match and --ignore patterns (case-sensitive by default, matching
    # the lib.args convention; the user can opt into case-insensitive matching
    # with the inline `(?i)` flag)
    match_patterns = [
        lib.base.coe(p) for p in lib.txt.compile_regex(args.MATCH, '--match')
    ]
    ignore_patterns = [
        lib.base.coe(p) for p in lib.txt.compile_regex(args.IGNORE, '--ignore')
    ]

    # fetch data
    if args.TEST is None:
        assert_docker_engine()
        # list all container IDs, then inspect them in a single call. `docker
        # inspect` errors out when called without arguments, so the empty case
        # is handled separately below.
        stdout, stderr, retc = lib.base.coe(
            lib.shell.shell_exec(['docker', 'ps', '--all', '--quiet', '--no-trunc']),
        )
        if retc != 0:
            lib.base.oao(*get_engine_error(stderr, stdout))
        container_ids = stdout.split()
        if container_ids:
            stdout, stderr, retc = lib.base.coe(
                lib.shell.shell_exec(['docker', 'inspect', *container_ids]),
            )
        else:
            stdout, stderr, retc = '[]', '', 0
    else:
        # do not call the command, put in test data
        stdout, stderr, retc = lib.lftest.test(args.TEST)

    try:
        inspected = json.loads(stdout)
    except Exception:
        inspected = None

    if not isinstance(inspected, list):
        # a container that is removed between being listed and being inspected makes
        # the command fail while it still prints every container it did find, which
        # happens on any host running short-lived containers. Those containers are
        # reported on; only an answer without a single usable container means the
        # engine could not be asked at all.
        if retc != 0:
            lib.base.oao(*get_engine_error(stderr, stdout))
        lib.base.cu(
            'Unable to parse docker inspect output as JSON.'
            ' If you are using Podman, use the podman-container check instead.'
        )

    # init some vars
    msg = ''
    msg_header = ''
    state = STATE_OK
    perfdata = ''
    table_values = []
    containers_running = 0
    containers_unhealthy = 0

    # convert the human-readable uptime thresholds to a seconds-based Nagios range,
    # leaving them as None (no alert) when the admin did not set them
    warn_uptime = (
        lib.human.humanrange2seconds(args.WARN_UPTIME) if args.WARN_UPTIME else None
    )
    crit_uptime = (
        lib.human.humanrange2seconds(args.CRIT_UPTIME) if args.CRIT_UPTIME else None
    )

    # reference time for the uptime calculation. In test mode it is pinned so that the
    # uptime derived from a fixture's StartedAt is deterministic across runs.
    now = (
        lib.time.timestr2epoch('2026-06-30T08:00:00Z', pattern='iso8601')
        if args.TEST is not None
        else lib.time.now()
    )

    # analyze data
    for container in sorted(inspected, key=lambda c: c.get('Name', '')):
        name = container.get('Name', '').lstrip('/')
        if not name:
            continue
        if not keep_container(name, match_patterns, ignore_patterns):
            continue

        # https://github.com/Linuxfabrik/monitoring-plugins/issues/586
        display_name = name if args.FULL_NAME else shorten(name)

        container_state = container.get('State', {}) or {}
        status = container_state.get('Status', '')
        health = get_health(container_state)
        restarts = int(container.get('RestartCount', 0))
        uptime = get_uptime(container_state, status, now)

        if status == 'running':
            containers_running += 1
        if health == 'unhealthy':
            containers_unhealthy += 1

        # determine the per-container state from status, health, restarts and uptime
        health_state = get_health_state(health)

        status_state = STATE_OK
        if args.STATUS is not None and status != args.STATUS:
            status_state = STATE_CRIT

        restarts_state = lib.base.get_state(
            restarts, args.WARN_RESTARTS, args.CRIT_RESTARTS, _operator='range'
        )

        uptime_state = STATE_OK
        if uptime is not None and (warn_uptime is not None or crit_uptime is not None):
            uptime_state = lib.base.get_state(
                uptime, warn_uptime, crit_uptime, _operator='range'
            )

        container_worst = lib.base.get_worst(
            health_state, status_state, restarts_state, uptime_state
        )
        state = lib.base.get_worst(state, container_worst)

        # collect a concise problem note for the first output line
        if container_worst != STATE_OK:
            problems = []
            if status_state != STATE_OK:
                problems.append(f'status {status} (want {args.STATUS})')
            if health_state != STATE_OK:
                problems.append(f'health {health}')
            if restarts_state != STATE_OK:
                problems.append(f'{restarts} restarts')
            if uptime_state != STATE_OK:
                problems.append(f'up {lib.human.seconds2human(uptime)}')
            msg_header += (
                f'{display_name}: {", ".join(problems)}'
                f'{lib.base.state2str(container_worst, prefix=" ")}, '
            )

        table_values.append(
            {
                'name': display_name,
                'status': status,
                'health': health or '-',
                'restarts': str(restarts),
                'uptime': lib.human.seconds2human(uptime)
                if uptime is not None
                else '-',
                'state': lib.base.state2str(container_worst, empty_ok=False),
            }
        )

    # nothing left after applying the --match / --ignore-regex filters (or no
    # containers exist at all); report the configured no-match severity
    if not table_values:
        lib.base.oao(
            'No containers to check.',
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
        )

    # build the message
    if msg_header:
        msg += msg_header[:-2] + '\n\n'
    else:
        checked = len(table_values)
        msg += (
            f'Everything is ok. {checked} '
            f'{lib.txt.pluralize("container", checked)} checked.\n\n'
        )
    perfdata += lib.base.get_perfdata(
        'containers_checked',
        len(table_values),
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'containers_running',
        containers_running,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'containers_unhealthy',
        containers_unhealthy,
        _min=0,
    )

    # build table output
    if len(table_values) > 0:
        msg += lib.base.get_table(
            table_values,
            ['name', 'status', 'health', 'restarts', 'uptime', 'state'],
            header=['Container', 'Status', 'Health', 'Restarts', 'Uptime', 'State'],
        )

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