Skip to content

Commit 7acdd9b

Browse files
committed
Add support for yank/unyank
Assisted-By: claude-opus-4.6
1 parent d766c88 commit 7acdd9b

11 files changed

Lines changed: 554 additions & 56 deletions

File tree

.github/workflows/scripts/stage-changelog-for-default-branch.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from git import Repo
1313
from git.exc import GitCommandError
1414

15-
helper = textwrap.dedent("""\
15+
helper = textwrap.dedent(
16+
"""\
1617
Stage the changelog for a release on main branch.
1718
1819
Example:

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
@@ -52,7 +52,6 @@ class RustContent(Content):
5252
name: The package name (crate name)
5353
vers: The semantic version string (SemVer 2.0.0)
5454
cksum: SHA256 checksum of the .crate file (tarball)
55-
yanked: Whether this version has been yanked (removed from normal use)
5655
features: JSON object mapping feature names to their dependencies
5756
features2: JSON object with extended feature syntax support
5857
links: Value from Cargo.toml manifest 'links' field (for native library linking)
@@ -72,11 +71,6 @@ class RustContent(Content):
7271
# SHA256 checksum (hex-encoded) of the .crate tarball file for verification
7372
cksum = models.CharField(max_length=64, blank=False, null=False, db_index=True)
7473

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

246240

241+
class RustPackageYank(Content):
242+
"""
243+
A marker content type indicating a crate version is yanked in a repository.
244+
245+
This is a per-repository marker: its presence in a repository version means
246+
the (name, vers) pair is yanked in that repository. Its absence means it is
247+
not yanked. This allows yanked status to vary across repositories without
248+
mutating the global RustContent object.
249+
"""
250+
251+
TYPE = "rust_yank"
252+
repo_key_fields = ("name", "vers")
253+
254+
name = models.CharField(max_length=255, db_index=True)
255+
vers = models.CharField(max_length=64, db_index=True)
256+
_pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
257+
258+
class Meta:
259+
default_related_name = "%(app_label)s_%(model_name)s"
260+
unique_together = (("name", "vers", "_pulp_domain"),)
261+
262+
247263
class RustRepository(Repository):
248264
"""
249265
A Repository for RustContent.
250266
"""
251267

252268
TYPE = "rust"
253269

254-
CONTENT_TYPES = [RustContent]
270+
CONTENT_TYPES = [RustContent, RustPackageYank]
255271
REMOTE_TYPES = [RustRemote]
256272
PULL_THROUGH_SUPPORTED = True
257273

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: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from rest_framework.views import APIView
77
from rest_framework.viewsets import ViewSet
88
from rest_framework.exceptions import Throttled
9+
from rest_framework.response import Response
910
from rest_framework.renderers import BaseRenderer
1011
from django.core.exceptions import ObjectDoesNotExist
1112
from django.shortcuts import redirect, get_object_or_404
@@ -21,7 +22,15 @@
2122

2223
from pulpcore.plugin.util import get_domain
2324

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

159173
# Fall back to proxying from the upstream remote
160174
if self.distribution.remote:
@@ -172,7 +186,7 @@ def retrieve(self, request, path, **kwargs):
172186
return HttpResponseNotFound(f"Crate '{crate_name}' not found")
173187

174188
@staticmethod
175-
def _build_index_response(crate_versions):
189+
def _build_index_response(crate_versions, yanked_versions=frozenset()):
176190
"""Build a newline-delimited JSON response from local crate versions."""
177191
lines = []
178192
for crate_version in crate_versions:
@@ -198,7 +212,7 @@ def _build_index_response(crate_versions):
198212
"deps": deps,
199213
"cksum": crate_version.cksum,
200214
"features": crate_version.features,
201-
"yanked": crate_version.yanked,
215+
"yanked": crate_version.vers in yanked_versions,
202216
"links": crate_version.links,
203217
"v": crate_version.v,
204218
}
@@ -291,7 +305,23 @@ def delete(self, request, name, version, rest, **kwargs):
291305
"""
292306
if rest != "yank":
293307
raise Http404(f"Unknown action: {rest}")
294-
raise NotImplementedError("Yank endpoint is not yet implemented")
308+
309+
distro = self.get_distribution()
310+
if not distro.repository:
311+
raise Http404("No repository associated with this distribution")
312+
313+
task = dispatch(
314+
ayank_package,
315+
exclusive_resources=[distro.repository],
316+
immediate=True,
317+
kwargs={
318+
"repository_pk": str(distro.repository.pk),
319+
"name": name,
320+
"vers": version,
321+
},
322+
)
323+
has_task_completed(task)
324+
return Response({"ok": True})
295325

296326
def put(self, request, name, version, rest, **kwargs):
297327
"""
@@ -302,7 +332,23 @@ def put(self, request, name, version, rest, **kwargs):
302332
"""
303333
if rest != "unyank":
304334
raise Http404(f"Unknown action: {rest}")
305-
raise NotImplementedError("Unyank endpoint is not yet implemented")
335+
336+
distro = self.get_distribution()
337+
if not distro.repository:
338+
raise Http404("No repository associated with this distribution")
339+
340+
task = dispatch(
341+
aunyank_package,
342+
exclusive_resources=[distro.repository],
343+
immediate=True,
344+
kwargs={
345+
"repository_pk": str(distro.repository.pk),
346+
"name": name,
347+
"vers": version,
348+
},
349+
)
350+
has_task_completed(task)
351+
return Response({"ok": True})
306352

307353

308354
def has_task_completed(task):

0 commit comments

Comments
 (0)