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.
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/PKG-INFO +1 -1
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/pyproject.toml +4 -4
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/pyproject.toml.orig +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
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/literals.py +4 -0
- postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/events/watcher.py +524 -0
- postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/scripts/authorisation_rules_observer.py +145 -0
- postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/scripts/cluster_topology_observer.py +177 -0
- postgresql_charms_single_kernel-16.3.11/single_kernel_postgresql/scripts/raft_observer.py +85 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/LICENSE +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/README.md +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charmcraft.yaml +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charms/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charms/k8s_charm.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/charms/vm_charm.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/compat/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/compat/postgresql.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/compat/pyproject.toml +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/enums.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/exceptions.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/locales.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/pyproject.toml +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/config/statuses.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/config.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/peer_relation.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/relation_state.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/s3.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/core/state.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/backup.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/database.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/ldap.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/postgresql.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/tls.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/events/tls_transfer.py +0 -0
- {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
- {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
- {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
- {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
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/backup.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/base.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/cluster.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/config.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/database.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/k8s.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/patroni.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/restore.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/managers/tls.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/scripts/rotate_logs.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/k8s/pgbackrest.conf.j2 +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/k8s/pgbackrest.logrotate.j2 +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/patroni.yml.j2 +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/vm/pgbackrest.conf.j2 +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/templates/vm/pgbackrest.logrotate.j2 +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/arch.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/backup.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/filesystem.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/postgresql.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/s3.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/secret.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/utils/status.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/base.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/k8s.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/base.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/k8s.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/paths/vm.py +0 -0
- {postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/single_kernel_postgresql/workload/vm.py +0 -0
{postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: postgresql-charms-single-kernel
|
|
3
|
-
Version: 16.3.
|
|
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>
|
{postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/pyproject.toml
RENAMED
|
@@ -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.
|
|
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.
|
|
160
|
+
format = ["ruff==0.16.10"]
|
|
161
161
|
lint = [
|
|
162
162
|
"codespell==2.4.3",
|
|
163
|
-
"ty==0.0.
|
|
163
|
+
"ty==0.0.84",
|
|
164
164
|
]
|
|
165
165
|
unit = [
|
|
166
|
-
"coverage[toml]==7.16.
|
|
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.
|
|
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.
|
|
66
|
+
"ruff==0.16.10"
|
|
67
67
|
]
|
|
68
68
|
lint = [
|
|
69
69
|
"codespell==2.4.3",
|
|
70
|
-
"ty==0.0.
|
|
70
|
+
"ty==0.0.84"
|
|
71
71
|
]
|
|
72
72
|
unit = [
|
|
73
|
-
"coverage[toml]==7.16.
|
|
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()
|
|
File without changes
|
{postgresql_charms_single_kernel-16.3.10 → postgresql_charms_single_kernel-16.3.11}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|