feat: add django-aqueduct typed settings for edx-platform K8s deployments#39
feat: add django-aqueduct typed settings for edx-platform K8s deployments#39blarghmatey wants to merge 4 commits into
Conversation
…ents Introduces a pydantic-settings based configuration layer for edx-platform that replaces the legacy LMS_CFG/STUDIO_CFG YAML file loading with a structured, typed approach. Layout injected into the edx-platform image by the Dagger build: lms/envs/models/base.py - BaseProductionSettings (shared validators) lms/envs/models/aqueduct.py - generated AqueductSettings pydantic model lms/envs/aqueduct.py - settings module (DJANGO_SETTINGS_MODULE target) cms/envs/models/base.py - BaseProductionSettings (shared) cms/envs/models/aqueduct.py - generated AqueductSettings pydantic model cms/envs/aqueduct.py - settings module Settings are loaded in priority order: 1. Env vars (flat scalars from K8s envFrom ConfigMaps/Secrets) 2. YAML files under OL_SETTINGS_DIR (complex types: DATABASES, CACHES, etc.) 3. AqueductSettings field defaults (typed snapshot of common.py) Adds django-aqueduct==0.3.0 and upgrades pydantic-settings to the [yaml] extra across all deployment/release pip package lists. Excludes settings/ and src/lehrer/ from mypy: settings/ files import pydantic, django_aqueduct, and openedx.* which only resolve inside the edx-platform container at runtime; src/lehrer/ uses dagger which is not in lehrer's own dev venv.
There was a problem hiding this comment.
Code Review
This pull request introduces django-aqueduct settings integration for both LMS and CMS, adding shared base production settings (BaseProductionSettings) and service-specific entry points using pydantic-settings to load configuration from environment variables and YAML files. It also configures pre-commit hooks, updates pip package lists with django-aqueduct and pydantic-settings[yaml], and updates the Lehrer build process to inject these settings. Feedback on the changes suggests conditionally loading YamlConfigSettingsSource only when YAML files exist to prevent runtime errors, using getattr and setattr to safely access dynamically resolved settings fields across the settings models, and updating outdated paths in the docstring of settings/models/base.py.
| @classmethod | ||
| def settings_customise_sources( | ||
| cls, | ||
| settings_cls, | ||
| init_settings, | ||
| env_settings, | ||
| dotenv_settings, | ||
| file_secret_settings, | ||
| ): | ||
| """Two-tier loading: env vars for flat scalars, YAML for complex types. | ||
|
|
||
| Priority (first wins): | ||
|
|
||
| 1. Environment variables — K8s injects flat scalars from ConfigMap and | ||
| Secret ``envFrom`` refs directly into the process environment. | ||
| 2. YAML files (sorted) — complex settings (lists, nested dicts) that | ||
| cannot be represented cleanly as individual env vars. Files are | ||
| deep-merged so ``FEATURES`` from a service-specific ConfigMap is | ||
| merged into, not replaced by, the base ``FEATURES`` dict. | ||
| """ | ||
| return ( | ||
| env_settings, | ||
| YamlConfigSettingsSource( | ||
| settings_cls, | ||
| yaml_file=_sorted_yaml_files(_SETTINGS_DIR), | ||
| deep_merge=True, | ||
| ), | ||
| ) |
There was a problem hiding this comment.
If no YAML files are found in _SETTINGS_DIR, _sorted_yaml_files returns an empty list. Passing an empty list to YamlConfigSettingsSource can cause runtime validation errors or unexpected behavior in pydantic-settings. It is safer to conditionally include YamlConfigSettingsSource only when YAML files are actually present.
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
):
"""Two-tier loading: env vars for flat scalars, YAML for complex types.
Priority (first wins):
1. Environment variables — K8s injects flat scalars from ConfigMap and
Secret ``envFrom`` refs directly into the process environment.
2. YAML files (sorted) — complex settings (lists, nested dicts) that
cannot be represented cleanly as individual env vars. Files are
deep-merged so ``FEATURES`` from a service-specific ConfigMap is
merged into, not replaced by, the base ``FEATURES`` dict.
"""
sources = [env_settings]
yaml_files = _sorted_yaml_files(_SETTINGS_DIR)
if yaml_files:
sources.append(
YamlConfigSettingsSource(
settings_cls,
yaml_file=yaml_files,
deep_merge=True,
)
)
return tuple(sources)| @model_validator(mode="after") | ||
| def _derive_celery_queue_names(self) -> "BaseProductionSettings": | ||
| """Build CELERY_DEFAULT_* queue names from SERVICE_VARIANT.""" | ||
| if self.CELERY_DEFAULT_QUEUE is None: | ||
| queue = f"edx.{self.SERVICE_VARIANT}.core.default" | ||
| self.CELERY_DEFAULT_QUEUE = queue | ||
| self.CELERY_DEFAULT_ROUTING_KEY = queue # type: ignore[attr-defined] | ||
| self.CELERY_DEFAULT_EXCHANGE = queue # type: ignore[attr-defined] | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_broker_url(self) -> "BaseProductionSettings": | ||
| """Build BROKER_URL from CELERY_BROKER_* components. | ||
|
|
||
| CELERY_BROKER_HOSTNAME and CELERY_BROKER_PASSWORD arrive as flat env | ||
| vars (hostname from a ConfigMap, password from a Secret). | ||
| """ | ||
| if self.CELERY_BROKER_TRANSPORT and not getattr(self, "BROKER_URL", None): | ||
| self.BROKER_URL = ( # type: ignore[attr-defined] | ||
| f"{self.CELERY_BROKER_TRANSPORT}://" | ||
| f"{self.CELERY_BROKER_USER}:{self.CELERY_BROKER_PASSWORD}" | ||
| f"@{self.CELERY_BROKER_HOSTNAME}/{self.CELERY_BROKER_VHOST}" | ||
| ) | ||
| if isinstance(self.CELERY_BROKER_USE_SSL, dict): | ||
| self.BROKER_USE_SSL = self.CELERY_BROKER_USE_SSL | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_static_paths(self) -> "BaseProductionSettings": | ||
| """Override STATIC_ROOT / STATIC_URL from *_BASE env-var variants.""" | ||
| if self.STATIC_ROOT_BASE: | ||
| self.STATIC_ROOT = self.STATIC_ROOT_BASE # type: ignore[assignment] | ||
| if self.STATIC_URL_BASE: | ||
| url = self.STATIC_URL_BASE | ||
| if not url.endswith("/"): | ||
| url += "/" | ||
| self.STATIC_URL = url | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_mako_module_dir(self) -> "BaseProductionSettings": | ||
| """MAKO_MODULE_DIR lives in the system temp dir, keyed by service variant.""" | ||
| if self.MAKO_MODULE_DIR is None: | ||
| self.MAKO_MODULE_DIR = os.path.join( # type: ignore[assignment] | ||
| tempfile.gettempdir(), f"edx_mako_{self.SERVICE_VARIANT}" | ||
| ) | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_statici18n_root(self) -> "BaseProductionSettings": | ||
| """STATICI18N_ROOT mirrors STATIC_ROOT.""" | ||
| if self.STATICI18N_ROOT is None and self.STATIC_ROOT: | ||
| self.STATICI18N_ROOT = self.STATIC_ROOT | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_language_settings(self) -> "BaseProductionSettings": | ||
| """Populate LANGUAGE_COOKIE_NAME (from YAML alias) and LANGUAGE_DICT.""" | ||
| if self.LANGUAGE_COOKIE: | ||
| self.LANGUAGE_COOKIE_NAME = self.LANGUAGE_COOKIE | ||
| if self.LANGUAGES: | ||
| self.LANGUAGE_DICT = dict(self.LANGUAGES) # type: ignore[arg-type, attr-defined] | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_elastic_search_config(self) -> "BaseProductionSettings": | ||
| """Use ELASTIC_SEARCH_CONFIG_ES7 as the authoritative search config.""" | ||
| if self.ELASTIC_SEARCH_CONFIG_ES7: | ||
| object.__setattr__( | ||
| self, "ELASTIC_SEARCH_CONFIG", self.ELASTIC_SEARCH_CONFIG_ES7 | ||
| ) | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_timezone(self) -> "BaseProductionSettings": | ||
| """Align Django TIME_ZONE with Celery's timezone.""" | ||
| if self.CELERY_TIMEZONE: | ||
| self.TIME_ZONE = self.CELERY_TIMEZONE | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_logging(self) -> "BaseProductionSettings": | ||
| """Build LOGGING from log-dir / environment / loglevel. | ||
|
|
||
| All three inputs are flat scalars arriving as env vars; they are | ||
| declared as explicit fields so pydantic populates them before this | ||
| validator runs. | ||
| """ | ||
| from openedx.core.lib.logsettings import get_logger_config # noqa: PLC0415 | ||
|
|
||
| self.LOGGING = get_logger_config( # type: ignore[attr-defined] | ||
| self.LOG_DIR, | ||
| logging_env=self.LOGGING_ENV, | ||
| local_loglevel=self.LOCAL_LOGLEVEL, | ||
| service_variant=self.SERVICE_VARIANT, | ||
| ) | ||
| return self |
There was a problem hiding this comment.
Since BaseProductionSettings is a shared base class and does not define the dynamically resolved settings fields (which are generated in AqueductSettings), accessing them directly (e.g., self.SERVICE_VARIANT) can raise AttributeError at runtime if they are missing or if the model generation is incomplete. Using getattr and setattr provides a robust fallback mechanism, prevents runtime crashes, and eliminates the need for type: ignore comments.
@model_validator(mode="after")
def _derive_celery_queue_names(self) -> "BaseProductionSettings":
"""Build CELERY_DEFAULT_* queue names from SERVICE_VARIANT."""
celery_default_queue = getattr(self, "CELERY_DEFAULT_QUEUE", None)
service_variant = getattr(self, "SERVICE_VARIANT", None)
if celery_default_queue is None and service_variant:
queue = f"edx.{service_variant}.core.default"
setattr(self, "CELERY_DEFAULT_QUEUE", queue)
setattr(self, "CELERY_DEFAULT_ROUTING_KEY", queue)
setattr(self, "CELERY_DEFAULT_EXCHANGE", queue)
return self
@model_validator(mode="after")
def _derive_broker_url(self) -> "BaseProductionSettings":
"""Build BROKER_URL from CELERY_BROKER_* components.
CELERY_BROKER_HOSTNAME and CELERY_BROKER_PASSWORD arrive as flat env
vars (hostname from a ConfigMap, password from a Secret).
"""
celery_broker_transport = getattr(self, "CELERY_BROKER_TRANSPORT", None)
broker_url = getattr(self, "BROKER_URL", None)
if celery_broker_transport and not broker_url:
user = getattr(self, "CELERY_BROKER_USER", "")
password = getattr(self, "CELERY_BROKER_PASSWORD", "")
hostname = getattr(self, "CELERY_BROKER_HOSTNAME", "")
vhost = getattr(self, "CELERY_BROKER_VHOST", "")
setattr(
self,
"BROKER_URL",
f"{celery_broker_transport}://{user}:{password}@{hostname}/{vhost}",
)
if isinstance(self.CELERY_BROKER_USE_SSL, dict):
self.BROKER_USE_SSL = self.CELERY_BROKER_USE_SSL
return self
@model_validator(mode="after")
def _derive_static_paths(self) -> "BaseProductionSettings":
"""Override STATIC_ROOT / STATIC_URL from *_BASE env-var variants."""
if self.STATIC_ROOT_BASE:
setattr(self, "STATIC_ROOT", self.STATIC_ROOT_BASE)
if self.STATIC_URL_BASE:
url = self.STATIC_URL_BASE
if not url.endswith("/"):
url += "/"
setattr(self, "STATIC_URL", url)
return self
@model_validator(mode="after")
def _derive_mako_module_dir(self) -> "BaseProductionSettings":
"""MAKO_MODULE_DIR lives in the system temp dir, keyed by service variant."""
mako_module_dir = getattr(self, "MAKO_MODULE_DIR", None)
service_variant = getattr(self, "SERVICE_VARIANT", None)
if mako_module_dir is None and service_variant:
setattr(
self,
"MAKO_MODULE_DIR",
os.path.join(tempfile.gettempdir(), f"edx_mako_{service_variant}"),
)
return self
@model_validator(mode="after")
def _derive_statici18n_root(self) -> "BaseProductionSettings":
"""STATICI18N_ROOT mirrors STATIC_ROOT."""
statici18n_root = getattr(self, "STATICI18N_ROOT", None)
static_root = getattr(self, "STATIC_ROOT", None)
if statici18n_root is None and static_root:
setattr(self, "STATICI18N_ROOT", static_root)
return self
@model_validator(mode="after")
def _derive_language_settings(self) -> "BaseProductionSettings":
"""Populate LANGUAGE_COOKIE_NAME (from YAML alias) and LANGUAGE_DICT."""
if self.LANGUAGE_COOKIE:
setattr(self, "LANGUAGE_COOKIE_NAME", self.LANGUAGE_COOKIE)
languages = getattr(self, "LANGUAGES", None)
if languages:
setattr(self, "LANGUAGE_DICT", dict(languages))
return self
@model_validator(mode="after")
def _derive_elastic_search_config(self) -> "BaseProductionSettings":
"""Use ELASTIC_SEARCH_CONFIG_ES7 as the authoritative search config."""
if self.ELASTIC_SEARCH_CONFIG_ES7:
setattr(self, "ELASTIC_SEARCH_CONFIG", self.ELASTIC_SEARCH_CONFIG_ES7)
return self
@model_validator(mode="after")
def _derive_timezone(self) -> "BaseProductionSettings":
"""Align Django TIME_ZONE with Celery's timezone."""
celery_timezone = getattr(self, "CELERY_TIMEZONE", None)
if celery_timezone:
setattr(self, "TIME_ZONE", celery_timezone)
return self
@model_validator(mode="after")
def _derive_logging(self) -> "BaseProductionSettings":
"""Build LOGGING from log-dir / environment / loglevel.
All three inputs are flat scalars arriving as env vars; they are
declared as explicit fields so pydantic populates them before this
validator runs.
"""
from openedx.core.lib.logsettings import get_logger_config # noqa: PLC0415
service_variant = getattr(self, "SERVICE_VARIANT", None)
logging_config = get_logger_config(
self.LOG_DIR,
logging_env=self.LOGGING_ENV,
local_loglevel=self.LOCAL_LOGLEVEL,
service_variant=service_variant,
)
setattr(self, "LOGGING", logging_config)
return self| @model_validator(mode="after") | ||
| def _derive_urlconf(self) -> LMSProductionSettings: | ||
| if self.ROOT_URLCONF is None: | ||
| self.ROOT_URLCONF = "lms.urls" | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_segment_key(self) -> LMSProductionSettings: | ||
| """Map env-var SEGMENT_KEY → Django LMS_SEGMENT_KEY.""" | ||
| if self.SEGMENT_KEY is not None: | ||
| self.LMS_SEGMENT_KEY = self.SEGMENT_KEY | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_cors_credentials(self) -> LMSProductionSettings: | ||
| """Enable CORS credentials when the relevant FEATURES flags are on.""" | ||
| features = self.FEATURES or {} | ||
| if isinstance(features, dict) and ( | ||
| features.get("ENABLE_CORS_HEADERS") | ||
| or features.get("ENABLE_CROSS_DOMAIN_CSRF_COOKIE") | ||
| ): | ||
| object.__setattr__(self, "CORS_ALLOW_CREDENTIALS", True) | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_authentication_backends(self) -> LMSProductionSettings: | ||
| """Prepend THIRD_PARTY_AUTH_BACKENDS before AUTHENTICATION_BACKENDS. | ||
|
|
||
| Arrives via YAML (complex list type). Deduplication preserves order. | ||
| """ | ||
| if not self.THIRD_PARTY_AUTH_BACKENDS: | ||
| return self | ||
| features = self.FEATURES or {} | ||
| if isinstance(features, dict) and not features.get( | ||
| "ENABLE_THIRD_PARTY_AUTH", True | ||
| ): | ||
| return self | ||
| existing = list(self.AUTHENTICATION_BACKENDS or []) | ||
| self.AUTHENTICATION_BACKENDS = self.THIRD_PARTY_AUTH_BACKENDS + [ | ||
| b for b in existing if b not in self.THIRD_PARTY_AUTH_BACKENDS | ||
| ] | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _derive_social_auth_clean_usernames(self) -> LMSProductionSettings: | ||
| if self.SOCIAL_AUTH_CLEAN_USERNAMES is None: | ||
| self.SOCIAL_AUTH_CLEAN_USERNAMES = False # type: ignore[assignment] | ||
| return self |
There was a problem hiding this comment.
Use getattr and setattr for robust settings derivation in LMSProductionSettings. This prevents potential AttributeError at runtime and eliminates the need for type: ignore comments.
@model_validator(mode="after")
def _derive_urlconf(self) -> LMSProductionSettings:
if getattr(self, "ROOT_URLCONF", None) is None:
setattr(self, "ROOT_URLCONF", "lms.urls")
return self
@model_validator(mode="after")
def _derive_segment_key(self) -> LMSProductionSettings:
"""Map env-var SEGMENT_KEY → Django LMS_SEGMENT_KEY."""
if self.SEGMENT_KEY is not None:
setattr(self, "LMS_SEGMENT_KEY", self.SEGMENT_KEY)
return self
@model_validator(mode="after")
def _derive_cors_credentials(self) -> LMSProductionSettings:
"""Enable CORS credentials when the relevant FEATURES flags are on."""
features = getattr(self, "FEATURES", None) or {}
if isinstance(features, dict) and (
features.get("ENABLE_CORS_HEADERS")
or features.get("ENABLE_CROSS_DOMAIN_CSRF_COOKIE")
):
setattr(self, "CORS_ALLOW_CREDENTIALS", True)
return self
@model_validator(mode="after")
def _derive_authentication_backends(self) -> LMSProductionSettings:
"""Prepend THIRD_PARTY_AUTH_BACKENDS before AUTHENTICATION_BACKENDS.
Arrives via YAML (complex list type). Deduplication preserves order.
"""
if not self.THIRD_PARTY_AUTH_BACKENDS:
return self
features = getattr(self, "FEATURES", None) or {}
if isinstance(features, dict) and not features.get(
"ENABLE_THIRD_PARTY_AUTH", True
):
return self
existing = list(getattr(self, "AUTHENTICATION_BACKENDS", None) or [])
new_backends = self.THIRD_PARTY_AUTH_BACKENDS + [
b for b in existing if b not in self.THIRD_PARTY_AUTH_BACKENDS
]
setattr(self, "AUTHENTICATION_BACKENDS", new_backends)
return self
@model_validator(mode="after")
def _derive_social_auth_clean_usernames(self) -> LMSProductionSettings:
if getattr(self, "SOCIAL_AUTH_CLEAN_USERNAMES", None) is None:
setattr(self, "SOCIAL_AUTH_CLEAN_USERNAMES", False)
return self| Injected by the lehrer Dagger build into both: | ||
| /openedx/edx-platform/lms/envs/mitol/ol_production_base.py | ||
| /openedx/edx-platform/cms/envs/mitol/ol_production_base.py | ||
|
|
||
| Each service's ``ol_production.py`` imports from this module using a relative | ||
| import (``from .ol_production_base import OLBaseProductionSettings``), so the | ||
| single source file works correctly in both namespaces without cross-service | ||
| imports. |
There was a problem hiding this comment.
The docstring references outdated paths (mitol/ol_production_base.py) and class names (OLBaseProductionSettings). Update it to reflect the new structure (models/base.py and BaseProductionSettings).
| Injected by the lehrer Dagger build into both: | |
| /openedx/edx-platform/lms/envs/mitol/ol_production_base.py | |
| /openedx/edx-platform/cms/envs/mitol/ol_production_base.py | |
| Each service's ``ol_production.py`` imports from this module using a relative | |
| import (``from .ol_production_base import OLBaseProductionSettings``), so the | |
| single source file works correctly in both namespaces without cross-service | |
| imports. | |
| Injected by the lehrer Dagger build into both: | |
| /openedx/edx-platform/lms/envs/models/base.py | |
| /openedx/edx-platform/cms/envs/models/base.py | |
| Each service's ``aqueduct.py`` imports from this module using a relative | |
| import (``from .models.base import BaseProductionSettings``), so the | |
| single source file works correctly in both namespaces without cross-service | |
| imports. |
| @model_validator(mode="after") | ||
| def _derive_urlconf(self) -> CMSProductionSettings: | ||
| if self.ROOT_URLCONF is None: | ||
| self.ROOT_URLCONF = "cms.urls" | ||
| return self |
There was a problem hiding this comment.
Use getattr and setattr for consistency and safety when setting ROOT_URLCONF in CMSProductionSettings.
| @model_validator(mode="after") | |
| def _derive_urlconf(self) -> CMSProductionSettings: | |
| if self.ROOT_URLCONF is None: | |
| self.ROOT_URLCONF = "cms.urls" | |
| return self | |
| @model_validator(mode="after") | |
| def _derive_urlconf(self) -> CMSProductionSettings: | |
| if getattr(self, "ROOT_URLCONF", None) is None: | |
| setattr(self, "ROOT_URLCONF", "cms.urls") | |
| return self |
There was a problem hiding this comment.
Pull request overview
Introduces a django-aqueduct based, typed settings layer for the edx-platform image built by lehrer. Six new files are injected into the container so that production LMS/CMS can be driven via DJANGO_SETTINGS_MODULE=lms.envs.aqueduct / cms.envs.aqueduct, with env vars (flat scalars) layered over deep-merged YAML (complex types) over generated pydantic defaults. Required runtime packages (django-aqueduct==0.3.0 and the [yaml] extra of pydantic-settings) are added to all changed deployment/release pip lists, and a dev dependency group plus a mypy exclusion for settings/ are added to pyproject.toml.
Changes:
- Add
settings/models/base.py(sharedBaseProductionSettings) and per-servicesettings/{lms,cms}/aqueduct.pyentry points plus generatedsettings/{lms,cms}/models/aqueduct.pypydantic models. - Extend
collectedinsrc/lehrer/main.pyto createlms/envs/modelsandcms/envs/modelsdirs and copy the aqueduct base/models/entry-point files into both service envs. - Bump
pydantic-settingsto the[yaml]extra and adddjango-aqueduct==0.3.0across master/teak/ulmo/verawood pip package lists; excludesettings/from mypy inpyproject.toml.
Reviewed changes
Copilot reviewed 18 out of 20 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/lehrer/main.py |
Creates models/ dirs and copies new base/aqueduct/model files into both lms/envs and cms/envs. |
settings/models/base.py |
Shared BaseProductionSettings with two-tier env-var-then-YAML source order and derived-setting validators (celery, broker, static, logging, etc.). |
settings/lms/aqueduct.py |
LMS settings module entry point and LMS-specific derived fields/validators. |
settings/cms/aqueduct.py |
CMS settings module entry point with cms.urls default. |
settings/cms/models/aqueduct.py |
Generated typed snapshot of CMS settings defaults (excluded from mypy). |
pyproject.toml |
Adds dev dependency group and [tool.mypy] exclude = ["settings/"]. |
.pre-commit-config.yaml |
Adds the full pre-commit suite, with settings/, src/lehrer/, and dockerfiles/ excluded from mypy. |
pip_package_lists/{master,teak,ulmo,verawood}/{mitx,mitx-staging,mitxonline,xpro}.txt |
Switches pydantic-settings to the [yaml] extra and adds django-aqueduct==0.3.0. |
| """Shared OL Kubernetes production settings base for both LMS and CMS. | ||
|
|
||
| Injected by the lehrer Dagger build into both: | ||
| /openedx/edx-platform/lms/envs/mitol/ol_production_base.py | ||
| /openedx/edx-platform/cms/envs/mitol/ol_production_base.py | ||
|
|
||
| Each service's ``ol_production.py`` imports from this module using a relative | ||
| import (``from .ol_production_base import OLBaseProductionSettings``), so the | ||
| single source file works correctly in both namespaces without cross-service | ||
| imports. |
| JWT_AUTH: dict[str, Any] = Field( | ||
| default=None | ||
| ) # OPAQUE: original dict value is not serialisable |
- Guard YamlConfigSettingsSource: only include it when YAML files are actually present, preventing validation errors when OL_SETTINGS_DIR is absent or empty (e.g. local development without a K8s config dir) - Replace direct attribute access on fields not declared in BaseProductionSettings with getattr(..., None) for safe reads; keep direct assignment self.FOO = value for writes (avoids ruff B010 while still being safe with pydantic's extra='allow' model) - Fix stale docstring in models/base.py: update injection paths from lms/envs/mitol/ol_production_base.py to lms/envs/models/base.py, class name from OLBaseProductionSettings to BaseProductionSettings, and import example from .ol_production_base to .models.base
What are the relevant tickets?
N/A
Description (What does it do?)
Introduces a django-aqueduct based configuration layer for edx-platform that replaces the legacy
LMS_CFG/STUDIO_CFGYAML file loading with a structured, typed, K8s-native approach.Files injected into the edx-platform image by the Dagger build:
lms/envs/models/aqueduct.pyAqueductSettingspydantic model — typed snapshot of all edx-platform settingslms/envs/models/base.pyBaseProductionSettings— shared pydantic-settings source configuration and derived-setting validatorslms/envs/aqueduct.pyDJANGO_SETTINGS_MODULE=lms.envs.aqueduct)cms/envs/models/aqueduct.pycms/envs/models/base.pycms/envs/aqueduct.pyDJANGO_SETTINGS_MODULE=cms.envs.aqueduct)Settings loading strategy (highest → lowest priority):
envFromConfigMaps and SecretsOL_SETTINGS_DIR(deep-merged, sorted 00→82) — complex types:DATABASES,CACHES,JWT_AUTH,FEATURES,MODULESTORE, etc.AqueductSettingsfield defaults — typed snapshot ofcommon.pydefaultsCompanion PR: mitodl/ol-infrastructure — updates the Pulumi edxapp K8s resources to use the new settings module and produce flat env ConfigMaps for scalar settings.
How can this be tested?
settings/dagger call build-platformusingDJANGO_SETTINGS_MODULE=lms.envs.aqueductand verify Django starts correctlyDJANGO_SETTINGS_MODULE=lms.envs.aqueduct python manage.py generate_aqueduct_settings --output lms/envs/models/aqueduct.pyAdditional Context
The
settings/models/files are excluded from mypy and intentionally carry dev-path defaults (e.g./home/tmacey/...) — these are generated artifacts that should be regenerated from inside the Docker image to pick up production paths (/openedx/edx-platform/...). The defaults are always overridden at runtime by env vars and the YAML config files.