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..d9e162bd1 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,40 @@ 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() + + 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: + 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..f066409c5 --- /dev/null +++ b/pulp_container/tests/functional/api/test_delete_manifest.py @@ -0,0 +1,131 @@ +"""Tests for deleting manifests via the Docker v2 API.""" + +import time + +import pytest + +from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP + + +def _wait_for_tag(container_bindings, repository_href, tag_name, present, timeout=60): + for _ in range(timeout): + 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: + 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") + + +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) + + 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}" + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code == 202 + + _wait_for_tag(container_bindings, repository.pulp_href, "manifest_a", present=False) + + +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( + add_to_cleanup, + gen_user, + container_bindings, + full_path, + local_registry, + registry_client, +): + """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" + 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}" + user_helpless = gen_user() + with user_helpless: + response, _ = local_registry.get_response("DELETE", delete_path) + assert response.status_code in (401, 403)