From 32e24242517f26d2b75b4f4de655f5b9b86d5ca3 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 16:06:21 -0400 Subject: [PATCH 1/6] Add DELETE support for cancelling blob uploads Implement the Docker v2 blob upload cancel endpoint so clients can release in-progress uploads instead of waiting for timeout. Co-authored-by: Cursor --- CHANGES/+cancel-blob-upload.feature | 1 + pulp_container/app/exceptions.py | 18 +++++ pulp_container/app/registry_api.py | 13 ++++ .../functional/api/test_cancel_blob_upload.py | 72 +++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 CHANGES/+cancel-blob-upload.feature create mode 100644 pulp_container/tests/functional/api/test_cancel_blob_upload.py diff --git a/CHANGES/+cancel-blob-upload.feature b/CHANGES/+cancel-blob-upload.feature new file mode 100644 index 000000000..43e419df8 --- /dev/null +++ b/CHANGES/+cancel-blob-upload.feature @@ -0,0 +1 @@ +Added support for cancelling blob uploads via `DELETE /v2//blobs/uploads/`. diff --git a/pulp_container/app/exceptions.py b/pulp_container/app/exceptions.py index 0c10267e3..d8946477d 100644 --- a/pulp_container/app/exceptions.py +++ b/pulp_container/app/exceptions.py @@ -90,6 +90,24 @@ def __init__(self, digest): ) +class BlobUploadUnknown(NotFound): + """Exception to render a 404 with the code 'BLOB_UPLOAD_UNKNOWN'""" + + def __init__(self, uuid): + """Initialize the exception with the upload uuid.""" + super().__init__( + detail={ + "errors": [ + { + "code": "BLOB_UPLOAD_UNKNOWN", + "message": "blob upload unknown to registry", + "detail": {"uuid": uuid}, + } + ] + } + ) + + class ManifestNotFound(NotFound): """Exception to render a 404 with the code 'MANIFEST_UNKNOWN'""" diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index dca3edbba..289eb4356 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -66,6 +66,7 @@ BadGateway, BlobInvalid, BlobNotFound, + BlobUploadUnknown, GatewayTimeout, InvalidRequest, ManifestInvalid, @@ -1042,6 +1043,18 @@ def head(self, request, path, pk=None): """Respond to HEAD requests about blob uploads.""" return self.get(request, path, pk=pk) + def destroy(self, request, path, pk=None): + """ + Cancel an outstanding blob upload. + """ + _, repository = self.get_dr_push(request, path) + try: + upload = models.Upload.objects.get(repository=repository, pk=pk) + except models.Upload.DoesNotExist: + raise BlobUploadUnknown(uuid=pk) + upload.delete() + return Response(status=204) + def put(self, request, path, pk=None): """ Create a blob from uploaded chunks. diff --git a/pulp_container/tests/functional/api/test_cancel_blob_upload.py b/pulp_container/tests/functional/api/test_cancel_blob_upload.py new file mode 100644 index 000000000..dfe738572 --- /dev/null +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -0,0 +1,72 @@ +"""Tests for cancelling blob uploads via the Docker v2 API.""" + +import uuid + +import pytest + +from pulp_container.app import models + + +class TestCancelBlobUpload: + """Tests for DELETE /v2//blobs/uploads/.""" + + repo_name = "cancel/upload" + + @pytest.fixture(scope="class") + def setup( + self, + add_to_cleanup, + container_bindings, + local_registry, + full_path, + ): + """Create a push repository for all cancel blob upload tests.""" + upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/" + response, _ = local_registry.get_response("POST", upload_path) + assert response.status_code == 202 + + distribution = container_bindings.DistributionsContainerApi.list(name=self.repo_name).results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, distribution.namespace) + + upload_uuid = response.headers["Docker-Upload-UUID"] + delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" + local_registry.get_response("DELETE", delete_path) + + def _start_upload(self, local_registry, full_path): + upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/" + response, _ = local_registry.get_response("POST", upload_path) + assert response.status_code == 202 + return response.headers["Docker-Upload-UUID"] + + def test_01_cancel_unknown_blob_upload(self, setup, local_registry, full_path): + """Cancelling a blob upload that does not exist returns 404.""" + upload_uuid = uuid.uuid4() + delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 404 + assert response.json()["errors"][0]["code"] == "BLOB_UPLOAD_UNKNOWN" + + def test_02_cancel_blob_upload_without_permission( + self, setup, gen_user, local_registry, full_path, pulp_settings + ): + """Cancel requires push permissions on the namespace.""" + if pulp_settings.TOKEN_AUTH_DISABLED: + pytest.skip("RBAC cannot be tested when token authentication is disabled") + + upload_uuid = self._start_upload(local_registry, full_path) + delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" + user_helpless = gen_user() + with user_helpless: + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 401 + + def test_03_cancel_blob_upload(self, setup, local_registry, full_path): + """Cancel an outstanding blob upload via DELETE /v2//blobs/uploads/.""" + upload_uuid = self._start_upload(local_registry, full_path) + assert models.Upload.objects.filter(pk=upload_uuid).exists() + + delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 204 + assert response.content == b"" + assert not models.Upload.objects.filter(pk=upload_uuid).exists() From ab8c5f1d8748248067add4bc98fe70aa8aa5f119 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Wed, 1 Jul 2026 11:04:52 -0400 Subject: [PATCH 2/6] Reuse setup upload across cancel blob upload tests. Share the upload created in class setup for RBAC and success cases, and align permission assertions with the delete manifest tests. Co-authored-by: Cursor --- .../functional/api/test_cancel_blob_upload.py | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/pulp_container/tests/functional/api/test_cancel_blob_upload.py b/pulp_container/tests/functional/api/test_cancel_blob_upload.py index dfe738572..6f3a8d9f4 100644 --- a/pulp_container/tests/functional/api/test_cancel_blob_upload.py +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -20,7 +20,7 @@ def setup( local_registry, full_path, ): - """Create a push repository for all cancel blob upload tests.""" + """Create a push repository and blob upload for all cancel blob upload tests.""" upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/" response, _ = local_registry.get_response("POST", upload_path) assert response.status_code == 202 @@ -29,14 +29,7 @@ def setup( add_to_cleanup(container_bindings.PulpContainerNamespacesApi, distribution.namespace) upload_uuid = response.headers["Docker-Upload-UUID"] - delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" - local_registry.get_response("DELETE", delete_path) - - def _start_upload(self, local_registry, full_path): - upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/" - response, _ = local_registry.get_response("POST", upload_path) - assert response.status_code == 202 - return response.headers["Docker-Upload-UUID"] + return upload_uuid def test_01_cancel_unknown_blob_upload(self, setup, local_registry, full_path): """Cancelling a blob upload that does not exist returns 404.""" @@ -46,23 +39,18 @@ def test_01_cancel_unknown_blob_upload(self, setup, local_registry, full_path): assert response.status_code == 404 assert response.json()["errors"][0]["code"] == "BLOB_UPLOAD_UNKNOWN" - def test_02_cancel_blob_upload_without_permission( - self, setup, gen_user, local_registry, full_path, pulp_settings - ): + def test_02_cancel_blob_upload_without_permission(self, setup, gen_user, local_registry, full_path): """Cancel requires push permissions on the namespace.""" - if pulp_settings.TOKEN_AUTH_DISABLED: - pytest.skip("RBAC cannot be tested when token authentication is disabled") - - upload_uuid = self._start_upload(local_registry, full_path) + upload_uuid = setup delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" user_helpless = gen_user() with user_helpless: response, _ = local_registry.get_response("DELETE", delete_path) - assert response.status_code == 401 + assert response.status_code in (401, 403) def test_03_cancel_blob_upload(self, setup, local_registry, full_path): """Cancel an outstanding blob upload via DELETE /v2//blobs/uploads/.""" - upload_uuid = self._start_upload(local_registry, full_path) + upload_uuid = setup assert models.Upload.objects.filter(pk=upload_uuid).exists() delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" From ef6284bc27efb1a3878efc5917753e30fd79f5cf Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Wed, 1 Jul 2026 11:16:51 -0400 Subject: [PATCH 3/6] Verify blob upload cancel via GET status checks. Replace ORM assertions with GET upload status before and after DELETE. Co-authored-by: Cursor --- .../functional/api/test_cancel_blob_upload.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pulp_container/tests/functional/api/test_cancel_blob_upload.py b/pulp_container/tests/functional/api/test_cancel_blob_upload.py index 6f3a8d9f4..abd925bc2 100644 --- a/pulp_container/tests/functional/api/test_cancel_blob_upload.py +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -4,8 +4,6 @@ import pytest -from pulp_container.app import models - class TestCancelBlobUpload: """Tests for DELETE /v2//blobs/uploads/.""" @@ -51,10 +49,15 @@ def test_02_cancel_blob_upload_without_permission(self, setup, gen_user, local_r def test_03_cancel_blob_upload(self, setup, local_registry, full_path): """Cancel an outstanding blob upload via DELETE /v2//blobs/uploads/.""" upload_uuid = setup - assert models.Upload.objects.filter(pk=upload_uuid).exists() + upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" - delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" - response, _ = local_registry.get_response("DELETE", delete_path) + response, _ = local_registry.get_response("GET", upload_path) + assert response.status_code == 204 + assert response.headers["Docker-Upload-UUID"] == upload_uuid + + response, _ = local_registry.get_response("DELETE", upload_path) assert response.status_code == 204 assert response.content == b"" - assert not models.Upload.objects.filter(pk=upload_uuid).exists() + + response, _ = local_registry.get_response("GET", upload_path) + assert response.status_code == 404 From 95f91524646630137448f7487b07c444d3c4d0e4 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Wed, 1 Jul 2026 11:28:49 -0400 Subject: [PATCH 4/6] Apply ruff format to cancel blob upload tests. Co-authored-by: Cursor --- .../tests/functional/api/test_cancel_blob_upload.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pulp_container/tests/functional/api/test_cancel_blob_upload.py b/pulp_container/tests/functional/api/test_cancel_blob_upload.py index abd925bc2..59daa40ad 100644 --- a/pulp_container/tests/functional/api/test_cancel_blob_upload.py +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -23,7 +23,9 @@ def setup( response, _ = local_registry.get_response("POST", upload_path) assert response.status_code == 202 - distribution = container_bindings.DistributionsContainerApi.list(name=self.repo_name).results[0] + distribution = container_bindings.DistributionsContainerApi.list( + name=self.repo_name + ).results[0] add_to_cleanup(container_bindings.PulpContainerNamespacesApi, distribution.namespace) upload_uuid = response.headers["Docker-Upload-UUID"] @@ -37,7 +39,9 @@ def test_01_cancel_unknown_blob_upload(self, setup, local_registry, full_path): assert response.status_code == 404 assert response.json()["errors"][0]["code"] == "BLOB_UPLOAD_UNKNOWN" - def test_02_cancel_blob_upload_without_permission(self, setup, gen_user, local_registry, full_path): + def test_02_cancel_blob_upload_without_permission( + self, setup, gen_user, local_registry, full_path + ): """Cancel requires push permissions on the namespace.""" upload_uuid = setup delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" From eb7ff2a7b1706d328e43c51298f26fc46f49f282 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Thu, 2 Jul 2026 15:48:43 -0400 Subject: [PATCH 5/6] Fix cancel blob upload test fixture scope mismatch. The class-scoped setup fixture used the function-scoped local_registry fixture, which pytest rejects with ScopeMismatch. Co-authored-by: Cursor --- .../tests/functional/api/test_cancel_blob_upload.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pulp_container/tests/functional/api/test_cancel_blob_upload.py b/pulp_container/tests/functional/api/test_cancel_blob_upload.py index 59daa40ad..3712fc7fa 100644 --- a/pulp_container/tests/functional/api/test_cancel_blob_upload.py +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -10,7 +10,7 @@ class TestCancelBlobUpload: repo_name = "cancel/upload" - @pytest.fixture(scope="class") + @pytest.fixture def setup( self, add_to_cleanup, @@ -18,7 +18,7 @@ def setup( local_registry, full_path, ): - """Create a push repository and blob upload for all cancel blob upload tests.""" + """Create a push repository and blob upload for cancel blob upload tests.""" upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/" response, _ = local_registry.get_response("POST", upload_path) assert response.status_code == 202 From f3aed06f9b2155027a699ae0d02a9f495c16a6cd Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Thu, 2 Jul 2026 15:52:12 -0400 Subject: [PATCH 6/6] Refactor cancel blob upload tests to share repo setup. Create the repository and distribution once in class-scoped setup and start blob uploads only in tests that need an outstanding upload. Co-authored-by: Cursor --- .../functional/api/test_cancel_blob_upload.py | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/pulp_container/tests/functional/api/test_cancel_blob_upload.py b/pulp_container/tests/functional/api/test_cancel_blob_upload.py index 3712fc7fa..3f62b36d1 100644 --- a/pulp_container/tests/functional/api/test_cancel_blob_upload.py +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -5,31 +5,35 @@ import pytest +def _start_blob_upload(local_registry, full_path, repo_name): + upload_path = f"/v2/{full_path(repo_name)}/blobs/uploads/" + response, _ = local_registry.get_response("POST", upload_path) + assert response.status_code == 202 + return response.headers["Docker-Upload-UUID"] + + class TestCancelBlobUpload: """Tests for DELETE /v2//blobs/uploads/.""" repo_name = "cancel/upload" - @pytest.fixture + @pytest.fixture(scope="class") def setup( self, add_to_cleanup, container_bindings, - local_registry, - full_path, + container_repository_factory, + container_distribution_factory, ): - """Create a push repository and blob upload for cancel blob upload tests.""" - upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/" - response, _ = local_registry.get_response("POST", upload_path) - assert response.status_code == 202 - - distribution = container_bindings.DistributionsContainerApi.list( - name=self.repo_name - ).results[0] - add_to_cleanup(container_bindings.PulpContainerNamespacesApi, distribution.namespace) - - upload_uuid = response.headers["Docker-Upload-UUID"] - return upload_uuid + """Create a push repository and distribution for all cancel blob upload tests.""" + repository = container_repository_factory() + distribution = container_distribution_factory( + name=self.repo_name, + base_path=self.repo_name, + repository=repository.pulp_href, + ) + namespace = container_bindings.PulpContainerNamespacesApi.read(distribution.namespace) + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) def test_01_cancel_unknown_blob_upload(self, setup, local_registry, full_path): """Cancelling a blob upload that does not exist returns 404.""" @@ -43,7 +47,7 @@ def test_02_cancel_blob_upload_without_permission( self, setup, gen_user, local_registry, full_path ): """Cancel requires push permissions on the namespace.""" - upload_uuid = setup + upload_uuid = _start_blob_upload(local_registry, full_path, self.repo_name) delete_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" user_helpless = gen_user() with user_helpless: @@ -52,7 +56,7 @@ def test_02_cancel_blob_upload_without_permission( def test_03_cancel_blob_upload(self, setup, local_registry, full_path): """Cancel an outstanding blob upload via DELETE /v2//blobs/uploads/.""" - upload_uuid = setup + upload_uuid = _start_blob_upload(local_registry, full_path, self.repo_name) upload_path = f"/v2/{full_path(self.repo_name)}/blobs/uploads/{upload_uuid}" response, _ = local_registry.get_response("GET", upload_path)