Skip to content

Commit cfd4010

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 cd6f078 commit cfd4010

8 files changed

Lines changed: 287 additions & 22 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:
@@ -122,3 +125,31 @@ pulp python repository update --repository "foo" --allow-package-substitution
122125
```
123126

124127
Once re-enabled, packages with duplicate filenames can replace existing content again.
128+
129+
## Partial rejection (`error_on_reject`)
130+
131+
When a package is rejected by the blocklist or by the package substitution policy
132+
(`allow_package_substitution=False`), the default behavior (`error_on_reject=True`) is to fail
133+
the entire operation. No packages from the request are added.
134+
135+
Setting `error_on_reject` to `False` changes this: rejected packages are skipped, remaining
136+
packages are added, and skipped packages are recorded in a task progress report (including
137+
filenames and, for substitution conflicts, the existing vs rejected checksums).
138+
139+
### Disable failing on rejected packages
140+
141+
```bash
142+
pulp python repository update --repository "foo" --no-error-on-reject
143+
```
144+
145+
You can also set this when creating a repository:
146+
147+
```bash
148+
pulp python repository create --name "foo3" --no-error-on-reject --block-package-substitution
149+
```
150+
151+
### Re-enable failing on rejected packages
152+
153+
```bash
154+
pulp python repository update --repository "foo" --error-on-reject
155+
```
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: 109 additions & 15 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,
@@ -370,6 +371,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
370371

371372
autopublish = models.BooleanField(default=False)
372373
allow_package_substitution = models.BooleanField(default=True)
374+
error_on_reject = models.BooleanField(default=True)
373375

374376
class Meta:
375377
default_related_name = "%(app_label)s_%(model_name)s"
@@ -400,7 +402,8 @@ def finalize_new_version(self, new_version):
400402
Remove duplicate packages that have the same filename.
401403
402404
When allow_package_substitution is False, reject any new version that would implicitly
403-
replace existing content with different checksums (content substitution).
405+
replace existing content with different checksums (content substitution), unless
406+
error_on_reject is False, in which case the conflicting packages are skipped.
404407
405408
Also checks newly added content against the repository's blocklist entries.
406409
"""
@@ -412,53 +415,144 @@ def finalize_new_version(self, new_version):
412415

413416
def _check_for_package_substitution(self, new_version):
414417
"""
415-
Raise a ValidationError if newly added packages would replace existing packages
416-
that have the same filename but a different sha256 checksum.
418+
Handle packages that would replace existing packages with the same filename but a
419+
different sha256 checksum.
420+
421+
When error_on_reject is True, raise a ValidationError. When False, remove the
422+
newly added conflicting packages from the version and record them in a progress report.
417423
"""
418424
qs = PythonPackageContent.objects.filter(pk__in=new_version.content)
419425
duplicates = collect_duplicates(qs, ("filename",))
420-
if duplicates:
426+
if not duplicates:
427+
return
428+
429+
if self.error_on_reject:
421430
raise ValidationError(
422431
"Found duplicate packages being added with the same filename but different "
423432
"checksums. To allow this, set 'allow_package_substitution' to True on the "
424433
f"repository. Conflicting packages: {duplicates}"
425434
)
426435

436+
added_pks = {
437+
str(pk)
438+
for pk in new_version.added(base_version=new_version.base_version).values_list(
439+
"pk", flat=True
440+
)
441+
}
442+
to_remove_pks = []
443+
messages = []
444+
for dup in duplicates:
445+
filename = dup.keyset_value[0]
446+
pkgs = {
447+
str(pkg.pk): pkg
448+
for pkg in PythonPackageContent.objects.filter(pk__in=dup.duplicate_pks).only(
449+
"pk", "filename", "sha256"
450+
)
451+
}
452+
existing = [pkgs[pk] for pk in dup.duplicate_pks if pk not in added_pks]
453+
added = [pkgs[pk] for pk in dup.duplicate_pks if pk in added_pks]
454+
if existing:
455+
kept_sha256 = existing[0].sha256
456+
for pkg in added:
457+
to_remove_pks.append(pkg.pk)
458+
messages.append(
459+
f"{filename} (existing sha256={kept_sha256}, rejected sha256={pkg.sha256})"
460+
)
461+
elif len(added) > 1:
462+
kept = added[0]
463+
for pkg in added[1:]:
464+
to_remove_pks.append(pkg.pk)
465+
messages.append(
466+
f"{filename} (kept sha256={kept.sha256}, rejected sha256={pkg.sha256})"
467+
)
468+
469+
if to_remove_pks:
470+
new_version.remove_content(PythonPackageContent.objects.filter(pk__in=to_remove_pks))
471+
self._report_rejected_packages(
472+
messages,
473+
message="Skipping packages rejected by package substitution policy",
474+
code="python.reject.substitution",
475+
)
476+
427477
def _check_blocklist(self, new_version):
428478
"""
429479
Check newly added content in a repository version against the blocklist.
480+
481+
When error_on_reject is True, raise a ValidationError. When False, remove the
482+
blocklisted packages from the version and record them in a progress report.
430483
"""
431484
added_content = PythonPackageContent.objects.filter(
432485
pk__in=new_version.added().values_list("pk", flat=True)
433-
).only("filename", "name_normalized", "version")
434-
if added_content.exists():
435-
self.check_blocklist_for_packages(added_content)
486+
).only("pk", "filename", "name_normalized", "version")
487+
if not added_content.exists():
488+
return
436489

437-
def check_blocklist_for_packages(self, packages):
490+
blocked = self.find_blocklisted_packages(added_content)
491+
if not blocked:
492+
return
493+
494+
if self.error_on_reject:
495+
raise ValidationError(
496+
"Blocklisted packages cannot be added to this repository: {}".format(
497+
", ".join(pkg.filename for pkg in blocked)
498+
)
499+
)
500+
501+
new_version.remove_content(
502+
PythonPackageContent.objects.filter(pk__in=[p.pk for p in blocked])
503+
)
504+
self._report_rejected_packages(
505+
[pkg.filename for pkg in blocked],
506+
message="Skipping packages rejected by blocklist policy",
507+
code="python.reject.blocklist",
508+
)
509+
510+
def find_blocklisted_packages(self, packages):
438511
"""
439-
Raise a ValidationError if any of the given packages match a blocklist entry.
512+
Return the packages from ``packages`` that match a blocklist entry.
440513
"""
441-
entries = PythonBlocklistEntry.objects.filter(repository=self)
442-
if not entries.exists():
443-
return
514+
entries = list(PythonBlocklistEntry.objects.filter(repository=self))
515+
if not entries:
516+
return []
444517

445518
blocked = []
446519
for pkg in packages:
447520
for entry in entries:
448521
if entry.filename and entry.filename == pkg.filename:
449-
blocked.append(pkg.filename)
522+
blocked.append(pkg)
450523
break
451524
if entry.name == pkg.name_normalized:
452525
if not entry.version or entry.version == pkg.version:
453-
blocked.append(pkg.filename)
526+
blocked.append(pkg)
454527
break
528+
return blocked
529+
530+
def check_blocklist_for_packages(self, packages):
531+
"""
532+
Raise a ValidationError if any of the given packages match a blocklist entry.
533+
"""
534+
blocked = self.find_blocklisted_packages(packages)
455535
if blocked:
456536
raise ValidationError(
457537
"Blocklisted packages cannot be added to this repository: {}".format(
458-
", ".join(blocked)
538+
", ".join(pkg.filename for pkg in blocked)
459539
)
460540
)
461541

542+
def _report_rejected_packages(self, details, message, code):
543+
"""
544+
Record skipped packages in a task progress report.
545+
"""
546+
suffix = "; ".join(details)
547+
log.info("%s: %s", message, suffix)
548+
with ProgressReport(
549+
message=message,
550+
code=code,
551+
total=len(details),
552+
suffix=suffix,
553+
) as pb:
554+
pb.increase_by(len(details))
555+
462556

463557
class PythonBlocklistEntry(BaseModel):
464558
"""

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
@@ -147,19 +147,21 @@ def modify(self, request, pk):
147147
"""
148148
Queues a task that creates a new RepositoryVersion by adding and removing content units.
149149
150-
If allow_package_substitution is False and the request is **only** adding packages, then a
151-
package substitution check is performed to provide a quicker error response. Otherwise, the
152-
check is delegated to the task.
150+
If allow_package_substitution is False, error_on_reject is True, and the request is
151+
**only** adding packages, then a package substitution check is performed to provide a
152+
quicker error response. Otherwise, the check is delegated to the task.
153153
154-
Also performs an early blocklist check on added packages.
154+
Also performs an early blocklist check on added packages when error_on_reject is True.
155+
When error_on_reject is False, rejected packages are skipped during task finalization.
155156
"""
156157
repository = self.get_object()
157158
add_content_units = request.data.get("add_content_units", [])
158159
content_ids = [extract_pk(x) for x in add_content_units]
159160

160-
self._early_blocklist_check(repository, content_ids)
161+
if repository.error_on_reject:
162+
self._early_blocklist_check(repository, content_ids)
161163

162-
if not repository.allow_package_substitution:
164+
if not repository.allow_package_substitution and repository.error_on_reject:
163165
remove_content_units = request.data.get("remove_content_units", [])
164166
if remove_content_units or "base_version" in request.data:
165167
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)