Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/8303.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Moved the domain default content-guard into a dedicated table, preparing for domains stored in separate databases.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/8303.removal
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 80 additions & 0 deletions pulpcore/app/migrations/0162_domaindefaultcontentguard.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
3 changes: 2 additions & 1 deletion pulpcore/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Group,
)

from .domain import Domain
from .domain import Domain, DomainDefaultContentGuard

from .acs import AlternateContentSource, AlternateContentSourcePath

Expand Down Expand Up @@ -117,6 +117,7 @@
"AutoAddObjPermsMixin",
"Group",
"Domain",
"DomainDefaultContentGuard",
"AlternateContentSource",
"AlternateContentSourcePath",
"Artifact",
Expand Down
48 changes: 40 additions & 8 deletions pulpcore/app/models/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand Down Expand Up @@ -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="+")
9 changes: 6 additions & 3 deletions pulpcore/app/models/publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions pulpcore/app/serializers/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 + (
Expand Down
2 changes: 1 addition & 1 deletion pulpcore/app/viewsets/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions pulpcore/tests/functional/api/test_crud_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions pulpcore/tests/unit/models/test_domain_default_content_guard.py
Original file line number Diff line number Diff line change
@@ -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
Loading