diff --git a/CHANGES/8303.misc b/CHANGES/8303.misc new file mode 100644 index 0000000000..f9a269bfcd --- /dev/null +++ b/CHANGES/8303.misc @@ -0,0 +1 @@ +Moved the domain default content-guard into a dedicated table, preparing for domains stored in separate databases. diff --git a/CHANGES/plugin_api/8303.removal b/CHANGES/plugin_api/8303.removal new file mode 100644 index 0000000000..87c5f3eb4a --- /dev/null +++ b/CHANGES/plugin_api/8303.removal @@ -0,0 +1 @@ +`Domain.default_content_guard` is now a read-only property backed by the new `DomainDefaultContentGuard` model; use `Domain.set_default_content_guard()` to change it. The `default_content_guard_id` attribute was removed. diff --git a/pulpcore/app/migrations/0162_domaindefaultcontentguard.py b/pulpcore/app/migrations/0162_domaindefaultcontentguard.py new file mode 100644 index 0000000000..61c484b74b --- /dev/null +++ b/pulpcore/app/migrations/0162_domaindefaultcontentguard.py @@ -0,0 +1,80 @@ +# Generated by Django 5.2.19 on 2026-11-05 18:56 + +import django.db.models.deletion +import django_lifecycle.mixins +import pulpcore.app.models.base +import pulpcore.app.util +from django.db import migrations, models + + +def move_default_content_guards(apps, schema_editor): + Domain = apps.get_model("core", "Domain") + DomainDefaultContentGuard = apps.get_model("core", "DomainDefaultContentGuard") + DomainDefaultContentGuard.objects.bulk_create( + DomainDefaultContentGuard(pulp_domain_id=domain_id, content_guard_id=content_guard_id) + for domain_id, content_guard_id in Domain.objects.filter( + default_content_guard__isnull=False + ).values_list("pk", "default_content_guard_id") + ) + + +def restore_default_content_guards(apps, schema_editor): + Domain = apps.get_model("core", "Domain") + DomainDefaultContentGuard = apps.get_model("core", "DomainDefaultContentGuard") + for mapping in DomainDefaultContentGuard.objects.all(): + Domain.objects.filter(pk=mapping.pulp_domain_id).update( + default_content_guard_id=mapping.content_guard_id + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0161_upstreampulp_remote_policy"), + ] + + operations = [ + migrations.CreateModel( + name="DomainDefaultContentGuard", + fields=[ + ( + "pulp_id", + models.UUIDField( + default=pulpcore.app.models.base.pulp_uuid, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("pulp_created", models.DateTimeField(auto_now_add=True)), + ("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "content_guard", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="core.contentguard", + ), + ), + ( + "pulp_domain", + models.OneToOneField( + default=pulpcore.app.util.get_domain_pk, + on_delete=django.db.models.deletion.CASCADE, + related_name="default_content_guard_mapping", + to="core.domain", + ), + ), + ], + options={ + "abstract": False, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + migrations.RunPython( + move_default_content_guards, restore_default_content_guards, elidable=True + ), + migrations.RemoveField( + model_name="domain", + name="default_content_guard", + ), + ] diff --git a/pulpcore/app/models/__init__.py b/pulpcore/app/models/__init__.py index e800a5c576..37b4de3737 100644 --- a/pulpcore/app/models/__init__.py +++ b/pulpcore/app/models/__init__.py @@ -15,7 +15,7 @@ Group, ) -from .domain import Domain +from .domain import Domain, DomainDefaultContentGuard from .acs import AlternateContentSource, AlternateContentSourcePath @@ -117,6 +117,7 @@ "AutoAddObjPermsMixin", "Group", "Domain", + "DomainDefaultContentGuard", "AlternateContentSource", "AlternateContentSourcePath", "Artifact", diff --git a/pulpcore/app/models/domain.py b/pulpcore/app/models/domain.py index e8e22eb76d..7762b337a9 100644 --- a/pulpcore/app/models/domain.py +++ b/pulpcore/app/models/domain.py @@ -4,6 +4,7 @@ from django_lifecycle import BEFORE_DELETE, BEFORE_UPDATE, hook from pulpcore.app.models import AutoAddObjPermsMixin, BaseModel +from pulpcore.app.util import get_domain_pk from pulpcore.exceptions import DomainProtectedError from .fields import EncryptedJSONField @@ -32,10 +33,6 @@ class Domain(BaseModel, AutoAddObjPermsMixin): storage_settings (EncryptedJSONField): Settings needed to configure storage backend redirect_to_object_storage (models.BooleanField): Redirect to object storage in content app hide_guarded_distributions (models.BooleanField): Hide guarded distributions in content app - - Relations: - default_content_guard (models.ForeignKey): An optional content-guard automatically - assigned to new distributions created within this domain. """ name = models.SlugField(null=False, unique=True) @@ -47,10 +44,24 @@ class Domain(BaseModel, AutoAddObjPermsMixin): # Pulp settings that are appropriate to be set on a "per domain" level redirect_to_object_storage = models.BooleanField(default=True) hide_guarded_distributions = models.BooleanField(default=False) - # related_name="+" suppresses the reverse accessor; use ContentGuard.pulp_domain to traverse. - default_content_guard = models.ForeignKey( - "ContentGuard", null=True, on_delete=models.SET_NULL, related_name="+" - ) + + @property + def default_content_guard(self): + """The content-guard automatically assigned to new distributions in this domain.""" + try: + return self.default_content_guard_mapping.content_guard + except DomainDefaultContentGuard.DoesNotExist: + return None + + def set_default_content_guard(self, content_guard): + """Set, replace, or clear (with `None`) this domain's default content-guard.""" + if content_guard is None: + DomainDefaultContentGuard.objects.filter(pulp_domain=self).delete() + else: + DomainDefaultContentGuard.objects.update_or_create( + pulp_domain=self, defaults={"content_guard": content_guard} + ) + self._state.fields_cache.pop("default_content_guard_mapping", None) def get_storage(self): """Returns this domain's instantiated storage class.""" @@ -87,3 +98,24 @@ class Meta: permissions = [ ("manage_roles_domain", "Can manage role assignments on domain"), ] + + +class DomainDefaultContentGuard(BaseModel): + """ + The default content-guard of a Domain. + + Kept out of the Domain table so the relation lives next to the content-guard it references. + + Relations: + pulp_domain (models.OneToOneField): The domain this default applies to. + content_guard (models.ForeignKey): The content-guard automatically assigned to new + distributions created within the domain. + """ + + pulp_domain = models.OneToOneField( + Domain, + default=get_domain_pk, + on_delete=models.CASCADE, + related_name="default_content_guard_mapping", + ) + content_guard = models.ForeignKey("ContentGuard", on_delete=models.CASCADE, related_name="+") diff --git a/pulpcore/app/models/publication.py b/pulpcore/app/models/publication.py index 8c9535d316..8d3aab4a2c 100644 --- a/pulpcore/app/models/publication.py +++ b/pulpcore/app/models/publication.py @@ -30,6 +30,7 @@ from .base import BaseModel, MasterModel from .content import Artifact, Content, ContentArtifact +from .domain import DomainDefaultContentGuard from .repository import Remote, Repository, RepositoryVersion from .task import CreatedResource @@ -917,9 +918,11 @@ def _set_default_content_guard(self): explicitly provided `content_guard` always takes precedence. """ if self.content_guard_id is None: - default_content_guard_id = self.pulp_domain.default_content_guard_id - if default_content_guard_id is not None: - self.content_guard_id = default_content_guard_id + self.content_guard_id = ( + DomainDefaultContentGuard.objects.filter(pulp_domain_id=self.pulp_domain_id) + .values_list("content_guard_id", flat=True) + .first() + ) @hook(AFTER_CREATE) @hook( diff --git a/pulpcore/app/serializers/domain.py b/pulpcore/app/serializers/domain.py index dd15291e00..4d72903f00 100644 --- a/pulpcore/app/serializers/domain.py +++ b/pulpcore/app/serializers/domain.py @@ -3,6 +3,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.db import transaction from django.utils.module_loading import import_string from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -479,6 +480,20 @@ def validate(self, data): self._validate_storage_backend(storage_class, storage_settings) return data + def create(self, validated_data): + # validate() rejects a non-null default content-guard on create. + validated_data.pop("default_content_guard", None) + return super().create(validated_data) + + def update(self, instance, validated_data): + unset = object() + default_content_guard = validated_data.pop("default_content_guard", unset) + with transaction.atomic(): + instance = super().update(instance, validated_data) + if default_content_guard is not unset: + instance.set_default_content_guard(default_content_guard) + return instance + class Meta: model = Domain fields = ModelSerializer.Meta.fields + ( diff --git a/pulpcore/app/viewsets/domain.py b/pulpcore/app/viewsets/domain.py index 677b2bf296..759b7ecaf2 100644 --- a/pulpcore/app/viewsets/domain.py +++ b/pulpcore/app/viewsets/domain.py @@ -105,7 +105,7 @@ class DomainViewSet( def get_queryset(self): """Prefetch the default content guard to avoid N+1 queries.""" qs = super().get_queryset() - return qs.select_related("default_content_guard") + return qs.select_related("default_content_guard_mapping__content_guard") @extend_schema( description="Trigger an asynchronous update task", diff --git a/pulpcore/tests/functional/api/test_crud_domains.py b/pulpcore/tests/functional/api/test_crud_domains.py index 2b427205f6..95b0bf6f76 100644 --- a/pulpcore/tests/functional/api/test_crud_domains.py +++ b/pulpcore/tests/functional/api/test_crud_domains.py @@ -365,6 +365,17 @@ def test_domain_default_content_guard( assert domain.default_content_guard is None assert domain.default_content_guard_prn is None + # Deleting the default content guard clears it from the domain + response = pulpcore_bindings.DomainsApi.partial_update( + domain.pulp_href, {"default_content_guard": guard.pulp_href} + ) + monitor_task(response.task) + pulpcore_bindings.ContentguardsRbacApi.delete(guard.pulp_href) + guard = None + domain = pulpcore_bindings.DomainsApi.read(domain.pulp_href) + assert domain.default_content_guard is None + assert domain.default_content_guard_prn is None + finally: # ContentGuard.pulp_domain is PROTECT; delete guard before domain. if guard is not None: diff --git a/pulpcore/tests/unit/models/test_domain_default_content_guard.py b/pulpcore/tests/unit/models/test_domain_default_content_guard.py new file mode 100644 index 0000000000..46e26b281e --- /dev/null +++ b/pulpcore/tests/unit/models/test_domain_default_content_guard.py @@ -0,0 +1,73 @@ +from uuid import uuid4 + +import pytest + +from pulpcore.app.models import Distribution, Domain, DomainDefaultContentGuard, RBACContentGuard +from pulpcore.app.serializers import DomainSerializer + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def domain(): + return Domain.objects.create( + name=str(uuid4()), storage_class="pulpcore.app.models.storage.FileSystem" + ) + + +@pytest.fixture +def guard(domain): + return RBACContentGuard.objects.create(name=str(uuid4()), pulp_domain=domain) + + +def test_set_replace_and_clear(domain, guard): + assert domain.default_content_guard is None + + domain.set_default_content_guard(guard) + assert domain.default_content_guard.pk == guard.pk + assert Domain.objects.get(pk=domain.pk).default_content_guard.pk == guard.pk + + other = RBACContentGuard.objects.create(name=str(uuid4()), pulp_domain=domain) + domain.set_default_content_guard(other) + assert domain.default_content_guard.pk == other.pk + assert DomainDefaultContentGuard.objects.filter(pulp_domain=domain).count() == 1 + + domain.set_default_content_guard(None) + assert domain.default_content_guard is None + assert not DomainDefaultContentGuard.objects.filter(pulp_domain=domain).exists() + + +def test_deleting_guard_clears_default(domain, guard): + domain.set_default_content_guard(guard) + guard.delete() + assert Domain.objects.get(pk=domain.pk).default_content_guard is None + + +def test_distribution_uses_default_guard(domain, guard): + domain.set_default_content_guard(guard) + explicit = RBACContentGuard.objects.create(name=str(uuid4()), pulp_domain=domain) + + inherited = Distribution.objects.create(name="a", base_path="a", pulp_domain=domain) + assert inherited.content_guard_id == guard.pk + chosen = Distribution.objects.create( + name="b", base_path="b", pulp_domain=domain, content_guard=explicit + ) + assert chosen.content_guard_id == explicit.pk + + +def test_distribution_without_default_guard(domain): + distribution = Distribution.objects.create(name="a", base_path="a", pulp_domain=domain) + assert distribution.content_guard_id is None + + +def test_serializer_update(domain, guard): + serializer = DomainSerializer() + serializer.update(domain, {"default_content_guard": guard}) + assert Domain.objects.get(pk=domain.pk).default_content_guard.pk == guard.pk + + # Updates that omit the field leave the default untouched. + serializer.update(domain, {"description": "changed"}) + assert Domain.objects.get(pk=domain.pk).default_content_guard.pk == guard.pk + + serializer.update(domain, {"default_content_guard": None}) + assert Domain.objects.get(pk=domain.pk).default_content_guard is None