1# Copyright 2012 OpenStack Foundation
2# All Rights Reserved.
3#
4#    Licensed under the Apache License, Version 2.0 (the "License"); you may
5#    not use this file except in compliance with the License. You may obtain
6#    a copy of the License at
7#
8#         http://www.apache.org/licenses/LICENSE-2.0
9#
10#    Unless required by applicable law or agreed to in writing, software
11#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13#    License for the specific language governing permissions and limitations
14#    under the License.
15
16import hashlib
17import json
18from oslo_utils import encodeutils
19from requests import codes
20import urllib.parse
21import warlock
22
23from glanceclient.common import utils
24from glanceclient import exc
25from glanceclient.v2 import schemas
26
27DEFAULT_PAGE_SIZE = 20
28
29SORT_DIR_VALUES = ('asc', 'desc')
30SORT_KEY_VALUES = ('name', 'status', 'container_format', 'disk_format',
31                   'size', 'id', 'created_at', 'updated_at')
32
33
34class Controller(object):
35    def __init__(self, http_client, schema_client):
36        self.http_client = http_client
37        self.schema_client = schema_client
38
39    @utils.memoized_property
40    def model(self):
41        schema = self.schema_client.get('image')
42        warlock_model = warlock.model_factory(
43            schema.raw(), base_class=schemas.SchemaBasedModel)
44        return warlock_model
45
46    @utils.memoized_property
47    def unvalidated_model(self):
48        """A model which does not validate the image against the v2 schema."""
49        schema = self.schema_client.get('image')
50        warlock_model = warlock.model_factory(
51            schema.raw(), base_class=schemas.SchemaBasedModel)
52        warlock_model.validate = lambda *args, **kwargs: None
53        return warlock_model
54
55    @staticmethod
56    def _wrap(value):
57        if isinstance(value, str):
58            return [value]
59        return value
60
61    @staticmethod
62    def _validate_sort_param(sort):
63        """Validates sorting argument for invalid keys and directions values.
64
65        :param sort: comma-separated list of sort keys with optional <:dir>
66        after each key
67        """
68        for sort_param in sort.strip().split(','):
69            key, _sep, dir = sort_param.partition(':')
70            if dir and dir not in SORT_DIR_VALUES:
71                msg = ('Invalid sort direction: %(sort_dir)s.'
72                       ' It must be one of the following: %(available)s.'
73                       ) % {'sort_dir': dir,
74                            'available': ', '.join(SORT_DIR_VALUES)}
75                raise exc.HTTPBadRequest(msg)
76            if key not in SORT_KEY_VALUES:
77                msg = ('Invalid sort key: %(sort_key)s.'
78                       ' It must be one of the following: %(available)s.'
79                       ) % {'sort_key': key,
80                            'available': ', '.join(SORT_KEY_VALUES)}
81                raise exc.HTTPBadRequest(msg)
82        return sort
83
84    @utils.add_req_id_to_generator()
85    def list(self, **kwargs):
86        """Retrieve a listing of Image objects.
87
88        :param page_size: Number of images to request in each
89                          paginated request.
90        :returns: generator over list of Images.
91        """
92
93        limit = kwargs.get('limit')
94        # NOTE(flaper87): Don't use `get('page_size', DEFAULT_SIZE)` otherwise,
95        # it could be possible to send invalid data to the server by passing
96        # page_size=None.
97        page_size = kwargs.get('page_size') or DEFAULT_PAGE_SIZE
98
99        def paginate(url, page_size, limit=None):
100            next_url = url
101            req_id_hdr = {}
102
103            while True:
104                if limit and page_size > limit:
105                    # NOTE(flaper87): Avoid requesting 2000 images when limit
106                    # is 1
107                    next_url = next_url.replace("limit=%s" % page_size,
108                                                "limit=%s" % limit)
109
110                resp, body = self.http_client.get(next_url, headers=req_id_hdr)
111                # NOTE(rsjethani): Store curent request id so that it can be
112                # used in subsequent requests. Refer bug #1525259
113                req_id_hdr['x-openstack-request-id'] = \
114                    utils._extract_request_id(resp)
115
116                for image in body['images']:
117                    # NOTE(bcwaldon): remove 'self' for now until we have
118                    # an elegant way to pass it into the model constructor
119                    # without conflict.
120                    image.pop('self', None)
121                    # We do not validate the model when listing.
122                    # This prevents side-effects of injecting invalid
123                    # schema values via v1.
124                    yield self.unvalidated_model(**image), resp
125                    if limit:
126                        limit -= 1
127                        if limit <= 0:
128                            return
129
130                try:
131                    next_url = body['next']
132                except KeyError:
133                    return
134
135        filters = kwargs.get('filters', {})
136        # NOTE(flaper87): We paginate in the client, hence we use
137        # the page_size as Glance's limit.
138        filters['limit'] = page_size
139
140        tags = filters.pop('tag', [])
141        tags_url_params = []
142
143        for tag in tags:
144            if not isinstance(tag, str):
145                raise exc.HTTPBadRequest("Invalid tag value %s" % tag)
146
147            tags_url_params.append({'tag': encodeutils.safe_encode(tag)})
148
149        for param, value in filters.items():
150            if isinstance(value, str):
151                filters[param] = encodeutils.safe_encode(value)
152
153        url = '/v2/images?%s' % urllib.parse.urlencode(filters)
154
155        for param in tags_url_params:
156            url = '%s&%s' % (url, urllib.parse.urlencode(param))
157
158        if 'sort' in kwargs:
159            if 'sort_key' in kwargs or 'sort_dir' in kwargs:
160                raise exc.HTTPBadRequest("The 'sort' argument is not supported"
161                                         " with 'sort_key' or 'sort_dir'.")
162            url = '%s&sort=%s' % (url,
163                                  self._validate_sort_param(
164                                      kwargs['sort']))
165        else:
166            sort_dir = self._wrap(kwargs.get('sort_dir', []))
167            sort_key = self._wrap(kwargs.get('sort_key', []))
168
169            if len(sort_key) != len(sort_dir) and len(sort_dir) > 1:
170                raise exc.HTTPBadRequest(
171                    "Unexpected number of sort directions: "
172                    "either provide a single sort direction or an equal "
173                    "number of sort keys and sort directions.")
174            for key in sort_key:
175                url = '%s&sort_key=%s' % (url, key)
176
177            for dir in sort_dir:
178                url = '%s&sort_dir=%s' % (url, dir)
179
180        if isinstance(kwargs.get('marker'), str):
181            url = '%s&marker=%s' % (url, kwargs['marker'])
182
183        for image, resp in paginate(url, page_size, limit):
184            yield image, resp
185
186    @utils.add_req_id_to_object()
187    def _get(self, image_id, header=None):
188        url = '/v2/images/%s' % image_id
189        header = header or {}
190        resp, body = self.http_client.get(url, headers=header)
191        # NOTE(bcwaldon): remove 'self' for now until we have an elegant
192        # way to pass it into the model constructor without conflict
193        body.pop('self', None)
194        return self.unvalidated_model(**body), resp
195
196    def get(self, image_id):
197        return self._get(image_id)
198
199    @utils.add_req_id_to_object()
200    def get_associated_image_tasks(self, image_id):
201        """Get the tasks associated with an image.
202
203        :param image_id: ID of the image
204        :raises: exc.HTTPNotImplemented if Glance is not new enough to support
205                 this API (v2.12).
206        """
207        # NOTE (abhishekk): Verify that /v2i/images/%s/tasks is supported by
208        # glance
209        if utils.has_version(self.http_client, 'v2.12'):
210            url = '/v2/images/%s/tasks' % image_id
211            resp, body = self.http_client.get(url)
212            body.pop('self', None)
213            return body, resp
214        else:
215            raise exc.HTTPNotImplemented(
216                'This operation is not supported by Glance.')
217
218    @utils.add_req_id_to_object()
219    def data(self, image_id, do_checksum=True, allow_md5_fallback=False):
220        """Retrieve data of an image.
221
222        When do_checksum is enabled, validation proceeds as follows:
223
224        1. if the image has a 'os_hash_value' property, the algorithm
225           specified in the image's 'os_hash_algo' property will be used
226           to validate against the 'os_hash_value' value.  If the
227           specified hash algorithm is not available AND allow_md5_fallback
228           is True, then continue to step #2
229        2. else if the image has a checksum property, MD5 is used to
230           validate against the 'checksum' value.  (If MD5 is not available
231           to the client, the download fails.)
232        3. else if the download response has a 'content-md5' header, MD5
233           is used to validate against the header value.  (If MD5 is not
234           available to the client, the download fails.)
235        4. if none of 1-3 obtain, the data is **not validated** (this is
236           compatible with legacy behavior)
237
238        :param image_id:    ID of the image to download
239        :param do_checksum: Enable/disable checksum validation
240        :param allow_md5_fallback:
241            Use the MD5 checksum for validation if the algorithm specified by
242            the image's 'os_hash_algo' property is not available
243        :returns: An iterable body or ``None``
244        """
245        if do_checksum:
246            # doing this first to prevent race condition if image record
247            # is deleted during the image download
248            url = '/v2/images/%s' % image_id
249            resp, image_meta = self.http_client.get(url)
250            meta_checksum = image_meta.get('checksum', None)
251            meta_hash_value = image_meta.get('os_hash_value', None)
252            meta_hash_algo = image_meta.get('os_hash_algo', None)
253
254        url = '/v2/images/%s/file' % image_id
255        resp, body = self.http_client.get(url)
256        if resp.status_code == codes.no_content:
257            return None, resp
258
259        checksum = resp.headers.get('content-md5', None)
260        content_length = int(resp.headers.get('content-length', 0))
261
262        check_md5sum = do_checksum
263        if do_checksum and meta_hash_value is not None:
264            try:
265                hasher = hashlib.new(str(meta_hash_algo))
266                body = utils.serious_integrity_iter(body,
267                                                    hasher,
268                                                    meta_hash_value)
269                check_md5sum = False
270            except ValueError as ve:
271                if (str(ve).startswith('unsupported hash type') and
272                        allow_md5_fallback):
273                    check_md5sum = True
274                else:
275                    raise
276
277        if do_checksum and check_md5sum:
278            if meta_checksum is not None:
279                body = utils.integrity_iter(body, meta_checksum)
280            elif checksum is not None:
281                body = utils.integrity_iter(body, checksum)
282            else:
283                # NOTE(rosmaita): this preserves legacy behavior to return the
284                # image data when checksumming is requested but there's no
285                # 'content-md5' header in the response.  Just want to make it
286                # clear that we're doing this on purpose.
287                pass
288
289        return utils.IterableWithLength(body, content_length), resp
290
291    @utils.add_req_id_to_object()
292    def upload(self, image_id, image_data, image_size=None, u_url=None,
293               backend=None):
294        """Upload the data for an image.
295
296        :param image_id: ID of the image to upload data for.
297        :param image_data: File-like object supplying the data to upload.
298        :param image_size: Unused - present for backwards compatibility
299        :param u_url: Upload url to upload the data to.
300        :param backend: Backend store to upload image to.
301        """
302        url = u_url or '/v2/images/%s/file' % image_id
303        hdrs = {'Content-Type': 'application/octet-stream'}
304        if backend is not None:
305            hdrs['x-image-meta-store'] = backend
306
307        body = image_data
308        resp, body = self.http_client.put(url, headers=hdrs, data=body)
309        return (resp, body), resp
310
311    @utils.add_req_id_to_object()
312    def get_import_info(self):
313        """Get Import info from discovery endpoint."""
314        url = '/v2/info/import'
315        resp, body = self.http_client.get(url)
316        return body, resp
317
318    @utils.add_req_id_to_object()
319    def get_stores_info(self):
320        """Get available stores info from discovery endpoint."""
321        url = '/v2/info/stores'
322        resp, body = self.http_client.get(url)
323        return body, resp
324
325    @utils.add_req_id_to_object()
326    def delete_from_store(self, store_id, image_id):
327        """Delete image data from specific store."""
328        url = ('/v2/stores/%(store)s/%(image)s' % {'store': store_id,
329                                                   'image': image_id})
330        resp, body = self.http_client.delete(url)
331        return body, resp
332
333    @utils.add_req_id_to_object()
334    def stage(self, image_id, image_data, image_size=None):
335        """Upload the data to image staging.
336
337        :param image_id: ID of the image to upload data for.
338        :param image_data: File-like object supplying the data to upload.
339        :param image_size: Unused - present for backwards compatibility
340        """
341        url = '/v2/images/%s/stage' % image_id
342        resp, body = self.upload(image_id,
343                                 image_data,
344                                 u_url=url)
345        return body, resp
346
347    @utils.add_req_id_to_object()
348    def image_import(self, image_id, method='glance-direct', uri=None,
349                     backend=None, stores=None, allow_failure=True,
350                     all_stores=None):
351        """Import Image via method."""
352        headers = {}
353        url = '/v2/images/%s/import' % image_id
354        data = {'method': {'name': method}}
355        if stores:
356            data['stores'] = stores
357            if allow_failure:
358                data['all_stores_must_succeed'] = False
359        if backend is not None:
360            headers['x-image-meta-store'] = backend
361        if all_stores:
362            data['all_stores'] = True
363            if allow_failure:
364                data['all_stores_must_succeed'] = False
365
366        if uri:
367            if method == 'web-download':
368                data['method']['uri'] = uri
369            else:
370                raise exc.HTTPBadRequest('URI is only supported with method: '
371                                         '"web-download"')
372        resp, body = self.http_client.post(url, data=data, headers=headers)
373        return body, resp
374
375    @utils.add_req_id_to_object()
376    def delete(self, image_id):
377        """Delete an image."""
378        url = '/v2/images/%s' % image_id
379        resp, body = self.http_client.delete(url)
380        return (resp, body), resp
381
382    @utils.add_req_id_to_object()
383    def create(self, **kwargs):
384        """Create an image."""
385        headers = {}
386        url = '/v2/images'
387        backend = kwargs.pop('backend', None)
388        if backend is not None:
389            headers['x-image-meta-store'] = backend
390
391        image = self.model()
392        for (key, value) in kwargs.items():
393            try:
394                setattr(image, key, value)
395            except warlock.InvalidOperation as e:
396                raise TypeError(encodeutils.exception_to_unicode(e))
397
398        resp, body = self.http_client.post(url, headers=headers, data=image)
399        # NOTE(esheffield): remove 'self' for now until we have an elegant
400        # way to pass it into the model constructor without conflict
401        body.pop('self', None)
402        return self.model(**body), resp
403
404    @utils.add_req_id_to_object()
405    def deactivate(self, image_id):
406        """Deactivate an image."""
407        url = '/v2/images/%s/actions/deactivate' % image_id
408        resp, body = self.http_client.post(url)
409        return (resp, body), resp
410
411    @utils.add_req_id_to_object()
412    def reactivate(self, image_id):
413        """Reactivate an image."""
414        url = '/v2/images/%s/actions/reactivate' % image_id
415        resp, body = self.http_client.post(url)
416        return (resp, body), resp
417
418    def update(self, image_id, remove_props=None, **kwargs):
419        """Update attributes of an image.
420
421        :param image_id: ID of the image to modify.
422        :param remove_props: List of property names to remove
423        :param kwargs: Image attribute names and their new values.
424        """
425        unvalidated_image = self.get(image_id)
426        image = self.model(**unvalidated_image)
427        for (key, value) in kwargs.items():
428            try:
429                setattr(image, key, value)
430            except warlock.InvalidOperation as e:
431                raise TypeError(encodeutils.exception_to_unicode(e))
432
433        if remove_props:
434            cur_props = image.keys()
435            new_props = kwargs.keys()
436            # NOTE(esheffield): Only remove props that currently exist on the
437            # image and are NOT in the properties being updated / added
438            props_to_remove = set(cur_props).intersection(
439                set(remove_props).difference(new_props))
440
441            for key in props_to_remove:
442                delattr(image, key)
443
444        url = '/v2/images/%s' % image_id
445        hdrs = {'Content-Type': 'application/openstack-images-v2.1-json-patch'}
446        resp, _ = self.http_client.patch(url, headers=hdrs, data=image.patch)
447        # Get request id from `patch` request so it can be passed to the
448        #  following `get` call
449        req_id_hdr = {
450            'x-openstack-request-id': utils._extract_request_id(resp)}
451
452        # NOTE(bcwaldon): calling image.patch doesn't clear the changes, so
453        # we need to fetch the image again to get a clean history. This is
454        # an obvious optimization for warlock
455        return self._get(image_id, req_id_hdr)
456
457    def _get_image_with_locations_or_fail(self, image_id):
458        image = self.get(image_id)
459        if getattr(image, 'locations', None) is None:
460            raise exc.HTTPBadRequest('The administrator has disabled '
461                                     'API access to image locations')
462        return image
463
464    @utils.add_req_id_to_object()
465    def _send_image_update_request(self, image_id, patch_body):
466        url = '/v2/images/%s' % image_id
467        hdrs = {'Content-Type': 'application/openstack-images-v2.1-json-patch'}
468        resp, body = self.http_client.patch(url, headers=hdrs,
469                                            data=json.dumps(patch_body))
470        return (resp, body), resp
471
472    def add_location(self, image_id, url, metadata, validation_data=None):
473        """Add a new location entry to an image's list of locations.
474
475        It is an error to add a URL that is already present in the list of
476        locations.
477
478        :param image_id: ID of image to which the location is to be added.
479        :param url: URL of the location to add.
480        :param metadata: Metadata associated with the location.
481        :param validation_data: Validation data for the image.
482        :returns: The updated image
483        """
484        add_patch = [{'op': 'add', 'path': '/locations/-',
485                      'value': {'url': url, 'metadata': metadata}}]
486        if validation_data:
487            add_patch[0]['value']['validation_data'] = validation_data
488        response = self._send_image_update_request(image_id, add_patch)
489        # Get request id from the above update request and pass the same to
490        # following get request
491        req_id_hdr = {'x-openstack-request-id': response.request_ids[0]}
492        return self._get(image_id, req_id_hdr)
493
494    def delete_locations(self, image_id, url_set):
495        """Remove one or more location entries of an image.
496
497        :param image_id: ID of image from which locations are to be removed.
498        :param url_set: set of URLs of location entries to remove.
499        :returns: None
500        """
501        image = self._get_image_with_locations_or_fail(image_id)
502        current_urls = [l['url'] for l in image.locations]
503
504        missing_locs = url_set.difference(set(current_urls))
505        if missing_locs:
506            raise exc.HTTPNotFound('Unknown URL(s): %s' % list(missing_locs))
507
508        # NOTE: warlock doesn't generate the most efficient patch for remove
509        # operations (it shifts everything up and deletes the tail elements) so
510        # we do it ourselves.
511        url_indices = [current_urls.index(url) for url in url_set]
512        url_indices.sort(reverse=True)
513        patches = [{'op': 'remove', 'path': '/locations/%s' % url_idx}
514                   for url_idx in url_indices]
515        return self._send_image_update_request(image_id, patches)
516
517    def update_location(self, image_id, url, metadata):
518        """Update an existing location entry in an image's list of locations.
519
520        The URL specified must be already present in the image's list of
521        locations.
522
523        :param image_id: ID of image whose location is to be updated.
524        :param url: URL of the location to update.
525        :param metadata: Metadata associated with the location.
526        :returns: The updated image
527        """
528        image = self._get_image_with_locations_or_fail(image_id)
529        url_map = dict([(l['url'], l) for l in image.locations])
530        if url not in url_map:
531            raise exc.HTTPNotFound('Unknown URL: %s, the URL must be one of'
532                                   ' existing locations of current image' %
533                                   url)
534
535        if url_map[url]['metadata'] == metadata:
536            return image
537
538        url_map[url]['metadata'] = metadata
539        patches = [{'op': 'replace',
540                    'path': '/locations',
541                    'value': list(url_map.values())}]
542        response = self._send_image_update_request(image_id, patches)
543        # Get request id from the above update request and pass the same to
544        # following get request
545        req_id_hdr = {'x-openstack-request-id': response.request_ids[0]}
546
547        return self._get(image_id, req_id_hdr)
548