Merge branch 'master' into 3453-arv-list-docker-images
[arvados.git] / sdk / python / arvados / commands / keepdocker.py
1 #!/usr/bin/env python
2
3 import argparse
4 import datetime
5 import errno
6 import json
7 import os
8 import subprocess
9 import sys
10 import tarfile
11 import tempfile
12 import textwrap
13
14 from collections import namedtuple
15 from stat import *
16
17 import arvados
18 import arvados.commands._util as arv_cmd
19 import arvados.commands.put as arv_put
20
21 STAT_CACHE_ERRORS = (IOError, OSError, ValueError)
22
23 DockerImage = namedtuple('DockerImage',
24                          ['repo', 'tag', 'hash', 'created', 'vsize'])
25
26 keepdocker_parser = argparse.ArgumentParser(add_help=False)
27 keepdocker_parser.add_argument(
28     '-f', '--force', action='store_true', default=False,
29     help="Re-upload the image even if it already exists on the server")
30
31 _group = keepdocker_parser.add_mutually_exclusive_group()
32 _group.add_argument(
33     '--pull', action='store_true', default=False,
34     help="Try to pull the latest image from Docker registry")
35 _group.add_argument(
36     '--no-pull', action='store_false', dest='pull',
37     help="Use locally installed image only, don't pull image from Docker registry (default)")
38
39 keepdocker_parser.add_argument(
40     'image', nargs='?',
41     help="Docker image to upload, as a repository name or hash")
42 keepdocker_parser.add_argument(
43     'tag', nargs='?', default='latest',
44     help="Tag of the Docker image to upload (default 'latest')")
45
46 # Combine keepdocker options listed above with run_opts options of arv-put.
47 # The options inherited from arv-put include --name, --project-uuid,
48 # --progress/--no-progress/--batch-progress and --resume/--no-resume.
49 arg_parser = argparse.ArgumentParser(
50         description="Upload or list Docker images in Arvados",
51         parents=[keepdocker_parser, arv_put.run_opts])
52
53 class DockerError(Exception):
54     pass
55
56
57 def popen_docker(cmd, *args, **kwargs):
58     manage_stdin = ('stdin' not in kwargs)
59     kwargs.setdefault('stdin', subprocess.PIPE)
60     kwargs.setdefault('stdout', sys.stderr)
61     try:
62         docker_proc = subprocess.Popen(['docker.io'] + cmd, *args, **kwargs)
63     except OSError:  # No docker.io in $PATH
64         docker_proc = subprocess.Popen(['docker'] + cmd, *args, **kwargs)
65     if manage_stdin:
66         docker_proc.stdin.close()
67     return docker_proc
68
69 def check_docker(proc, description):
70     proc.wait()
71     if proc.returncode != 0:
72         raise DockerError("docker {} returned status code {}".
73                           format(description, proc.returncode))
74
75 def docker_images():
76     # Yield a DockerImage tuple for each installed image.
77     list_proc = popen_docker(['images', '--no-trunc'], stdout=subprocess.PIPE)
78     list_output = iter(list_proc.stdout)
79     next(list_output)  # Ignore the header line
80     for line in list_output:
81         words = line.split()
82         size_index = len(words) - 2
83         repo, tag, imageid = words[:3]
84         ctime = ' '.join(words[3:size_index])
85         vsize = ' '.join(words[size_index:])
86         yield DockerImage(repo, tag, imageid, ctime, vsize)
87     list_proc.stdout.close()
88     check_docker(list_proc, "images")
89
90 def find_image_hashes(image_search, image_tag=None):
91     # Given one argument, search for Docker images with matching hashes,
92     # and return their full hashes in a set.
93     # Given two arguments, also search for a Docker image with the
94     # same repository and tag.  If one is found, return its hash in a
95     # set; otherwise, fall back to the one-argument hash search.
96     # Returns None if no match is found, or a hash search is ambiguous.
97     hash_search = image_search.lower()
98     hash_matches = set()
99     for image in docker_images():
100         if (image.repo == image_search) and (image.tag == image_tag):
101             return set([image.hash])
102         elif image.hash.startswith(hash_search):
103             hash_matches.add(image.hash)
104     return hash_matches
105
106 def find_one_image_hash(image_search, image_tag=None):
107     hashes = find_image_hashes(image_search, image_tag)
108     hash_count = len(hashes)
109     if hash_count == 1:
110         return hashes.pop()
111     elif hash_count == 0:
112         raise DockerError("no matching image found")
113     else:
114         raise DockerError("{} images match {}".format(hash_count, image_search))
115
116 def stat_cache_name(image_file):
117     return getattr(image_file, 'name', image_file) + '.stat'
118
119 def pull_image(image_name, image_tag):
120     check_docker(popen_docker(['pull', '-t', image_tag, image_name]), "pull")
121
122 def save_image(image_hash, image_file):
123     # Save the specified Docker image to image_file, then try to save its
124     # stats so we can try to resume after interruption.
125     check_docker(popen_docker(['save', image_hash], stdout=image_file),
126                  "save")
127     image_file.flush()
128     try:
129         with open(stat_cache_name(image_file), 'w') as statfile:
130             json.dump(tuple(os.fstat(image_file.fileno())), statfile)
131     except STAT_CACHE_ERRORS:
132         pass  # We won't resume from this cache.  No big deal.
133
134 def prep_image_file(filename):
135     # Return a file object ready to save a Docker image,
136     # and a boolean indicating whether or not we need to actually save the
137     # image (False if a cached save is available).
138     cache_dir = arv_cmd.make_home_conf_dir(
139         os.path.join('.cache', 'arvados', 'docker'), 0o700)
140     if cache_dir is None:
141         image_file = tempfile.NamedTemporaryFile(suffix='.tar')
142         need_save = True
143     else:
144         file_path = os.path.join(cache_dir, filename)
145         try:
146             with open(stat_cache_name(file_path)) as statfile:
147                 prev_stat = json.load(statfile)
148             now_stat = os.stat(file_path)
149             need_save = any(prev_stat[field] != now_stat[field]
150                             for field in [ST_MTIME, ST_SIZE])
151         except STAT_CACHE_ERRORS + (AttributeError, IndexError):
152             need_save = True  # We couldn't compare against old stats
153         image_file = open(file_path, 'w+b' if need_save else 'rb')
154     return image_file, need_save
155
156 def make_link(link_class, link_name, **link_attrs):
157     link_attrs.update({'link_class': link_class, 'name': link_name})
158     return arvados.api('v1').links().create(body=link_attrs).execute()
159
160 def ptimestamp(t):
161     s = t.split(".")
162     if len(s) == 2:
163         t = s[0] + s[1][-1:]
164     return datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%SZ")
165
166 def list_images_in_arv():
167     existing_links = arvados.api('v1').links().list(filters=[['link_class', 'in', ['docker_image_hash', 'docker_image_repo+tag']]]).execute()['items']
168     images = {}
169     for link in existing_links:
170         collection_uuid = link["head_uuid"]
171         if collection_uuid not in images:
172             images[collection_uuid]= {"dockerhash": "<none>",
173                       "repo":"<none>",
174                       "tag":"<none>",
175                       "timestamp": ptimestamp("1970-01-01T00:00:01Z")}
176
177         if link["link_class"] == "docker_image_hash":
178             images[collection_uuid]["dockerhash"] = link["name"]
179
180         if link["link_class"] == "docker_image_repo+tag":
181             r = link["name"].split(":")
182             images[collection_uuid]["repo"] = r[0]
183             if len(r) > 1:
184                 images[collection_uuid]["tag"] = r[1]
185
186         if "image_timestamp" in link["properties"]:
187             images[collection_uuid]["timestamp"] = ptimestamp(link["properties"]["image_timestamp"])
188         else:
189             images[collection_uuid]["timestamp"] = ptimestamp(link["created_at"])
190
191     st = sorted(images.items(), lambda a, b: cmp(b[1]["timestamp"], a[1]["timestamp"]))
192
193     fmt = "{:30}  {:10}  {:12}  {:29}  {:20}"
194     print fmt.format("REPOSITORY", "TAG", "IMAGE ID", "COLLECTION", "CREATED")
195     for i, j in st:
196         print(fmt.format(j["repo"], j["tag"], j["dockerhash"][0:11], i, j["timestamp"].strftime("%c")))
197
198 def main(arguments=None):
199     args = arg_parser.parse_args(arguments)
200
201     if args.image is None or args.image == 'images':
202         list_images_in_arv()
203         sys.exit(0)
204
205     # Pull the image if requested, unless the image is specified as a hash
206     # that we already have.
207     if args.pull and not find_image_hashes(args.image):
208         pull_image(args.image, args.tag)
209
210     try:
211         image_hash = find_one_image_hash(args.image, args.tag)
212     except DockerError as error:
213         print >>sys.stderr, "arv-keepdocker:", error.message
214         sys.exit(1)
215
216     image_repo_tag = '{}:{}'.format(args.image, args.tag)
217
218     if args.name is None:
219         collection_name = 'Docker image {} {}'.format(image_repo_tag, image_hash[0:11])
220     else:
221         collection_name = args.name
222
223     api = arvados.api('v1')
224
225     if not args.force:
226         # Check if this image is already in Arvados.
227
228         # Project where everything should be owned
229         parent_project_uuid = args.project_uuid if args.project_uuid else api.users().current().execute()['uuid']
230
231         # Find image hash tags
232         existing_links = api.links().list(
233             filters=[['link_class', '=', 'docker_image_hash'],
234                      ['name', '=', image_hash]]).execute()['items']
235         if existing_links:
236             # get readable collections
237             collections = api.collections().list(
238                 filters=[['uuid', 'in', [link['head_uuid'] for link in existing_links]]], 
239                 select=["uuid", "owner_uuid", "name", "manifest_text"]).execute()['items']
240
241             if collections:
242                 # check for repo+tag links on these collections
243                 existing_repo_tag = api.links().list(
244                     filters=[['link_class', '=', 'docker_image_repo+tag'],
245                              ['name', '=', image_repo_tag],
246                              ['head_uuid', 'in', collections]]).execute()['items']
247
248                 # Filter on elements owned by the parent project
249                 owned_col = [c for c in collections if c['owner_uuid'] == parent_project_uuid]
250                 owned_img = [c for c in existing_links if c['owner_uuid'] == parent_project_uuid] 
251                 owned_rep = [c for c in existing_repo_tag if c['owner_uuid'] == parent_project_uuid] 
252
253                 if owned_col:
254                     # already have a collection owned by this project
255                     coll_uuid = owned_col[0]['uuid']
256                 else:
257                     # create new collection owned by the project
258                     coll_uuid = api.collections().create(body={"manifest_text": collections[0]['manifest_text'], 
259                                                                "name": collection_name, 
260                                                                "owner_uuid": parent_project_uuid}).execute()['uuid']
261
262                 link_base = {'owner_uuid': parent_project_uuid, 
263                              'head_uuid':  coll_uuid }
264
265                 if not owned_img:
266                     # create image link owned by the project
267                     make_link('docker_image_hash', image_hash, **link_base)
268
269                 if not owned_rep:
270                     # create repo+tag link owned by the project
271                     make_link('docker_image_repo+tag', image_repo_tag, **link_base)
272
273                 print(coll_uuid)
274
275                 sys.exit(0)                
276
277     # Open a file for the saved image, and write it if needed.
278     outfile_name = '{}.tar'.format(image_hash)
279     image_file, need_save = prep_image_file(outfile_name)
280     if need_save:
281         save_image(image_hash, image_file)
282
283     # Call arv-put with switches we inherited from it
284     # (a.k.a., switches that aren't our own).
285     put_args = keepdocker_parser.parse_known_args(arguments)[1]
286
287     if args.name is None:
288         put_args += ['--name', collection_name]
289
290     coll_uuid = arv_put.main(
291         put_args + ['--filename', outfile_name, image_file.name]).strip()
292
293     # Read the image metadata and make Arvados links from it.
294     image_file.seek(0)
295     image_tar = tarfile.open(fileobj=image_file)
296     json_file = image_tar.extractfile(image_tar.getmember(image_hash + '/json'))
297     image_metadata = json.load(json_file)
298     json_file.close()
299     image_tar.close()
300     link_base = {'head_uuid': coll_uuid, 'properties': {}}
301     if 'created' in image_metadata:
302         link_base['properties']['image_timestamp'] = image_metadata['created']
303     if args.project_uuid is not None:
304         link_base['owner_uuid'] = args.project_uuid
305
306     make_link('docker_image_hash', image_hash, **link_base)
307     if not image_hash.startswith(args.image.lower()):
308         make_link('docker_image_repo+tag', image_repo_tag,
309                   **link_base)
310
311     # Clean up.
312     image_file.close()
313     for filename in [stat_cache_name(image_file), image_file.name]:
314         try:
315             os.unlink(filename)
316         except OSError as error:
317             if error.errno != errno.ENOENT:
318                 raise
319
320 if __name__ == '__main__':
321     main()