Skip to content

Commit 4c7db60

Browse files
gerrod3cursoragent
andcommitted
Add error_on_reject for partial package policy rejection
Allow repositories to skip packages rejected by blocklist or substitution policies instead of failing the entire version. closes #1278 Assisted By: Cursor Grok 4.5 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 275c161 commit 4c7db60

8 files changed

Lines changed: 269 additions & 32 deletions

File tree

CHANGES/1278.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added an `error_on_reject` boolean field to PythonRepository (default: `True`). When `False`, packages rejected by the blocklist or package substitution policies are skipped instead of failing the entire repository version.

docs/user/guides/package_policies.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Python repositories offer two mechanisms for controlling which packages they acc
44
**blocklists** to prevent specific packages from being added, and
55
**package substitution control** to prevent silent replacement of existing packages.
66

7+
By default, when either policy rejects a package, the entire repository version operation fails.
8+
Set `error_on_reject` to `False` to instead skip rejected packages and continue adding the rest.
9+
710
## Setup
811

912
If you do not already have a repository, create one:
@@ -178,3 +181,34 @@ pulp python repository update --repository "foo" --allow-package-substitution
178181
```
179182

180183
Once re-enabled, packages with duplicate filenames can replace existing content again.
184+
185+
## Partial rejection (`error_on_reject`)
186+
187+
When a package is rejected by the blocklist or by the package substitution policy
188+
(`allow_package_substitution=False`), the default behavior (`error_on_reject=True`) is to fail
189+
the entire operation. No packages from the request are added.
190+
191+
Setting `error_on_reject` to `False` changes this: rejected packages are skipped, remaining
192+
packages are added, and skipped packages are recorded in a task progress report (including
193+
filenames and package pks).
194+
195+
### Disable failing on rejected packages
196+
197+
```bash
198+
http PATCH http://localhost:5001/pulp/api/v3/repositories/python/python/<repo_pk>/ \
199+
error_on_reject:=false -a admin:password
200+
```
201+
202+
You can also set this when creating a repository:
203+
204+
```bash
205+
http POST http://localhost:5001/pulp/api/v3/repositories/python/python/ \
206+
name=foo3 error_on_reject:=false allow_package_substitution:=false -a admin:password
207+
```
208+
209+
### Re-enable failing on rejected packages
210+
211+
```bash
212+
http PATCH http://localhost:5001/pulp/api/v3/repositories/python/python/<repo_pk>/ \
213+
error_on_reject:=true -a admin:password
214+
```
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
("python", "0023_packageyank"),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name="pythonrepository",
13+
name="error_on_reject",
14+
field=models.BooleanField(default=True),
15+
),
16+
]

pulp_python/app/models.py

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
BaseModel,
2020
Content,
2121
Distribution,
22+
ProgressReport,
2223
Publication,
2324
Remote,
2425
Repository,
@@ -394,6 +395,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
394395

395396
autopublish = models.BooleanField(default=False)
396397
allow_package_substitution = models.BooleanField(default=True)
398+
error_on_reject = models.BooleanField(default=True)
397399

398400
class Meta:
399401
default_related_name = "%(app_label)s_%(model_name)s"
@@ -423,10 +425,9 @@ def finalize_new_version(self, new_version):
423425
"""
424426
Remove duplicate packages that have the same filename.
425427
426-
When allow_package_substitution is False, reject any new version that would implicitly
427-
replace existing content with different checksums (content substitution).
428-
429-
Also checks newly added content against the repository's blocklist entries.
428+
Enforces package substitution and blocklist policies on newly added content.
429+
When error_on_reject is True (default), a ValidationError is raised and the version
430+
fails. When False, rejected packages are skipped and recorded in a progress report.
430431
"""
431432
if not self.allow_package_substitution:
432433
self._check_for_package_substitution(new_version)
@@ -436,52 +437,111 @@ def finalize_new_version(self, new_version):
436437

437438
def _check_for_package_substitution(self, new_version):
438439
"""
439-
Raise a ValidationError if newly added packages would replace existing packages
440-
that have the same filename but a different sha256 checksum.
440+
Handle packages that would replace existing packages with the same filename but a
441+
different sha256 checksum.
442+
443+
When error_on_reject is True, raise a ValidationError. When False, remove the
444+
newly added conflicting packages from the version and record them in a progress report.
441445
"""
442446
qs = PythonPackageContent.objects.filter(pk__in=new_version.content)
443447
duplicates = collect_duplicates(qs, ("filename",))
444-
if duplicates:
448+
if not duplicates:
449+
return
450+
451+
if self.error_on_reject:
445452
raise ValidationError(
446453
"Found duplicate packages being added with the same filename but different "
447454
"checksums. To allow this, set 'allow_package_substitution' to True on the "
448455
f"repository. Conflicting packages: {duplicates}"
449456
)
450457

458+
added_content = PythonPackageContent.objects.filter(
459+
pk__in=new_version.added(base_version=new_version.base_version)
460+
)
461+
added_pks = {str(pkg.pk): pkg.filename for pkg in added_content.only("pk", "filename")}
462+
to_remove_pks = []
463+
messages = []
464+
# Skip every newly added package in a conflicting filename group, including when
465+
# multiple new packages share a filename and none of them remain in the version.
466+
for dup in duplicates:
467+
for pk in dup.duplicate_pks:
468+
if pk in added_pks:
469+
to_remove_pks.append(pk)
470+
messages.append(f"{added_pks[pk]} ({pk})")
471+
472+
if to_remove_pks:
473+
new_version.remove_content(PythonPackageContent.objects.filter(pk__in=to_remove_pks))
474+
self._report_rejected_packages(
475+
messages,
476+
message="Skipping packages rejected by package substitution policy",
477+
code="python.reject.substitution",
478+
)
479+
451480
def _check_blocklist(self, new_version):
452481
"""
453482
Check newly added content in a repository version against the blocklist.
483+
484+
When error_on_reject is True, raise a ValidationError. When False, remove the
485+
blocklisted packages from the version and record them in a progress report.
454486
"""
455487
added_content = PythonPackageContent.objects.filter(
456-
pk__in=new_version.added().values_list("pk", flat=True)
457-
).only("filename", "name_normalized", "version")
458-
if added_content.exists():
459-
self.check_blocklist_for_packages(added_content)
488+
pk__in=new_version.added(base_version=new_version.base_version)
489+
).only("pk", "filename", "name_normalized", "version")
490+
if not added_content.exists():
491+
return
492+
493+
blocked = self.find_blocklisted_packages(added_content)
494+
if not blocked:
495+
return
496+
497+
if self.error_on_reject:
498+
raise ValidationError(
499+
"Blocklisted packages cannot be added to this repository: {}".format(
500+
", ".join(pkg.filename for pkg in blocked)
501+
)
502+
)
460503

461-
def check_blocklist_for_packages(self, packages):
504+
new_version.remove_content(
505+
PythonPackageContent.objects.filter(pk__in=[p.pk for p in blocked])
506+
)
507+
self._report_rejected_packages(
508+
[f"{pkg.filename} ({pkg.pk})" for pkg in blocked],
509+
message="Skipping packages rejected by blocklist policy",
510+
code="python.reject.blocklist",
511+
)
512+
513+
def find_blocklisted_packages(self, packages):
462514
"""
463-
Raise a ValidationError if any of the given packages match a blocklist entry.
515+
Return the packages from `packages` that match a blocklist entry.
464516
"""
465-
entries = PythonBlocklistEntry.objects.filter(repository=self)
466-
if not entries.exists():
467-
return
517+
entries = list(PythonBlocklistEntry.objects.filter(repository=self))
518+
if not entries:
519+
return []
468520

469521
blocked = []
470522
for pkg in packages:
471523
for entry in entries:
472524
if entry.filename and entry.filename == pkg.filename:
473-
blocked.append(pkg.filename)
525+
blocked.append(pkg)
474526
break
475527
if entry.name == pkg.name_normalized:
476528
if not entry.version or entry.version == pkg.version:
477-
blocked.append(pkg.filename)
529+
blocked.append(pkg)
478530
break
479-
if blocked:
480-
raise ValidationError(
481-
"Blocklisted packages cannot be added to this repository: {}".format(
482-
", ".join(blocked)
483-
)
484-
)
531+
return blocked
532+
533+
def _report_rejected_packages(self, details, message, code):
534+
"""
535+
Record skipped packages in a task progress report.
536+
"""
537+
log.info("%s (%s package(s))", message, len(details))
538+
with ProgressReport(
539+
message=message,
540+
code=code,
541+
total=len(details),
542+
suffix="; ".join(details),
543+
) as pb:
544+
pb.increase_by(len(details))
485545

486546

487547
class PythonBlocklistEntry(BaseModel):

pulp_python/app/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
7373
default=True,
7474
required=False,
7575
)
76+
error_on_reject = serializers.BooleanField(
77+
help_text=_(
78+
"Whether to fail the entire repository version when packages are rejected by the "
79+
"package substitution or blocklist policies. When True (the default), a ValidationError "
80+
"is raised and no packages from the request are added. When False, rejected packages "
81+
"are skipped and remaining packages are added; skipped packages are recorded in a "
82+
"task progress report."
83+
),
84+
default=True,
85+
required=False,
86+
)
7687

7788
def get_blocklist_entries_href(self, obj):
7889
repo_href = reverse("repositories-python/python-detail", kwargs={"pk": obj.pk})
@@ -82,6 +93,7 @@ class Meta:
8293
fields = core_serializers.RepositorySerializer.Meta.fields + (
8394
"autopublish",
8495
"allow_package_substitution",
96+
"error_on_reject",
8597
"blocklist_entries_href",
8698
)
8799
model = python_models.PythonRepository

pulp_python/app/viewsets.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,19 +148,21 @@ def modify(self, request, pk):
148148
"""
149149
Queues a task that creates a new RepositoryVersion by adding and removing content units.
150150
151-
If allow_package_substitution is False and the request is **only** adding packages, then a
152-
package substitution check is performed to provide a quicker error response. Otherwise, the
153-
check is delegated to the task.
151+
If allow_package_substitution is False, error_on_reject is True, and the request is
152+
**only** adding packages, then a package substitution check is performed to provide a
153+
quicker error response. Otherwise, the check is delegated to the task.
154154
155-
Also performs an early blocklist check on added packages.
155+
Also performs an early blocklist check on added packages when error_on_reject is True.
156+
When error_on_reject is False, rejected packages are skipped during task finalization.
156157
"""
157158
repository = self.get_object()
158159
add_content_units = request.data.get("add_content_units", [])
159160
content_ids = [extract_pk(x) for x in add_content_units]
160161

161-
self._early_blocklist_check(repository, content_ids)
162+
if repository.error_on_reject:
163+
self._early_blocklist_check(repository, content_ids)
162164

163-
if not repository.allow_package_substitution:
165+
if not repository.allow_package_substitution and repository.error_on_reject:
164166
remove_content_units = request.data.get("remove_content_units", [])
165167
if remove_content_units or "base_version" in request.data:
166168
return super().modify(request, pk)
@@ -189,7 +191,13 @@ def _early_blocklist_check(self, repository, content_ids):
189191
packages = python_models.PythonPackageContent.objects.filter(pk__in=content_ids).only(
190192
"filename", "name_normalized", "version"
191193
)
192-
repository.check_blocklist_for_packages(packages)
194+
blocked = repository.find_blocklisted_packages(packages)
195+
if blocked:
196+
raise ValidationError(
197+
"Blocklisted packages cannot be added to this repository: {}".format(
198+
", ".join(pkg.filename for pkg in blocked)
199+
)
200+
)
193201

194202
@extend_schema(
195203
summary="Repair metadata",

pulp_python/tests/functional/api/test_blocklist.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from pulpcore.tests.functional.utils import PulpTaskError
44

5-
from pulp_python.tests.functional.constants import PYTHON_EGG_FILENAME, PYTHON_EGG_URL
5+
from pulp_python.tests.functional.constants import (
6+
PYTHON_EGG_FILENAME,
7+
PYTHON_EGG_URL,
8+
PYTHON_WHEEL_FILENAME,
9+
PYTHON_WHEEL_URL,
10+
)
611

712
CONTENT_BODY = {"relative_path": PYTHON_EGG_FILENAME, "file_url": PYTHON_EGG_URL}
813
BLOCKED_MSG = "Blocklisted packages cannot be added to this repository"
@@ -150,3 +155,48 @@ def test_modify_blocked(monitor_task, python_bindings, python_repo):
150155

151156
repo = python_bindings.RepositoriesPythonApi.read(python_repo.pulp_href)
152157
assert repo.latest_version_href.endswith("/0/")
158+
159+
160+
@pytest.mark.parallel
161+
def test_error_on_reject_false_skips_blocklisted(
162+
monitor_task, python_bindings, python_repo_factory
163+
):
164+
"""
165+
When error_on_reject=False, blocklisted packages in a batch modify are skipped while
166+
non-blocklisted packages are still added.
167+
"""
168+
repo = python_repo_factory(error_on_reject=False)
169+
python_bindings.RepositoriesPythonBlocklistEntriesApi.create(
170+
repo.pulp_href,
171+
python_bindings.PythonPythonBlocklistEntry(filename=PYTHON_EGG_FILENAME),
172+
)
173+
174+
response = python_bindings.ContentPackagesApi.create(**CONTENT_BODY)
175+
blocked = python_bindings.ContentPackagesApi.read(
176+
monitor_task(response.task).created_resources[0]
177+
)
178+
response = python_bindings.ContentPackagesApi.create(
179+
relative_path=PYTHON_WHEEL_FILENAME, file_url=PYTHON_WHEEL_URL
180+
)
181+
allowed = python_bindings.ContentPackagesApi.read(
182+
monitor_task(response.task).created_resources[0]
183+
)
184+
185+
body = {"add_content_units": [blocked.pulp_href, allowed.pulp_href]}
186+
task = monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task)
187+
188+
reports = {report.code: report for report in task.progress_reports}
189+
assert "python.reject.blocklist" in reports
190+
report = reports["python.reject.blocklist"]
191+
assert report.done == 1
192+
assert PYTHON_EGG_FILENAME in report.suffix
193+
assert blocked.prn.split(":")[-1] in report.suffix
194+
195+
repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
196+
content_list = python_bindings.ContentPackagesApi.list(
197+
repository_version=repo.latest_version_href
198+
)
199+
hrefs = {c.pulp_href for c in content_list.results}
200+
assert allowed.pulp_href in hrefs
201+
assert blocked.pulp_href not in hrefs
202+
assert content_list.count == 1

0 commit comments

Comments
 (0)