postgresql-charms-single-kernel 16.3.10__tar.gz → 16.3.11__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 (75) hide show
  1. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/PKG-INFO +1 -1
  2. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/pyproject.toml +4 -4
  3. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/pyproject.toml.orig +4 -4
  4. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charms/abstract_charm.py +2 -0
  5. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/literals.py +4 -0
  6. postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/events/watcher.py +524 -0
  7. postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/scripts/authorisation_rules_observer.py +145 -0
  8. postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/scripts/cluster_topology_observer.py +177 -0
  9. postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/scripts/raft_observer.py +85 -0
  10. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/LICENSE +0 -0
  11. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/README.md +0 -0
  12. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/__init__.py +0 -0
  13. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charmcraft.yaml +0 -0
  14. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charms/__init__.py +0 -0
  15. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charms/k8s_charm.py +0 -0
  16. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charms/vm_charm.py +0 -0
  17. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/compat/__init__.py +0 -0
  18. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/compat/postgresql.py +0 -0
  19. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/compat/pyproject.toml +0 -0
  20. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/__init__.py +0 -0
  21. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/enums.py +0 -0
  22. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/exceptions.py +0 -0
  23. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/locales.py +0 -0
  24. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/pyproject.toml +0 -0
  25. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/statuses.py +0 -0
  26. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/__init__.py +0 -0
  27. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/config.py +0 -0
  28. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/peer_relation.py +0 -0
  29. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/relation_state.py +0 -0
  30. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/s3.py +0 -0
  31. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/state.py +0 -0
  32. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/__init__.py +0 -0
  33. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/backup.py +0 -0
  34. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/database.py +0 -0
  35. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/ldap.py +0 -0
  36. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/postgresql.py +0 -0
  37. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/tls.py +0 -0
  38. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/tls_transfer.py +0 -0
  39. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/lib/charms/data_platform_libs/v0/data_interfaces.py +0 -0
  40. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/lib/charms/data_platform_libs/v0/s3.py +0 -0
  41. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/lib/charms/data_platform_libs/v1/data_models.py +0 -0
  42. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/lib/charms/glauth_k8s/v0/ldap.py +0 -0
  43. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/__init__.py +0 -0
  44. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/backup.py +0 -0
  45. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/base.py +0 -0
  46. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/cluster.py +0 -0
  47. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/config.py +0 -0
  48. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/database.py +0 -0
  49. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/k8s.py +0 -0
  50. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/patroni.py +0 -0
  51. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/restore.py +0 -0
  52. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/tls.py +0 -0
  53. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/scripts/rotate_logs.py +0 -0
  54. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/__init__.py +0 -0
  55. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/k8s/pgbackrest.conf.j2 +0 -0
  56. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/k8s/pgbackrest.logrotate.j2 +0 -0
  57. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/patroni.yml.j2 +0 -0
  58. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/vm/pgbackrest.conf.j2 +0 -0
  59. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/vm/pgbackrest.logrotate.j2 +0 -0
  60. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/__init__.py +0 -0
  61. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/arch.py +0 -0
  62. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/backup.py +0 -0
  63. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/filesystem.py +0 -0
  64. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/postgresql.py +0 -0
  65. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/s3.py +0 -0
  66. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/secret.py +0 -0
  67. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/status.py +0 -0
  68. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/__init__.py +0 -0
  69. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/base.py +0 -0
  70. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/k8s.py +0 -0
  71. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/__init__.py +0 -0
  72. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/base.py +0 -0
  73. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/k8s.py +0 -0
  74. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/vm.py +0 -0
  75. {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/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.10
3
+ Version: 16.3.11
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.10"
4
+ version = "16.3.11"
5
5
  readme = "README.md"
6
6
  classifiers = [
7
7
  "Development Status :: 3 - Alpha",
@@ -157,12 +157,12 @@ exclude = [
157
157
  ]
158
158
 
159
159
  [dependency-groups]
160
- format = ["ruff==0.16.8"]
160
+ format = ["ruff==0.16.10"]
161
161
  lint = [
162
162
  "codespell==2.4.3",
163
- "ty==0.0.82",
163
+ "ty==0.0.84",
164
164
  ]
165
165
  unit = [
166
- "coverage[toml]==7.16.1",
166
+ "coverage[toml]==7.16.2",
167
167
  "pytest==9.1.1",
168
168
  ]
@@ -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.10"
7
+ version = "16.3.11"
8
8
  readme = "README.md"
9
9
  license = {file = "LICENSE"}
10
10
  authors = [
@@ -63,14 +63,14 @@ module-root = ""
63
63
 
64
64
  [dependency-groups]
65
65
  format = [
66
- "ruff==0.16.8"
66
+ "ruff==0.16.10"
67
67
  ]
68
68
  lint = [
69
69
  "codespell==2.4.3",
70
- "ty==0.0.82"
70
+ "ty==0.0.84"
71
71
  ]
72
72
  unit = [
73
- "coverage[toml]==7.16.1",
73
+ "coverage[toml]==7.16.2",
74
74
  "pytest==9.1.1"
75
75
  ]
76
76
 
@@ -13,6 +13,7 @@ from single_kernel_postgresql.events.database import DatabaseEventsHandler
13
13
  from single_kernel_postgresql.events.ldap import LDAP
14
14
  from single_kernel_postgresql.events.postgresql import PostgreSQLEventsHandler
15
15
  from single_kernel_postgresql.events.tls import TLS
16
+ from single_kernel_postgresql.events.watcher import WatcherEventsHandler
16
17
  from single_kernel_postgresql.lib.charms.data_platform_libs.v0.data_interfaces import (
17
18
  DatabaseProvides,
18
19
  )
@@ -94,6 +95,7 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
94
95
  self.config_manager,
95
96
  self.patroni_manager,
96
97
  )
98
+ self.watcher_handler = WatcherEventsHandler(self, self.workload, self.state)
97
99
 
98
100
  # Status Handler
99
101
  self.status_handler = StatusHandler(
@@ -207,3 +207,7 @@ K8S_PGBACKREST_LOGS_SYMLINK_PATH = "var/log/pgbackrest"
207
207
  VM_PATRONICTL_EXECUTABLE = "charmed-postgresql.patronictl"
208
208
  PATRONICTL_REMOVE_CONFIRMATION = "Yes I am aware"
209
209
  RESTORE_REPEAT_CAUSE = "restore-backup"
210
+
211
+ # Watcher relation
212
+ RAFT_PORT = 2222
213
+ RAFT_PARTNER_PREFIX = "partner_node_status_server_"
@@ -0,0 +1,524 @@
1
+ # Copyright 2026 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ """PostgreSQL Watcher Relation implementation.
5
+
6
+ This module handles the relation between the PostgreSQL charm and a watcher/witness charm
7
+ that participates in the Raft consensus for stereo mode (2-node PostgreSQL clusters).
8
+
9
+ The watcher provides quorum without storing data, enabling automatic failover
10
+ when one of the two PostgreSQL nodes becomes unavailable.
11
+ """
12
+
13
+ import contextlib
14
+ import json
15
+ import logging
16
+ import os
17
+ from functools import cached_property
18
+ from typing import TYPE_CHECKING
19
+
20
+ from ops import (
21
+ Object,
22
+ Relation,
23
+ RelationBrokenEvent,
24
+ RelationChangedEvent,
25
+ RelationJoinedEvent,
26
+ Secret,
27
+ SecretNotFoundError,
28
+ )
29
+
30
+ with contextlib.suppress(ImportError):
31
+ from pysyncobj.utility import TcpUtility
32
+
33
+ from single_kernel_postgresql.config.enums import Substrates
34
+ from single_kernel_postgresql.config.literals import (
35
+ RAFT_PARTNER_PREFIX,
36
+ RAFT_PASSWORD_KEY,
37
+ RAFT_PORT,
38
+ REPLICATION_CONSUMER_RELATION,
39
+ REPLICATION_OFFER_RELATION,
40
+ WATCHER_OFFER_RELATION,
41
+ WATCHER_PASSWORD_KEY,
42
+ WATCHER_SECRET_LABEL,
43
+ WATCHER_USER,
44
+ )
45
+ from single_kernel_postgresql.utils import new_password
46
+
47
+ if TYPE_CHECKING:
48
+ from single_kernel_postgresql.core.state import CharmState
49
+ from single_kernel_postgresql.workload.base import BaseWorkload
50
+
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ class WatcherEventsHandler(Object):
56
+ """Handles the watcher relation for stereo mode support."""
57
+
58
+ def __init__(self, charm, workload: "BaseWorkload", state: "CharmState"):
59
+ """Initialize the watcher relation handler."""
60
+ super().__init__(charm, WATCHER_OFFER_RELATION)
61
+ self.charm = charm
62
+ self.state = state
63
+ self.workload = workload
64
+
65
+ if self.state.substrate == Substrates.VM:
66
+ self.framework.observe(
67
+ self.charm.on[WATCHER_OFFER_RELATION].relation_joined,
68
+ self._on_watcher_relation_joined,
69
+ )
70
+ self.framework.observe(
71
+ self.charm.on[WATCHER_OFFER_RELATION].relation_changed,
72
+ self._on_watcher_relation_changed,
73
+ )
74
+ self.framework.observe(
75
+ self.charm.on[WATCHER_OFFER_RELATION].relation_broken,
76
+ self._on_watcher_relation_broken,
77
+ )
78
+
79
+ @cached_property
80
+ def _relation(self) -> Relation | None:
81
+ """Return the watcher relation if it exists."""
82
+ return self.model.get_relation(WATCHER_OFFER_RELATION)
83
+
84
+ @property
85
+ def is_watcher_connected(self) -> bool:
86
+ """Check if a watcher is connected to this cluster.
87
+
88
+ Returns:
89
+ True if a watcher is connected, False otherwise.
90
+ """
91
+ if self.state.substrate == Substrates.K8S:
92
+ return True
93
+
94
+ try:
95
+ syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3)
96
+ raft_status = syncobj_util.executeCommand(f"127.0.0.1:{RAFT_PORT}", ["status"])
97
+ if raft_status:
98
+ # Check if watcher is in the partner_node_status entries
99
+ member_key = f"{RAFT_PARTNER_PREFIX}{self.watcher_raft_address}"
100
+ return member_key in raft_status
101
+ except Exception as e:
102
+ logger.debug(f"Error checking Raft membership: {e}")
103
+ return False
104
+
105
+ def enable_watcher(self) -> None:
106
+ """Clear up disable flag."""
107
+ if (
108
+ self.state.substrate == Substrates.K8S
109
+ or not self._relation
110
+ or not self.state.peer.is_app_leader
111
+ ):
112
+ return None
113
+
114
+ self._relation.data[self.state.peer.unit.app].pop("disable-watcher", None)
115
+ self.update_watcher_secret()
116
+
117
+ def disable_watcher(self) -> None:
118
+ """Inform watcher to stop service."""
119
+ if (
120
+ self.state.substrate == Substrates.K8S
121
+ or not self._relation
122
+ or not self.state.peer.is_app_leader
123
+ ):
124
+ return None
125
+
126
+ self._relation.data[self.state.peer.unit.app].update({"disable-watcher": "True"})
127
+ try:
128
+ if self.watcher_raft_address:
129
+ self.charm._patroni.remove_raft_member(self.watcher_raft_address)
130
+ except Exception as e:
131
+ logger.warning(f"Error remove Raft watcher: {e}")
132
+
133
+ @cached_property
134
+ def is_active(self) -> bool:
135
+ """Check if the watcher should be added to peers."""
136
+ if self.state.substrate == Substrates.K8S or not self._relation:
137
+ return False
138
+
139
+ return self._relation.data[self._relation.app].get("raft-status") == "connected"
140
+
141
+ @cached_property
142
+ def watcher_raft_address(self) -> str | None:
143
+ """Return the watcher's Raft address for inclusion in partner_addrs.
144
+
145
+ Returns:
146
+ The watcher's Raft address (ip:port), or None if not available.
147
+ """
148
+ if self.state.substrate == Substrates.K8S or not self._relation:
149
+ return None
150
+
151
+ unit_address = None
152
+ port = None
153
+ # Get the watcher unit address from the relation data
154
+ for unit in self._relation.units:
155
+ if unit_address := self._relation.data[unit].get("unit-address"):
156
+ break
157
+ port_str = self._relation.data[self._relation.app].get("watcher-raft-port")
158
+ if port_str:
159
+ try:
160
+ port = int(port_str)
161
+ except ValueError:
162
+ logger.warning(f"Invalid watcher-raft-port value: {port_str}")
163
+
164
+ if unit_address and port is not None:
165
+ return f"{unit_address}:{port}"
166
+ return None
167
+
168
+ def _on_watcher_relation_joined(self, event: RelationJoinedEvent) -> None:
169
+ """Handle a new watcher joining the relation.
170
+
171
+ Shares cluster information including Raft password and PostgreSQL endpoints
172
+ with the watcher charm.
173
+
174
+ Args:
175
+ event: The relation joined event.
176
+ """
177
+ if self.state.substrate == Substrates.K8S:
178
+ return
179
+
180
+ # Every unit should publish its own per-unit data.
181
+ self.update_unit_address(event.relation)
182
+
183
+ if not self.state.peer.is_app_leader:
184
+ return
185
+
186
+ logger.info("Watcher relation joined, sharing cluster information")
187
+
188
+ # Ensure watcher user exists before creating the secret,
189
+ # so both raft-password and watcher-password are included from the start
190
+ watcher_pw = self._ensure_watcher_user()
191
+
192
+ # Create or get the watcher secret containing Raft password and watcher password
193
+ secret = self._get_or_create_watcher_secret(watcher_password=watcher_pw)
194
+ if secret is None:
195
+ logger.warning("Failed to create watcher secret, deferring event")
196
+ event.defer()
197
+ return
198
+
199
+ # Grant the secret to the watcher application
200
+ try:
201
+ secret.grant(event.relation)
202
+ except Exception as e:
203
+ logger.warning(f"Failed to grant secret to watcher: {e}")
204
+
205
+ # Update relation data with cluster information
206
+ self._update_relation_data(event.relation)
207
+
208
+ def _on_watcher_relation_changed(self, event: RelationChangedEvent) -> None:
209
+ """Handle watcher relation data changes.
210
+
211
+ Updates Patroni configuration to include the watcher in the Raft cluster.
212
+
213
+ Args:
214
+ event: The relation changed event.
215
+ """
216
+ # Keep this unit's relation data current on every relation-changed hook.
217
+ self.update_unit_address(event.relation)
218
+
219
+ if not self.charm.is_cluster_initialised:
220
+ logger.debug("Cluster not initialized, deferring watcher relation changed")
221
+ event.defer()
222
+ return
223
+
224
+ watcher_address = None
225
+ for unit in event.relation.units:
226
+ if unit_address := event.relation.data[unit].get("unit-address"):
227
+ watcher_address = unit_address
228
+ break
229
+
230
+ if watcher_address:
231
+ logger.info(f"Watcher address updated: {watcher_address}")
232
+ # Only the leader handles Raft membership changes and user management
233
+ # to avoid race conditions between multiple PostgreSQL units
234
+ if self.state.peer.is_app_leader:
235
+ self.charm._patroni.cleanup_raft_cluster()
236
+ self._ensure_watcher_user()
237
+ # Update Patroni configuration to include watcher in Raft
238
+ self.charm.update_config()
239
+
240
+ # Update relation data for the watcher
241
+ if self.state.peer.is_app_leader:
242
+ self._update_relation_data(event.relation)
243
+
244
+ def _on_watcher_relation_broken(self, event: RelationBrokenEvent) -> None:
245
+ """Handle watcher relation being broken.
246
+
247
+ Updates Patroni configuration to remove the watcher from the Raft cluster.
248
+
249
+ Args:
250
+ event: The relation broken event.
251
+ """
252
+ if not self.charm.is_cluster_initialised:
253
+ return
254
+
255
+ logger.info("Watcher relation broken, updating Patroni configuration")
256
+ self.watcher_raft_address = None
257
+ if self.state.peer.is_app_leader:
258
+ self.charm._patroni.cleanup_raft_cluster()
259
+ # Update Patroni configuration without the watcher
260
+ self.charm.update_config()
261
+
262
+ def _ensure_watcher_user(self) -> str | None:
263
+ """Ensure the watcher PostgreSQL user exists for health checks.
264
+
265
+ Creates the watcher user if it doesn't exist, and updates the watcher
266
+ secret with the password so the watcher charm can authenticate.
267
+
268
+ Returns:
269
+ The watcher password, or None if user creation failed.
270
+ """
271
+ if not self.charm.is_cluster_initialised:
272
+ logger.debug("Cluster not initialized, cannot create watcher user")
273
+ return None
274
+
275
+ try:
276
+ users = self.charm.postgresql.list_users()
277
+ if WATCHER_USER in users:
278
+ logger.debug(f"User {WATCHER_USER} already exists")
279
+ # Get existing password from secret if available
280
+ try:
281
+ secret = self.model.get_secret(label=WATCHER_SECRET_LABEL)
282
+ content = secret.get_content(refresh=True)
283
+ existing_pw = content.get(WATCHER_PASSWORD_KEY)
284
+ if existing_pw:
285
+ return existing_pw
286
+ # Password not in secret — fall through to regenerate
287
+ except SecretNotFoundError:
288
+ # Secret doesn't exist yet, will be created below with new password
289
+ pass
290
+
291
+ # Generate a password for the watcher user
292
+ watcher_password = new_password()
293
+
294
+ # Create the watcher user (minimal privileges - only needs to connect and run SELECT 1)
295
+ if WATCHER_USER not in users:
296
+ logger.info(f"Creating PostgreSQL user: {WATCHER_USER}")
297
+ self.charm.postgresql.create_user(WATCHER_USER, watcher_password)
298
+ else:
299
+ # User exists but we don't have the password, update it
300
+ logger.info(f"Updating password for PostgreSQL user: {WATCHER_USER}")
301
+ self.charm.postgresql.update_user_password(WATCHER_USER, watcher_password)
302
+
303
+ # Grant connect privilege on postgres database (for health checks)
304
+ self.charm.postgresql.grant_database_privileges_to_user(
305
+ WATCHER_USER, "postgres", ["connect"]
306
+ )
307
+
308
+ # Update the secret to include the watcher password
309
+ self._update_watcher_secret_with_password(watcher_password)
310
+
311
+ return watcher_password
312
+
313
+ except Exception as e:
314
+ logger.error(f"Failed to ensure watcher user: {e}")
315
+ return None
316
+
317
+ def _update_watcher_secret_with_password(self, watcher_password: str) -> None:
318
+ """Update the watcher secret to include the watcher password.
319
+
320
+ Args:
321
+ watcher_password: The password for the watcher PostgreSQL user.
322
+ """
323
+ try:
324
+ secret = self.model.get_secret(label=WATCHER_SECRET_LABEL)
325
+ content = secret.get_content(refresh=True)
326
+ content[WATCHER_PASSWORD_KEY] = watcher_password
327
+ secret.set_content(content)
328
+ logger.info("Updated watcher secret with watcher password")
329
+ except SecretNotFoundError:
330
+ logger.warning(
331
+ "Watcher secret not found, password change cannot be propagated to watcher. "
332
+ "It will be synced on next relation-changed event."
333
+ )
334
+ except Exception as e:
335
+ logger.error(f"Failed to update watcher secret with password: {e}")
336
+
337
+ def _get_existing_watcher_password(self) -> str | None:
338
+ """Get the watcher password from an existing secret if available."""
339
+ try:
340
+ secret = self.model.get_secret(label=WATCHER_SECRET_LABEL)
341
+ content = secret.get_content(refresh=True)
342
+ return content.get(WATCHER_PASSWORD_KEY)
343
+ except SecretNotFoundError:
344
+ return None
345
+ except Exception as e:
346
+ logger.debug(f"Failed to get existing watcher password: {e}")
347
+ return None
348
+
349
+ def _get_or_create_watcher_secret(self, watcher_password: str | None = None) -> Secret | None:
350
+ """Get or create the secret for sharing Raft credentials with the watcher.
351
+
352
+ Args:
353
+ watcher_password: Optional watcher password to include in the secret.
354
+
355
+ Returns:
356
+ The Juju secret containing Raft password, or None if creation failed.
357
+ """
358
+ try:
359
+ secret = self.model.get_secret(label=WATCHER_SECRET_LABEL)
360
+ logger.debug("Found existing watcher secret")
361
+ return secret
362
+ except SecretNotFoundError:
363
+ logger.debug("No existing watcher secret found, creating new one")
364
+
365
+ # Get the Raft password from the internal secret
366
+ try:
367
+ raft_password = self.state.application.raft_password
368
+ except Exception as e:
369
+ logger.warning(f"Error getting raft_password: {e}")
370
+ raft_password = None
371
+
372
+ if not raft_password:
373
+ logger.warning("Raft password not available, cannot create secret")
374
+ return None
375
+
376
+ # Create a new secret with the Raft password (and watcher password if available)
377
+ try:
378
+ content = {RAFT_PASSWORD_KEY: raft_password}
379
+ # Include watcher password if provided, or look it up from existing secret
380
+ watcher_pw = watcher_password or self._get_existing_watcher_password()
381
+ if watcher_pw:
382
+ content[WATCHER_PASSWORD_KEY] = watcher_pw
383
+ secret = self.model.app.add_secret(
384
+ content=content,
385
+ label=WATCHER_SECRET_LABEL,
386
+ )
387
+ logger.info("Created watcher secret")
388
+ return secret
389
+ except Exception as e:
390
+ logger.error(f"Failed to create watcher secret: {e}")
391
+ return None
392
+
393
+ def _update_relation_data(self, relation: Relation) -> None:
394
+ """Update the relation data with cluster information.
395
+
396
+ Args:
397
+ relation: The watcher relation.
398
+ """
399
+ if not self.state.peer.is_app_leader:
400
+ return
401
+
402
+ # Get the secret ID for sharing
403
+ try:
404
+ secret = self.model.get_secret(label=WATCHER_SECRET_LABEL)
405
+ secret_id = secret.id
406
+ if not secret_id:
407
+ # When a secret is retrieved by label, the ops library may lazily load the ID.
408
+ # Calling get_info() forces it to resolve.
409
+ secret_id = secret.get_info().id
410
+ if secret_id is None:
411
+ logger.warning("Watcher secret has no ID")
412
+ return
413
+ # Ensure the secret is granted to the watcher relation (handles
414
+ # cases where the secret was recreated after initial relation_joined)
415
+ with contextlib.suppress(Exception):
416
+ secret.grant(relation)
417
+ except SecretNotFoundError:
418
+ logger.warning("Watcher secret not found")
419
+ return
420
+ except Exception as e:
421
+ logger.error(f"Error getting secret: {e}")
422
+ return
423
+
424
+ # Collect PostgreSQL unit endpoints using fresh IPs from unit relation data.
425
+ # _units_ips reads directly from unit relation data (always fresh), while
426
+ # _peer_members_ips reads from app peer data (may be stale after network disruptions).
427
+ pg_endpoints: list[str] = sorted(self.charm._units_ips)
428
+ if not pg_endpoints:
429
+ logger.warning("No PostgreSQL endpoints available")
430
+ return
431
+
432
+ # Update relation data
433
+ relation.data[self.state.peer.unit.app].update({
434
+ "cluster-name": self.state.cluster_name,
435
+ "raft-secret-id": secret_id,
436
+ "raft-partner-addrs": json.dumps(pg_endpoints),
437
+ "raft-port": str(RAFT_PORT),
438
+ "patroni-cas": self.charm.tls_manager.get_peer_ca_bundle(),
439
+ "standby-clusters": json.dumps(self._get_standby_clusters()),
440
+ "tls-enabled": "true" if self.charm.is_tls_enabled else "false",
441
+ })
442
+ self.update_watcher_secret()
443
+
444
+ # Also share this unit's per-unit data.
445
+ self.update_unit_address(relation)
446
+
447
+ def update_unit_address(self, relation: Relation | None = None) -> None:
448
+ """Update this unit's address in the watcher relation.
449
+
450
+ Called when the unit's IP changes (e.g., after network isolation).
451
+ This updates unit-specific data in the relation, not application data.
452
+ Can be called by any unit, not just the leader.
453
+ """
454
+ if relation is None:
455
+ relation = self._relation
456
+
457
+ if not relation:
458
+ return
459
+
460
+ if not (unit_ip := self.state.unit_ip):
461
+ return
462
+
463
+ relation.data[self.state.peer.unit]["version"] = self.workload.get_postgresql_version()
464
+ if self.charm.refresh:
465
+ relation.data[self.state.peer.unit]["snap"] = self.charm.refresh.pinned_snap_revision
466
+ current_address = relation.data[self.state.peer.unit].get("unit-address")
467
+ if current_address != unit_ip:
468
+ logger.info(
469
+ f"Updating unit-address in watcher relation from {current_address} to {unit_ip}"
470
+ )
471
+ relation.data[self.state.peer.unit]["unit-address"] = unit_ip
472
+
473
+ unit_az = os.environ.get("JUJU_AVAILABILITY_ZONE")
474
+ current_az = relation.data[self.state.peer.unit].get("unit-az")
475
+ if unit_az and current_az != unit_az:
476
+ relation.data[self.state.peer.unit]["unit-az"] = unit_az
477
+
478
+ def update_endpoints(self) -> None:
479
+ """Update the watcher with current cluster endpoints.
480
+
481
+ Called when cluster membership changes (peer joins/departs).
482
+ Also dynamically adds new PostgreSQL peers to the running Raft cluster.
483
+ """
484
+ if self.state.substrate == Substrates.K8S:
485
+ return
486
+
487
+ if relation := self._relation:
488
+ if self.state.peer.is_app_leader:
489
+ self._update_relation_data(relation)
490
+ self.update_unit_address(relation)
491
+
492
+ def _get_standby_clusters(self) -> list[str]:
493
+ """Return the names of related standby clusters."""
494
+ standby_clusters = []
495
+ for relation in [
496
+ self.model.get_relation(REPLICATION_OFFER_RELATION),
497
+ self.model.get_relation(REPLICATION_CONSUMER_RELATION),
498
+ ]:
499
+ if relation is None:
500
+ continue
501
+ # We are interested in the other side's application name
502
+ if relation.app and self.charm.async_replication.is_primary_cluster():
503
+ standby_clusters.append(relation.app.name)
504
+ return sorted(set(standby_clusters))
505
+
506
+ def update_watcher_secret(self) -> None:
507
+ """Update the watcher secret with current Raft password.
508
+
509
+ Called when credentials are rotated. Preserves existing secret content
510
+ (e.g., watcher-password) while updating the Raft password.
511
+ """
512
+ if not self.state.peer.is_app_leader:
513
+ return
514
+
515
+ try:
516
+ if raft_password := self.state.application.raft_password:
517
+ secret = self.model.get_secret(label=WATCHER_SECRET_LABEL)
518
+ content = secret.get_content(refresh=True)
519
+ if content.get(RAFT_PASSWORD_KEY) != raft_password:
520
+ content[RAFT_PASSWORD_KEY] = raft_password
521
+ secret.set_content(content)
522
+ logger.info("Updated watcher secret with new Raft password")
523
+ except SecretNotFoundError:
524
+ logger.debug("Watcher secret not found, nothing to update")
@@ -0,0 +1,145 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Authorisation rules changes observer."""
5
+
6
+ import subprocess
7
+ import sys
8
+ from asyncio import as_completed, create_task, run, wait
9
+ from contextlib import suppress
10
+ from ssl import create_default_context
11
+ from time import sleep
12
+ from urllib.parse import urljoin
13
+
14
+ import psycopg2
15
+ import yaml
16
+ from httpx import AsyncClient
17
+
18
+ API_REQUEST_TIMEOUT = 5
19
+ PATRONI_CLUSTER_STATUS_ENDPOINT = "cluster"
20
+ PATRONI_CONFIG_STATUS_ENDPOINT = "config"
21
+ TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem"
22
+ PATRONI_CONF_FILE_PATH = "/var/lib/pg/data/patroni.yaml"
23
+
24
+ # File path for the spawned cluster topology observer process to write logs.
25
+ LOG_FILE_PATH = "/var/log/authorisation_rules_observer.log"
26
+
27
+
28
+ class UnreachableUnitsError(Exception):
29
+ """Cannot reach any known cluster member."""
30
+
31
+
32
+ async def _httpx_get_request(url: str):
33
+ ssl_ctx = create_default_context()
34
+ with suppress(FileNotFoundError):
35
+ # Certificate bundle is not secret
36
+ ssl_ctx.load_verify_locations(cafile=f"/tmp/{TLS_CA_BUNDLE_FILE}") # noqa: S108
37
+ async with AsyncClient(timeout=API_REQUEST_TIMEOUT, verify=ssl_ctx) as client:
38
+ try:
39
+ return (await client.get(url)).json()
40
+ except Exception as e:
41
+ print(f"Failed to contact {url} with {e}")
42
+ return None
43
+
44
+
45
+ def dispatch(run_cmd, unit, charm_dir, custom_event):
46
+ """Use the input juju-run command to dispatch a custom event."""
47
+ dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/{} {}/dispatch"
48
+ # Input is generated by the charm
49
+ subprocess.run([run_cmd, "-u", unit, dispatch_sub_cmd.format(custom_event, charm_dir)]) # noqa: S603
50
+
51
+
52
+ def check_for_database_changes(run_cmd, unit, charm_dir, previous_databases):
53
+ """Check for changes in the databases.
54
+
55
+ If changes are detected, dispatch an event to handle them.
56
+ """
57
+ with open(PATRONI_CONF_FILE_PATH) as conf_file:
58
+ conf_file_contents = yaml.safe_load(conf_file)
59
+ password = conf_file_contents["postgresql"]["authentication"]["superuser"]["password"]
60
+ connection = None
61
+ try:
62
+ # Input is generated by the charm
63
+ with (
64
+ psycopg2.connect(
65
+ "dbname='postgres' user='operator' host='localhost' "
66
+ f"password='{password}' connect_timeout=1"
67
+ ) as connection,
68
+ connection.cursor() as cursor,
69
+ ):
70
+ cursor.execute("SELECT datname, datacl FROM pg_database;")
71
+ current_databases = cursor.fetchall()
72
+ cursor.execute(
73
+ "SELECT oid, rolname FROM pg_roles WHERE pg_has_role(oid, 'relation_access', 'member');"
74
+ )
75
+ current_databases.append(cursor.fetchall())
76
+ except psycopg2.Error as e:
77
+ with open(LOG_FILE_PATH, "a") as log_file:
78
+ log_file.write(f"Failed to retrieve databases: {e}\n")
79
+ return previous_databases
80
+ else:
81
+ # If it's the first time the databases were retrieved, then store it and use
82
+ # it for subsequent checks.
83
+ if not previous_databases:
84
+ previous_databases = current_databases
85
+ # If the databases changed, dispatch a charm event to handle this change.
86
+ elif current_databases != previous_databases:
87
+ previous_databases = current_databases
88
+ dispatch(run_cmd, unit, charm_dir, "databases_change")
89
+ return previous_databases
90
+ finally:
91
+ if connection:
92
+ connection.close()
93
+
94
+
95
+ async def main():
96
+ """Main watch and dispatch loop.
97
+
98
+ Watch the Patroni API cluster info. When changes are detected, dispatch the change event.
99
+ """
100
+ patroni_urls, run_cmd, unit, charm_dir = sys.argv[1:]
101
+
102
+ previous_databases = None
103
+ urls = [urljoin(url, PATRONI_CLUSTER_STATUS_ENDPOINT) for url in patroni_urls.split(",")]
104
+ member_name = unit.replace("/", "-")
105
+ while True:
106
+ # Disable TLS chain verification
107
+ context = create_default_context()
108
+ with suppress(FileNotFoundError):
109
+ # Certificate bundle is not secret
110
+ context.load_verify_locations(cafile=f"/tmp/{TLS_CA_BUNDLE_FILE}") # noqa: S108
111
+
112
+ cluster_status = None
113
+ tasks = [create_task(_httpx_get_request(url)) for url in urls]
114
+ for task in as_completed(tasks):
115
+ if result := await task:
116
+ for task in tasks:
117
+ task.cancel()
118
+ await wait(tasks)
119
+ cluster_status = result
120
+ break
121
+ if not cluster_status:
122
+ raise UnreachableUnitsError("Unable to reach cluster members")
123
+ is_primary = False
124
+ for member in cluster_status["members"]:
125
+ member_url = urljoin(member["api_url"], PATRONI_CLUSTER_STATUS_ENDPOINT)
126
+ # Call the current unit first
127
+ if member["name"] == member_name:
128
+ urls.insert(0, member_url)
129
+ # Check if the current member is the primary.
130
+ if member["role"] == "leader":
131
+ is_primary = True
132
+ else:
133
+ urls.append(member_url)
134
+
135
+ if is_primary:
136
+ previous_databases = check_for_database_changes(
137
+ run_cmd, unit, charm_dir, previous_databases
138
+ )
139
+
140
+ # Wait some time before checking again for a authorisation rules change.
141
+ sleep(30)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ run(main())
@@ -0,0 +1,177 @@
1
+ # Copyright 2023 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Cluster topology changes observer."""
5
+
6
+ import subprocess
7
+ import sys
8
+ from asyncio import as_completed, create_task, run, wait
9
+ from contextlib import suppress
10
+ from ssl import create_default_context
11
+ from time import sleep
12
+ from urllib.parse import urljoin
13
+
14
+ import psycopg2
15
+ import yaml
16
+ from httpx import AsyncClient
17
+
18
+ API_REQUEST_TIMEOUT = 5
19
+ PATRONI_CLUSTER_STATUS_ENDPOINT = "cluster"
20
+ TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem"
21
+ SNAP_CURRENT_PATH = "/var/snap/charmed-postgresql/current"
22
+ SNAP_CONF_PATH = f"{SNAP_CURRENT_PATH}/etc"
23
+ PATRONI_CONF_PATH = f"{SNAP_CONF_PATH}/patroni"
24
+ PATRONI_CONF_FILE_PATH = f"{PATRONI_CONF_PATH}/patroni.yaml"
25
+
26
+ # File path for the spawned cluster topology observer process to write logs.
27
+ LOG_FILE_PATH = "/var/log/cluster_topology_observer.log"
28
+
29
+
30
+ class UnreachableUnitsError(Exception):
31
+ """Cannot reach any known cluster member."""
32
+
33
+
34
+ async def _httpx_get_request(url: str):
35
+ ssl_ctx = create_default_context()
36
+ with suppress(FileNotFoundError):
37
+ ssl_ctx.load_verify_locations(cafile=f"{PATRONI_CONF_PATH}/{TLS_CA_BUNDLE_FILE}")
38
+ async with AsyncClient(timeout=API_REQUEST_TIMEOUT, verify=ssl_ctx) as client:
39
+ try:
40
+ return (await client.get(url)).json()
41
+ except Exception as e:
42
+ print(f"Failed to contact {url} with {e}")
43
+ return None
44
+
45
+
46
+ def check_for_cluster_topology_changes(
47
+ run_cmd, unit, charm_dir, previous_cluster_topology, current_cluster_topology
48
+ ):
49
+ """Check for changes in the cluster topology.
50
+
51
+ If changes are detected, dispatch an event to handle them.
52
+ """
53
+ # If it's the first time the cluster topology was retrieved, then store it and use
54
+ # it for subsequent checks.
55
+ if not previous_cluster_topology:
56
+ previous_cluster_topology = current_cluster_topology
57
+ # If the cluster topology changed, dispatch a charm event to handle this change.
58
+ elif current_cluster_topology != previous_cluster_topology:
59
+ previous_cluster_topology = current_cluster_topology
60
+ dispatch(run_cmd, unit, charm_dir, "cluster_topology_change")
61
+ return previous_cluster_topology
62
+
63
+
64
+ def check_for_database_changes(run_cmd, unit, charm_dir, previous_databases):
65
+ """Check for changes in the databases.
66
+
67
+ If changes are detected, dispatch an event to handle them.
68
+ """
69
+ with open(PATRONI_CONF_FILE_PATH) as conf_file:
70
+ conf_file_contents = yaml.safe_load(conf_file)
71
+ password = conf_file_contents["postgresql"]["authentication"]["superuser"]["password"]
72
+ listen_addr = conf_file_contents["postgresql"]["listen"]
73
+ if not listen_addr or ":" not in listen_addr:
74
+ with open(LOG_FILE_PATH, "a") as log_file:
75
+ log_file.write("Failed to retrieve databases: Host not set yet.\n")
76
+ return previous_databases
77
+
78
+ connection = None
79
+ try:
80
+ # Input is generated by the charm
81
+ with (
82
+ psycopg2.connect(
83
+ f"dbname='postgres' user='operator' host='/tmp/snap-private-tmp/snap.charmed-postgresql/tmp/' "
84
+ f"password='{password}' connect_timeout=1"
85
+ ) as connection,
86
+ connection.cursor() as cursor,
87
+ ):
88
+ cursor.execute("SELECT datname, datacl FROM pg_database;")
89
+ current_databases = cursor.fetchall()
90
+ cursor.execute(
91
+ "SELECT oid, rolname FROM pg_roles WHERE pg_has_role(oid, 'relation_access', 'member');"
92
+ )
93
+ current_databases.append(cursor.fetchall())
94
+ except psycopg2.Error as e:
95
+ with open(LOG_FILE_PATH, "a") as log_file:
96
+ log_file.write(f"Failed to retrieve databases: {e}\n")
97
+ return previous_databases
98
+ else:
99
+ # If it's the first time the databases were retrieved, then store it and use
100
+ # it for subsequent checks.
101
+ if not previous_databases:
102
+ previous_databases = current_databases
103
+ # If the databases changed, dispatch a charm event to handle this change.
104
+ elif current_databases != previous_databases:
105
+ previous_databases = current_databases
106
+ dispatch(run_cmd, unit, charm_dir, "databases_change")
107
+ return previous_databases
108
+ finally:
109
+ if connection:
110
+ connection.close()
111
+
112
+
113
+ def dispatch(run_cmd, unit, charm_dir, custom_event):
114
+ """Use the input juju-run command to dispatch a custom event."""
115
+ dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/{} {}/dispatch"
116
+ # Input is generated by the charm
117
+ subprocess.run([run_cmd, "-u", unit, dispatch_sub_cmd.format(custom_event, charm_dir)]) # noqa: S603
118
+
119
+
120
+ async def main():
121
+ """Main watch and dispatch loop.
122
+
123
+ Watch the Patroni API cluster info. When changes are detected, dispatch the change event.
124
+ """
125
+ patroni_urls, run_cmd, unit, charm_dir = sys.argv[1:]
126
+
127
+ previous_cluster_topology = {}
128
+ previous_databases = None
129
+ urls = [urljoin(url, PATRONI_CLUSTER_STATUS_ENDPOINT) for url in patroni_urls.split(",")]
130
+ member_name = unit.replace("/", "-")
131
+ while True:
132
+ # Disable TLS chain verification
133
+ context = create_default_context()
134
+ with suppress(FileNotFoundError):
135
+ context.load_verify_locations(cafile=f"{PATRONI_CONF_PATH}/{TLS_CA_BUNDLE_FILE}")
136
+
137
+ cluster_status = None
138
+ tasks = [create_task(_httpx_get_request(url)) for url in urls]
139
+ for task in as_completed(tasks):
140
+ if result := await task:
141
+ for task in tasks:
142
+ task.cancel()
143
+ await wait(tasks)
144
+ cluster_status = result
145
+ break
146
+ if not cluster_status:
147
+ raise UnreachableUnitsError("Unable to reach cluster members")
148
+ current_cluster_topology = {}
149
+ urls = []
150
+ is_primary = False
151
+ for member in cluster_status["members"]:
152
+ current_cluster_topology[member["name"]] = member["role"]
153
+ member_url = urljoin(member["api_url"], PATRONI_CLUSTER_STATUS_ENDPOINT)
154
+ # Call the current unit first
155
+ if member["name"] == member_name:
156
+ urls.insert(0, member_url)
157
+ # Check if the current member is the primary.
158
+ if member["role"] == "leader":
159
+ is_primary = True
160
+ else:
161
+ urls.append(member_url)
162
+
163
+ previous_cluster_topology = check_for_cluster_topology_changes(
164
+ run_cmd, unit, charm_dir, previous_cluster_topology, current_cluster_topology
165
+ )
166
+
167
+ if is_primary:
168
+ previous_databases = check_for_database_changes(
169
+ run_cmd, unit, charm_dir, previous_databases
170
+ )
171
+
172
+ # Wait some time before checking again for a cluster topology change.
173
+ sleep(10)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ run(main())
@@ -0,0 +1,85 @@
1
+ # Copyright 2026 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Raft connectivity observer."""
5
+
6
+ from os import environ
7
+ from subprocess import run
8
+
9
+ from charmlibs.snap import SnapCache
10
+ from pysyncobj.utility import TcpUtility
11
+ from yaml import safe_load
12
+
13
+ from single_kernel_postgresql.config.literals import (
14
+ PATRONI_CONF_PATH,
15
+ RAFT_PARTNER_PREFIX,
16
+ RAFT_PORT,
17
+ )
18
+
19
+ PATRONI_CONF_FILE_PATH = f"{PATRONI_CONF_PATH}/patroni.yaml"
20
+
21
+
22
+ def dispatch(custom_event):
23
+ """Use the input juju-run command to dispatch a custom event."""
24
+ dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/{} {}/dispatch"
25
+ # Input is generated by the charm
26
+ run([ # noqa: S603
27
+ "/usr/bin/juju-exec",
28
+ "-u",
29
+ environ["JUJU_UNIT_NAME"],
30
+ dispatch_sub_cmd.format(custom_event, environ["JUJU_CHARM_DIR"]),
31
+ ])
32
+
33
+
34
+ def get_raft_password() -> str | None:
35
+ """Get the raft password from the patroni config file."""
36
+ try:
37
+ with open(PATRONI_CONF_FILE_PATH) as conf_file:
38
+ conf_file_contents = safe_load(conf_file)
39
+ return conf_file_contents["raft"]["password"]
40
+ except Exception as e:
41
+ print(f"Unable to get password: {e}")
42
+
43
+
44
+ def check_raft_connection(password: str) -> None:
45
+ """Verify that the watcher has joined the Raft cluster."""
46
+ # Get the status of the raft cluster.
47
+ syncobj_util = TcpUtility(password=password, timeout=3)
48
+
49
+ try:
50
+ raft_status = syncobj_util.executeCommand(f"127.0.0.1:{RAFT_PORT}", ["status"])
51
+ except Exception:
52
+ print("Cannot connect to local Raft")
53
+ return
54
+ if not raft_status:
55
+ print("No Raft status")
56
+ return
57
+ print(f"Local raft: {raft_status}")
58
+ if not raft_status["leader"] or not raft_status["has_quorum"]:
59
+ print("No leader or quorum")
60
+ return
61
+ for key in raft_status:
62
+ if key.startswith(RAFT_PARTNER_PREFIX) and raft_status[key] != 2:
63
+ addr = key.split(RAFT_PARTNER_PREFIX)[-1]
64
+ try:
65
+ print(f"{addr} Raft: {syncobj_util.executeCommand(addr, ['status'])}")
66
+ except Exception:
67
+ print(f"Unable to connect to {addr}")
68
+ continue
69
+ print(f"Potentially stuck connection with {addr}")
70
+ dispatch("raft_reconnect")
71
+ return
72
+
73
+
74
+ def main() -> None:
75
+ """Main watch and dispatch."""
76
+ if not SnapCache()["charmed-postgresql"].services["patroni"]["active"]:
77
+ print("Patroni is not running")
78
+ return
79
+ if not (password := get_raft_password()):
80
+ return
81
+ check_raft_connection(password)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()