From 9fa4b5a1b5105dff40bce211c60413a523f48e31 Mon Sep 17 00:00:00 2001 From: Gerrod Ubben Date: Mon, 29 Jun 2026 13:17:34 -0400 Subject: [PATCH] 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 | 32 +++++- .../functional/api/test_delete_manifest.py | 97 +++++++++++++++++++ 3 files changed, 129 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 8c47c872b..d18b3964b 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -79,7 +79,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, @@ -1345,6 +1345,36 @@ 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: + manifest = repository.pending_manifests.filter(digest=pk).first() + if not manifest: + raise ManifestNotFound(reference=pk) + repository.pending_manifests.remove(manifest) + + 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..4a8c68f3f --- /dev/null +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -0,0 +1,97 @@ +"""Tests for deleting manifests via the Docker v2 API.""" + +import time + +import pytest + +from pulp_container.tests.functional.constants import PULP_FIXTURE_1 + + +def _wait_for_tag(container_bindings, repository_href, tag_name, present, timeout=60): + for _ in range(timeout): + repository = container_bindings.RepositoriesContainerApi.read(repository_href) + tags = container_bindings.ContentTagsApi.list( + name=tag_name, repository_version=repository.latest_version_href + ) + if bool(tags.results) == present: + 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") + pytest.fail(f"Tag '{tag_name}' was not removed from the repository") + + +class TestDeleteManifest: + """Tests for DELETE /v2//manifests/.""" + + repo_name = "delete/manifest" + 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 manifest 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) + + manifest_href = ( + container_bindings.ContentTagsApi.list( + name=self.tag_name, repository_version=repository.latest_version_href + ) + .results[0] + .tagged_manifest + ) + digest = container_bindings.ContentManifestsApi.read(manifest_href).digest + return repository, digest + + def test_01_delete_by_tag_rejected(self, setup, local_registry, full_path): + """Delete by tag name is not allowed.""" + delete_path = f"/v2/{full_path(self.repo_name)}/manifests/{self.tag_name}" + response, _ = local_registry.get_response("DELETE", delete_path) + 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 manifest returns 404.""" + digest = f"sha256:{'0' * 64}" + delete_path = f"/v2/{full_path(self.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_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)}/manifests/{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_by_digest(self, setup, local_registry, container_bindings, full_path): + """Delete a manifest by digest via DELETE /v2//manifests/.""" + repository, digest = setup + delete_path = f"/v2/{full_path(self.repo_name)}/manifests/{digest}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 202 + + _wait_for_tag(container_bindings, repository.pulp_href, self.tag_name, present=False)