postgresql-charms-single-kernel 16.3.6__tar.gz → 16.3.8__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 (59) hide show
  1. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/PKG-INFO +1 -1
  2. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/pyproject.toml +1 -1
  3. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/pyproject.toml.orig +1 -1
  4. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/charms/abstract_charm.py +7 -1
  5. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/config/literals.py +3 -0
  6. postgresql_charms_single_kernel-16.3.8/single_kernel_postgresql/events/ldap.py +98 -0
  7. postgresql_charms_single_kernel-16.3.8/single_kernel_postgresql/lib/charms/glauth_k8s/v0/ldap.py +583 -0
  8. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/config.py +4 -3
  9. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/database.py +10 -2
  10. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/LICENSE +0 -0
  11. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/README.md +0 -0
  12. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/__init__.py +0 -0
  13. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/charmcraft.yaml +0 -0
  14. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/charms/__init__.py +0 -0
  15. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/charms/k8s_charm.py +0 -0
  16. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/charms/vm_charm.py +0 -0
  17. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/compat/__init__.py +0 -0
  18. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/compat/postgresql.py +0 -0
  19. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/compat/pyproject.toml +0 -0
  20. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/config/__init__.py +0 -0
  21. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/config/enums.py +0 -0
  22. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/config/exceptions.py +0 -0
  23. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/config/locales.py +0 -0
  24. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/config/pyproject.toml +0 -0
  25. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/config/statuses.py +0 -0
  26. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/core/__init__.py +0 -0
  27. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/core/config.py +0 -0
  28. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/core/peer_relation.py +0 -0
  29. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/core/relation_state.py +0 -0
  30. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/core/state.py +0 -0
  31. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/events/__init__.py +0 -0
  32. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/events/database.py +0 -0
  33. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/events/postgresql.py +0 -0
  34. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/events/tls.py +0 -0
  35. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/events/tls_transfer.py +0 -0
  36. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/lib/charms/data_platform_libs/v0/data_interfaces.py +0 -0
  37. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/lib/charms/data_platform_libs/v1/data_models.py +0 -0
  38. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/__init__.py +0 -0
  39. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/base.py +0 -0
  40. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/cluster.py +0 -0
  41. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/k8s.py +0 -0
  42. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/patroni.py +0 -0
  43. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/managers/tls.py +0 -0
  44. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/templates/__init__.py +0 -0
  45. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/templates/patroni.yml.j2 +0 -0
  46. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/utils/__init__.py +0 -0
  47. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/utils/arch.py +0 -0
  48. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/utils/filesystem.py +0 -0
  49. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/utils/postgresql.py +0 -0
  50. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/utils/secret.py +0 -0
  51. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/utils/status.py +0 -0
  52. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/__init__.py +0 -0
  53. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/base.py +0 -0
  54. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/k8s.py +0 -0
  55. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/paths/__init__.py +0 -0
  56. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/paths/base.py +0 -0
  57. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/paths/k8s.py +0 -0
  58. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/paths/vm.py +0 -0
  59. {postgresql_charms_single_kernel-16.3.6 → postgresql_charms_single_kernel-16.3.8}/single_kernel_postgresql/workload/vm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: postgresql-charms-single-kernel
3
- Version: 16.3.6
3
+ Version: 16.3.8
4
4
  Summary: Shared and reusable code for PostgreSQL-related charms
5
5
  Author: Canonical Data Platform
6
6
  Author-email: Canonical Data Platform <data-platform@lists.launchpad.net>
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "postgresql-charms-single-kernel"
3
3
  description = "Shared and reusable code for PostgreSQL-related charms"
4
- version = "16.3.6"
4
+ version = "16.3.8"
5
5
  readme = "README.md"
6
6
  classifiers = [
7
7
  "Development Status :: 3 - Alpha",
@@ -4,7 +4,7 @@
4
4
  [project]
5
5
  name = "postgresql-charms-single-kernel"
6
6
  description = "Shared and reusable code for PostgreSQL-related charms"
7
- version = "16.3.6"
7
+ version = "16.3.8"
8
8
  readme = "README.md"
9
9
  license = {file = "LICENSE"}
10
10
  authors = [
@@ -10,6 +10,7 @@ from ops.charm import CharmBase
10
10
 
11
11
  from single_kernel_postgresql.core.state import CharmState
12
12
  from single_kernel_postgresql.events.database import DatabaseEventsHandler
13
+ from single_kernel_postgresql.events.ldap import LDAP
13
14
  from single_kernel_postgresql.events.postgresql import PostgreSQLEventsHandler
14
15
  from single_kernel_postgresql.events.tls import TLS
15
16
  from single_kernel_postgresql.lib.charms.data_platform_libs.v0.data_interfaces import (
@@ -40,6 +41,10 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
40
41
  # TLS manager so the manager can constructor-inject them for its live-fetch getters.
41
42
  self.tls = TLS(self, self.state)
42
43
 
44
+ # LDAP events handler owns the ldap requirer relation and the auth parameters
45
+ # the config manager renders into the Patroni hba section.
46
+ self.ldap = LDAP(self, self.state)
47
+
43
48
  # Managers
44
49
  self.tls_manager = TLSManager(
45
50
  state=self.state,
@@ -68,6 +73,7 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
68
73
  tls_manager=self.tls_manager,
69
74
  patroni_manager=self.patroni_manager,
70
75
  database_manager=self.database_manager,
76
+ ldap_handler=self.ldap,
71
77
  resource_provider=self.get_resource_provider,
72
78
  request_restart=self.request_restart,
73
79
  restart_services=self.restart_services,
@@ -116,7 +122,7 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
116
122
 
117
123
  # Charm-side bridges the lib calls back into. request_restart/restart_services are
118
124
  # substrate-tangled and stay until their own migration phases; update_config still
119
- # supplies the ldap/async/watcher values those phases own; primary_endpoint is the
125
+ # supplies the async/watcher values those phases own; primary_endpoint is the
120
126
  # VM's Patroni-derived primary lookup. set_unit_status routes status writes through
121
127
  # the charm_refresh priority gate and stays until the refresh logic itself migrates
122
128
  # into the library, at which point the managers own their status writes.
@@ -56,6 +56,9 @@ TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem"
56
56
  TLS_CLIENT_RELATION = "client-certificates"
57
57
  TLS_PEER_RELATION = "peer-certificates"
58
58
 
59
+ # LDAP relation names
60
+ LDAP_RELATION = "ldap"
61
+
59
62
  # Scopes
60
63
  SCOPES = Literal["app", "unit"]
61
64
  APP_SCOPE = "app"
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2025 Canonical Ltd.
3
+ # See LICENSE file for licensing details.
4
+
5
+ """LDAP events handler — owns the ``ldap`` requirer relation and the LDAP auth parameters.
6
+
7
+ Ported from the PostgreSQL VM and K8s charms' LDAP module. The substrate-specific
8
+ LDAP-sync sidecar restart stays behind the charm's ``restart_services`` bridge.
9
+ """
10
+
11
+ import logging
12
+ from typing import Any
13
+
14
+ from ops import Relation
15
+ from ops.framework import Object
16
+ from ops.model import ActiveStatus
17
+
18
+ from single_kernel_postgresql.config.literals import LDAP_RELATION
19
+ from single_kernel_postgresql.core.state import CharmState
20
+ from single_kernel_postgresql.lib.charms.glauth_k8s.v0.ldap import (
21
+ LdapProviderData,
22
+ LdapReadyEvent,
23
+ LdapRequirer,
24
+ LdapUnavailableEvent,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class LDAP(Object):
31
+ """In this class, we manage PostgreSQL LDAP access."""
32
+
33
+ def __init__(self, charm, state: CharmState):
34
+ super().__init__(charm, "ldap")
35
+ self.charm = charm
36
+ self.state = state
37
+
38
+ # LDAP relation handles the config options for LDAP access
39
+ self.ldap = LdapRequirer(self.charm, LDAP_RELATION)
40
+ self.framework.observe(self.ldap.on.ldap_ready, self._on_ldap_ready)
41
+ self.framework.observe(self.ldap.on.ldap_unavailable, self._on_ldap_unavailable)
42
+
43
+ @property
44
+ def _relation(self) -> Relation | None:
45
+ """Return the relation object."""
46
+ return self.model.get_relation(LDAP_RELATION)
47
+
48
+ def _on_ldap_ready(self, _: LdapReadyEvent) -> None:
49
+ """Handler for the LDAP ready event."""
50
+ logger.debug("Enabling LDAP connection")
51
+ if self.charm.unit.is_leader():
52
+ self.state.application.data.update({"ldap_enabled": "True"})
53
+
54
+ self.charm.update_config()
55
+ self.charm.set_unit_status(ActiveStatus())
56
+
57
+ def _on_ldap_unavailable(self, _: LdapUnavailableEvent) -> None:
58
+ """Handler for the LDAP unavailable event."""
59
+ logger.debug("Disabling LDAP connection")
60
+ if self.charm.unit.is_leader():
61
+ self.state.application.data.update({"ldap_enabled": "False"})
62
+
63
+ self.charm.update_config()
64
+
65
+ def get_relation_data(self) -> LdapProviderData | None:
66
+ """Get the LDAP info from the LDAP Provider class."""
67
+ data = self.ldap.consume_ldap_relation_data(relation=self._relation)
68
+ if data is None:
69
+ logger.warning("LDAP relation is not ready")
70
+
71
+ if not self.state.peer.is_connectivity_enabled:
72
+ logger.warning("LDAP server will not be accessible")
73
+
74
+ return data
75
+
76
+ def get_ldap_parameters(self) -> dict[str, Any]:
77
+ """Returns the LDAP configuration to use."""
78
+ if not self.state.application.is_cluster_initialised:
79
+ return {}
80
+ if not self.state.application.is_ldap_charm_related:
81
+ logger.debug("LDAP is not enabled")
82
+ return {}
83
+
84
+ relation_data = self.get_relation_data()
85
+ if relation_data is None:
86
+ return {}
87
+
88
+ return {
89
+ "ldapbasedn": relation_data.base_dn,
90
+ "ldapbinddn": relation_data.bind_dn,
91
+ "ldapbindpasswd": relation_data.bind_password,
92
+ "ldaptls": relation_data.starttls,
93
+ "ldapurl": relation_data.urls[0],
94
+ # LDAP authentication parameters that are exclusive to
95
+ # one of the two supported modes (simple bind or search+bind)
96
+ # must be put at the very end of the parameters string
97
+ "ldapsearchfilter": self.state.config.ldap_search_filter,
98
+ }
@@ -0,0 +1,583 @@
1
+ # Copyright 2023 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ """# Juju Charm Library for the `ldap` Juju Interface.
5
+
6
+ This juju charm library contains the Provider and Requirer classes for handling
7
+ the `ldap` interface.
8
+
9
+ ## Requirer Charm
10
+
11
+ The requirer charm is expected to:
12
+
13
+ - Provide information for the provider charm to deliver LDAP related
14
+ information in the juju integration, in order to communicate with the LDAP
15
+ server and authenticate LDAP operations
16
+ - Listen to the custom juju event `LdapReadyEvent` to obtain the LDAP
17
+ related information from the integration
18
+ - Listen to the custom juju event `LdapUnavailableEvent` to handle the
19
+ situation when the LDAP integration is broken
20
+
21
+ ```python
22
+
23
+ from charms.glauth_k8s.v0.ldap import (
24
+ LdapRequirer,
25
+ LdapReadyEvent,
26
+ LdapUnavailableEvent,
27
+ )
28
+
29
+ class RequirerCharm(CharmBase):
30
+ # LDAP requirer charm that integrates with an LDAP provider charm.
31
+
32
+ def __init__(self, *args):
33
+ super().__init__(*args)
34
+
35
+ self.ldap_requirer = LdapRequirer(self)
36
+ self.framework.observe(
37
+ self.ldap_requirer.on.ldap_ready,
38
+ self._on_ldap_ready,
39
+ )
40
+ self.framework.observe(
41
+ self.ldap_requirer.on.ldap_unavailable,
42
+ self._on_ldap_unavailable,
43
+ )
44
+
45
+ def _on_ldap_ready(self, event: LdapReadyEvent) -> None:
46
+ # Consume the LDAP related information
47
+ ldap_data = self.ldap_requirer.consume_ldap_relation_data(
48
+ relation=event.relation,
49
+ )
50
+
51
+ # Configure the LDAP requirer charm
52
+ ...
53
+
54
+ def _on_ldap_unavailable(self, event: LdapUnavailableEvent) -> None:
55
+ # Handle the situation where the LDAP integration is broken
56
+ ...
57
+ ```
58
+
59
+ As shown above, the library offers custom juju events to handle specific
60
+ situations, which are listed below:
61
+
62
+ - ldap_ready: event emitted when the LDAP related information is ready for
63
+ requirer charm to use.
64
+ - ldap_unavailable: event emitted when the LDAP integration is broken.
65
+
66
+ Additionally, the requirer charmed operator needs to declare the `ldap`
67
+ interface in the `metadata.yaml`:
68
+
69
+ ```yaml
70
+ requires:
71
+ ldap:
72
+ interface: ldap
73
+ ```
74
+
75
+ ## Provider Charm
76
+
77
+ The provider charm is expected to:
78
+
79
+ - Use the information provided by the requirer charm to provide LDAP related
80
+ information for the requirer charm to connect and authenticate to the LDAP
81
+ server
82
+ - Listen to the custom juju event `LdapRequestedEvent` to offer LDAP related
83
+ information in the integration
84
+
85
+ ```python
86
+
87
+ from charms.glauth_k8s.v0.ldap import (
88
+ LdapProvider,
89
+ LdapRequestedEvent,
90
+ )
91
+
92
+ class ProviderCharm(CharmBase):
93
+ # LDAP provider charm.
94
+
95
+ def __init__(self, *args):
96
+ super().__init__(*args)
97
+
98
+ self.ldap_provider = LdapProvider(self)
99
+ self.framework.observe(
100
+ self.ldap_provider.on.ldap_requested,
101
+ self._on_ldap_requested,
102
+ )
103
+
104
+ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None:
105
+ # Consume the information provided by the requirer charm
106
+ requirer_data = event.data
107
+
108
+ # Prepare the LDAP related information using the requirer's data
109
+ ldap_data = ...
110
+
111
+ # Update the integration data
112
+ self.ldap_provider.update_relations_app_data(
113
+ relation.id,
114
+ ldap_data,
115
+ )
116
+ ```
117
+
118
+ As shown above, the library offers custom juju events to handle specific
119
+ situations, which are listed below:
120
+
121
+ - ldap_requested: event emitted when the requirer charm is requesting the
122
+ LDAP related information in order to connect and authenticate to the LDAP server
123
+ """
124
+
125
+ import json
126
+ from functools import wraps
127
+ from string import Template
128
+ from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
129
+
130
+ import ops
131
+ from ops.charm import (
132
+ CharmBase,
133
+ RelationBrokenEvent,
134
+ RelationChangedEvent,
135
+ RelationCreatedEvent,
136
+ RelationEvent,
137
+ )
138
+ from ops.framework import EventSource, Handle, Object, ObjectEvents
139
+ from ops.model import Relation, SecretNotFoundError
140
+ from pydantic import StrictBool, ValidationError, version
141
+
142
+ # The unique CharmHub library identifier, never change it
143
+ LIBID = "5a535b3c4d0b40da98e29867128e57b9"
144
+
145
+ # Increment this major API version when introducing breaking changes
146
+ LIBAPI = 0
147
+
148
+ # Increment this PATCH version before using `charmcraft publish-lib` or reset
149
+ # to 0 if you are raising the major API version
150
+ LIBPATCH = 13
151
+
152
+ PYDEPS = ["pydantic"]
153
+
154
+ DEFAULT_RELATION_NAME = "ldap"
155
+ BIND_ACCOUNT_SECRET_LABEL_TEMPLATE = Template("relation-$relation_id-bind-account-secret")
156
+
157
+ PYDANTIC_IS_V1 = int(version.VERSION.split(".")[0]) < 2
158
+ if PYDANTIC_IS_V1:
159
+ # Pydantic v1 backwards compatibility logic,
160
+ # see https://docs.pydantic.dev/latest/migration/ for more info.
161
+ # This does not offer complete backwards compatibility
162
+
163
+ from pydantic import BaseModel as BaseModelV1
164
+ from pydantic import Field as FieldV1
165
+ from pydantic import validator
166
+ from pydantic.main import ModelMetaclass
167
+
168
+ def Field(*args: Any, **kwargs: Any) -> FieldV1: # noqa N802
169
+ if frozen := kwargs.pop("frozen", None):
170
+ kwargs["allow_mutations"] = not frozen
171
+ return FieldV1(*args, **kwargs)
172
+
173
+ def field_validator(*args: Any, **kwargs: Any) -> Callable:
174
+ if kwargs.get("mode") == "before":
175
+ kwargs.pop("mode")
176
+ kwargs["pre"] = True
177
+ return validator(*args, **kwargs)
178
+
179
+ encoders_config = {}
180
+
181
+ def field_serializer(*fields: str, mode: Optional[str] = None) -> Callable:
182
+ def _field_serializer(f: Callable, *args: Any, **kwargs: Any) -> Callable:
183
+ @wraps(f)
184
+ def wrapper(self: object, *args: Any, **kwargs: Any) -> Any:
185
+ return f(self, *args, **kwargs)
186
+
187
+ encoders_config[wrapper] = fields
188
+ return wrapper
189
+
190
+ return _field_serializer
191
+
192
+ class ModelCompatibilityMeta(ModelMetaclass):
193
+ def __init__(self, name: str, bases: Tuple[object], attrs: Dict) -> None:
194
+ if not hasattr(self, "_encoders"):
195
+ self._encoders = {}
196
+
197
+ self._encoders.update({
198
+ encoder: func
199
+ for func in attrs.values()
200
+ if callable(func) and func in encoders_config
201
+ for encoder in encoders_config[func]
202
+ })
203
+
204
+ super().__init__(name, bases, attrs)
205
+
206
+ class BaseModel(BaseModelV1, metaclass=ModelCompatibilityMeta):
207
+ def model_dump(self, *args: Any, **kwargs: Any) -> Dict:
208
+ d = self.dict(*args, **kwargs)
209
+ for name, f in self._encoders.items():
210
+ d[name] = f(self, d[name])
211
+ return d
212
+
213
+ else:
214
+ from pydantic import ( # type: ignore[no-redef]
215
+ BaseModel,
216
+ Field,
217
+ field_serializer,
218
+ field_validator,
219
+ )
220
+
221
+
222
+ def leader_unit(func: Callable) -> Callable:
223
+ @wraps(func)
224
+ def wrapper(
225
+ obj: Union["LdapProvider", "LdapRequirer"], *args: Any, **kwargs: Any
226
+ ) -> Optional[Any]:
227
+ if not obj.unit.is_leader():
228
+ return None
229
+
230
+ return func(obj, *args, **kwargs)
231
+
232
+ return wrapper
233
+
234
+
235
+ @leader_unit
236
+ def _update_relation_app_databag(
237
+ ldap: Union["LdapProvider", "LdapRequirer"], relation: Relation, data: dict
238
+ ) -> None:
239
+ if relation is None:
240
+ return
241
+
242
+ data = {k: str(v) if v else "" for k, v in data.items()}
243
+ relation.data[ldap.app].update(data)
244
+
245
+
246
+ class Secret:
247
+ def __init__(self, secret: ops.Secret = None) -> None:
248
+ self._secret: ops.Secret = secret
249
+
250
+ @property
251
+ def uri(self) -> str:
252
+ return self._secret.id if self._secret else ""
253
+
254
+ @classmethod
255
+ def load(
256
+ cls,
257
+ charm: CharmBase,
258
+ label: str,
259
+ ) -> Optional["Secret"]:
260
+ try:
261
+ secret = charm.model.get_secret(label=label)
262
+ except SecretNotFoundError:
263
+ return None
264
+
265
+ return Secret(secret)
266
+
267
+ @classmethod
268
+ def create_or_update(cls, charm: CharmBase, label: str, content: dict[str, str]) -> "Secret":
269
+ try:
270
+ secret = charm.model.get_secret(label=label)
271
+ secret.set_content(content=content)
272
+ except SecretNotFoundError:
273
+ secret = charm.app.add_secret(label=label, content=content)
274
+
275
+ return Secret(secret)
276
+
277
+ def grant(self, relation: Relation) -> None:
278
+ self._secret.grant(relation)
279
+
280
+ def remove(self) -> None:
281
+ self._secret.remove_all_revisions()
282
+
283
+
284
+ class LdapProviderBaseData(BaseModel):
285
+ urls: List[str] = Field(frozen=True)
286
+ ldaps_urls: List[str] = Field(frozen=True)
287
+ base_dn: str = Field(frozen=True)
288
+ starttls: StrictBool = Field(frozen=True)
289
+
290
+ @field_validator("urls", mode="before")
291
+ @classmethod
292
+ def validate_ldap_urls(cls, vs: List[str] | str) -> List[str]:
293
+ if isinstance(vs, str):
294
+ vs = json.loads(vs)
295
+ if isinstance(vs, str):
296
+ vs = [vs]
297
+
298
+ for v in vs:
299
+ if not v.startswith("ldap://"):
300
+ raise ValidationError.from_exception_data("Invalid LDAP URL scheme.")
301
+
302
+ return vs
303
+
304
+ @field_validator("ldaps_urls", mode="before")
305
+ @classmethod
306
+ def validate_ldaps_urls(cls, vs: List[str] | str) -> List[str]:
307
+ if isinstance(vs, str):
308
+ vs = json.loads(vs)
309
+ if isinstance(vs, str):
310
+ vs = [vs]
311
+
312
+ for v in vs:
313
+ if not v.startswith("ldaps://"):
314
+ raise ValidationError.from_exception_data("Invalid LDAPS URL scheme.")
315
+
316
+ return vs
317
+
318
+ @field_serializer("urls", "ldaps_urls")
319
+ def serialize_list(self, urls: List[str]) -> str:
320
+ return str(json.dumps(urls))
321
+
322
+ @field_validator("starttls", mode="before")
323
+ @classmethod
324
+ def deserialize_bool(cls, v: str | bool) -> bool:
325
+ if isinstance(v, str):
326
+ return True if v.casefold() == "true" else False
327
+
328
+ return v
329
+
330
+ @field_serializer("starttls")
331
+ def serialize_bool(self, starttls: bool) -> str:
332
+ return str(starttls)
333
+
334
+
335
+ class LdapProviderData(LdapProviderBaseData):
336
+ bind_dn: str = Field(frozen=True)
337
+ bind_password: str = Field(exclude=True)
338
+ bind_password_secret: Optional[str] = None
339
+ auth_method: Literal["simple"] = Field(frozen=True)
340
+
341
+
342
+ class LdapRequirerData(BaseModel):
343
+ user: str = Field(frozen=True)
344
+ group: str = Field(frozen=True)
345
+
346
+
347
+ class LdapRequestedEvent(RelationEvent):
348
+ """An event emitted when the LDAP integration is built."""
349
+
350
+ def __init__(self, handle: Handle, relation: Relation) -> None:
351
+ super().__init__(handle, relation, relation.app)
352
+
353
+ @property
354
+ def data(self) -> Optional[LdapRequirerData]:
355
+ relation_data = self.relation.data.get(self.relation.app)
356
+ return LdapRequirerData(**relation_data) if relation_data else None
357
+
358
+
359
+ class LdapProviderEvents(ObjectEvents):
360
+ ldap_requested = EventSource(LdapRequestedEvent)
361
+
362
+
363
+ class LdapReadyEvent(RelationEvent):
364
+ """An event when the LDAP related information is ready."""
365
+
366
+
367
+ class LdapUnavailableEvent(RelationEvent):
368
+ """An event when the LDAP integration is unavailable."""
369
+
370
+
371
+ class LdapRequirerEvents(ObjectEvents):
372
+ ldap_ready = EventSource(LdapReadyEvent)
373
+ ldap_unavailable = EventSource(LdapUnavailableEvent)
374
+
375
+
376
+ class _LdapInterface(Object):
377
+ def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None:
378
+ super().__init__(charm, relation_name)
379
+
380
+ self.charm = charm
381
+ self.app = charm.app
382
+ self.unit = charm.unit
383
+ self._relation_name = relation_name
384
+
385
+ @property
386
+ def relations(self) -> List[Relation]:
387
+ """The list of Relation instances associated with this relation_name."""
388
+ return [
389
+ relation
390
+ for relation in self.charm.model.relations[self._relation_name]
391
+ if self._is_relation_active(relation)
392
+ ]
393
+
394
+ @staticmethod
395
+ def _is_relation_active(relation: Relation) -> bool:
396
+ """Whether the relation is active based on contained data."""
397
+ try:
398
+ _ = repr(relation.data)
399
+ return True
400
+ except (RuntimeError, ops.ModelError):
401
+ return False
402
+
403
+
404
+ class LdapProvider(_LdapInterface):
405
+ on = LdapProviderEvents()
406
+
407
+ def __init__(
408
+ self,
409
+ charm: CharmBase,
410
+ relation_name: str = DEFAULT_RELATION_NAME,
411
+ ) -> None:
412
+ super().__init__(charm, relation_name)
413
+
414
+ self.framework.observe(
415
+ self.charm.on[self._relation_name].relation_changed,
416
+ self._on_relation_changed,
417
+ )
418
+ self.framework.observe(
419
+ self.charm.on[self._relation_name].relation_broken,
420
+ self._on_relation_broken,
421
+ )
422
+
423
+ @leader_unit
424
+ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
425
+ """Handle the event emitted when the requirer charm provides the necessary data."""
426
+ self.on.ldap_requested.emit(event.relation)
427
+
428
+ @leader_unit
429
+ def _on_relation_broken(self, event: RelationBrokenEvent) -> None:
430
+ """Handle the event emitted when the LDAP integration is broken."""
431
+ secret = Secret.load(
432
+ self.charm,
433
+ label=BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=event.relation.id),
434
+ )
435
+ if secret:
436
+ secret.remove()
437
+
438
+ def get_bind_password(self, relation_id: int) -> Optional[str]:
439
+ """Retrieve the bind account password for a given integration."""
440
+ try:
441
+ secret = self.charm.model.get_secret(
442
+ label=BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=relation_id)
443
+ )
444
+ except SecretNotFoundError:
445
+ return None
446
+ return secret.get_content().get("password")
447
+
448
+ def update_relations_app_data(
449
+ self,
450
+ data: Union[LdapProviderBaseData, LdapProviderData],
451
+ /,
452
+ relation_id: Optional[int] = None,
453
+ ) -> None:
454
+ """An API for the provider charm to provide the LDAP related information."""
455
+ if not (relations := self.charm.model.relations.get(self._relation_name)):
456
+ return
457
+
458
+ if relation_id is not None and isinstance(data, LdapProviderData):
459
+ relations = [relation for relation in relations if relation.id == relation_id]
460
+ secret = Secret.create_or_update(
461
+ self.charm,
462
+ BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=relation_id),
463
+ {"password": data.bind_password},
464
+ )
465
+ secret.grant(relations[0])
466
+ data.bind_password_secret = secret.uri
467
+
468
+ for relation in relations:
469
+ _update_relation_app_databag(self.charm, relation, data.model_dump())
470
+
471
+
472
+ class LdapRequirer(_LdapInterface):
473
+ """An LDAP requirer to consume data delivered by an LDAP provider charm."""
474
+
475
+ on = LdapRequirerEvents()
476
+
477
+ def __init__(
478
+ self,
479
+ charm: CharmBase,
480
+ relation_name: str = DEFAULT_RELATION_NAME,
481
+ *,
482
+ data: Optional[LdapRequirerData] = None,
483
+ ) -> None:
484
+ super().__init__(charm, relation_name)
485
+
486
+ self._data = data
487
+
488
+ self.framework.observe(
489
+ self.charm.on[self._relation_name].relation_created,
490
+ self._on_ldap_relation_created,
491
+ )
492
+ self.framework.observe(
493
+ self.charm.on[self._relation_name].relation_changed,
494
+ self._on_ldap_relation_changed,
495
+ )
496
+ self.framework.observe(
497
+ self.charm.on[self._relation_name].relation_broken,
498
+ self._on_ldap_relation_broken,
499
+ )
500
+
501
+ def _on_ldap_relation_created(self, event: RelationCreatedEvent) -> None:
502
+ """Handle the event emitted when an LDAP integration is created."""
503
+ user = self._data.user if self._data else self.app.name
504
+ group = self._data.group if self._data else self.model.name
505
+ _update_relation_app_databag(self.charm, event.relation, {"user": user, "group": group})
506
+
507
+ def _on_ldap_relation_changed(self, event: RelationChangedEvent) -> None:
508
+ """Handle the event emitted when the LDAP related information is ready."""
509
+ provider_app = event.relation.app
510
+
511
+ if not (provider_data := event.relation.data.get(provider_app)):
512
+ return
513
+
514
+ provider_data = dict(provider_data)
515
+ if self._load_provider_data(provider_data):
516
+ self.on.ldap_ready.emit(event.relation)
517
+
518
+ def _on_ldap_relation_broken(self, event: RelationBrokenEvent) -> None:
519
+ """Handle the event emitted when the LDAP integration is broken."""
520
+ self.on.ldap_unavailable.emit(event.relation)
521
+
522
+ def _load_provider_data(self, provider_data: dict) -> Optional[LdapProviderData]:
523
+ try:
524
+ secret_id = provider_data.get("bind_password_secret")
525
+ secret = self.charm.model.get_secret(id=secret_id)
526
+ provider_data["bind_password"] = secret.get_content().get("password")
527
+ return LdapProviderData(**provider_data)
528
+ except (ops.ModelError, ops.SecretNotFoundError, TypeError, ValidationError):
529
+ return None
530
+
531
+ def consume_ldap_relation_data(
532
+ self,
533
+ /,
534
+ relation: Optional[Relation] = None,
535
+ relation_id: Optional[int] = None,
536
+ ) -> Optional[LdapProviderData]:
537
+ """An API for the requirer charm to consume the LDAP related information in the application databag."""
538
+ if not relation:
539
+ relation = self.charm.model.get_relation(self._relation_name, relation_id)
540
+
541
+ if not relation:
542
+ return None
543
+
544
+ provider_data = dict(relation.data.get(relation.app))
545
+ if not provider_data:
546
+ return None
547
+
548
+ return self._load_provider_data(provider_data)
549
+
550
+ def _ready_for_relation(self, relation: Relation) -> bool:
551
+ if not relation.app:
552
+ return False
553
+
554
+ return "urls" in relation.data[relation.app] and "bind_dn" in relation.data[relation.app]
555
+
556
+ def ready(self, relation_id: Optional[int] = None) -> bool:
557
+ """Check if the resource has been created.
558
+
559
+ This function can be used to check if the Provider answered with data in the charm code
560
+ when outside an event callback.
561
+
562
+ Args:
563
+ relation_id (int, optional): When provided the check is done only for the relation id
564
+ provided, otherwise the check is done for all relations
565
+
566
+ Returns:
567
+ True or False
568
+
569
+ Raises:
570
+ IndexError: If relation_id is provided but that relation does not exist
571
+ """
572
+ if relation_id is None:
573
+ return (
574
+ all(self._ready_for_relation(relation) for relation in self.relations)
575
+ if self.relations
576
+ else False
577
+ )
578
+
579
+ try:
580
+ relation = [relation for relation in self.relations if relation.id == relation_id][0]
581
+ return self._ready_for_relation(relation)
582
+ except IndexError:
583
+ raise IndexError(f"relation id {relation_id} cannot be accessed")
@@ -40,6 +40,7 @@ from single_kernel_postgresql.workload.base import BaseWorkload, ResourceProvide
40
40
 
41
41
  if TYPE_CHECKING:
42
42
  # Import-time only: the VM workload pulls the snap charm lib, which K8s does not ship.
43
+ from single_kernel_postgresql.events.ldap import LDAP
43
44
  from single_kernel_postgresql.managers.database import DatabaseManager
44
45
  from single_kernel_postgresql.workload.vm import VMWorkload
45
46
 
@@ -59,6 +60,7 @@ class ConfigManager(BaseManager):
59
60
  tls_manager: TLSManager,
60
61
  patroni_manager: PatroniManager,
61
62
  database_manager: "DatabaseManager",
63
+ ldap_handler: "LDAP",
62
64
  resource_provider: Callable[[], ResourceProvider],
63
65
  request_restart: Callable[[], None],
64
66
  restart_services: Callable[[], None],
@@ -66,6 +68,7 @@ class ConfigManager(BaseManager):
66
68
  super().__init__(state, workload, "config_manager")
67
69
  self.tls_manager = tls_manager
68
70
  self.patroni_manager = patroni_manager
71
+ self.ldap_handler = ldap_handler
69
72
  self.database_manager = database_manager
70
73
  # Resolved on use, not at construction: the K8s manager that provides it is built
71
74
  # after this manager in the charm's __init__.
@@ -470,8 +473,6 @@ class ConfigManager(BaseManager):
470
473
  # TODO add rel handler
471
474
  relations_user_databases_map: dict[str, Any] | None = None,
472
475
  # TODO add rel handler
473
- ldap_parameters: dict[str, Any] | None = None,
474
- # TODO add rel handler
475
476
  async_primary_cluster_endpoint: str | None = None,
476
477
  async_partner_addresses: list[str] | None = None,
477
478
  async_standby_endpoints: list[str] | None = None,
@@ -520,7 +521,7 @@ class ConfigManager(BaseManager):
520
521
  parameters=pg_parameters,
521
522
  user_databases_map=relations_user_databases_map,
522
523
  slots=replication_slots,
523
- ldap_parameters=ldap_parameters,
524
+ ldap_parameters=self.ldap_handler.get_ldap_parameters(),
524
525
  async_primary_cluster_endpoint=async_primary_cluster_endpoint,
525
526
  async_partner_addresses=async_partner_addresses,
526
527
  async_standby_endpoints=async_standby_endpoints,
@@ -570,7 +570,11 @@ class DatabaseManager(BaseManager):
570
570
  for relation in self.state.model.relations.get(self.relation_name, []):
571
571
  if relation.id == relation_id:
572
572
  continue
573
- for data in relation.data.values():
573
+ for entity, data in relation.data.items():
574
+ # Requested data only lives in remote databags; a non-leader unit
575
+ # cannot read its own application databag (ops forbids that read).
576
+ if entity in (self.state.model.app, self.state.model.unit):
577
+ continue
574
578
  for extra_user_role in self.sanitize_extra_roles(data.get("extra-user-roles")):
575
579
  if (
576
580
  extra_user_role not in valid_privileges
@@ -589,7 +593,11 @@ class DatabaseManager(BaseManager):
589
593
  for relation in self.state.model.relations.get(self.relation_name, []):
590
594
  if relation.id == relation_id:
591
595
  continue
592
- for data in relation.data.values():
596
+ for entity, data in relation.data.items():
597
+ # Requested data only lives in remote databags; a non-leader unit
598
+ # cannot read its own application databag (ops forbids that read).
599
+ if entity in (self.state.model.app, self.state.model.unit):
600
+ continue
593
601
  database = data.get("database")
594
602
  if database is not None and (
595
603
  len(database) > 49 or database in INVALID_DATABASE_NAMES