paas-charm 2.0.dev1__tar.gz → 2.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/PKG-INFO +4 -1
  2. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/pyproject.toml +5 -2
  3. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/webserver.py +2 -26
  4. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/workload_config.py +0 -6
  5. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/app.py +193 -23
  6. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/charm.py +162 -69
  7. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/charm_state.py +81 -10
  8. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/exceptions.py +9 -0
  9. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/charm.py +0 -1
  10. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/app.py +4 -4
  11. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/charm.py +0 -1
  12. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/go/charm.py +0 -1
  13. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/oauth.py +15 -4
  14. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/observability.py +7 -42
  15. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/paas_config.py +107 -1
  16. paas_charm-2.0.dev4/src/paas_charm/relations.py +213 -0
  17. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/springboot/charm.py +10 -8
  18. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/tracing.py +4 -2
  19. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/utils.py +0 -19
  20. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/PKG-INFO +4 -1
  21. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/SOURCES.txt +1 -9
  22. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/requires.txt +3 -0
  23. paas_charm-2.0.dev4/src/paas_charm.egg-info/top_level.txt +1 -0
  24. paas_charm-2.0.dev1/src/paas_app_charmer/__init__.py +0 -13
  25. paas_charm-2.0.dev1/src/paas_app_charmer/django/__init__.py +0 -13
  26. paas_charm-2.0.dev1/src/paas_app_charmer/django/charm.py +0 -15
  27. paas_charm-2.0.dev1/src/paas_app_charmer/fastapi/__init__.py +0 -13
  28. paas_charm-2.0.dev1/src/paas_app_charmer/fastapi/charm.py +0 -16
  29. paas_charm-2.0.dev1/src/paas_app_charmer/flask/__init__.py +0 -13
  30. paas_charm-2.0.dev1/src/paas_app_charmer/flask/charm.py +0 -15
  31. paas_charm-2.0.dev1/src/paas_app_charmer/go/__init__.py +0 -13
  32. paas_charm-2.0.dev1/src/paas_app_charmer/go/charm.py +0 -15
  33. paas_charm-2.0.dev1/src/paas_charm.egg-info/top_level.txt +0 -2
  34. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/README.md +0 -0
  35. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/setup.cfg +0 -0
  36. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/__init__.py +0 -0
  37. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/__init__.py +0 -0
  38. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/charm.py +0 -0
  39. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/wsgi_app.py +0 -0
  40. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/charm_utils.py +0 -0
  41. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/database_migration.py +0 -0
  42. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/databases.py +0 -0
  43. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/django/__init__.py +0 -0
  44. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/django/charm.py +0 -0
  45. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/django/cos/grafana_dashboards/django.json +0 -0
  46. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/django/cos/loki_alert_rules/.gitkeep +0 -0
  47. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/django/cos/prometheus_alert_rules/django.rule +0 -0
  48. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/__init__.py +0 -0
  49. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/cos/grafana_dashboards/expressjs.json +0 -0
  50. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/cos/loki_alert_rules/.gitkeep +0 -0
  51. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/cos/prometheus_alert_rules/.gitkeep +0 -0
  52. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/__init__.py +0 -0
  53. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/cos/grafana_dashboards/fastapi.json +0 -0
  54. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/cos/loki_alert_rules/.gitkeep +0 -0
  55. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/cos/prometheus_alert_rules/.gitkeep +0 -0
  56. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/flask/__init__.py +0 -0
  57. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/flask/charm.py +0 -0
  58. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/flask/cos/grafana_dashboards/flask.json +0 -0
  59. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/flask/cos/loki_alert_rules/.gitkeep +0 -0
  60. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/flask/cos/prometheus_alert_rules/flask.rule +0 -0
  61. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/framework.py +0 -0
  62. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/go/__init__.py +0 -0
  63. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/go/cos/grafana_dashboards/go.json +0 -0
  64. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/go/cos/loki_alert_rules/.gitkeep +0 -0
  65. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/go/cos/prometheus_alert_rules/.gitkeep +0 -0
  66. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/http_proxy.py +0 -0
  67. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/peers.py +0 -0
  68. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/rabbitmq.py +0 -0
  69. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/s3.py +0 -0
  70. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/saml.py +0 -0
  71. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/secret_key.py +0 -0
  72. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/springboot/__init__.py +0 -0
  73. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/springboot/cos/grafana_dashboards/spring_boot.json +0 -0
  74. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/springboot/cos/loki_alert_rules/.gitkeep +0 -0
  75. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/springboot/cos/prometheus_alert_rules/.gitkeep +0 -0
  76. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/templates/_gunicorn_json_logging.py +0 -0
  77. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/templates/fastapi/uvicorn-log-config.json +0 -0
  78. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/templates/fastapi/uvicorn_log_handler.py +0 -0
  79. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/templates/gunicorn.conf.py.j2 +0 -0
  80. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm/valkey.py +0 -0
  81. {paas_charm-2.0.dev1 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/dependency_links.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paas-charm
3
- Version: 2.0.dev1
3
+ Version: 2.0.dev4
4
4
  Summary: Companion library for 12-factor app support in Charmcraft & Rockcraft.
5
5
  Author-email: Canonical IS DevOps team <is-devops-team@canonical.com>
6
6
  Project-URL: Repository, https://github.com/canonical/paas-charm
@@ -11,6 +11,9 @@ Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.12
13
13
  Description-Content-Type: text/markdown
14
+ Requires-Dist: charmlibs-interfaces-tracing==1.0.0
15
+ Requires-Dist: charmlibs-interfaces-oauth==1.1.0
16
+ Requires-Dist: charmlibs-interfaces-openfga==1.0.0
14
17
  Requires-Dist: cosl==1.10.3
15
18
  Requires-Dist: dpcharmlibs-interfaces==1.2.0
16
19
  Requires-Dist: Jinja2==3.1.6
@@ -2,7 +2,7 @@
2
2
  # See LICENSE file for licensing details.
3
3
  [project]
4
4
  name = "paas-charm"
5
- version = "2.0.dev1"
5
+ version = "2.0.dev4"
6
6
  description = "Companion library for 12-factor app support in Charmcraft & Rockcraft."
7
7
  readme = "README.md"
8
8
  authors = [
@@ -17,6 +17,9 @@ classifiers = [
17
17
  "Programming Language :: Python :: 3",
18
18
  ]
19
19
  dependencies = [
20
+ "charmlibs-interfaces-tracing==1.0.0",
21
+ "charmlibs-interfaces-oauth==1.1.0",
22
+ "charmlibs-interfaces-openfga==1.0.0",
20
23
  "cosl==1.10.3",
21
24
  "dpcharmlibs-interfaces==1.2.0",
22
25
  "Jinja2==3.1.6",
@@ -37,7 +40,7 @@ where = ["src"]
37
40
 
38
41
  [tool.uv.sources]
39
42
  # opcli is published only from the charm-ci git repository, not PyPI.
40
- opcli = { git = "https://github.com/canonical/charm-ci.git", tag = "v1.0.1" }
43
+ opcli = { git = "https://github.com/canonical/charm-ci.git", tag = "v1.0.2" }
41
44
 
42
45
  [tool.setuptools.package-data]
43
46
  paas_charm = ["**/cos/**", "**/cos/**/.**", "**/*.j2", "**/templates/**/*.py", "**/templates/**/*.json"]
@@ -17,14 +17,11 @@ import ops
17
17
  from ops.pebble import ExecError, PathError
18
18
 
19
19
  from paas_charm._gunicorn.workload_config import (
20
- APPLICATION_ERROR_LOG_FILE,
21
- APPLICATION_LOG_FILE,
22
20
  STATSD_HOST,
23
21
  )
24
22
  from paas_charm.app import WorkloadConfig
25
23
  from paas_charm.exceptions import CharmConfigInvalidError
26
24
  from paas_charm.paas_config import LoggingFormat
27
- from paas_charm.utils import enable_pebble_log_forwarding
28
25
 
29
26
  logger = logging.getLogger(__name__)
30
27
 
@@ -153,21 +150,14 @@ class GunicornWebserver: # pylint: disable=too-few-public-methods
153
150
  if isinstance(setting_value, (int, str))
154
151
  else int(setting_value.total_seconds())
155
152
  )
156
- if enable_pebble_log_forwarding():
157
- access_log = "-"
158
- error_log = "-"
159
- else:
160
- access_log = str(APPLICATION_LOG_FILE)
161
- error_log = str(APPLICATION_ERROR_LOG_FILE)
162
-
163
153
  jinja_environment = jinja2.Environment(
164
154
  loader=jinja2.PackageLoader("paas_charm", "templates"), autoescape=True
165
155
  )
166
156
  config = jinja_environment.get_template("gunicorn.conf.py.j2").render(
167
157
  workload_port=self._workload_config.port,
168
158
  workload_app_dir=str(self._workload_config.app_dir),
169
- access_log=access_log,
170
- error_log=error_log,
159
+ access_log="-",
160
+ error_log="-",
171
161
  statsd_host=str(STATSD_HOST),
172
162
  enable_tracing=self._workload_config.tracing_enabled,
173
163
  enable_json_logging=self._workload_config.logging_format == LoggingFormat.JSON,
@@ -197,7 +187,6 @@ class GunicornWebserver: # pylint: disable=too-few-public-methods
197
187
  Raises:
198
188
  CharmConfigInvalidError: if the charm configuration is not valid.
199
189
  """
200
- self._prepare_log_dir()
201
190
  webserver_config_path = str(self._config_path)
202
191
  try:
203
192
  current_webserver_config = self._container.pull(webserver_config_path)
@@ -235,16 +224,3 @@ class GunicornWebserver: # pylint: disable=too-few-public-methods
235
224
  if is_webserver_running:
236
225
  logger.info("gunicorn config changed, reloading")
237
226
  self._container.send_signal(self._reload_signal, self._workload_config.service_name)
238
-
239
- def _prepare_log_dir(self) -> None:
240
- """Prepare access and error log directory for the application."""
241
- container = self._container
242
- for log in self._workload_config.log_files:
243
- log_dir = str(log.parent.absolute())
244
- if not container.exists(log_dir):
245
- container.make_dir(
246
- log_dir,
247
- make_parents=True,
248
- user=self._workload_config.user,
249
- group=self._workload_config.group,
250
- )
@@ -9,8 +9,6 @@ from paas_charm.app import WorkloadConfig
9
9
  from paas_charm.paas_config import PaasConfig
10
10
 
11
11
  STATSD_HOST = "localhost:9125"
12
- APPLICATION_LOG_FILE = "/var/log/app/access.log"
13
- APPLICATION_ERROR_LOG_FILE = "/var/log/app/error.log"
14
12
  GUNICORN_CONFIG_DIR = "/var/lib/gunicorn"
15
13
 
16
14
 
@@ -50,10 +48,6 @@ def create_workload_config(
50
48
  app_dir=app_dir,
51
49
  state_dir=state_dir,
52
50
  service_name=framework_name,
53
- log_files=[
54
- pathlib.Path(APPLICATION_LOG_FILE),
55
- pathlib.Path(APPLICATION_ERROR_LOG_FILE),
56
- ],
57
51
  metrics_path=metrics_path,
58
52
  metrics_port=metrics_port,
59
53
  unit_name=unit_name,
@@ -9,19 +9,20 @@ import logging
9
9
  import pathlib
10
10
  import urllib.parse
11
11
  from dataclasses import dataclass
12
- from typing import TYPE_CHECKING, List
12
+ from typing import TYPE_CHECKING
13
13
 
14
14
  import ops
15
15
  from dpcharmlibs.interfaces import ValkeyResponseModel
16
16
 
17
17
  from paas_charm.charm_state import CharmState
18
18
  from paas_charm.database_migration import DatabaseMigration
19
- from paas_charm.paas_config import LoggingFormat
19
+ from paas_charm.exceptions import PaasConfigError
20
+ from paas_charm.paas_config import EnvVarConfig, LoggingFormat, SecretEnvVarsConfig
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
  if TYPE_CHECKING:
24
- from charms.openfga_k8s.v1.openfga import OpenfgaProviderAppData
25
+ from charmlibs.interfaces.openfga import OpenfgaProviderAppData
25
26
  from charms.smtp_integrator.v0.smtp import SmtpRelationData
26
27
 
27
28
  from paas_charm.databases import PaaSDatabaseRelationData
@@ -52,7 +53,6 @@ class WorkloadConfig: # pylint: disable=too-many-instance-attributes
52
53
  app_dir: the application directory in the application container.
53
54
  state_dir: the directory in the application container to store states information.
54
55
  service_name: the WSGI application pebble service name.
55
- log_files: list of files to monitor.
56
56
  metrics_path: path to scrape for metrics.
57
57
  metrics_port: port on which the application exposes metrics.
58
58
  unit_name: Name of the unit. Needed to know if schedulers should run here.
@@ -69,7 +69,6 @@ class WorkloadConfig: # pylint: disable=too-many-instance-attributes
69
69
  app_dir: pathlib.Path
70
70
  state_dir: pathlib.Path
71
71
  service_name: str
72
- log_files: List[pathlib.Path]
73
72
  metrics_path: str | None = "/metrics"
74
73
  metrics_port: int = 8080
75
74
  unit_name: str
@@ -444,37 +443,198 @@ class App: # pylint: disable=too-many-instance-attributes
444
443
  self._run_migrations()
445
444
  self._container.replan()
446
445
 
447
- # 2024/04/25 - we're refactoring this method which will get rid of map_integrations_to_env
448
- # wrapper function. Ignore too-complex error from flake8 for now.
449
- def gen_environment(self) -> dict[str, str]: # noqa: too-complex
450
- """Generate a environment dictionary from the charm configurations.
451
-
452
- The environment generation follows these rules:
453
- 1. User-defined configuration cannot overwrite built-in framework configurations,
454
- even if the built-in framework application configuration value is None (undefined).
455
- 2. Boolean and integer-typed configuration values will be JSON encoded before
456
- being passed to application.
457
- 3. String-typed configuration values will be passed to the application as environment
458
- variables directly.
459
- 4. Different prefixes can be set to the environment variable names depending on the
460
- framework.
446
+ def gen_environment(self) -> dict[str, str]:
447
+ """Generate the final workload environment in precedence order.
448
+
449
+ Explicit config mappings override integrations, which override library-generated
450
+ variables. The config/framework layer comes from ``_framework_environment``.
451
+
452
+ Returns:
453
+ The final environment with string-encoded values.
454
+ """
455
+ framework_sources = self._framework_config_mapping_sources()
456
+ env = self._framework_environment()
457
+ for name, value in self._generate_integration_environments(
458
+ prefix=self.integrations_prefix
459
+ ).items():
460
+ if name in env:
461
+ logger.warning(
462
+ "Environment variable %r for a relation or Prometheus metrics overwrites an "
463
+ "environment variable from charm configuration or framework settings",
464
+ name,
465
+ )
466
+ env[name] = value
467
+ for name, (source, value) in self._mapped_config_environment(framework_sources).items():
468
+ if name in env:
469
+ logger.warning(
470
+ "Explicit config.options mapping from %r overwrites environment variable %r",
471
+ source,
472
+ name,
473
+ )
474
+ env[name] = value
475
+ return env
476
+
477
+ def _mapped_framework_config_fields(self) -> set[str]:
478
+ """Get framework fields whose default outputs are explicitly renamed.
479
+
480
+ Returns:
481
+ Framework field names, excluding unmapped secret-key entries.
482
+ """
483
+ mappings = self._charm_state.config_options.options
484
+ return {
485
+ field
486
+ for option, field in self._charm_state.framework_config_fields.items()
487
+ if (mapping := mappings.get(option)) is not None
488
+ and (
489
+ isinstance(mapping, EnvVarConfig)
490
+ or (
491
+ field in ("secret_key", "app_secret_key")
492
+ and "value" in mapping.secret_env_vars
493
+ and not isinstance(self._charm_state.framework_config.get(field), dict)
494
+ )
495
+ )
496
+ }
497
+
498
+ def _framework_config_mapping_sources(self) -> dict[str, str | dict[str, str]]:
499
+ """Read resolved framework values without borrowing unrelated generated outputs.
500
+
501
+ Returns:
502
+ Values keyed by exact option names, using the existing encoding and key fallback.
503
+ """
504
+ sources: dict[str, str | dict[str, str]] = {}
505
+ for option, mapping in self._charm_state.config_options.options.items():
506
+ field = self._charm_state.framework_config_fields.get(option)
507
+ if field is None:
508
+ continue
509
+ value = self._charm_state.framework_config.get(field)
510
+ if value is None and field in ("secret_key", "app_secret_key"):
511
+ value = self._charm_state.secret_key
512
+ if value is None:
513
+ continue
514
+ if isinstance(mapping, SecretEnvVarsConfig):
515
+ if isinstance(value, dict):
516
+ sources[option] = value
517
+ elif field in ("secret_key", "app_secret_key"):
518
+ sources[option] = {"value": encode_env(value)}
519
+ else:
520
+ sources[option] = encode_env(value)
521
+ else:
522
+ sources[option] = encode_env(value)
523
+ return sources
524
+
525
+ def _mapped_config_environment(
526
+ self, framework_sources: dict[str, str | dict[str, str]]
527
+ ) -> dict[str, tuple[str, str]]:
528
+ """Generate explicit config destinations from their original source values.
529
+
530
+ Args:
531
+ framework_sources: Resolved framework outputs keyed by exact option names,
532
+ including secret-key fallbacks.
461
533
 
462
534
  Returns:
463
- A dictionary representing the application environment variables.
535
+ Destinations mapped to source names and encoded values, omitting absent values.
536
+
537
+ Raises:
538
+ PaasConfigError: If a secret mapping receives non-secret content.
539
+ """
540
+ env: dict[str, tuple[str, str]] = {}
541
+ for option, mapping in self._charm_state.config_options.options.items():
542
+ value = (
543
+ framework_sources.get(option)
544
+ if option in self._charm_state.framework_config_fields
545
+ else self._charm_state.user_defined_config.get(option.replace("-", "_"))
546
+ )
547
+ if value is None:
548
+ continue
549
+ if isinstance(mapping, SecretEnvVarsConfig):
550
+ if not isinstance(value, collections.abc.Mapping):
551
+ raise PaasConfigError(
552
+ f"config.options.{option}.secret-env-vars requires secret content"
553
+ )
554
+ for key, destination in mapping.secret_env_vars.items():
555
+ if key not in value:
556
+ logger.warning(
557
+ "Charm configuration option %r has no secret content key %r; "
558
+ "skipping mapped environment variable %r",
559
+ option,
560
+ key,
561
+ destination,
562
+ )
563
+ continue
564
+ env[destination] = (f"{option}.{key}", encode_env(value[key]))
565
+ else:
566
+ env[mapping.env_var] = (option, encode_env(value))
567
+ return env
568
+
569
+ def _unmapped_framework_config(self) -> dict[str, str | int | bool | dict[str, str]]:
570
+ """Keep framework defaults and secret entries that have not been renamed.
571
+
572
+ Returns:
573
+ Resolved framework configuration with mapped fields or secret entries omitted.
574
+ """
575
+ mapped_fields = self._mapped_framework_config_fields()
576
+ config = {
577
+ field: value
578
+ for field, value in self._charm_state.framework_config.items()
579
+ if field not in mapped_fields
580
+ }
581
+ for option, field in self._charm_state.framework_config_fields.items():
582
+ mapping = self._charm_state.config_options.options.get(option)
583
+ value = config.get(field)
584
+ if (
585
+ isinstance(mapping, SecretEnvVarsConfig)
586
+ and isinstance(value, dict)
587
+ and value.keys() & mapping.secret_env_vars.keys()
588
+ ):
589
+ remaining = {
590
+ key: val for key, val in value.items() if key not in mapping.secret_env_vars
591
+ }
592
+ if remaining:
593
+ config[field] = remaining
594
+ else:
595
+ config.pop(field)
596
+ return config
597
+
598
+ def _framework_environment(self) -> dict[str, str]: # noqa: too-complex
599
+ """Build config and library-owned variables before higher-priority overrides.
600
+
601
+ Explicitly mapped sources are omitted from their default names. Unmapped config
602
+ retains its existing prefixes and value encoding. Framework subclasses extend
603
+ this layer; ``gen_environment`` then applies relations and explicit mappings.
604
+
605
+ Returns:
606
+ The config/framework environment with string-encoded values.
464
607
  """
465
608
  prefix = self.configuration_prefix
466
609
  env = {}
610
+ mappings = {
611
+ option.replace("-", "_"): mapping
612
+ for option, mapping in self._charm_state.config_options.options.items()
613
+ }
467
614
  for app_config_key, app_config_value in self._charm_state.user_defined_config.items():
615
+ mapping = mappings.get(app_config_key)
616
+ if app_config_key in self._charm_state.framework_config_field_names or isinstance(
617
+ mapping, EnvVarConfig
618
+ ):
619
+ continue
620
+ if isinstance(mapping, SecretEnvVarsConfig) and app_config_value is None:
621
+ continue
622
+ mapped_keys = (
623
+ mapping.secret_env_vars if isinstance(mapping, SecretEnvVarsConfig) else {}
624
+ )
468
625
  if isinstance(app_config_value, collections.abc.Mapping):
469
626
  for k, v in app_config_value.items():
627
+ if k in mapped_keys:
628
+ continue
470
629
  env[f"{prefix}{app_config_key.upper()}_{k.replace('-', '_').upper()}"] = (
471
630
  encode_env(v)
472
631
  )
473
632
  else:
474
633
  env[f"{prefix}{app_config_key.upper()}"] = encode_env(app_config_value)
475
634
 
476
- framework_config = self._charm_state.framework_config
635
+ framework_config = self._unmapped_framework_config()
477
636
  framework_config_prefix = self.framework_config_prefix
637
+ mapped_fields = self._mapped_framework_config_fields()
478
638
  env.update(
479
639
  {
480
640
  f"{framework_config_prefix}{k.upper()}": encode_env(v)
@@ -489,7 +649,7 @@ class App: # pylint: disable=too-many-instance-attributes
489
649
  if self._charm_state.base_url:
490
650
  env[f"{prefix}BASE_URL"] = self._charm_state.base_url
491
651
  secret_key_env = f"{prefix}SECRET_KEY"
492
- if secret_key_env not in env:
652
+ if secret_key_env not in env and not {"secret_key", "app_secret_key"} & mapped_fields:
493
653
  env[secret_key_env] = self._charm_state.secret_key
494
654
  for proxy_variable in ("http_proxy", "https_proxy", "no_proxy"):
495
655
  proxy_value = getattr(self._charm_state.proxy, proxy_variable)
@@ -500,7 +660,6 @@ class App: # pylint: disable=too-many-instance-attributes
500
660
  if self._charm_state.peer_fqdns is not None:
501
661
  env[f"{prefix}PEER_FQDNS"] = self._charm_state.peer_fqdns
502
662
 
503
- env.update(self._generate_integration_environments(prefix=self.integrations_prefix))
504
663
  return env
505
664
 
506
665
  def _generate_integration_environments(self, prefix: str = "") -> dict[str, str]:
@@ -531,6 +690,17 @@ class App: # pylint: disable=too-many-instance-attributes
531
690
  relation_data=self._charm_state.integrations.oauth,
532
691
  )
533
692
  )
693
+
694
+ for relation in self._charm_state.custom_relations:
695
+ for key, value in (relation.gen_environment() or {}).items():
696
+ if key in env:
697
+ logger.warning(
698
+ "Custom relation %s overwrites environment variable %s",
699
+ relation.relation_name,
700
+ key,
701
+ )
702
+ env[key] = value
703
+
534
704
  return {prefix + k: v for (k, v) in env.items()}
535
705
 
536
706
  @property