From 6077b71100bd4a8c8542c0f38e02e960feb7eeeb Mon Sep 17 00:00:00 2001 From: Adar Nimrod <nimrod@shore.co.il> Date: Fri, 13 May 2022 23:27:15 +0300 Subject: [PATCH] Implementation. --- deepclean/__main__.py | 166 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/deepclean/__main__.py b/deepclean/__main__.py index 9935e96..7e031e3 100644 --- a/deepclean/__main__.py +++ b/deepclean/__main__.py @@ -1,15 +1,177 @@ """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__": -- GitLab