Quellcode durchsuchen

clean_registry.py

master
Ricardo Branco vor 6 Jahren
Ursprung
Commit
887ec1166c
1 geänderte Dateien mit 295 neuen und 0 gelöschten Zeilen
  1. 295
    0
      clean_registry.py

+ 295
- 0
clean_registry.py Datei anzeigen

@@ -0,0 +1,295 @@
1
+#!/usr/bin/env python3
2
+#
3
+# This script purges untagged repositories and runs the garbage collector in Docker Registry >= 2.4.0.
4
+# It works on the whole registry or the specified repositories.
5
+# The optional flag -x may be used to completely remove the specified repositories or tagged images.
6
+#
7
+# NOTES:
8
+#   - This script stops the Registry container during cleanup to prevent corruption,
9
+#     making it temporarily unavailable to clients.
10
+#   - This script assumes local storage (the filesystem storage driver).
11
+#   - This script may run standalone or dockerized.
12
+#   - This script is Python 3 only.
13
+#
14
+# v1.0 by Ricardo Branco
15
+#
16
+# MIT License
17
+#
18
+
19
+import os
20
+import re
21
+import sys
22
+import tarfile
23
+import subprocess
24
+
25
+from argparse import ArgumentParser
26
+from distutils.version import LooseVersion
27
+from glob import iglob
28
+from io import BytesIO
29
+from shutil import rmtree
30
+from requests import exceptions
31
+from docker.errors import APIError, NotFound
32
+
33
+try:
34
+    import docker
35
+except ImportError:
36
+    error("Please install docker-py with: pip3 install docker")
37
+
38
+try:
39
+    import yaml
40
+except ImportError:
41
+    error("Please install PyYaml with: pip3 install pyyaml")
42
+
43
+VERSION = "1.0"
44
+
45
+
46
+def dockerized():
47
+    '''Returns True if we're inside a Docker container, False otherwise.'''
48
+    return os.path.isfile("/.dockerenv")
49
+
50
+
51
+def error(msg, Exit=True):
52
+    '''Prints an error message and optionally exit with a status code of 1'''
53
+    print("ERROR: " + str(msg), file=sys.stderr)
54
+    if Exit:
55
+        sys.exit(1)
56
+
57
+
58
+def remove(path):
59
+    '''Run rmtree() in verbose mode'''
60
+    rmtree(path)
61
+    if not args.quiet:
62
+        print("removed directory " + path)
63
+
64
+
65
+def clean_revisions(repo):
66
+    '''Remove the revision manifests that are not present in the tags directory'''
67
+    revisions = set(os.listdir(repo + "/_manifests/revisions/sha256/"))
68
+    manifests = set(map(os.path.basename, iglob(repo + "/_manifests/tags/*/*/sha256/*")))
69
+    revisions.difference_update(manifests)
70
+    for revision in revisions:
71
+        remove(repo + "/_manifests/revisions/sha256/" + revision)
72
+
73
+
74
+def clean_tag(repo, tag):
75
+    '''Clean a specific repo:tag'''
76
+    link = repo + "/_manifests/tags/" + tag + "/current/link"
77
+    if not os.path.isfile(link):
78
+        error("No such tag: %s in repository %s" % (tag, repo), Exit=False)
79
+        return False
80
+    if args.remove:
81
+        remove(repo + "/_manifests/tags/" + tag)
82
+    else:
83
+        with open(link) as f:
84
+            current = f.read()[len("sha256:"):]
85
+        path = repo + "/_manifests/tags/" + tag + "/index/sha256/"
86
+        for index in os.listdir(path):
87
+            if index == current:
88
+                continue
89
+            remove(path + index)
90
+        clean_revisions(repo)
91
+    return True
92
+
93
+
94
+def clean_repo(image):
95
+    '''Clean all tags (or a specific one, if specified) from a specific repository'''
96
+    repo, tag = image.split(":", 1) if ":" in image else (image, "")
97
+
98
+    if not os.path.isdir(repo):
99
+        error("No such repository: " + repo, Exit=False)
100
+        return False
101
+
102
+    if args.remove:
103
+        tags = os.listdir(repo + "/_manifests/tags/")
104
+        if not tag or len(tags) == 1 and tag in tags:
105
+            remove(repo)
106
+            return True
107
+
108
+    if tag:
109
+        return clean_tag(repo, tag)
110
+
111
+    currents = set()
112
+    for link in iglob(repo + "/_manifests/tags/*/current/link"):
113
+        with open(link) as f:
114
+            currents.add(f.read()[len("sha256:"):])
115
+    for index in iglob(repo + "/_manifests/tags/*/index/sha256/*"):
116
+        if os.path.basename(index) not in currents:
117
+            remove(index)
118
+
119
+    clean_revisions(repo)
120
+    return True
121
+
122
+
123
+def check_name(image):
124
+    '''Checks the whole repository:tag name'''
125
+    repo, tag = image.split(":", 1) if ":" in image else (image, "latest")
126
+
127
+    # From https://github.com/moby/moby/blob/master/image/spec/v1.2.md
128
+    # Tag values are limited to the set of characters [a-zA-Z0-9_.-], except they may not start with a . or - character.
129
+    # Tags are limited to 128 characters.
130
+    #
131
+    # From https://github.com/docker/distribution/blob/master/docs/spec/api.md
132
+    # 1. A repository name is broken up into path components. A component of a repository name must be at least
133
+    #    one lowercase, alpha-numeric characters, optionally separated by periods, dashes or underscores.
134
+    #    More strictly, it must match the regular expression [a-z0-9]+(?:[._-][a-z0-9]+)*
135
+    # 2. If a repository name has two or more path components, they must be separated by a forward slash ("/").
136
+    # 3. The total length of a repository name, including slashes, must be less than 256 characters.
137
+
138
+    # Note: Internally, distribution permits multiple dashes and up to 2 underscores as separators.
139
+    # See https://github.com/docker/distribution/blob/master/reference/regexp.go
140
+
141
+    return len(image) < 256 and len(tag) < 129 and re.match('[a-zA-Z0-9_][a-zA-Z0-9_.-]*$', tag) and \
142
+           all(re.match('[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*$', path) for path in repo.split("/"))
143
+
144
+
145
+class RegistryCleaner():
146
+    '''Simple callable class for Docker Registry cleaning duties'''
147
+    def __init__(self, container_name):
148
+        self.docker = docker.from_env()
149
+
150
+        try:
151
+            self.info = self.docker.api.inspect_container(container_name)
152
+            self.container = self.info['Id']
153
+        except (APIError, exceptions.ConnectionError) as err:
154
+            error(str(err))
155
+
156
+        if self.info['Config']['Image'] != "registry:2":
157
+            error("The container %s is not running the registry:2 image" % (container_name))
158
+
159
+        if LooseVersion(self.get_image_version()) < LooseVersion("v2.4.0"):
160
+            error("You're not running Docker Registry 2.4.0+")
161
+
162
+        self.registry_dir = self.get_registry_dir()
163
+        try:
164
+            os.chdir(self.registry_dir + "/docker/registry/v2/repositories")
165
+        except FileNotFoundError as err:
166
+            error(err)
167
+
168
+        if dockerized() and not os.getenv("REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY"):
169
+            os.environ['REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY'] = self.registry_dir
170
+
171
+    def __call__(self):
172
+        self.docker.api.stop(self.container)
173
+
174
+        images = args.images if args.images else os.listdir(".")
175
+
176
+        rc = 0
177
+        for image in images:
178
+            if not clean_repo(image):
179
+                rc = 1
180
+
181
+        if not self.garbage_collect():
182
+            rc = 1
183
+
184
+        self.docker.api.start(self.container)
185
+        return rc
186
+
187
+    def get_file(self, filename):
188
+        '''Returns the contents of the specified file from the container'''
189
+        try:
190
+            with self.docker.api.get_archive(self.container, filename)[0] as tar_stream:
191
+                with BytesIO(tar_stream.data) as buf:
192
+                    with tarfile.open(fileobj=buf) as tarf:
193
+                        with tarf.extractfile(os.path.basename(filename)) as f:
194
+                            data = f.read()
195
+        except NotFound as err:
196
+            error(err)
197
+        return data
198
+
199
+    def get_registry_dir(self):
200
+        '''Gets the Registry directory'''
201
+        registry_dir = os.getenv("REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY")
202
+        if registry_dir:
203
+            return registry_dir
204
+
205
+        registry_dir = ""
206
+        for env in self.info['Config']['Env']:
207
+            var, value = env.split("=", 1)
208
+            if var == "REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY":
209
+                registry_dir = value
210
+                break
211
+
212
+        if not registry_dir:
213
+            config_yml = self.info['Args'][0]
214
+            data = yaml.load(self.get_file(config_yml))
215
+            try:
216
+                registry_dir = data['storage']['filesystem']['rootdirectory']
217
+            except KeyError:
218
+                error("Unsupported storage driver")
219
+
220
+        if dockerized():
221
+            return registry_dir
222
+
223
+        for item in self.info['Mounts']:
224
+            if item['Destination'] == registry_dir:
225
+                return item['Source']
226
+
227
+    def get_image_version(self):
228
+        '''Gets the Docker distribution version running on the container'''
229
+        if self.info['State']['Running']:
230
+            data = self.docker.containers.get(self.container).exec_run("/bin/registry --version").decode('utf-8')
231
+        else:
232
+            data = self.docker.containers.run(self.info["Image"], command="--version", remove=True).decode('utf-8')
233
+        return data.split()[2]
234
+
235
+    def garbage_collect(self):
236
+        '''Runs garbage-collect'''
237
+        command = "garbage-collect " + "/etc/docker/registry/config.yml"
238
+        if dockerized():
239
+            command = "/bin/registry " + command
240
+            with subprocess.Popen(command.split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as proc:
241
+                if not args.quiet:
242
+                    print(proc.stdout.read().decode('utf-8'))
243
+            status = proc.wait()
244
+        else:
245
+            cli = self.docker.containers.run("registry:2", command=command, detach=True,
246
+                                             volumes={self.registry_dir: {'bind': "/var/lib/registry", 'mode': "rw"}})
247
+            if not args.quiet:
248
+                for line in cli.logs(stream=True):
249
+                    print(line.decode('utf-8'), end="")
250
+            status = True if cli.wait() == 0 else False
251
+            cli.remove()
252
+        return status
253
+
254
+
255
+def main():
256
+    '''Main function'''
257
+    progname = os.path.basename(sys.argv[0])
258
+    usage = "\rUsage: " + progname + " [OPTIONS] CONTAINER [REPOSITORY[:TAG]]..." + """
259
+Options:
260
+        -x, --remove    Remove the specified images or repositories.
261
+        -q, --quiet     Supress non-error messages.
262
+        -V, --version   Show version and exit."""
263
+
264
+    parser = ArgumentParser(usage=usage, add_help=False)
265
+    parser.add_argument('-h', '--help', action='store_true')
266
+    parser.add_argument('-q', '--quiet', action='store_true')
267
+    parser.add_argument('-x', '--remove', action='store_true')
268
+    parser.add_argument('-V', '--version', action='store_true')
269
+    parser.add_argument('container')
270
+    parser.add_argument('images', nargs='*')
271
+    global args
272
+    args = parser.parse_args()
273
+
274
+    if args.help:
275
+        print('usage: ' + usage)
276
+        sys.exit(0)
277
+    elif args.version:
278
+        print(progname + " " + VERSION)
279
+        sys.exit(0)
280
+
281
+    for image in args.images:
282
+        if not check_name(image):
283
+            error("Invalid Docker repository/tag: " + image)
284
+
285
+    if args.remove and not args.images:
286
+        error("The -x option requires that you specify at least one repository...")
287
+
288
+    rc = RegistryCleaner(args.container)
289
+    sys.exit(rc())
290
+
291
+if __name__ == "__main__":
292
+    try:
293
+        main()
294
+    except KeyboardInterrupt:
295
+        sys.exit(1)

Laden…
Abbrechen
Speichern