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