From 249a4956f32e216c15539c0b238281570258f416 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 15:48:44 -0400 Subject: [PATCH 1/8] Enable DELETE on the Docker v2 manifest endpoint Allow users to delete manifests by digest via the registry API, with recursive removal of related tags and content from push repositories. fixes: #480 Co-authored-by: Cursor --- CHANGES/480.feature | 1 + pulp_container/app/registry_api.py | 33 ++++++- .../functional/api/test_delete_manifest.py | 95 +++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 CHANGES/480.feature create mode 100644 pulp_container/tests/functional/api/test_delete_manifest.py diff --git a/CHANGES/480.feature b/CHANGES/480.feature new file mode 100644 index 000000000..f831636ba --- /dev/null +++ b/CHANGES/480.feature @@ -0,0 +1 @@ +Enable DELETE on the Docker v2 manifest endpoint so users can delete manifests by digest. diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index 92adfcbc9..6db068747 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, @@ -1329,6 +1329,37 @@ def handle_safe_method(self, request, path, pk): # Fallthrough catchall, no manifest or tag found raise ManifestNotFound(reference=pk) + def destroy(self, request, path, pk=None): + """ + Delete a manifest identified by digest. + """ + if not pk.startswith("sha256:"): + raise InvalidRequest(message="A manifest can only be deleted by digest.") + + _, repository = self.get_dr_push(request, path) + latest_version = repository.latest_version() + + manifest = models.Manifest.objects.filter(digest=pk, pk__in=latest_version.content).first() + if not manifest: + pending_manifest = repository.pending_manifests.filter(digest=pk).first() + if pending_manifest: + repository.pending_manifests.remove(pending_manifest) + return Response(status=202) + raise ManifestNotFound(reference=pk) + + tags = models.Tag.objects.filter(tagged_manifest=manifest, pk__in=latest_version.content) + content_units = [str(manifest.pk)] + [str(tag.pk) for tag in tags] + + dispatch( + recursive_remove_content, + exclusive_resources=[repository], + kwargs={ + "repository_pk": str(repository.pk), + "content_units": content_units, + }, + ) + return Response(status=202) + def get_content_units_to_add(self, manifest, tag=None): add_content_units = [str(manifest.pk)] if tag: diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py new file mode 100644 index 000000000..309cc3296 --- /dev/null +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -0,0 +1,95 @@ +"""Tests for deleting manifests via the Docker v2 API.""" + +import subprocess + +import pytest + +from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP + + +def test_delete_manifest_by_digest( + add_to_cleanup, + local_registry, + registry_client, + container_bindings, + full_path, +): + """Delete a manifest by digest via DELETE /v2//manifests/.""" + repo_name = "delete/manifest" + local_url = full_path(f"{repo_name}:manifest_a") + image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" + registry_client.pull(image_path) + local_registry.tag_and_push(image_path, local_url) + + namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) + + local_image = local_registry.inspect(f"{local_registry.name}/{local_url}") + digest = local_image[0]["Digest"] + + delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 202 + + with pytest.raises(subprocess.CalledProcessError): + local_registry.pull(f"{local_registry.name}/{full_path(repo_name)}:manifest_a") + + +def test_delete_manifest_by_tag_rejected( + add_to_cleanup, + local_registry, + registry_client, + container_bindings, + full_path, +): + """Delete by tag name is not allowed.""" + repo_name = "delete/by-tag" + local_url = full_path(f"{repo_name}:manifest_a") + image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" + registry_client.pull(image_path) + local_registry.tag_and_push(image_path, local_url) + + 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)}/manifests/manifest_a" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 400 + assert response.json()["errors"][0]["code"] == "INVALID_REQUEST" + + +def test_delete_manifest_not_found( + add_to_cleanup, + local_registry, + registry_client, + container_bindings, + full_path, +): + """Deleting a non-existent manifest returns 404.""" + repo_name = "delete/not-found" + local_url = full_path(f"{repo_name}:manifest_a") + image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" + registry_client.pull(image_path) + local_registry.tag_and_push(image_path, local_url) + + 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)}/manifests/{digest}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 404 + assert response.json()["errors"][0]["code"] == "MANIFEST_UNKNOWN" + + +def test_delete_manifest_without_login( + anonymous_user, + local_registry, + full_path, +): + """Delete requires authentication.""" + digest = f"sha256:{'0' * 64}" + delete_path = f"/v2/{full_path('delete/unauth')}/manifests/{digest}" + with anonymous_user: + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 401 From 0859b2149487374f68e7cb1aae175235d097e84b Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 16:12:29 -0400 Subject: [PATCH 2/8] Fix delete manifest functional test Obtain the manifest digest via HEAD instead of inspect after push, since tag_and_push removes the local image. Poll until the manifest is gone after delete because removal runs asynchronously. Co-authored-by: Cursor --- .../functional/api/test_delete_manifest.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py index 309cc3296..629c571b9 100644 --- a/pulp_container/tests/functional/api/test_delete_manifest.py +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -1,6 +1,6 @@ """Tests for deleting manifests via the Docker v2 API.""" -import subprocess +import time import pytest @@ -24,15 +24,22 @@ def test_delete_manifest_by_digest( namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0] add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) - local_image = local_registry.inspect(f"{local_registry.name}/{local_url}") - digest = local_image[0]["Digest"] + head_path = f"/v2/{full_path(repo_name)}/manifests/manifest_a" + response, _ = local_registry.get_response("HEAD", head_path) + assert response.status_code == 200 + digest = response.headers["Docker-Content-Digest"] delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" response, _ = local_registry.get_response("DELETE", delete_path) assert response.status_code == 202 - with pytest.raises(subprocess.CalledProcessError): - local_registry.pull(f"{local_registry.name}/{full_path(repo_name)}:manifest_a") + for _ in range(60): + response, _ = local_registry.get_response("HEAD", head_path) + if response.status_code == 404: + break + time.sleep(1) + else: + pytest.fail("Manifest was not removed from the repository") def test_delete_manifest_by_tag_rejected( From de2776e3e29557415a5bbe456bfb6128ce5589d1 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 16:33:28 -0400 Subject: [PATCH 3/8] Fix delete manifest test timing and auth checks Wait for the pushed manifest to appear before reading its digest, and send unauthenticated DELETE requests directly instead of via get_response. Co-authored-by: Cursor --- .../functional/api/test_delete_manifest.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py index 629c571b9..f7d090d59 100644 --- a/pulp_container/tests/functional/api/test_delete_manifest.py +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -1,12 +1,23 @@ """Tests for deleting manifests via the Docker v2 API.""" import time +from urllib.parse import urljoin import pytest +import requests from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP +def _wait_for_manifest_head(local_registry, head_path, expected_status, timeout=60): + for _ in range(timeout): + response, _ = local_registry.get_response("HEAD", head_path) + if response.status_code == expected_status: + return response + time.sleep(1) + pytest.fail(f"Manifest HEAD did not return {expected_status}") + + def test_delete_manifest_by_digest( add_to_cleanup, local_registry, @@ -25,21 +36,14 @@ def test_delete_manifest_by_digest( add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) head_path = f"/v2/{full_path(repo_name)}/manifests/manifest_a" - response, _ = local_registry.get_response("HEAD", head_path) - assert response.status_code == 200 + response = _wait_for_manifest_head(local_registry, head_path, 200) digest = response.headers["Docker-Content-Digest"] delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" response, _ = local_registry.get_response("DELETE", delete_path) assert response.status_code == 202 - for _ in range(60): - response, _ = local_registry.get_response("HEAD", head_path) - if response.status_code == 404: - break - time.sleep(1) - else: - pytest.fail("Manifest was not removed from the repository") + _wait_for_manifest_head(local_registry, head_path, 404) def test_delete_manifest_by_tag_rejected( @@ -89,14 +93,11 @@ def test_delete_manifest_not_found( assert response.json()["errors"][0]["code"] == "MANIFEST_UNKNOWN" -def test_delete_manifest_without_login( - anonymous_user, - local_registry, - full_path, -): +def test_delete_manifest_without_login(anonymous_user, bindings_cfg, full_path): """Delete requires authentication.""" digest = f"sha256:{'0' * 64}" delete_path = f"/v2/{full_path('delete/unauth')}/manifests/{digest}" + url = urljoin(bindings_cfg.host, delete_path) with anonymous_user: - response, _ = local_registry.get_response("DELETE", delete_path) + response = requests.delete(url) assert response.status_code == 401 From 9e311c474b81f9b344ede43623094ad6739bf547 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 16:54:13 -0400 Subject: [PATCH 4/8] Use Pulp API to verify manifest delete test state Wait for the pushed tag via repository API instead of registry HEAD, which is unreliable during async push completion in CI. Co-authored-by: Cursor --- .../functional/api/test_delete_manifest.py | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py index f7d090d59..095ebf01f 100644 --- a/pulp_container/tests/functional/api/test_delete_manifest.py +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -6,16 +6,24 @@ import pytest import requests -from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP +from pulp_container.tests.functional.constants import ( + PULP_FIXTURE_1_MANIFEST_A_DIGEST, + REGISTRY_V2_REPO_PULP, +) -def _wait_for_manifest_head(local_registry, head_path, expected_status, timeout=60): +def _wait_for_tag(container_bindings, repository_href, tag_name, present, timeout=60): for _ in range(timeout): - response, _ = local_registry.get_response("HEAD", head_path) - if response.status_code == expected_status: - return response + repository = container_bindings.RepositoriesContainerPushApi.read(repository_href) + tags = container_bindings.ContentTagsApi.list( + name=tag_name, repository_version=repository.latest_version_href + ) + if bool(tags.results) == present: + return time.sleep(1) - pytest.fail(f"Manifest HEAD did not return {expected_status}") + if present: + pytest.fail(f"Tag '{tag_name}' was not available in the repository") + pytest.fail(f"Tag '{tag_name}' was not removed from the repository") def test_delete_manifest_by_digest( @@ -35,15 +43,14 @@ def test_delete_manifest_by_digest( namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0] add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) - head_path = f"/v2/{full_path(repo_name)}/manifests/manifest_a" - response = _wait_for_manifest_head(local_registry, head_path, 200) - digest = response.headers["Docker-Content-Digest"] + repository = container_bindings.RepositoriesContainerPushApi.list(name=repo_name).results[0] + _wait_for_tag(container_bindings, repository.pulp_href, "manifest_a", present=True) - delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" + delete_path = f"/v2/{full_path(repo_name)}/manifests/{PULP_FIXTURE_1_MANIFEST_A_DIGEST}" response, _ = local_registry.get_response("DELETE", delete_path) assert response.status_code == 202 - _wait_for_manifest_head(local_registry, head_path, 404) + _wait_for_tag(container_bindings, repository.pulp_href, "manifest_a", present=False) def test_delete_manifest_by_tag_rejected( From 0a72b07561fe797d8af0c5c57ad3da0a854b0ac1 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 17:16:40 -0400 Subject: [PATCH 5/8] Fix manifest delete lookup for tagged manifests Resolve manifests through repository tags when they are not directly present in the repository version, and tighten functional test setup. Co-authored-by: Cursor --- pulp_container/app/registry_api.py | 3 ++ .../functional/api/test_delete_manifest.py | 44 ++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index 6db068747..d9e162bd1 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -1339,7 +1339,10 @@ def destroy(self, request, path, pk=None): _, repository = self.get_dr_push(request, path) latest_version = repository.latest_version() + tags = models.Tag.objects.filter(tagged_manifest__digest=pk, pk__in=latest_version.content) manifest = models.Manifest.objects.filter(digest=pk, pk__in=latest_version.content).first() + if not manifest and tags.exists(): + manifest = tags.first().tagged_manifest if not manifest: pending_manifest = repository.pending_manifests.filter(digest=pk).first() if pending_manifest: diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py index 095ebf01f..2fc3f5f13 100644 --- a/pulp_container/tests/functional/api/test_delete_manifest.py +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -6,10 +6,7 @@ import pytest import requests -from pulp_container.tests.functional.constants import ( - PULP_FIXTURE_1_MANIFEST_A_DIGEST, - REGISTRY_V2_REPO_PULP, -) +from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP def _wait_for_tag(container_bindings, repository_href, tag_name, present, timeout=60): @@ -19,7 +16,9 @@ def _wait_for_tag(container_bindings, repository_href, tag_name, present, timeou name=tag_name, repository_version=repository.latest_version_href ) if bool(tags.results) == present: - return + if present: + return tags.results[0].tagged_manifest + return None time.sleep(1) if present: pytest.fail(f"Tag '{tag_name}' was not available in the repository") @@ -44,9 +43,12 @@ def test_delete_manifest_by_digest( add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) repository = container_bindings.RepositoriesContainerPushApi.list(name=repo_name).results[0] - _wait_for_tag(container_bindings, repository.pulp_href, "manifest_a", present=True) + manifest_href = _wait_for_tag( + container_bindings, repository.pulp_href, "manifest_a", present=True + ) + digest = container_bindings.ContentManifestsApi.read(manifest_href).digest - delete_path = f"/v2/{full_path(repo_name)}/manifests/{PULP_FIXTURE_1_MANIFEST_A_DIGEST}" + delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" response, _ = local_registry.get_response("DELETE", delete_path) assert response.status_code == 202 @@ -100,10 +102,32 @@ def test_delete_manifest_not_found( assert response.json()["errors"][0]["code"] == "MANIFEST_UNKNOWN" -def test_delete_manifest_without_login(anonymous_user, bindings_cfg, full_path): +def test_delete_manifest_without_login( + add_to_cleanup, + anonymous_user, + bindings_cfg, + container_bindings, + full_path, + local_registry, + registry_client, +): """Delete requires authentication.""" - digest = f"sha256:{'0' * 64}" - delete_path = f"/v2/{full_path('delete/unauth')}/manifests/{digest}" + repo_name = "delete/unauth" + local_url = full_path(f"{repo_name}:manifest_a") + image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" + registry_client.pull(image_path) + local_registry.tag_and_push(image_path, local_url) + + namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0] + add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href) + + repository = container_bindings.RepositoriesContainerPushApi.list(name=repo_name).results[0] + manifest_href = _wait_for_tag( + container_bindings, repository.pulp_href, "manifest_a", present=True + ) + digest = container_bindings.ContentManifestsApi.read(manifest_href).digest + + delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" url = urljoin(bindings_cfg.host, delete_path) with anonymous_user: response = requests.delete(url) From 48da17a2c1156b2cb7024564115efbc61743ee74 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 17:41:50 -0400 Subject: [PATCH 6/8] Fix unauthenticated delete test credentials Avoid picking up CI netrc credentials by disabling trust_env when sending an unauthenticated DELETE request. Co-authored-by: Cursor --- pulp_container/tests/functional/api/test_delete_manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py index 2fc3f5f13..8fa2132fe 100644 --- a/pulp_container/tests/functional/api/test_delete_manifest.py +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -130,5 +130,5 @@ def test_delete_manifest_without_login( delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" url = urljoin(bindings_cfg.host, delete_path) with anonymous_user: - response = requests.delete(url) + response = requests.delete(url, trust_env=False) assert response.status_code == 401 From c032addaf14bc4f55c98e39de0caa6563f7b40bb Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 18:05:02 -0400 Subject: [PATCH 7/8] Fix unauthenticated delete test to use registry client auth Use gen_user and local_registry.get_response like other RBAC registry tests instead of raw requests.delete with trust_env, which is invalid and could authenticate as admin via RemoteUser in CI. fixes: #480 Co-authored-by: Cursor --- .../tests/functional/api/test_delete_manifest.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py index 8fa2132fe..d4cb1d425 100644 --- a/pulp_container/tests/functional/api/test_delete_manifest.py +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -1,10 +1,8 @@ """Tests for deleting manifests via the Docker v2 API.""" import time -from urllib.parse import urljoin import pytest -import requests from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP @@ -104,14 +102,13 @@ def test_delete_manifest_not_found( def test_delete_manifest_without_login( add_to_cleanup, - anonymous_user, - bindings_cfg, + gen_user, container_bindings, full_path, local_registry, registry_client, ): - """Delete requires authentication.""" + """Delete requires push permissions on the namespace.""" repo_name = "delete/unauth" local_url = full_path(f"{repo_name}:manifest_a") image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" @@ -128,7 +125,7 @@ def test_delete_manifest_without_login( digest = container_bindings.ContentManifestsApi.read(manifest_href).digest delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}" - url = urljoin(bindings_cfg.host, delete_path) - with anonymous_user: - response = requests.delete(url, trust_env=False) + user_helpless = gen_user() + with user_helpless: + response, _ = local_registry.get_response("DELETE", delete_path) assert response.status_code == 401 From fe9682c5c65c0bd6820ad81d8fb6c4e086d3deea Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 1 Jun 2026 18:27:50 -0400 Subject: [PATCH 8/8] Accept 403 for unauthorized manifest delete in s3 CI Users without push permissions may receive 403 Forbidden instead of 401 Unauthorized depending on the storage backend configuration. fixes: #480 Co-authored-by: Cursor --- pulp_container/tests/functional/api/test_delete_manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pulp_container/tests/functional/api/test_delete_manifest.py b/pulp_container/tests/functional/api/test_delete_manifest.py index d4cb1d425..f066409c5 100644 --- a/pulp_container/tests/functional/api/test_delete_manifest.py +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -128,4 +128,4 @@ def test_delete_manifest_without_login( user_helpless = gen_user() with user_helpless: response, _ = local_registry.get_response("DELETE", delete_path) - assert response.status_code == 401 + assert response.status_code in (401, 403)