Skip to content

Commit ae4e74b

Browse files
committed
Add support for yank/unyank
Assisted-By: claude-opus-4.6
1 parent 54c3a40 commit ae4e74b

11 files changed

Lines changed: 609 additions & 58 deletions

File tree

pulp_rust/app/migrations/0001_initial.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ class Migration(migrations.Migration):
1919
fields=[
2020
('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')),
2121
('name', models.CharField(db_index=True, max_length=255)),
22-
('vers', models.CharField(max_length=64)),
23-
('cksum', models.CharField(max_length=64)),
24-
('yanked', models.BooleanField(default=False)),
22+
('vers', models.CharField(db_index=True, max_length=64)),
23+
('cksum', models.CharField(db_index=True, max_length=64)),
2524
('features', models.JSONField(blank=True, default=dict)),
2625
('features2', models.JSONField(blank=True, default=dict, null=True)),
2726
('links', models.CharField(blank=True, max_length=255, null=True)),
@@ -87,4 +86,18 @@ class Migration(migrations.Migration):
8786
'indexes': [models.Index(fields=['content', 'kind'], name='rust_rustde_content_a46e30_idx'), models.Index(fields=['name'], name='rust_rustde_name_6a2db4_idx')],
8887
},
8988
),
89+
migrations.CreateModel(
90+
name='RustPackageYank',
91+
fields=[
92+
('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')),
93+
('name', models.CharField(db_index=True, max_length=255)),
94+
('vers', models.CharField(db_index=True, max_length=64)),
95+
('_pulp_domain', models.ForeignKey(default=pulpcore.app.util.get_domain_pk, on_delete=django.db.models.deletion.PROTECT, to='core.domain')),
96+
],
97+
options={
98+
'default_related_name': '%(app_label)s_%(model_name)s',
99+
'unique_together': {('name', 'vers', '_pulp_domain')},
100+
},
101+
bases=('core.content',),
102+
),
90103
]

pulp_rust/app/migrations/0002_alter_rustcontent_cksum_alter_rustcontent_vers.py

Lines changed: 0 additions & 23 deletions
This file was deleted.

pulp_rust/app/models.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ class RustContent(Content):
5555
name: The package name (crate name)
5656
vers: The semantic version string (SemVer 2.0.0)
5757
cksum: SHA256 checksum of the .crate file (tarball)
58-
yanked: Whether this version has been yanked (removed from normal use)
5958
features: JSON object mapping feature names to their dependencies
6059
features2: JSON object with extended feature syntax support
6160
links: Value from Cargo.toml manifest 'links' field (for native library linking)
@@ -75,11 +74,6 @@ class RustContent(Content):
7574
# SHA256 checksum (hex-encoded) of the .crate tarball file for verification
7675
cksum = models.CharField(max_length=64, blank=False, null=False, db_index=True)
7776

78-
# Indicates if this version has been yanked (deprecated/removed from use)
79-
# Yanked versions can still be used by existing Cargo.lock files but won't be selected
80-
# for new builds
81-
yanked = models.BooleanField(default=False)
82-
8377
# Feature flags and compatibility
8478
# Maps feature names to lists of features/dependencies they enable
8579
# Example: {"default": ["std"], "std": [], "serde": ["dep:serde"]}
@@ -264,14 +258,36 @@ class Meta:
264258
default_related_name = "%(app_label)s_%(model_name)s"
265259

266260

261+
class RustPackageYank(Content):
262+
"""
263+
A marker content type indicating a crate version is yanked in a repository.
264+
265+
This is a per-repository marker: its presence in a repository version means
266+
the (name, vers) pair is yanked in that repository. Its absence means it is
267+
not yanked. This allows yanked status to vary across repositories without
268+
mutating the global RustContent object.
269+
"""
270+
271+
TYPE = "rust_yank"
272+
repo_key_fields = ("name", "vers")
273+
274+
name = models.CharField(max_length=255, db_index=True)
275+
vers = models.CharField(max_length=64, db_index=True)
276+
_pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
277+
278+
class Meta:
279+
default_related_name = "%(app_label)s_%(model_name)s"
280+
unique_together = (("name", "vers", "_pulp_domain"),)
281+
282+
267283
class RustRepository(Repository):
268284
"""
269285
A Repository for RustContent.
270286
"""
271287

272288
TYPE = "rust"
273289

274-
CONTENT_TYPES = [RustContent]
290+
CONTENT_TYPES = [RustContent, RustPackageYank]
275291
REMOTE_TYPES = [RustRemote]
276292
PULL_THROUGH_SUPPORTED = True
277293

pulp_rust/app/serializers.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,6 @@ class RustContentSerializer(core_serializers.SingleArtifactContentSerializer):
135135
help_text=_("Extended feature syntax support (newer registry format)"),
136136
)
137137

138-
yanked = serializers.BooleanField(
139-
default=False,
140-
required=False,
141-
help_text=_("Whether this version has been yanked (removed from normal use)"),
142-
)
143-
144138
links = serializers.CharField(
145139
allow_null=True,
146140
required=False,
@@ -189,7 +183,6 @@ class Meta:
189183
"cksum",
190184
"features",
191185
"features2",
192-
"yanked",
193186
"links",
194187
"v",
195188
"rust_version",
@@ -265,6 +258,19 @@ class Meta:
265258
model = models.RustDistribution
266259

267260

261+
class YankSerializer(serializers.Serializer):
262+
"""Serializer for yank/unyank operations on a repository."""
263+
264+
name = serializers.CharField(
265+
required=True,
266+
help_text=_("The crate name to yank or unyank."),
267+
)
268+
vers = serializers.CharField(
269+
required=True,
270+
help_text=_("The crate version to yank or unyank."),
271+
)
272+
273+
268274
class RepositoryAddCachedContentSerializer(
269275
core_serializers.ValidateFieldsMixin, serializers.Serializer
270276
):

pulp_rust/app/tasks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from .synchronizing import synchronize # noqa
22
from .streaming import add_cached_content_to_repository # noqa
3+
from .yanking import ayank_package, aunyank_package # noqa

pulp_rust/app/tasks/streaming.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
import datetime
22

3-
from asgiref.sync import sync_to_async
4-
53
from pulpcore.plugin.models import Content, ContentArtifact, RemoteArtifact
6-
from pulpcore.plugin.tasking import add_and_remove
74

85
from pulp_rust.app.models import RustRemote, RustRepository
96

107

11-
async def aadd_and_remove(*args, **kwargs):
12-
return await sync_to_async(add_and_remove)(*args, **kwargs)
13-
14-
15-
# TODO: look at the version in models/repository.py
8+
# Note: pulpcore's Repository.pull_through_add_content() is a different pattern — it adds a
9+
# single content unit immediately during streaming. This task instead does a batch "catch up",
10+
# finding all content cached since the last repo version and adding them in one new version.
1611
def add_cached_content_to_repository(repository_pk=None, remote_pk=None):
1712
"""
1813
Create a new repository version by adding content that was cached by pulpcore-content when

pulp_rust/app/tasks/yanking.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from pulpcore.plugin.tasking import aadd_and_remove
2+
3+
from pulp_rust.app.models import RustContent, RustPackageYank, RustRepository
4+
5+
6+
async def ayank_package(repository_pk, name, vers):
7+
"""
8+
Yank a package version in a repository by adding a RustPackageYank marker.
9+
10+
Creates a new repository version with the yank marker added.
11+
"""
12+
repository = await RustRepository.objects.aget(pk=repository_pk)
13+
latest = await repository.alatest_version()
14+
15+
# Verify the package version exists in this repository
16+
exists = await RustContent.objects.filter(pk__in=latest.content, name=name, vers=vers).aexists()
17+
if not exists:
18+
raise ValueError(f"Package {name}=={vers} not found in repository")
19+
20+
# Check if already yanked
21+
already_yanked = await RustPackageYank.objects.filter(
22+
pk__in=latest.content, name=name, vers=vers
23+
).aexists()
24+
if already_yanked:
25+
return # Already yanked, no-op
26+
27+
yank_marker, _ = await RustPackageYank.objects.aget_or_create(
28+
name=name, vers=vers, _pulp_domain_id=repository.pulp_domain_id
29+
)
30+
31+
await aadd_and_remove(
32+
repository_pk=repository.pk,
33+
add_content_units=[yank_marker.pk],
34+
remove_content_units=[],
35+
)
36+
37+
38+
async def aunyank_package(repository_pk, name, vers):
39+
"""
40+
Unyank a package version by removing its RustPackageYank marker.
41+
42+
Creates a new repository version with the yank marker removed.
43+
"""
44+
repository = await RustRepository.objects.aget(pk=repository_pk)
45+
latest = await repository.alatest_version()
46+
47+
yank_marker = await RustPackageYank.objects.filter(
48+
pk__in=latest.content, name=name, vers=vers
49+
).afirst()
50+
51+
if yank_marker is None:
52+
return # Not yanked, no-op
53+
54+
await aadd_and_remove(
55+
repository_pk=repository.pk,
56+
add_content_units=[],
57+
remove_content_units=[yank_marker.pk],
58+
)

pulp_rust/app/views.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@
2121

2222
from pulpcore.plugin.util import get_domain
2323

24-
from pulp_rust.app.models import RustDistribution, RustContent, _strip_sparse_prefix
24+
from pulpcore.plugin.tasking import dispatch
25+
26+
from pulp_rust.app.models import (
27+
RustDistribution,
28+
RustContent,
29+
RustPackageYank,
30+
_strip_sparse_prefix,
31+
)
32+
from pulp_rust.app.tasks import ayank_package, aunyank_package
2533
from pulp_rust.app.serializers import (
2634
IndexRootSerializer,
2735
RustContentSerializer,
@@ -154,7 +162,12 @@ def retrieve(self, request, path, **kwargs):
154162
if content is not None:
155163
crate_versions = content.filter(name=crate_name).order_by("vers")
156164
if crate_versions.exists():
157-
return self._build_index_response(crate_versions)
165+
yanked_versions = set(
166+
RustPackageYank.objects.filter(
167+
pk__in=repo_ver.content, name=crate_name
168+
).values_list("vers", flat=True)
169+
)
170+
return self._build_index_response(crate_versions, yanked_versions)
158171

159172
# Fall back to proxying from the upstream remote
160173
if self.distribution.remote:
@@ -172,7 +185,7 @@ def retrieve(self, request, path, **kwargs):
172185
return HttpResponseNotFound(f"Crate '{crate_name}' not found")
173186

174187
@staticmethod
175-
def _build_index_response(crate_versions):
188+
def _build_index_response(crate_versions, yanked_versions=frozenset()):
176189
"""Build a newline-delimited JSON response from local crate versions."""
177190
lines = []
178191
for crate_version in crate_versions:
@@ -200,7 +213,7 @@ def _build_index_response(crate_versions):
200213
"deps": deps,
201214
"cksum": crate_version.cksum,
202215
"features": crate_version.features,
203-
"yanked": crate_version.yanked,
216+
"yanked": crate_version.vers in yanked_versions,
204217
"links": crate_version.links,
205218
"v": crate_version.v,
206219
}
@@ -293,7 +306,23 @@ def delete(self, request, name, version, rest, **kwargs):
293306
"""
294307
if rest != "yank":
295308
raise Http404(f"Unknown action: {rest}")
296-
raise NotImplementedError("Yank endpoint is not yet implemented")
309+
310+
distro = self.get_distribution()
311+
if not distro.repository:
312+
raise Http404("No repository associated with this distribution")
313+
314+
task = dispatch(
315+
ayank_package,
316+
exclusive_resources=[distro.repository],
317+
immediate=True,
318+
kwargs={
319+
"repository_pk": str(distro.repository.pk),
320+
"name": name,
321+
"vers": version,
322+
},
323+
)
324+
has_task_completed(task)
325+
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")
297326

298327
def put(self, request, name, version, rest, **kwargs):
299328
"""
@@ -304,7 +333,23 @@ def put(self, request, name, version, rest, **kwargs):
304333
"""
305334
if rest != "unyank":
306335
raise Http404(f"Unknown action: {rest}")
307-
raise NotImplementedError("Unyank endpoint is not yet implemented")
336+
337+
distro = self.get_distribution()
338+
if not distro.repository:
339+
raise Http404("No repository associated with this distribution")
340+
341+
task = dispatch(
342+
aunyank_package,
343+
exclusive_resources=[distro.repository],
344+
immediate=True,
345+
kwargs={
346+
"repository_pk": str(distro.repository.pk),
347+
"name": name,
348+
"vers": version,
349+
},
350+
)
351+
has_task_completed(task)
352+
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")
308353

309354

310355
def has_task_completed(task):

pulp_rust/app/viewsets.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.db import transaction
2-
from django_filters import CharFilter, BooleanFilter
2+
from django.db.models import Exists, OuterRef
3+
from django_filters import BooleanFilter, CharFilter
34
from drf_spectacular.utils import extend_schema
45
from rest_framework import status
56
from rest_framework.decorators import action
@@ -34,12 +35,24 @@ class RustContentFilter(core.ContentFilter):
3435
# Filter by checksum
3536
cksum = CharFilter(field_name="cksum")
3637

37-
# Filter by yanked status
38-
yanked = BooleanFilter(field_name="yanked")
38+
# Filter by whether a RustPackageYank marker exists for matching name+vers
39+
yanked = BooleanFilter(method="filter_yanked")
3940

4041
# Filter by minimum Rust version requirement
4142
rust_version = CharFilter(field_name="rust_version")
4243

44+
def filter_yanked(self, queryset, name, value):
45+
yank_exists = Exists(
46+
models.RustPackageYank.objects.filter(
47+
name=OuterRef("name"),
48+
vers=OuterRef("vers"),
49+
)
50+
)
51+
if value:
52+
return queryset.filter(yank_exists)
53+
else:
54+
return queryset.exclude(yank_exists)
55+
4356
class Meta:
4457
model = models.RustContent
4558
fields = [

0 commit comments

Comments
 (0)