Skip to content

Commit a5298c3

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 a5298c3

8 files changed

Lines changed: 269 additions & 23 deletions

File tree

CHANGES/1278.feature

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

docs/user/guides/package_policies.md

Lines changed: 31 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,31 @@ 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, for substitution conflicts, the existing vs rejected checksums).
194+
195+
### Disable failing on rejected packages
196+
197+
```bash
198+
pulp python repository update --repository "foo" --no-error-on-reject
199+
```
200+
201+
You can also set this when creating a repository:
202+
203+
```bash
204+
pulp python repository create --name "foo3" --no-error-on-reject --block-package-substitution
205+
```
206+
207+
### Re-enable failing on rejected packages
208+
209+
```bash
210+
pulp python repository update --repository "foo" --error-on-reject
211+
```
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", "0022_pythonblocklistentry"),
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: 92 additions & 16 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"
@@ -424,7 +426,8 @@ def finalize_new_version(self, new_version):
424426
Remove duplicate packages that have the same filename.
425427
426428
When allow_package_substitution is False, reject any new version that would implicitly
427-
replace existing content with different checksums (content substitution).
429+
replace existing content with different checksums (content substitution), unless
430+
error_on_reject is False, in which case the conflicting packages are skipped.
428431
429432
Also checks newly added content against the repository's blocklist entries.
430433
"""
@@ -436,53 +439,126 @@ def finalize_new_version(self, new_version):
436439

437440
def _check_for_package_substitution(self, new_version):
438441
"""
439-
Raise a ValidationError if newly added packages would replace existing packages
440-
that have the same filename but a different sha256 checksum.
442+
Handle packages that would replace existing packages with the same filename but a
443+
different sha256 checksum.
444+
445+
When error_on_reject is True, raise a ValidationError. When False, remove the
446+
newly added conflicting packages from the version and record them in a progress report.
441447
"""
442448
qs = PythonPackageContent.objects.filter(pk__in=new_version.content)
443449
duplicates = collect_duplicates(qs, ("filename",))
444-
if duplicates:
450+
if not duplicates:
451+
return
452+
453+
if self.error_on_reject:
445454
raise ValidationError(
446455
"Found duplicate packages being added with the same filename but different "
447456
"checksums. To allow this, set 'allow_package_substitution' to True on the "
448457
f"repository. Conflicting packages: {duplicates}"
449458
)
450459

460+
added_content = PythonPackageContent.objects.filter(
461+
pk__in=new_version.added(base_version=new_version.base_version)
462+
)
463+
added_pks = {
464+
str(pkg.pk): pkg.filename
465+
for pkg in added_content.only("pk", "filename")
466+
}
467+
to_remove_pks = []
468+
messages = []
469+
for dup in duplicates:
470+
for pk in dup.duplicate_pks:
471+
if pk in added_pks:
472+
to_remove_pks.append(pk)
473+
messages.append(f"{added_pks[pk]} ({pk})")
474+
475+
if to_remove_pks:
476+
new_version.remove_content(PythonPackageContent.objects.filter(pk__in=to_remove_pks))
477+
self._report_rejected_packages(
478+
messages,
479+
message="Skipping packages rejected by package substitution policy",
480+
code="python.reject.substitution",
481+
)
482+
451483
def _check_blocklist(self, new_version):
452484
"""
453485
Check newly added content in a repository version against the blocklist.
486+
487+
When error_on_reject is True, raise a ValidationError. When False, remove the
488+
blocklisted packages from the version and record them in a progress report.
454489
"""
455490
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)
491+
pk__in=new_version.added(base_version=new_version.base_version)
492+
).only("pk", "filename", "name_normalized", "version")
493+
if not added_content.exists():
494+
return
460495

461-
def check_blocklist_for_packages(self, packages):
496+
blocked = self.find_blocklisted_packages(added_content)
497+
if not blocked:
498+
return
499+
500+
if self.error_on_reject:
501+
raise ValidationError(
502+
"Blocklisted packages cannot be added to this repository: {}".format(
503+
", ".join(pkg.filename for pkg in blocked)
504+
)
505+
)
506+
507+
new_version.remove_content(
508+
PythonPackageContent.objects.filter(pk__in=[p.pk for p in blocked])
509+
)
510+
self._report_rejected_packages(
511+
[pkg.filename for pkg in blocked],
512+
message="Skipping packages rejected by blocklist policy",
513+
code="python.reject.blocklist",
514+
)
515+
516+
def find_blocklisted_packages(self, packages):
462517
"""
463-
Raise a ValidationError if any of the given packages match a blocklist entry.
518+
Return the packages from ``packages`` that match a blocklist entry.
464519
"""
465-
entries = PythonBlocklistEntry.objects.filter(repository=self)
466-
if not entries.exists():
467-
return
520+
entries = list(PythonBlocklistEntry.objects.filter(repository=self))
521+
if not entries:
522+
return []
468523

469524
blocked = []
470525
for pkg in packages:
471526
for entry in entries:
472527
if entry.filename and entry.filename == pkg.filename:
473-
blocked.append(pkg.filename)
528+
blocked.append(pkg)
474529
break
475530
if entry.name == pkg.name_normalized:
476531
if not entry.version or entry.version == pkg.version:
477-
blocked.append(pkg.filename)
532+
blocked.append(pkg)
478533
break
534+
return blocked
535+
536+
def check_blocklist_for_packages(self, packages):
537+
"""
538+
Raise a ValidationError if any of the given packages match a blocklist entry.
539+
"""
540+
blocked = self.find_blocklisted_packages(packages)
479541
if blocked:
480542
raise ValidationError(
481543
"Blocklisted packages cannot be added to this repository: {}".format(
482-
", ".join(blocked)
544+
", ".join(pkg.filename for pkg in blocked)
483545
)
484546
)
485547

548+
def _report_rejected_packages(self, details, message, code):
549+
"""
550+
Record skipped packages in a task progress report.
551+
"""
552+
suffix = "; ".join(details)
553+
log.info("%s: %s", message, suffix)
554+
with ProgressReport(
555+
message=message,
556+
code=code,
557+
total=len(details),
558+
suffix=suffix,
559+
) as pb:
560+
pb.increase_by(len(details))
561+
486562

487563
class PythonBlocklistEntry(BaseModel):
488564
"""

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: 8 additions & 6 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)

pulp_python/tests/functional/api/test_blocklist.py

Lines changed: 50 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,47 @@ 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+
194+
repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
195+
content_list = python_bindings.ContentPackagesApi.list(
196+
repository_version=repo.latest_version_href
197+
)
198+
hrefs = {c.pulp_href for c in content_list.results}
199+
assert allowed.pulp_href in hrefs
200+
assert blocked.pulp_href not in hrefs
201+
assert content_list.count == 1

0 commit comments

Comments
 (0)