Skip to content

Commit 0521ec5

Browse files
dklibanclaude
andcommitted
feat: add default content guard auto-assignment for distributions
Add a default_content_guard field to the Domain model that is automatically assigned to new distributions created within the domain when they do not specify their own content guard. Use a composite content guard as the default to apply multiple guards. Closes: #7988 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff9930d commit 0521ec5

9 files changed

Lines changed: 239 additions & 2 deletions

File tree

CHANGES/7988.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a ``default_content_guard`` field to domains that is automatically assigned to new distributions created within the domain when they do not specify their own content-guard.

pulp_file/tests/functional/api/test_domains.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,54 @@ def test_no_cross_pollination(
370370
assert error["add_content_units"][0].startswith(
371371
f"Content units are not a part of the current domain {domain.name}: ["
372372
)
373+
374+
375+
@pytest.mark.parallel
376+
def test_distribution_default_content_guard_auto_assignment(
377+
pulpcore_bindings,
378+
file_bindings,
379+
gen_object_with_cleanup,
380+
monitor_task,
381+
):
382+
"""A distribution created in a domain inherits the domain's default_content_guard."""
383+
domain = gen_object_with_cleanup(
384+
pulpcore_bindings.DomainsApi,
385+
{
386+
"name": str(uuid.uuid4()),
387+
"storage_class": "pulpcore.app.models.storage.FileSystem",
388+
"storage_settings": {"MEDIA_ROOT": "/var/lib/pulp/media/"},
389+
},
390+
)
391+
domain_name = domain.name
392+
393+
# Create a content guard in the domain and set it as the domain default
394+
guard = gen_object_with_cleanup(
395+
pulpcore_bindings.ContentguardsRbacApi, {"name": str(uuid.uuid4())}, pulp_domain=domain_name
396+
)
397+
response = pulpcore_bindings.DomainsApi.partial_update(
398+
domain.pulp_href, {"default_content_guard": guard.pulp_href}
399+
)
400+
monitor_task(response.task)
401+
402+
# A distribution created WITHOUT a content guard inherits the domain default
403+
distro = gen_object_with_cleanup(
404+
file_bindings.DistributionsFileApi,
405+
{"name": str(uuid.uuid4()), "base_path": str(uuid.uuid4())},
406+
pulp_domain=domain_name,
407+
)
408+
assert distro.content_guard == guard.pulp_href
409+
410+
# A distribution created WITH an explicit content guard keeps its own
411+
other_guard = gen_object_with_cleanup(
412+
pulpcore_bindings.ContentguardsRbacApi, {"name": str(uuid.uuid4())}, pulp_domain=domain_name
413+
)
414+
distro_explicit = gen_object_with_cleanup(
415+
file_bindings.DistributionsFileApi,
416+
{
417+
"name": str(uuid.uuid4()),
418+
"base_path": str(uuid.uuid4()),
419+
"content_guard": other_guard.pulp_href,
420+
},
421+
pulp_domain=domain_name,
422+
)
423+
assert distro_explicit.content_guard == other_guard.pulp_href
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
dependencies = [
7+
("core", "0156_alter_contentartifact_relative_path_and_more"),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name="domain",
13+
name="default_content_guard",
14+
field=models.ForeignKey(
15+
null=True,
16+
on_delete=django.db.models.deletion.SET_NULL,
17+
related_name="+",
18+
to="core.contentguard",
19+
),
20+
),
21+
]

pulpcore/app/models/domain.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class Domain(BaseModel, AutoAddObjPermsMixin):
3232
storage_settings (EncryptedJSONField): Settings needed to configure storage backend
3333
redirect_to_object_storage (models.BooleanField): Redirect to object storage in content app
3434
hide_guarded_distributions (models.BooleanField): Hide guarded distributions in content app
35+
36+
Relations:
37+
default_content_guard (models.ForeignKey): An optional content-guard automatically
38+
assigned to new distributions created within this domain.
3539
"""
3640

3741
name = models.SlugField(null=False, unique=True)
@@ -43,6 +47,9 @@ class Domain(BaseModel, AutoAddObjPermsMixin):
4347
# Pulp settings that are appropriate to be set on a "per domain" level
4448
redirect_to_object_storage = models.BooleanField(default=True)
4549
hide_guarded_distributions = models.BooleanField(default=False)
50+
default_content_guard = models.ForeignKey(
51+
"ContentGuard", null=True, on_delete=models.SET_NULL, related_name="+"
52+
)
4653

4754
def get_storage(self):
4855
"""Returns this domain's instantiated storage class."""

pulpcore/app/models/publication.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from django.contrib.postgres.indexes import OpClass, SpGistIndex
1717
from django.db import DatabaseError, IntegrityError, models, transaction
1818
from django.utils import timezone
19-
from django_lifecycle import AFTER_CREATE, AFTER_UPDATE, BEFORE_DELETE, hook
19+
from django_lifecycle import AFTER_CREATE, AFTER_UPDATE, BEFORE_CREATE, BEFORE_DELETE, hook
2020
from rest_framework.exceptions import APIException
2121
from url_normalize import url_normalize
2222

@@ -792,6 +792,19 @@ def get_fallback_ca(self, path):
792792
return pa.content_artifact
793793
return None
794794

795+
@hook(BEFORE_CREATE)
796+
def _set_default_content_guard(self):
797+
"""Apply the domain's default content guard when none is explicitly set.
798+
799+
If the distribution is created without a ``content_guard`` and its domain has a
800+
``default_content_guard`` configured, that guard is assigned automatically. An
801+
explicitly provided ``content_guard`` always takes precedence.
802+
"""
803+
if self.content_guard_id is None:
804+
default_content_guard_id = self.pulp_domain.default_content_guard_id
805+
if default_content_guard_id is not None:
806+
self.content_guard_id = default_content_guard_id
807+
795808
@hook(AFTER_CREATE)
796809
@hook(
797810
AFTER_UPDATE,

pulpcore/app/serializers/domain.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
from rest_framework import serializers
1010
from rest_framework.validators import UniqueValidator
1111

12-
from pulpcore.app.models import Domain
12+
from pulpcore.app.models import ContentGuard, Domain
1313
from pulpcore.app.serializers import (
14+
DetailRelatedField,
1415
HiddenFieldsMixin,
1516
IdentityField,
1617
ModelSerializer,
1718
pulp_labels_validator,
1819
)
20+
from pulpcore.app.util import get_prn
1921

2022
BACKEND_CHOICES = (
2123
("pulpcore.app.models.storage.FileSystem", "Use local filesystem as storage"),
@@ -454,6 +456,23 @@ class DomainSerializer(BackendSettingsValidator, ModelSerializer):
454456
help_text=_("Boolean to hide distributions with a content guard in the content app."),
455457
default=False,
456458
)
459+
default_content_guard = DetailRelatedField(
460+
required=False,
461+
allow_null=True,
462+
help_text=_(
463+
"An optional content-guard that is automatically assigned to new distributions "
464+
"created within this domain when they do not specify their own content-guard. To "
465+
"apply multiple guards by default, use a composite content-guard."
466+
),
467+
view_name_pattern=r"contentguards(-.*/.*)?-detail",
468+
queryset=ContentGuard.objects.all(),
469+
)
470+
default_content_guard_prn = serializers.SerializerMethodField(
471+
help_text=_("The Pulp Resource Name (PRN) of the domain's default content-guard."),
472+
)
473+
474+
def get_default_content_guard_prn(self, obj):
475+
return get_prn(obj.default_content_guard) if obj.default_content_guard else None
457476

458477
def validate_name(self, value):
459478
"""Ensure name is not 'api' or 'content'."""
@@ -463,6 +482,26 @@ def validate_name(self, value):
463482

464483
def validate(self, data):
465484
"""Ensure that Domain settings are valid."""
485+
# A default content-guard must live in the same domain it is being assigned to.
486+
# Checked before the "default" domain short-circuit so it is never silently skipped.
487+
default_content_guard = data.get("default_content_guard")
488+
if default_content_guard is not None:
489+
if self.instance is None:
490+
raise serializers.ValidationError(
491+
detail={
492+
"default_content_guard": _(
493+
"A default content-guard can only be set on an existing domain. "
494+
"Create the domain first, then set this field with an update."
495+
)
496+
}
497+
)
498+
if default_content_guard.pulp_domain_id != self.instance.pulp_id:
499+
raise serializers.ValidationError(
500+
detail={
501+
"default_content_guard": _("The content-guard must belong to this domain.")
502+
}
503+
)
504+
466505
# Validate for update gets called before ViewSet default check
467506
if self.instance and self.instance.name == "default":
468507
return data
@@ -495,6 +534,8 @@ class Meta:
495534
"storage_settings",
496535
"redirect_to_object_storage",
497536
"hide_guarded_distributions",
537+
"default_content_guard",
538+
"default_content_guard_prn",
498539
)
499540

500541

pulpcore/app/viewsets/domain.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ class DomainViewSet(
108108
"core.domain_viewer": ["core.view_domain"],
109109
}
110110

111+
def get_queryset(self):
112+
"""Prefetch the default content guard to avoid N+1 queries on the list endpoint."""
113+
qs = super().get_queryset()
114+
if getattr(self, "action", "") == "list":
115+
qs = qs.select_related("default_content_guard")
116+
return qs
117+
111118
@extend_schema(
112119
description="Trigger an asynchronous update task",
113120
responses={200: DomainSerializer, 202: AsyncOperationResponseSerializer},

pulpcore/tests/functional/api/test_crud_domains.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,61 @@ def test_special_domain_creation(pulpcore_bindings, gen_object_with_cleanup, pul
323323
assert random_name not in domain.pulp_href
324324

325325

326+
@pytest.mark.parallel
327+
def test_domain_default_content_guard(pulpcore_bindings, monitor_task, pulp_settings):
328+
"""Set, read, clear, and reject cross-domain values for a domain's default_content_guard."""
329+
if not pulp_settings.DOMAIN_ENABLED:
330+
pytest.skip("Domains not enabled")
331+
name = str(uuid.uuid4())
332+
body = {
333+
"name": name,
334+
"storage_class": "pulpcore.app.models.storage.FileSystem",
335+
"storage_settings": {"MEDIA_ROOT": ""},
336+
}
337+
domain = pulpcore_bindings.DomainsApi.create(body)
338+
try:
339+
# A new domain has no default content guard
340+
assert domain.default_content_guard is None
341+
assert domain.default_content_guard_prn is None
342+
343+
# Create a content guard within the domain and set it as the default
344+
guard = pulpcore_bindings.ContentguardsRbacApi.create({"name": name}, pulp_domain=name)
345+
response = pulpcore_bindings.DomainsApi.partial_update(
346+
domain.pulp_href, {"default_content_guard": guard.pulp_href}
347+
)
348+
monitor_task(response.task)
349+
350+
domain = pulpcore_bindings.DomainsApi.read(domain.pulp_href)
351+
assert domain.default_content_guard == guard.pulp_href
352+
assert domain.default_content_guard_prn is not None
353+
354+
# A content guard from a different domain (default) is rejected
355+
other_guard = pulpcore_bindings.ContentguardsRbacApi.create({"name": str(uuid.uuid4())})
356+
try:
357+
with pytest.raises(ApiException) as e:
358+
pulpcore_bindings.DomainsApi.partial_update(
359+
domain.pulp_href, {"default_content_guard": other_guard.pulp_href}
360+
)
361+
assert e.value.status == 400
362+
assert "default_content_guard" in e.value.body
363+
finally:
364+
pulpcore_bindings.ContentguardsRbacApi.delete(other_guard.pulp_href)
365+
366+
# Clear the default content guard
367+
response = pulpcore_bindings.DomainsApi.partial_update(
368+
domain.pulp_href, {"default_content_guard": None}
369+
)
370+
monitor_task(response.task)
371+
domain = pulpcore_bindings.DomainsApi.read(domain.pulp_href)
372+
assert domain.default_content_guard is None
373+
assert domain.default_content_guard_prn is None
374+
375+
pulpcore_bindings.ContentguardsRbacApi.delete(guard.pulp_href)
376+
finally:
377+
response = pulpcore_bindings.DomainsApi.delete(domain.pulp_href)
378+
monitor_task(response.task)
379+
380+
326381
@pytest.mark.parallel
327382
def test_filter_domains_by_label(pulpcore_bindings, domain_factory):
328383
"""Test filtering domains by label."""

pulpcore/tests/unit/serializers/test_domain.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,47 @@ def test_cloudfront_s3_storage_settings(storage_class, required_settings):
159159
assert serializer.is_valid(raise_exception=True)
160160

161161

162+
DOMAIN_ID = "00000000-0000-0000-0000-000000000001"
163+
OTHER_DOMAIN_ID = "00000000-0000-0000-0000-000000000002"
164+
165+
166+
def test_default_content_guard_cross_domain_rejected():
167+
"""A content-guard from another domain cannot be a domain's default_content_guard."""
168+
domain = SimpleNamespace(pulp_id=DOMAIN_ID, name="doma")
169+
other_domain_guard = SimpleNamespace(pulp_domain_id=OTHER_DOMAIN_ID)
170+
serializer = DomainSerializer(instance=domain)
171+
172+
with pytest.raises(serializers.ValidationError) as exc_info:
173+
serializer.validate({"default_content_guard": other_domain_guard})
174+
assert "default_content_guard" in str(exc_info.value)
175+
176+
177+
def test_default_content_guard_rejected_on_create():
178+
"""default_content_guard cannot be set while creating a domain (no instance yet)."""
179+
guard = SimpleNamespace(pulp_domain_id=OTHER_DOMAIN_ID)
180+
serializer = DomainSerializer(data={})
181+
182+
with pytest.raises(serializers.ValidationError) as exc_info:
183+
serializer.validate({"default_content_guard": guard})
184+
assert "existing domain" in str(exc_info.value)
185+
186+
187+
def test_default_content_guard_same_domain_accepted():
188+
"""A content-guard from the same domain passes validation."""
189+
domain = SimpleNamespace(
190+
pulp_id=DOMAIN_ID,
191+
name="doma",
192+
storage_class="pulpcore.app.models.storage.FileSystem",
193+
storage_settings={"location": "/var/lib/pulp/media/"},
194+
redirect_to_object_storage=True,
195+
)
196+
guard = SimpleNamespace(pulp_domain_id=DOMAIN_ID)
197+
serializer = DomainSerializer(instance=domain)
198+
199+
# Should not raise (storage backend check is monkeypatched out by the autouse fixture).
200+
serializer.validate({"default_content_guard": guard})
201+
202+
162203
class DomainSettingsBaseMixin:
163204
storage_class = None
164205
serializer_class = None

0 commit comments

Comments
 (0)