Repository: pulp/pulpcore

Issue:
AttributeError: 'Settings' object has no attribute 'CONTENT_ORIGIN'
Author: daviddavis (daviddavis)


Redmine Issue: 8235, https://pulp.plan.io/issues/8235

---

Reproducer:

1. Have pulpcore and pulp_ansible installed
2. Unset `CONTENT_ORIGIN` in your settings file.

Observe that you get an ugly AttributeError instead of [the user friendly error that pulpcore raises](https://github.com/pulp/pulpcore/blob/f8a8c64bb28cbe3908720ea56f417312a4389862/pulpcore/app/settings.py#L279-L281). 

This is caused because pulpcore loads the plugin settings before checking if CONTENT_ORIGIN is defined.





Discussion:

Resolving patch:
diff --git a/pulpcore/app/settings.py b/pulpcore/app/settings.py
--- a/pulpcore/app/settings.py
+++ b/pulpcore/app/settings.py
@@ -297,9 +297,62 @@
 
 # HERE STARTS DYNACONF EXTENSION LOAD (Keep at the very bottom of settings.py)
 # Read more at https://dynaconf.readthedocs.io/en/latest/guides/django.html
-import dynaconf  # noqa
+from dynaconf import DjangoDynaconf, Validator  # noqa
+
+# Validators
+content_origin_validator = Validator(
+    "CONTENT_ORIGIN",
+    must_exist=True,
+    messages={
+        "must_exist_true": _(
+            "CONTENT_ORIGIN is a required setting but it was not configured. This may be caused "
+            "by invalid read permissions of the settings file. Note that CONTENT_ORIGIN is set by "
+            "the installer automatically."
+        )
+    },
+)
+
+cache_enabled_validator = Validator("CACHE_ENABLED", eq=True)
+redis_url_validator = Validator("REDIS_URL", must_exist=True, when=cache_enabled_validator)
+redis_host_validator = Validator("REDIS_HOST", must_exist=True, when=cache_enabled_validator)
+redis_port_validator = Validator("REDIS_PORT", must_exist=True, when=cache_enabled_validator)
+cache_validator = redis_url_validator | (redis_host_validator & redis_port_validator)
+cache_validator.messages["combined"] = _(
+    "CACHE_ENABLED is enabled but it requires to have REDIS configured. Please check "
+    "https://docs.pulpproject.org/pulpcore/configuration/settings.html#redis-settings "
+    "for more information."
+)
+
+sha256_validator = Validator(
+    "ALLOWED_CONTENT_CHECKSUMS",
+    cont="sha256",
+    messages={
+        "operations": "ALLOWED_CONTENT_CHECKSUMS MUST contain 'sha256' - Pulp's "
+        "content addressable storage relies on sha256 to identify entities."
+    },
+)
+
+unknown_algs_validator = Validator(
+    "ALLOWED_CONTENT_CHECKSUMS",
+    condition=lambda x: len(set(x).difference(constants.ALL_KNOWN_CONTENT_CHECKSUMS)) == 0,
+    messages={
+        "condition": _(
+            "ALLOWED_CONTENT_CHECKSUMS may only contain algorithms known to pulp - see "
+            "constants.ALL_KNOWN_CONTENT_CHECKSUMS for the allowed list."
+        )
+    },
+)
+
+api_root_validator = Validator(
+    "API_ROOT",
+    condition=lambda x: x.startswith("/") and x.endswith("/"),
+    messages={
+        "condition": _("The API_ROOT must start and end with a '/', currently it is '{value}'")
+    },
+)
+
 
-settings = dynaconf.DjangoDynaconf(
+settings = DjangoDynaconf(
     __name__,
     GLOBAL_ENV_FOR_DYNACONF="PULP",
     ENV_SWITCHER_FOR_DYNACONF="PULP_ENV",
@@ -308,34 +361,18 @@
     ],
     ENVVAR_FOR_DYNACONF="PULP_SETTINGS",
     load_dotenv=False,
+    validators=[
+        content_origin_validator,
+        cache_validator,
+        sha256_validator,
+        unknown_algs_validator,
+        api_root_validator,
+    ],
 )
 # HERE ENDS DYNACONF EXTENSION LOAD (No more code below this line)
 
 _logger = getLogger(__name__)
 
-# Post-dyna-conf check if user provided a redis connection when enabling cache
-try:
-    if CACHE_ENABLED:
-        REDIS_URL or (REDIS_HOST and REDIS_PORT)
-except NameError:
-    raise ImproperlyConfigured(
-        _(
-            "CACHE_ENABLED is enabled but it requires to have REDIS configured. Please check "
-            "https://docs.pulpproject.org/pulpcore/configuration/settings.html#redis-settings "
-            "for more information."
-        )
-    )
-
-try:
-    CONTENT_ORIGIN
-except NameError:
-    raise ImproperlyConfigured(
-        _(
-            "CONTENT_ORIGIN is a required setting but it was not configured. This may be caused "
-            "by invalid read permissions of the settings file. Note that CONTENT_ORIGIN is set by "
-            "the installer automatically."
-        )
-    )
 
 if not (
     Path(sys.argv[0]).name == "sphinx-build"
@@ -351,25 +388,6 @@
             )
         )
 
-# Check legality of ALLOWED_CONTENT_CHECKSUMS post-dynaconf-load, in case it has been overridden
-# in a site-specific location (eg, in /etc/pulp/settings.py)
-if "sha256" not in ALLOWED_CONTENT_CHECKSUMS:
-    raise ImproperlyConfigured(
-        _(
-            "ALLOWED_CONTENT_CHECKSUMS MUST contain 'sha256' - Pulp's content-storage-addressing "
-            "relies on sha256 to identify entities."
-        )
-    )
-
-unknown_algs = set(ALLOWED_CONTENT_CHECKSUMS).difference(constants.ALL_KNOWN_CONTENT_CHECKSUMS)
-if unknown_algs:
-    raise ImproperlyConfigured(
-        _(
-            "ALLOWED_CONTENT_CHECKSUMS may only contain algorithms known to pulp - see "
-            "constants.ALL_KNOWN_CONTENT_CHECKSUMS for the allowed list. Unknown algorithms "
-            "provided: {}".format(unknown_algs)
-        )
-    )
 
 FORBIDDEN_CHECKSUMS = set(constants.ALL_KNOWN_CONTENT_CHECKSUMS).difference(
     ALLOWED_CONTENT_CHECKSUMS
@@ -433,15 +451,6 @@
     finally:
         connection.close()
 
-
-if not API_ROOT.startswith("/"):
-    i8ln_msg = _("The API_ROOT must start with a '/', currently it is '{API_ROOT}'")
-    raise ImproperlyConfigured(i8ln_msg.format(API_ROOT=API_ROOT))
-
-if not API_ROOT.endswith("/"):
-    i8ln_msg = _("The API_ROOT must end with a '/', currently it is '{API_ROOT}'")
-    raise ImproperlyConfigured(i8ln_msg.format(API_ROOT=API_ROOT))
-
 settings.set("V3_API_ROOT", settings.API_ROOT + "api/v3/")  # Not user configurable
 settings.set(
     "V3_API_ROOT_NO_FRONT_SLASH", settings.V3_API_ROOT.lstrip("/")


Test patch:
diff --git a/pulpcore/tests/unit/test_settings.py b/pulpcore/tests/unit/test_settings.py
new file mode 100644
--- /dev/null
+++ b/pulpcore/tests/unit/test_settings.py
@@ -0,0 +1,59 @@
+from django.test import TestCase
+from django.conf import settings
+from dynaconf.validator import ValidationError
+
+
+class SettingsTestCase(TestCase):
+    def test_content_origin(self):
+        """Test validation error is raised when CONTENT_ORIGIN is missing."""
+        # See https://github.com/rochacbruno/dynaconf/issues/731
+        # keep needs to be True in order to copy all the current settings already initialized
+        msettings = settings.from_env("development", keep=True, validators=settings.validators)
+        # force needs to be True in order to remove CONTENT_ORIGIN since keep makes it a default
+        msettings.unset("CONTENT_ORIGIN", force=True)
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()
+
+    def test_cache_enabled(self):
+        """Test that when CACHE_ENABLED is set REDIS_URL or REDIS_HOST & REDIS_PORT."""
+        msettings = settings.from_env("development", keep=True, validators=settings.validators)
+        msettings.set("CACHE_ENABLED", True)
+        msettings.unset("REDIS_URL", force=True)
+        msettings.unset("REDIS_HOST", force=True)
+        msettings.unset("REDIS_PORT", force=True)
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()
+
+        msettings.set("REDIS_HOST", "localhost")
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()
+
+        msettings.unset("REDIS_HOST", force=True)
+        msettings.set("REDIS_PORT", 8000)
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()
+
+    def test_allowed_content_checksums(self):
+        """Test that removing 'sha256' from ALLOWED_CONTENT_CHECKSUMS raises ValidationError."""
+        msettings = settings.from_env("development", keep=True, validators=settings.validators)
+        msettings.set("ALLOWED_CONTENT_CHECKSUMS", ["sha224", "sha512"])
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()
+
+    def test_unknown_content_checksums(self):
+        """Test that providing invalid checksum for ALLOWED_CONTENT_CHECKSUMS fails."""
+        msettings = settings.from_env("development", keep=True, validators=settings.validators)
+        msettings.set("ALLOWED_CONTENT_CHECKSUMS", ["aaa"])
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()
+
+    def test_api_root(self):
+        """Test that API_ROOT validation checks for beginning and ending '/'."""
+        msettings = settings.from_env("development", keep=True, validators=settings.validators)
+        msettings.set("API_ROOT", "/hi/there")
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()
+
+        msettings.set("API_ROOT", "hi/there/")
+        with self.assertRaises(ValidationError):
+            msettings.validators.validate()