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..3f62b36d1 --- /dev/null +++ b/pulp_container/tests/functional/api/test_cancel_blob_upload.py @@ -0,0 +1,71 @@ +"""Tests for cancelling blob uploads via the Docker v2 API.""" + +import uuid + +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(scope="class") + def setup( + self, + add_to_cleanup, + container_bindings, + container_repository_factory, + container_distribution_factory, + ): + """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.""" + 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 + ): + """Cancel requires push permissions on the namespace.""" + 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: + response, _ = local_registry.get_response("DELETE", delete_path) + 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 = _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) + 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"" + + response, _ = local_registry.get_response("GET", upload_path) + assert response.status_code == 404