diff --git a/registry.py b/registry.py new file mode 100644 index 0000000..f1e34f1 --- /dev/null +++ b/registry.py @@ -0,0 +1,312 @@ +#!/usr/bin/python + +import requests +from requests.auth import HTTPBasicAuth +import json +import re +import argparse + +## this is a registry manipulator, can do following: +## - list all images (including layers) +## - delete images +## - all except last N images +## - all images and/or tags +## +## run +## registry.py -h +## to get more help +## or read README.md +## +## important: after removing the tags, run the garbage collector +## on your registry host: +## docker-compose -f [path_to_your_docker_compose_file] run \ +## registry bin/registry garbage-collect \ +## /etc/docker/registry/config.yml +## +## or if you are not using docker-compose: +## docker run registry:2 bin/registry garbage-collect \ +## /etc/docker/registry/config.yml +## +## for more detail on garbage collection read here: +## https://docs.docker.com/registry/garbage-collection/ + + +# number of image versions to keep +CONST_KEEP_LAST_VERSIONS = 10 + + +# class to manipulate registry +class Registry: + username = "" + password = "" + hostname = "" + + # this is required for proper digest processing + HEADERS = {"Accept": + "application/vnd.docker.distribution.manifest.v2+json"} + + # store last error if any + __error = None + + def __init__(self, host, userpass): + if not ':' in userpass: + print "Please provide -l in the form USER:PASSWORD" + exit(1) + + (self.username, self.password) = userpass.split(':') + self.hostname = host + + def __atoi(self, text): + return int(text) if text.isdigit() else text + + def __natural_keys(self, text): + ''' + alist.sort(key=natural_keys) sorts in human order + http://nedbatchelder.com/blog/200712/human_sorting.html + (See Toothy's implementation in the comments) + ''' + return [ self.__atoi(c) for c in re.split('(\d+)', text) ] + + def send(self, path, method="GET"): + try: + result = requests.request( + method, "{}{}".format(self.hostname, path), + headers = self.HEADERS, + auth=(self.username, self.password)) + except Exception as error: + print "cannot connect to {}\nerror {}".format( + self.hostname, + error) + exit(1) + if str(result.status_code)[0] == '2': + self.__error = None + return result + + self.__error=result.status_code + return None + + def list_images(self): + result = self.send('/v2/_catalog') + if result == None: + return [] + + return json.loads(result.text)['repositories'] + + def list_tags(self, image_name): + result = self.send("/v2/{}/tags/list".format(image_name)) + if result == None: + return [] + + tags_list = json.loads(result.text)['tags'] + + if tags_list != None: + tags_list.sort(key=self.__natural_keys) + + return tags_list + + def get_tag_digest(self, image_name, tag): + image_headers = self.send("/v2/{}/manifests/{}".format( + image_name, tag), method="HEAD") + + if image_headers == None: + + print " tag digest not found: {}".format(self.__error) + return None + + tag_digest = image_headers.headers['Docker-Content-Digest'] + + return tag_digest + + def delete_tag(self, image_name, tag, dry_run): + if dry_run: + print 'would delete tag {}'.format(tag) + return True + + tag_digest = self.get_tag_digest(image_name, tag) + + if tag_digest == None: + return False + + delete_result = self.send("/v2/{}/manifests/{}".format( + image_name, tag_digest), method="DELETE") + + if delete_result == None: + print "failed, error: {}".format(self.__error) + return False + + print "done" + return True + + def delete_tag_layer(self, image_name, layer_digest, dry_run): + if dry_run: + print 'would delete layer {}'.format(layer_digest) + return False + + print 'deleting layer {}'.format(layer_digest), + + delete_result = self.send('/v2/{}/blobs/{}'.format( + image_name, layer_digest), method='DELETE') + + if delete_result == None: + print "failed, error: {}".format(self.__error) + return False + + print "done" + return True + + + def list_tag_layers(self, image_name, tag): + layers_result = self.send("/v2/{}/manifests/{}".format( + image_name, tag)) + + if layers_result == None: + print "error {}".format(self.__error) + return [] + + layers = json.loads(layers_result.text)['layers'] + + return layers + +def parse_args(): + parser = argparse.ArgumentParser( + description="List or delete images from Docker registry", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=(""" +IMPORTANT: after removing the tags, run the garbage collector + on your registry host: + + docker-compose -f [path_to_your_docker_compose_file] run \\ + registry bin/registry garbage-collect \\ + /etc/docker/registry/config.yml + +or if you are not using docker-compose: + + docker run registry:2 bin/registry garbage-collect \\ + /etc/docker/registry/config.yml + +for more detail on garbage collection read here: + https://docs.docker.com/registry/garbage-collection/ + """)) + parser.add_argument( + '-l','--login', + help="Login and password to access to docker registry", + required=True, + metavar="USER:PASSWORD") + + parser.add_argument( + '-r','--host', + help="Hostname for registry server, e.g. https://example.com:5000", + required=True, + metavar="URL") + + parser.add_argument( + '-d','--delete', + help=('If specified, delete all but last {} tags ' + 'of all images').format(CONST_KEEP_LAST_VERSIONS), + action='store_const', + default=False, + const=True) + + parser.add_argument( + '-n','--num', + help=('Set the number of tags to keep' + '({} if not set)').format(CONST_KEEP_LAST_VERSIONS), + default=CONST_KEEP_LAST_VERSIONS, + nargs='?', + metavar='N') + + parser.add_argument( + '--dry-run', + help=('If used in combination with --delete,' + 'then images will not be deleted'), + action='store_const', + default=False, + const=True) + + parser.add_argument( + '-i','--image', + help='Specify images and tags to list/delete', + nargs='+', + metavar="IMAGE:[TAG]") + + parser.add_argument( + '--delete-all', + help="Will delete all tags. Be careful with this!", + const=True, + default=False, + action="store_const") + + parser.add_argument( + '--layers', + help=('Show layers digests for all images and all tags'), + action='store_const', + default=False, + const=True) + + + return parser.parse_args() + + +def delete_tags( + registry, image_name, dry_run, tags_to_delete, keep_last_versions): + + for tag in tags_to_delete: + print " deleting tag {}".format(tag) + for layer in registry.list_tag_layers(image_name, tag): + layer_digest = layer['digest'] + registry.delete_tag_layer(image_name, layer_digest, dry_run) + + registry.delete_tag(image_name, tag, dry_run) + + +def main_loop(args): + + keep_last_versions = int(args.num) + + registry = Registry(args.host, args.login) + if args.delete: + print "Will delete all but {} last tags".format(keep_last_versions) + + if args.image != None: + image_list = args.image + else: + image_list = registry.list_images() + + # loop through registry's images + # or through the ones given in command line + for image_name in image_list: + print "Image: {}".format(image_name) + + # get tags from arguments if any + if ":" in image_name: + (image_name, tag_name) = image_name.split(":") + tags_list = [tag_name] + else: + tags_list = registry.list_tags(image_name) + + if tags_list == None or tags_list == []: + print " no tags!" + continue + + # print tags and optionally layers + for tag in tags_list: + print " tag: {}".format(tag) + if args.layers: + for layer in registry.list_tag_layers(image_name, tag): + print " layer: {}, size: {}".format( + layer['digest'], layer['size']) + + # delete tags if told so + if args.delete or args.delete_all: + if args.delete_all: + tags_list_to_delete = tags_list + else: + tags_list_to_delete = tags_list[:-keep_last_versions] + + delete_tags( + registry, image_name, args.dry_run, + tags_list_to_delete, keep_last_versions) + +if __name__ == "__main__": + args = parse_args() + main_loop(args)