From e127428ab70ab9e914fc5744e3d958406594f780 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 16:44:12 -0400 Subject: [PATCH 1/2] Enable DELETE on the Docker v2 blob endpoint. Implement DELETE /v2//blobs/ for push repositories, with functional tests. Fixes #481. Co-authored-by: Cursor --- CHANGES/481.feature | 1 + pulp_container/app/registry_api.py | 30 +++++- .../tests/functional/api/test_delete_blob.py | 102 ++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 CHANGES/481.feature create mode 100644 pulp_container/tests/functional/api/test_delete_blob.py diff --git a/CHANGES/481.feature b/CHANGES/481.feature new file mode 100644 index 000000000..3880dd931 --- /dev/null +++ b/CHANGES/481.feature @@ -0,0 +1 @@ +Enable DELETE on the Docker v2 blob endpoint so users can delete blobs by digest. diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index 92adfcbc9..c5879948c 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -73,7 +73,7 @@ FileStorageRedirects, S3StorageRedirects, ) -from pulp_container.app.tasks import aadd_and_remove, download_image_data +from pulp_container.app.tasks import aadd_and_remove, download_image_data, recursive_remove_content from pulp_container.app.token_verification import ( RegistryAuthentication, RegistryPermission, @@ -1185,6 +1185,34 @@ def fetch_blob(self, remote, pk): raise BlobNotFound(digest=pk) return blob_url + def destroy(self, request, path, pk=None): + """ + Delete a blob identified by digest. + """ + if not pk.startswith("sha256:"): + raise InvalidRequest(message="A blob can only be deleted by digest.") + + _, repository = self.get_dr_push(request, path) + latest_version = repository.latest_version() + + blob = models.Blob.objects.filter(digest=pk, pk__in=latest_version.content).first() + if not blob: + pending_blob = repository.pending_blobs.filter(digest=pk).first() + if pending_blob: + repository.pending_blobs.remove(pending_blob) + return Response(status=202) + raise BlobNotFound(digest=pk) + + dispatch( + recursive_remove_content, + exclusive_resources=[repository], + kwargs={ + "repository_pk": str(repository.pk), + "content_units": [str(blob.pk)], + }, + ) + return Response(status=202) + class Manifests(RedirectsMixin, ContainerRegistryApiMixin, ViewSet): """ diff --git a/pulp_container/tests/functional/api/test_delete_blob.py b/pulp_container/tests/functional/api/test_delete_blob.py new file mode 100644 index 000000000..ab442c7dc --- /dev/null +++ b/pulp_container/tests/functional/api/test_delete_blob.py @@ -0,0 +1,102 @@ +"""Tests for deleting blobs via the Docker v2 API.""" + +import pytest + +from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP + + +def test_delete_pending_blob( + add_to_cleanup, + local_registry, + registry_client, + container_bindings, + full_path, +): + """Delete a pending blob via DELETE /v2//blobs/.""" + source_repo = "delete/blob-source" + dest_repo = "delete/blob-pending" + image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" + registry_client.pull(image_path) + local_registry.tag_and_push(image_path, full_path(f"{source_repo}:manifest_a")) + + namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) + + repository = container_bindings.RepositoriesContainerApi.list(name=source_repo).results[0] + blob = container_bindings.ContentBlobsApi.list( + repository_version=repository.latest_version_href + ).results[0] + + mount_path = ( + f"/v2/{full_path(dest_repo)}/blobs/uploads/" + f"?from={full_path(source_repo)}&mount={blob.digest}" + ) + response, _ = local_registry.get_response("POST", mount_path) + assert response.status_code == 201 + + delete_path = f"/v2/{full_path(dest_repo)}/blobs/{blob.digest}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 202 + + head_path = f"/v2/{full_path(dest_repo)}/blobs/{blob.digest}" + response, _ = local_registry.get_response("HEAD", head_path) + assert response.status_code == 404 + assert response.headers.get("Docker-Distribution-Api-Version") == "registry/2.0" + + +def test_delete_blob_not_found( + add_to_cleanup, + local_registry, + registry_client, + container_bindings, + full_path, +): + """Deleting a non-existent blob returns 404.""" + repo_name = "delete/blob-not-found" + image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" + registry_client.pull(image_path) + local_registry.tag_and_push(image_path, full_path(f"{repo_name}:manifest_a")) + + namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) + + digest = f"sha256:{'0' * 64}" + delete_path = f"/v2/{full_path(repo_name)}/blobs/{digest}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 404 + assert response.json()["errors"][0]["code"] == "BLOB_UNKNOWN" + + +def test_delete_blob_invalid_digest( + add_to_cleanup, + local_registry, + registry_client, + container_bindings, + full_path, +): + """Delete requires a sha256 digest.""" + repo_name = "delete/blob-invalid" + image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" + registry_client.pull(image_path) + local_registry.tag_and_push(image_path, full_path(f"{repo_name}:manifest_a")) + + namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) + + delete_path = f"/v2/{full_path(repo_name)}/blobs/not-a-digest" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 400 + assert response.json()["errors"][0]["code"] == "INVALID_REQUEST" + + +def test_delete_blob_without_login( + anonymous_user, + local_registry, + full_path, +): + """Delete requires authentication.""" + digest = f"sha256:{'0' * 64}" + delete_path = f"/v2/{full_path('delete/blob-unauth')}/blobs/{digest}" + with anonymous_user: + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 401 From 05264ca220c018131de77334f4baa64e8b231bf0 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 17:03:04 -0400 Subject: [PATCH 2/2] Remove unused pytest import from blob delete tests. Fixes lint failure blocking CI on PR #2380. Co-authored-by: Cursor --- pulp_container/tests/functional/api/test_delete_blob.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pulp_container/tests/functional/api/test_delete_blob.py b/pulp_container/tests/functional/api/test_delete_blob.py index ab442c7dc..394ec838c 100644 --- a/pulp_container/tests/functional/api/test_delete_blob.py +++ b/pulp_container/tests/functional/api/test_delete_blob.py @@ -1,7 +1,5 @@ """Tests for deleting blobs via the Docker v2 API.""" -import pytest - from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP