From a3f13cfd1080574105c01adbbc65a0f7fb176979 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 16:06:21 -0400 Subject: [PATCH] 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 | 71 +++++++++++++++++++ 4 files changed, 103 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 92adfcbc9..3d0ac3fc0 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -60,6 +60,7 @@ BadGateway, BlobInvalid, BlobNotFound, + BlobUploadUnknown, GatewayTimeout, InvalidRequest, ManifestInvalid, @@ -1014,6 +1015,18 @@ def partial_update(self, request, path, pk=None): return UploadResponse(upload=upload, path=path, request=request) + 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..7fedfd949 --- /dev/null +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -0,0 +1,71 @@ +"""Tests that verify blob uploads can be cancelled via the registry API.""" + +import uuid + +import pytest + +from pulp_container.app import models + + +def test_cancel_blob_upload(local_registry, container_bindings, full_path, add_to_cleanup): + """Test cancelling an outstanding blob upload.""" + repo_name = f"cancel-upload/{uuid.uuid4()}" + upload_path = f"/v2/{full_path(repo_name)}/blobs/uploads/" + + response, _ = local_registry.get_response("POST", upload_path) + assert response.status_code == 202 + upload_uuid = response.headers["Docker-Upload-UUID"] + + distribution = container_bindings.DistributionsContainerApi.list(name=repo_name).results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, distribution.namespace) + + assert models.Upload.objects.filter(pk=upload_uuid).exists() + + delete_path = f"/v2/{full_path(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() + + +def test_cancel_unknown_blob_upload(local_registry, full_path, add_to_cleanup, container_bindings): + """Test cancelling a blob upload that does not exist.""" + repo_name = f"cancel-upload/{uuid.uuid4()}" + upload_path = f"/v2/{full_path(repo_name)}/blobs/uploads/" + + response, _ = local_registry.get_response("POST", upload_path) + assert response.status_code == 202 + + distribution = container_bindings.DistributionsContainerApi.list(name=repo_name).results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, distribution.namespace) + + upload_uuid = uuid.uuid4() + delete_path = f"/v2/{full_path(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_cancel_blob_upload_without_permission( + gen_user, local_registry, full_path, pulp_settings, add_to_cleanup, container_bindings +): + """Test that cancelling a blob upload requires push permission.""" + if pulp_settings.TOKEN_AUTH_DISABLED: + pytest.skip("RBAC cannot be tested when token authentication is disabled") + + user_helpless = gen_user() + repo_name = f"cancel-upload/{uuid.uuid4()}" + upload_path = f"/v2/{full_path(repo_name)}/blobs/uploads/" + + response, _ = local_registry.get_response("POST", upload_path) + assert response.status_code == 202 + upload_uuid = response.headers["Docker-Upload-UUID"] + + distribution = container_bindings.DistributionsContainerApi.list(name=repo_name).results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, distribution.namespace) + + delete_path = f"/v2/{full_path(repo_name)}/blobs/uploads/{upload_uuid}" + with user_helpless: + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 401