Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+cancel-blob-upload.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added support for cancelling blob uploads via `DELETE /v2/<name>/blobs/uploads/<uuid>`.
18 changes: 18 additions & 0 deletions pulp_container/app/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'"""

Expand Down
13 changes: 13 additions & 0 deletions pulp_container/app/registry_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
BadGateway,
BlobInvalid,
BlobNotFound,
BlobUploadUnknown,
GatewayTimeout,
InvalidRequest,
ManifestInvalid,
Expand Down Expand Up @@ -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.
Expand Down
71 changes: 71 additions & 0 deletions pulp_container/tests/functional/api/test_cancel_blob_upload.py
Original file line number Diff line number Diff line change
@@ -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/<name>/blobs/uploads/<uuid>."""

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."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the "push" repository terminology still accurate in this instance?

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:

@dralley dralley Jul 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure "helpless" is the best name for it. Maybe "user_no_permissions".

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/<name>/blobs/uploads/<uuid>."""
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
Loading