paas-charm 2.0.dev3__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 (71) hide show
  1. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/PKG-INFO +1 -1
  2. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/pyproject.toml +2 -2
  3. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/app.py +180 -21
  4. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/charm.py +22 -1
  5. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/charm_state.py +72 -9
  6. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/app.py +4 -4
  7. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/paas_config.py +107 -1
  8. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/springboot/charm.py +9 -6
  9. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/PKG-INFO +1 -1
  10. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/README.md +0 -0
  11. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/setup.cfg +0 -0
  12. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/__init__.py +0 -0
  13. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/__init__.py +0 -0
  14. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/charm.py +0 -0
  15. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/webserver.py +0 -0
  16. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/workload_config.py +0 -0
  17. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/_gunicorn/wsgi_app.py +0 -0
  18. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/charm_utils.py +0 -0
  19. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/database_migration.py +0 -0
  20. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/databases.py +0 -0
  21. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/django/__init__.py +0 -0
  22. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/django/charm.py +0 -0
  23. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/django/cos/grafana_dashboards/django.json +0 -0
  24. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/django/cos/loki_alert_rules/.gitkeep +0 -0
  25. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/django/cos/prometheus_alert_rules/django.rule +0 -0
  26. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/exceptions.py +0 -0
  27. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/__init__.py +0 -0
  28. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/charm.py +0 -0
  29. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/cos/grafana_dashboards/expressjs.json +0 -0
  30. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/cos/loki_alert_rules/.gitkeep +0 -0
  31. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/expressjs/cos/prometheus_alert_rules/.gitkeep +0 -0
  32. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/__init__.py +0 -0
  33. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/charm.py +0 -0
  34. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/cos/grafana_dashboards/fastapi.json +0 -0
  35. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/cos/loki_alert_rules/.gitkeep +0 -0
  36. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/fastapi/cos/prometheus_alert_rules/.gitkeep +0 -0
  37. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/flask/__init__.py +0 -0
  38. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/flask/charm.py +0 -0
  39. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/flask/cos/grafana_dashboards/flask.json +0 -0
  40. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/flask/cos/loki_alert_rules/.gitkeep +0 -0
  41. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/flask/cos/prometheus_alert_rules/flask.rule +0 -0
  42. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/framework.py +0 -0
  43. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/go/__init__.py +0 -0
  44. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/go/charm.py +0 -0
  45. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/go/cos/grafana_dashboards/go.json +0 -0
  46. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/go/cos/loki_alert_rules/.gitkeep +0 -0
  47. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/go/cos/prometheus_alert_rules/.gitkeep +0 -0
  48. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/http_proxy.py +0 -0
  49. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/oauth.py +0 -0
  50. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/observability.py +0 -0
  51. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/peers.py +0 -0
  52. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/rabbitmq.py +0 -0
  53. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/relations.py +0 -0
  54. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/s3.py +0 -0
  55. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/saml.py +0 -0
  56. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/secret_key.py +0 -0
  57. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/springboot/__init__.py +0 -0
  58. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/springboot/cos/grafana_dashboards/spring_boot.json +0 -0
  59. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/springboot/cos/loki_alert_rules/.gitkeep +0 -0
  60. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/springboot/cos/prometheus_alert_rules/.gitkeep +0 -0
  61. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/templates/_gunicorn_json_logging.py +0 -0
  62. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/templates/fastapi/uvicorn-log-config.json +0 -0
  63. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/templates/fastapi/uvicorn_log_handler.py +0 -0
  64. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/templates/gunicorn.conf.py.j2 +0 -0
  65. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/tracing.py +0 -0
  66. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/utils.py +0 -0
  67. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm/valkey.py +0 -0
  68. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/SOURCES.txt +0 -0
  69. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/dependency_links.txt +0 -0
  70. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/requires.txt +0 -0
  71. {paas_charm-2.0.dev3 → paas_charm-2.0.dev4}/src/paas_charm.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paas-charm
3
- Version: 2.0.dev3
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
@@ -2,7 +2,7 @@
2
2
  # See LICENSE file for licensing details.
3
3
  [project]
4
4
  name = "paas-charm"
5
- version = "2.0.dev3"
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 = [
@@ -40,7 +40,7 @@ where = ["src"]
40
40
 
41
41
  [tool.uv.sources]
42
42
  # opcli is published only from the charm-ci git repository, not PyPI.
43
- 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" }
44
44
 
45
45
  [tool.setuptools.package-data]
46
46
  paas_charm = ["**/cos/**", "**/cos/**/.**", "**/*.j2", "**/templates/**/*.py", "**/templates/**/*.json"]
@@ -16,7 +16,8 @@ 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
 
@@ -442,38 +443,198 @@ class App: # pylint: disable=too-many-instance-attributes
442
443
  self._run_migrations()
443
444
  self._container.replan()
444
445
 
445
- # 2024/04/25 - we're refactoring this method which will get rid of map_integrations_to_env
446
- # wrapper function. Ignore too-complex error from flake8 for now.
447
- # pylint: disable=too-many-branches
448
- def gen_environment(self) -> dict[str, str]: # noqa: too-complex
449
- """Generate a environment dictionary from the charm configurations.
450
-
451
- The environment generation follows these rules:
452
- 1. User-defined configuration cannot overwrite built-in framework configurations,
453
- even if the built-in framework application configuration value is None (undefined).
454
- 2. Boolean and integer-typed configuration values will be JSON encoded before
455
- being passed to application.
456
- 3. String-typed configuration values will be passed to the application as environment
457
- variables directly.
458
- 4. Different prefixes can be set to the environment variable names depending on the
459
- 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.
533
+
534
+ Returns:
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.
460
571
 
461
572
  Returns:
462
- A dictionary representing the application environment variables.
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.
463
607
  """
464
608
  prefix = self.configuration_prefix
465
609
  env = {}
610
+ mappings = {
611
+ option.replace("-", "_"): mapping
612
+ for option, mapping in self._charm_state.config_options.options.items()
613
+ }
466
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
+ )
467
625
  if isinstance(app_config_value, collections.abc.Mapping):
468
626
  for k, v in app_config_value.items():
627
+ if k in mapped_keys:
628
+ continue
469
629
  env[f"{prefix}{app_config_key.upper()}_{k.replace('-', '_').upper()}"] = (
470
630
  encode_env(v)
471
631
  )
472
632
  else:
473
633
  env[f"{prefix}{app_config_key.upper()}"] = encode_env(app_config_value)
474
634
 
475
- framework_config = self._charm_state.framework_config
635
+ framework_config = self._unmapped_framework_config()
476
636
  framework_config_prefix = self.framework_config_prefix
637
+ mapped_fields = self._mapped_framework_config_fields()
477
638
  env.update(
478
639
  {
479
640
  f"{framework_config_prefix}{k.upper()}": encode_env(v)
@@ -488,7 +649,7 @@ class App: # pylint: disable=too-many-instance-attributes
488
649
  if self._charm_state.base_url:
489
650
  env[f"{prefix}BASE_URL"] = self._charm_state.base_url
490
651
  secret_key_env = f"{prefix}SECRET_KEY"
491
- if secret_key_env not in env:
652
+ if secret_key_env not in env and not {"secret_key", "app_secret_key"} & mapped_fields:
492
653
  env[secret_key_env] = self._charm_state.secret_key
493
654
  for proxy_variable in ("http_proxy", "https_proxy", "no_proxy"):
494
655
  proxy_value = getattr(self._charm_state.proxy, proxy_variable)
@@ -499,8 +660,6 @@ class App: # pylint: disable=too-many-instance-attributes
499
660
  if self._charm_state.peer_fqdns is not None:
500
661
  env[f"{prefix}PEER_FQDNS"] = self._charm_state.peer_fqdns
501
662
 
502
- env.update(self._generate_integration_environments(prefix=self.integrations_prefix))
503
-
504
663
  return env
505
664
 
506
665
  def _generate_integration_environments(self, prefix: str = "") -> dict[str, str]:
@@ -18,7 +18,12 @@ from ops.model import Container
18
18
  from pydantic import BaseModel, ValidationError
19
19
 
20
20
  from paas_charm.app import App, WorkloadConfig
21
- from paas_charm.charm_state import CharmState, IntegrationRequirers
21
+ from paas_charm.charm_state import (
22
+ CharmState,
23
+ IntegrationRequirers,
24
+ framework_config_option_fields,
25
+ is_user_defined_config,
26
+ )
22
27
  from paas_charm.charm_utils import block_if_invalid_data
23
28
  from paas_charm.database_migration import DatabaseMigration, DatabaseMigrationStatus
24
29
  from paas_charm.databases import make_database_requirers
@@ -32,6 +37,7 @@ from paas_charm.oauth import PaaSOAuthRequirer
32
37
  from paas_charm.observability import Observability
33
38
  from paas_charm.paas_config import (
34
39
  FRAMEWORKS_SUPPORTING_LOGGING_FORMAT,
40
+ ConfigOptions,
35
41
  LoggingFormat,
36
42
  read_paas_config,
37
43
  )
@@ -45,6 +51,7 @@ from paas_charm.tracing import PaaSTracingEndpointRequirer
45
51
  from paas_charm.utils import (
46
52
  build_validation_error_message,
47
53
  config_get_with_secret,
54
+ config_metadata,
48
55
  get_endpoints_by_interface_name,
49
56
  merge_cos_directories,
50
57
  )
@@ -96,6 +103,19 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
96
103
  super().__init__(framework)
97
104
  self._framework_name = framework_name
98
105
  self._paas_config = read_paas_config()
106
+ config_options: ConfigOptions = self._paas_config.config
107
+ if config_options.options: # pylint: disable=no-member
108
+ options = config_metadata(pathlib.Path(self.charm_dir))["options"]
109
+ framework_fields = framework_config_option_fields(
110
+ self.framework_config_class, options.keys()
111
+ )
112
+ unsupported = {
113
+ option
114
+ for option in options
115
+ if not is_user_defined_config(option, framework_name)
116
+ and option not in framework_fields
117
+ }
118
+ config_options.validate_sources(options, unsupported) # pylint: disable=no-member
99
119
 
100
120
  self._secret_key = SecretKeyStorage(charm=self, label="app-secret-key")
101
121
  self._peers = Peers(charm=self)
@@ -769,6 +789,7 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
769
789
  config=config,
770
790
  framework=self._framework_name,
771
791
  framework_config=self.get_framework_config(),
792
+ config_options=self._paas_config.config,
772
793
  secret_key=self._secret_key,
773
794
  peers=self._peers,
774
795
  integration_requirers=IntegrationRequirers(
@@ -9,13 +9,14 @@ import pathlib
9
9
  import typing
10
10
  from dataclasses import dataclass, field
11
11
 
12
- from pydantic import BaseModel, Field, ValidationError, create_model
12
+ from pydantic import AliasChoices, BaseModel, Field, ValidationError, create_model
13
13
 
14
14
  from paas_charm.exceptions import (
15
15
  CharmConfigInvalidError,
16
16
  InvalidRelationDataError,
17
17
  RelationDataError,
18
18
  )
19
+ from paas_charm.paas_config import ConfigOptions
19
20
  from paas_charm.peers import Peers
20
21
  from paas_charm.relations import CustomRelation
21
22
  from paas_charm.secret_key import SecretKeyStorage
@@ -50,6 +51,9 @@ class CharmState: # pylint: disable=too-many-instance-attributes
50
51
  secret_key: the charm managed application secret key.
51
52
  is_secret_key_ready: whether the application secret key is ready.
52
53
  proxy: proxy information.
54
+ config_options: Settings for existing charm configuration options.
55
+ framework_config_fields: Accepted framework config input names mapped to model fields.
56
+ framework_config_field_names: Framework fields protected from implicit user config outputs.
53
57
  """
54
58
 
55
59
  def __init__( # pylint: disable=too-many-arguments
@@ -58,12 +62,15 @@ class CharmState: # pylint: disable=too-many-instance-attributes
58
62
  framework: str,
59
63
  is_secret_key_ready: bool,
60
64
  user_defined_config: dict[str, int | str | bool | dict[str, str]] | None = None,
61
- framework_config: dict[str, int | str] | None = None,
65
+ framework_config: dict[str, int | str | dict[str, str]] | None = None,
62
66
  secret_key: str | None = None,
63
67
  peer_fqdns: str | None = None,
64
68
  integrations: "IntegrationsState | None" = None,
65
69
  base_url: str | None = None,
70
+ config_options: ConfigOptions | None = None,
71
+ framework_config_fields: dict[str, str] | None = None,
66
72
  custom_relations: list[CustomRelation] | None = None,
73
+ framework_config_field_names: set[str] | None = None,
67
74
  ):
68
75
  """Initialize a new instance of the CharmState class.
69
76
 
@@ -76,7 +83,10 @@ class CharmState: # pylint: disable=too-many-instance-attributes
76
83
  peer_fqdns: The FQDN of units in the peer relation.
77
84
  integrations: Information about the integrations.
78
85
  base_url: Base URL for the service.
86
+ config_options: Settings for existing charm configuration options.
87
+ framework_config_fields: Accepted framework config input names mapped to model fields.
79
88
  custom_relations: Custom relations.
89
+ framework_config_field_names: Framework fields protected from implicit user outputs.
80
90
  """
81
91
  self.framework = framework
82
92
  self._framework_config = framework_config if framework_config is not None else {}
@@ -86,6 +96,9 @@ class CharmState: # pylint: disable=too-many-instance-attributes
86
96
  self.peer_fqdns = peer_fqdns
87
97
  self.integrations = integrations or IntegrationsState()
88
98
  self.base_url = base_url
99
+ self.config_options = config_options if config_options is not None else ConfigOptions()
100
+ self.framework_config_fields = framework_config_fields or {}
101
+ self.framework_config_field_names = framework_config_field_names or set()
89
102
  self.custom_relations = custom_relations or []
90
103
 
91
104
  @classmethod
@@ -101,6 +114,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
101
114
  integration_requirers: "IntegrationRequirers",
102
115
  custom_relations: list[CustomRelation] | None = None,
103
116
  base_url: str | None = None,
117
+ config_options: ConfigOptions | None = None,
104
118
  ) -> "CharmState":
105
119
  """Initialize a new instance of the CharmState class from the associated charm.
106
120
 
@@ -114,6 +128,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
114
128
  integration_requirers: The collection of integration requirers.
115
129
  custom_relations: Custom relations.
116
130
  base_url: Base URL for the service.
131
+ config_options: Settings for existing charm configuration options.
117
132
 
118
133
  Return:
119
134
  The CharmState instance created by the provided charm.
@@ -122,16 +137,22 @@ class CharmState: # pylint: disable=too-many-instance-attributes
122
137
  CharmConfigInvalidError: If some parameter in invalid.
123
138
  RelationDataError: When relation data is either unavailable, invalid or not usable.
124
139
  """
140
+ framework_fields = framework_config_option_fields(type(framework_config), config.keys())
141
+ for option, field_name in framework_config_option_fields(
142
+ type(framework_config), config_metadata(pathlib.Path(charm_dir))["options"]
143
+ ).items():
144
+ if field_name not in framework_fields.values():
145
+ framework_fields[option] = field_name
146
+ framework_options = set(framework_fields)
125
147
  user_defined_config = {
126
148
  k.replace("-", "_"): v
127
149
  for k, v in config.items()
128
- if is_user_defined_config(k, framework)
129
- }
130
- user_defined_config = {
131
- k: v for k, v in user_defined_config.items() if k not in framework_config.dict().keys()
150
+ if is_user_defined_config(k, framework) and k not in framework_options
132
151
  }
133
152
 
134
- app_config_class = app_config_class_factory(charm_dir, framework)
153
+ app_config_class = app_config_class_factory(
154
+ charm_dir, framework, framework_options=framework_options
155
+ )
135
156
  try:
136
157
  app_config_class(**user_defined_config)
137
158
  except ValidationError as exc:
@@ -230,6 +251,9 @@ class CharmState: # pylint: disable=too-many-instance-attributes
230
251
  secret_key=(secret_key.get_secret_key() if secret_key.is_ready else None),
231
252
  is_secret_key_ready=secret_key.is_ready,
232
253
  peer_fqdns=peer_fqdns,
254
+ config_options=config_options,
255
+ framework_config_fields=framework_fields,
256
+ framework_config_field_names=set(type(framework_config).model_fields),
233
257
  integrations=integrations,
234
258
  base_url=base_url,
235
259
  custom_relations=custom_relations,
@@ -256,7 +280,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
256
280
  )
257
281
 
258
282
  @property
259
- def framework_config(self) -> dict[str, str | int | bool]:
283
+ def framework_config(self) -> dict[str, str | int | bool | dict[str, str]]:
260
284
  """Get the value of the framework application specific configuration.
261
285
 
262
286
  Returns:
@@ -413,12 +437,19 @@ def _create_config_attribute(option_name: str, option: dict) -> tuple[str, tuple
413
437
  return (option_name, type_tuple)
414
438
 
415
439
 
416
- def app_config_class_factory(charm_dir: pathlib.Path, framework: str) -> type[BaseModel]:
440
+ def app_config_class_factory(
441
+ charm_dir: pathlib.Path,
442
+ framework: str,
443
+ *,
444
+ framework_options: set[str] | None = None,
445
+ ) -> type[BaseModel]:
417
446
  """App config class factory.
418
447
 
419
448
  Args:
420
449
  charm_dir: The charm directory.
421
450
  framework: The framework name.
451
+ framework_options: Exact framework-owned option names to exclude.
452
+ If omitted, only reserved prefixes are excluded.
422
453
 
423
454
  Returns:
424
455
  Constructed app config class.
@@ -428,11 +459,43 @@ def app_config_class_factory(charm_dir: pathlib.Path, framework: str) -> type[Ba
428
459
  _create_config_attribute(option_name, config_options[option_name])
429
460
  for option_name in config_options
430
461
  if is_user_defined_config(option_name, framework)
462
+ and (framework_options is None or option_name not in framework_options)
431
463
  )
432
464
  # mypy doesn't like the model_attributes dict
433
465
  return create_model("AppConfig", **model_attributes) # type: ignore[call-overload]
434
466
 
435
467
 
468
+ def framework_config_option_fields(
469
+ framework_config_class: type[BaseModel], options: typing.Collection[str]
470
+ ) -> dict[str, str]:
471
+ """Map accepted flat config inputs to their framework model fields.
472
+
473
+ Args:
474
+ framework_config_class: Framework configuration model class.
475
+ options: Available charm configuration option names.
476
+
477
+ Returns:
478
+ Exact input names selected according to the model's alias validation rules.
479
+ """
480
+ config = framework_config_class.model_config
481
+ allow_name = config.get("validate_by_name", config.get("populate_by_name", False))
482
+ fields: dict[str, str] = {}
483
+ for field_name, model_field in framework_config_class.model_fields.items():
484
+ names: list[str] = []
485
+ alias = model_field.validation_alias
486
+ if config.get("validate_by_alias") is not False:
487
+ if isinstance(alias, str):
488
+ names.append(alias)
489
+ elif isinstance(alias, AliasChoices):
490
+ names.extend(name for name in alias.choices if isinstance(name, str))
491
+ if alias is None or allow_name:
492
+ names.append(field_name)
493
+ source = next((name for name in names if name in options), None)
494
+ if source is not None:
495
+ fields[source] = field_name
496
+ return fields
497
+
498
+
436
499
  def is_user_defined_config(option_name: str, framework: str) -> bool:
437
500
  """Check if a config option is user defined.
438
501
 
@@ -78,13 +78,13 @@ class FastAPIApp(App):
78
78
  )
79
79
  logger.debug("Pushed %s to container", filename)
80
80
 
81
- def gen_environment(self) -> dict[str, str]:
82
- """Return the application environment, adding logging vars when JSON is configured.
81
+ def _framework_environment(self) -> dict[str, str]:
82
+ """Build the config/framework layer, including structured logging variables.
83
83
 
84
84
  Returns:
85
- A dictionary representing the application environment variables.
85
+ Config and library-owned variables before relations and explicit mappings.
86
86
  """
87
- env = super().gen_environment()
87
+ env = super()._framework_environment()
88
88
  if self._workload_config.logging_format == LoggingFormat.JSON:
89
89
  existing = env.get("PYTHONPATH", "")
90
90
  env["PYTHONPATH"] = (
@@ -153,6 +153,110 @@ class PrometheusConfig(BaseModel):
153
153
  return self
154
154
 
155
155
 
156
+ class EnvVarConfig(BaseModel):
157
+ """Environment variable name for a non-secret charm configuration option.
158
+
159
+ Attributes:
160
+ env_var: Complete environment variable name.
161
+ model_config: Pydantic model configuration.
162
+ """
163
+
164
+ env_var: str = Field(alias="env-var")
165
+ model_config = ConfigDict(extra="forbid", strict=True)
166
+
167
+
168
+ class SecretEnvVarsConfig(BaseModel):
169
+ """Environment variable names for a secret charm configuration option.
170
+
171
+ Attributes:
172
+ secret_env_vars: Secret content keys mapped to complete environment variable names.
173
+ model_config: Pydantic model configuration.
174
+ """
175
+
176
+ secret_env_vars: dict[str, str] = Field(alias="secret-env-vars")
177
+ model_config = ConfigDict(extra="forbid", strict=True)
178
+
179
+
180
+ class ConfigOptions(BaseModel):
181
+ """Settings for existing charm configuration options.
182
+
183
+ Attributes:
184
+ options: Exact charm configuration option names mapped to their settings.
185
+ model_config: Pydantic model configuration.
186
+ """
187
+
188
+ options: dict[str, EnvVarConfig | SecretEnvVarsConfig] = Field(default_factory=dict)
189
+ model_config = ConfigDict(extra="forbid", strict=True)
190
+
191
+ @model_validator(mode="after")
192
+ def validate_destinations(self) -> "ConfigOptions":
193
+ """Validate destination names and reject ambiguous mappings.
194
+
195
+ Returns:
196
+ The validated configuration.
197
+
198
+ Raises:
199
+ ValueError: If a destination is invalid or used by multiple sources.
200
+ """
201
+ destinations: dict[str, str] = {}
202
+ for option, mapping in self.options.items(): # pylint: disable=no-member
203
+ entries = (
204
+ mapping.secret_env_vars.items()
205
+ if isinstance(mapping, SecretEnvVarsConfig)
206
+ else [(None, mapping.env_var)]
207
+ )
208
+ for key, destination in entries:
209
+ source = option if key is None else f"{option}.{key}"
210
+ if not destination or "\0" in destination or "=" in destination:
211
+ raise ValueError(
212
+ f"Invalid environment variable name for {source!r} in {CONFIG_FILE_NAME}: "
213
+ "destination names must be non-empty and contain neither '=' nor NUL"
214
+ )
215
+ if destination in destinations:
216
+ raise ValueError(
217
+ f"Environment variable {destination!r} is mapped from both "
218
+ f"{destinations[destination]!r} and {source!r}"
219
+ )
220
+ destinations[destination] = source
221
+ return self
222
+
223
+ def validate_sources(self, options: dict, unsupported: set[str]) -> None:
224
+ """Validate sources against charm configuration metadata.
225
+
226
+ Args:
227
+ options: Charm configuration option metadata.
228
+ unsupported: Option names without mappable environment outputs.
229
+
230
+ Raises:
231
+ PaasConfigError: If an option is unknown, unsupported, or has the wrong mapping shape.
232
+ """
233
+ for option, mapping in self.options.items(): # pylint: disable=no-member
234
+ if option not in options:
235
+ raise PaasConfigError(
236
+ f"{CONFIG_FILE_NAME}: config.options references unknown "
237
+ f"charm configuration option {option!r}"
238
+ )
239
+ if option in unsupported:
240
+ raise PaasConfigError(
241
+ f"{CONFIG_FILE_NAME}: config.options cannot map option {option!r}: "
242
+ "it has no configuration-derived environment output"
243
+ )
244
+ normalized = option.replace("-", "_")
245
+ for other in options:
246
+ if other != option and other.replace("-", "_") == normalized:
247
+ raise PaasConfigError(
248
+ f"{CONFIG_FILE_NAME}: config.options cannot distinguish charm configuration "
249
+ f"options {option!r} and {other!r}: hyphens and underscores are "
250
+ "treated identically internally"
251
+ )
252
+ is_secret = options[option]["type"] == "secret"
253
+ if is_secret != isinstance(mapping, SecretEnvVarsConfig):
254
+ expected = "secret-env-vars" if is_secret else "env-var"
255
+ raise PaasConfigError(
256
+ f"{CONFIG_FILE_NAME}: config.options.{option} must use {expected!r}"
257
+ )
258
+
259
+
156
260
  class PaasConfig(BaseModel):
157
261
  """Configuration from paas-config.yaml file.
158
262
 
@@ -165,11 +269,13 @@ class PaasConfig(BaseModel):
165
269
  port: Optional override for the port on which the application server listens.
166
270
  metrics_port: Optional override for the port on which the application serves metrics.
167
271
  metrics_path: Optional override for the path on which the application serves metrics.
272
+ config: Settings for existing charm configuration options.
168
273
  """
169
274
 
170
275
  prometheus: PrometheusConfig | None = Field(
171
276
  default=None, description="Prometheus configuration"
172
277
  )
278
+ config: ConfigOptions = Field(default_factory=ConfigOptions)
173
279
  framework_logging_format: LoggingFormat = Field(
174
280
  default=LoggingFormat.NONE,
175
281
  description="Structured logging format for the framework server (e.g. 'json').",
@@ -273,7 +379,7 @@ def read_paas_config(charm_root: pathlib.Path | None = None) -> PaasConfig:
273
379
  try:
274
380
  return PaasConfig(**config_data)
275
381
  except ValidationError as exc:
276
- error_details = build_validation_error_message(exc, underscore_to_dash=True)
382
+ error_details = build_validation_error_message(exc)
277
383
  error_msg = f"Invalid {CONFIG_FILE_NAME}: {error_details.short}"
278
384
  logger.error("%s: %s", error_msg, error_details.long)
279
385
  raise PaasConfigError(error_msg) from exc
@@ -360,20 +360,23 @@ class SpringBootApp(App):
360
360
  generate_prometheus_env = staticmethod(generate_prometheus_env)
361
361
  generate_oauth_env = staticmethod(generate_oauth_env)
362
362
 
363
- def gen_environment(self) -> dict[str, str]:
364
- """Generate a environment dictionary from the charm configurations.
363
+ def _framework_environment(self) -> dict[str, str]:
364
+ """Build the config/framework layer with Spring Boot-specific settings.
365
365
 
366
- Adds to the base environment variables specific ones for the Spring Boot framework.
366
+ Relation outputs and explicit mappings are applied afterward.
367
367
 
368
368
  Returns:
369
- A dictionary representing the application environment variables.
369
+ Config and library-owned variables before relations and explicit mappings.
370
370
  """
371
- env = super().gen_environment()
371
+ env = super()._framework_environment()
372
372
  env.pop("METRICS_PORT", None)
373
373
  env.pop("METRICS_PATH", None)
374
374
  # Name of the profiles field in SpringBootConfig
375
375
  profiles_field = "app_profiles"
376
- if profiles_field in self._charm_state.framework_config:
376
+ if (
377
+ profiles_field in self._charm_state.framework_config
378
+ and profiles_field not in self._mapped_framework_config_fields()
379
+ ):
377
380
  env["spring.profiles.active"] = str(self._charm_state.framework_config[profiles_field])
378
381
  # Required because of the strip prefix in the ingress configuration.
379
382
  env["server.forward-headers-strategy"] = "framework"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paas-charm
3
- Version: 2.0.dev3
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
File without changes
File without changes