Skip to content

Commit 5d9caeb

Browse files
committed
Add repository-specific package blocklist
closes #1166 Assisted By: Claude Opus 4.6
1 parent e1e023c commit 5d9caeb

File tree

7 files changed

+464
-1
lines changed

7 files changed

+464
-1
lines changed

CHANGES/1166.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added repository-specific package blocklist.

docs/user/guides/upload.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ as Pulp may contain different content units with the same name.
135135
}
136136
```
137137

138+
TODO: blocklist docs
139+
138140
## Remove content from a repository
139141

140142
A content unit can be removed from a repository using the `remove` command.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Generated by Django 5.2.10 on 2026-04-09 08:27
2+
3+
import django.db.models.deletion
4+
import django_lifecycle.mixins
5+
import pulpcore.app.models.base
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("python", "0021_pythonrepository_upload_duplicate_filenames"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="PythonBlocklistEntry",
18+
fields=[
19+
(
20+
"pulp_id",
21+
models.UUIDField(
22+
default=pulpcore.app.models.base.pulp_uuid,
23+
editable=False,
24+
primary_key=True,
25+
serialize=False,
26+
),
27+
),
28+
("pulp_created", models.DateTimeField(auto_now_add=True)),
29+
("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)),
30+
("name", models.TextField(default=None, null=True)),
31+
("version", models.TextField(default=None, null=True)),
32+
("filename", models.TextField(default=None, null=True)),
33+
("added_by", models.TextField(blank=True, default="")),
34+
(
35+
"repository",
36+
models.ForeignKey(
37+
on_delete=django.db.models.deletion.CASCADE,
38+
related_name="blocklist_entries",
39+
to="python.pythonrepository",
40+
),
41+
),
42+
],
43+
options={
44+
"default_related_name": "%(app_label)s_%(model_name)s",
45+
},
46+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
47+
),
48+
]

pulp_python/app/models.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from rest_framework.serializers import ValidationError
1515
from pulpcore.plugin.models import (
1616
AutoAddObjPermsMixin,
17+
BaseModel,
1718
Content,
1819
Publication,
1920
Distribution,
@@ -399,9 +400,12 @@ def finalize_new_version(self, new_version):
399400
400401
When allow_package_substitution is False, reject any new version that would implicitly
401402
replace existing content with different checksums (content substitution).
403+
404+
Also checks newly added content against the repository's blocklist entries.
402405
"""
403406
if not self.allow_package_substitution:
404407
self._check_for_package_substitution(new_version)
408+
self._check_blocklist(new_version)
405409
remove_duplicates(new_version)
406410
validate_repo_version(new_version)
407411

@@ -418,3 +422,58 @@ def _check_for_package_substitution(self, new_version):
418422
"To allow this, set 'allow_package_substitution' to True on the repository. "
419423
f"Conflicting packages: {duplicates}"
420424
)
425+
426+
def _check_blocklist(self, new_version):
427+
"""
428+
Check newly added content in a repository version against the blocklist.
429+
"""
430+
added_content = PythonPackageContent.objects.filter(
431+
pk__in=new_version.added().values_list("pk", flat=True)
432+
)
433+
if added_content.exists():
434+
self.check_blocklist_for_packages(added_content)
435+
436+
def check_blocklist_for_packages(self, packages):
437+
"""
438+
Raise a ValidationError if any of the given packages match a blocklist entry.
439+
"""
440+
entries = PythonBlocklistEntry.objects.filter(repository=self)
441+
if not entries.exists():
442+
return
443+
444+
blocked = []
445+
for pkg in packages:
446+
pkg_name_normalized = canonicalize_name(pkg.name) if pkg.name else ""
447+
for entry in entries:
448+
if entry.filename and entry.filename == pkg.filename:
449+
blocked.append(pkg.filename)
450+
break
451+
if entry.name and canonicalize_name(entry.name) == pkg_name_normalized:
452+
if not entry.version or entry.version == pkg.version:
453+
blocked.append(pkg.filename)
454+
break
455+
if blocked:
456+
raise ValidationError(
457+
"Blocklisted packages cannot be added to this repository: "
458+
"{}".format(", ".join(blocked))
459+
)
460+
461+
462+
class PythonBlocklistEntry(BaseModel):
463+
"""
464+
An entry in a PythonRepository's package blocklist.
465+
466+
Blocks package uploads by exact filename, package name, or package name + version.
467+
At least one of `name` or `filename` must be non-empty.
468+
"""
469+
470+
name = models.TextField(null=True, default=None)
471+
version = models.TextField(null=True, default=None)
472+
filename = models.TextField(null=True, default=None)
473+
added_by = models.TextField(blank=True, default="")
474+
repository = models.ForeignKey(
475+
PythonRepository, on_delete=models.CASCADE, related_name="blocklist_entries"
476+
)
477+
478+
class Meta:
479+
default_related_name = "%(app_label)s_%(model_name)s"

pulp_python/app/serializers.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
from django.db.utils import IntegrityError
77
from drf_spectacular.utils import extend_schema_serializer
88
from packaging.requirements import Requirement
9+
from packaging.version import Version, InvalidVersion
910
from rest_framework import serializers
11+
from rest_framework_nested.relations import NestedHyperlinkedIdentityField
12+
from rest_framework_nested.serializers import NestedHyperlinkedModelSerializer
1013
from pypi_attestations import AttestationError
1114
from pydantic import TypeAdapter, ValidationError
1215
from urllib.parse import urljoin
1316

1417
from pulpcore.plugin import models as core_models
1518
from pulpcore.plugin import serializers as core_serializers
16-
from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user
19+
from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user, reverse
1720

1821
from pulp_python.app import models as python_models
1922
from pulp_python.app.provenance import (
@@ -53,6 +56,11 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
5356
default=False,
5457
required=False,
5558
)
59+
blocklist_entries_href = serializers.SerializerMethodField(
60+
help_text=_("URL to the blocklist entries for this repository."),
61+
read_only=True,
62+
)
63+
5664
allow_package_substitution = serializers.BooleanField(
5765
help_text=_(
5866
"Whether to allow package substitution (replacing existing packages with packages "
@@ -65,10 +73,15 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
6573
required=False,
6674
)
6775

76+
def get_blocklist_entries_href(self, obj):
77+
repo_href = reverse("repositories-python/python-detail", kwargs={"pk": obj.pk})
78+
return f"{repo_href}blocklist_entries/"
79+
6880
class Meta:
6981
fields = core_serializers.RepositorySerializer.Meta.fields + (
7082
"autopublish",
7183
"allow_package_substitution",
84+
"blocklist_entries_href",
7285
)
7386
model = python_models.PythonRepository
7487

@@ -780,6 +793,117 @@ class Meta:
780793
model = python_models.PythonRemote
781794

782795

796+
class _NestedIdentityField(NestedHyperlinkedIdentityField):
797+
"""
798+
NestedHyperlinkedIdentityField that uses pulpcore's reverse for relative URLs.
799+
Mimics NestedIdentityField from pulpcore, which is not exposed via the plugin API.
800+
"""
801+
802+
def get_url(self, obj, view_name, request, *args, **kwargs):
803+
self.reverse = reverse
804+
return super().get_url(obj, view_name, request, *args, **kwargs)
805+
806+
807+
class PythonBlocklistEntrySerializer(
808+
core_serializers.ModelSerializer, NestedHyperlinkedModelSerializer
809+
):
810+
"""
811+
Serializer for PythonBlocklistEntry.
812+
813+
The `repository` is supplied by the URL (not the request body) and is injected
814+
by the viewset before saving.
815+
"""
816+
817+
pulp_href = _NestedIdentityField(
818+
view_name="blocklist_entries-detail",
819+
parent_lookup_kwargs={"repository_pk": "repository__pk"},
820+
)
821+
repository = core_serializers.DetailRelatedField(
822+
read_only=True,
823+
view_name_pattern=r"repositories(-.*/.*)?-detail",
824+
help_text=_("Repository this blocklist entry belongs to."),
825+
)
826+
name = serializers.CharField(
827+
required=False,
828+
allow_null=True,
829+
default=None,
830+
help_text=_(
831+
"Package name to block (for all versions). Compared after PEP 503 normalization. "
832+
"Required when 'filename' is not provided."
833+
),
834+
)
835+
version = serializers.CharField(
836+
required=False,
837+
allow_null=True,
838+
default=None,
839+
help_text=_("Exact version string to block (e.g. '1.0'). Only used when 'name' is set."),
840+
)
841+
filename = serializers.CharField(
842+
required=False,
843+
allow_null=True,
844+
default=None,
845+
help_text=_("Exact filename to block. Required when 'name' is not provided."),
846+
)
847+
added_by = serializers.CharField(read_only=True)
848+
849+
def validate(self, data):
850+
"""
851+
Validate that the blocklist entry is well-formed and not a duplicate.
852+
"""
853+
name = data.get("name")
854+
filename = data.get("filename")
855+
version = data.get("version")
856+
857+
if version and filename:
858+
raise serializers.ValidationError(_("'version' cannot be used with 'filename'."))
859+
if version and not name:
860+
raise serializers.ValidationError(_("'version' requires 'name' to be provided."))
861+
if name and filename:
862+
raise serializers.ValidationError(_("'name' and 'filename' are mutually exclusive."))
863+
if not name and not filename:
864+
raise serializers.ValidationError(_("Either 'name' or 'filename' must be provided."))
865+
866+
if version:
867+
try:
868+
Version(version)
869+
except InvalidVersion:
870+
raise serializers.ValidationError(
871+
{"version": _("'{}' is not a valid version.").format(version)}
872+
)
873+
874+
repository = self.context.get("repository")
875+
if repository:
876+
qs = python_models.PythonBlocklistEntry.objects.filter(repository=repository)
877+
if name and qs.filter(name=name, version=version).exists():
878+
raise serializers.ValidationError(
879+
_("A blocklist entry with this name and version already exists.")
880+
)
881+
if filename and qs.filter(filename=filename).exists():
882+
raise serializers.ValidationError(
883+
_("A blocklist entry with this filename already exists.")
884+
)
885+
886+
return data
887+
888+
def create(self, validated_data):
889+
"""
890+
Create a new blocklist entry, recording the authenticated user in `added_by`.
891+
"""
892+
user = get_current_authenticated_user()
893+
validated_data["added_by"] = get_prn(user) if user else ""
894+
return super().create(validated_data)
895+
896+
class Meta:
897+
fields = core_serializers.ModelSerializer.Meta.fields + (
898+
"repository",
899+
"name",
900+
"version",
901+
"filename",
902+
"added_by",
903+
)
904+
model = python_models.PythonBlocklistEntry
905+
906+
783907
class PythonBanderRemoteSerializer(serializers.Serializer):
784908
"""
785909
A Serializer for the initial step of creating a Python Remote from a Bandersnatch config file

0 commit comments

Comments
 (0)