Commit 6077b711 authored by nimrod's avatar nimrod
Browse files

Implementation.

parent bdc134d6
Loading
Loading
Loading
Loading
Loading
+164 −2
Original line number Diff line number Diff line
"""Clean old versions of Docker images."""

import argparse
import re

import docker  # pylint: disable=import-error,useless-suppression

from . import __version__


def image_date(image):
    """Return the creation date of the image."""
    return image.history()[0]["Created"]


def include(images, includes, verbose):
    """Return only the images that match the regexes in the includes list."""
    regexes = [re.compile(r) for r in includes]
    included_images = {}
    for Id, image in images.items():  # noqa: N806 pylint: disable=invalid-name
        for tag in image.tags:
            for r in regexes:  # pylint: disable=invalid-name
                if r.match(tag):
                    if verbose:
                        print(f"Including image {Id}.")
                    included_images[Id] = image
                    break
    return included_images


def exclude(images, excludes, verbose):
    """Return the images that don't match the regexes in the excludes list."""
    remaining_images = images.copy()
    regexes = [re.compile(r) for r in excludes]
    # pylint: disable=invalid-name
    for (Id, image) in images.items():  # noqa: N806
        for tag in image.tags:
            for r in regexes:
                if r.match(tag):
                    if verbose:
                        print(f"Excluding image {Id}.")
                    remaining_images.pop(Id)
                    break
    return remaining_images


def not_in_use(client, images, verbose):
    """Return the images that aren't in use by containers right now."""
    in_use_images = [c.image.id for c in client.containers.list()]
    not_in_use_images = images.copy()
    # pylint: disable=invalid-name
    for Id in in_use_images:  # noqa: N806
        if verbose:
            print(f"Image {Id} is in use, ignoring.")
        not_in_use_images.pop(Id)
    return not_in_use_images


def normalize_names(images_by_name):
    """Normalize the different Docker hub registry names."""
    copy_of_images = {k: v[:] for k, v in images_by_name.items()}
    for name in images_by_name.keys():
        if name.startswith("registry.hub.docker.com"):
            new_name = name.replace("registry.hub.docker.com", "docker.io", 1)
            if new_name in copy_of_images:
                copy_of_images[new_name] = list(
                    set(copy_of_images[name] + copy_of_images[new_name])
                )
                copy_of_images[new_name].sort(key=image_date, reverse=True)
            else:
                copy_of_images[new_name] = copy_of_images[name]
            copy_of_images.pop(name)
    return copy_of_images


def deepclean(
    includes=None, excludes=None, verbose=False, dry_run=False
):  # noqa: MC0001
    """Clean old versions of Docker images."""
    client = docker.from_env()
    images = {i.id: i for i in client.images.list()}

    images = not_in_use(client, images, verbose)

    if includes:
        images = include(images, includes, verbose)

    if excludes:
        images = exclude(images, excludes, verbose)

    # First we build a dictionary with the image name as key and the value is
    # an empty list that later will contain the images that have that name.
    images_by_name = {
        name.split(":")[0]: []
        for image in images.values()
        for name in image.tags
    }

    # Now we're populating the list of images for each image name.
    # We keep the list of images sorted from most recent to least.
    for image in images.values():
        for tag in image.tags:
            name = tag.split(":")[0]
            images_by_name[name].append(image)
            images_by_name[name].sort(key=image_date, reverse=True)

    images_by_name = normalize_names(images_by_name)

    # noqa: LPY101
    for name in images_by_name.keys():
        kept = images_by_name[name][0].id
        if kept in images:
            if verbose:
                print(f"Keeping {kept}, the latest image for {name}.")
                images.pop(kept)

    # Now we've removed the images that are in use, kept the included ones,
    # removed the excluded ones and the latest image for each name. We're left
    # with images that are meant for deletion.

    # pylint: disable=invalid-name
    for Id in images.keys():  # noqa: N806
        if dry_run:
            print(f"Would have removed image {Id}.")
        else:
            print(f"Removing image {Id}.")
            client.image.remove(Id)


def main():
    """Main entrypoint."""
    # noqa: D401
    """The main entrypoint."""
    epilog = (
        "Regular Docker environment variables (like DOCKER_HOST) can be used."
        "-i and -e can be used multiple times."
    )
    parser = argparse.ArgumentParser(
        description=__doc__,
        epilog=epilog,
    )
    parser.add_argument(
        "-i",
        "--include",
        help="Regular expression of images to exclusively prune.",
        action="append",
    )
    parser.add_argument(
        "-e",
        "--exclude",
        help="Regular expression of images to ignore.",
        action="append",
    )
    parser.add_argument(
        "-v", "--verbose", help="Verbose output", action="store_true"
    )
    parser.add_argument(
        "-V",
        "--version",
        action="version",
        version=f"deepclean version {__version__}",
    )
    parser.add_argument(
        "-d", "--dry-run", help="Dry-run, don't delete", action="store_true"
    )
    args = parser.parse_args()
    print(args)
    try:
        deepclean(
            includes=args.include,
            excludes=args.exclude,
            verbose=args.verbose,
            dry_run=args.dry_run,
        )
    except Exception as ex:  # noqa: PIE786 pylint: disable=broad-except
        parser.error(str(ex))


if __name__ == "__main__":