From 6d188ec68f06f7fd4e60e4ee30d07bac6102562d Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 16:44:12 -0400 Subject: [PATCH 1/4] 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 | 28 +++++ .../tests/functional/api/test_delete_blob.py | 102 ++++++++++++++++++ 3 files changed, 131 insertions(+) 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 d18b3964b..eca226065 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -1201,6 +1201,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 b3ebb223065f9b3207b2f378e941b58ca8ec32b7 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 17:03:04 -0400 Subject: [PATCH 2/4] 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 From a9b8ff925e3fd40c61e368416c116335e07b797c Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Wed, 1 Jul 2026 10:42:37 -0400 Subject: [PATCH 3/4] Align blob delete tests with the delete manifest test pattern. Use a shared class-scoped sync setup, numbered tests, and async removal polling. Co-authored-by: Cursor --- .../tests/functional/api/test_delete_blob.py | 213 ++++++++++-------- 1 file changed, 116 insertions(+), 97 deletions(-) diff --git a/pulp_container/tests/functional/api/test_delete_blob.py b/pulp_container/tests/functional/api/test_delete_blob.py index 394ec838c..b8cd93caf 100644 --- a/pulp_container/tests/functional/api/test_delete_blob.py +++ b/pulp_container/tests/functional/api/test_delete_blob.py @@ -1,100 +1,119 @@ """Tests for deleting blobs via the Docker v2 API.""" -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: +import time + +import pytest + +from pulp_container.tests.functional.constants import PULP_FIXTURE_1 + + +def _wait_for_blob(container_bindings, repository_href, digest, present, timeout=60): + for _ in range(timeout): + repository = container_bindings.RepositoriesContainerApi.read(repository_href) + blobs = container_bindings.ContentBlobsApi.list( + digest=digest, repository_version=repository.latest_version_href + ) + if bool(blobs.results) == present: + if present: + return blobs.results[0] + return None + time.sleep(1) + if present: + pytest.fail(f"Blob '{digest}' was not available in the repository") + pytest.fail(f"Blob '{digest}' was not removed from the repository") + + +class TestDeleteBlob: + """Tests for DELETE /v2//blobs/.""" + + repo_name = "delete/blob" + dest_repo_name = "delete/blob-pending" + tag_name = "manifest_a" + + @pytest.fixture(scope="class") + def setup( + self, + add_to_cleanup, + container_bindings, + container_repository_factory, + container_remote_factory, + container_sync, + container_distribution_factory, + ): + """Sync an image once for all delete blob tests.""" + repository = container_repository_factory() + remote = container_remote_factory(upstream_name=PULP_FIXTURE_1, includes=[self.tag_name]) + container_sync(repository, remote) + repository = container_bindings.RepositoriesContainerApi.read(repository.pulp_href) + + 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) + + dest_repository = container_repository_factory() + container_distribution_factory( + name=self.dest_repo_name, + base_path=self.dest_repo_name, + repository=dest_repository.pulp_href, + ) + + blob = container_bindings.ContentBlobsApi.list( + repository_version=repository.latest_version_href + ).results[0] + + return repository, blob.digest + + def test_01_delete_invalid_digest(self, setup, local_registry, full_path): + """Delete requires a sha256 digest.""" + delete_path = f"/v2/{full_path(self.repo_name)}/blobs/not-a-digest" response, _ = local_registry.get_response("DELETE", delete_path) - assert response.status_code == 401 + assert response.status_code == 400 + assert response.json()["errors"][0]["code"] == "INVALID_REQUEST" + + def test_02_delete_not_found(self, setup, local_registry, full_path): + """Deleting a non-existent blob returns 404.""" + digest = f"sha256:{'0' * 64}" + delete_path = f"/v2/{full_path(self.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_03_delete_without_login(self, setup, gen_user, local_registry, full_path): + """Delete requires push permissions on the namespace.""" + _, digest = setup + delete_path = f"/v2/{full_path(self.repo_name)}/blobs/{digest}" + user_helpless = gen_user() + with user_helpless: + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code in (401, 403) + + def test_04_delete_pending_blob(self, setup, local_registry, full_path): + """Delete a pending blob via DELETE /v2//blobs/.""" + _, digest = setup + mount_path = ( + f"/v2/{full_path(self.dest_repo_name)}/blobs/uploads/" + f"?from={full_path(self.repo_name)}&mount={digest}" + ) + response, _ = local_registry.get_response("POST", mount_path) + assert response.status_code == 201 + + delete_path = f"/v2/{full_path(self.dest_repo_name)}/blobs/{digest}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 202 + + head_path = f"/v2/{full_path(self.dest_repo_name)}/blobs/{digest}" + response, _ = local_registry.get_response("HEAD", head_path) + assert response.status_code == 404 + + def test_05_delete_by_digest(self, setup, local_registry, container_bindings, full_path): + """Delete a committed blob by digest via DELETE /v2//blobs/.""" + repository, digest = setup + delete_path = f"/v2/{full_path(self.repo_name)}/blobs/{digest}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 202 + + _wait_for_blob(container_bindings, repository.pulp_href, digest, present=False) From 06860f275755fc9adf772ab3c14f1666f29094e3 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Wed, 1 Jul 2026 11:13:28 -0400 Subject: [PATCH 4/4] Remove committed blobs with add_and_remove instead of recursive remove. Use the immediate async task so blob delete matches registry semantics. Co-authored-by: Cursor --- pulp_container/app/registry_api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index eca226065..1e7c4c640 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -1220,12 +1220,15 @@ def destroy(self, request, path, pk=None): raise BlobNotFound(digest=pk) dispatch( - recursive_remove_content, + aadd_and_remove, exclusive_resources=[repository], kwargs={ "repository_pk": str(repository.pk), - "content_units": [str(blob.pk)], + "add_content_units": [], + "remove_content_units": [str(blob.pk)], }, + immediate=True, + deferred=True, ) return Response(status=202)