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

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

DESCRIPTION = """Lists the container images on a host and checks how old they are. Reports each
image's repository tag, age and size, and alerts when an image is older than the
configured thresholds, which is a sign that a rebuild or pull was missed. Images can be
selected or excluded by name using regular expressions. On a host with many images,
--brief hides the rows within the thresholds so the table shows only the images that are
too old. For Docker, use the docker-image check instead.
Requires root or sudo."""

DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_WARN_AGE = '90D'
DEFAULT_CRIT_AGE = '365D'


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(
        '--brief',
        help=lib.args.help('--brief'),
        dest='BRIEF',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for the image age in a human-readable format '
        '(s = seconds, m = minutes, h = hours, D = days, W = weeks, M = months, '
        'Y = years). '
        'Supports Nagios ranges. '
        'Example: `180D` alerts on images older than 180 days. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT_AGE,
    )

    parser.add_argument(
        '--ignore',
        help='Ignore images whose repository tag matches this Python regular expression. '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times. '
        'Example: `--ignore="^localhost/"` to skip locally built images. '
        'Example: `--ignore="(?i)test"` (case-insensitive) to skip any image with '
        '"test" in its tag. '
        'Default: %(default)s',
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match',
        help='Only check images whose repository tag matches this Python regular '
        'expression. '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times. '
        + lib.args.MATCH_IGNORE_PRECEDENCE
        + ' Example: `--match="^docker.io/library/nginx"` to check only the nginx '
        'images. '
        '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(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '--user',
        help='Inspect the rootless images of this user instead of those visible to the '
        'executing user. '
        "Podman keeps each user's rootless images in that user's own storage, so root "
        '(the monitoring user runs the check via sudo) does not see them. With --user, '
        'the check runs podman as that user. '
        'Requires the right to `sudo -u <user>` (root has this by default). '
        'Example: `--user=webapp`. '
        'Default: %(default)s',
        dest='USER',
        default=None,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the image age in a human-readable format '
        '(s = seconds, m = minutes, h = hours, D = days, W = weeks, M = months, '
        'Y = years). '
        'Supports Nagios ranges. '
        'Example: `90D` alerts on images older than 90 days. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN_AGE,
    )

    args, _ = parser.parse_known_args()
    return args


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


def is_dangling(image):
    """Return True if the image has lost its repository tag (the engine reports no tags,
    or the placeholder `<none>:<none>`). Such an image is only reachable by its ID.
    """
    return not any(
        tag and tag != '<none>:<none>' for tag in (image.get('RepoTags') or [])
    )


def get_image_name(image):
    """Return the image's primary repository tag for display and filtering. A dangling
    image (one that has lost its tag) has no tag to show, so fall back to its short image
    ID, which is also what the admin needs to act on it (`podman rmi <id>`).
    """
    for tag in image.get('RepoTags') or []:
        if tag and tag != '<none>:<none>':
            return tag
    return image.get('Id', '').split(':')[-1][:12]


def get_age(created, now):
    """Return the image age in seconds, or None if the `Created` timestamp is missing or
    unusable. `Created` is an RFC 3339 string and states when the image was built, not
    when it was pulled. The offset it carries is kept rather than assumed to be UTC.
    """
    if not created or created.startswith('0001'):
        return None
    try:
        created_epoch = lib.time.timestr2epoch(created, pattern='iso8601')
    except ValueError:
        return None
    # whole seconds: the timestamp carries nanoseconds, and an age rendered down to
    # microseconds says nothing an admin acts on
    return int(now - created_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')
    ]

    # convert the human-readable age thresholds to a seconds-based Nagios range,
    # leaving them as None (no alert) when the admin did not set them
    warn_age = lib.human.humanrange2seconds(args.WARN) if args.WARN else None
    crit_age = lib.human.humanrange2seconds(args.CRIT) if args.CRIT else None

    # fetch data
    if args.TEST is None:
        # list all image IDs, then inspect them in a single call. `podman image
        # 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(
                ['podman', 'images', '--quiet', '--no-trunc'],
                run_as=args.USER,
            ),
        )
        if retc != 0:
            lib.base.oao(*get_engine_error(stderr, stdout))
        # `podman images -q` repeats an ID once per tag; inspect each ID only once
        image_ids = sorted(set(stdout.split()))
        if image_ids:
            stdout, stderr, retc = lib.base.coe(
                lib.shell.shell_exec(
                    ['podman', 'image', 'inspect', *image_ids],
                    run_as=args.USER,
                ),
            )
        else:
            stdout, stderr, retc = '[]', '', 0
        now = lib.time.now()
    else:
        # do not call the command, put in test data
        stdout, stderr, retc = lib.lftest.test(args.TEST)
        # pin "now" so the age derived from a fixture's Created is deterministic
        now = lib.time.timestr2epoch('2026-06-30T08:00:00Z', pattern='iso8601')

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

    if not isinstance(inspected, list):
        # an image that is removed between being listed and being inspected makes the
        # command fail while it still prints every image it did find, which is what a
        # cleanup job running next to the check looks like. Those images are reported
        # on; only an answer without a single usable image 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 podman image inspect output as JSON.'
            ' If you are using Docker, use the docker-image check instead.'
        )

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    table_values = []
    too_old = []
    images_dangling = 0

    # analyze data
    for image in sorted(inspected, key=get_image_name):
        name = get_image_name(image)
        if not keep_image(name, match_patterns, ignore_patterns):
            continue

        if is_dangling(image):
            images_dangling += 1
        age = get_age(image.get('Created', ''), now)
        size = int(image.get('Size', 0))

        age_state = STATE_OK
        if age is not None and (warn_age is not None or crit_age is not None):
            age_state = lib.base.get_state(age, warn_age, crit_age, _operator='range')
        state = lib.base.get_worst(state, age_state)

        if age_state != STATE_OK:
            too_old.append((age, name, age_state))

        table_values.append(
            {
                'name': name,
                'age': lib.human.seconds2human(age) if age is not None else '-',
                'size': lib.human.bytes2human(size),
                'state': lib.base.state2str(age_state, empty_ok=False),
                # not rendered (see the column list below); this is what --brief filters on
                'age_state': age_state,
            }
        )

    # name the inspected user on every output line. Rootless Podman is per-user, so
    # making the user explicit removes any doubt about whose storage was looked at.
    inspected_user = args.USER or pwd.getpwuid(os.geteuid()).pw_name
    user_note = f'(user: `{inspected_user}`)'

    # nothing left after applying the --match / --ignore filters, or the inspected user
    # simply has no images
    if not table_values:
        lib.base.oao(
            f'No images to check {user_note}.',
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
        )

    # build the message. The applied age thresholds go on the first line, like the
    # updates and rpm-lastactivity checks, together with the inspected user.
    note = f'(thresholds {args.WARN}/{args.CRIT}; user: `{inspected_user}`)'
    checked = len(table_values)
    if too_old:
        # One line per affected image put every one of them into the summary, which on
        # a build host is thousands of characters and is also the line a notification
        # carries. Count them and name the oldest, which is the one to act on first;
        # the table below still lists every image.
        oldest_age, oldest_name, oldest_state = max(too_old)
        crit_count = sum(1 for _, _, s in too_old if s == STATE_CRIT)
        counted = f'{len(too_old)} of {checked} {lib.txt.pluralize("image", checked)}'
        msg += (
            f'{counted} too old, {crit_count} of them critical. '
            f'Oldest: {oldest_name}, age {lib.human.seconds2human(oldest_age)}'
            f'{lib.base.state2str(oldest_state, prefix=" ")} {note}\n\n'
        )
    else:
        msg += (
            f'Everything is ok. {checked} '
            f'{lib.txt.pluralize("image", checked)} checked {note}.\n\n'
        )
    perfdata += lib.base.get_perfdata(
        'images_checked',
        len(table_values),
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'images_dangling',
        images_dangling,
        _min=0,
    )

    # build table output. --brief drops the rows within the thresholds; the counts, the
    # performance data and the check state above cover every image either way.
    rows = table_values
    if args.BRIEF:
        rows = [row for row in table_values if row['age_state'] != STATE_OK]
    if len(rows) > 0:
        msg += lib.base.get_table(
            rows,
            ['name', 'age', 'size', 'state'],
            header=['Image', 'Age', 'Size', '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()
