blob: e4e742b3bcde2229df2b9b2a0e0e16bab677f5a2 [file] [log] [blame]
Milan Verespej87866322019-04-18 14:37:51 +02001#! /usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# COPYRIGHT NOTICE STARTS HERE
5
6# Copyright 2019 © Samsung Electronics Co., Ltd.
7#
8# Licensed under the Apache License, Version 2.0 (the "License");
9# you may not use this file except in compliance with the License.
10# You may obtain a copy of the License at
11#
12# http://www.apache.org/licenses/LICENSE-2.0
13#
14# Unless required by applicable law or agreed to in writing, software
15# distributed under the License is distributed on an "AS IS" BASIS,
16# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17# See the License for the specific language governing permissions and
18# limitations under the License.
19
20# COPYRIGHT NOTICE ENDS HERE
21
22
23import argparse
24import concurrent.futures
25import docker
26import itertools
27import json
28import logging
29import os
30import prettytable
31import sys
32import threading
33from retrying import retry
34
35import base
36
37log = logging.getLogger(__name__)
38
39
40def image_filename(image_name):
41 """
42 Get a name of a file where image will be saved.
43 :param image_name: Name of the image from list
44 :return: Filename of the image
45 """
46 return '{}.tar'.format(image_name.replace(':', '_').replace('/', '_'))
47
48
49def image_registry_name(image_name):
50 """
51 Get the name as shown in local registry. Since some strings are not part of name
52 when using default registry e.g. docker.io
53 :param image_name: name of the image from the list
54 :return: name of the image as it is shown by docker
55 """
56 name = image_name
57
58 if name.startswith('docker.io/'):
59 name = name.replace('docker.io/', '')
60
61 if name.startswith('library/'):
62 name = name.replace('library/', '')
63
64 if ':' not in name.rsplit('/')[-1]:
65 name = '{}:latest'.format(name)
66
67 return name
68
69
70def not_pulled_images(docker_client, target_list):
71 """
72 Get set of images that are not pulled on local system.
73 :param docker_client: docker.client.DockerClient
74 :param target_list: list of images to look for
75 :return: (set) images that are not present on local system
76 """
77 pulled = set(itertools.chain.from_iterable((image.tags for image
78 in docker_client.images.list())))
79 return {image for image in target_list if image_registry_name(image) not in pulled}
80
81
82def not_saved(target_images, target_dir):
83 """
84 Get set of images that are not saved in target directory
85 :param target_images: List of images to check for
86 :param target_dir: Directory where those images should be
87 :return: (set) Images that are missing from target directory
88 """
89 return set(image for image in target_images
90 if not os.path.isfile('/'.join((target_dir, image_filename(image)))))
91
92
93def missing(docker_client, target_list, save, target_dir):
94 """
95 Get dictionary of images not present locally.
96 :param docker_client: docker.client.DockerClient for communication with docker
97 :param target_list: list of desired images
98 :param save: (boolean) check for saved images
99 :param target_dir: target directory for saved images
100 :return: Dictionary of missing images ('not_pulled', 'not_saved')
101 """
102 return {'not_pulled': not_pulled_images(docker_client, target_list),
103 'not_saved': not_saved(target_list, target_dir) if save else set()}
104
105
106def merge_dict_sets(dictionary):
107 return set.union(*dictionary.values())
108
109
110def check_table(check_list, missing, save):
111 table = prettytable.PrettyTable(['Image', 'Pulled', 'Saved'])
112 table.align['Image'] = 'l'
113 for image in sorted(check_list):
114 pulled = not image in missing['not_pulled']
115 download_state = [pulled]
116 if save:
117 # if not pulled save anyway
118 download_state.append(pulled and not image in missing['not_saved'])
119 else:
120 download_state.append('Not checked')
121 table.add_row([image] + download_state)
122 return table
123
124
125@retry(stop_max_attempt_number=5, wait_fixed=5000)
126def pull_image(docker_client, image_name):
127 """
128 Pull docker image.
129 :param docker_client: docker.client.DockerClient for communication with docker
130 :param image_name: name of the image to be pulled
131 :return: pulled image (image object)
132 :raises docker.errors.APIError: after unsuccessful retries
133 """
134 if ':' not in image_name.rsplit('/')[-1]:
135 image_name = '{}:latest'.format(image_name)
136 try:
137 image = docker_client.images.pull(image_name)
138 log.info('Image {} pulled'.format(image_name))
139 return image
140 except docker.errors.APIError as err:
141 log.warning('Failed: {}: {}. Retrying...'.format(image_name, err))
142 raise err
143
144
145def save_image(image_name, image, output_dir, docker_client=None):
146 """
147 Save image to tar.
148 :param output_dir: path to destination directory
149 :param image: image object from pull_image function
150 :param image_name: name of the image from list
151 :param docker_client: docker.client.DockerClient for communication with docker
152 :return: None
153 """
154 dst = '{}/{}'.format(output_dir, image_filename(image_name))
155 if not os.path.exists(output_dir):
156 os.makedirs(output_dir)
157 if not isinstance(image, docker.models.images.Image):
158 image = docker_client.images.get(image_name)
159 try:
160 with open(dst, 'wb') as f:
161 for chunk in image.save(named=image_registry_name(image_name)):
162 f.write(chunk)
163 log.info('Image {} saved as {}'.format(image_name, dst))
164 except Exception as err:
165 os.remove(dst)
166 raise err
167
168
169def download_docker_image(image, save, output_dir, docker_client):
170 """ Pull and save docker image from specified docker registry
171 :param docker_client: docker.client.DockerClient for communication with docker
172 :param image: image to be downloaded
173 :param save: boolean - save image to disk or skip saving
174 :param output_dir: directory where image will be saved
175 :return: None
176 """
177 log.info('Downloading image: {}'.format(image))
178 try:
179 pulled_image = pull_image(docker_client, image)
180 if save:
181 save_image(image, pulled_image, output_dir)
182 except Exception as err:
183 log.error('Error downloading {}: {}'.format(image, err))
184 raise err
185
186
187def download(image_list, save, output_dir, check_mode, progress, workers=3):
188 """
189 Download images from list
190 :param image_list: list of images to be downloaded
191 :param save: whether images should be saved to disk
192 :param output_dir: directory where images will be saved
193 :param check_mode: only check for missing images. No download
194 :param progress_bar: progressbar.ProgressBar to show how far download is
195 :return: None
196 """
197 try:
198 docker_client = docker.client.DockerClient(version='auto')
199 except docker.errors.DockerException as err:
200 log.error(err)
201 log.error('Error creating docker client. Check if is docker installed and running'
202 ' or if you have right permissions.')
203 raise err
204
205 target_images = base.load_list(image_list)
206 missing_images = missing(docker_client, target_images, save, output_dir)
207
208 if check_mode:
209 log.info(check_table(target_images, missing_images, save))
210 return
211
212 skipping = target_images - merge_dict_sets(missing_images)
213
214 base.start_progress(progress, len(target_images), skipping, log)
215
216 # if pulling and save is True. Save every pulled image to assure parity
217 error_count = base.run_concurrent(workers, progress, download_docker_image, missing_images['not_pulled'],
218 save, output_dir, docker_client)
219 # only save those that are pulled already but not saved
220 error_count += base.run_concurrent(workers, progress, save_image,
221 missing_images['not_saved'] - missing_images['not_pulled'],
222 None, output_dir, docker_client)
223
224 if error_count > 0:
225 log.error('{} images were not downloaded'.format(error_count))
226 missing_images = missing(docker_client, target_images, save, output_dir)
227 log.info(check_table(merge_dict_sets(missing_images), missing_images, save))
228
229 base.finish_progress(progress, error_count, log)
230
231 return error_count
232
233
234def run_cli():
235 parser = argparse.ArgumentParser(description='Download docker images from list')
236 parser.add_argument('image_list', metavar='image-list',
237 help='File with list of images to download.')
238 parser.add_argument('--save', '-s', action='store_true', default=False,
239 help='Save images (without it only pull is executed)')
240 parser.add_argument('--output-dir', '-o', default=os.getcwd(),
241 help='Download destination')
242 parser.add_argument('--check', '-c', action='store_true', default=False,
243 help='Check what is missing. No download.'
244 'Use with combination with -s to check saved images as well.')
245 parser.add_argument('--debug', action='store_true', default=False,
246 help='Turn on debug output')
247 parser.add_argument('--workers', type=int, default=3,
248 help='Set maximum workers for parallel download (default: 3)')
249
250 args = parser.parse_args()
251
252 if args.debug:
253 logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
254 else:
255 logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(message)s')
256
257 progress = base.init_progress('Docker images') if not args.check else None
258 try:
259 sys.exit(download(args.image_list, args.save, args.output_dir, args.check,
260 progress, args.workers))
261 except docker.errors.DockerException:
262 log.error('Irrecoverable error detected.')
263 sys.exit(1)
264
265
266if __name__ == '__main__':
267 run_cli()
268