diff --git a/pulp_python/app/migrations/0023_packageyank.py b/pulp_python/app/migrations/0023_packageyank.py new file mode 100644 index 00000000..9c1f057a --- /dev/null +++ b/pulp_python/app/migrations/0023_packageyank.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.13 on 2026-07-17 17:05 + +import django.db.models.deletion +from django.db import migrations, models + +import pulpcore.app.util + + +class Migration(migrations.Migration): + + dependencies = [ + ("python", "0022_pythonblocklistentry"), + ] + + operations = [ + migrations.CreateModel( + name="PackageYank", + fields=[ + ( + "content_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.content", + ), + ), + ("name_normalized", models.TextField()), + ("version", models.TextField()), + ("yanked_reason", models.TextField(default="")), + ( + "_pulp_domain", + models.ForeignKey( + default=pulpcore.app.util.get_domain_pk, + on_delete=django.db.models.deletion.PROTECT, + to="core.domain", + ), + ), + ], + options={ + "default_related_name": "%(app_label)s_%(model_name)s", + "unique_together": {("name_normalized", "version", "_pulp_domain")}, + }, + bases=("core.content",), + ), + ] diff --git a/pulp_python/app/models.py b/pulp_python/app/models.py index bdfe9e7f..ab1a21a9 100644 --- a/pulp_python/app/models.py +++ b/pulp_python/app/models.py @@ -204,8 +204,6 @@ class PythonPackageContent(Content): sha256 = models.CharField(db_index=True, max_length=64) metadata_sha256 = models.CharField(max_length=64, null=True) size = models.BigIntegerField(default=0) - # yanked and yanked_reason are not implemented because they are mutable - # From pulpcore PROTECTED_FROM_RECLAIM = False TYPE = "python" @@ -289,6 +287,29 @@ class Meta: unique_together = ("sha256", "_pulp_domain") +class PackageYank(Content): + """ + A marker content type indicating a package version is yanked in a repository (PEP 592). + + Its presence in a repository version means all files for the matching + (name_normalized, version) pair are yanked. Yank/unyank operations + add/remove this marker, creating new repository versions. + """ + + TYPE = "python_yank" + repo_key_fields = ("name_normalized", "version") + + name_normalized = models.TextField() + version = models.TextField() + yanked_reason = models.TextField(default="") + + _pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT) + + class Meta: + default_related_name = "%(app_label)s_%(model_name)s" + unique_together = ("name_normalized", "version", "_pulp_domain") + + class PythonPublication(Publication, AutoAddObjPermsMixin): """ A Publication for PythonContent. @@ -364,7 +385,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin): """ TYPE = "python" - CONTENT_TYPES = [PythonPackageContent, PackageProvenance] + CONTENT_TYPES = [PythonPackageContent, PackageProvenance, PackageYank] REMOTE_TYPES = [PythonRemote] PULL_THROUGH_SUPPORTED = True diff --git a/pulp_python/app/pypi/serializers.py b/pulp_python/app/pypi/serializers.py index 746606a9..de241297 100644 --- a/pulp_python/app/pypi/serializers.py +++ b/pulp_python/app/pypi/serializers.py @@ -128,6 +128,27 @@ def validate(self, data): return data +class YankSerializer(serializers.Serializer): + """ + A Serializer for yank/unyank requests (PEP 592). + """ + + name = serializers.CharField( + help_text=_("The name of the package to yank or unyank."), + required=True, + ) + version = serializers.CharField( + help_text=_("The version of the package to yank or unyank."), + required=True, + ) + yanked_reason = serializers.CharField( + help_text=_("The reason for yanking the package version."), + required=False, + allow_blank=True, + default="", + ) + + class PackageUploadTaskSerializer(serializers.Serializer): """ A Serializer for responding to a package upload task. diff --git a/pulp_python/app/pypi/views.py b/pulp_python/app/pypi/views.py index e228d88d..b2172967 100644 --- a/pulp_python/app/pypi/views.py +++ b/pulp_python/app/pypi/views.py @@ -42,6 +42,7 @@ PackageUploadSerializer, PackageUploadTaskSerializer, SummarySerializer, + YankSerializer, ) from pulp_python.app.utils import ( PYPI_LAST_SERIAL, @@ -565,3 +566,70 @@ def retrieve(self, request, path, package, version, filename): if provenance: return Response(data=provenance.provenance) return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.") + + +class YankView(PyPIMixin, ViewSet): + """View for yank/unyank requests (PEP 592).""" + + endpoint_name = "yank" + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["yank", "unyank"], + "principal": "authenticated", + "effect": "allow", + "condition": "index_has_repo_perm:python.modify_pythonrepository", + }, + ], + } + + @extend_schema(request=YankSerializer, summary="Yank a package version") + def yank(self, request, path): + """Yank a package version, marking all its files with data-yanked.""" + repo = self.distribution.repository + if not repo: + return HttpResponseBadRequest(reason="Index is not pointing to a repository") + + serializer = YankSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + normalized = canonicalize_name(serializer.validated_data["name"]) + version = serializer.validated_data["version"] + repo_ver = self.get_repository_version(self.distribution) + if not PythonPackageContent.objects.filter( + pk__in=repo_ver.content, name_normalized=normalized, version=version + ).exists(): + return HttpResponseNotFound(f"{normalized}=={version} not found in repository") + + result = dispatch( + tasks.ayank_package, + exclusive_resources=[repo], + kwargs={ + "repository_pk": str(repo.pk), + "name": serializer.validated_data["name"], + "version": serializer.validated_data["version"], + "reason": serializer.validated_data.get("yanked_reason", ""), + }, + ) + return OperationPostponedResponse(result, request) + + @extend_schema(request=YankSerializer, summary="Unyank a package version") + def unyank(self, request, path): + """Unyank a package version, unmarking all its files with data-yanked.""" + repo = self.distribution.repository + if not repo: + return HttpResponseBadRequest(reason="Index is not pointing to a repository") + + serializer = YankSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + result = dispatch( + tasks.aunyank_package, + exclusive_resources=[repo], + kwargs={ + "repository_pk": str(repo.pk), + "name": serializer.validated_data["name"], + "version": serializer.validated_data["version"], + }, + ) + return OperationPostponedResponse(result, request) diff --git a/pulp_python/app/serializers.py b/pulp_python/app/serializers.py index 560d8e93..99256122 100644 --- a/pulp_python/app/serializers.py +++ b/pulp_python/app/serializers.py @@ -700,6 +700,25 @@ def to_representation(self, value): return result +class PackageYankSerializer(core_serializers.NoArtifactContentSerializer): + """ + Read-only serializer for PackageYank content units (PEP 592). + Used by PackageYankViewSet to expose yank markers via the Pulp REST API. + """ + + name_normalized = serializers.CharField(read_only=True) + version = serializers.CharField(read_only=True) + yanked_reason = serializers.CharField(read_only=True) + + class Meta: + fields = core_serializers.NoArtifactContentSerializer.Meta.fields + ( + "name_normalized", + "version", + "yanked_reason", + ) + model = python_models.PackageYank + + class PythonRemoteSerializer(core_serializers.RemoteSerializer): """ A Serializer for PythonRemote. diff --git a/pulp_python/app/tasks/__init__.py b/pulp_python/app/tasks/__init__.py index 6c2949eb..6c193ddd 100644 --- a/pulp_python/app/tasks/__init__.py +++ b/pulp_python/app/tasks/__init__.py @@ -7,3 +7,4 @@ from .sync import sync # noqa:F401 from .upload import upload, upload_group # noqa:F401 from .vulnerability_report import get_repo_version_content # noqa:F401 +from .yank import aunyank_package, ayank_package # noqa:F401 diff --git a/pulp_python/app/tasks/yank.py b/pulp_python/app/tasks/yank.py new file mode 100644 index 00000000..b305bece --- /dev/null +++ b/pulp_python/app/tasks/yank.py @@ -0,0 +1,63 @@ +from packaging.utils import canonicalize_name + +from pulpcore.plugin.tasking import aadd_and_remove + +from pulp_python.app.models import PackageYank, PythonPackageContent, PythonRepository + + +async def ayank_package(repository_pk, name, version, reason=""): + """ + Yank a package version in a repository by adding a PackageYank marker. + Creates a new repository version with the yank marker added. + """ + normalized = canonicalize_name(name) + repository = await PythonRepository.objects.aget(pk=repository_pk) + latest = await repository.alatest_version() + + exists = await PythonPackageContent.objects.filter( + pk__in=latest.content, name_normalized=normalized, version=version + ).aexists() + if not exists: + raise ValueError(f"Package {name}=={version} not found in repository") + + already_yanked = await PackageYank.objects.filter( + pk__in=latest.content, name_normalized=normalized, version=version + ).aexists() + if already_yanked: + return + + yank_marker, _ = await PackageYank.objects.aget_or_create( + name_normalized=normalized, + version=version, + _pulp_domain_id=repository.pulp_domain_id, + defaults={"yanked_reason": reason}, + ) + + await aadd_and_remove( + repository_pk=repository.pk, + add_content_units=[yank_marker.pk], + remove_content_units=[], + ) + + +async def aunyank_package(repository_pk, name, version): + """ + Unyank a package version in a repository by removing its PackageYank marker. + Creates a new repository version with the yank marker removed. + """ + normalized = canonicalize_name(name) + repository = await PythonRepository.objects.aget(pk=repository_pk) + latest = await repository.alatest_version() + + yank_marker = await PackageYank.objects.filter( + pk__in=latest.content, name_normalized=normalized, version=version + ).afirst() + + if yank_marker is None: + return + + await aadd_and_remove( + repository_pk=repository.pk, + add_content_units=[], + remove_content_units=[yank_marker.pk], + ) diff --git a/pulp_python/app/urls.py b/pulp_python/app/urls.py index 309900b9..7345bd6e 100644 --- a/pulp_python/app/urls.py +++ b/pulp_python/app/urls.py @@ -7,6 +7,7 @@ PyPIView, SimpleView, UploadView, + YankView, ) if settings.DOMAIN_ENABLED: @@ -40,5 +41,7 @@ SimpleView.as_view({"get": "list", "post": "create"}), name="simple-detail", ), + path(PYPI_API_URL + "yank/", YankView.as_view({"post": "yank"}), name="yank"), + path(PYPI_API_URL + "unyank/", YankView.as_view({"post": "unyank"}), name="unyank"), path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"), ] diff --git a/pulp_python/app/viewsets.py b/pulp_python/app/viewsets.py index 58a41187..c64ba919 100644 --- a/pulp_python/app/viewsets.py +++ b/pulp_python/app/viewsets.py @@ -615,6 +615,30 @@ class PackageProvenanceViewSet(core_viewsets.NoArtifactContentUploadViewSet): } +# todo: for .../repo-uuid/versions/ ? +class PackageYankViewSet(core_viewsets.ReadOnlyContentViewSet): + """ + Read-only viewset for PackageYank content units (PEP 592). + PackageYank markers indicate that a package version has been yanked in a repository. + Use the /yank/ and /unyank/ PyPI endpoints to create or remove these markers. + """ + + endpoint_name = "yanks" + queryset = python_models.PackageYank.objects.all() + serializer_class = python_serializers.PackageYankSerializer + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + + class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin): """ diff --git a/pulp_python/tests/functional/api/test_yank.py b/pulp_python/tests/functional/api/test_yank.py new file mode 100644 index 00000000..50323865 --- /dev/null +++ b/pulp_python/tests/functional/api/test_yank.py @@ -0,0 +1,204 @@ +from urllib.parse import urljoin + +import pytest +import requests + +from pulp_python.tests.functional.constants import ( + PYTHON_FIXTURES_URL, + TWINE_EGG_FILENAME, + TWINE_EGG_URL, + TWINE_WHEEL_FILENAME, + TWINE_WHEEL_URL, +) + +YANK_AUTH = ("admin", "password") +TWINE_NAME = "twine" +TWINE_VERSION = "5.1.0" + +TWINE_500_WHEEL_FILENAME = "twine-5.0.0-py3-none-any.whl" +TWINE_500_WHEEL_URL = urljoin(urljoin(PYTHON_FIXTURES_URL, "packages/"), TWINE_500_WHEEL_FILENAME) + + +def yank(distro, name, version, reason=""): + url = urljoin(distro.base_url, "yank/") + return requests.post( + url, json={"name": name, "version": version, "reason": reason}, auth=YANK_AUTH + ) + + +def unyank(distro, name, version): + url = urljoin(distro.base_url, "unyank/") + return requests.post(url, json={"name": name, "version": version}, auth=YANK_AUTH) + + +@pytest.mark.parallel +def test_yank_and_unyank( + monitor_task, + python_bindings, + python_content_factory, + python_content_summary, + python_distribution_factory, + python_repo_factory, +): + """ + Yank and unyank lifecycle including idempotency checks. + + Every yank/unyank that changes state creates a new repo version. + Repeating the same operation is a no-op (no new version). + """ + content_sdist = python_content_factory(TWINE_EGG_FILENAME, url=TWINE_EGG_URL) + content_whl = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL) + repo = python_repo_factory() + body = {"add_content_units": [content_sdist.pulp_href, content_whl.pulp_href]} + monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task) + repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href) + distro = python_distribution_factory(repository=repo) + version_1 = repo.latest_version_href + + # Yank + response = yank(distro, TWINE_NAME, TWINE_VERSION, reason="broken") + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href) + version_2 = repo.latest_version_href + assert version_2 != version_1 + summary = python_content_summary(repository=repo, version=2) + assert summary.added["python.python_yank"]["count"] == 1 + + # Yank again - idempotent, no new repo version + response = yank(distro, TWINE_NAME, TWINE_VERSION, reason="broken") + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href) + assert repo.latest_version_href == version_2 + + # Unyank + response = unyank(distro, TWINE_NAME, TWINE_VERSION) + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href) + version_3 = repo.latest_version_href + assert version_3 != version_2 + summary = python_content_summary(repository=repo, version=3) + assert summary.removed["python.python_yank"]["count"] == 1 + + # Unyank again - idempotent, no new repo version + response = unyank(distro, TWINE_NAME, TWINE_VERSION) + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href) + assert repo.latest_version_href == version_3 + + +@pytest.mark.parallel +def test_partial_yank( + monitor_task, + python_bindings, + python_content_factory, + python_content_summary, + python_distribution_factory, + python_repo_factory, +): + """ + Yanking one version does not affect other versions of the same package. + """ + content_510 = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL) + content_500 = python_content_factory(TWINE_500_WHEEL_FILENAME, url=TWINE_500_WHEEL_URL) + + repo = python_repo_factory() + body = {"add_content_units": [content_510.pulp_href, content_500.pulp_href]} + monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task) + distro = python_distribution_factory(repository=repo) + + response = yank(distro, TWINE_NAME, TWINE_VERSION, reason="broken 5.1.0") + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + summary = python_content_summary(repository=repo, version=2) + assert summary.added["python.python_yank"]["count"] == 1 + assert summary.present["python.python_yank"]["count"] == 1 + + # 5.0.0 should still be yankable (proves it wasn't already yanked) + response = yank(distro, TWINE_NAME, "5.0.0", reason="broken 5.0.0") + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + summary = python_content_summary(repository=repo, version=3) + assert summary.present["python.python_yank"]["count"] == 2 + + +@pytest.mark.parallel +def test_yank_isolation_across_repositories( + monitor_task, + python_bindings, + python_content_factory, + python_content_summary, + python_distribution_factory, + python_repo_factory, +): + """ + Yanking in one repo does not affect another repo with the same content. + """ + content = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL) + + repo_a = python_repo_factory() + repo_b = python_repo_factory() + body = {"add_content_units": [content.pulp_href]} + monitor_task(python_bindings.RepositoriesPythonApi.modify(repo_a.pulp_href, body).task) + monitor_task(python_bindings.RepositoriesPythonApi.modify(repo_b.pulp_href, body).task) + + distro_a = python_distribution_factory(repository=repo_a) + distro_b = python_distribution_factory(repository=repo_b) + + # Yank in repo A only + response = yank(distro_a, TWINE_NAME, TWINE_VERSION) + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + # Repo A should have a yank marker, repo B should not + summary_a = python_content_summary(repository=repo_a, version=2) + assert summary_a.present["python.python_yank"]["count"] == 1 + summary_b = python_content_summary(repository=repo_b, version=1) + assert "python.python_yank" not in summary_b.present + + # Yank in repo B too, then unyank only in A + response = yank(distro_b, TWINE_NAME, TWINE_VERSION) + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + response = unyank(distro_a, TWINE_NAME, TWINE_VERSION) + assert response.status_code == 202 + monitor_task(response.json()["task"]) + + # A should be not-yanked, B should remain yanked + summary_a = python_content_summary(repository=repo_a, version=3) + assert "python.python_yank" not in summary_a.present + summary_b = python_content_summary(repository=repo_b, version=2) + assert summary_b.present["python.python_yank"]["count"] == 1 + + +@pytest.mark.parallel +def test_yank_nonexistent_package(python_repo_factory, python_distribution_factory): + """ + Yanking a package not in the repo should return 404. + """ + repo = python_repo_factory() + distro = python_distribution_factory(repository=repo) + + response = yank(distro, "nonexistent-package", "99.99.99") + assert response.status_code == 404 + + +@pytest.mark.parallel +def test_yank_no_repository(python_distribution_factory): + """ + Yanking on a distribution with no repository should return 400. + """ + distro = python_distribution_factory() + + response = yank(distro, TWINE_NAME, TWINE_VERSION) + assert response.status_code == 400