From 13b3c7283fd085131a3dd73f2b7300b91ca97007 Mon Sep 17 00:00:00 2001 From: Abhishek Kekane Date: Tue, 29 Jul 2025 14:02:39 +0000 Subject: [PATCH] Fix inaccurate usage reporting when delayed_delete is enabled This change addresses issues where usage reports include images in pending_delete, leading to potential user blocking and limit enforcement errors. Closes-Bug: #2118756 Change-Id: I491c486a0b967baba9bbdca4cd241ba691a94435 Signed-off-by: Abhishek Kekane --- glance/db/sqlalchemy/api.py | 12 +++-- glance/tests/functional/v2/test_images.py | 57 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/glance/db/sqlalchemy/api.py b/glance/db/sqlalchemy/api.py index b133ea3947..85ba58ff05 100644 --- a/glance/db/sqlalchemy/api.py +++ b/glance/db/sqlalchemy/api.py @@ -782,7 +782,8 @@ def _image_get_disk_usage_by_owner(context, session, owner, image_id=None): if image_id is not None: query = query.filter(models.Image.id != image_id) query = query.filter(models.Image.size > 0) - query = query.filter(~models.Image.status.in_(['killed', 'deleted'])) + query = query.filter(~models.Image.status.in_([ + 'killed', 'deleted', 'pending_delete'])) images = query.all() total = 0 @@ -820,7 +821,8 @@ def _image_get_staging_usage_by_owner(context, session, owner): query = query.filter(~models.Image.status.in_(('uploading', 'importing', 'killed', - 'deleted'))) + 'deleted', + 'pending_delete'))) copying_images = query.all() return sum(i.size for i in itertools.chain(importing_images, @@ -830,7 +832,8 @@ def _image_get_staging_usage_by_owner(context, session, owner): def _image_get_count_by_owner(context, session, owner): query = session.query(models.Image) query = query.filter(models.Image.owner == owner) - query = query.filter(~models.Image.status.in_(['killed', 'deleted'])) + query = query.filter(~models.Image.status.in_([ + 'killed', 'pending_delete', 'deleted'])) return query.count() @@ -855,7 +858,8 @@ def _image_get_uploading_count_by_owner(context, session, owner): query = query.join(props, props.c.image_id == models.Image.id) query = query.filter(models.Image.owner == owner) query = query.filter(~models.Image.status.in_(importing_statuses + - ('killed', 'deleted'))) + ('killed', 'deleted', + 'pending_delete'))) copying = query.count() return uploading + copying diff --git a/glance/tests/functional/v2/test_images.py b/glance/tests/functional/v2/test_images.py index a6f9027197..581222576b 100644 --- a/glance/tests/functional/v2/test_images.py +++ b/glance/tests/functional/v2/test_images.py @@ -35,6 +35,8 @@ import requests from glance.api import policy from glance.common import wsgi +from glance import context +import glance.db as db_api from glance.quota import keystone as ks_quota from glance.tests import functional from glance.tests.functional import ft_utils as func_utils @@ -4549,6 +4551,61 @@ class TestKeystoneQuotas(functional.SynchronousAPIBase): # Make sure we can still import. self._create_and_import(stores=['store1']) + def test_image_count_total_with_delayed_delete(self): + self.config(delayed_delete=True) + self.set_limit({'image_size_total': 100, + 'image_stage_total': 10, + 'image_count_total': 1, + 'image_count_uploading': 10}) + self.start_server() + # Create an image + image_id = self._create_and_upload() + # Make sure we can not create any more images. + resp = self._create() + self.assertEqual(413, resp.status_code) + + # Delete one image, which should put us under quota + self.api_delete('/v2/images/%s' % image_id) + + # Verify image is in pending_delete state + image = self._get_pending_delete_image(image_id) + self.assertEqual('pending_delete', image['status']) + + # Now we can create that image + self._create() + + def test_image_size_total_with_delayed_delete(self): + self.config(delayed_delete=True) + self.set_limit({'image_size_total': 6, + 'image_stage_total': 10, + 'image_count_total': 10, + 'image_count_uploading': 1}) + self.start_server() + # Create an image + image_id = self._create_and_upload( + data_iter=test_utils.FakeData(8 * units.Mi)) + # Make sure we can not upload any more images. + self._create_and_upload(expected_code=413) + + # Delete one image, which should put us under quota + self.api_delete('/v2/images/%s' % image_id) + + # Verify image is in pending_delete state + image = self._get_pending_delete_image(image_id) + self.assertEqual('pending_delete', image['status']) + + # Now we can create that image + self._create_and_upload() + + def _get_pending_delete_image(self, image_id): + # In Glance V2, there is no way to get the 'pending_delete' image from + # API. So we get the image from db here for testing. + # Clean the session cache first to avoid connecting to the old db data. + admin_context = context.get_admin_context(show_deleted=True) + db_api.get_api()._FACADE = None + image = db_api.get_api().image_get(admin_context, image_id) + return image + class TestStoreWeight(functional.SynchronousAPIBase): def setUp(self):