From 798907f187cdc2f80010960ac37f8ddd0a95bda2 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 11 Feb 2025 18:13:59 +0100
Subject: [PATCH] Perform config magic before extension loading

* previously, our configuration magic was performed as part of the
  `finalize_app` entrypoint
* while this worked well enough for most use cases, some extensions
  would break this model by caching values during their initialization
* especially caching-related extensions like to take eagerly create
  clients with the current configuration items, which are hard to fix
  afterwards
* keeping track of all the instantiated clients just to change their
  connection details when we perform our magic really didn't seem like
  a good approach
* so instead, we now made sure that our extension is loaded first and
  can thus perform its configuration magic before any other extension
  has a chance of caching values
* also, extension loading happens after the configuration loading is
  done (especially the handling of environment variables)
---
 invenio_config_tuw/config.py           | 39 ++++++++++++++++++++
 invenio_config_tuw/ext.py              | 20 ++++++++---
 invenio_config_tuw/startup/__init__.py |  3 +-
 invenio_config_tuw/startup/config.py   | 50 ++++++++++----------------
 pyproject.toml                         | 17 +++++----
 5 files changed, 84 insertions(+), 45 deletions(-)

diff --git a/invenio_config_tuw/config.py b/invenio_config_tuw/config.py
index 899edbc..c94d4ea 100644
--- a/invenio_config_tuw/config.py
+++ b/invenio_config_tuw/config.py
@@ -8,7 +8,10 @@
 """Invenio module containing some customizations and configuration for TU Wien."""
 
 from datetime import datetime
+from operator import attrgetter
 
+import invenio_base.app
+from importlib_metadata import entry_points as iter_entry_points
 from invenio_app_rdm.config import (
     NOTIFICATIONS_BACKENDS,
     NOTIFICATIONS_BUILDERS,
@@ -389,3 +392,39 @@ NOTIFICATIONS_BACKENDS = {
 }
 
 NOTIFICATIONS_SETTINGS_VIEW_FUNCTION = notification_settings
+
+
+def sorted_app_loader(app, entry_points=None, modules=None):
+    """Application extension loader that operates in lexicographic order.
+
+    This is useful for us, to ensure that our `Invenio-Config-TUW` extension
+    is loaded before any of the others.
+    This enables us to hook into the startup process after the configuration
+    loader is done (and, especially, has finished interpreting environment
+    variables that we use heavily for configuration), but before any other
+    Invenio extensions have been loaded (and potentially started caching values).
+    """
+
+    def init_func(ext):
+        ext(app)
+
+    if entry_points:
+        for entry_point in entry_points:
+            unique_eps = set(iter_entry_points(group=entry_point))
+            for ep in sorted(unique_eps, key=attrgetter("name")):
+                try:
+                    init_func(ep.load())
+                except Exception:
+                    app.logger.error(f"Failed to initialize entry point: {ep}")
+                    raise
+    if modules:
+        for m in modules:
+            try:
+                init_func(m)
+            except Exception:
+                app.logger.error(f"Failed to initialize module: {m}")
+                raise
+
+
+# override the app loader with our sorted variant
+invenio_base.app.app_loader = sorted_app_loader
diff --git a/invenio_config_tuw/ext.py b/invenio_config_tuw/ext.py
index 1f8d636..7ea1ad3 100644
--- a/invenio_config_tuw/ext.py
+++ b/invenio_config_tuw/ext.py
@@ -14,8 +14,12 @@ from flask_minify import Minify
 from flask_security.signals import user_registered
 from invenio_base.utils import obj_or_import_string
 
-from . import config
 from .auth.utils import auto_trust_user
+from .startup.config import (
+    assemble_and_populate_config,
+    override_flask_config,
+    override_prefixed_config,
+)
 
 
 @user_registered.connect
@@ -78,10 +82,16 @@ class InvenioConfigTUW(object):
             return response
 
     def init_config(self, app):
-        """Initialize configuration."""
-        for k in dir(config):
-            if len(k.replace("_", "")) >= 3 and k.isupper():
-                app.config.setdefault(k, getattr(config, k))
+        """Initialize configuration.
+
+        We use our comfortable position between the finalized initial loading of
+        configuration and the start of extension loading to perform a little bit
+        of magic on the configuration items, like building connection URIs from
+        their various pieces.
+        """
+        override_flask_config(app)
+        override_prefixed_config(app)
+        assemble_and_populate_config(app)
 
         # the datacenter symbol seems to be the username for DataCite Fabrica
         if app.config.get("DATACITE_ENABLED", False):
diff --git a/invenio_config_tuw/startup/__init__.py b/invenio_config_tuw/startup/__init__.py
index d25e9f3..8be7de3 100644
--- a/invenio_config_tuw/startup/__init__.py
+++ b/invenio_config_tuw/startup/__init__.py
@@ -14,7 +14,7 @@ initialized, and thus we can rely on them being already available.
 """
 
 
-from .config import finalize_config, patch_flask_create_url_adapter
+from .config import patch_flask_create_url_adapter
 from .misc import (
     customize_curation_request_type,
     override_search_drafts_options,
@@ -24,7 +24,6 @@ from .misc import (
 
 __all__ = (
     "customize_curation_request_type",
-    "finalize_config",
     "override_search_drafts_options",
     "patch_flask_create_url_adapter",
     "register_menu_entries",
diff --git a/invenio_config_tuw/startup/config.py b/invenio_config_tuw/startup/config.py
index 90781d8..03aa16d 100644
--- a/invenio_config_tuw/startup/config.py
+++ b/invenio_config_tuw/startup/config.py
@@ -124,8 +124,10 @@ def assemble_broker_uri_from_parts(app):
     else:
         broker_url = "amqp://guest:guest@localhost:5672/"
 
-    app.config.setdefault("BROKER_URL", broker_url)
-    app.config.setdefault("CELERY_BROKER_URL", broker_url)
+    # celery doesn't like having BROKER_HOST *and* the other values set
+    app.config.pop("BROKER_HOST", None)
+    app.config["BROKER_URL"] = broker_url
+    app.config["CELERY_BROKER_URL"] = broker_url
 
 
 def assemble_cache_uri_from_parts(app):
@@ -162,14 +164,15 @@ def assemble_cache_uri_from_parts(app):
     ratelimit_storage_url = _make_redis_url(ratelimit_storage_db)
     communities_identities_cache_url = _make_redis_url(communities_identities_db)
 
-    app.config.setdefault("CACHE_TYPE", "redis")
-    app.config.setdefault("CACHE_REDIS_URL", cache_redis_url)
-    app.config.setdefault("IIIF_CACHE_REDIS_URL", cache_redis_url)
-    app.config.setdefault("ACCOUNTS_SESSION_REDIS_URL", accounts_session_redis_url)
-    app.config.setdefault("CELERY_RESULT_BACKEND", celery_results_backend_url)
-    app.config.setdefault("RATELIMIT_STORAGE_URL", ratelimit_storage_url)
-    app.config.setdefault(
-        "COMMUNITIES_IDENTITIES_CACHE_REDIS_URL", communities_identities_cache_url
+    app.config["CACHE_TYPE"] = "redis"
+    app.config["CACHE_REDIS_URL"] = cache_redis_url
+    app.config["IIIF_CACHE_REDIS_URL"] = cache_redis_url
+    app.config["ACCOUNTS_SESSION_REDIS_URL"] = accounts_session_redis_url
+    app.config["CELERY_RESULT_BACKEND"] = celery_results_backend_url
+    app.config["RATELIMIT_STORAGE_URL"] = ratelimit_storage_url
+    app.config["RATELIMIT_STORAGE_URI"] = ratelimit_storage_url
+    app.config["COMMUNITIES_IDENTITIES_CACHE_REDIS_URL"] = (
+        communities_identities_cache_url
     )
 
 
@@ -188,7 +191,7 @@ def assemble_site_urls_from_parts(app):
     # note: 'invenio-cli run' likes to populate INVENIO_SITE_{UI,API}_URL...
     app.config["SITE_UI_URL"] = LocalProxy(partial(_make_site_url, ""))
     app.config["SITE_API_URL"] = LocalProxy(partial(_make_site_url, "/api"))
-    app.config.setdefault("OAISERVER_ID_PREFIX", hostname)
+    app.config["OAISERVER_ID_PREFIX"] = hostname
 
 
 def assemble_keycloak_config_from_parts(app):
@@ -201,8 +204,8 @@ def assemble_keycloak_config_from_parts(app):
             "consumer_key": consumer_key,
             "consumer_secret": consumer_secret,
         }
-        app.config.setdefault("OAUTHCLIENT_KEYCLOAK_APP_CREDENTIALS", app_credentials)
-        app.config.setdefault("KEYCLOAK_APP_CREDENTIALS", app_credentials)
+        app.config["OAUTHCLIENT_KEYCLOAK_APP_CREDENTIALS"] = app_credentials
+        app.config["KEYCLOAK_APP_CREDENTIALS"] = app_credentials
 
     base_url = app.config.get("OAUTHCLIENT_KEYCLOAK_BASE_URL")
     realm = app.config.get("OAUTHCLIENT_KEYCLOAK_REALM")
@@ -226,10 +229,8 @@ def assemble_keycloak_config_from_parts(app):
             **(app.config.get("OAUTHCLIENT_REMOTE_APPS") or {}),
             "keycloak": remote_app,
         }
-        app.config.setdefault("OAUTHCLIENT_KEYCLOAK_REALM_URL", helper.realm_url)
-        app.config.setdefault(
-            "OAUTHCLIENT_KEYCLOAK_USER_INFO_URL", helper.user_info_url
-        )
+        app.config["OAUTHCLIENT_KEYCLOAK_REALM_URL"] = helper.realm_url
+        app.config["OAUTHCLIENT_KEYCLOAK_USER_INFO_URL"] = helper.user_info_url
         app.config["OAUTHCLIENT_REMOTE_APPS"] = remote_apps
 
 
@@ -291,21 +292,6 @@ def override_flask_config(app):
     app.add_template_global(app.config, "config")
 
 
-def finalize_config(app):
-    """Finalize the application configuration with a few tweaks.
-
-    First, override the Flask configuration with the ``TUWConfig`` class.
-    Then, use prefixed configuration items to override non-prefixed ones
-    if a prefix is configured.
-    Lastly, assemble some configuration items from their parts (like building
-    the DB connection string from its pieces), and populate some unset
-    values like salts.
-    """
-    override_flask_config(app)
-    override_prefixed_config(app)
-    assemble_and_populate_config(app)
-
-
 def patch_flask_create_url_adapter(app):
     """Patch Flask's {host,subdomain}_matching with 3.1 behavior.
 
diff --git a/pyproject.toml b/pyproject.toml
index 6cb4e70..960d25b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,12 +66,18 @@ tests = [
 [project.urls]
 Repository = "https://gitlab.tuwien.ac.at/crdm/invenio-config-tuw"
 
-# entrypoints
+# Entrypoints
+# -----------
+# note: we omit the usual "invenio_*" prefix here, to be listed *before* the other
+#       invenio extensions in lexicographic ordering so that we can piece together
+#       the config items before they get picked up by the other extensions
+#       since lexicographic ordering is currently not the default however, this
+#       requires a hack performed in "invenio_config_tuw.config"
 [project.entry-points."invenio_base.apps"]
-invenio_config_tuw = "invenio_config_tuw:InvenioConfigTUW"
+config_tuw = "invenio_config_tuw:InvenioConfigTUW"
 
 [project.entry-points."invenio_base.api_apps"]
-invenio_config_tuw = "invenio_config_tuw:InvenioConfigTUW"
+config_tuw = "invenio_config_tuw:InvenioConfigTUW"
 
 [project.entry-points."invenio_base.blueprints"]
 invenio_config_tuw_settings = "invenio_config_tuw.users.views:user_settings_blueprint"
@@ -81,14 +87,12 @@ invenio_config_tuw_mail_handler = "invenio_config_tuw.startup:register_smtp_erro
 invenio_config_tuw_search_drafts = "invenio_config_tuw.startup:override_search_drafts_options"
 invenio_config_tuw_curation_settings = "invenio_config_tuw.startup:register_menu_entries"
 invenio_config_tuw_curation_request = "invenio_config_tuw.startup:customize_curation_request_type"
-invenio_config_tuw_finalize_config = "invenio_config_tuw.startup:finalize_config"
 invenio_config_tuw_patch_flask = "invenio_config_tuw.startup:patch_flask_create_url_adapter"
 
 [project.entry-points."invenio_base.api_finalize_app"]
 invenio_config_tuw_mail_handler = "invenio_config_tuw.startup:register_smtp_error_handler"
 invenio_config_tuw_search_drafts = "invenio_config_tuw.startup:override_search_drafts_options"
 invenio_config_tuw_curation_request = "invenio_config_tuw.startup:customize_curation_request_type"
-invenio_config_tuw_finalize_config = "invenio_config_tuw.startup:finalize_config"
 invenio_config_tuw_patch_flask = "invenio_config_tuw.startup:patch_flask_create_url_adapter"
 
 [project.entry-points."invenio_i18n.translations"]
@@ -106,7 +110,8 @@ invenio_config_tuw_curations = "invenio_config_tuw.curations.tasks"
 # (which could be done in the ext.init_config(app) method)
 zzz_invenio_config_tuw = "invenio_config_tuw.config"
 
-# configuration for tools
+# Configuration for tools
+# -----------------------
 [tool.isort]
 profile = "black"
 
-- 
GitLab