From 1412372542742e649a486257a27a5df77ed2deed Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Wed, 1 Apr 2026 09:17:03 -0400 Subject: [PATCH] Add support for yank/unyank Assisted-By: claude-opus-4.6 --- README.md | 8 + pulp_rust/app/migrations/0001_initial.py | 19 +- ...ustcontent_cksum_alter_rustcontent_vers.py | 23 -- pulp_rust/app/models.py | 30 +- pulp_rust/app/serializers.py | 20 +- pulp_rust/app/tasks/__init__.py | 1 + pulp_rust/app/tasks/streaming.py | 11 +- pulp_rust/app/tasks/yanking.py | 58 +++ pulp_rust/app/views.py | 69 +++- pulp_rust/app/viewsets.py | 8 +- pulp_rust/tests/functional/api/test_yank.py | 329 ++++++++++++++++++ template_config.yml | 4 +- 12 files changed, 518 insertions(+), 62 deletions(-) delete mode 100644 pulp_rust/app/migrations/0002_alter_rustcontent_cksum_alter_rustcontent_vers.py create mode 100644 pulp_rust/app/tasks/yanking.py create mode 100644 pulp_rust/tests/functional/api/test_yank.py diff --git a/README.md b/README.md index fe021e6..60afe67 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ A Pulp plugin to support hosting your own Rust/Cargo package registry. - Host content either locally or on S3/Azure/GCP - De-duplication of all saved content +## Not Yet Supported + +The following features are not yet implemented but are planned for future releases: + +- **Publishing** (`cargo publish`) -- crates cannot yet be uploaded via the Cargo CLI +- **Authentication & authorization** -- the registry is currently open to all clients +- **Syncing** -- mirroring an entire upstream registry is not yet supported; use pull-through caching instead + For more information, please see the [documentation](docs/index.md) or the [Pulp project page](https://pulpproject.org/). diff --git a/pulp_rust/app/migrations/0001_initial.py b/pulp_rust/app/migrations/0001_initial.py index b4ef764..162231e 100644 --- a/pulp_rust/app/migrations/0001_initial.py +++ b/pulp_rust/app/migrations/0001_initial.py @@ -19,9 +19,8 @@ class Migration(migrations.Migration): 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', models.CharField(db_index=True, max_length=255)), - ('vers', models.CharField(max_length=64)), - ('cksum', models.CharField(max_length=64)), - ('yanked', models.BooleanField(default=False)), + ('vers', models.CharField(db_index=True, max_length=64)), + ('cksum', models.CharField(db_index=True, max_length=64)), ('features', models.JSONField(blank=True, default=dict)), ('features2', models.JSONField(blank=True, default=dict, null=True)), ('links', models.CharField(blank=True, max_length=255, null=True)), @@ -87,4 +86,18 @@ class Migration(migrations.Migration): 'indexes': [models.Index(fields=['content', 'kind'], name='rust_rustde_content_a46e30_idx'), models.Index(fields=['name'], name='rust_rustde_name_6a2db4_idx')], }, ), + migrations.CreateModel( + name='RustPackageYank', + 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', models.CharField(db_index=True, max_length=255)), + ('vers', models.CharField(db_index=True, max_length=64)), + ('_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', 'vers', '_pulp_domain')}, + }, + bases=('core.content',), + ), ] diff --git a/pulp_rust/app/migrations/0002_alter_rustcontent_cksum_alter_rustcontent_vers.py b/pulp_rust/app/migrations/0002_alter_rustcontent_cksum_alter_rustcontent_vers.py deleted file mode 100644 index 260b4ea..0000000 --- a/pulp_rust/app/migrations/0002_alter_rustcontent_cksum_alter_rustcontent_vers.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.5 on 2026-01-28 04:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('rust', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='rustcontent', - name='cksum', - field=models.CharField(db_index=True, max_length=64), - ), - migrations.AlterField( - model_name='rustcontent', - name='vers', - field=models.CharField(db_index=True, max_length=64), - ), - ] diff --git a/pulp_rust/app/models.py b/pulp_rust/app/models.py index c456c36..a4edcfc 100755 --- a/pulp_rust/app/models.py +++ b/pulp_rust/app/models.py @@ -55,7 +55,6 @@ class RustContent(Content): name: The package name (crate name) vers: The semantic version string (SemVer 2.0.0) cksum: SHA256 checksum of the .crate file (tarball) - yanked: Whether this version has been yanked (removed from normal use) features: JSON object mapping feature names to their dependencies features2: JSON object with extended feature syntax support links: Value from Cargo.toml manifest 'links' field (for native library linking) @@ -75,11 +74,6 @@ class RustContent(Content): # SHA256 checksum (hex-encoded) of the .crate tarball file for verification cksum = models.CharField(max_length=64, blank=False, null=False, db_index=True) - # Indicates if this version has been yanked (deprecated/removed from use) - # Yanked versions can still be used by existing Cargo.lock files but won't be selected - # for new builds - yanked = models.BooleanField(default=False) - # Feature flags and compatibility # Maps feature names to lists of features/dependencies they enable # Example: {"default": ["std"], "std": [], "serde": ["dep:serde"]} @@ -264,6 +258,28 @@ class Meta: default_related_name = "%(app_label)s_%(model_name)s" +class RustPackageYank(Content): + """ + A marker content type indicating a crate version is yanked in a repository. + + This is a per-repository marker: its presence in a repository version means + the (name, vers) pair is yanked in that repository. Its absence means it is + not yanked. This allows yanked status to vary across repositories without + mutating the global RustContent object. + """ + + TYPE = "rust_yank" + repo_key_fields = ("name", "vers") + + name = models.CharField(max_length=255, db_index=True) + vers = models.CharField(max_length=64, db_index=True) + _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", "vers", "_pulp_domain"),) + + class RustRepository(Repository): """ A Repository for RustContent. @@ -271,7 +287,7 @@ class RustRepository(Repository): TYPE = "rust" - CONTENT_TYPES = [RustContent] + CONTENT_TYPES = [RustContent, RustPackageYank] REMOTE_TYPES = [RustRemote] PULL_THROUGH_SUPPORTED = True diff --git a/pulp_rust/app/serializers.py b/pulp_rust/app/serializers.py index ad424f4..bf5757c 100755 --- a/pulp_rust/app/serializers.py +++ b/pulp_rust/app/serializers.py @@ -135,12 +135,6 @@ class RustContentSerializer(core_serializers.SingleArtifactContentSerializer): help_text=_("Extended feature syntax support (newer registry format)"), ) - yanked = serializers.BooleanField( - default=False, - required=False, - help_text=_("Whether this version has been yanked (removed from normal use)"), - ) - links = serializers.CharField( allow_null=True, required=False, @@ -189,7 +183,6 @@ class Meta: "cksum", "features", "features2", - "yanked", "links", "v", "rust_version", @@ -265,6 +258,19 @@ class Meta: model = models.RustDistribution +class YankSerializer(serializers.Serializer): + """Serializer for yank/unyank operations on a repository.""" + + name = serializers.CharField( + required=True, + help_text=_("The crate name to yank or unyank."), + ) + vers = serializers.CharField( + required=True, + help_text=_("The crate version to yank or unyank."), + ) + + class RepositoryAddCachedContentSerializer( core_serializers.ValidateFieldsMixin, serializers.Serializer ): diff --git a/pulp_rust/app/tasks/__init__.py b/pulp_rust/app/tasks/__init__.py index 9b19390..037ed67 100755 --- a/pulp_rust/app/tasks/__init__.py +++ b/pulp_rust/app/tasks/__init__.py @@ -1,2 +1,3 @@ from .synchronizing import synchronize # noqa from .streaming import add_cached_content_to_repository # noqa +from .yanking import ayank_package, aunyank_package # noqa diff --git a/pulp_rust/app/tasks/streaming.py b/pulp_rust/app/tasks/streaming.py index 7680f24..8aaebb1 100644 --- a/pulp_rust/app/tasks/streaming.py +++ b/pulp_rust/app/tasks/streaming.py @@ -1,18 +1,13 @@ import datetime -from asgiref.sync import sync_to_async - from pulpcore.plugin.models import Content, ContentArtifact, RemoteArtifact -from pulpcore.plugin.tasking import add_and_remove from pulp_rust.app.models import RustRemote, RustRepository -async def aadd_and_remove(*args, **kwargs): - return await sync_to_async(add_and_remove)(*args, **kwargs) - - -# TODO: look at the version in models/repository.py +# Note: pulpcore's Repository.pull_through_add_content() is a different pattern — it adds a +# single content unit immediately during streaming. This task instead does a batch "catch up", +# finding all content cached since the last repo version and adding them in one new version. def add_cached_content_to_repository(repository_pk=None, remote_pk=None): """ Create a new repository version by adding content that was cached by pulpcore-content when diff --git a/pulp_rust/app/tasks/yanking.py b/pulp_rust/app/tasks/yanking.py new file mode 100644 index 0000000..9237a53 --- /dev/null +++ b/pulp_rust/app/tasks/yanking.py @@ -0,0 +1,58 @@ +from pulpcore.plugin.tasking import aadd_and_remove + +from pulp_rust.app.models import RustContent, RustPackageYank, RustRepository + + +async def ayank_package(repository_pk, name, vers): + """ + Yank a package version in a repository by adding a RustPackageYank marker. + + Creates a new repository version with the yank marker added. + """ + repository = await RustRepository.objects.aget(pk=repository_pk) + latest = await repository.alatest_version() + + # Verify the package version exists in this repository + exists = await RustContent.objects.filter(pk__in=latest.content, name=name, vers=vers).aexists() + if not exists: + raise ValueError(f"Package {name}=={vers} not found in repository") + + # Check if already yanked + already_yanked = await RustPackageYank.objects.filter( + pk__in=latest.content, name=name, vers=vers + ).aexists() + if already_yanked: + return # Already yanked, no-op + + yank_marker, _ = await RustPackageYank.objects.aget_or_create( + name=name, vers=vers, _pulp_domain_id=repository.pulp_domain_id + ) + + await aadd_and_remove( + repository_pk=repository.pk, + add_content_units=[yank_marker.pk], + remove_content_units=[], + ) + + +async def aunyank_package(repository_pk, name, vers): + """ + Unyank a package version by removing its RustPackageYank marker. + + Creates a new repository version with the yank marker removed. + """ + repository = await RustRepository.objects.aget(pk=repository_pk) + latest = await repository.alatest_version() + + yank_marker = await RustPackageYank.objects.filter( + pk__in=latest.content, name=name, vers=vers + ).afirst() + + if yank_marker is None: + return # Not yanked, no-op + + await aadd_and_remove( + repository_pk=repository.pk, + add_content_units=[], + remove_content_units=[yank_marker.pk], + ) diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index 8827d3c..9e02bde 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -21,7 +21,15 @@ from pulpcore.plugin.util import get_domain -from pulp_rust.app.models import RustDistribution, RustContent, _strip_sparse_prefix +from pulpcore.plugin.tasking import dispatch + +from pulp_rust.app.models import ( + RustDistribution, + RustContent, + RustPackageYank, + _strip_sparse_prefix, +) +from pulp_rust.app.tasks import ayank_package, aunyank_package from pulp_rust.app.serializers import ( IndexRootSerializer, RustContentSerializer, @@ -154,7 +162,12 @@ def retrieve(self, request, path, **kwargs): if content is not None: crate_versions = content.filter(name=crate_name).order_by("vers") if crate_versions.exists(): - return self._build_index_response(crate_versions) + yanked_versions = set( + RustPackageYank.objects.filter( + pk__in=repo_ver.content, name=crate_name + ).values_list("vers", flat=True) + ) + return self._build_index_response(crate_versions, yanked_versions) # Fall back to proxying from the upstream remote if self.distribution.remote: @@ -172,7 +185,7 @@ def retrieve(self, request, path, **kwargs): return HttpResponseNotFound(f"Crate '{crate_name}' not found") @staticmethod - def _build_index_response(crate_versions): + def _build_index_response(crate_versions, yanked_versions=frozenset()): """Build a newline-delimited JSON response from local crate versions.""" lines = [] for crate_version in crate_versions: @@ -200,7 +213,7 @@ def _build_index_response(crate_versions): "deps": deps, "cksum": crate_version.cksum, "features": crate_version.features, - "yanked": crate_version.yanked, + "yanked": crate_version.vers in yanked_versions, "links": crate_version.links, "v": crate_version.v, } @@ -293,7 +306,35 @@ def delete(self, request, name, version, rest, **kwargs): """ if rest != "yank": raise Http404(f"Unknown action: {rest}") - raise NotImplementedError("Yank endpoint is not yet implemented") + + distro = self.get_distribution() + if not distro.repository: + raise Http404("No repository associated with this distribution") + + repo_version = distro.repository.latest_version() + if not RustContent.objects.filter( + pk__in=repo_version.content, name=name, vers=version + ).exists(): + return HttpResponse( + json.dumps( + {"errors": [{"detail": f"crate `{name}` does not have a version `{version}`"}]} + ), + content_type="application/json", + status=404, + ) + + task = dispatch( + ayank_package, + exclusive_resources=[distro.repository], + immediate=True, + kwargs={ + "repository_pk": str(distro.repository.pk), + "name": name, + "vers": version, + }, + ) + has_task_completed(task) + return HttpResponse(json.dumps({"ok": True}), content_type="application/json") def put(self, request, name, version, rest, **kwargs): """ @@ -304,7 +345,23 @@ def put(self, request, name, version, rest, **kwargs): """ if rest != "unyank": raise Http404(f"Unknown action: {rest}") - raise NotImplementedError("Unyank endpoint is not yet implemented") + + distro = self.get_distribution() + if not distro.repository: + raise Http404("No repository associated with this distribution") + + task = dispatch( + aunyank_package, + exclusive_resources=[distro.repository], + immediate=True, + kwargs={ + "repository_pk": str(distro.repository.pk), + "name": name, + "vers": version, + }, + ) + has_task_completed(task) + return HttpResponse(json.dumps({"ok": True}), content_type="application/json") def has_task_completed(task): diff --git a/pulp_rust/app/viewsets.py b/pulp_rust/app/viewsets.py index 1f1a6fd..a091e8d 100755 --- a/pulp_rust/app/viewsets.py +++ b/pulp_rust/app/viewsets.py @@ -1,5 +1,5 @@ from django.db import transaction -from django_filters import CharFilter, BooleanFilter +from django_filters import CharFilter from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.decorators import action @@ -22,7 +22,7 @@ class RustContentFilter(core.ContentFilter): """ FilterSet for RustContent (Cargo packages). - Provides filtering capabilities for package name, version, and yanked status. + Provides filtering capabilities for package name, version, and checksum. """ # Filter by exact package name @@ -34,9 +34,6 @@ class RustContentFilter(core.ContentFilter): # Filter by checksum cksum = CharFilter(field_name="cksum") - # Filter by yanked status - yanked = BooleanFilter(field_name="yanked") - # Filter by minimum Rust version requirement rust_version = CharFilter(field_name="rust_version") @@ -46,7 +43,6 @@ class Meta: "name", "vers", "cksum", - "yanked", "rust_version", ] diff --git a/pulp_rust/tests/functional/api/test_yank.py b/pulp_rust/tests/functional/api/test_yank.py new file mode 100644 index 0000000..c4dcbd8 --- /dev/null +++ b/pulp_rust/tests/functional/api/test_yank.py @@ -0,0 +1,329 @@ +"""Functional tests for Cargo yank/unyank support.""" + +import json +import hashlib +from urllib.parse import urljoin + +import aiohttp +import asyncio +import pytest +from aiohttp.client_exceptions import ClientResponseError + +from pulp_rust.tests.functional.utils import ( + CRATES_IO_URL, + download_file, + get_index_entry, +) + + +def cargo_api_request(url, method="DELETE"): + """Make a DELETE or PUT request to the Cargo API.""" + + async def _request(): + async with aiohttp.ClientSession(raise_for_status=True) as session: + async with session.request(method, url, verify_ssl=False) as response: + return json.loads(await response.read()) + + return asyncio.run(_request()) + + +def get_all_index_entries(cargo_url, sparse_path): + """Fetch the sparse index and return all entries.""" + index_url = urljoin(cargo_url, sparse_path) + downloaded = download_file(index_url) + body = downloaded.body.decode("utf-8") + return [json.loads(line) for line in body.strip().split("\n")] + + +@pytest.fixture +def populated_repo( + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + rust_repo_api_client, + rust_distro_api_client, + monitor_task, + cargo_registry_url, +): + """Create a repo with itoa 1.0.0 and 1.0.1 cached locally.""" + remote = rust_remote_factory(url=CRATES_IO_URL) + repository = rust_repo_factory(remote=remote.pulp_href) + distribution = rust_distribution_factory( + remote=remote.pulp_href, repository=repository.pulp_href + ) + base_url = cargo_registry_url(distribution.base_path) + + # Pull through two versions to cache them + for version in ("1.0.0", "1.0.1"): + unit_path = f"api/v1/crates/itoa/{version}/download" + download_file(urljoin(base_url, unit_path)) + + # Detach remote from distribution so index is served from local content + monitor_task( + rust_distro_api_client.partial_update(distribution.pulp_href, {"remote": None}).task + ) + + return { + "repository": rust_repo_api_client.read(repository.pulp_href), + "distribution": rust_distro_api_client.read(distribution.pulp_href), + "base_url": base_url, + } + + +# --- Cargo API happy path --- + + +def test_yank_happy_path(populated_repo): + """Yanking a crate version via the Cargo API should mark it as yanked in the index.""" + base_url = populated_repo["base_url"] + + # Verify initially not yanked + entry = get_index_entry(base_url, "it/oa/itoa", "1.0.0") + assert entry is not None + assert entry["yanked"] is False + + # Yank via Cargo API + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + result = cargo_api_request(yank_url, method="DELETE") + assert result["ok"] is True + + # Verify now yanked + entry = get_index_entry(base_url, "it/oa/itoa", "1.0.0") + assert entry["yanked"] is True + + +def test_unyank_happy_path(populated_repo): + """Unyanking a crate version should restore it in the index.""" + base_url = populated_repo["base_url"] + + # Yank first + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + cargo_api_request(yank_url, method="DELETE") + + entry = get_index_entry(base_url, "it/oa/itoa", "1.0.0") + assert entry["yanked"] is True + + # Unyank + unyank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/unyank") + result = cargo_api_request(unyank_url, method="PUT") + assert result["ok"] is True + + # Verify no longer yanked + entry = get_index_entry(base_url, "it/oa/itoa", "1.0.0") + assert entry["yanked"] is False + + +# --- Error cases --- + + +def test_yank_nonexistent_package( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Yanking a crate that doesn't exist in the repo should fail.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base_url = cargo_registry_url(distribution.base_path) + + yank_url = urljoin(base_url, "api/v1/crates/nonexistent/0.0.0/yank") + with pytest.raises(ClientResponseError) as exc: + cargo_api_request(yank_url, method="DELETE") + assert exc.value.status == 404 + + +def test_yank_no_repository( + rust_distribution_factory, + cargo_registry_url, +): + """Yanking on a distribution with no repository should 404.""" + distribution = rust_distribution_factory() + base_url = cargo_registry_url(distribution.base_path) + + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + with pytest.raises(ClientResponseError) as exc: + cargo_api_request(yank_url, method="DELETE") + assert exc.value.status == 404 + + +# --- Idempotency --- + + +def test_yank_idempotent(populated_repo, rust_repo_api_client): + """Yanking the same version twice should be a no-op the second time.""" + base_url = populated_repo["base_url"] + repo_href = populated_repo["repository"].pulp_href + + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + cargo_api_request(yank_url, method="DELETE") + + repo_after_first = rust_repo_api_client.read(repo_href) + first_version = repo_after_first.latest_version_href + + # Yank again — should be no-op + result = cargo_api_request(yank_url, method="DELETE") + assert result["ok"] is True + + repo_after_second = rust_repo_api_client.read(repo_href) + assert repo_after_second.latest_version_href == first_version + + +def test_unyank_idempotent(populated_repo, rust_repo_api_client): + """Unyanking something not yanked should be a no-op.""" + base_url = populated_repo["base_url"] + repo_href = populated_repo["repository"].pulp_href + + repo_before = rust_repo_api_client.read(repo_href) + before_version = repo_before.latest_version_href + + unyank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/unyank") + result = cargo_api_request(unyank_url, method="PUT") + assert result["ok"] is True + + repo_after = rust_repo_api_client.read(repo_href) + assert repo_after.latest_version_href == before_version + + +# --- Multi-repository isolation --- + + +def test_yank_isolation_across_repositories( + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + rust_distro_api_client, + monitor_task, + cargo_registry_url, +): + """Yank state is per-repository: yanking/unyanking in one repo must not affect another.""" + remote = rust_remote_factory(url=CRATES_IO_URL) + + # Create two repos, both caching the same crate + repos = {} + for label in ("a", "b"): + repository = rust_repo_factory(remote=remote.pulp_href) + distribution = rust_distribution_factory( + remote=remote.pulp_href, repository=repository.pulp_href + ) + base_url = cargo_registry_url(distribution.base_path) + + # Pull through to cache + download_file(urljoin(base_url, "api/v1/crates/itoa/1.0.0/download")) + + # Detach remote from distribution so index is served from local content + monitor_task( + rust_distro_api_client.partial_update(distribution.pulp_href, {"remote": None}).task + ) + + repos[label] = {"base_url": base_url} + + # Yank in repo A only + yank_url = urljoin(repos["a"]["base_url"], "api/v1/crates/itoa/1.0.0/yank") + cargo_api_request(yank_url, method="DELETE") + + # Repo A should show yanked, repo B should not + entry_a = get_index_entry(repos["a"]["base_url"], "it/oa/itoa", "1.0.0") + assert entry_a["yanked"] is True + entry_b = get_index_entry(repos["b"]["base_url"], "it/oa/itoa", "1.0.0") + assert entry_b["yanked"] is False + + # Yank in repo B too, then unyank only in A + yank_url_b = urljoin(repos["b"]["base_url"], "api/v1/crates/itoa/1.0.0/yank") + cargo_api_request(yank_url_b, method="DELETE") + + unyank_url = urljoin(repos["a"]["base_url"], "api/v1/crates/itoa/1.0.0/unyank") + cargo_api_request(unyank_url, method="PUT") + + # A should be not-yanked, B should remain yanked + entry_a = get_index_entry(repos["a"]["base_url"], "it/oa/itoa", "1.0.0") + assert entry_a["yanked"] is False + entry_b = get_index_entry(repos["b"]["base_url"], "it/oa/itoa", "1.0.0") + assert entry_b["yanked"] is True + + +# --- Repository versioning --- + + +def test_yank_creates_new_repo_version(populated_repo, rust_repo_api_client): + """Yanking should create a new repository version.""" + base_url = populated_repo["base_url"] + repo_href = populated_repo["repository"].pulp_href + + repo_before = rust_repo_api_client.read(repo_href) + version_before = repo_before.latest_version_href + + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + cargo_api_request(yank_url, method="DELETE") + + repo_after = rust_repo_api_client.read(repo_href) + assert repo_after.latest_version_href != version_before + + +# --- Partial yank (multiple versions) --- + + +def test_partial_yank(populated_repo): + """Yanking one version should not affect other versions of the same crate.""" + base_url = populated_repo["base_url"] + + # Yank only 1.0.0 + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + cargo_api_request(yank_url, method="DELETE") + + # 1.0.0 should be yanked + entry_100 = get_index_entry(base_url, "it/oa/itoa", "1.0.0") + assert entry_100["yanked"] is True + + # 1.0.1 should not be yanked + entry_101 = get_index_entry(base_url, "it/oa/itoa", "1.0.1") + assert entry_101["yanked"] is False + + +# --- Download after yank --- + + +def test_download_still_works_after_yank(populated_repo): + """Per Cargo spec, yanked crates must remain downloadable.""" + base_url = populated_repo["base_url"] + + # Download before yank to get reference checksum + download_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/download") + before = download_file(download_url) + checksum_before = hashlib.sha256(before.body).hexdigest() + + # Yank + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + cargo_api_request(yank_url, method="DELETE") + + # Download should still work + after = download_file(download_url) + assert after.response_obj.status == 200 + assert hashlib.sha256(after.body).hexdigest() == checksum_before + + +# --- Proxy passthrough --- + + +def test_proxied_index_preserves_upstream_yanked_status( + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Proxied index responses should pass through upstream's yanked status verbatim.""" + remote = rust_remote_factory(url=CRATES_IO_URL) + repository = rust_repo_factory(remote=remote.pulp_href) + distribution = rust_distribution_factory( + remote=remote.pulp_href, repository=repository.pulp_href + ) + base_url = cargo_registry_url(distribution.base_path) + + # serde 0.7.6 is known to be yanked on crates.io + entries = get_all_index_entries(base_url, "se/rd/serde") + + yanked_entries = [e for e in entries if e["yanked"] is True] + not_yanked_entries = [e for e in entries if e["yanked"] is False] + + # There should be both yanked and non-yanked versions + assert len(yanked_entries) > 0, "Expected at least one yanked serde version from upstream" + assert len(not_yanked_entries) > 0, "Expected at least one non-yanked serde version" diff --git a/template_config.yml b/template_config.yml index c5a0327..19edd2a 100644 --- a/template_config.yml +++ b/template_config.yml @@ -97,13 +97,13 @@ stalebot_days_until_stale: 90 stalebot_limit_to_pulls: true supported_release_branches: [] sync_ci: true -test_azure: false +test_azure: true test_cli: false test_deprecations: true test_gcp: false test_lowerbounds: true test_performance: false -test_s3: false +test_s3: true use_issue_template: true ...