File: image.py

package info (click to toggle)
python-tempestconf 3.5.1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 964 kB
  • sloc: python: 4,530; makefile: 18; sh: 9
file content (355 lines) | stat: -rw-r--r-- 14,128 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# Copyright 2013 Red Hat, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

import os
import shutil
import subprocess
import time

from functools import wraps

from six.moves import urllib
from tempest.lib import exceptions
from tenacity import retry
from tenacity import stop_after_attempt

from config_tempest import constants as C
from config_tempest.services.base import VersionedService


stop = stop_after_attempt(len(C.DEFAULT_IMAGES))


class ImageService(VersionedService):

    def __init__(self, name, s_type, service_url, token,
                 disable_ssl_validation, client=None, **kwargs):
        super(ImageService, self).__init__(
            name, s_type, service_url, token, disable_ssl_validation,
            client, **kwargs)
        self.retry_attempt = -1

    def set_image_preferences(self, disk_format, non_admin, no_rng=False,
                              convert=False):
        """Sets image prefferences.

        :type disk_format: string
        :type non_admin: bool
        :type no_rng: bool
        :type convert: bool
        """
        self.disk_format = disk_format
        self.non_admin = non_admin
        self.no_rng = no_rng
        self.convert = convert

    def set_default_tempest_options(self, conf):
        # set 'image-feature-enabled' only if multiple stores available
        if self._is_multistore_enabled():
            conf.set('image-feature-enabled', 'import_image', 'True')
        # When cirros is the image, set validation.image_ssh_user to cirros.
        # The option is heavily used in CI and it's also usefull for refstack,
        # because we don't have to specify overrides.
        if 'cirros' in conf.get_defaulted('image',
                                          'image_path').rsplit('/')[-1]:
            conf.set('validation', 'image_ssh_user', 'cirros')
        # image.http_image is a tempest option which defines 'http accessible
        # image', it can be in a compressed format so it can't be mistaken
        # for an image which will be uploaded to the glance.
        # image.http_image and image.image_path can be 2 different images.
        # If image.http_image wasn't set as an override, it will be set to
        # image.image_path or to DEFAULT_IMAGE
        image_path = conf.get_defaulted('image', 'image_path')
        if self._find_image_by_name(image_path) is None:
            conf.set('image', 'http_image', image_path)
        else:
            # image.image_path is name of the image already present in glance,
            # this value can't be set to image.http_image, therefor set the
            # default value
            conf.set('image', 'http_image', C.DEFAULT_IMAGE)
        conf.set('image', 'http_qcow2_image', C.DEFAULT_QCOW2_IMAGE)

    def _is_multistore_enabled(self):
        try:
            self.client.info_stores()['stores']
        except Exception:
            C.LOG.info('Can not retrieve stores, either multiple stores are '
                       'not configured or user are not allowed access '
                       'to the stores information')
            return False
        return True

    def get_supported_versions(self):
        return ['v1', 'v2']

    @staticmethod
    def get_service_type():
        return ['image']

    @staticmethod
    def get_codename():
        return 'glance'

    def set_versions(self):
        super(ImageService, self).set_versions(top_level=False)

    def create_tempest_images(self, conf, retry_alt=False):
        """Uploads an image to the glance.

        The method creates images specified in conf, if they're not created
        already. Then it sets their IDs to the conf.

        :type conf: TempestConf object
        """
        # the absolute path is necessary for supporting older tempest versions,
        # which had CONF.scenario.img_dir option, see this line of code:
        # https://github.com/openstack/tempest/blob/a0ee8b4ccfc512a09
        # e1ddb135950b767110aae9b/tempest/scenario/manager.py#L534
        # If the path is not an absolute one, the concatenation of strings ^^
        # will result in an invalid path
        # Moreover the absolute path is needed so that users can move the
        # generated tempest.conf outside of python-tempestconf destination,
        # otherwise tempest would fail accessing the CONF.scenario.img_file
        img_dir = os.path.abspath(os.path.join(C.DEFAULT_IMAGE_DIR))
        image_path = conf.get_defaulted('image', 'image_path')
        img_path = os.path.join(img_dir,
                                os.path.basename(image_path))
        name = image_path[image_path.rfind('/') + 1:]
        if self.convert and name[-4:] == ".img":
            name = name[:-4] + ".raw"
        # create img_dir if it doesn't exist already
        os.makedirs(img_dir, exist_ok=True)
        alt_name = name + "_alt"
        image_id = None
        if conf.has_option('compute', 'image_ref'):
            image_id = conf.get('compute', 'image_ref')
        image_id = self.find_or_upload_image(image_id, name,
                                             image_source=image_path,
                                             image_dest=img_path,
                                             retry_alt=retry_alt)
        alt_image_id = None
        if conf.has_option('compute', 'image_ref_alt'):
            alt_image_id = conf.get('compute', 'image_ref_alt')
        alt_image_id = self.find_or_upload_image(alt_image_id, alt_name,
                                                 image_source=image_path,
                                                 image_dest=img_path,
                                                 retry_alt=retry_alt)
        # get name of the image_id
        conf.set('scenario', 'img_file', img_path)
        conf.set('compute', 'image_ref', image_id)
        conf.set('compute', 'image_ref_alt', alt_image_id)

    def find_or_upload_image(self, image_id, image_name, image_source='',
                             image_dest='', retry_alt=False):
        """If the image is not found, uploads it.

        :type image_id: string
        :type image_name: string
        :type image_source: string
        :type image_dest: string
        """
        image = self._find_image(image_id, image_name)

        if image:
            C.LOG.info("(no change) Found image '%s'", image['name'])
            path = os.path.abspath(image_dest)
            if not os.path.isfile(path):
                self._download_image(image['id'], path)
        else:
            C.LOG.info("Creating image '%s'", image_name)
            if image_source.startswith("http:") or \
               image_source.startswith("https:"):
                try:
                    self._download_file(image_source, image_dest)
                    # We only download alternative image if the default image
                    # fails
                except Exception:
                    if retry_alt:
                        self._download_with_retry(image_dest)
            else:
                try:
                    shutil.copyfile(image_source, image_dest)
                except Exception:
                    # let's try if this is the case when a user uses already
                    # existing image in glance which is not uploaded as *_alt
                    if image_name[-4:] == "_alt":
                        image = self._find_image(None, image_name[:-4])
                        if image:
                            path = os.path.abspath(image_dest)
                            if not os.path.isfile(path):
                                self._download_image(image['id'], path)
                    else:
                        if retry_alt:
                            self._download_with_retry(image_dest)
                        else:
                            raise IOError
            image = self._upload_image(image_name, image_dest)
        return image['id']

    @retry(stop=stop)
    def _download_with_retry(self, destination):
        self.retry_attempt += 1
        self._download_file(C.DEFAULT_IMAGES[self.retry_attempt], destination)

    def _find_image(self, image_id, image_name):
        """Find image by ID or name (the image client doesn't have this).

        :type image_id: string
        :type image_name: string
        """
        if image_id:
            try:
                return self.client.show_image(image_id)
            except exceptions.NotFound:
                pass
        return self._find_image_by_name(image_name)

    def _find_image_by_name(self, image_name):
        """Find image by name.

        :type image_name: string
        :return: Information in a dict about the found image
        :rtype: dict or None if image was not found
        """
        for x in self.client.list_images()['images']:
            if x['name'] == image_name:
                return x
        return None

    def _upload_image(self, name, path):
        """Upload image file from `path` into Glance with `name`.

        :type name: string
        :type path: string
        """
        if self.convert:
            path = self.convert_image_to_raw(path)

        C.LOG.info("Uploading image '%s' from '%s'",
                   name, os.path.abspath(path))
        if self.non_admin:
            visibility = 'community'
        else:
            visibility = 'public'

        with open(path, 'rb') as data:
            args = {'name': name, 'disk_format': self.disk_format,
                    'container_format': 'bare', 'visibility': visibility,
                    'hw_rng_model': 'virtio'}
            if self.no_rng:
                args.pop('hw_rng_model')
            image = self.client.create_image(**args)
            self.client.store_image_file(image['id'], data)
        return image

    def _download_image(self, id, path):
        """Download image from glance.

        :type id: string
        :type path: string
        """
        C.LOG.info("Downloading image %s to %s", id, path)
        body = self.client.show_image_file(id)
        C.LOG.debug(type(body.data))
        with open(path, 'wb') as out:
            out.write(body.data)

    def retry(ExceptionToCheck, tries=4, delay=3, backoff=2, logger=None):
        """Retry calling the decorated function using exponential backoff

        http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/
        original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry

        Licensed under the BSD 3-Clause "New" or "Revised" License
        (https://github.com/saltycrane/retry-decorator/blob/master/LICENSE)

        :param ExceptionToCheck: the exception to check
        :type ExceptionToCheck: Exception or tuple
        :param tries: number of times before giving up
        :type type: int
        :param delay: initial delay between retries in seconds
        :type type: int
        :param backoff: backoff multiplier e.g. value of 2 will double the
            delay each retry
        :type backoff: int
        :param logger: logger to use. If None, print
        :type logger: logging. Logger instance
        """
        def deco_retry(f):
            @wraps(f)
            def f_retry(*args, **kwargs):
                mtries, mdelay = tries, delay
                while mtries > 1:
                    try:
                        return f(*args, **kwargs)
                    except ExceptionToCheck as e:
                        msg = "%s, Retrying in %d seconds." % (str(e), mdelay)
                        if logger:
                            logger.warning(msg)
                        else:
                            print(msg)
                        time.sleep(mdelay)
                        mtries -= 1
                        mdelay *= backoff
                return f(*args, **kwargs)
            return f_retry
        return deco_retry

    @retry(urllib.error.URLError, logger=C.LOG)
    def retry_urlopen(self, url):
        """Opens url using urlopen. If it fails, it will try again.

        :type url: string
        """
        return urllib.request.urlopen(url)

    def _download_file(self, url, destination):
        """Downloads a file specified by `url` to `destination`.

        :type url: string
        :type destination: string
        """
        if os.path.exists(destination):
            C.LOG.info("Image '%s' already fetched to '%s'.", url, destination)
            return
        C.LOG.info("Downloading '%s' and saving as '%s'", url, destination)
        f = self.retry_urlopen(url)
        data = f.read()
        with open(destination, "wb") as dest:
            dest.write(data)

    def convert_image_to_raw(self, path):
        """Converts given image to raw format.

        :type path: string
        :return: path of the converted image
        :rtype: string
        """
        head, tail = os.path.split(path)
        name = tail.rsplit('.', 1)[0] + '.raw'
        raw_path = os.path.join(head, name)
        # check if converted already
        if os.path.exists(raw_path):
            C.LOG.info("Image already converted in '%s'.", raw_path)
        else:
            C.LOG.info("Converting image '%s' to '%s'",
                       os.path.abspath(path), os.path.abspath(raw_path))
            rc = subprocess.call(['qemu-img', 'convert', path, raw_path])
            if rc != 0:
                raise Exception("Converting of the image has finished with "
                                "non-zero return code. The return code was "
                                "'%d'", rc)
        self.disk_format = 'raw'
        return raw_path