Dan Smith | a15846e | 2021-04-27 11:59:22 -0700 | [diff] [blame] | 1 | # Copyright 2021 Red Hat, Inc. |
| 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 | |
| 16 | import io |
| 17 | |
| 18 | from oslo_utils import units |
| 19 | from tempest.common import utils |
| 20 | from tempest.common import waiters |
| 21 | from tempest import config |
| 22 | from tempest.lib.common.utils import data_utils |
| 23 | from tempest.lib.common.utils import test_utils |
| 24 | from tempest.lib import decorators |
| 25 | from tempest.lib import exceptions as lib_exc |
| 26 | from tempest.scenario import manager |
| 27 | |
| 28 | CONF = config.CONF |
| 29 | |
| 30 | |
| 31 | class ImageQuotaTest(manager.ScenarioTest): |
| 32 | credentials = ['primary', 'system_admin'] |
| 33 | |
| 34 | @classmethod |
| 35 | def resource_setup(cls): |
| 36 | super(ImageQuotaTest, cls).resource_setup() |
| 37 | |
| 38 | # Figure out and record the glance service id |
| 39 | services = cls.os_system_admin.identity_services_v3_client.\ |
| 40 | list_services() |
| 41 | glance_services = [x for x in services['services'] |
| 42 | if x['name'] == 'glance'] |
| 43 | cls.glance_service_id = glance_services[0]['id'] |
| 44 | |
| 45 | # Pre-create all the quota limits and record their IDs so we can |
| 46 | # update them in-place without needing to know which ones have been |
| 47 | # created and in which order. |
| 48 | cls.limit_ids = {} |
| 49 | |
| 50 | try: |
| 51 | cls.limit_ids['image_size_total'] = cls._create_limit( |
| 52 | 'image_size_total', 10) |
| 53 | cls.limit_ids['image_stage_total'] = cls._create_limit( |
| 54 | 'image_stage_total', 10) |
| 55 | cls.limit_ids['image_count_total'] = cls._create_limit( |
| 56 | 'image_count_total', 10) |
| 57 | cls.limit_ids['image_count_uploading'] = cls._create_limit( |
| 58 | 'image_count_uploading', 10) |
| 59 | except lib_exc.Forbidden: |
| 60 | # If we fail to set limits, it means they are not |
| 61 | # registered, and thus we will skip these tests once we |
| 62 | # have our os_system_admin client and run |
| 63 | # check_quotas_enabled(). |
| 64 | pass |
| 65 | |
| 66 | def setUp(self): |
| 67 | super(ImageQuotaTest, self).setUp() |
| 68 | self.created_images = [] |
| 69 | |
| 70 | def create_image(self, data=None, **kwargs): |
| 71 | """Wrapper that returns a test image.""" |
| 72 | |
| 73 | if 'name' not in kwargs: |
| 74 | name = data_utils.rand_name(self.__name__ + "-image") |
| 75 | kwargs['name'] = name |
| 76 | |
| 77 | params = dict(kwargs) |
| 78 | if data: |
| 79 | # NOTE: On glance v1 API, the data should be passed on |
| 80 | # a header. Then here handles the data separately. |
| 81 | params['data'] = data |
| 82 | |
| 83 | image = self.image_client.create_image(**params) |
| 84 | # Image objects returned by the v1 client have the image |
| 85 | # data inside a dict that is keyed against 'image'. |
| 86 | if 'image' in image: |
| 87 | image = image['image'] |
| 88 | self.created_images.append(image['id']) |
| 89 | self.addCleanup( |
| 90 | self.image_client.wait_for_resource_deletion, |
| 91 | image['id']) |
| 92 | self.addCleanup( |
| 93 | test_utils.call_and_ignore_notfound_exc, |
| 94 | self.image_client.delete_image, image['id']) |
| 95 | return image |
| 96 | |
| 97 | def check_quotas_enabled(self): |
| 98 | # Check to see if we should even be running these tests. Use |
| 99 | # the presence of a registered limit that we recognize as an |
| 100 | # indication. This will be set up by the operator (or |
| 101 | # devstack) if glance is configured to use/honor the unified |
| 102 | # limits. If one is set, they must all be set, because glance |
| 103 | # has a single all-or-nothing flag for whether or not to use |
| 104 | # keystone limits. If anything, checking only one helps to |
| 105 | # assert the assumption that, if enabled, they must all be at |
| 106 | # least registered for proper operation. |
| 107 | registered_limits = self.os_system_admin.identity_limits_client.\ |
| 108 | get_registered_limits()['registered_limits'] |
| 109 | if 'image_count_total' not in [x['resource_name'] |
| 110 | for x in registered_limits]: |
| 111 | raise self.skipException('Target system is not configured with ' |
| 112 | 'glance unified limits') |
| 113 | |
| 114 | @classmethod |
| 115 | def _create_limit(cls, name, value): |
| 116 | return cls.os_system_admin.identity_limits_client.create_limit( |
| 117 | CONF.identity.region, cls.glance_service_id, |
| 118 | cls.image_client.tenant_id, name, value)['limits'][0]['id'] |
| 119 | |
| 120 | def _update_limit(self, name, value): |
| 121 | self.os_system_admin.identity_limits_client.update_limit( |
| 122 | self.limit_ids[name], value) |
| 123 | |
| 124 | def _cleanup_images(self): |
| 125 | while self.created_images: |
| 126 | image_id = self.created_images.pop() |
| 127 | try: |
| 128 | self.image_client.delete_image(image_id) |
| 129 | except lib_exc.NotFound: |
| 130 | pass |
| 131 | |
| 132 | @decorators.idempotent_id('9b74fe24-183b-41e6-bf42-84c2958a7be8') |
| 133 | @utils.services('image', 'identity') |
| 134 | def test_image_count_quota(self): |
| 135 | self.check_quotas_enabled() |
| 136 | |
| 137 | # Set a quota on the number of images for our tenant to one. |
| 138 | self._update_limit('image_count_total', 1) |
| 139 | |
| 140 | # Create one image |
| 141 | image = self.create_image(name='first', |
| 142 | container_format='bare', |
| 143 | disk_format='raw', |
| 144 | visibility='private') |
| 145 | |
| 146 | # Second image would put us over quota, so expect failure. |
| 147 | self.assertRaises(lib_exc.OverLimit, |
| 148 | self.create_image, |
| 149 | name='second', |
| 150 | container_format='bare', |
| 151 | disk_format='raw', |
| 152 | visibility='private') |
| 153 | |
| 154 | # Update our limit to two. |
| 155 | self._update_limit('image_count_total', 2) |
| 156 | |
| 157 | # Now the same create should succeed. |
| 158 | self.create_image(name='second', |
| 159 | container_format='bare', |
| 160 | disk_format='raw', |
| 161 | visibility='private') |
| 162 | |
| 163 | # Third image would put us over quota, so expect failure. |
| 164 | self.assertRaises(lib_exc.OverLimit, |
| 165 | self.create_image, |
| 166 | name='third', |
| 167 | container_format='bare', |
| 168 | disk_format='raw', |
| 169 | visibility='private') |
| 170 | |
| 171 | # Delete the first image to put us under quota. |
| 172 | self.image_client.delete_image(image['id']) |
| 173 | |
| 174 | # Now the same create should succeed. |
| 175 | self.create_image(name='third', |
| 176 | container_format='bare', |
| 177 | disk_format='raw', |
| 178 | visibility='private') |
| 179 | |
| 180 | # Delete all the images we created before the next test runs, |
| 181 | # so that it starts with full quota. |
| 182 | self._cleanup_images() |
| 183 | |
| 184 | @decorators.idempotent_id('b103788b-5329-4aa9-8b0d-97f8733460db') |
| 185 | @utils.services('image', 'identity') |
| 186 | def test_image_count_uploading_quota(self): |
| 187 | if not CONF.image_feature_enabled.import_image: |
| 188 | skip_msg = ( |
| 189 | "%s skipped as image import is not available" % __name__) |
| 190 | raise self.skipException(skip_msg) |
| 191 | |
| 192 | self.check_quotas_enabled() |
| 193 | |
| 194 | # Set a quota on the number of images we can have in uploading state. |
| 195 | self._update_limit('image_stage_total', 10) |
| 196 | self._update_limit('image_size_total', 10) |
| 197 | self._update_limit('image_count_total', 10) |
| 198 | self._update_limit('image_count_uploading', 1) |
| 199 | |
| 200 | file_content = data_utils.random_bytes(1 * units.Mi) |
| 201 | |
| 202 | # Create and stage an image |
| 203 | image1 = self.create_image(name='first', |
| 204 | container_format='bare', |
| 205 | disk_format='raw', |
| 206 | visibility='private') |
| 207 | self.image_client.stage_image_file(image1['id'], |
| 208 | io.BytesIO(file_content)) |
| 209 | |
| 210 | # Check that we can not stage another |
| 211 | image2 = self.create_image(name='second', |
| 212 | container_format='bare', |
| 213 | disk_format='raw', |
| 214 | visibility='private') |
| 215 | self.assertRaises(lib_exc.OverLimit, |
| 216 | self.image_client.stage_image_file, |
| 217 | image2['id'], io.BytesIO(file_content)) |
| 218 | |
| 219 | # ... nor upload directly |
| 220 | image3 = self.create_image(name='third', |
| 221 | container_format='bare', |
| 222 | disk_format='raw', |
| 223 | visibility='private') |
| 224 | self.assertRaises(lib_exc.OverLimit, |
| 225 | self.image_client.store_image_file, |
| 226 | image3['id'], |
| 227 | io.BytesIO(file_content)) |
| 228 | |
| 229 | # Update our quota to make room |
| 230 | self._update_limit('image_count_uploading', 2) |
| 231 | |
| 232 | # Now our upload should work |
| 233 | self.image_client.store_image_file(image3['id'], |
| 234 | io.BytesIO(file_content)) |
| 235 | |
| 236 | # ...and because that is no longer in uploading state, we should be |
| 237 | # able to stage our second image from above. |
| 238 | self.image_client.stage_image_file(image2['id'], |
| 239 | io.BytesIO(file_content)) |
| 240 | |
| 241 | # Finish our import of image2 |
| 242 | self.image_client.image_import(image2['id'], method='glance-direct') |
| 243 | waiters.wait_for_image_imported_to_stores(self.image_client, |
| 244 | image2['id']) |
| 245 | |
| 246 | # Set our quota back to one |
| 247 | self._update_limit('image_count_uploading', 1) |
| 248 | |
| 249 | # Since image1 is still staged, we should not be able to upload |
| 250 | # an image. |
| 251 | image4 = self.create_image(name='fourth', |
| 252 | container_format='bare', |
| 253 | disk_format='raw', |
| 254 | visibility='private') |
| 255 | self.assertRaises(lib_exc.OverLimit, |
| 256 | self.image_client.store_image_file, |
| 257 | image4['id'], |
| 258 | io.BytesIO(file_content)) |
| 259 | |
| 260 | # Finish our import of image1 to make space in our uploading quota. |
| 261 | self.image_client.image_import(image1['id'], method='glance-direct') |
| 262 | waiters.wait_for_image_imported_to_stores(self.image_client, |
| 263 | image1['id']) |
| 264 | |
| 265 | # Make sure that freed up the one upload quota to complete our upload |
| 266 | self.image_client.store_image_file(image4['id'], |
| 267 | io.BytesIO(file_content)) |
| 268 | |
| 269 | # Delete all the images we created before the next test runs, |
| 270 | # so that it starts with full quota. |
| 271 | self._cleanup_images() |
| 272 | |
| 273 | @decorators.idempotent_id('05e8d064-c39a-4801-8c6a-465df375ec5b') |
| 274 | @utils.services('image', 'identity') |
| 275 | def test_image_size_quota(self): |
| 276 | self.check_quotas_enabled() |
| 277 | |
| 278 | # Set a quota on the image size for our tenant to 1MiB, and allow ten |
| 279 | # images. |
| 280 | self._update_limit('image_size_total', 1) |
| 281 | self._update_limit('image_count_total', 10) |
| 282 | self._update_limit('image_count_uploading', 10) |
| 283 | |
| 284 | file_content = data_utils.random_bytes(1 * units.Mi) |
| 285 | |
| 286 | # Create and upload a 1MiB image. |
| 287 | image1 = self.create_image(name='first', |
| 288 | container_format='bare', |
| 289 | disk_format='raw', |
| 290 | visibility='private') |
| 291 | self.image_client.store_image_file(image1['id'], |
| 292 | io.BytesIO(file_content)) |
| 293 | |
| 294 | # Create and upload a second 1MiB image. This succeeds, but |
| 295 | # after completion, we are over quota. Despite us being at |
| 296 | # quota above, the initial quota check for the second |
| 297 | # operation has no idea what the image size will be, and thus |
| 298 | # uses delta=0. This will succeed because we're not |
| 299 | # technically over-quota and have not asked for any more (this |
| 300 | # is oslo.limit behavior). After the second operation, |
| 301 | # however, we will be over-quota regardless of the delta and |
| 302 | # subsequent attempts will fail. Because glance goes not |
| 303 | # require an image size to be declared before upload, this is |
| 304 | # really the best it can do without an API change. |
| 305 | image2 = self.create_image(name='second', |
| 306 | container_format='bare', |
| 307 | disk_format='raw', |
| 308 | visibility='private') |
| 309 | self.image_client.store_image_file(image2['id'], |
| 310 | io.BytesIO(file_content)) |
| 311 | |
| 312 | # Create and attempt to upload a third 1MiB image. This should fail to |
| 313 | # upload (but not create) because we are over quota. |
| 314 | image3 = self.create_image(name='third', |
| 315 | container_format='bare', |
| 316 | disk_format='raw', |
| 317 | visibility='private') |
| 318 | self.assertRaises(lib_exc.OverLimit, |
| 319 | self.image_client.store_image_file, |
| 320 | image3['id'], io.BytesIO(file_content)) |
| 321 | |
| 322 | # Increase our size quota to 2MiB. |
| 323 | self._update_limit('image_size_total', 2) |
| 324 | |
| 325 | # Now the upload of the already-created image is allowed, but |
| 326 | # after completion, we are over quota again. |
| 327 | self.image_client.store_image_file(image3['id'], |
| 328 | io.BytesIO(file_content)) |
| 329 | |
| 330 | # Create and attempt to upload a fourth 1MiB image. This should |
| 331 | # fail to upload (but not create) because we are over quota. |
| 332 | image4 = self.create_image(name='fourth', |
| 333 | container_format='bare', |
| 334 | disk_format='raw', |
| 335 | visibility='private') |
| 336 | self.assertRaises(lib_exc.OverLimit, |
| 337 | self.image_client.store_image_file, |
| 338 | image4['id'], io.BytesIO(file_content)) |
| 339 | |
| 340 | # Delete our first image to make space in our existing 2MiB quota. |
| 341 | self.image_client.delete_image(image1['id']) |
| 342 | |
| 343 | # Now the upload of the already-created image is allowed. |
| 344 | self.image_client.store_image_file(image4['id'], |
| 345 | io.BytesIO(file_content)) |
| 346 | |
| 347 | # Delete all the images we created before the next test runs, |
| 348 | # so that it starts with full quota. |
| 349 | self._cleanup_images() |
| 350 | |
| 351 | @decorators.idempotent_id('fc76b8d9-aae5-46fb-9285-099e37f311f7') |
| 352 | @utils.services('image', 'identity') |
| 353 | def test_image_stage_quota(self): |
| 354 | if not CONF.image_feature_enabled.import_image: |
| 355 | skip_msg = ( |
| 356 | "%s skipped as image import is not available" % __name__) |
| 357 | raise self.skipException(skip_msg) |
| 358 | |
| 359 | self.check_quotas_enabled() |
| 360 | |
| 361 | # Create a staging quota of 1MiB, allow 10MiB of active |
| 362 | # images, and a total of ten images. |
| 363 | self._update_limit('image_stage_total', 1) |
| 364 | self._update_limit('image_size_total', 10) |
| 365 | self._update_limit('image_count_total', 10) |
| 366 | self._update_limit('image_count_uploading', 10) |
| 367 | |
| 368 | file_content = data_utils.random_bytes(1 * units.Mi) |
| 369 | |
| 370 | # Create and stage a 1MiB image. |
| 371 | image1 = self.create_image(name='first', |
| 372 | container_format='bare', |
| 373 | disk_format='raw', |
| 374 | visibility='private') |
| 375 | self.image_client.stage_image_file(image1['id'], |
| 376 | io.BytesIO(file_content)) |
| 377 | |
| 378 | # Create and stage a second 1MiB image. This succeeds, but |
| 379 | # after completion, we are over quota. |
| 380 | image2 = self.create_image(name='second', |
| 381 | container_format='bare', |
| 382 | disk_format='raw', |
| 383 | visibility='private') |
| 384 | self.image_client.stage_image_file(image2['id'], |
| 385 | io.BytesIO(file_content)) |
| 386 | |
| 387 | # Create and attempt to stage a third 1MiB image. This should fail to |
| 388 | # stage (but not create) because we are over quota. |
| 389 | image3 = self.create_image(name='third', |
| 390 | container_format='bare', |
| 391 | disk_format='raw', |
| 392 | visibility='private') |
| 393 | self.assertRaises(lib_exc.OverLimit, |
| 394 | self.image_client.stage_image_file, |
| 395 | image3['id'], io.BytesIO(file_content)) |
| 396 | |
| 397 | # Make sure that even though we are over our stage quota, we |
| 398 | # can still create and upload an image the regular way. |
| 399 | image_upload = self.create_image(name='uploaded', |
| 400 | container_format='bare', |
| 401 | disk_format='raw', |
| 402 | visibility='private') |
| 403 | self.image_client.store_image_file(image_upload['id'], |
| 404 | io.BytesIO(file_content)) |
| 405 | |
| 406 | # Increase our stage quota to two MiB. |
| 407 | self._update_limit('image_stage_total', 2) |
| 408 | |
| 409 | # Now the upload of the already-created image is allowed, but |
| 410 | # after completion, we are over quota again. |
| 411 | self.image_client.stage_image_file(image3['id'], |
| 412 | io.BytesIO(file_content)) |
| 413 | |
| 414 | # Create and attempt to stage a fourth 1MiB image. This should |
| 415 | # fail to stage (but not create) because we are over quota. |
| 416 | image4 = self.create_image(name='fourth', |
| 417 | container_format='bare', |
| 418 | disk_format='raw', |
| 419 | visibility='private') |
| 420 | self.assertRaises(lib_exc.OverLimit, |
| 421 | self.image_client.stage_image_file, |
| 422 | image4['id'], io.BytesIO(file_content)) |
| 423 | |
| 424 | # Finish our import of image1 to make space in our stage quota. |
| 425 | self.image_client.image_import(image1['id'], method='glance-direct') |
| 426 | waiters.wait_for_image_imported_to_stores(self.image_client, |
| 427 | image1['id']) |
| 428 | |
| 429 | # Now the upload of the already-created image is allowed. |
| 430 | self.image_client.stage_image_file(image4['id'], |
| 431 | io.BytesIO(file_content)) |
| 432 | |
| 433 | # Delete all the images we created before the next test runs, |
| 434 | # so that it starts with full quota. |
| 435 | self._cleanup_images() |