postgresql-charms-single-kernel 16.3.5__tar.gz → 16.3.7__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 (57) hide show
  1. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/PKG-INFO +1 -1
  2. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/pyproject.toml +1 -1
  3. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/pyproject.toml.orig +1 -1
  4. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/charms/abstract_charm.py +49 -9
  5. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/charms/k8s_charm.py +21 -6
  6. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/charms/vm_charm.py +22 -6
  7. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/core/state.py +13 -14
  8. postgresql_charms_single_kernel-16.3.7/single_kernel_postgresql/events/database.py +243 -0
  9. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/managers/config.py +32 -9
  10. postgresql_charms_single_kernel-16.3.7/single_kernel_postgresql/managers/database.py +675 -0
  11. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/LICENSE +0 -0
  12. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/README.md +0 -0
  13. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/__init__.py +0 -0
  14. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/charmcraft.yaml +0 -0
  15. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/charms/__init__.py +0 -0
  16. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/compat/__init__.py +0 -0
  17. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/compat/postgresql.py +0 -0
  18. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/compat/pyproject.toml +0 -0
  19. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/config/__init__.py +0 -0
  20. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/config/enums.py +0 -0
  21. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/config/exceptions.py +0 -0
  22. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/config/literals.py +0 -0
  23. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/config/locales.py +0 -0
  24. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/config/pyproject.toml +0 -0
  25. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/config/statuses.py +0 -0
  26. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/core/__init__.py +0 -0
  27. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/core/config.py +0 -0
  28. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/core/peer_relation.py +0 -0
  29. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/core/relation_state.py +0 -0
  30. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/events/__init__.py +0 -0
  31. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/events/postgresql.py +0 -0
  32. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/events/tls.py +0 -0
  33. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/events/tls_transfer.py +0 -0
  34. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/lib/charms/data_platform_libs/v0/data_interfaces.py +0 -0
  35. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/lib/charms/data_platform_libs/v1/data_models.py +0 -0
  36. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/managers/__init__.py +0 -0
  37. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/managers/base.py +0 -0
  38. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/managers/cluster.py +0 -0
  39. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/managers/k8s.py +0 -0
  40. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/managers/patroni.py +0 -0
  41. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/managers/tls.py +0 -0
  42. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/templates/__init__.py +0 -0
  43. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/templates/patroni.yml.j2 +0 -0
  44. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/utils/__init__.py +0 -0
  45. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/utils/arch.py +0 -0
  46. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/utils/filesystem.py +0 -0
  47. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/utils/postgresql.py +0 -0
  48. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/utils/secret.py +0 -0
  49. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/utils/status.py +0 -0
  50. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/workload/__init__.py +0 -0
  51. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/workload/base.py +0 -0
  52. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/workload/k8s.py +0 -0
  53. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/workload/paths/__init__.py +0 -0
  54. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/workload/paths/base.py +0 -0
  55. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/workload/paths/k8s.py +0 -0
  56. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/single_kernel_postgresql/workload/paths/vm.py +0 -0
  57. {postgresql_charms_single_kernel-16.3.5 → postgresql_charms_single_kernel-16.3.7}/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.5
3
+ Version: 16.3.7
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.5"
4
+ version = "16.3.7"
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.5"
7
+ version = "16.3.7"
8
8
  readme = "README.md"
9
9
  license = {file = "LICENSE"}
10
10
  authors = [
@@ -5,18 +5,25 @@
5
5
  from abc import ABC, abstractmethod
6
6
 
7
7
  from data_platform_helpers.advanced_statuses import StatusHandler
8
+ from ops import StatusBase
8
9
  from ops.charm import CharmBase
9
10
 
10
11
  from single_kernel_postgresql.core.state import CharmState
12
+ from single_kernel_postgresql.events.database import DatabaseEventsHandler
11
13
  from single_kernel_postgresql.events.postgresql import PostgreSQLEventsHandler
12
14
  from single_kernel_postgresql.events.tls import TLS
15
+ from single_kernel_postgresql.lib.charms.data_platform_libs.v0.data_interfaces import (
16
+ DatabaseProvides,
17
+ )
13
18
  from single_kernel_postgresql.managers.cluster import ClusterManager
14
19
  from single_kernel_postgresql.managers.config import ConfigManager
20
+ from single_kernel_postgresql.managers.database import DatabaseManager
15
21
  from single_kernel_postgresql.managers.patroni import PatroniManager
16
22
  from single_kernel_postgresql.managers.tls import TLSManager
17
- from single_kernel_postgresql.workload.base import BaseWorkload
23
+ from single_kernel_postgresql.workload.base import BaseWorkload, ResourceProvider
18
24
 
19
25
  from ..config.enums import Substrates
26
+ from ..config.literals import DATABASE
20
27
  from ..utils.postgresql import PostgreSQL
21
28
 
22
29
 
@@ -42,13 +49,27 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
42
49
  )
43
50
  self.patroni_manager = PatroniManager(state=self.state, workload=self.workload)
44
51
  self.cluster_manager = ClusterManager(state=self.state, workload=self.workload)
52
+
53
+ # Client-relation subsystem: the charm is the composition root, as with the
54
+ # other managers; the handler owns only the observers and guard/defer decisions.
55
+ self.database_manager = DatabaseManager(
56
+ state=self.state,
57
+ workload=self.workload,
58
+ database_provides=DatabaseProvides(self, relation_name=DATABASE),
59
+ set_unit_status=self.set_unit_status,
60
+ )
61
+ self.database = DatabaseEventsHandler(
62
+ self, self.state, self.database_manager, self.patroni_manager, self.tls_manager
63
+ )
64
+
45
65
  self.config_manager = ConfigManager(
46
66
  state=self.state,
47
67
  workload=self.workload,
48
68
  tls_manager=self.tls_manager,
49
69
  patroni_manager=self.patroni_manager,
70
+ database_manager=self.database_manager,
71
+ resource_provider=self.get_resource_provider,
50
72
  request_restart=self.request_restart,
51
- refresh_endpoints=self.refresh_endpoints,
52
73
  restart_services=self.restart_services,
53
74
  )
54
75
 
@@ -93,20 +114,39 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
93
114
  """Access current substrate."""
94
115
  pass
95
116
 
96
- # Config-update bridges: charm-side callables the ConfigManager invokes for the
97
- # substrate-tangled restart trigger, endpoint refresh and monitoring/ldap service
98
- # restarts. They stay in the charm until their own migration phases.
117
+ # Charm-side bridges the lib calls back into. request_restart/restart_services are
118
+ # 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
120
+ # VM's Patroni-derived primary lookup. set_unit_status routes status writes through
121
+ # the charm_refresh priority gate and stays until the refresh logic itself migrates
122
+ # into the library, at which point the managers own their status writes.
99
123
  @abstractmethod
100
- def request_restart(self) -> None:
101
- """Run the substrate pre-restart side effect and acquire the restart lock."""
124
+ def get_resource_provider(self) -> ResourceProvider:
125
+ """Return the substrate's (cpu_cores, memory_bytes) introspector."""
102
126
  pass
103
127
 
104
128
  @abstractmethod
105
- def refresh_endpoints(self) -> None:
106
- """Refresh the client relation endpoints."""
129
+ def request_restart(self) -> None:
130
+ """Run the substrate pre-restart side effect and acquire the restart lock."""
107
131
  pass
108
132
 
109
133
  @abstractmethod
110
134
  def restart_services(self) -> None:
111
135
  """Restart the monitoring and LDAP-sync sidecar services."""
112
136
  pass
137
+
138
+ @abstractmethod
139
+ def set_unit_status(self, status: StatusBase) -> None:
140
+ """Set the unit status without overriding a higher-priority refresh status."""
141
+ pass
142
+
143
+ @abstractmethod
144
+ def update_config(self) -> bool:
145
+ """Re-render the Patroni configuration and apply it."""
146
+ pass
147
+
148
+ @property
149
+ @abstractmethod
150
+ def primary_endpoint(self) -> str | None:
151
+ """Address of the cluster primary, or None when there is not one."""
152
+ pass
@@ -6,6 +6,8 @@
6
6
 
7
7
  import logging
8
8
 
9
+ from ops import StatusBase
10
+
9
11
  from single_kernel_postgresql.charms.abstract_charm import AbstractPostgreSQLCharm, PostgreSQL
10
12
  from single_kernel_postgresql.config.enums import Substrates
11
13
  from single_kernel_postgresql.config.literals import CONTAINER_NAME, SYSTEM_USERS, USER
@@ -26,7 +28,6 @@ class PostgreSQLK8sCharm(AbstractPostgreSQLCharm):
26
28
  "Workload must be an instance of K8sWorkload"
27
29
  )
28
30
  self.k8s_manager = K8sManager(self.state, self.workload)
29
- self.state.resource_provider = self.k8s_manager
30
31
 
31
32
  @property
32
33
  def postgresql(self) -> PostgreSQL:
@@ -67,13 +68,27 @@ class PostgreSQLK8sCharm(AbstractPostgreSQLCharm):
67
68
  return Substrates.K8S
68
69
 
69
70
  # The concrete production charm owns these bridges (update_scrape_job_spec +
70
- # acquire_lock, update_endpoints, pebble metrics/ldap restarts), so they default to
71
- # no-ops here.
71
+ # acquire_lock, pebble metrics/ldap restarts, the refresh-aware status write and the
72
+ # config re-render), so they are minimal here.
73
+ def get_resource_provider(self) -> K8sManager:
74
+ """Return the substrate's (cpu_cores, memory_bytes) introspector."""
75
+ return self.k8s_manager
76
+
72
77
  def request_restart(self) -> None:
73
78
  """Run the substrate pre-restart side effect and acquire the restart lock."""
74
79
 
75
- def refresh_endpoints(self) -> None:
76
- """Refresh the client relation endpoints."""
77
-
78
80
  def restart_services(self) -> None:
79
81
  """Restart the monitoring and LDAP-sync sidecar services."""
82
+
83
+ def set_unit_status(self, status: StatusBase) -> None:
84
+ """Set the unit status without overriding a higher-priority refresh status."""
85
+ self.unit.status = status
86
+
87
+ def update_config(self) -> bool:
88
+ """Re-render the Patroni configuration and apply it."""
89
+ return self.config_manager.update_config(self.postgresql)
90
+
91
+ @property
92
+ def primary_endpoint(self) -> str | None:
93
+ """Address of the cluster primary's Service."""
94
+ return self.state.primary_endpoint
@@ -6,6 +6,8 @@
6
6
 
7
7
  import logging
8
8
 
9
+ from ops import StatusBase
10
+
9
11
  from single_kernel_postgresql.charms.abstract_charm import AbstractPostgreSQLCharm, PostgreSQL
10
12
  from single_kernel_postgresql.config.enums import Substrates
11
13
  from single_kernel_postgresql.config.literals import SYSTEM_USERS, USER
@@ -20,7 +22,6 @@ class PostgreSQLVMCharm(AbstractPostgreSQLCharm):
20
22
  def __init__(self, *args):
21
23
  """Initialize the PostgreSQL VM Charm."""
22
24
  super().__init__(*args)
23
- self.state.resource_provider = self.workload
24
25
 
25
26
  @property
26
27
  def postgresql(self) -> PostgreSQL:
@@ -58,13 +59,28 @@ class PostgreSQLVMCharm(AbstractPostgreSQLCharm):
58
59
  return Substrates.VM
59
60
 
60
61
  # The concrete production charm owns these bridges (pops postgresql_restarted +
61
- # acquire_lock, update_endpoints, snap metrics/ldap restarts), so they default to
62
- # no-ops here.
62
+ # acquire_lock, snap metrics/ldap restarts, the refresh-aware status write, the
63
+ # Patroni-derived primary lookup and the config re-render), so they are minimal here.
64
+ def get_resource_provider(self) -> VMWorkload:
65
+ """Return the substrate's (cpu_cores, memory_bytes) introspector."""
66
+ return self.workload
67
+
63
68
  def request_restart(self) -> None:
64
69
  """Run the substrate pre-restart side effect and acquire the restart lock."""
65
70
 
66
- def refresh_endpoints(self) -> None:
67
- """Refresh the client relation endpoints."""
68
-
69
71
  def restart_services(self) -> None:
70
72
  """Restart the monitoring and LDAP-sync sidecar services."""
73
+
74
+ def set_unit_status(self, status: StatusBase) -> None:
75
+ """Set the unit status without overriding a higher-priority refresh status."""
76
+ self.unit.status = status
77
+
78
+ def update_config(self) -> bool:
79
+ """Re-render the Patroni configuration and apply it."""
80
+ return self.config_manager.update_config(self.postgresql)
81
+
82
+ @property
83
+ def primary_endpoint(self) -> str | None:
84
+ """Address of the cluster primary, or None when there is not one."""
85
+ primary = self.patroni_manager.get_primary()
86
+ return self.patroni_manager.get_member_ip(primary) if primary else None
@@ -8,7 +8,7 @@ import re
8
8
  import socket
9
9
  from contextlib import suppress
10
10
  from functools import cached_property
11
- from typing import TYPE_CHECKING, Any, get_args
11
+ from typing import Any, get_args
12
12
 
13
13
  from data_platform_helpers.advanced_statuses import StatusesState, StatusObject
14
14
  from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope
@@ -44,9 +44,6 @@ from single_kernel_postgresql.utils import unit_name_to_pod_name
44
44
  from single_kernel_postgresql.utils.secret import translate_field_to_secret_key
45
45
  from single_kernel_postgresql.utils.status import format_status
46
46
 
47
- if TYPE_CHECKING:
48
- from single_kernel_postgresql.workload.base import ResourceProvider
49
-
50
47
 
51
48
  class CharmState(Object):
52
49
  """The global PostgreSQL Charm State."""
@@ -64,16 +61,6 @@ class CharmState(Object):
64
61
 
65
62
  self.statuses = StatusesState(self, STATUS_PEERS_RELATION)
66
63
 
67
- # Set by the charm to the substrate's resource introspector (VM workload / K8s manager).
68
- self.resource_provider: ResourceProvider | None = None
69
-
70
- @property
71
- def available_resources(self) -> tuple[int, int]:
72
- """(cpu_cores, memory_bytes) for this unit, via the substrate's provider."""
73
- if self.resource_provider is None:
74
- raise RuntimeError("resource_provider was not set by the charm")
75
- return self.resource_provider.get_available_resources()
76
-
77
64
  # -- Charm Config
78
65
  @cached_property
79
66
  def config(self) -> CharmConfig:
@@ -248,6 +235,13 @@ class CharmState(Object):
248
235
  addresses.remove(current_unit_ip)
249
236
  return addresses
250
237
 
238
+ def unit_database_address(self, unit: Unit, relation_name: str = DATABASE) -> str | None:
239
+ """The client-facing address a peer unit published for the given relation."""
240
+ if not (peer_relation := self.peer_relation):
241
+ return None
242
+ with suppress(KeyError):
243
+ return peer_relation.data[unit].get(f"{relation_name}-address")
244
+
251
245
  @property
252
246
  def host(self) -> str:
253
247
  """Current unit host."""
@@ -539,3 +533,8 @@ class CharmState(Object):
539
533
  def primary_endpoint(self) -> str:
540
534
  """Returns the endpoint of the primary instance's service."""
541
535
  return self._build_service_name("primary")
536
+
537
+ @property
538
+ def replicas_endpoint(self) -> str:
539
+ """Returns the endpoint of the replicas instances' service."""
540
+ return self._build_service_name("replicas")
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Canonical Ltd.
3
+ # See LICENSE file for licensing details.
4
+ """Client-relation events handler — owns the client-relation observers."""
5
+
6
+ import logging
7
+
8
+ from ops import BlockedStatus, ModelError, Object, RelationBrokenEvent, RelationDepartedEvent
9
+
10
+ from single_kernel_postgresql.config.enums import Substrates
11
+ from single_kernel_postgresql.config.literals import DATABASE, DATABASE_PORT
12
+ from single_kernel_postgresql.core.state import CharmState
13
+ from single_kernel_postgresql.lib.charms.data_platform_libs.v0.data_interfaces import (
14
+ DatabaseRequestedEvent,
15
+ )
16
+ from single_kernel_postgresql.managers.database import (
17
+ NO_ACCESS_TO_SECRET_MSG,
18
+ DatabaseManager,
19
+ DatabaseRequest,
20
+ )
21
+ from single_kernel_postgresql.managers.patroni import PatroniManager
22
+ from single_kernel_postgresql.managers.tls import TLSManager
23
+ from single_kernel_postgresql.utils.postgresql import (
24
+ PostgreSQLCreateDatabaseError,
25
+ PostgreSQLCreateUserError,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class DatabaseEventsHandler(Object):
32
+ """Handles the ``database`` (``postgresql_client``) provider relation.
33
+
34
+ Owns the three observers the client relation needs and drives the
35
+ :class:`~single_kernel_postgresql.managers.database.DatabaseManager` the charm
36
+ constructs, mirroring how the postgresql events handler receives its managers.
37
+
38
+ Each handler keeps its guard and its work in one observer: ``defer()`` is
39
+ per-observer, so splitting the readiness check from the action would let a deferred
40
+ action retry alone against state its guard never re-checked.
41
+
42
+ ``charm`` is deliberately untyped, as in the TLS handler: the production charms do
43
+ not derive from :class:`AbstractPostgreSQLCharm` until the cutover phase, and they
44
+ are the callers that construct this.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ charm,
50
+ state: CharmState,
51
+ database_manager: DatabaseManager,
52
+ patroni_manager: PatroniManager,
53
+ tls_manager: TLSManager,
54
+ relation_name: str = DATABASE,
55
+ ) -> None:
56
+ super().__init__(charm, key="database")
57
+ self.charm = charm
58
+ self.state = state
59
+ self.relation_name = relation_name
60
+ # The charm is the composition root: it constructs the manager and the provider
61
+ # interface; this handler owns the observers and the guard/defer decisions.
62
+ self.manager = database_manager
63
+ self.database_provides = database_manager.database_provides
64
+ # Held for the readiness guards and the endpoint-input gathers: the manager
65
+ # takes no peer-manager references.
66
+ self.patroni_manager = patroni_manager
67
+ self.tls_manager = tls_manager
68
+
69
+ self.framework.observe(
70
+ charm.on[relation_name].relation_departed, self._on_relation_departed
71
+ )
72
+ self.framework.observe(charm.on[relation_name].relation_broken, self._on_relation_broken)
73
+ self.framework.observe(
74
+ self.database_provides.on.database_requested, self._on_database_requested
75
+ )
76
+
77
+ def _ready_for_request(self) -> bool:
78
+ """Whether the cluster can serve a new client request right now."""
79
+ if not self.state.application.is_cluster_initialised:
80
+ return False
81
+ if self.state.substrate == Substrates.K8S:
82
+ return self.patroni_manager.primary_endpoint_ready
83
+ return bool(self.patroni_manager.member_started and self.charm.primary_endpoint)
84
+
85
+ def _ready_for_removal(self) -> bool:
86
+ """Whether the cluster can serve a relation removal right now.
87
+
88
+ K8s settles for a started member here where a request additionally waits on the
89
+ primary endpoint being reachable.
90
+ """
91
+ if not self.state.application.is_cluster_initialised:
92
+ return False
93
+ if self.state.substrate == Substrates.K8S:
94
+ return self.patroni_manager.member_started
95
+ return bool(self.patroni_manager.member_started and self.charm.primary_endpoint)
96
+
97
+ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
98
+ """Generate password and handle user and database creation for the related app."""
99
+ # The provider library only emits this on the leader.
100
+ if not self.state.peer.is_app_leader:
101
+ return
102
+
103
+ if not self._ready_for_request():
104
+ logger.debug(
105
+ "Deferring on_database_requested: %s",
106
+ "Cluster must be initialized before database can be requested"
107
+ if self.state.substrate == Substrates.K8S
108
+ else "cluster not initialized, Patroni not started or primary endpoint not available",
109
+ )
110
+ event.defer()
111
+ return
112
+
113
+ # The vendored property resolves the secret on access: until the grant lands it
114
+ # raises ModelError, which the manager's own guard can no longer see.
115
+ try:
116
+ requested_entity_secret_content = event.requested_entity_secret_content
117
+ except ModelError:
118
+ self.charm.set_unit_status(BlockedStatus(NO_ACCESS_TO_SECRET_MSG))
119
+ return
120
+
121
+ request = DatabaseRequest(
122
+ relation_id=event.relation.id,
123
+ database=event.database or "",
124
+ extra_user_roles=event.extra_user_roles,
125
+ prefix_matching=event.prefix_matching,
126
+ requested_entity_secret_content=requested_entity_secret_content,
127
+ )
128
+ postgresql = self.charm.postgresql
129
+
130
+ if not (creds := self.manager.get_credentials(postgresql, request)):
131
+ return
132
+ user, password = creds
133
+
134
+ if not (databases_setup := self.manager.collect_databases(postgresql, user, request)):
135
+ return
136
+ database, databases = databases_setup
137
+
138
+ self.manager.update_username_mapping(event.relation.id, user)
139
+ self.charm.update_config()
140
+ if not self.manager.are_units_in_sync():
141
+ logger.debug("Not all units have synced configuration")
142
+ event.defer()
143
+ return
144
+
145
+ extra_user_roles = self.manager.build_extra_user_roles(event.extra_user_roles)
146
+
147
+ try:
148
+ self.manager.create_relation_user_and_database(
149
+ postgresql, user, password, database, databases, extra_user_roles
150
+ )
151
+ self.database_provides.set_credentials(event.relation.id, user, password)
152
+ self._publish_k8s_inline_endpoints(event.relation.id, user, password, database)
153
+ self.database_provides.set_version(
154
+ event.relation.id, postgresql.get_postgresql_version()
155
+ )
156
+ self.database_provides.set_database(event.relation.id, database)
157
+ if self.state.substrate == Substrates.K8S:
158
+ # K8s refreshed every relation from the request handler.
159
+ self.manager.update_endpoints(
160
+ client_tls_files=self.tls_manager.get_client_tls_files()
161
+ )
162
+ else:
163
+ self.manager.update_endpoints(
164
+ event.relation.id,
165
+ online_members=self.patroni_manager.online_cluster_members(),
166
+ client_tls_files=self.tls_manager.get_client_tls_files(),
167
+ )
168
+ self.manager.update_unit_status(postgresql, event.relation)
169
+ self.charm.update_config()
170
+ except self.manager.request_errors as e:
171
+ if self.state.substrate == Substrates.K8S:
172
+ logger.exception(e)
173
+ self.charm.set_unit_status(
174
+ BlockedStatus(
175
+ e.message
176
+ if isinstance(e, PostgreSQLCreateDatabaseError | PostgreSQLCreateUserError)
177
+ and e.message is not None
178
+ # The clearer of the two charms' wordings, now shared by both.
179
+ else f"Failed to initialize {self.relation_name} relation"
180
+ )
181
+ )
182
+ return
183
+
184
+ def _publish_k8s_inline_endpoints(
185
+ self, relation_id: int, user: str, password: str, database: str
186
+ ) -> None:
187
+ """Publish the endpoint/URI/TLS fields the K8s charm wrote inside the request handler.
188
+
189
+ ``update_endpoints`` writes the same values moments later, so this is redundant;
190
+ it is kept so the port does not change K8s databag write ordering.
191
+ """
192
+ if self.state.substrate != Substrates.K8S:
193
+ return
194
+ primary = f"{self.state.primary_endpoint}:{DATABASE_PORT}"
195
+ self.database_provides.set_endpoints(relation_id, primary)
196
+ self.database_provides.set_uris(
197
+ relation_id, f"postgresql://{user}:{password}@{primary}/{database}"
198
+ )
199
+ client_tls_files = self.tls_manager.get_client_tls_files()
200
+ self.database_provides.set_tls(relation_id, "True" if all(client_tls_files) else "False")
201
+ if all(client_tls_files):
202
+ self.database_provides.set_tls_ca(relation_id, client_tls_files[1] or "")
203
+
204
+ def _on_relation_departed(self, event: RelationDepartedEvent) -> None:
205
+ """Set a flag to avoid deleting database users when not wanted."""
206
+ # Set a flag to avoid deleting database users when this unit
207
+ # is removed and receives relation broken events from related applications.
208
+ # This is needed because of https://bugs.launchpad.net/juju/+bug/1979811.
209
+ if event.departing_unit == self.state.model.unit and self.state.peer_relation:
210
+ self.state.peer.data.update({"departing": "True"})
211
+
212
+ def _on_relation_broken(self, event: RelationBrokenEvent) -> None:
213
+ """Remove the user created for this relation."""
214
+ if not self.state.peer_relation or not self._ready_for_removal():
215
+ logger.debug(
216
+ "Deferring on_relation_broken: %s",
217
+ "Cluster must be initialized before user can be deleted"
218
+ if self.state.substrate == Substrates.K8S
219
+ else "Cluster must be initialized and primary available before user can be deleted",
220
+ )
221
+ event.defer()
222
+ return
223
+
224
+ postgresql = self.charm.postgresql
225
+ self.manager.update_unit_status(postgresql, event.relation)
226
+
227
+ if self.state.peer.is_unit_departing:
228
+ logger.debug("Early exit on_relation_broken: Skipping departing unit")
229
+ return
230
+
231
+ if not self.state.peer.is_app_leader:
232
+ user = self.manager.get_username_mapping().get(
233
+ str(event.relation.id), self.manager.relation_username(event.relation.id)
234
+ )
235
+ if user in postgresql.list_users():
236
+ logger.debug("Deferring on_relation_broken: user was not deleted yet")
237
+ event.defer()
238
+ else:
239
+ self.charm.update_config()
240
+ return
241
+
242
+ self.manager.delete_relation_user(postgresql, event.relation.id)
243
+ self.charm.update_config()
@@ -36,10 +36,11 @@ from single_kernel_postgresql.managers.patroni import PatroniManager
36
36
  from single_kernel_postgresql.managers.tls import TLSManager
37
37
  from single_kernel_postgresql.utils import _change_owner, render_file
38
38
  from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient
39
- from single_kernel_postgresql.workload.base import BaseWorkload
39
+ from single_kernel_postgresql.workload.base import BaseWorkload, ResourceProvider
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.managers.database import DatabaseManager
43
44
  from single_kernel_postgresql.workload.vm import VMWorkload
44
45
 
45
46
  logger = logging.getLogger(__name__)
@@ -57,17 +58,21 @@ class ConfigManager(BaseManager):
57
58
  workload: BaseWorkload,
58
59
  tls_manager: TLSManager,
59
60
  patroni_manager: PatroniManager,
61
+ database_manager: "DatabaseManager",
62
+ resource_provider: Callable[[], ResourceProvider],
60
63
  request_restart: Callable[[], None],
61
- refresh_endpoints: Callable[[], None],
62
64
  restart_services: Callable[[], None],
63
65
  ):
64
66
  super().__init__(state, workload, "config_manager")
65
67
  self.tls_manager = tls_manager
66
68
  self.patroni_manager = patroni_manager
67
- # Charm-side bridges: the substrate-tangled restart trigger, endpoint refresh and
68
- # monitoring/ldap service restarts stay in the charm until their own migration phases.
69
+ self.database_manager = database_manager
70
+ # Resolved on use, not at construction: the K8s manager that provides it is built
71
+ # after this manager in the charm's __init__.
72
+ self.resource_provider = resource_provider
73
+ # Charm-side bridges: the substrate-tangled restart trigger and the monitoring/ldap
74
+ # service restarts stay in the charm until their own migration phases.
69
75
  self.request_restart = request_restart
70
- self.refresh_endpoints = refresh_endpoints
71
76
  self.restart_services = restart_services
72
77
 
73
78
  @staticmethod
@@ -304,6 +309,24 @@ class ConfigManager(BaseManager):
304
309
  self.tls_manager.client_tls_files_on_disk()
305
310
  )
306
311
 
312
+ def _client_relation_endpoint_inputs(self) -> dict:
313
+ """Gather the DatabaseManager.update_endpoints inputs this caller must supply.
314
+
315
+ The Patroni cluster-status query is a live REST call: gather it only where
316
+ update_endpoints would use it — on the leader (it returns early otherwise) and
317
+ on VM (K8s endpoints come from the Services state). The raw TLS files are
318
+ passed as-is: the endpoint publish uses the databag-only view, not this
319
+ manager's on-disk-augmented is_tls_enabled.
320
+ """
321
+ return {
322
+ "online_members": (
323
+ self.patroni_manager.online_cluster_members()
324
+ if self.state.peer.is_app_leader and self.state.substrate == Substrates.VM
325
+ else None
326
+ ),
327
+ "client_tls_files": self.tls_manager.get_client_tls_files(),
328
+ }
329
+
307
330
  @cached_property
308
331
  def generate_config_hash(self) -> str:
309
332
  """Generate current configuration hash."""
@@ -432,7 +455,7 @@ class ConfigManager(BaseManager):
432
455
  pass
433
456
 
434
457
  self.state.peer.tls = self.is_tls_enabled
435
- self.refresh_endpoints()
458
+ self.database_manager.update_endpoints(**self._client_relation_endpoint_inputs())
436
459
 
437
460
  # Restart PostgreSQL if TLS configuration has changed
438
461
  # (so the both old and new connections use the configuration).
@@ -443,7 +466,6 @@ class ConfigManager(BaseManager):
443
466
  def update_config(
444
467
  self,
445
468
  postgresql_client: PostgreSQLClient,
446
- user_hash: str,
447
469
  is_creating_backup: bool = False,
448
470
  # TODO add rel handler
449
471
  relations_user_databases_map: dict[str, Any] | None = None,
@@ -468,7 +490,7 @@ class ConfigManager(BaseManager):
468
490
  # Snapshot resources once so parameter-building and the API patch agree. On K8s
469
491
  # these are lightkube reads; re-fetching per callee doubled the API calls and
470
492
  # could disagree mid-scaling.
471
- cpu_cores, available_memory = self.state.available_resources
493
+ cpu_cores, available_memory = self.resource_provider().get_available_resources()
472
494
 
473
495
  # Build PostgreSQL parameters
474
496
  pg_parameters = self._build_postgresql_parameters(
@@ -514,7 +536,7 @@ class ConfigManager(BaseManager):
514
536
  # in a bundle together with the TLS certificates operator. This flag is used to
515
537
  # know when to call the Patroni API using HTTP or HTTPS.
516
538
  self.state.peer.tls = self.is_tls_enabled
517
- self.refresh_endpoints()
539
+ self.database_manager.update_endpoints(**self._client_relation_endpoint_inputs())
518
540
  logger.debug("Early exit update_config: Workload not started yet")
519
541
  return True
520
542
 
@@ -563,6 +585,7 @@ class ConfigManager(BaseManager):
563
585
 
564
586
  self.restart_services()
565
587
 
588
+ user_hash = self.database_manager.user_hash
566
589
  self.state.peer.user_hash = user_hash
567
590
  self.state.peer.config_hash = self.generate_config_hash
568
591
  if self.state.peer.is_app_leader: