paas-charm 2.0.dev1__tar.gz → 2.0.dev3__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.dev3}/PKG-INFO +4 -1
  2. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/pyproject.toml +4 -1
  3. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/_gunicorn/webserver.py +2 -26
  4. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/_gunicorn/workload_config.py +0 -6
  5. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/app.py +15 -4
  6. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/charm.py +140 -68
  7. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/charm_state.py +9 -1
  8. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/exceptions.py +9 -0
  9. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/expressjs/charm.py +0 -1
  10. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/fastapi/charm.py +0 -1
  11. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/go/charm.py +0 -1
  12. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/oauth.py +15 -4
  13. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/observability.py +7 -42
  14. paas_charm-2.0.dev3/src/paas_charm/relations.py +213 -0
  15. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/springboot/charm.py +1 -2
  16. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/tracing.py +4 -2
  17. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/utils.py +0 -19
  18. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm.egg-info/PKG-INFO +4 -1
  19. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm.egg-info/SOURCES.txt +1 -9
  20. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm.egg-info/requires.txt +3 -0
  21. paas_charm-2.0.dev3/src/paas_charm.egg-info/top_level.txt +1 -0
  22. paas_charm-2.0.dev1/src/paas_app_charmer/__init__.py +0 -13
  23. paas_charm-2.0.dev1/src/paas_app_charmer/django/__init__.py +0 -13
  24. paas_charm-2.0.dev1/src/paas_app_charmer/django/charm.py +0 -15
  25. paas_charm-2.0.dev1/src/paas_app_charmer/fastapi/__init__.py +0 -13
  26. paas_charm-2.0.dev1/src/paas_app_charmer/fastapi/charm.py +0 -16
  27. paas_charm-2.0.dev1/src/paas_app_charmer/flask/__init__.py +0 -13
  28. paas_charm-2.0.dev1/src/paas_app_charmer/flask/charm.py +0 -15
  29. paas_charm-2.0.dev1/src/paas_app_charmer/go/__init__.py +0 -13
  30. paas_charm-2.0.dev1/src/paas_app_charmer/go/charm.py +0 -15
  31. paas_charm-2.0.dev1/src/paas_charm.egg-info/top_level.txt +0 -2
  32. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/README.md +0 -0
  33. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/setup.cfg +0 -0
  34. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/__init__.py +0 -0
  35. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/_gunicorn/__init__.py +0 -0
  36. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/_gunicorn/charm.py +0 -0
  37. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/_gunicorn/wsgi_app.py +0 -0
  38. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/charm_utils.py +0 -0
  39. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/database_migration.py +0 -0
  40. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/databases.py +0 -0
  41. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/django/__init__.py +0 -0
  42. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/django/charm.py +0 -0
  43. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/django/cos/grafana_dashboards/django.json +0 -0
  44. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/django/cos/loki_alert_rules/.gitkeep +0 -0
  45. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/django/cos/prometheus_alert_rules/django.rule +0 -0
  46. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/expressjs/__init__.py +0 -0
  47. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/expressjs/cos/grafana_dashboards/expressjs.json +0 -0
  48. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/expressjs/cos/loki_alert_rules/.gitkeep +0 -0
  49. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/expressjs/cos/prometheus_alert_rules/.gitkeep +0 -0
  50. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/fastapi/__init__.py +0 -0
  51. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/fastapi/app.py +0 -0
  52. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/fastapi/cos/grafana_dashboards/fastapi.json +0 -0
  53. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/fastapi/cos/loki_alert_rules/.gitkeep +0 -0
  54. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/fastapi/cos/prometheus_alert_rules/.gitkeep +0 -0
  55. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/flask/__init__.py +0 -0
  56. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/flask/charm.py +0 -0
  57. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/flask/cos/grafana_dashboards/flask.json +0 -0
  58. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/flask/cos/loki_alert_rules/.gitkeep +0 -0
  59. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/flask/cos/prometheus_alert_rules/flask.rule +0 -0
  60. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/framework.py +0 -0
  61. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/go/__init__.py +0 -0
  62. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/go/cos/grafana_dashboards/go.json +0 -0
  63. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/go/cos/loki_alert_rules/.gitkeep +0 -0
  64. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/go/cos/prometheus_alert_rules/.gitkeep +0 -0
  65. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/http_proxy.py +0 -0
  66. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/paas_config.py +0 -0
  67. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/peers.py +0 -0
  68. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/rabbitmq.py +0 -0
  69. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/s3.py +0 -0
  70. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/saml.py +0 -0
  71. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/secret_key.py +0 -0
  72. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/springboot/__init__.py +0 -0
  73. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/springboot/cos/grafana_dashboards/spring_boot.json +0 -0
  74. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/springboot/cos/loki_alert_rules/.gitkeep +0 -0
  75. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/springboot/cos/prometheus_alert_rules/.gitkeep +0 -0
  76. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/templates/_gunicorn_json_logging.py +0 -0
  77. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/templates/fastapi/uvicorn-log-config.json +0 -0
  78. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/templates/fastapi/uvicorn_log_handler.py +0 -0
  79. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/templates/gunicorn.conf.py.j2 +0 -0
  80. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/src/paas_charm/valkey.py +0 -0
  81. {paas_charm-2.0.dev1 → paas_charm-2.0.dev3}/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.dev3
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.dev3"
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",
@@ -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,7 +9,7 @@ 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
@@ -21,7 +21,7 @@ from paas_charm.paas_config import LoggingFormat
21
21
  logger = logging.getLogger(__name__)
22
22
 
23
23
  if TYPE_CHECKING:
24
- from charms.openfga_k8s.v1.openfga import OpenfgaProviderAppData
24
+ from charmlibs.interfaces.openfga import OpenfgaProviderAppData
25
25
  from charms.smtp_integrator.v0.smtp import SmtpRelationData
26
26
 
27
27
  from paas_charm.databases import PaaSDatabaseRelationData
@@ -52,7 +52,6 @@ class WorkloadConfig: # pylint: disable=too-many-instance-attributes
52
52
  app_dir: the application directory in the application container.
53
53
  state_dir: the directory in the application container to store states information.
54
54
  service_name: the WSGI application pebble service name.
55
- log_files: list of files to monitor.
56
55
  metrics_path: path to scrape for metrics.
57
56
  metrics_port: port on which the application exposes metrics.
58
57
  unit_name: Name of the unit. Needed to know if schedulers should run here.
@@ -69,7 +68,6 @@ class WorkloadConfig: # pylint: disable=too-many-instance-attributes
69
68
  app_dir: pathlib.Path
70
69
  state_dir: pathlib.Path
71
70
  service_name: str
72
- log_files: List[pathlib.Path]
73
71
  metrics_path: str | None = "/metrics"
74
72
  metrics_port: int = 8080
75
73
  unit_name: str
@@ -446,6 +444,7 @@ class App: # pylint: disable=too-many-instance-attributes
446
444
 
447
445
  # 2024/04/25 - we're refactoring this method which will get rid of map_integrations_to_env
448
446
  # wrapper function. Ignore too-complex error from flake8 for now.
447
+ # pylint: disable=too-many-branches
449
448
  def gen_environment(self) -> dict[str, str]: # noqa: too-complex
450
449
  """Generate a environment dictionary from the charm configurations.
451
450
 
@@ -501,6 +500,7 @@ class App: # pylint: disable=too-many-instance-attributes
501
500
  env[f"{prefix}PEER_FQDNS"] = self._charm_state.peer_fqdns
502
501
 
503
502
  env.update(self._generate_integration_environments(prefix=self.integrations_prefix))
503
+
504
504
  return env
505
505
 
506
506
  def _generate_integration_environments(self, prefix: str = "") -> dict[str, str]:
@@ -531,6 +531,17 @@ class App: # pylint: disable=too-many-instance-attributes
531
531
  relation_data=self._charm_state.integrations.oauth,
532
532
  )
533
533
  )
534
+
535
+ for relation in self._charm_state.custom_relations:
536
+ for key, value in (relation.gen_environment() or {}).items():
537
+ if key in env:
538
+ logger.warning(
539
+ "Custom relation %s overwrites environment variable %s",
540
+ relation.relation_name,
541
+ key,
542
+ )
543
+ env[key] = value
544
+
534
545
  return {prefix + k: v for (k, v) in env.items()}
535
546
 
536
547
  @property
@@ -9,8 +9,8 @@ import pathlib
9
9
  import typing
10
10
 
11
11
  import ops
12
+ from charmlibs.interfaces.openfga import OpenFGARequires
12
13
  from charms.data_platform_libs.v0.data_interfaces import DatabaseRequiresEvent
13
- from charms.openfga_k8s.v1.openfga import OpenFGARequires
14
14
  from charms.smtp_integrator.v0.smtp import SmtpRequires
15
15
  from charms.traefik_k8s.v2.ingress import IngressPerAppRequirer
16
16
  from ops import RelationMeta
@@ -22,7 +22,11 @@ from paas_charm.charm_state import CharmState, IntegrationRequirers
22
22
  from paas_charm.charm_utils import block_if_invalid_data
23
23
  from paas_charm.database_migration import DatabaseMigration, DatabaseMigrationStatus
24
24
  from paas_charm.databases import make_database_requirers
25
- from paas_charm.exceptions import CharmConfigInvalidError, RelationDataError
25
+ from paas_charm.exceptions import (
26
+ CharmConfigInvalidError,
27
+ CustomRelationError,
28
+ RelationDataError,
29
+ )
26
30
  from paas_charm.http_proxy import PaaSHttpProxyRequirer
27
31
  from paas_charm.oauth import PaaSOAuthRequirer
28
32
  from paas_charm.observability import Observability
@@ -33,6 +37,7 @@ from paas_charm.paas_config import (
33
37
  )
34
38
  from paas_charm.peers import Peers
35
39
  from paas_charm.rabbitmq import RabbitMQRequires
40
+ from paas_charm.relations import Context, CustomRelation
36
41
  from paas_charm.s3 import PaaSS3Requirer
37
42
  from paas_charm.saml import PaaSSAMLRequirer
38
43
  from paas_charm.secret_key import SecretKeyStorage
@@ -54,10 +59,12 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
54
59
  Attrs:
55
60
  framework_config_class: base class for the framework config.
56
61
  paas_config_framework_fields: Mapping from framework fields to paas-config fields.
62
+ custom_relations: Classes describing charm relations.
57
63
  """
58
64
 
59
65
  framework_config_class: type[BaseModel]
60
66
  paas_config_framework_fields: dict[str, str] = {}
67
+ custom_relations: list[type[CustomRelation]] = []
61
68
 
62
69
  @property
63
70
  @abc.abstractmethod
@@ -128,7 +135,6 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
128
135
  )
129
136
  self._observability = Observability(
130
137
  charm=self,
131
- log_files=self._workload_config.log_files,
132
138
  container_name=self._workload_config.container_name,
133
139
  cos_dir=self.build_cos_dir(),
134
140
  metrics_port=self._workload_config.metrics_port,
@@ -173,6 +179,8 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
173
179
  self._reconcile_without_migrations,
174
180
  )
175
181
 
182
+ self._custom_relations: list[CustomRelation] = self._init_custom_relations()
183
+
176
184
  def _init_valkey(self, requires: dict[str, RelationMeta]) -> "ValkeyClientRequirer | None":
177
185
  """Initialize the Valkey relation if it is required.
178
186
 
@@ -299,21 +307,15 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
299
307
  """
300
308
  _tracing = None
301
309
  if "tracing" in requires and requires["tracing"].interface_name == "tracing":
302
- try:
303
- _tracing = PaaSTracingEndpointRequirer(
304
- self, relation_name="tracing", protocols=["otlp_http"]
305
- )
306
- self.framework.observe(
307
- _tracing.on.endpoint_changed, self._reconcile_without_migrations
308
- )
309
- self.framework.observe(
310
- _tracing.on.endpoint_removed, self._reconcile_without_migrations
311
- )
312
- except NameError:
313
- logger.exception(
314
- "Missing charm library, please run "
315
- "`charmcraft fetch-lib charms.tempo_coordinator_k8s.v0.tracing`"
316
- )
310
+ _tracing = PaaSTracingEndpointRequirer(
311
+ self, relation_name="tracing", protocols=["otlp_http"]
312
+ )
313
+ self.framework.observe(
314
+ _tracing.on.endpoint_changed, self._reconcile_without_migrations
315
+ )
316
+ self.framework.observe(
317
+ _tracing.on.endpoint_removed, self._reconcile_without_migrations
318
+ )
317
319
  return _tracing
318
320
 
319
321
  def _init_smtp(self, requires: dict[str, RelationMeta]) -> "SmtpRequires | None":
@@ -350,16 +352,10 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
350
352
  """
351
353
  openfga = None
352
354
  if "openfga" in requires and requires["openfga"].interface_name == "openfga":
353
- try:
354
- openfga = OpenFGARequires(self, self.app.name)
355
- self.framework.observe(
356
- openfga.on.openfga_store_created, self._reconcile_without_migrations
357
- )
358
- except NameError:
359
- logger.exception(
360
- "Missing charm library, please run "
361
- "`charmcraft fetch-lib charms.openfga_k8s.v1.openfga`"
362
- )
355
+ openfga = OpenFGARequires(self, self.app.name)
356
+ self.framework.observe(
357
+ openfga.on.openfga_store_created, self._reconcile_without_migrations
358
+ )
363
359
  return openfga
364
360
 
365
361
  def _init_oauth(self, requires: dict[str, RelationMeta]) -> "PaaSOAuthRequirer | None":
@@ -376,26 +372,62 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
376
372
  if len(oauth_integrations) != 1:
377
373
  return None
378
374
  endpoint_name = oauth_integrations[0][0]
379
- try:
380
- _oauth = PaaSOAuthRequirer(
381
- charm=self,
382
- base_url=self._base_url,
383
- relation_name=endpoint_name,
384
- charm_config=self.config,
385
- )
386
- self.framework.observe(
387
- _oauth.on.oauth_info_changed, self._reconcile_without_migrations
388
- )
389
- self.framework.observe(
390
- _oauth.on.oauth_info_removed, self._reconcile_without_migrations
391
- )
392
- except NameError:
393
- logger.exception(
394
- "Missing charm library, please run `charmcraft fetch-lib charms.hydra.v0.oauth`"
395
- )
396
- return None
375
+ _oauth = PaaSOAuthRequirer(
376
+ charm=self,
377
+ base_url=self._base_url,
378
+ relation_name=endpoint_name,
379
+ charm_config=self.config,
380
+ )
381
+ self.framework.observe(_oauth.on.oauth_info_changed, self._reconcile_without_migrations)
382
+ self.framework.observe(_oauth.on.oauth_info_removed, self._reconcile_without_migrations)
397
383
  return _oauth
398
384
 
385
+ def _build_custom_relation_context(self) -> Context:
386
+ """Build the read-only :class:`Context` injected into custom relations.
387
+
388
+ Returns:
389
+ A :class:`Context` populated with the current charm configuration.
390
+ """
391
+ return Context(
392
+ app_name=self.app.name,
393
+ framework_name=self._framework_name,
394
+ port=self._workload_config.port,
395
+ container_name=self._workload_config.container_name,
396
+ config=self._resolve_charm_config(),
397
+ )
398
+
399
+ def _init_custom_relations(self) -> list[CustomRelation]:
400
+ """Instantiate and wire author-supplied custom relations.
401
+
402
+ Each class in :attr:`custom_relations` is validated against the charm
403
+ metadata, instantiated as an ``ops.Object`` child, injected with
404
+ :class:`Context` and the required flag (read from the metadata
405
+ ``optional`` field), and finally set up with the reconcile callback.
406
+
407
+ Returns:
408
+ The list of instantiated custom relations.
409
+ """
410
+ if not self.custom_relations:
411
+ return []
412
+ context = self._build_custom_relation_context()
413
+ requires: dict[str, RelationMeta] = self.framework.meta.requires
414
+ relations: list[CustomRelation] = []
415
+ for relation_class in self.custom_relations:
416
+ if not isinstance(relation_class, type) or not issubclass(
417
+ relation_class, CustomRelation
418
+ ):
419
+ raise CustomRelationError(f"non-CustomRelation entry: {relation_class!r}")
420
+ relation_name = relation_class.relation_name
421
+ if relation_name not in requires:
422
+ raise CustomRelationError(f"Unused custom relation: {relation_class!r}")
423
+ instance = relation_class(self)
424
+ # Framework-injected private state; pylint: disable=protected-access
425
+ instance._context = context
426
+ instance._required = not requires[relation_name].optional
427
+ instance.setup(on_change=self._reconcile)
428
+ relations.append(instance)
429
+ return relations
430
+
399
431
  def get_framework_config(self) -> BaseModel:
400
432
  """Return the framework related configurations.
401
433
 
@@ -407,14 +439,7 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
407
439
  """
408
440
  # Will raise an AttributeError if it the attribute framework_config_class does not exist.
409
441
  framework_config_class = self.framework_config_class
410
- charm_config = {k: config_get_with_secret(self, k) for k in self.config.keys()}
411
- config = typing.cast(
412
- dict,
413
- {
414
- k: v.get_content(refresh=True) if isinstance(v, ops.Secret) else v
415
- for k, v in charm_config.items()
416
- },
417
- )
442
+ config = self._resolve_charm_config()
418
443
  for framework_field, paas_config_field in self.paas_config_framework_fields.items():
419
444
  if paas_config_field not in self._paas_config.model_fields_set:
420
445
  continue
@@ -523,12 +548,7 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
523
548
  self.update_app_and_unit_status(ops.WaitingStatus("Waiting for secret key creation"))
524
549
  return False
525
550
 
526
- missing_integrations = list(self._missing_required_integrations(charm_state))
527
- if missing_integrations:
528
- self._create_app().stop_all_services()
529
- self._database_migration.set_status_to_pending()
530
- logger.info(message := f"missing integrations: {', '.join(missing_integrations)}")
531
- self.update_app_and_unit_status(ops.BlockedStatus(message))
551
+ if self._has_missing_relations(charm_state):
532
552
  return False
533
553
 
534
554
  if self._oauth and self._oauth.is_related():
@@ -549,6 +569,33 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
549
569
  return False
550
570
  return True
551
571
 
572
+ def _stop_all_and_set_blocked(self, message: str) -> None:
573
+ """Stop services, mark database migrations as pending and set ``BlockedStatus``."""
574
+ self._create_app().stop_all_services()
575
+ self._database_migration.set_status_to_pending()
576
+ self.update_app_and_unit_status(ops.BlockedStatus(message))
577
+
578
+ def _has_missing_relations(self, charm_state: CharmState) -> bool:
579
+ """Check if any relations are missing or not ready."""
580
+ missing_integrations = list(self._missing_required_integrations(charm_state))
581
+ if missing_integrations:
582
+ logger.info(message := f"missing integrations: {', '.join(missing_integrations)}")
583
+ self._stop_all_and_set_blocked(message)
584
+ return True
585
+
586
+ try:
587
+ missing_integrations = list(self._missing_custom_relations())
588
+ if missing_integrations:
589
+ logger.info(message := f"missing integrations: {', '.join(missing_integrations)}")
590
+ self._stop_all_and_set_blocked(message)
591
+ return True
592
+ except RelationDataError as e:
593
+ logger.error(message := f"RelationDataError: {e}")
594
+ self._stop_all_and_set_blocked(message)
595
+ return True
596
+
597
+ return False
598
+
552
599
  def _missing_required_database_integrations(
553
600
  self, requires: dict[str, RelationMeta], charm_state: CharmState
554
601
  ) -> typing.Generator:
@@ -621,6 +668,29 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
621
668
  ):
622
669
  yield "valkey"
623
670
 
671
+ def _missing_custom_relations(self) -> typing.Generator:
672
+ """Return custom relations that are not established and required.
673
+
674
+ For not established but required relations this method returns a list
675
+ of the relation names. If a relation is not ready due to some problems
676
+ with missing / invalid data and raises an exception, the exception
677
+ is propagated to the caller.
678
+
679
+ Raises:
680
+ RelationDataError: if relation is not ready due to missing or
681
+ invalid data.
682
+ """ # noqa: DCO051
683
+ for relation in self._custom_relations:
684
+ name = relation.relation_name
685
+ related = any(
686
+ model_relation.active for model_relation in self.model.relations.get(name, [])
687
+ )
688
+ if related:
689
+ # may raise RelationDataError or InvalidRelationDataError
690
+ relation.ensure_ready()
691
+ elif relation.required:
692
+ yield name
693
+
624
694
  def _missing_required_integrations(
625
695
  self, charm_state: CharmState
626
696
  ) -> typing.Generator: # noqa: C901
@@ -677,6 +747,14 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
677
747
  """
678
748
  return self._create_app().gen_environment()
679
749
 
750
+ def _resolve_charm_config(self) -> dict[str, typing.Any]:
751
+ """Resolve any secrets in the config."""
752
+ charm_config = {k: config_get_with_secret(self, k) for k in self.config}
753
+ return {
754
+ k: v.get_content(refresh=True) if isinstance(v, ops.Secret) else v
755
+ for k, v in charm_config.items()
756
+ }
757
+
680
758
  def _create_charm_state(self) -> CharmState:
681
759
  """Create charm state.
682
760
 
@@ -685,14 +763,7 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
685
763
  Returns:
686
764
  New CharmState
687
765
  """
688
- charm_config = {k: config_get_with_secret(self, k) for k in self.config.keys()}
689
- config = typing.cast(
690
- dict,
691
- {
692
- k: v.get_content(refresh=True) if isinstance(v, ops.Secret) else v
693
- for k, v in charm_config.items()
694
- },
695
- )
766
+ config = self._resolve_charm_config()
696
767
  return CharmState.from_charm(
697
768
  charm_dir=self.charm_dir,
698
769
  config=config,
@@ -712,6 +783,7 @@ class PaasCharm(abc.ABC, ops.CharmBase): # pylint: disable=too-many-instance-at
712
783
  oauth=self._oauth,
713
784
  http_proxy=self._http_proxy,
714
785
  ),
786
+ custom_relations=self._custom_relations,
715
787
  base_url=self._base_url,
716
788
  )
717
789
 
@@ -17,12 +17,13 @@ from paas_charm.exceptions import (
17
17
  RelationDataError,
18
18
  )
19
19
  from paas_charm.peers import Peers
20
+ from paas_charm.relations import CustomRelation
20
21
  from paas_charm.secret_key import SecretKeyStorage
21
22
  from paas_charm.utils import build_validation_error_message, config_metadata
22
23
 
23
24
  # This is just for type checking, no need to cover this code.
24
25
  if typing.TYPE_CHECKING: # pragma: nocover
25
- from charms.openfga_k8s.v1.openfga import OpenfgaProviderAppData, OpenFGARequires
26
+ from charmlibs.interfaces.openfga import OpenfgaProviderAppData, OpenFGARequires
26
27
  from charms.smtp_integrator.v0.smtp import SmtpRelationData, SmtpRequires
27
28
  from charms.squid_forward_proxy.v0.http_proxy import ProxyConfig
28
29
  from dpcharmlibs.interfaces import ValkeyResponseModel
@@ -62,6 +63,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
62
63
  peer_fqdns: str | None = None,
63
64
  integrations: "IntegrationsState | None" = None,
64
65
  base_url: str | None = None,
66
+ custom_relations: list[CustomRelation] | None = None,
65
67
  ):
66
68
  """Initialize a new instance of the CharmState class.
67
69
 
@@ -74,6 +76,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
74
76
  peer_fqdns: The FQDN of units in the peer relation.
75
77
  integrations: Information about the integrations.
76
78
  base_url: Base URL for the service.
79
+ custom_relations: Custom relations.
77
80
  """
78
81
  self.framework = framework
79
82
  self._framework_config = framework_config if framework_config is not None else {}
@@ -83,6 +86,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
83
86
  self.peer_fqdns = peer_fqdns
84
87
  self.integrations = integrations or IntegrationsState()
85
88
  self.base_url = base_url
89
+ self.custom_relations = custom_relations or []
86
90
 
87
91
  @classmethod
88
92
  def from_charm( # pylint: disable=too-many-arguments,too-many-locals
@@ -95,6 +99,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
95
99
  secret_key: SecretKeyStorage,
96
100
  peers: Peers,
97
101
  integration_requirers: "IntegrationRequirers",
102
+ custom_relations: list[CustomRelation] | None = None,
98
103
  base_url: str | None = None,
99
104
  ) -> "CharmState":
100
105
  """Initialize a new instance of the CharmState class from the associated charm.
@@ -107,6 +112,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
107
112
  secret_key: The application secret key manager.
108
113
  peers: The peer coordination helper.
109
114
  integration_requirers: The collection of integration requirers.
115
+ custom_relations: Custom relations.
110
116
  base_url: Base URL for the service.
111
117
 
112
118
  Return:
@@ -210,6 +216,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
210
216
  f"{exc.relation} relation data is either unavailable, invalid or not usable.",
211
217
  relation=exc.relation,
212
218
  ) from exc
219
+
213
220
  peer_fqdns = None
214
221
  if peers.is_related and (peer_unit_fqdns := peers.get_peer_unit_fqdns()):
215
222
  peer_fqdns = ",".join(peer_unit_fqdns)
@@ -225,6 +232,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
225
232
  peer_fqdns=peer_fqdns,
226
233
  integrations=integrations,
227
234
  base_url=base_url,
235
+ custom_relations=custom_relations,
228
236
  )
229
237
 
230
238
  @property
@@ -68,3 +68,12 @@ class InvalidRelationDataError(RelationDataError):
68
68
 
69
69
  class InvalidCustomCOSDirectoryError(Exception):
70
70
  """Raised when the custom COS directory is invalid."""
71
+
72
+
73
+ class CustomRelationError(Exception):
74
+ """Raised when a custom relation class is invalid.
75
+
76
+ Raised when an entry in ``custom_relations`` is not a ``CustomRelation``
77
+ subclass, or when the relation class is not declared in the charm metadata
78
+ (unused custom relation).
79
+ """
@@ -68,7 +68,6 @@ class Charm(PaasCharm):
68
68
  base_dir=base_dir,
69
69
  app_dir=base_dir,
70
70
  state_dir=base_dir / "state",
71
- log_files=[],
72
71
  service_name=self._framework_name,
73
72
  metrics_path=metrics_path,
74
73
  metrics_port=metrics_port,
@@ -76,7 +76,6 @@ class Charm(PaasCharm):
76
76
  app_dir=base_dir,
77
77
  state_dir=self._state_dir,
78
78
  service_name=framework_name,
79
- log_files=[],
80
79
  metrics_path=metrics_path,
81
80
  metrics_port=metrics_port,
82
81
  unit_name=self.unit.name,
@@ -66,7 +66,6 @@ class Charm(PaasCharm):
66
66
  app_dir=base_dir,
67
67
  state_dir=self._state_dir,
68
68
  service_name=framework_name,
69
- log_files=[],
70
69
  unit_name=self.unit.name,
71
70
  metrics_path=metrics_path,
72
71
  metrics_port=metrics_port,
@@ -6,7 +6,7 @@
6
6
  import logging
7
7
 
8
8
  import ops
9
- from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer
9
+ from charmlibs.interfaces.oauth import ClientConfig, OAuthRequirer
10
10
  from ops import ConfigData
11
11
  from pydantic import BaseModel, ValidationError
12
12
 
@@ -95,6 +95,11 @@ class PaaSOAuthRequirer(OAuthRequirer):
95
95
  if not self.is_client_created():
96
96
  return None
97
97
  prod_info = self.get_provider_info()
98
+ client_config = self._client_config
99
+ if prod_info is None or client_config is None:
100
+ raise InvalidOAuthRelationDataError(
101
+ "OAuth provider or client configuration is unavailable."
102
+ )
98
103
  user_name_attribute = str(
99
104
  self._charm_config.get(f"{self._relation_name}-user-name-attribute", "sub")
100
105
  )
@@ -107,9 +112,9 @@ class PaaSOAuthRequirer(OAuthRequirer):
107
112
  "token_endpoint": prod_info.token_endpoint,
108
113
  "userinfo_endpoint": prod_info.userinfo_endpoint,
109
114
  "jwks_endpoint": prod_info.jwks_endpoint,
110
- "scopes": self._client_config.scope,
115
+ "scopes": client_config.scope,
111
116
  "provider_name": self._relation_name,
112
- "redirect_uri": self._client_config.redirect_uri,
117
+ "redirect_uri": client_config.redirect_uri,
113
118
  "user_name_attribute": user_name_attribute,
114
119
  }
115
120
  )
@@ -160,7 +165,13 @@ class PaaSOAuthRequirer(OAuthRequirer):
160
165
  def get_related_app_name(self) -> str:
161
166
  """Return the related Oauth provider charm's name.
162
167
 
168
+ Raises:
169
+ InvalidOAuthRelationDataError: If no OAuth provider is related.
170
+
163
171
  Returns:
164
172
  Name of the Oauth provider charm.
165
173
  """
166
- return self.model.relations.get(self._relation_name)[0].app.name
174
+ relations = self.model.relations.get(self._relation_name)
175
+ if not relations:
176
+ raise InvalidOAuthRelationDataError("No OAuth provider is related.")
177
+ return relations[0].app.name