Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).


Expand Down
19 changes: 16 additions & 3 deletions pulp_rust/app/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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',),
),
]

This file was deleted.

30 changes: 23 additions & 7 deletions pulp_rust/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"]}
Expand Down Expand Up @@ -264,14 +258,36 @@ 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.
"""

TYPE = "rust"

CONTENT_TYPES = [RustContent]
CONTENT_TYPES = [RustContent, RustPackageYank]
REMOTE_TYPES = [RustRemote]
PULL_THROUGH_SUPPORTED = True

Expand Down
20 changes: 13 additions & 7 deletions pulp_rust/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -189,7 +183,6 @@ class Meta:
"cksum",
"features",
"features2",
"yanked",
"links",
"v",
"rust_version",
Expand Down Expand Up @@ -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
):
Expand Down
1 change: 1 addition & 0 deletions pulp_rust/app/tasks/__init__.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 3 additions & 8 deletions pulp_rust/app/tasks/streaming.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
58 changes: 58 additions & 0 deletions pulp_rust/app/tasks/yanking.py
Original file line number Diff line number Diff line change
@@ -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],
)
69 changes: 63 additions & 6 deletions pulp_rust/app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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):
Expand Down
8 changes: 2 additions & 6 deletions pulp_rust/app/viewsets.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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")

Expand All @@ -46,7 +43,6 @@ class Meta:
"name",
"vers",
"cksum",
"yanked",
"rust_version",
]

Expand Down
Loading