Skip to content

Commit 249a495

Browse files
gerrod3cursoragent
andcommitted
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 <cursoragent@cursor.com>
1 parent e42c6ef commit 249a495

3 files changed

Lines changed: 128 additions & 1 deletion

File tree

CHANGES/480.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Enable DELETE on the Docker v2 manifest endpoint so users can delete manifests by digest.

pulp_container/app/registry_api.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
FileStorageRedirects,
7474
S3StorageRedirects,
7575
)
76-
from pulp_container.app.tasks import aadd_and_remove, download_image_data
76+
from pulp_container.app.tasks import aadd_and_remove, download_image_data, recursive_remove_content
7777
from pulp_container.app.token_verification import (
7878
RegistryAuthentication,
7979
RegistryPermission,
@@ -1329,6 +1329,37 @@ def handle_safe_method(self, request, path, pk):
13291329
# Fallthrough catchall, no manifest or tag found
13301330
raise ManifestNotFound(reference=pk)
13311331

1332+
def destroy(self, request, path, pk=None):
1333+
"""
1334+
Delete a manifest identified by digest.
1335+
"""
1336+
if not pk.startswith("sha256:"):
1337+
raise InvalidRequest(message="A manifest can only be deleted by digest.")
1338+
1339+
_, repository = self.get_dr_push(request, path)
1340+
latest_version = repository.latest_version()
1341+
1342+
manifest = models.Manifest.objects.filter(digest=pk, pk__in=latest_version.content).first()
1343+
if not manifest:
1344+
pending_manifest = repository.pending_manifests.filter(digest=pk).first()
1345+
if pending_manifest:
1346+
repository.pending_manifests.remove(pending_manifest)
1347+
return Response(status=202)
1348+
raise ManifestNotFound(reference=pk)
1349+
1350+
tags = models.Tag.objects.filter(tagged_manifest=manifest, pk__in=latest_version.content)
1351+
content_units = [str(manifest.pk)] + [str(tag.pk) for tag in tags]
1352+
1353+
dispatch(
1354+
recursive_remove_content,
1355+
exclusive_resources=[repository],
1356+
kwargs={
1357+
"repository_pk": str(repository.pk),
1358+
"content_units": content_units,
1359+
},
1360+
)
1361+
return Response(status=202)
1362+
13321363
def get_content_units_to_add(self, manifest, tag=None):
13331364
add_content_units = [str(manifest.pk)]
13341365
if tag:
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for deleting manifests via the Docker v2 API."""
2+
3+
import subprocess
4+
5+
import pytest
6+
7+
from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP
8+
9+
10+
def test_delete_manifest_by_digest(
11+
add_to_cleanup,
12+
local_registry,
13+
registry_client,
14+
container_bindings,
15+
full_path,
16+
):
17+
"""Delete a manifest by digest via DELETE /v2/<name>/manifests/<digest>."""
18+
repo_name = "delete/manifest"
19+
local_url = full_path(f"{repo_name}:manifest_a")
20+
image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a"
21+
registry_client.pull(image_path)
22+
local_registry.tag_and_push(image_path, local_url)
23+
24+
namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0]
25+
add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href)
26+
27+
local_image = local_registry.inspect(f"{local_registry.name}/{local_url}")
28+
digest = local_image[0]["Digest"]
29+
30+
delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}"
31+
response, _ = local_registry.get_response("DELETE", delete_path)
32+
assert response.status_code == 202
33+
34+
with pytest.raises(subprocess.CalledProcessError):
35+
local_registry.pull(f"{local_registry.name}/{full_path(repo_name)}:manifest_a")
36+
37+
38+
def test_delete_manifest_by_tag_rejected(
39+
add_to_cleanup,
40+
local_registry,
41+
registry_client,
42+
container_bindings,
43+
full_path,
44+
):
45+
"""Delete by tag name is not allowed."""
46+
repo_name = "delete/by-tag"
47+
local_url = full_path(f"{repo_name}:manifest_a")
48+
image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a"
49+
registry_client.pull(image_path)
50+
local_registry.tag_and_push(image_path, local_url)
51+
52+
namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0]
53+
add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href)
54+
55+
delete_path = f"/v2/{full_path(repo_name)}/manifests/manifest_a"
56+
response, _ = local_registry.get_response("DELETE", delete_path)
57+
assert response.status_code == 400
58+
assert response.json()["errors"][0]["code"] == "INVALID_REQUEST"
59+
60+
61+
def test_delete_manifest_not_found(
62+
add_to_cleanup,
63+
local_registry,
64+
registry_client,
65+
container_bindings,
66+
full_path,
67+
):
68+
"""Deleting a non-existent manifest returns 404."""
69+
repo_name = "delete/not-found"
70+
local_url = full_path(f"{repo_name}:manifest_a")
71+
image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a"
72+
registry_client.pull(image_path)
73+
local_registry.tag_and_push(image_path, local_url)
74+
75+
namespace = container_bindings.PulpContainerNamespacesApi.list(name="delete").results[0]
76+
add_to_cleanup(container_bindings.PulpContainerNamespacesApi, namespace.pulp_href)
77+
78+
digest = f"sha256:{'0' * 64}"
79+
delete_path = f"/v2/{full_path(repo_name)}/manifests/{digest}"
80+
response, _ = local_registry.get_response("DELETE", delete_path)
81+
assert response.status_code == 404
82+
assert response.json()["errors"][0]["code"] == "MANIFEST_UNKNOWN"
83+
84+
85+
def test_delete_manifest_without_login(
86+
anonymous_user,
87+
local_registry,
88+
full_path,
89+
):
90+
"""Delete requires authentication."""
91+
digest = f"sha256:{'0' * 64}"
92+
delete_path = f"/v2/{full_path('delete/unauth')}/manifests/{digest}"
93+
with anonymous_user:
94+
response, _ = local_registry.get_response("DELETE", delete_path)
95+
assert response.status_code == 401

0 commit comments

Comments
 (0)