Skip to content

Commit 1eb656d

Browse files
committed
Rework Rust plugin + domain support + testing
Assisted-By: claude-opus-4.6
1 parent 05e8ab3 commit 1eb656d

12 files changed

Lines changed: 600 additions & 136 deletions

pulp_rust/app/models.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
import urllib.request
13
from logging import getLogger
24

35
from django.db import models
@@ -13,6 +15,31 @@
1315
logger = getLogger(__name__)
1416

1517

18+
def _strip_sparse_prefix(url):
19+
"""Strip the sparse+ prefix from a Cargo registry URL."""
20+
if url.startswith("sparse+"):
21+
return url[len("sparse+") :]
22+
return url
23+
24+
25+
def _parse_crate_relative_path(relative_path):
26+
"""
27+
Parse crate name and version from a relative path.
28+
29+
Expected format: {name}/{name}-{version}.crate
30+
Returns: (crate_name, version)
31+
"""
32+
# "serde/serde-1.0.0.crate" -> "serde-1.0.0.crate"
33+
filename = relative_path.rsplit("/", 1)[-1]
34+
# "serde-1.0.0.crate" -> "serde-1.0.0"
35+
stem = filename[: -len(".crate")]
36+
# "serde/serde-1.0.0.crate" -> "serde"
37+
crate_name = relative_path.split("/", 1)[0]
38+
# "serde-1.0.0" -> "1.0.0"
39+
version = stem[len(crate_name) + 1 :]
40+
return crate_name, version
41+
42+
1643
class RustContent(Content):
1744
"""
1845
The "rust" content type representing a Cargo package version.
@@ -73,6 +100,22 @@ class RustContent(Content):
73100

74101
_pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
75102

103+
@staticmethod
104+
def init_from_artifact_and_relative_path(artifact, relative_path):
105+
"""
106+
Create an unsaved RustContent from a downloaded .crate artifact.
107+
108+
Called by pulpcore's content handler during pull-through caching.
109+
Only populates name, version, and checksum -- dependency and feature
110+
metadata is served from the upstream sparse index via the proxy.
111+
"""
112+
crate_name, version = _parse_crate_relative_path(relative_path)
113+
return RustContent(
114+
name=crate_name,
115+
vers=version,
116+
cksum=artifact.sha256,
117+
)
118+
76119
class Meta:
77120
default_related_name = "%(app_label)s_%(model_name)s"
78121
unique_together = (("name", "vers", "_pulp_domain"),)
@@ -159,25 +202,58 @@ class RustRemote(Remote):
159202
"""
160203
A Remote for RustContent.
161204
162-
Define any additional fields for your new remote if needed.
205+
The `url` field should point to the sparse index root, optionally prefixed
206+
with `sparse+` (e.g. `sparse+https://index.crates.io/`).
163207
"""
164208

165209
TYPE = "rust"
166210

211+
def get_remote_artifact_url(self, relative_path=None, request=None):
212+
"""
213+
Construct the upstream download URL for a .crate file.
214+
215+
Fetches config.json from the index root to obtain the `dl` template,
216+
then substitutes {crate} and {version} markers per the Cargo spec.
217+
"""
218+
if relative_path is None or not relative_path.endswith(".crate"):
219+
return None
220+
221+
crate_name, version = _parse_crate_relative_path(relative_path)
222+
index_url = _strip_sparse_prefix(self.url).rstrip("/")
223+
224+
# TODO: Cache the config.json response to avoid fetching it on every request.
225+
config_url = f"{index_url}/config.json"
226+
response = urllib.request.urlopen(config_url)
227+
config = json.loads(response.read())
228+
dl_template = config["dl"]
229+
230+
if "{crate}" in dl_template or "{version}" in dl_template:
231+
return dl_template.replace("{crate}", crate_name).replace("{version}", version)
232+
else:
233+
# No markers: per Cargo spec, append /{crate}/{version}/download
234+
return f"{dl_template.rstrip('/')}/{crate_name}/{version}/download"
235+
236+
@staticmethod
237+
def get_remote_artifact_content_type(relative_path=None):
238+
"""Return the content type for the given relative path."""
239+
if relative_path and relative_path.endswith(".crate"):
240+
return RustContent
241+
return None
242+
167243
class Meta:
168244
default_related_name = "%(app_label)s_%(model_name)s"
169245

170246

171247
class RustRepository(Repository):
172248
"""
173249
A Repository for RustContent.
174-
175-
Define any additional fields for your new repository if needed.
176250
"""
177251

178252
TYPE = "rust"
179253

180254
CONTENT_TYPES = [RustContent]
255+
REMOTE_TYPES = [RustRemote]
256+
PULL_THROUGH_SUPPORTED = True
181257

182258
class Meta:
183259
default_related_name = "%(app_label)s_%(model_name)s"

pulp_rust/app/serializers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,9 @@ class RustRemoteSerializer(core_serializers.RemoteSerializer):
204204

205205
policy = serializers.ChoiceField(
206206
help_text="The policy to use when downloading content. The possible values include: "
207-
"'immediate', 'on_demand', and 'streamed'. 'streamed' is the default.",
207+
"'immediate', 'on_demand', and 'streamed'. 'on_demand' is the default.",
208208
choices=models.Remote.POLICY_CHOICES,
209-
default=models.Remote.STREAMED,
209+
default=models.Remote.ON_DEMAND,
210210
)
211211

212212
class Meta:

pulp_rust/app/tasks/streaming.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ async def aadd_and_remove(*args, **kwargs):
1212
return await sync_to_async(add_and_remove)(*args, **kwargs)
1313

1414

15+
# TODO: look at the version in models/repository.py
1516
def add_cached_content_to_repository(repository_pk=None, remote_pk=None):
1617
"""
1718
Create a new repository version by adding content that was cached by pulpcore-content when

pulp_rust/app/tasks/synchronizing.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
from pulp_rust.app.models import RustContent, RustRemote
1313

14-
1514
log = logging.getLogger(__name__)
1615

1716

pulp_rust/app/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
urlpatterns = [
1313
path(
14-
CRATES_IO_URL + "api/v1/crates/<str:package>/<str:version>/<path:rest>",
14+
CRATES_IO_URL + "api/v1/crates/<str:name>/<str:version>/<path:rest>",
1515
CargoDownloadApiView.as_view(),
1616
name="cargo-download-api",
1717
),

pulp_rust/app/views.py

Lines changed: 87 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
22
import logging
3+
import urllib.request
4+
import urllib.error
35

46
from rest_framework.views import APIView
57
from rest_framework.viewsets import ViewSet
@@ -15,12 +17,11 @@
1517
)
1618
from drf_spectacular.utils import extend_schema
1719
from dynaconf import settings
18-
from pathlib import PurePath
1920
from urllib.parse import urljoin
2021

2122
from pulpcore.plugin.util import get_domain
2223

23-
from pulp_rust.app.models import RustDistribution, RustRepository, RustContent
24+
from pulp_rust.app.models import RustDistribution, RustContent, _strip_sparse_prefix
2425
from pulp_rust.app.serializers import (
2526
IndexRootSerializer,
2627
RustContentSerializer,
@@ -85,7 +86,6 @@ def initial(self, request, *args, **kwargs):
8586
"""Perform common initialization tasks for API endpoints."""
8687
super().initial(request, *args, **kwargs)
8788
domain_name = get_domain().name
88-
log.warning(self.kwargs)
8989
repo = self.kwargs["repo"]
9090
if settings.DOMAIN_ENABLED:
9191
self.base_content_url = urljoin(BASE_CONTENT_URL, f"pulp/cargo/{domain_name}/{repo}/")
@@ -121,7 +121,7 @@ class CargoIndexApiViewSet(ApiMixin, ViewSet):
121121
responses={200: RustContentSerializer},
122122
summary="Get package metadata",
123123
)
124-
def retrieve(self, request, path):
124+
def retrieve(self, request, path, **kwargs):
125125
"""
126126
Retrieve crate metadata for the sparse protocol.
127127
@@ -132,42 +132,57 @@ def retrieve(self, request, path):
132132
- 4+ chars: {first-two}/{second-two}/{crate}
133133
134134
Returns newline-delimited JSON, one version per line.
135+
136+
If the crate is not found locally and the distribution has a remote,
137+
the metadata is proxied from the upstream sparse index.
135138
"""
136139
repo_ver, content = self.get_rvc()
137140

138-
if content is None:
139-
return HttpResponseNotFound("No content available")
140-
141-
# Extract crate name from the path
142-
meta_path = PurePath(path)
143-
crate_name = meta_path.name.lower()
141+
# Extract crate name from the path (last component)
142+
crate_name = path.rsplit("/", 1)[-1].lower()
144143

145-
# Query for all versions of this crate
146-
crate_versions = content.filter(name=crate_name).order_by("vers")
144+
# Try to serve from local content first
145+
if content is not None:
146+
crate_versions = content.filter(name=crate_name).order_by("vers")
147+
if crate_versions.exists():
148+
return self._build_index_response(crate_versions)
147149

148-
if not crate_versions.exists():
149-
return HttpResponseNotFound(f"Crate '{crate_name}' not found")
150+
# Fall back to proxying from the upstream remote
151+
if self.distribution.remote:
152+
remote = self.distribution.remote.cast()
153+
index_url = _strip_sparse_prefix(remote.url).rstrip("/")
154+
upstream_url = f"{index_url}/{path}"
155+
try:
156+
response = urllib.request.urlopen(upstream_url)
157+
return HttpResponse(response.read(), content_type="text/plain")
158+
except urllib.error.HTTPError as e:
159+
if e.code == 404:
160+
return HttpResponseNotFound(f"Crate '{crate_name}' not found")
161+
raise
162+
163+
return HttpResponseNotFound(f"Crate '{crate_name}' not found")
150164

151-
# Build newline-delimited JSON response
165+
@staticmethod
166+
def _build_index_response(crate_versions):
167+
"""Build a newline-delimited JSON response from local crate versions."""
152168
lines = []
153169
for crate_version in crate_versions:
154-
# Fetch dependencies for this version
155170
deps = []
156171
for dep in crate_version.dependencies.all():
157-
dep_obj = {
158-
"name": dep.name,
159-
"req": dep.req,
160-
"features": dep.features,
161-
"optional": dep.optional,
162-
"default_features": dep.default_features,
163-
"target": dep.target,
164-
"kind": dep.kind,
165-
"registry": dep.registry,
166-
"package": dep.package,
167-
}
168-
deps.append(dep_obj)
169-
170-
# Build the version object according to sparse protocol
172+
deps.append(
173+
{
174+
"name": dep.name,
175+
"req": dep.req,
176+
"features": dep.features,
177+
"optional": dep.optional,
178+
"default_features": dep.default_features,
179+
"target": dep.target,
180+
"kind": dep.kind,
181+
"registry": dep.registry,
182+
"package": dep.package,
183+
}
184+
)
185+
171186
version_obj = {
172187
"name": crate_version.name,
173188
"vers": crate_version.vers,
@@ -179,18 +194,14 @@ def retrieve(self, request, path):
179194
"v": crate_version.v,
180195
}
181196

182-
# Add optional fields only if present
183197
if crate_version.features2:
184198
version_obj["features2"] = crate_version.features2
185199
if crate_version.rust_version:
186200
version_obj["rust_version"] = crate_version.rust_version
187201

188-
# Serialize to JSON and add to lines
189202
lines.append(json.dumps(version_obj))
190203

191-
# Join with newlines and return as plain text
192-
response_text = "\n".join(lines)
193-
return HttpResponse(response_text, content_type="text/plain")
204+
return HttpResponse("\n".join(lines), content_type="text/plain")
194205

195206

196207
class IndexRoot(ApiMixin, ViewSet):
@@ -221,14 +232,9 @@ def retrieve(self, request, repo):
221232

222233
class CargoDownloadApiView(APIView):
223234
"""
224-
ViewSet for interacting with Cargo's API API
235+
View for Cargo's crate download, readme, yank, and unyank endpoints.
225236
"""
226237

227-
model = RustRepository
228-
queryset = RustRepository.objects.all()
229-
230-
lookup_field = "name"
231-
232238
# Authentication disabled for now
233239
authentication_classes = []
234240
permission_classes = []
@@ -248,23 +254,50 @@ def redirect_to_content_app(self, distribution, relative_path, request):
248254
f"{self.get_full_path(distribution.base_path)}/{relative_path}"
249255
)
250256

251-
def get_repository_and_distributions(self, name):
252-
repository = get_object_or_404(RustRepository, name=name, pulp_domain=get_domain())
253-
distribution = get_object_or_404(
254-
RustDistribution, repository=repository, pulp_domain=get_domain()
257+
def get_distribution(self):
258+
return get_object_or_404(
259+
RustDistribution, base_path=self.kwargs["repo"], pulp_domain=get_domain()
255260
)
256-
return repository, distribution
257261

258-
def get(self, request, name, version):
262+
def get(self, request, name, version, rest, **kwargs):
259263
"""
260-
Responds to GET requests about packages by reference
264+
Responds to GET requests for crate downloads and readmes.
265+
266+
Handles:
267+
- api/v1/crates/{name}/{version}/download - redirect to .crate file
268+
- api/v1/crates/{name}/{version}/readme - not yet implemented
261269
"""
262-
repo, distro = self.get_repository_and_distributions(name)
263-
content = get_object_or_404(
264-
RustContent, name=name, vers=version, pk__in=repo.latest_version().content
265-
)
266-
relative_path = content.contentartifact_set.get().relative_path
267-
return self.redirect_to_content_app(distro, relative_path, request)
270+
distro = self.get_distribution()
271+
272+
if rest == "download":
273+
relative_path = f"{name}/{name}-{version}.crate"
274+
return self.redirect_to_content_app(distro, relative_path, request)
275+
elif rest == "readme":
276+
raise NotImplementedError("Readme endpoint is not yet implemented")
277+
else:
278+
raise Http404(f"Unknown action: {rest}")
279+
280+
def delete(self, request, name, version, rest, **kwargs):
281+
"""
282+
Responds to DELETE requests for yanking crate versions.
283+
284+
Handles:
285+
- api/v1/crates/{name}/{version}/yank
286+
"""
287+
if rest != "yank":
288+
raise Http404(f"Unknown action: {rest}")
289+
raise NotImplementedError("Yank endpoint is not yet implemented")
290+
291+
def put(self, request, name, version, rest, **kwargs):
292+
"""
293+
Responds to PUT requests for unyanking crate versions.
294+
295+
Handles:
296+
- api/v1/crates/{name}/{version}/unyank
297+
"""
298+
if rest != "unyank":
299+
raise Http404(f"Unknown action: {rest}")
300+
raise NotImplementedError("Unyank endpoint is not yet implemented")
268301

269302

270303
def has_task_completed(task):

0 commit comments

Comments
 (0)