Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGES/1166.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added repository-specific package blocklist.
2 changes: 2 additions & 0 deletions docs/user/guides/upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ as Pulp may contain different content units with the same name.
}
```

TODO: blocklist docs

## Remove content from a repository

A content unit can be removed from a repository using the `remove` command.
Expand Down
48 changes: 48 additions & 0 deletions pulp_python/app/migrations/0022_pythonblocklistentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.2.10 on 2026-04-09 08:27

import django.db.models.deletion
import django_lifecycle.mixins
import pulpcore.app.models.base
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("python", "0021_pythonrepository_upload_duplicate_filenames"),
]

operations = [
migrations.CreateModel(
name="PythonBlocklistEntry",
fields=[
(
"pulp_id",
models.UUIDField(
default=pulpcore.app.models.base.pulp_uuid,
editable=False,
primary_key=True,
serialize=False,
),
),
("pulp_created", models.DateTimeField(auto_now_add=True)),
("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(default=None, null=True)),
("version", models.TextField(default=None, null=True)),
("filename", models.TextField(default=None, null=True)),
("added_by", models.TextField(blank=True, default="")),
(
"repository",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="blocklist_entries",
to="python.pythonrepository",
),
),
],
options={
"default_related_name": "%(app_label)s_%(model_name)s",
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
]
56 changes: 56 additions & 0 deletions pulp_python/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from rest_framework.serializers import ValidationError
from pulpcore.plugin.models import (
AutoAddObjPermsMixin,
BaseModel,
Content,
Publication,
Distribution,
Expand Down Expand Up @@ -399,9 +400,12 @@ def finalize_new_version(self, new_version):

When allow_package_substitution is False, reject any new version that would implicitly
replace existing content with different checksums (content substitution).

Also checks newly added content against the repository's blocklist entries.
"""
if not self.allow_package_substitution:
self._check_for_package_substitution(new_version)
self._check_blocklist(new_version)
remove_duplicates(new_version)
validate_repo_version(new_version)

Expand All @@ -418,3 +422,55 @@ def _check_for_package_substitution(self, new_version):
"To allow this, set 'allow_package_substitution' to True on the repository. "
f"Conflicting packages: {duplicates}"
)

def _check_blocklist(self, new_version):
"""
Raise a ValidationError if any newly added package matches a blocklist entry.
"""
entries = PythonBlocklistEntry.objects.filter(repository=self)
if not entries.exists():
return

added_content = PythonPackageContent.objects.filter(
pk__in=new_version.added().values_list("pk", flat=True)
)
if not added_content.exists():
return

blocked = []
for pkg in added_content:
pkg_name_normalized = canonicalize_name(pkg.name) if pkg.name else ""
for entry in entries:
if entry.filename and entry.filename == pkg.filename:
blocked.append(pkg.filename)
break
if entry.name and canonicalize_name(entry.name) == pkg_name_normalized:
if not entry.version or entry.version == pkg.version:
blocked.append(pkg.filename)
break
if blocked:
raise ValidationError(
"Blocklisted packages cannot be added to this repository: "
"{}".format(", ".join(blocked))
)


class PythonBlocklistEntry(BaseModel):
"""
An entry in a PythonRepository's package blocklist.

Blocks package uploads by exact filename, package name, or package name + version.
At least one of ``name`` or ``filename`` must be non-empty.
"""

# todo? domain?, added_by?
name = models.TextField(null=True, default=None)
version = models.TextField(null=True, default=None)
filename = models.TextField(null=True, default=None)
added_by = models.TextField(blank=True, default="")
repository = models.ForeignKey(
PythonRepository, on_delete=models.CASCADE, related_name="blocklist_entries"
)

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
105 changes: 105 additions & 0 deletions pulp_python/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
from django.db.utils import IntegrityError
from drf_spectacular.utils import extend_schema_serializer
from packaging.requirements import Requirement
from packaging.version import Version, InvalidVersion
from rest_framework import serializers
from rest_framework_nested.serializers import NestedHyperlinkedModelSerializer
from pypi_attestations import AttestationError
from pydantic import TypeAdapter, ValidationError
from urllib.parse import urljoin

# todo: cannot import from pulpcore.plugin!
from pulpcore.app.serializers import NestedIdentityField

from pulpcore.plugin import models as core_models
from pulpcore.plugin import serializers as core_serializers
from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user
Expand Down Expand Up @@ -780,6 +785,106 @@ class Meta:
model = python_models.PythonRemote


class PythonBlocklistEntrySerializer(
core_serializers.ModelSerializer, NestedHyperlinkedModelSerializer
):
"""
Serializer for PythonBlocklistEntry.

The `repository` is supplied by the URL (not the request body) and is injected
by the viewset before saving.
"""

pulp_href = NestedIdentityField(
view_name="blocklist_entries-detail",
parent_lookup_kwargs={"repository_pk": "repository__pk"},
)
repository = core_serializers.DetailRelatedField(
read_only=True,
view_name_pattern=r"repositories(-.*/.*)?-detail",
help_text=_("Repository this blocklist entry belongs to."),
)
name = serializers.CharField(
required=False,
allow_null=True,
default=None,
help_text=_(
"Package name to block (for all versions). Compared after PEP 503 normalization. "
"Required when 'filename' is not provided."
),
)
version = serializers.CharField(
required=False,
allow_null=True,
default=None,
help_text=_("Exact version string to block (e.g. '1.0'). Only used when 'name' is set."),
)
filename = serializers.CharField(
required=False,
allow_null=True,
default=None,
help_text=_("Exact filename to block. Required when 'name' is not provided."),
)
added_by = serializers.CharField(read_only=True)

def validate(self, data):
"""
Validate that the blocklist entry is well-formed and not a duplicate.
"""
name = data.get("name")
filename = data.get("filename")
version = data.get("version")

if version and filename:
raise serializers.ValidationError(_("'version' cannot be used with 'filename'."))
if version and not name:
raise serializers.ValidationError(_("'version' requires 'name' to be provided."))
if name and filename:
raise serializers.ValidationError(_("'name' and 'filename' are mutually exclusive."))
if not name and not filename:
raise serializers.ValidationError(_("Either 'name' or 'filename' must be provided."))

if version:
try:
Version(version)
except InvalidVersion:
raise serializers.ValidationError(
{"version": _("'{}' is not a valid version.").format(version)}
)

repository = self.context.get("repository")
if repository:
qs = python_models.PythonBlocklistEntry.objects.filter(repository=repository)
if name and qs.filter(name=name, version=version).exists():
raise serializers.ValidationError(
_("A blocklist entry with this name and version already exists.")
)
if filename and qs.filter(filename=filename).exists():
raise serializers.ValidationError(
_("A blocklist entry with this filename already exists.")
)

return data

def create(self, validated_data):
"""
Create a new blocklist entry, recording the authenticated user in `added_by`.
"""
user = get_current_authenticated_user() # todo?
validated_data["added_by"] = user.username if user else ""
return super().create(validated_data)

class Meta:
fields = core_serializers.ModelSerializer.Meta.fields + (
"repository",
"name",
"version",
"filename",
"added_by",
)
model = python_models.PythonBlocklistEntry


class PythonBanderRemoteSerializer(serializers.Serializer):
"""
A Serializer for the initial step of creating a Python Remote from a Bandersnatch config file
Expand Down
70 changes: 68 additions & 2 deletions pulp_python/app/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@
from pathlib import Path
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.mixins import (
CreateModelMixin,
DestroyModelMixin,
ListModelMixin,
RetrieveModelMixin,
)
from rest_framework.response import Response
from rest_framework.serializers import ValidationError


from pulpcore.plugin import viewsets as core_viewsets
from pulpcore.plugin.actions import ModifyRepositoryActionMixin
from pulpcore.plugin.models import RepositoryVersion
Expand All @@ -28,7 +35,7 @@

class PythonRepositoryViewSet(
core_viewsets.RepositoryViewSet, ModifyRepositoryActionMixin, core_viewsets.RolesMixin
):
): # todo? remove ModifyRepositoryActionMixin?
"""
PythonRepository represents a single Python repository, to which content can be
synced, added, or removed.
Expand Down Expand Up @@ -136,7 +143,7 @@ class PythonRepositoryViewSet(
responses={202: AsyncOperationResponseSerializer},
)
@action(detail=True, methods=["post"], serializer_class=RepositoryAddRemoveContentSerializer)
def modify(self, request, pk):
def modify(self, request, pk): # todo?
"""
Queues a task that creates a new RepositoryVersion by adding and removing content units.

Expand Down Expand Up @@ -216,6 +223,65 @@ def sync(self, request, pk):
return core_viewsets.OperationPostponedResponse(result, request)


class PythonBlocklistEntryViewSet(
core_viewsets.NamedModelViewSet,
CreateModelMixin,
RetrieveModelMixin,
ListModelMixin,
DestroyModelMixin,
):
"""
ViewSet for managing blocklist entries on a PythonRepository.

Blocklist entries prevent packages from being uploaded to the repository.
Each entry can match by exact filename, package name, or package name with an exact version.
"""

endpoint_name = "blocklist_entries"
router_lookup = "pythonblocklistentry"
parent_viewset = PythonRepositoryViewSet
parent_lookup_kwargs = {"repository_pk": "repository__pk"}
serializer_class = python_serializers.PythonBlocklistEntrySerializer
queryset = python_models.PythonBlocklistEntry.objects.all()
ordering = ("-pulp_created",)

DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["list", "retrieve"],
"principal": "authenticated",
"effect": "allow",
"condition": "has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository", # noqa: E501
},
{
"action": ["create", "destroy"],
"principal": "authenticated",
"effect": "allow",
"condition": [
"has_repository_model_or_domain_or_obj_perms:python.modify_pythonrepository",
"has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
],
},
],
}

def get_serializer_context(self):
"""
Inject the parent repository into the serializer context so that `validate()` can check for
duplicate entries. The guard on `repository_pk` prevents errors during schema generation.
"""
context = super().get_serializer_context()
if self.kwargs.get("repository_pk"):
context["repository"] = self.get_parent_object()
return context

def perform_create(self, serializer):
"""
Set the repository FK from the URL before saving the entry.
"""
serializer.save(repository=self.get_parent_object())


class PythonRepositoryVersionViewSet(core_viewsets.RepositoryVersionViewSet):
"""
PythonRepositoryVersion represents a single Python repository version.
Expand Down
Loading
Loading