Skip to content

Commit 1412372

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

12 files changed

Lines changed: 518 additions & 62 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ A Pulp plugin to support hosting your own Rust/Cargo package registry.
1414
- Host content either locally or on S3/Azure/GCP
1515
- De-duplication of all saved content
1616

17+
## Not Yet Supported
18+
19+
The following features are not yet implemented but are planned for future releases:
20+
21+
- **Publishing** (`cargo publish`) -- crates cannot yet be uploaded via the Cargo CLI
22+
- **Authentication & authorization** -- the registry is currently open to all clients
23+
- **Syncing** -- mirroring an entire upstream registry is not yet supported; use pull-through caching instead
24+
1725
For more information, please see the [documentation](docs/index.md) or the [Pulp project page](https://pulpproject.org/).
1826

1927

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: 63 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,35 @@ 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+
repo_version = distro.repository.latest_version()
315+
if not RustContent.objects.filter(
316+
pk__in=repo_version.content, name=name, vers=version
317+
).exists():
318+
return HttpResponse(
319+
json.dumps(
320+
{"errors": [{"detail": f"crate `{name}` does not have a version `{version}`"}]}
321+
),
322+
content_type="application/json",
323+
status=404,
324+
)
325+
326+
task = dispatch(
327+
ayank_package,
328+
exclusive_resources=[distro.repository],
329+
immediate=True,
330+
kwargs={
331+
"repository_pk": str(distro.repository.pk),
332+
"name": name,
333+
"vers": version,
334+
},
335+
)
336+
has_task_completed(task)
337+
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")
297338

298339
def put(self, request, name, version, rest, **kwargs):
299340
"""
@@ -304,7 +345,23 @@ def put(self, request, name, version, rest, **kwargs):
304345
"""
305346
if rest != "unyank":
306347
raise Http404(f"Unknown action: {rest}")
307-
raise NotImplementedError("Unyank endpoint is not yet implemented")
348+
349+
distro = self.get_distribution()
350+
if not distro.repository:
351+
raise Http404("No repository associated with this distribution")
352+
353+
task = dispatch(
354+
aunyank_package,
355+
exclusive_resources=[distro.repository],
356+
immediate=True,
357+
kwargs={
358+
"repository_pk": str(distro.repository.pk),
359+
"name": name,
360+
"vers": version,
361+
},
362+
)
363+
has_task_completed(task)
364+
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")
308365

309366

310367
def has_task_completed(task):

pulp_rust/app/viewsets.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.db import transaction
2-
from django_filters import CharFilter, BooleanFilter
2+
from django_filters import CharFilter
33
from drf_spectacular.utils import extend_schema
44
from rest_framework import status
55
from rest_framework.decorators import action
@@ -22,7 +22,7 @@ class RustContentFilter(core.ContentFilter):
2222
"""
2323
FilterSet for RustContent (Cargo packages).
2424
25-
Provides filtering capabilities for package name, version, and yanked status.
25+
Provides filtering capabilities for package name, version, and checksum.
2626
"""
2727

2828
# Filter by exact package name
@@ -34,9 +34,6 @@ class RustContentFilter(core.ContentFilter):
3434
# Filter by checksum
3535
cksum = CharFilter(field_name="cksum")
3636

37-
# Filter by yanked status
38-
yanked = BooleanFilter(field_name="yanked")
39-
4037
# Filter by minimum Rust version requirement
4138
rust_version = CharFilter(field_name="rust_version")
4239

@@ -46,7 +43,6 @@ class Meta:
4643
"name",
4744
"vers",
4845
"cksum",
49-
"yanked",
5046
"rust_version",
5147
]
5248

0 commit comments

Comments
 (0)