From 78043a1dfeed46c30735099ec6d33cdc0b8eefcc Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Thu, 18 Jun 2026 14:58:27 +0300 Subject: [PATCH 01/31] Redo --- pyproject.toml | 10 +++++++--- uv.lock | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0248943..f19b9cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ [project] name = "postgresql-charms-single-kernel" description = "Shared and reusable code for PostgreSQL-related charms" -version = "16.2.2" +version = "16.3.0" readme = "README.md" license = {file = "LICENSE"} authors = [ @@ -27,7 +27,6 @@ postgresql = [ "httpx; python_version >= '3.12'", "data-platform-helpers>=0.1.7; python_version >= '3.12'", "charmlibs-pathops>=1.0.1; python_version >= '3.12'", - "charmlibs-snap>=1.0.1; python_version >= '3.12'", "charmlibs-interfaces-tls-certificates>=1.8.3; python_version >= '3.12'", "charm-refresh; python_version >= '3.12'", "requests; python_version >= '3.12'", @@ -35,7 +34,12 @@ postgresql = [ "pydantic>=2.0; python_version >= '3.12'", "jinja2 >=3.1.6; python_version >= '3.12'", "charmlibs-rollingops>=1.1.1; python_version >= '3.12'", - ] +] +vm = [ + "pysyncobj>=0.3.15; python_version >= '3.12'", + "psutil>=7.2.2; python_version >= '3.12'", + "charmlibs-snap>=1.0.1; python_version >= '3.12'", +] [build-system] requires = ["uv_build>=0.11.0,<0.12.0"] diff --git a/uv.lock b/uv.lock index 6a1ff88..9df4f28 100644 --- a/uv.lock +++ b/uv.lock @@ -988,7 +988,7 @@ wheels = [ [[package]] name = "postgresql-charms-single-kernel" -version = "16.2.2" +version = "16.3.0" source = { editable = "." } dependencies = [ { name = "ops", version = "2.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -1006,7 +1006,6 @@ postgresql = [ { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-pathops", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-rollingops", marker = "python_full_version >= '3.12'" }, - { name = "charmlibs-snap", marker = "python_full_version >= '3.12'" }, { name = "data-platform-helpers", marker = "python_full_version >= '3.12'" }, { name = "httpx", marker = "python_full_version >= '3.12'" }, { name = "jinja2", marker = "python_full_version >= '3.12'" }, @@ -1014,6 +1013,11 @@ postgresql = [ { name = "requests", marker = "python_full_version >= '3.12'" }, { name = "tomli", marker = "python_full_version >= '3.12'" }, ] +vm = [ + { name = "charmlibs-snap", marker = "python_full_version >= '3.12'" }, + { name = "psutil", marker = "python_full_version >= '3.12'" }, + { name = "pysyncobj", marker = "python_full_version >= '3.12'" }, +] [package.dev-dependencies] format = [ @@ -1034,18 +1038,20 @@ requires-dist = [ { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.8.3" }, { name = "charmlibs-pathops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.0.1" }, { name = "charmlibs-rollingops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.1.1" }, - { name = "charmlibs-snap", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.0.1" }, + { name = "charmlibs-snap", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=1.0.1" }, { name = "data-platform-helpers", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=0.1.7" }, { name = "httpx", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "jinja2", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=3.1.6" }, { name = "ops", specifier = ">=2.0.0" }, + { name = "psutil", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=7.2.2" }, { name = "psycopg2", specifier = ">=2.9.10" }, { name = "pydantic", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=2.0" }, + { name = "pysyncobj", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=0.3.15" }, { name = "requests", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "tomli", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, ] -provides-extras = ["postgresql"] +provides-extras = ["postgresql", "vm"] [package.metadata.requires-dev] format = [{ name = "ruff", marker = "python_full_version >= '3.12'", specifier = "==0.15.17" }] @@ -1058,6 +1064,34 @@ unit = [ { name = "pytest", marker = "python_full_version >= '3.12'", specifier = "==9.1.0" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "psycopg2" version = "2.9.10" @@ -1260,6 +1294,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pysyncobj" +version = "0.3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/48/5f96d7f8c46446f1a1a660a594c06906b59185a1b19e334180f72fc0dc0e/pysyncobj-0.3.15.tar.gz", hash = "sha256:7b98f523bc7810203763f5a6ef569ff2dd5de889fb7f5f0ccc8c8b6c87df9680", size = 41987, upload-time = "2026-03-17T21:50:24.39Z" } + [[package]] name = "pytest" version = "9.1.0" From 6192b0b01cfbedc546f406ea7aa5606512ee2421 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Thu, 18 Jun 2026 17:07:16 +0300 Subject: [PATCH 02/31] Conditional password --- single_kernel_postgresql/managers/config.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index d78d261..261a8b7 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -14,6 +14,7 @@ from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope from jinja2 import Template +from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( POSTGRESQL_STORAGE_PERMISSIONS, USER, @@ -179,13 +180,15 @@ def render_patroni_yml_file( @property def _are_passwords_set(self) -> bool: - return all([ + passes = [ self.state.application.user_password, self.state.application.replication_password, self.state.application.rewind_password, - self.state.application.raft_password, self.state.application.patroni_password, - ]) + ] + if self.state.substrate == Substrates.VM: + passes.append(self.state.application.raft_password) + return all(passes) def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False From 6e45739e0848c0da4531944d4b902af6857accde Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 22 Jun 2026 17:39:56 +0300 Subject: [PATCH 03/31] Port changes WIP --- single_kernel_postgresql/config/literals.py | 12 +- single_kernel_postgresql/core/state.py | 28 +- single_kernel_postgresql/managers/config.py | 125 +- single_kernel_postgresql/managers/patroni.py | 1028 ++++++++++++++++- single_kernel_postgresql/workload/base.py | 5 + single_kernel_postgresql/workload/k8s.py | 13 +- .../workload/paths/base.py | 6 + .../workload/paths/k8s.py | 6 + single_kernel_postgresql/workload/paths/vm.py | 6 + tox.ini | 4 +- 10 files changed, 1194 insertions(+), 39 deletions(-) diff --git a/single_kernel_postgresql/config/literals.py b/single_kernel_postgresql/config/literals.py index 803e8af..6372e16 100644 --- a/single_kernel_postgresql/config/literals.py +++ b/single_kernel_postgresql/config/literals.py @@ -42,7 +42,9 @@ # e.g. snap_current / "/etc/postgresql" will result in "/etc/postgresql" instead of "/var/snap/postgresql/current/etc/postgresql" POSTGRESQL_CONF_PATH = "etc/postgresql" POSTGRESQL_CONF_FILE = "postgresql.conf" - +## Pgbackreest Paths +PGBACKREST_CONF_PATH = "etc/pgbackrest" +PGBACKREST_CONF_FILE = "pgbackrest.conf" ## TLS Paths TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem" @@ -161,3 +163,11 @@ WATCHER_USER = "watcher" WATCHER_PASSWORD_KEY = "watcher-password" # noqa: S105 WATCHER_SECRET_LABEL = "watcher-secret" # noqa: S105 + +# Raft +RAFT_PORT = 2222 +RAFT_PARTNER_PREFIX = "partner_node_status_server_" + +# VM services +PATRONI_VM_SERVICE_NAME = "snap.charmed-postgresql.patroni.service" +PATRONI_VM_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{PATRONI_VM_SERVICE_NAME}" diff --git a/single_kernel_postgresql/core/state.py b/single_kernel_postgresql/core/state.py index ab19b77..ed20978 100644 --- a/single_kernel_postgresql/core/state.py +++ b/single_kernel_postgresql/core/state.py @@ -185,7 +185,7 @@ def model_name(self) -> str: @cached_property def patroni_url(self) -> str: """Patroni REST API URL.""" - return f"https://{self.unit_ip}:8007" + return f"https://{self.unit_ip}:8008" @property def peer_members_ips(self) -> set[str]: @@ -403,3 +403,29 @@ def _search_interpolated_status( if re.fullmatch(regex_pattern, present_status.message) is not None: return present_status return None + + @cached_property + def synchronous_node_count(self) -> int: + """Number of expected sync standbys.""" + planned_units = self.application.planned_units + if self.config.synchronous_node_count == "all": + return planned_units - 1 + elif self.config.synchronous_node_count == "majority": + return planned_units // 2 + # -1 for leader + return ( + self.config.synchronous_node_count + if self.config.synchronous_node_count < planned_units - 1 + else planned_units - 1 + ) + + @cached_property + def synchronous_configuration(self) -> dict[str, Any]: + """Synchronous mode configuration.""" + # Try to update synchronous_node_count. + return { + "synchronous_node_count": self.synchronous_node_count, + "synchronous_mode_strict": len(self.application.members_ips) > 1 + and self.config.synchronous_mode_strict + and self.synchronous_node_count > 0, + } diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index 261a8b7..b57ab9b 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -8,6 +8,7 @@ """ import logging +from typing import Any import charm_refresh from data_platform_helpers.advanced_statuses import StatusObject @@ -16,7 +17,9 @@ from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( + PGBACKREST_CONF_FILE, POSTGRESQL_STORAGE_PERMISSIONS, + REWIND_USER, USER, ) from single_kernel_postgresql.config.statuses import GeneralStatuses @@ -38,6 +41,17 @@ class ConfigManager(BaseManager): def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): super().__init__(state, workload, "config_manager", client) + @staticmethod + def _dict_to_hba_string(_dict: dict[str, Any]) -> str: + """Transform a dictionary into a Host Based Authentication valid string.""" + for key, value in _dict.items(): + if isinstance(value, bool): + _dict[key] = int(value) + if isinstance(value, str): + _dict[key] = f'"{value}"' + + return " ".join(f"{key}={value}" for key, value in _dict.items()) + def configure_patroni_on_unit(self): """Configure Patroni (configuration files and service) on the unit.""" _change_owner(self.state.substrate, str(self.workload.paths.data)) @@ -101,6 +115,7 @@ def render_patroni_yml_file( is_creating_backup: bool = False, enable_ldap: bool = False, enable_tls: bool = False, + is_no_sync_member: bool = False, stanza: str | None = None, restore_stanza: str | None = None, disable_pgbackrest_archiving: bool = False, @@ -112,6 +127,14 @@ def render_patroni_yml_file( no_peers: bool = False, user_databases_map: dict[str, str] | None = None, slots: dict[str, str] | None = None, + # LDAP rel + ldap_parameters: dict[str, Any] | None = None, + # Async rel + async_primary_cluster_endpoint: str | None = None, + async_partner_addresses: list[str] | None = None, + async_standby_endpoints: list[str] | None = None, + # VM watcher rel + watcher_raft_address: str | None = None, ) -> None: """Render the Patroni configuration file. @@ -120,6 +143,8 @@ def render_patroni_yml_file( is_creating_backup: whether this unit is creating a backup. enable_ldap: whether to enable LDAP authentication. enable_tls: whether to enable client TLS. + is_no_sync_member: whether this member shouldn't be a synchronous standby + (when it's a replica). K8s only. stanza: name of the stanza created by pgBackRest. restore_stanza: name of the stanza used when restoring a backup. disable_pgbackrest_archiving: whether to force disable pgBackRest WAL archiving. @@ -131,8 +156,14 @@ def render_patroni_yml_file( no_peers: Don't include peers. user_databases_map: map of databases to be accessible by each user. slots: replication slots (keys) with assigned database name (values). + ldap_parameters: LDAP configuration. + async_primary_cluster_endpoint: Primary async cluster endpoint. + async_standby_endpoints: Primary async cluster endpoint. + async_partner_addresses: Primary async cluster endpoint. + watcher_raft_address: IP address of a related Raft watcher. """ slots = slots or {} + ldap_parameters = ldap_parameters or {} if not self._are_passwords_set: logger.warning("Passwords are not yet generated by the leader") return @@ -141,41 +172,73 @@ def render_patroni_yml_file( with open("templates/patroni.yml.j2") as file: template = Template(file.read()) - # TODO: configure LDAP - # ldap_params = self.charm.get_ldap_parameters() - - # Render the template file with the correct values. - rendered = template.render( - conf_path=self.workload.paths.patroni_conf, - connectivity=connectivity, - is_creating_backup=is_creating_backup, - log_path=self.workload.paths.patroni_logs, - postgresql_log_path=self.workload.paths.logs, - data_path=self.workload.paths.data, - enable_ldap=enable_ldap, - enable_tls=enable_tls, - member_name=self.state.peer.member_name, - partner_addrs=[], - peers_ips=set(), - scope=self.state.application.cluster_name, - self_ip=self.state.unit_ip, - listen_ips=self.state.listen_ips, - superuser=USER, - superuser_password=self.state.application.user_password, - replication_password=self.state.application.replication_password, - rewind_password=self.state.application.rewind_password, - raft_password=self.state.application.raft_password, - version=self.workload.get_postgresql_version().split(".")[0], - pg_parameters=parameters, - patroni_password=self.state.application.patroni_password, - slots=slots, - instance_password_encryption=self.state.config.instance_password_encryption, - ) + confs = { + "connectivity": connectivity, + "enable_ldap": enable_ldap, + "enable_tls": enable_tls, + "member_name": self.state.peer.member_name, + "superuser": USER, + "superuser_password": self.state.application.user_password, + "rewind_user": REWIND_USER, + "rewind_password": self.state.application.rewind_password, + "replication_password": self.state.application.replication_password, + "enable_pgbackrest_archiving": stanza is not None + and disable_pgbackrest_archiving is False, + "stanza": stanza, + "restore_stanza": restore_stanza, + "restoring_backup": backup_id is not None or pitr_target is not None, + "backup_id": backup_id, + "pitr_target": pitr_target if not restore_to_latest else None, + "restore_timeline": restore_timeline, + "restore_to_latest": restore_to_latest, + "is_creating_backup": is_creating_backup, + "version": self.workload.get_postgresql_version().split(".")[0], + "synchronous_node_count": self.state.synchronous_node_count, + "maximum_lag_on_failover": self.state.config.durability_maximum_lag_on_failover, + "pg_parameters": parameters, + "primary_cluster_endpoint": async_primary_cluster_endpoint, + "ldap_parameters": self._dict_to_hba_string(ldap_parameters), + "patroni_password": self.state.application.patroni_password, + "user_databases_map": user_databases_map, + "slots": slots, + "instance_password_encryption": self.state.config.instance_password_encryption, + "extra_replication_endpoints": async_standby_endpoints, + } + if self.state.substrate == Substrates.VM: + confs.update({ + "conf_path": str(self.workload.paths.patroni_conf), + "log_path": str(self.workload.paths.patroni_logs), + "postgresql_log_path": str(self.workload.paths.logs), + "data_path": str(self.workload.paths.data), + "wal_dir": str(self.workload.paths.data / "logs"), + "partner_addrs": async_partner_addresses if not no_peers else [], + "peers_ips": sorted(self.state.endpoints) if not no_peers else [], + "pgbackrest_configuration_file": f"--config={self.workload.paths.pgbackrest_conf / PGBACKREST_CONF_FILE}", + "scope": self.state.application.cluster_name, + "self_ip": self.state.unit_ip, + "listen_ips": self.state.listen_ips, + "raft_password": self.state.application.raft_password, + "watcher": watcher_raft_address, + }) + perms = 0o600 + else: + confs.update({ + "endpoint": self.state.endpoint, + "endpoints": list(self.state.endpoints), + "is_no_sync_member": is_no_sync_member, + "namespace": self.state.model_name, + "storage_path": str(self.workload.paths.data), + "logs_storage_path": str(self.workload.paths.logs), + "pgdata_path": str(self.workload.paths.data), + "restoring_backup": backup_id is not None or pitr_target is not None, + }) + perms = 0o644 + rendered = template.render(**confs) render_file( self.state.substrate, f"{self.workload.paths.patroni_conf}/patroni.yaml", rendered, - 0o600, + perms, ) @property diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index f850646..ec9ac07 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -8,24 +8,61 @@ such as starting the service and checking its status. """ +import glob import logging +import os +import pathlib +import re +import shutil +import subprocess +from contextlib import suppress from functools import cached_property +from pathlib import Path +from typing import Any, Literal, TypedDict +# Platform specific imports +with suppress(ImportError): + import psutil + from charmlibs import snap + from pysyncobj.utility import TcpUtility, UtilityException +import psycopg2 +import psycopg2.extras import requests +import tomli from data_platform_helpers.advanced_statuses import StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope +from httpx import BasicAuth +from ops import BlockedStatus from requests.auth import HTTPBasicAuth -from tenacity import RetryError, Retrying, stop_after_delay, wait_fixed +from tenacity import ( + Future, + RetryError, + Retrying, + retry, + retry_if_result, + stop_after_attempt, + stop_after_delay, + wait_exponential, + wait_fixed, +) from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( API_REQUEST_TIMEOUT, + PATRONI_CLUSTER_STATUS_ENDPOINT, + PATRONI_VM_SERVICE_DEFAULT_PATH, + PEER_RELATION, + POSTGRESQL_STORAGE_PERMISSIONS, + RAFT_PARTNER_PREFIX, + RAFT_PORT, RUNNING_STATES, + STARTED_STATES, TLS_CA_BUNDLE_FILE, ) from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager +from single_kernel_postgresql.utils import _change_owner, label2name, parallel_patroni_get_request from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload from single_kernel_postgresql.workload.vm import VMWorkload @@ -33,18 +70,98 @@ logger = logging.getLogger(__name__) +class RaftPostgresqlNotUpError(Exception): + """Postgresql not yet started.""" + + +class RaftPostgresqlStillUpError(Exception): + """Postgresql not yet down.""" + + +class RaftNotPromotedError(Exception): + """Leader not yet set when reinitialising raft.""" + + +class ClusterNotPromotedError(Exception): + """Raised when a cluster is not promoted.""" + + +class NotReadyError(Exception): + """Raised when not all cluster members healthy or finished initial sync.""" + + +class EndpointNotReadyError(Exception): + """Raised when an endpoint is not ready.""" + + +class StandbyClusterAlreadyPromotedError(Exception): + """Raised when a standby cluster is already promoted.""" + + +class RemoveRaftMemberFailedError(Exception): + """Raised when a remove raft member failed for some reason.""" + + +class SwitchoverFailedError(Exception): + """Raised when a switchover failed for some reason.""" + + +class SwitchoverNotSyncError(SwitchoverFailedError): + """Raised when a switchover failed because node is not sync.""" + + +class UpdateSyncNodeCountError(Exception): + """Raised when updating synchronous_node_count failed for some reason.""" + + +class ClusterMember(TypedDict): + """Type for cluster member.""" + + name: str + role: str + state: str + api_url: str + host: str + port: int + timeline: int + lag: int + + class PatroniManager(BaseManager): """PostgreSQL Patroni Manager. This manager is responsible for handling operations related to Patroni. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): + def __init__( + self, + state: CharmState, + workload: BaseWorkload, + client: PostgreSQLClient, + endpoint: str | None, + endpoints: list[str], + primary_endpoint: str | None, + cluster_name: str, + member_name: str, + ): super().__init__(state, workload, "patroni_manager", client) + self.endpoint = endpoint + self.endpoints = endpoints + self.primary_endpoint = primary_endpoint + self.cluster_name = cluster_name + self.member_name = member_name # Variable mapping to requests library verify parameter. # The CA bundle file is used to validate the server certificate when # TLS is enabled, otherwise True is set because it's the default value. - self.verify = f"{self.workload.paths.patroni_conf}/{TLS_CA_BUNDLE_FILE}" + if self.state.substrate == Substrates.VM: + # if any([primary_endpoint]): + # raise Exception("K8s attributes set with VM substrate") + self.verify = f"{self.workload.paths.patroni_conf}/{TLS_CA_BUNDLE_FILE}" + else: + # if any([raft_password]): + # raise Exception("VM attributes set with K8s substrate") + # CA bundle is not secret + self.verify = f"/tmp/{TLS_CA_BUNDLE_FILE}" # noqa: S108 def start_patroni(self) -> bool: """Start Patroni.""" @@ -100,6 +217,911 @@ def _patroni_auth(self) -> HTTPBasicAuth | None: if self.state.application.patroni_password: return HTTPBasicAuth("patroni", self.state.application.patroni_password) + @cached_property + def _patroni_async_auth(self) -> BasicAuth | None: + if self.state.application.patroni_password: + return BasicAuth("patroni", password=self.state.application.patroni_password) + + def bootstrap_cluster(self) -> bool: + """Bootstrap a PostgreSQL cluster using Patroni.""" + # Render the configuration files and start the cluster. + self.configure_patroni_on_unit() + return self.start_patroni() + + def configure_patroni_on_unit(self): + """Configure Patroni (configuration files and service) on the unit.""" + patroni_conf_path = str(self.workload.paths.patroni_conf) + patroni_data_path = str(self.workload.paths.data) + os.makedirs(patroni_conf_path, exist_ok=True) + # Parent must be _daemon_-owned so Patroni can rename/remove the data dir on reinit. + _change_owner(Substrates.VM, os.path.dirname(patroni_conf_path)) + _change_owner(Substrates.VM, patroni_conf_path) + + # Create empty base config + open(patroni_conf_path, "a").close() + + # Expected permission + # Replicas refuse to start with the default permissions + os.chmod(patroni_data_path, POSTGRESQL_STORAGE_PERMISSIONS) + + @cached_property + def cluster_members(self) -> set: + """Get the current cluster members.""" + # Request info from cluster endpoint (which returns all members of the cluster). + # Request info from cluster endpoint (which returns all members of the cluster). + try: + return {member["name"] for member in self.cluster_status()} + except Exception: + return set() + + def get_postgresql_version(self) -> str: + """Return the PostgreSQL version from the system.""" + with pathlib.Path("refresh_versions.toml").open("rb") as file: + return tomli.load(file)["workload"] + + @cached_property + def cached_cluster_status(self): + """Cached cluster status.""" + return self.cluster_status() + + def cluster_status(self, alternative_endpoints: list | None = None) -> list[ClusterMember]: + """Query the cluster status.""" + if not self._patroni_async_auth: + raise RetryError( + last_attempt=Future.construct(1, Exception("Unable to reach any units"), True) + ) + + # TODO we don't know the other cluster's ca + verify = not bool(alternative_endpoints) + if alternative_endpoints: + endpoints = alternative_endpoints + else: + endpoints = [] + if self.endpoint: + endpoints.append(self.endpoint) + for peer in self.endpoints: + endpoints.append(peer) + # Request info from cluster endpoint (which returns all members of the cluster). + if response := parallel_patroni_get_request( + f"/{PATRONI_CLUSTER_STATUS_ENDPOINT}", + endpoints, + self.verify, + self._patroni_async_auth, + verify, + ): + logger.debug("API cluster_status: %s", response["members"]) + return response["members"] + raise RetryError( + last_attempt=Future.construct(1, Exception("Unable to reach any units"), True) + ) + + def get_member_ip(self, member_name: str) -> str | None: + """Get cluster member IP address. + + Args: + member_name: cluster member name. + + Returns: + IP address of the cluster member. + """ + try: + cluster_status = self.cluster_status() + + for member in cluster_status: + if member["name"] == member_name: + return member["host"] + except RetryError: + logger.debug("Unable to get IP. Cluster status unreachable") + + def get_member_status(self, member_name: str) -> str: + """Get cluster member status. + + Args: + member_name: cluster member name. + + Returns: + status of the cluster member or an empty string if the status + couldn't be retrieved yet. + """ + # Request info from cluster endpoint (which returns all members of the cluster). + cluster_status = self.cluster_status() + if cluster_status: + for member in cluster_status: + if member["name"] == member_name: + return member["state"] + return "" + + def get_primary( + self, unit_name_pattern=False, alternative_endpoints: list[str] | None = None + ) -> str | None: + """Get primary instance. + + Args: + unit_name_pattern: whether to convert pod name to unit name + alternative_endpoints: list of alternative endpoints to check for the primary. + + Returns: + primary pod or unit name. + """ + # Request info from cluster endpoint (which returns all members of the cluster). + try: + cluster_status = self.cluster_status(alternative_endpoints) + for member in cluster_status: + if member["role"] == "leader": + primary = member["name"] + if unit_name_pattern: + # Change the last dash to / in order to match unit name pattern. + primary = label2name(primary) + return primary + except RetryError: + logger.debug("Unable to get primary. Cluster status unreachable") + + def get_standby_leader( + self, unit_name_pattern=False, check_whether_is_running: bool = False + ) -> str | None: + """Get standby leader instance. + + Args: + unit_name_pattern: whether to convert pod name to unit name + check_whether_is_running: whether to check if the standby leader is running + + Returns: + standby leader pod or unit name. + """ + # Request info from cluster endpoint (which returns all members of the cluster). + cluster_status = self.cluster_status() + if cluster_status: + for member in cluster_status: + if member["role"] == "standby_leader": + if check_whether_is_running and member["state"] not in STARTED_STATES: + logger.warning(f"standby leader {member['name']} is not running") + continue + standby_leader = member["name"] + if unit_name_pattern: + # Change the last dash to / in order to match unit name pattern. + standby_leader = label2name(standby_leader) + return standby_leader + + def get_sync_standby_names(self) -> list[str]: + """Get the list of sync standby unit names.""" + sync_standbys = [] + # Request info from cluster endpoint (which returns all members of the cluster). + cluster_status = self.cluster_status() + if cluster_status: + for member in cluster_status: + if member["role"] == "sync_standby": + sync_standbys.append(label2name(member["name"])) + return sync_standbys + + def are_all_members_ready(self) -> bool: + """Check if all members are correctly running Patroni and PostgreSQL. + + Returns: + True if all members are ready False otherwise. Retries over a period of 10 seconds + 3 times to allow server time to start up. + """ + if self.state.substrate == Substrates.VM: + # Request info from cluster endpoint + # (which returns all members of the cluster and their states). + try: + members = self.cluster_status() + except RetryError: + return False + + # Check if all members are running and one of them is a leader (primary) or + # a standby leader, because sometimes there may exist (for some period of time) + # only replicas after a failed switchover. + return all(member["state"] in STARTED_STATES for member in members) and any( + member["role"] in ["leader", "standby_leader"] for member in members + ) + else: + # Request info from cluster endpoint + # (which returns all members of the cluster and their states). + return len(self.get_running_cluster_members()) == len(self.endpoints) + + @property + def is_creating_backup(self) -> bool: + """Returns whether a backup is being created.""" + # Request info from cluster endpoint (which returns the list of tags from each + # cluster member; the "is_creating_backup" tag means that the member is creating + # a backup). + try: + members = self.cached_cluster_status + except RetryError: + return False + + return any( + "tags" in member and member["tags"].get("is_creating_backup") for member in members + ) + + def is_replication_healthy(self) -> bool: + """Return whether the replication is healthy.""" + if not self.endpoint: + logger.debug("Failed replication check no endpoint set") + return False + try: + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): + with attempt: + primary = self.get_primary() or self.get_standby_leader() + if not primary: + logger.debug("Failed replication check no primary reported") + raise Exception + primary_addr = self.get_member_ip(primary) + members_addrs = {self.endpoint} + members_addrs.update(self.endpoints) + for members_addr in members_addrs: + endpoint = ( + "leader" if members_addr == primary_addr else "replica?lag=100MB" + ) + url = self.state.patroni_url.replace(self.endpoint, members_addr) + r = requests.get( + f"{url}/{endpoint}", + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug( + "API is_replication_healthy: %s (%s)", + r, + r.elapsed.total_seconds(), + ) + if r.status_code != 200: + logger.debug( + f"Failed replication check for {members_addr} with code {r.status_code}" + ) + raise Exception + except RetryError: + logger.exception("replication is not healthy") + return False + + logger.debug("replication is healthy") + return True + + @property + def member_inactive(self) -> bool: + """Are Patroni and PostgreSQL in inactive state. + + Returns: + True if services is not running, starting or restarting. Retries over a period of 60 + seconds times to allow server time to start up. + """ + try: + response = self.cached_patroni_health + except RetryError: + return True + + return response["state"] not in [ + *RUNNING_STATES, + "creating replica", + "starting", + "restarting", + ] + + @property + def is_member_isolated(self) -> bool: + """Returns whether the unit is isolated from the cluster.""" + try: + for attempt in Retrying(stop=stop_after_delay(10), wait=wait_fixed(3)): + with attempt: + r = requests.get( + f"{self.state.patroni_url}/{PATRONI_CLUSTER_STATUS_ENDPOINT}", + verify=self.verify, + timeout=API_REQUEST_TIMEOUT, + auth=self._patroni_auth, + ) + logger.debug( + "API is_member_isolated: %s (%s)", + r.json()["members"], + r.elapsed.total_seconds(), + ) + except RetryError: + # Return False if it was not possible to get the cluster info. Try again later. + return False + + return len(r.json()["members"]) == 0 + + def is_member_registered_in_cluster(self) -> bool: + """Check if this member is registered in the Raft DCS cluster. + + In Raft mode, a new member may be running and replicating but not yet + registered in the DCS if it hasn't been added to the Raft cluster. + + Returns: + True if this member appears in the /cluster endpoint, False otherwise. + """ + try: + cluster_status = self.cluster_status() + except RetryError: + logger.debug("Could not get cluster status to check member registration") + return False + + if not cluster_status: + return False + + # Check if this member's name appears in the cluster members list + member_name = self.member_name + return any(member.get("name") == member_name for member in cluster_status) + + def online_cluster_members(self) -> list[ClusterMember]: + """Return list of online cluster members.""" + try: + cluster_status = self.cluster_status() + except RetryError: + logger.exception("Unable to get the state of the cluster") + return [] + if not cluster_status: + return [] + + return [member for member in cluster_status if member["state"] in STARTED_STATES] + + def are_replicas_up(self) -> dict[str, bool] | None: + """Check if cluster members are running or streaming.""" + try: + members = self.cluster_status() + return {member["host"]: member["state"] in STARTED_STATES for member in members} + except Exception: + logger.exception("Unable to get the state of the cluster") + return + + def promote_standby_cluster(self) -> None: + """Promote a standby cluster to be a regular cluster.""" + config_response = requests.get( + f"{self.state.patroni_url}/config", + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug( + "API promote_standby_cluster: %s (%s)", + config_response, + config_response.elapsed.total_seconds(), + ) + if "standby_cluster" not in config_response.json(): + raise StandbyClusterAlreadyPromotedError("standby cluster is already promoted") + r = requests.patch( + f"{self.state.patroni_url}/config", + verify=self.verify, + json={"standby_cluster": None}, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug("API promote_standby_cluster patch: %s (%s)", r, r.elapsed.total_seconds()) + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): + with attempt: + if self.get_primary() is None: + raise ClusterNotPromotedError("cluster not promoted") + + def set_max_timelines_history(self) -> None: + """Patch the DCS with max_timelines_history limit.""" + requests.patch( + f"{self.state.patroni_url}/config", + verify=self.verify, + json={"max_timelines_history": 50}, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + + def patroni_logs(self, num_lines: int | Literal["all"] = 10) -> str: + """Get Patroni snap service logs. Executes only on current unit. + + Args: + num_lines: number of log last lines being returned. + + Returns: + Multi-line logs string. + """ + try: + logger.debug("Getting Patroni logs...") + cache = snap.SnapCache() + selected_snap = cache["charmed-postgresql"] + # Lib definition of num_lines only allows int + return selected_snap.logs(services=["patroni"], num_lines=num_lines) # pyright: ignore + except snap.SnapError as e: + error_message = "Failed to get logs from patroni snap service" + logger.exception(error_message, exc_info=e) + return "" + + def last_postgresql_logs(self) -> str: + """Get last log file content of Postgresql service. + + If there is no available log files, empty line will be returned. + + Returns: + Content of last log file of Postgresql service. + """ + log_files = glob.glob(f"{self.workload.paths.logs}/*.log") + if len(log_files) == 0: + return "" + log_files.sort(reverse=True) + try: + with open(log_files[0]) as last_log_file: + return last_log_file.read() + except OSError as e: + error_message = "Failed to read last postgresql log file" + logger.exception(error_message, exc_info=e) + return "" + + def stop_patroni(self) -> bool: + """Stop Patroni service using systemd. + + Returns: + Whether the service stopped successfully. + """ + try: + logger.debug("Stopping Patroni...") + cache = snap.SnapCache() + selected_snap = cache["charmed-postgresql"] + selected_snap.stop(services=["patroni"]) + return not selected_snap.services["patroni"]["active"] + except snap.SnapError as e: + error_message = "Failed to stop patroni snap service" + logger.exception(error_message, exc_info=e) + return False + + def switchover(self, candidate: str | None = None, async_cluster: bool = False) -> None: + """Trigger a switchover.""" + # Try to trigger the switchover. + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): + with attempt: + current_primary = ( + self.get_primary() if not async_cluster else self.get_standby_leader() + ) + if current_primary == candidate: + logger.info("Candidate and leader are the same") + return + + body = {"leader": current_primary} + if candidate: + body["candidate"] = candidate + r = requests.post( + f"{self.state.patroni_url}/switchover", + json=body, + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug("API switchover: %s (%s)", r, r.elapsed.total_seconds()) + + # Check whether the switchover was unsuccessful. + if r.status_code != 200: + if ( + r.status_code == 412 + and r.text == "candidate name does not match with sync_standby" + ): + logger.debug("Unit is not sync standby") + raise SwitchoverNotSyncError() + logger.warning(f"Switchover call failed with code {r.status_code} {r.text}") + raise SwitchoverFailedError(f"received {r.status_code}") + + @retry( + retry=retry_if_result(lambda x: not x), + stop=stop_after_attempt(10), + wait=wait_exponential(multiplier=1, min=2, max=30), + ) + def primary_changed(self, old_primary: str) -> bool: + """Checks whether the primary unit has changed.""" + primary = self.get_primary() + return primary != old_primary + + def has_raft_quorum(self) -> bool: + """Check if raft cluster has quorum.""" + # Get the status of the raft cluster. + syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) + + raft_host = "127.0.0.1:2222" + try: + raft_status = syncobj_util.executeCommand(raft_host, ["status"]) + except UtilityException: + logger.warning("Has raft quorum: Cannot connect to raft cluster") + return False + if not raft_status: + logger.warning("Has raft quorum: No status reported") + return False + return raft_status["has_quorum"] + + def remove_raft_data(self) -> None: + """Stops Patroni and removes the raft journals.""" + logger.info("Stopping patroni") + self.stop_patroni() + + logger.info("Wait for postgresql to stop") + for attempt in Retrying(wait=wait_fixed(5)): + with attempt: + for proc in psutil.process_iter(["name"]): + if proc.name() == "postgres": + raise RaftPostgresqlStillUpError() + + logger.info("Removing raft data") + try: + path = Path(f"{self.workload.paths.patroni_conf}/raft") + if path.exists() and path.is_dir(): + shutil.rmtree(path) + except OSError as e: + raise Exception( + f"Failed to remove previous cluster information with error: {e!s}" + ) from e + logger.info("Raft ready to reinitialise") + + def reinitialise_raft_data(self) -> None: + """Reinitialise the raft journals and promoting the unit to leader. Should only be run on sync replicas.""" + logger.info("Rerendering patroni config without peers") + self.charm.update_config(no_peers=True) + logger.info("Starting patroni") + self.start_patroni() + + logger.info("Waiting for new raft cluster to initialise") + for attempt in Retrying(wait=wait_fixed(5)): + with attempt: + health_status = self.get_patroni_health() + if ( + health_status["role"] not in ["leader", "master"] + or health_status["state"] != "running" + ): + raise RaftNotPromotedError() + + logger.info("Restarting patroni") + self.restart_patroni() + for attempt in Retrying(wait=wait_fixed(5)): + with attempt: + found_postgres = False + for proc in psutil.process_iter(["name"]): + if proc.name() == "postgres": + found_postgres = True + break + if not found_postgres: + raise RaftPostgresqlNotUpError() + logger.info("Raft should be unstuck") + + def get_running_cluster_members(self) -> list[str]: + """List running patroni members.""" + try: + members = self.cluster_status() + return [member["name"] for member in members if member["state"] in STARTED_STATES] + except Exception: + return [] + + def cleanup_raft_cluster(self, watcher_addr) -> bool: + """Cleanup RAFT members not belonging to the current cluster or not a related watcher.""" + # Get Raft cluster status to find all members + try: + if not self.workload.is_patroni_running(): + logger.warning("Raft cleanup: Patroni service not running.") + return True + syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) + if raft_status := syncobj_util.executeCommand(f"127.0.0.1:{RAFT_PORT}", ["status"]): + # Find all partner nodes in the Raft cluster + # Keys look like: partner_node_status_server_10.131.50.142:2222 + for key in raft_status: + if key.startswith(RAFT_PARTNER_PREFIX) and raft_status[key] != 2: + member_addr = key.replace(RAFT_PARTNER_PREFIX, "") + member_ip = member_addr.split(":")[0] + + # Check if this is a stale watcher (not a PostgreSQL node and not current watcher) + if member_ip not in self.charm._units_ips and member_addr != watcher_addr: + logger.info(f"Removing stale Raft member: {member_addr}") + self.remove_raft_member(member_addr) + self.charm._remove_from_members_ips(member_ip) + return True + return False + except Exception as e: + logger.debug(f"Error during Raft cleanup: {e}") + return False + + def _set_stuck_raft_flag(self) -> None: + self.charm.set_unit_status(BlockedStatus("Raft majority loss, run: promote-to-primary")) + logger.warning("Remove raft member: Stuck raft cluster detected") + data_flags = {"raft_stuck": "True"} + self.charm.unit_peer_data.update(data_flags) + + # Leader doesn't always trigger when changing it's own peer data. + if self.charm.unit.is_leader(): + self.charm.on[PEER_RELATION].relation_changed.emit( + unit=self.charm.unit, + app=self.charm.app, + relation=self.charm.model.get_relation(PEER_RELATION), + ) + + def remove_raft_member(self, member_address: str | None) -> None: + """Remove a member from the raft cluster. + + The raft cluster is a different cluster from the Patroni cluster. + It is responsible for defining which Patroni member can update + the primary member in the DCS. + + Raises: + RaftMemberNotFoundError: if the member to be removed + is not part of the raft cluster. + """ + if not member_address: + logger.debug("Remove raft member: No address provided") + return + + if self.charm.has_raft_keys(): + logger.debug("Remove raft member: Raft already in recovery") + return + + # Get the status of the raft cluster. + syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) + + raft_host = "127.0.0.1:2222" + try: + raft_status = syncobj_util.executeCommand(raft_host, ["status"]) + except UtilityException as e: + logger.warning("Remove raft member: Cannot connect to raft cluster") + raise RemoveRaftMemberFailedError() from e + if not raft_status: + logger.warning("Remove raft member: No raft status") + raise RemoveRaftMemberFailedError() from None + + # Check whether the member is still part of the raft cluster. + if f"{RAFT_PARTNER_PREFIX}{member_address}" not in raft_status: + return + + # If there's no quorum and the leader left raft cluster is stuck + if raft_status["has_quorum"] and not raft_status["leader"]: + logger.warning("Remove raft member: No raft leader") + raise RemoveRaftMemberFailedError() from None + if not raft_status["has_quorum"] and ( + not raft_status["leader"] or raft_status["leader"].address == member_address + ): + self._set_stuck_raft_flag() + return + + # Remove the member from the raft cluster. + try: + result = syncobj_util.executeCommand(raft_host, ["remove", member_address]) + except UtilityException as e: + logger.debug("Remove raft member: Remove call failed") + raise RemoveRaftMemberFailedError() from e + + if not result or not result.startswith("SUCCESS"): + logger.debug(f"Remove raft member: Remove call not successful with {result}") + raise RemoveRaftMemberFailedError() from None + + @retry(stop=stop_after_attempt(20), wait=wait_exponential(multiplier=1, min=2, max=10)) + def reload_patroni_configuration(self): + """Reload Patroni configuration after it was changed.""" + logger.debug("Reloading Patroni configuration...") + r = requests.post( + f"{self.state.patroni_url}/reload", + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug("API reload_patroni_configuration: %s (%s)", r, r.elapsed.total_seconds()) + + def is_patroni_running(self) -> bool: + """Check if the Patroni service is running.""" + try: + cache = snap.SnapCache() + selected_snap = cache["charmed-postgresql"] + return selected_snap.services["patroni"]["active"] + except snap.SnapError as e: + logger.debug(f"Failed to check Patroni service: {e}") + return False + + def restart_patroni(self) -> bool: + """Restart Patroni. + + Returns: + Whether the service restarted successfully. + """ + try: + logger.debug("Restarting Patroni...") + cache = snap.SnapCache() + selected_snap = cache["charmed-postgresql"] + selected_snap.restart(services=["patroni"]) + return selected_snap.services["patroni"]["active"] + except snap.SnapError as e: + error_message = "Failed to start patroni snap service" + logger.exception(error_message, exc_info=e) + return False + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) + def restart_postgresql(self) -> None: + """Restart PostgreSQL.""" + logger.debug("Restarting PostgreSQL...") + r = requests.post( + f"{self.state.patroni_url}/restart", + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug("API restart_postgresql: %s (%s)", r, r.elapsed.total_seconds()) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) + def reinitialize_postgresql(self) -> None: + """Reinitialize PostgreSQL.""" + logger.debug("Reinitializing PostgreSQL...") + r = requests.post( + f"{self.state.patroni_url}/reinitialize", + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug("API reinitialize_postgresql: %s (%s)", r, r.elapsed.total_seconds()) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) + def bulk_update_parameters_controller_by_patroni( + self, parameters: dict[str, Any], base_parameters: dict[str, Any] | None + ) -> None: + """Update the value of a parameter controller by Patroni. + + For more information, check https://patroni.readthedocs.io/en/latest/patroni_configuration.html#postgresql-parameters-controlled-by-patroni. + """ + if not base_parameters: + base_parameters = {} + r = requests.patch( + f"{self.state.patroni_url}/config", + verify=self.verify, + json={ + "postgresql": { + "remove_data_directory_on_rewind_failure": False, + "remove_data_directory_on_diverged_timelines": False, + "parameters": parameters, + }, + **base_parameters, + }, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug( + "API bulk_update_parameters_controller_by_patroni: %s (%s)", + r, + r.elapsed.total_seconds(), + ) + r.raise_for_status() + + def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> None: + """Synchronises slots controlled by Patroni with the provided state by removing unneeded slots and creating new ones. + + Args: + slots: dictionary of slots in the {slot: database} format. + """ + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3), reraise=True): + with attempt: + current_config = requests.get( + f"{self.state.patroni_url}/config", + verify=self.verify, + timeout=API_REQUEST_TIMEOUT, + auth=self._patroni_auth, + ) + logger.debug( + "API ensure_slots_controller_by_patroni: %s (%s)", + current_config, + current_config.elapsed.total_seconds(), + ) + if current_config.status_code != 200: + raise Exception( + f"Failed to get current Patroni config: {current_config.status_code} {current_config.text}" + ) + slots_patch: dict[str, dict[str, str] | None] = dict.fromkeys( + current_config.json().get("slots") or {} + ) + for slot, database in slots.items(): + slots_patch[slot] = { + "database": database, + "plugin": "pgoutput", + "type": "logical", + } + r = requests.patch( + f"{self.state.patroni_url}/config", + verify=self.verify, + json={"slots": slots_patch}, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug( + "API ensure_slots_controller_by_patroni: %s (%s)", + r, + r.elapsed.total_seconds(), + ) + + def update_synchronous_node_count(self) -> None: + """Update synchronous_node_count to the minority of the planned cluster.""" + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): + with attempt: + r = requests.patch( + f"{self.state.patroni_url}/config", + json=self.state.synchronous_configuration, + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + logger.debug( + "API update_synchronous_node_count: %s (%s)", r, r.elapsed.total_seconds() + ) + + # Check whether the update was unsuccessful. + if r.status_code != 200: + raise UpdateSyncNodeCountError(f"received {r.status_code}") + + def get_patroni_restart_condition(self) -> str: + """Get current restart condition for Patroni systemd service. Executes only on current unit. + + Returns: + Patroni systemd service restart condition. + """ + with open(PATRONI_VM_SERVICE_DEFAULT_PATH) as patroni_service_file: + patroni_service = patroni_service_file.read() + found_restart = re.findall(r"Restart=(\w+)", patroni_service) + if len(found_restart) == 1: + return str(found_restart[0]) + raise RuntimeError("failed to find patroni service restart condition") + + def update_patroni_restart_condition(self, new_condition: str) -> None: + """Override restart condition for Patroni systemd service by rewriting service file and doing daemon-reload. + + Executes only on current unit. + + Args: + new_condition: new Patroni systemd service restart condition. + """ + logger.info(f"setting restart-condition to {new_condition} for patroni service") + with open(PATRONI_VM_SERVICE_DEFAULT_PATH) as patroni_service_file: + patroni_service = patroni_service_file.read() + logger.debug(f"patroni service file: {patroni_service}") + new_patroni_service = re.sub(r"Restart=\w+", f"Restart={new_condition}", patroni_service) + logger.debug(f"new patroni service file: {new_patroni_service}") + with open(PATRONI_VM_SERVICE_DEFAULT_PATH, "w") as patroni_service_file: + patroni_service_file.write(new_patroni_service) + subprocess.run(["/bin/systemctl", "daemon-reload"]) + + @property + def primary_endpoint_ready(self) -> bool: + """Is the primary endpoint redirecting connections to the primary pod. + + Returns: + Return whether the primary endpoint is redirecting connections to the primary pod. + """ + if not self.primary_endpoint: + return False + + try: + for attempt in Retrying(stop=stop_after_delay(10), wait=wait_fixed(1)): + with attempt: + r = requests.get( + f"https://{self.primary_endpoint}:8008/health", + verify=self.verify, + auth=self._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + if r.json()["state"] not in RUNNING_STATES: + raise EndpointNotReadyError + except RetryError: + return False + + return True + + def is_replication_hba_ready(self, endpoint: str | None = None) -> bool: + """Check whether the pg_hba allows replication from this unit. + + Attempts a physical replication connection. If it succeeds, the pg_hba + has been reloaded with this unit's endpoint. + + Args: + endpoint: The host to connect to. If None, uses self._primary_endpoint. + + Returns True if the connection is accepted, False otherwise. + """ + host = endpoint if endpoint is not None else self.primary_endpoint + try: + conn = psycopg2.connect( + host=host, + port=5432, + user="replication", + password=self.state.application.replication_password, + dbname="replication", + connect_timeout=1, + connection_factory=psycopg2.extras.PhysicalReplicationConnection, + ) + conn.close() + logger.debug("Replication HBA check passed: %s accepts replication connection", host) + return True + except psycopg2.OperationalError as e: + logger.debug("Replication HBA check failed: %s", e) + return False + def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False ) -> list[StatusObject]: diff --git a/single_kernel_postgresql/workload/base.py b/single_kernel_postgresql/workload/base.py index ec6b4a0..f4e543c 100644 --- a/single_kernel_postgresql/workload/base.py +++ b/single_kernel_postgresql/workload/base.py @@ -193,6 +193,11 @@ def is_service_started(self, paused: bool | None = False) -> bool: """ pass + @abstractmethod + def is_patroni_running(self) -> bool: + """Check if the Patroni service is running.""" + pass + @abstractmethod def start_service_only(self): """Start the actual service only (snap / pebble).""" diff --git a/single_kernel_postgresql/workload/k8s.py b/single_kernel_postgresql/workload/k8s.py index 605dcf8..7faecda 100644 --- a/single_kernel_postgresql/workload/k8s.py +++ b/single_kernel_postgresql/workload/k8s.py @@ -13,7 +13,7 @@ from charmlibs import pathops from charmlibs.pathops import PathProtocol from ops import Container, ModelError -from ops.pebble import Plan +from ops.pebble import Plan, ServiceStatus from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError from single_kernel_postgresql.config.literals import ( @@ -78,6 +78,17 @@ def start_service_only(self): """Start the actual service only (snap / pebble).""" raise NotImplementedError + def is_patroni_running(self) -> bool: + """Check if the Patroni service is running.""" + if not self.container.can_connect(): + return False + + services = self.container.pebble.get_services(names=[K8S_POSTGRESQL_SERVICE_NAME]) + if len(services) == 0: + return False + + return services[0].current == ServiceStatus.ACTIVE + def run_cmd( self, command: str, diff --git a/single_kernel_postgresql/workload/paths/base.py b/single_kernel_postgresql/workload/paths/base.py index 7a88b4d..01ad0ad 100644 --- a/single_kernel_postgresql/workload/paths/base.py +++ b/single_kernel_postgresql/workload/paths/base.py @@ -68,3 +68,9 @@ def patroni_conf(self) -> PathProtocol: def patroni_logs(self) -> PathProtocol: """Path to the patroni logs.""" pass + + @property + @abstractmethod + def pgbackrest_conf(self) -> PathProtocol: + """Path to the patroni logs.""" + pass diff --git a/single_kernel_postgresql/workload/paths/k8s.py b/single_kernel_postgresql/workload/paths/k8s.py index 87defef..1d84148 100644 --- a/single_kernel_postgresql/workload/paths/k8s.py +++ b/single_kernel_postgresql/workload/paths/k8s.py @@ -8,6 +8,7 @@ K8S_DATA_PATH, PATRONI_CONF_PATH, PATRONI_LOGS_PATH, + PGBACKREST_CONF_PATH, POSTGRESQL_CONF_FILE, POSTGRESQL_CONF_PATH, ) @@ -61,3 +62,8 @@ def patroni_conf(self) -> PathProtocol: def patroni_logs(self) -> PathProtocol: """Path to the patroni logs.""" return self.logs / PATRONI_LOGS_PATH + + @property + def pgbackrest_conf(self) -> PathProtocol: + """Path to the pgbackrest config.""" + return self.conf / PGBACKREST_CONF_PATH diff --git a/single_kernel_postgresql/workload/paths/vm.py b/single_kernel_postgresql/workload/paths/vm.py index 523f8b2..6d5e9c4 100644 --- a/single_kernel_postgresql/workload/paths/vm.py +++ b/single_kernel_postgresql/workload/paths/vm.py @@ -8,6 +8,7 @@ BASE_SNAP_DIR, PATRONI_CONF_PATH, PATRONI_LOGS_PATH, + PGBACKREST_CONF_PATH, POSTGRESQL_CONF_FILE, POSTGRESQL_CONF_PATH, SNAP, @@ -91,3 +92,8 @@ def logs(self) -> PathProtocol: def tmp(self) -> PathProtocol: """Path to the temporary directory.""" return self.root / "tmp" + + @property + def pgbackrest_conf(self) -> PathProtocol: + """Path to the pgbackrest configuration.""" + return self.snap_current / PGBACKREST_CONF_PATH diff --git a/tox.ini b/tox.ini index d8ec751..18c352c 100644 --- a/tox.ini +++ b/tox.ini @@ -26,8 +26,8 @@ description = Apply coding style standards to code commands_pre = uv sync --active --group format --all-extras commands = - uv run --active ruff check --fix {[vars]all_path} - uv run --active ruff format {[vars]all_path} + uv run --active ruff check --fix {[vars]all_path} --exclude {[vars]src_path}/lib + uv run --active ruff format {[vars]all_path} --exclude {[vars]src_path}/lib [testenv:lint] From f9f251c66eae7cc24d18b1feaa9d0a78289988b8 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Wed, 24 Jun 2026 18:29:51 +0300 Subject: [PATCH 04/31] Comment out raft logic --- .../core/peer_relation.py | 70 ++++ single_kernel_postgresql/core/state.py | 21 +- single_kernel_postgresql/managers/config.py | 38 +- single_kernel_postgresql/managers/patroni.py | 381 +++++++++--------- 4 files changed, 306 insertions(+), 204 deletions(-) diff --git a/single_kernel_postgresql/core/peer_relation.py b/single_kernel_postgresql/core/peer_relation.py index 069a859..0dde0be 100644 --- a/single_kernel_postgresql/core/peer_relation.py +++ b/single_kernel_postgresql/core/peer_relation.py @@ -6,6 +6,8 @@ """State objects for database-peers relation.""" import json +from collections.abc import MutableMapping +from functools import cached_property from ops import Application, BlockedStatus, Relation, Unit @@ -177,6 +179,34 @@ def peer_addresses(self) -> set[str]: peer_addrs.add(addr) return peer_addrs + @property + def is_unit_departing(self) -> bool: + """Returns whether the unit is departing.""" + if not self.relation: + return False + return "departing" in self.relation.data[self.unit] + + @property + def is_unit_stopped(self) -> bool: + """Returns whether the unit is stopped.""" + if not self.relation: + return False + return "stopped" in self.relation.data[self.unit] + + @property + def is_connectivity_enabled(self) -> bool: + """Return whether this unit can be connected externally.""" + if not self.relation: + return True + return self.relation.data[self.unit].get("connectivity", "on") == "on" + + @cached_property + def data(self) -> MutableMapping[str, str]: + """Escape hatch method to access the peer data directly.""" + if not self.relation: + return {} + return self.relation.data[self.unit] + class PostgreSQLApplication(RelationState): """An PostgreSQL Application is the peer application state. @@ -309,6 +339,39 @@ def endpoints(self) -> set[str]: return set() return set(json.loads(self.relation.data[self.app].get("endpoints", "[]"))) + @property + def is_cluster_initialised(self) -> bool: + """Returns whether the cluster is already initialised.""" + if not self.relation: + return False + return "cluster_initialised" in self.relation.data[self.app] + + @property + def is_cluster_restoring_backup(self) -> bool: + """Returns whether the cluster is restoring a backup.""" + if not self.relation: + return False + return "restoring-backup" in self.relation.data[self.app] + + @property + def is_cluster_restoring_to_time(self) -> bool: + """Returns whether the cluster is restoring a backup to a specific time.""" + if not self.relation: + return False + return "restore-to-time" in self.relation.data[self.app] + + @property + def is_ldap_charm_related(self) -> bool: + """Return whether this unit has an LDAP charm related.""" + if not self.relation: + return False + return self.relation.data[self.app].get("ldap_enabled", "False") == "True" + + @property + def is_ldap_enabled(self) -> bool: + """Return whether this unit has LDAP enabled.""" + return self.is_ldap_charm_related and self.is_cluster_initialised + def get_secret(self, key: str) -> str | None: """Get the secret value for 'key' from the peer relation data.""" if not self.relation: @@ -326,3 +389,10 @@ def remove_secret(self, key: str) -> None: if not self.relation: return self.data_interface.delete_relation_data(self.relation.id, [key]) + + @cached_property + def data(self) -> MutableMapping[str, str]: + """Escape hatch method to access the peer data directly.""" + if not self.relation: + return {} + return self.relation.data[self.app] diff --git a/single_kernel_postgresql/core/state.py b/single_kernel_postgresql/core/state.py index ed20978..deb4210 100644 --- a/single_kernel_postgresql/core/state.py +++ b/single_kernel_postgresql/core/state.py @@ -165,7 +165,7 @@ def fqdn(self) -> str | None: def endpoint(self) -> str | None: """Current unit endpoint.""" if self.substrate == Substrates.K8S: - return self.fqdn + return f"{self.peer.unit.name.replace('/', '-')}.{self.application.app.name}-endpoints" else: return self.unit_ip @@ -173,9 +173,11 @@ def endpoint(self) -> str | None: def endpoints(self) -> set[str]: """Returns the list of endpoints of the current members of the cluster.""" if self.peer_relation: - return self.application.endpoints - else: - return {self.endpoint} if self.endpoint else set() + if self.substrate == Substrates.K8S: + return self.application.endpoints + else: + return self.peer_members_ips + return {self.endpoint} if self.endpoint else set() @property def model_name(self) -> str: @@ -185,7 +187,7 @@ def model_name(self) -> str: @cached_property def patroni_url(self) -> str: """Patroni REST API URL.""" - return f"https://{self.unit_ip}:8008" + return f"https://{self.endpoint}:8008" @property def peer_members_ips(self) -> set[str]: @@ -429,3 +431,12 @@ def synchronous_configuration(self) -> dict[str, Any]: and self.config.synchronous_mode_strict and self.synchronous_node_count > 0, } + + def _build_service_name(self, service: str) -> str: + """Build a full k8s service name based on the service name.""" + return f"{self.application.app.name}-{service}.{self.model_name}.svc.cluster.local" + + @property + def primary_endpoint(self) -> str: + """Returns the endpoint of the primary instance's service.""" + return self._build_service_name("primary") diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index b57ab9b..6992e5b 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -88,24 +88,54 @@ def _build_postgresql_parameters(self) -> dict[str, str] | None: def update_config( self, is_creating_backup: bool = False, + # TODO add rel handler + is_tls_enabled: bool = False, + # TODO add rel handler + relations_user_databases_map: dict[str, Any] | None = None, + # TODO add rel handler + ldap_parameters: dict[str, Any] | None = None, + # TODO add rel handler + async_primary_cluster_endpoint: str | None = None, + async_partner_addresses: list[str] | None = None, + async_standby_endpoints: list[str] | None = None, + # TODO add rel handler + watcher_raft_address: str | None = None, no_peers: bool = False, *, refresh: charm_refresh.Machines | None = None, ) -> bool: """Updates Patroni config file based on the existence of the TLS files.""" - # if refresh is None: - # refresh = self.refresh - # Build PostgreSQL parameters pg_parameters = self._build_postgresql_parameters() # replication_slots = self.logical_replication.replication_slots() + replication_slots = {} + + # TODO add rel handler + relations_user_databases_map = relations_user_databases_map or {} # Update and reload configuration based on TLS files availability. logger.debug(f"Calling render_patroni_yml_file with parameters = {pg_parameters}") self.render_patroni_yml_file( + connectivity=self.state.peer.is_connectivity_enabled, + is_creating_backup=is_creating_backup, + enable_ldap=self.state.application.is_ldap_enabled, + enable_tls=is_tls_enabled, + backup_id=self.state.application.data.get("restoring-backup"), + pitr_target=self.state.application.data.get("restore-to-time"), + restore_timeline=self.state.application.data.get("restore-timeline"), + restore_to_latest=self.state.application.data.get("restore-to-time", None) == "latest", + stanza=self.state.application.data.get("stanza", self.state.peer.data.get("stanza")), + restore_stanza=self.state.application.data.get("restore-stanza"), parameters=pg_parameters, - slots={}, + user_databases_map=relations_user_databases_map, + slots=replication_slots, + ldap_parameters=ldap_parameters, + async_primary_cluster_endpoint=async_primary_cluster_endpoint, + async_partner_addresses=async_partner_addresses, + async_standby_endpoints=async_standby_endpoints, + watcher_raft_address=watcher_raft_address, + no_peers=no_peers, ) return True diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index ec9ac07..d084200 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -8,23 +8,24 @@ such as starting the service and checking its status. """ +# import shutil +# from pathlib import Path import glob import logging import os import pathlib import re -import shutil import subprocess from contextlib import suppress from functools import cached_property -from pathlib import Path from typing import Any, Literal, TypedDict # Platform specific imports with suppress(ImportError): - import psutil + # import psutil from charmlibs import snap - from pysyncobj.utility import TcpUtility, UtilityException + # from pysyncobj.utility import TcpUtility, UtilityException +# from ops import BlockedStatus import psycopg2 import psycopg2.extras import requests @@ -32,7 +33,6 @@ from data_platform_helpers.advanced_statuses import StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope from httpx import BasicAuth -from ops import BlockedStatus from requests.auth import HTTPBasicAuth from tenacity import ( Future, @@ -51,10 +51,10 @@ API_REQUEST_TIMEOUT, PATRONI_CLUSTER_STATUS_ENDPOINT, PATRONI_VM_SERVICE_DEFAULT_PATH, - PEER_RELATION, + # PEER_RELATION, POSTGRESQL_STORAGE_PERMISSIONS, - RAFT_PARTNER_PREFIX, - RAFT_PORT, + # RAFT_PARTNER_PREFIX, + # RAFT_PORT, RUNNING_STATES, STARTED_STATES, TLS_CA_BUNDLE_FILE, @@ -138,18 +138,8 @@ def __init__( state: CharmState, workload: BaseWorkload, client: PostgreSQLClient, - endpoint: str | None, - endpoints: list[str], - primary_endpoint: str | None, - cluster_name: str, - member_name: str, ): super().__init__(state, workload, "patroni_manager", client) - self.endpoint = endpoint - self.endpoints = endpoints - self.primary_endpoint = primary_endpoint - self.cluster_name = cluster_name - self.member_name = member_name # Variable mapping to requests library verify parameter. # The CA bundle file is used to validate the server certificate when # TLS is enabled, otherwise True is set because it's the default value. @@ -277,9 +267,9 @@ def cluster_status(self, alternative_endpoints: list | None = None) -> list[Clus endpoints = alternative_endpoints else: endpoints = [] - if self.endpoint: - endpoints.append(self.endpoint) - for peer in self.endpoints: + if self.state.endpoint: + endpoints.append(self.state.endpoint) + for peer in self.state.endpoints: endpoints.append(peer) # Request info from cluster endpoint (which returns all members of the cluster). if response := parallel_patroni_get_request( @@ -417,7 +407,7 @@ def are_all_members_ready(self) -> bool: else: # Request info from cluster endpoint # (which returns all members of the cluster and their states). - return len(self.get_running_cluster_members()) == len(self.endpoints) + return len(self.get_running_cluster_members()) == len(self.state.endpoints) @property def is_creating_backup(self) -> bool: @@ -436,7 +426,7 @@ def is_creating_backup(self) -> bool: def is_replication_healthy(self) -> bool: """Return whether the replication is healthy.""" - if not self.endpoint: + if not self.state.endpoint: logger.debug("Failed replication check no endpoint set") return False try: @@ -447,13 +437,13 @@ def is_replication_healthy(self) -> bool: logger.debug("Failed replication check no primary reported") raise Exception primary_addr = self.get_member_ip(primary) - members_addrs = {self.endpoint} - members_addrs.update(self.endpoints) + members_addrs = {self.state.endpoint} + members_addrs.update(self.state.endpoints) for members_addr in members_addrs: endpoint = ( "leader" if members_addr == primary_addr else "replica?lag=100MB" ) - url = self.state.patroni_url.replace(self.endpoint, members_addr) + url = self.state.patroni_url.replace(self.state.endpoint, members_addr) r = requests.get( f"{url}/{endpoint}", verify=self.verify, @@ -539,7 +529,7 @@ def is_member_registered_in_cluster(self) -> bool: return False # Check if this member's name appears in the cluster members list - member_name = self.member_name + member_name = self.state.peer.member_name return any(member.get("name") == member_name for member in cluster_status) def online_cluster_members(self) -> list[ClusterMember]: @@ -703,74 +693,75 @@ def primary_changed(self, old_primary: str) -> bool: primary = self.get_primary() return primary != old_primary - def has_raft_quorum(self) -> bool: - """Check if raft cluster has quorum.""" - # Get the status of the raft cluster. - syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) - - raft_host = "127.0.0.1:2222" - try: - raft_status = syncobj_util.executeCommand(raft_host, ["status"]) - except UtilityException: - logger.warning("Has raft quorum: Cannot connect to raft cluster") - return False - if not raft_status: - logger.warning("Has raft quorum: No status reported") - return False - return raft_status["has_quorum"] - - def remove_raft_data(self) -> None: - """Stops Patroni and removes the raft journals.""" - logger.info("Stopping patroni") - self.stop_patroni() - - logger.info("Wait for postgresql to stop") - for attempt in Retrying(wait=wait_fixed(5)): - with attempt: - for proc in psutil.process_iter(["name"]): - if proc.name() == "postgres": - raise RaftPostgresqlStillUpError() - - logger.info("Removing raft data") - try: - path = Path(f"{self.workload.paths.patroni_conf}/raft") - if path.exists() and path.is_dir(): - shutil.rmtree(path) - except OSError as e: - raise Exception( - f"Failed to remove previous cluster information with error: {e!s}" - ) from e - logger.info("Raft ready to reinitialise") - - def reinitialise_raft_data(self) -> None: - """Reinitialise the raft journals and promoting the unit to leader. Should only be run on sync replicas.""" - logger.info("Rerendering patroni config without peers") - self.charm.update_config(no_peers=True) - logger.info("Starting patroni") - self.start_patroni() - - logger.info("Waiting for new raft cluster to initialise") - for attempt in Retrying(wait=wait_fixed(5)): - with attempt: - health_status = self.get_patroni_health() - if ( - health_status["role"] not in ["leader", "master"] - or health_status["state"] != "running" - ): - raise RaftNotPromotedError() - - logger.info("Restarting patroni") - self.restart_patroni() - for attempt in Retrying(wait=wait_fixed(5)): - with attempt: - found_postgres = False - for proc in psutil.process_iter(["name"]): - if proc.name() == "postgres": - found_postgres = True - break - if not found_postgres: - raise RaftPostgresqlNotUpError() - logger.info("Raft should be unstuck") + # def has_raft_quorum(self) -> bool: + # """Check if raft cluster has quorum.""" + # # Get the status of the raft cluster. + # syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) + + # raft_host = "127.0.0.1:2222" + # try: + # raft_status = syncobj_util.executeCommand(raft_host, ["status"]) + # except UtilityException: + # logger.warning("Has raft quorum: Cannot connect to raft cluster") + # return False + # if not raft_status: + # logger.warning("Has raft quorum: No status reported") + # return False + # return raft_status["has_quorum"] + + # def remove_raft_data(self) -> None: + # """Stops Patroni and removes the raft journals.""" + # logger.info("Stopping patroni") + # self.stop_patroni() + + # logger.info("Wait for postgresql to stop") + # for attempt in Retrying(wait=wait_fixed(5)): + # with attempt: + # for proc in psutil.process_iter(["name"]): + # if proc.name() == "postgres": + # raise RaftPostgresqlStillUpError() + + # logger.info("Removing raft data") + # try: + # path = Path(f"{self.workload.paths.patroni_conf}/raft") + # if path.exists() and path.is_dir(): + # shutil.rmtree(path) + # except OSError as e: + # raise Exception( + # f"Failed to remove previous cluster information with error: {e!s}" + # ) from e + # logger.info("Raft ready to reinitialise") + + # def reinitialise_raft_data(self, update_config, update_config_kwargs) -> None: + # """Reinitialise the raft journals and promoting the unit to leader. Should only be run on sync replicas.""" + # logger.info("Rerendering patroni config without peers") + # # TODO figure out cross manager update config calls + # update_config(no_peers=True, **update_config_kwargs) + # logger.info("Starting patroni") + # self.start_patroni() + + # logger.info("Waiting for new raft cluster to initialise") + # for attempt in Retrying(wait=wait_fixed(5)): + # with attempt: + # health_status = self.get_patroni_health() + # if ( + # health_status["role"] not in ["leader", "master"] + # or health_status["state"] != "running" + # ): + # raise RaftNotPromotedError() + + # logger.info("Restarting patroni") + # self.restart_patroni() + # for attempt in Retrying(wait=wait_fixed(5)): + # with attempt: + # found_postgres = False + # for proc in psutil.process_iter(["name"]): + # if proc.name() == "postgres": + # found_postgres = True + # break + # if not found_postgres: + # raise RaftPostgresqlNotUpError() + # logger.info("Raft should be unstuck") def get_running_cluster_members(self) -> list[str]: """List running patroni members.""" @@ -780,103 +771,103 @@ def get_running_cluster_members(self) -> list[str]: except Exception: return [] - def cleanup_raft_cluster(self, watcher_addr) -> bool: - """Cleanup RAFT members not belonging to the current cluster or not a related watcher.""" - # Get Raft cluster status to find all members - try: - if not self.workload.is_patroni_running(): - logger.warning("Raft cleanup: Patroni service not running.") - return True - syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) - if raft_status := syncobj_util.executeCommand(f"127.0.0.1:{RAFT_PORT}", ["status"]): - # Find all partner nodes in the Raft cluster - # Keys look like: partner_node_status_server_10.131.50.142:2222 - for key in raft_status: - if key.startswith(RAFT_PARTNER_PREFIX) and raft_status[key] != 2: - member_addr = key.replace(RAFT_PARTNER_PREFIX, "") - member_ip = member_addr.split(":")[0] - - # Check if this is a stale watcher (not a PostgreSQL node and not current watcher) - if member_ip not in self.charm._units_ips and member_addr != watcher_addr: - logger.info(f"Removing stale Raft member: {member_addr}") - self.remove_raft_member(member_addr) - self.charm._remove_from_members_ips(member_ip) - return True - return False - except Exception as e: - logger.debug(f"Error during Raft cleanup: {e}") - return False - - def _set_stuck_raft_flag(self) -> None: - self.charm.set_unit_status(BlockedStatus("Raft majority loss, run: promote-to-primary")) - logger.warning("Remove raft member: Stuck raft cluster detected") - data_flags = {"raft_stuck": "True"} - self.charm.unit_peer_data.update(data_flags) - - # Leader doesn't always trigger when changing it's own peer data. - if self.charm.unit.is_leader(): - self.charm.on[PEER_RELATION].relation_changed.emit( - unit=self.charm.unit, - app=self.charm.app, - relation=self.charm.model.get_relation(PEER_RELATION), - ) - - def remove_raft_member(self, member_address: str | None) -> None: - """Remove a member from the raft cluster. - - The raft cluster is a different cluster from the Patroni cluster. - It is responsible for defining which Patroni member can update - the primary member in the DCS. - - Raises: - RaftMemberNotFoundError: if the member to be removed - is not part of the raft cluster. - """ - if not member_address: - logger.debug("Remove raft member: No address provided") - return - - if self.charm.has_raft_keys(): - logger.debug("Remove raft member: Raft already in recovery") - return - - # Get the status of the raft cluster. - syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) - - raft_host = "127.0.0.1:2222" - try: - raft_status = syncobj_util.executeCommand(raft_host, ["status"]) - except UtilityException as e: - logger.warning("Remove raft member: Cannot connect to raft cluster") - raise RemoveRaftMemberFailedError() from e - if not raft_status: - logger.warning("Remove raft member: No raft status") - raise RemoveRaftMemberFailedError() from None - - # Check whether the member is still part of the raft cluster. - if f"{RAFT_PARTNER_PREFIX}{member_address}" not in raft_status: - return - - # If there's no quorum and the leader left raft cluster is stuck - if raft_status["has_quorum"] and not raft_status["leader"]: - logger.warning("Remove raft member: No raft leader") - raise RemoveRaftMemberFailedError() from None - if not raft_status["has_quorum"] and ( - not raft_status["leader"] or raft_status["leader"].address == member_address - ): - self._set_stuck_raft_flag() - return - - # Remove the member from the raft cluster. - try: - result = syncobj_util.executeCommand(raft_host, ["remove", member_address]) - except UtilityException as e: - logger.debug("Remove raft member: Remove call failed") - raise RemoveRaftMemberFailedError() from e - - if not result or not result.startswith("SUCCESS"): - logger.debug(f"Remove raft member: Remove call not successful with {result}") - raise RemoveRaftMemberFailedError() from None + # def cleanup_raft_cluster(self, watcher_addr) -> bool: + # """Cleanup RAFT members not belonging to the current cluster or not a related watcher.""" + # # Get Raft cluster status to find all members + # try: + # if not self.workload.is_patroni_running(): + # logger.warning("Raft cleanup: Patroni service not running.") + # return True + # syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) + # if raft_status := syncobj_util.executeCommand(f"127.0.0.1:{RAFT_PORT}", ["status"]): + # # Find all partner nodes in the Raft cluster + # # Keys look like: partner_node_status_server_10.131.50.142:2222 + # for key in raft_status: + # if key.startswith(RAFT_PARTNER_PREFIX) and raft_status[key] != 2: + # member_addr = key.replace(RAFT_PARTNER_PREFIX, "") + # member_ip = member_addr.split(":")[0] + + # # Check if this is a stale watcher (not a PostgreSQL node and not current watcher) + # if member_ip not in self.charm._units_ips and member_addr != watcher_addr: + # logger.info(f"Removing stale Raft member: {member_addr}") + # self.remove_raft_member(member_addr) + # self.state.application.remove_from_members_ips(member_ip) + # return True + # return False + # except Exception as e: + # logger.debug(f"Error during Raft cleanup: {e}") + # return False + + # def _set_stuck_raft_flag(self) -> None: + # self.charm.set_unit_status(BlockedStatus("Raft majority loss, run: promote-to-primary")) + # logger.warning("Remove raft member: Stuck raft cluster detected") + # data_flags = {"raft_stuck": "True"} + # self.state.peer.data.update(data_flags) + + # Leader doesn't always trigger when changing it's own peer data. + # if self.state.peer.unit.is_leader(): + # self.charm.on[PEER_RELATION].relation_changed.emit( + # unit=self.state.peer.unit, + # app=self.state.application.app, + # relation=self.state.application.relation, + # ) + + # def remove_raft_member(self, member_address: str | None) -> None: + # """Remove a member from the raft cluster. + + # The raft cluster is a different cluster from the Patroni cluster. + # It is responsible for defining which Patroni member can update + # the primary member in the DCS. + + # Raises: + # RaftMemberNotFoundError: if the member to be removed + # is not part of the raft cluster. + # """ + # if not member_address: + # logger.debug("Remove raft member: No address provided") + # return + + # if self.state.has_raft_keys(): + # logger.debug("Remove raft member: Raft already in recovery") + # return + + # # Get the status of the raft cluster. + # syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) + + # raft_host = "127.0.0.1:2222" + # try: + # raft_status = syncobj_util.executeCommand(raft_host, ["status"]) + # except UtilityException as e: + # logger.warning("Remove raft member: Cannot connect to raft cluster") + # raise RemoveRaftMemberFailedError() from e + # if not raft_status: + # logger.warning("Remove raft member: No raft status") + # raise RemoveRaftMemberFailedError() from None + + # # Check whether the member is still part of the raft cluster. + # if f"{RAFT_PARTNER_PREFIX}{member_address}" not in raft_status: + # return + + # # If there's no quorum and the leader left raft cluster is stuck + # if raft_status["has_quorum"] and not raft_status["leader"]: + # logger.warning("Remove raft member: No raft leader") + # raise RemoveRaftMemberFailedError() from None + # if not raft_status["has_quorum"] and ( + # not raft_status["leader"] or raft_status["leader"].address == member_address + # ): + # self._set_stuck_raft_flag() + # return + + # # Remove the member from the raft cluster. + # try: + # result = syncobj_util.executeCommand(raft_host, ["remove", member_address]) + # except UtilityException as e: + # logger.debug("Remove raft member: Remove call failed") + # raise RemoveRaftMemberFailedError() from e + + # if not result or not result.startswith("SUCCESS"): + # logger.debug(f"Remove raft member: Remove call not successful with {result}") + # raise RemoveRaftMemberFailedError() from None @retry(stop=stop_after_attempt(20), wait=wait_exponential(multiplier=1, min=2, max=10)) def reload_patroni_configuration(self): @@ -1074,14 +1065,14 @@ def primary_endpoint_ready(self) -> bool: Returns: Return whether the primary endpoint is redirecting connections to the primary pod. """ - if not self.primary_endpoint: + if not self.state.primary_endpoint: return False try: for attempt in Retrying(stop=stop_after_delay(10), wait=wait_fixed(1)): with attempt: r = requests.get( - f"https://{self.primary_endpoint}:8008/health", + f"https://{self.state.primary_endpoint}:8008/health", verify=self.verify, auth=self._patroni_auth, timeout=API_REQUEST_TIMEOUT, @@ -1104,7 +1095,7 @@ def is_replication_hba_ready(self, endpoint: str | None = None) -> bool: Returns True if the connection is accepted, False otherwise. """ - host = endpoint if endpoint is not None else self.primary_endpoint + host = endpoint if endpoint is not None else self.state.primary_endpoint try: conn = psycopg2.connect( host=host, From b8508207d0ffb2572d302f9e7bbd72258807d0ba Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Wed, 24 Jun 2026 20:51:08 +0300 Subject: [PATCH 05/31] Attr loop --- single_kernel_postgresql/charms/abstract_charm.py | 8 ++------ single_kernel_postgresql/charms/k8s_charm.py | 2 +- single_kernel_postgresql/managers/base.py | 6 +----- single_kernel_postgresql/managers/cluster.py | 3 ++- single_kernel_postgresql/managers/config.py | 3 ++- single_kernel_postgresql/managers/k8s.py | 5 ++--- single_kernel_postgresql/managers/patroni.py | 4 +--- single_kernel_postgresql/managers/tls.py | 5 ++--- 8 files changed, 13 insertions(+), 23 deletions(-) diff --git a/single_kernel_postgresql/charms/abstract_charm.py b/single_kernel_postgresql/charms/abstract_charm.py index 0cf7b0a..be356c8 100644 --- a/single_kernel_postgresql/charms/abstract_charm.py +++ b/single_kernel_postgresql/charms/abstract_charm.py @@ -29,18 +29,14 @@ def __init__(self, *args): self.state = CharmState(charm=self, substrate=self.substrate) # Managers + self.tls_manager = TLSManager(state=self.state, workload=self.workload) + self.patroni_manager = PatroniManager(state=self.state, workload=self.workload) self.cluster_manager = ClusterManager( state=self.state, workload=self.workload, client=self.postgresql ) - self.tls_manager = TLSManager( - state=self.state, workload=self.workload, client=self.postgresql - ) self.config_manager = ConfigManager( state=self.state, workload=self.workload, client=self.postgresql ) - self.patroni_manager = PatroniManager( - state=self.state, workload=self.workload, client=self.postgresql - ) # Events Handler self.postgresql_events_handler = PostgreSQLEventsHandler( diff --git a/single_kernel_postgresql/charms/k8s_charm.py b/single_kernel_postgresql/charms/k8s_charm.py index b261ac6..772d210 100644 --- a/single_kernel_postgresql/charms/k8s_charm.py +++ b/single_kernel_postgresql/charms/k8s_charm.py @@ -25,7 +25,7 @@ def __init__(self, *args): assert isinstance(self.workload, K8sWorkload), ( # noqa: S101 "Workload must be an instance of K8sWorkload" ) - self.k8s_manager = K8sManager(self.state, self.workload, self.postgresql) + self.k8s_manager = K8sManager(self.state, self.workload) @property def postgresql(self) -> PostgreSQL: diff --git a/single_kernel_postgresql/managers/base.py b/single_kernel_postgresql/managers/base.py index e3e8ebc..85f85ef 100644 --- a/single_kernel_postgresql/managers/base.py +++ b/single_kernel_postgresql/managers/base.py @@ -11,7 +11,6 @@ from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload logger = logging.getLogger(__name__) @@ -25,13 +24,10 @@ class BaseManager(ManagerStatusProtocol): state: CharmState - def __init__( - self, state: CharmState, workload: BaseWorkload, name: str, client: PostgreSQLClient - ): + def __init__(self, state: CharmState, workload: BaseWorkload, name: str): self.state = state self.workload = workload self.name = name - self.postgresql_client = client def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False diff --git a/single_kernel_postgresql/managers/cluster.py b/single_kernel_postgresql/managers/cluster.py index 3e1ff3b..8b9ee17 100644 --- a/single_kernel_postgresql/managers/cluster.py +++ b/single_kernel_postgresql/managers/cluster.py @@ -46,7 +46,8 @@ class ClusterManager(BaseManager): """ def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "cluster_manager", client) + super().__init__(state, workload, "cluster_manager") + self.postgresql_client = client def install_workload(self) -> None: """Install the workload.""" diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index 6992e5b..ff68ad5 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -39,7 +39,8 @@ class ConfigManager(BaseManager): """ def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "config_manager", client) + super().__init__(state, workload, "config_manager") + self.postgresql_client = client @staticmethod def _dict_to_hba_string(_dict: dict[str, Any]) -> str: diff --git a/single_kernel_postgresql/managers/k8s.py b/single_kernel_postgresql/managers/k8s.py index a8c84cd..1685b41 100644 --- a/single_kernel_postgresql/managers/k8s.py +++ b/single_kernel_postgresql/managers/k8s.py @@ -32,7 +32,6 @@ from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager from single_kernel_postgresql.utils import unit_name_to_pod_name -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.k8s import K8sWorkload logger = logging.getLogger(__name__) @@ -44,8 +43,8 @@ class K8sManager(BaseManager): This manager is responsible for handling operations related to Kubernetes and Pebble. """ - def __init__(self, state: CharmState, workload: K8sWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "pebble_manager", client) + def __init__(self, state: CharmState, workload: K8sWorkload): + super().__init__(state, workload, "pebble_manager") self.workload: K8sWorkload = workload # type: ignore[assignment] def update_pebble_layers(self, replan: bool = True) -> None: diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index d084200..2252777 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -63,7 +63,6 @@ from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager from single_kernel_postgresql.utils import _change_owner, label2name, parallel_patroni_get_request -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload from single_kernel_postgresql.workload.vm import VMWorkload @@ -137,9 +136,8 @@ def __init__( self, state: CharmState, workload: BaseWorkload, - client: PostgreSQLClient, ): - super().__init__(state, workload, "patroni_manager", client) + super().__init__(state, workload, "patroni_manager") # Variable mapping to requests library verify parameter. # The CA bundle file is used to validate the server certificate when # TLS is enabled, otherwise True is set because it's the default value. diff --git a/single_kernel_postgresql/managers/tls.py b/single_kernel_postgresql/managers/tls.py index 67681ea..8b12614 100644 --- a/single_kernel_postgresql/managers/tls.py +++ b/single_kernel_postgresql/managers/tls.py @@ -28,7 +28,6 @@ from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload logger = logging.getLogger(__name__) @@ -40,8 +39,8 @@ class TLSManager(BaseManager): This manager is responsible for handling TLS configuration operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "tls_manager", client) + def __init__(self, state: CharmState, workload: BaseWorkload): + super().__init__(state, workload, "tls_manager") def configure_internal_peer_ca(self) -> None: """Configure TLS internal peer CA.""" From 4f9840db17443562aa4a913946d1df8cc596fd3d Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Thu, 25 Jun 2026 02:10:04 +0300 Subject: [PATCH 06/31] Don't pull psycopg2 --- pyproject.toml | 2 +- uv.lock | 42 ------------------------------------------ 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 266f169..9657662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ [project.optional-dependencies] postgresql = [ - "psycopg2>=2.9.10", + # "psycopg2>=2.9.10", "httpx; python_version >= '3.12'", "data-platform-helpers>=0.1.7; python_version >= '3.12'", "charmlibs-pathops>=1.0.1; python_version >= '3.12'", diff --git a/uv.lock b/uv.lock index fdac67f..8f57dd8 100644 --- a/uv.lock +++ b/uv.lock @@ -992,8 +992,6 @@ postgresql = [ { name = "data-platform-helpers", marker = "python_full_version >= '3.12'" }, { name = "httpx", marker = "python_full_version >= '3.12'" }, { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "psycopg2", version = "2.9.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "psycopg2", version = "2.9.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pydantic", marker = "python_full_version >= '3.12'" }, { name = "requests", marker = "python_full_version >= '3.12'" }, { name = "tomli", marker = "python_full_version >= '3.12'" }, @@ -1029,7 +1027,6 @@ requires-dist = [ { name = "jinja2", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=3.1.6" }, { name = "ops", specifier = ">=2.0.0" }, { name = "psutil", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=7.2.2" }, - { name = "psycopg2", marker = "extra == 'postgresql'", specifier = ">=2.9.10" }, { name = "pydantic", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=2.0" }, { name = "pysyncobj", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=0.3.15" }, { name = "requests", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, @@ -1077,45 +1074,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] -[[package]] -name = "psycopg2" -version = "2.9.10" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672, upload-time = "2024-10-16T11:24:54.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/a9/146b6bdc0d33539a359f5e134ee6dda9173fb8121c5b96af33fa299e50c4/psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716", size = 1024527, upload-time = "2024-10-16T11:18:24.43Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/c509e56f725fd2572b59b69bd964edaf064deebf1c896b2452f6b46fdfb3/psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a", size = 1163735, upload-time = "2024-10-16T11:18:29.586Z" }, - { url = "https://files.pythonhosted.org/packages/20/a2/c51ca3e667c34e7852157b665e3d49418e68182081060231d514dd823225/psycopg2-2.9.10-cp311-cp311-win32.whl", hash = "sha256:47c4f9875125344f4c2b870e41b6aad585901318068acd01de93f3677a6522c2", size = 1024538, upload-time = "2024-10-16T11:18:33.48Z" }, - { url = "https://files.pythonhosted.org/packages/33/39/5a9a229bb5414abeb86e33b8fc8143ab0aecce5a7f698a53e31367d30caa/psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4", size = 1163736, upload-time = "2024-10-16T11:18:36.616Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113, upload-time = "2024-10-16T11:18:40.148Z" }, - { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951, upload-time = "2024-10-16T11:18:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060, upload-time = "2025-01-04T20:09:15.28Z" }, - { url = "https://files.pythonhosted.org/packages/5f/29/bc9639b9c50abd93a8274fd2deffbf70b2a65aa9e7881e63ea6bc4319e84/psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b", size = 1025259, upload-time = "2024-10-16T11:18:48.181Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f8/0be7d99d24656b689d83ac167240c3527efb0b161d814fb1dd58329ddf75/psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442", size = 1163878, upload-time = "2024-10-16T11:18:52.549Z" }, -] - -[[package]] -name = "psycopg2" -version = "2.9.12" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version >= '3.10' and python_full_version < '3.12'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/bc/f66df707ed1aec949fbf24e4460e4f4277a7ba23cdadb3965bb1f634ddb9/psycopg2-2.9.12.tar.gz", hash = "sha256:1dedb1c7a1d8552c4a6044c6b1c41a52e6a8e2d144af83eccac758076b1b7c15", size = 379683, upload-time = "2026-04-20T23:36:11.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/7e/940ade9113c6cdd8b4c0b27f820006bdf91c30170a5a95e8332104d5d3ed/psycopg2-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:d5fbe092315fb007c03544704e6d1e678a6c0378139d01cea433dc59edf041b4", size = 2756868, upload-time = "2026-04-20T23:33:13.049Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a1/9cc069f8efc92383d3823ef8805c932f5fda723b3f07d2a8ee019ae2919f/psycopg2-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:2532c0cdc6ad18c9c35cd935cc3159712e14f05276a6d29a6435c52d24b840c1", size = 2756862, upload-time = "2026-04-20T23:33:17.206Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b2/7319dc488444b1dfe9ce89c4440df1d5425e2579e6ce9a63e2b70f648491/psycopg2-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:83d48e66e18c301d832e93c984a7bcbc0f4ac3bb79e2137e3bc335978c756dc0", size = 2757068, upload-time = "2026-04-20T23:33:20.664Z" }, - { url = "https://files.pythonhosted.org/packages/91/ab/03403779a251ca14a7f1b07a41c7aa735fd451f0aebe4c4f901a231167a4/psycopg2-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:3d23e684927d37b95cee9a943f6927b04ae2fdcd056fd0e2a30929ee89fee5a9", size = 2757176, upload-time = "2026-04-20T23:33:23.577Z" }, - { url = "https://files.pythonhosted.org/packages/73/4a/a3566f77501c21a6c2a1fc234dbe5dff74a86e3f150ef070e4ffb835e7f9/psycopg2-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:a73d5513bfe929c56555006c7a9cc7ae6e4276aa99dd2b1e2544eb8bb54f8b23", size = 2848588, upload-time = "2026-04-20T23:33:25.983Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c7/f04d68a178c9a5830f8f6c2116bdbafe12386d65aabf88c099814264d22e/psycopg2-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:09826a6b89714626a662275d03f21639f1c68d183e2dcc9ba134d463a3da753e", size = 2757855, upload-time = "2026-04-20T23:33:28.837Z" }, -] - [[package]] name = "pycparser" version = "3.0" From 2c319f5f44549d3cf285a079ddfe0273c7576de5 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Thu, 25 Jun 2026 15:37:29 +0300 Subject: [PATCH 07/31] None lists and touching wrong path --- single_kernel_postgresql/managers/config.py | 4 +++- single_kernel_postgresql/managers/patroni.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index ff68ad5..906c4a3 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -195,6 +195,8 @@ def render_patroni_yml_file( """ slots = slots or {} ldap_parameters = ldap_parameters or {} + async_partner_addresses = async_partner_addresses or [] + async_standby_endpoints = async_standby_endpoints or [] if not self._are_passwords_set: logger.warning("Passwords are not yet generated by the leader") return @@ -267,7 +269,7 @@ def render_patroni_yml_file( rendered = template.render(**confs) render_file( self.state.substrate, - f"{self.workload.paths.patroni_conf}/patroni.yaml", + str(self.workload.paths.patroni_conf / "patroni.yaml"), rendered, perms, ) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 2252777..096b5e6 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -219,6 +219,7 @@ def bootstrap_cluster(self) -> bool: def configure_patroni_on_unit(self): """Configure Patroni (configuration files and service) on the unit.""" patroni_conf_path = str(self.workload.paths.patroni_conf) + patroni_conf_file = str(self.workload.paths.patroni_conf / "patroni.yaml") patroni_data_path = str(self.workload.paths.data) os.makedirs(patroni_conf_path, exist_ok=True) # Parent must be _daemon_-owned so Patroni can rename/remove the data dir on reinit. @@ -226,7 +227,7 @@ def configure_patroni_on_unit(self): _change_owner(Substrates.VM, patroni_conf_path) # Create empty base config - open(patroni_conf_path, "a").close() + open(patroni_conf_file, "a").close() # Expected permission # Replicas refuse to start with the default permissions From 2aa5ce677c85c7e9f5fe0669d82d1804e45f601a Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Fri, 26 Jun 2026 06:04:55 +0300 Subject: [PATCH 08/31] Wrong bootstrap paths --- single_kernel_postgresql/managers/patroni.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 096b5e6..cdb90a9 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -218,13 +218,12 @@ def bootstrap_cluster(self) -> bool: def configure_patroni_on_unit(self): """Configure Patroni (configuration files and service) on the unit.""" - patroni_conf_path = str(self.workload.paths.patroni_conf) patroni_conf_file = str(self.workload.paths.patroni_conf / "patroni.yaml") patroni_data_path = str(self.workload.paths.data) - os.makedirs(patroni_conf_path, exist_ok=True) + os.makedirs(patroni_data_path, exist_ok=True) # Parent must be _daemon_-owned so Patroni can rename/remove the data dir on reinit. - _change_owner(Substrates.VM, os.path.dirname(patroni_conf_path)) - _change_owner(Substrates.VM, patroni_conf_path) + _change_owner(Substrates.VM, os.path.dirname(patroni_data_path)) + _change_owner(Substrates.VM, patroni_data_path) # Create empty base config open(patroni_conf_file, "a").close() From 2427b694bcbb172f929234110f4a5915fa208f65 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Fri, 26 Jun 2026 14:57:13 +0300 Subject: [PATCH 09/31] Versioned volume paths --- pyproject.toml | 4 +- single_kernel_postgresql/config/literals.py | 7 ++- single_kernel_postgresql/managers/config.py | 2 +- single_kernel_postgresql/managers/patroni.py | 8 ++-- single_kernel_postgresql/workload/k8s.py | 4 +- .../workload/paths/base.py | 12 ++++- .../workload/paths/k8s.py | 8 +++- single_kernel_postgresql/workload/paths/vm.py | 14 ++++-- single_kernel_postgresql/workload/vm.py | 2 +- uv.lock | 46 ++++++++++++++++++- 10 files changed, 88 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9657662..ec14556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ dependencies = [ [project.optional-dependencies] postgresql = [ - # "psycopg2>=2.9.10", "httpx; python_version >= '3.12'", "data-platform-helpers>=0.1.7; python_version >= '3.12'", "charmlibs-pathops>=1.0.1; python_version >= '3.12'", @@ -40,6 +39,9 @@ vm = [ "psutil>=7.2.2; python_version >= '3.12'", "charmlibs-snap>=1.0.1; python_version >= '3.12'", ] +db-driver = [ + "psycopg2>=2.9.10", +] [build-system] requires = ["uv_build>=0.11.0,<0.12.0"] diff --git a/single_kernel_postgresql/config/literals.py b/single_kernel_postgresql/config/literals.py index 6372e16..7bd6e7f 100644 --- a/single_kernel_postgresql/config/literals.py +++ b/single_kernel_postgresql/config/literals.py @@ -31,6 +31,9 @@ SNAP = "/snap/charmed-postgresql/current" VM_LOGS_PATH = "var/log/postgresql" VM_DATA_PATH = "var/lib/postgresql" +VM_ARCHIVE_PATH = "data/archive" +VM_DATA_LOGS_PATH = "data/logs" +VM_TEMP_PATH = "data/temp" ## K8s Paths K8S_DATA_PATH = "var/lib/pg/data" @@ -169,5 +172,5 @@ RAFT_PARTNER_PREFIX = "partner_node_status_server_" # VM services -PATRONI_VM_SERVICE_NAME = "snap.charmed-postgresql.patroni.service" -PATRONI_VM_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{PATRONI_VM_SERVICE_NAME}" +VM_PATRONI_SERVICE_NAME = "snap.charmed-postgresql.patroni.service" +VM_PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{VM_PATRONI_SERVICE_NAME}" diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index 906c4a3..01b08f0 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -243,7 +243,7 @@ def render_patroni_yml_file( "log_path": str(self.workload.paths.patroni_logs), "postgresql_log_path": str(self.workload.paths.logs), "data_path": str(self.workload.paths.data), - "wal_dir": str(self.workload.paths.data / "logs"), + "wal_dir": str(self.workload.paths.wal), "partner_addrs": async_partner_addresses if not no_peers else [], "peers_ips": sorted(self.state.endpoints) if not no_peers else [], "pgbackrest_configuration_file": f"--config={self.workload.paths.pgbackrest_conf / PGBACKREST_CONF_FILE}", diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index cdb90a9..f90c3ca 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -50,7 +50,6 @@ from single_kernel_postgresql.config.literals import ( API_REQUEST_TIMEOUT, PATRONI_CLUSTER_STATUS_ENDPOINT, - PATRONI_VM_SERVICE_DEFAULT_PATH, # PEER_RELATION, POSTGRESQL_STORAGE_PERMISSIONS, # RAFT_PARTNER_PREFIX, @@ -58,6 +57,7 @@ RUNNING_STATES, STARTED_STATES, TLS_CA_BUNDLE_FILE, + VM_PATRONI_SERVICE_DEFAULT_PATH, ) from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState @@ -1031,7 +1031,7 @@ def get_patroni_restart_condition(self) -> str: Returns: Patroni systemd service restart condition. """ - with open(PATRONI_VM_SERVICE_DEFAULT_PATH) as patroni_service_file: + with open(VM_PATRONI_SERVICE_DEFAULT_PATH) as patroni_service_file: patroni_service = patroni_service_file.read() found_restart = re.findall(r"Restart=(\w+)", patroni_service) if len(found_restart) == 1: @@ -1047,12 +1047,12 @@ def update_patroni_restart_condition(self, new_condition: str) -> None: new_condition: new Patroni systemd service restart condition. """ logger.info(f"setting restart-condition to {new_condition} for patroni service") - with open(PATRONI_VM_SERVICE_DEFAULT_PATH) as patroni_service_file: + with open(VM_PATRONI_SERVICE_DEFAULT_PATH) as patroni_service_file: patroni_service = patroni_service_file.read() logger.debug(f"patroni service file: {patroni_service}") new_patroni_service = re.sub(r"Restart=\w+", f"Restart={new_condition}", patroni_service) logger.debug(f"new patroni service file: {new_patroni_service}") - with open(PATRONI_VM_SERVICE_DEFAULT_PATH, "w") as patroni_service_file: + with open(VM_PATRONI_SERVICE_DEFAULT_PATH, "w") as patroni_service_file: patroni_service_file.write(new_patroni_service) subprocess.run(["/bin/systemctl", "daemon-reload"]) diff --git a/single_kernel_postgresql/workload/k8s.py b/single_kernel_postgresql/workload/k8s.py index 7faecda..3b44bd2 100644 --- a/single_kernel_postgresql/workload/k8s.py +++ b/single_kernel_postgresql/workload/k8s.py @@ -147,7 +147,7 @@ def temp_file( PebbleError: if file operations fail. """ # PathProtocol exposes text operations. - temp_dir_path = directory or self.paths.tmp + temp_dir_path = directory or self.paths.temp self.mkdir( temp_dir_path, mode=DIR_PERMISSIONS_READONLY, @@ -193,7 +193,7 @@ def paths(self) -> BasePaths: # this may raise RuntimeError if container isn't set, which is expected # during initialization before container is available root_path = self.root - self._paths = K8sPaths(root_path) + self._paths = K8sPaths(root_path, self.get_postgresql_version().split(".")[0]) return self._paths @property diff --git a/single_kernel_postgresql/workload/paths/base.py b/single_kernel_postgresql/workload/paths/base.py index 01ad0ad..4b98794 100644 --- a/single_kernel_postgresql/workload/paths/base.py +++ b/single_kernel_postgresql/workload/paths/base.py @@ -18,14 +18,16 @@ class Paths(ABC): bin: Path to the bin/ folder """ - def __init__(self, root: PathProtocol): + def __init__(self, root: PathProtocol, major_version: str): """Initialize the Paths. Args: root: The root path for the PostgreSQL installation. + major_version: Postgresql major version """ super().__init__() self.root = root + self.versioned_path = f"{major_version}/main" @property @abstractmethod @@ -47,7 +49,13 @@ def logs(self) -> PathProtocol: @property @abstractmethod - def tmp(self) -> PathProtocol: + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + pass + + @property + @abstractmethod + def temp(self) -> PathProtocol: """Path to the temporary directory.""" pass diff --git a/single_kernel_postgresql/workload/paths/k8s.py b/single_kernel_postgresql/workload/paths/k8s.py index 1d84148..536a50b 100644 --- a/single_kernel_postgresql/workload/paths/k8s.py +++ b/single_kernel_postgresql/workload/paths/k8s.py @@ -44,7 +44,13 @@ def logs(self) -> PathProtocol: return self.root / "logs" @property - def tmp(self) -> PathProtocol: + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + # TODO: Update path + return self.root / "logs" + + @property + def temp(self) -> PathProtocol: """Path to the temporary directory.""" return self.root / "tmp" diff --git a/single_kernel_postgresql/workload/paths/vm.py b/single_kernel_postgresql/workload/paths/vm.py index 6d5e9c4..3f5e76b 100644 --- a/single_kernel_postgresql/workload/paths/vm.py +++ b/single_kernel_postgresql/workload/paths/vm.py @@ -14,8 +14,10 @@ SNAP, SNAP_COMMON, SNAP_DATA, + VM_DATA_LOGS_PATH, VM_DATA_PATH, VM_LOGS_PATH, + VM_TEMP_PATH, ) from single_kernel_postgresql.workload.paths.base import Paths @@ -80,18 +82,22 @@ def patroni_logs(self) -> PathProtocol: @property def data(self) -> PathProtocol: """Path to the data folder of PostgreSQL.""" - return self.snap_common / VM_DATA_PATH + return self.snap_common / VM_DATA_PATH / self.versioned_path @property def logs(self) -> PathProtocol: """Path to the logs folder of PostgreSQL.""" - # TODO: Update path return self.snap_common / VM_LOGS_PATH @property - def tmp(self) -> PathProtocol: + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + return self.snap_common / VM_DATA_LOGS_PATH / self.versioned_path + + @property + def temp(self) -> PathProtocol: """Path to the temporary directory.""" - return self.root / "tmp" + return self.snap_common / VM_TEMP_PATH / self.versioned_path @property def pgbackrest_conf(self) -> PathProtocol: diff --git a/single_kernel_postgresql/workload/vm.py b/single_kernel_postgresql/workload/vm.py index 4839876..516a602 100644 --- a/single_kernel_postgresql/workload/vm.py +++ b/single_kernel_postgresql/workload/vm.py @@ -198,7 +198,7 @@ def temp_file( @property def paths(self) -> BasePaths: """Return Workload's paths.""" - return VMPaths(self.root) + return VMPaths(self.root, self.get_postgresql_version().split(".")[0]) @property def root(self) -> PathProtocol: diff --git a/uv.lock b/uv.lock index 8f57dd8..d051edd 100644 --- a/uv.lock +++ b/uv.lock @@ -984,6 +984,10 @@ dependencies = [ ] [package.optional-dependencies] +db-driver = [ + { name = "psycopg2", version = "2.9.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "psycopg2", version = "2.9.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] postgresql = [ { name = "charm-refresh", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, @@ -1027,13 +1031,14 @@ requires-dist = [ { name = "jinja2", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=3.1.6" }, { name = "ops", specifier = ">=2.0.0" }, { name = "psutil", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=7.2.2" }, + { name = "psycopg2", marker = "extra == 'db-driver'", specifier = ">=2.9.10" }, { name = "pydantic", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=2.0" }, { name = "pysyncobj", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=0.3.15" }, { name = "requests", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "tomli", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, ] -provides-extras = ["postgresql", "vm"] +provides-extras = ["postgresql", "vm", "db-driver"] [package.metadata.requires-dev] format = [{ name = "ruff", marker = "python_full_version >= '3.12'", specifier = "==0.15.18" }] @@ -1074,6 +1079,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg2" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672, upload-time = "2024-10-16T11:24:54.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a9/146b6bdc0d33539a359f5e134ee6dda9173fb8121c5b96af33fa299e50c4/psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716", size = 1024527, upload-time = "2024-10-16T11:18:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/c509e56f725fd2572b59b69bd964edaf064deebf1c896b2452f6b46fdfb3/psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a", size = 1163735, upload-time = "2024-10-16T11:18:29.586Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/c51ca3e667c34e7852157b665e3d49418e68182081060231d514dd823225/psycopg2-2.9.10-cp311-cp311-win32.whl", hash = "sha256:47c4f9875125344f4c2b870e41b6aad585901318068acd01de93f3677a6522c2", size = 1024538, upload-time = "2024-10-16T11:18:33.48Z" }, + { url = "https://files.pythonhosted.org/packages/33/39/5a9a229bb5414abeb86e33b8fc8143ab0aecce5a7f698a53e31367d30caa/psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4", size = 1163736, upload-time = "2024-10-16T11:18:36.616Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113, upload-time = "2024-10-16T11:18:40.148Z" }, + { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951, upload-time = "2024-10-16T11:18:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060, upload-time = "2025-01-04T20:09:15.28Z" }, + { url = "https://files.pythonhosted.org/packages/5f/29/bc9639b9c50abd93a8274fd2deffbf70b2a65aa9e7881e63ea6bc4319e84/psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b", size = 1025259, upload-time = "2024-10-16T11:18:48.181Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f8/0be7d99d24656b689d83ac167240c3527efb0b161d814fb1dd58329ddf75/psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442", size = 1163878, upload-time = "2024-10-16T11:18:52.549Z" }, +] + +[[package]] +name = "psycopg2" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version >= '3.10' and python_full_version < '3.12'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/bc/f66df707ed1aec949fbf24e4460e4f4277a7ba23cdadb3965bb1f634ddb9/psycopg2-2.9.12.tar.gz", hash = "sha256:1dedb1c7a1d8552c4a6044c6b1c41a52e6a8e2d144af83eccac758076b1b7c15", size = 379683, upload-time = "2026-04-20T23:36:11.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/7e/940ade9113c6cdd8b4c0b27f820006bdf91c30170a5a95e8332104d5d3ed/psycopg2-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:d5fbe092315fb007c03544704e6d1e678a6c0378139d01cea433dc59edf041b4", size = 2756868, upload-time = "2026-04-20T23:33:13.049Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/9cc069f8efc92383d3823ef8805c932f5fda723b3f07d2a8ee019ae2919f/psycopg2-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:2532c0cdc6ad18c9c35cd935cc3159712e14f05276a6d29a6435c52d24b840c1", size = 2756862, upload-time = "2026-04-20T23:33:17.206Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/7319dc488444b1dfe9ce89c4440df1d5425e2579e6ce9a63e2b70f648491/psycopg2-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:83d48e66e18c301d832e93c984a7bcbc0f4ac3bb79e2137e3bc335978c756dc0", size = 2757068, upload-time = "2026-04-20T23:33:20.664Z" }, + { url = "https://files.pythonhosted.org/packages/91/ab/03403779a251ca14a7f1b07a41c7aa735fd451f0aebe4c4f901a231167a4/psycopg2-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:3d23e684927d37b95cee9a943f6927b04ae2fdcd056fd0e2a30929ee89fee5a9", size = 2757176, upload-time = "2026-04-20T23:33:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/73/4a/a3566f77501c21a6c2a1fc234dbe5dff74a86e3f150ef070e4ffb835e7f9/psycopg2-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:a73d5513bfe929c56555006c7a9cc7ae6e4276aa99dd2b1e2544eb8bb54f8b23", size = 2848588, upload-time = "2026-04-20T23:33:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c7/f04d68a178c9a5830f8f6c2116bdbafe12386d65aabf88c099814264d22e/psycopg2-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:09826a6b89714626a662275d03f21639f1c68d183e2dcc9ba134d463a3da753e", size = 2757855, upload-time = "2026-04-20T23:33:28.837Z" }, +] + [[package]] name = "pycparser" version = "3.0" From 86f336d47e0223b63c5f83180d3303155a86d85f Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Sat, 27 Jun 2026 18:42:06 +0300 Subject: [PATCH 10/31] Fix unit tests --- tests/unit/test_postgresql.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/test_postgresql.py b/tests/unit/test_postgresql.py index a10668b..9176665 100644 --- a/tests/unit/test_postgresql.py +++ b/tests/unit/test_postgresql.py @@ -39,17 +39,20 @@ def harness(substrate, test_charm_path): meta = meta_file.read() with open(test_charm_path + "/actions.yaml") as actions_file: actions = actions_file.read() + wl_patch = patch("single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version") if substrate == "vm": harness = Harness(vm_charm.PostgreSQLVMCharm, meta=meta, actions=actions) else: harness = Harness(k8s_charm.PostgreSQLK8sCharm, meta=meta, actions=actions) # Set up the initial relation and hooks. + wl_patch.start() peer_rel_id = harness.add_relation(PEER_RELATION, "postgresql-single-kernel") harness.add_relation_unit(peer_rel_id, "postgresql-single-kernel/0") harness.begin() yield harness harness.cleanup() + wl_patch.stop() @pytest.mark.parametrize("users_exist", [True, False]) From c17fe2f299c060cea037656c0a3d51f0580d122b Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Sun, 28 Jun 2026 01:54:14 +0300 Subject: [PATCH 11/31] Wait for switchover (K8s) --- single_kernel_postgresql/managers/patroni.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index f90c3ca..5a73f62 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -646,7 +646,9 @@ def stop_patroni(self) -> bool: logger.exception(error_message, exc_info=e) return False - def switchover(self, candidate: str | None = None, async_cluster: bool = False) -> None: + def switchover( + self, candidate: str | None = None, async_cluster: bool = False, wait: bool = True + ) -> None: """Trigger a switchover.""" # Try to trigger the switchover. for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): @@ -681,6 +683,17 @@ def switchover(self, candidate: str | None = None, async_cluster: bool = False) logger.warning(f"Switchover call failed with code {r.status_code} {r.text}") raise SwitchoverFailedError(f"received {r.status_code}") + if not wait: + return + + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3), reraise=True): + with attempt: + new_primary = self.get_primary() + if ( + candidate is not None and new_primary != candidate + ) or new_primary == current_primary: + raise SwitchoverFailedError("primary was not switched correctly") + @retry( retry=retry_if_result(lambda x: not x), stop=stop_after_attempt(10), From 187f9a985b0219d78bdf361bfe15db52dd676570 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Sun, 28 Jun 2026 02:12:50 +0300 Subject: [PATCH 12/31] Snaplib conditional import --- single_kernel_postgresql/managers/cluster.py | 9 ++++---- single_kernel_postgresql/managers/patroni.py | 23 ++++++++------------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/single_kernel_postgresql/managers/cluster.py b/single_kernel_postgresql/managers/cluster.py index 8b9ee17..49d71f5 100644 --- a/single_kernel_postgresql/managers/cluster.py +++ b/single_kernel_postgresql/managers/cluster.py @@ -34,7 +34,6 @@ from single_kernel_postgresql.utils import new_password from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload -from single_kernel_postgresql.workload.vm import VMWorkload logger = logging.getLogger(__name__) @@ -51,10 +50,10 @@ def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQL def install_workload(self) -> None: """Install the workload.""" - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - self.workload.install_snap_package(revision=None) - self.workload.create_snap_alias("patronictl") - self.workload.create_snap_alias("psql") + if self.state.substrate == Substrates.VM: + self.workload.install_snap_package(revision=None) # type: ignore + self.workload.create_snap_alias("patronictl") # type:ignore + self.workload.create_snap_alias("psql") # type: ignore else: logger.debug( "No workload installation steps defined for substrate %s", self.state.substrate diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 5a73f62..fb92465 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -64,7 +64,6 @@ from single_kernel_postgresql.managers.base import BaseManager from single_kernel_postgresql.utils import _change_owner, label2name, parallel_patroni_get_request from single_kernel_postgresql.workload.base import BaseWorkload -from single_kernel_postgresql.workload.vm import VMWorkload logger = logging.getLogger(__name__) @@ -153,8 +152,8 @@ def __init__( def start_patroni(self) -> bool: """Start Patroni.""" - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - return self.workload.start_patroni() + if self.state.substrate == Substrates.VM: + return self.workload.start_patroni() # type: ignore else: # TODO: Implement for other substrates return False @@ -167,19 +166,15 @@ def member_started(self) -> bool: True if services is ready False otherwise. Retries over a period of 60 seconds times to allow server time to start up. """ - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - if not self.workload.is_patroni_running(): - return False - try: - response = self.cached_patroni_health - except RetryError: - return False - - return response["state"] in RUNNING_STATES - else: - # TODO: Implement for other substrates + if self.state.substrate == Substrates.VM and not self.workload.is_patroni_running(): + return False + try: + response = self.cached_patroni_health + except RetryError: return False + return response["state"] in RUNNING_STATES + @cached_property def cached_patroni_health(self) -> dict[str, str]: """Cached local unit health.""" From 87a139a532a36eeba0f4382797517c243d2cc4e5 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Sun, 28 Jun 2026 20:25:07 +0300 Subject: [PATCH 13/31] K8s paths --- single_kernel_postgresql/managers/config.py | 2 +- single_kernel_postgresql/workload/paths/k8s.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index 01b08f0..5806112 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -260,7 +260,7 @@ def render_patroni_yml_file( "endpoints": list(self.state.endpoints), "is_no_sync_member": is_no_sync_member, "namespace": self.state.model_name, - "storage_path": str(self.workload.paths.data), + "storage_path": str(self.workload.paths.patroni_conf), "logs_storage_path": str(self.workload.paths.logs), "pgdata_path": str(self.workload.paths.data), "restoring_backup": backup_id is not None or pitr_target is not None, diff --git a/single_kernel_postgresql/workload/paths/k8s.py b/single_kernel_postgresql/workload/paths/k8s.py index 536a50b..ffe82ce 100644 --- a/single_kernel_postgresql/workload/paths/k8s.py +++ b/single_kernel_postgresql/workload/paths/k8s.py @@ -6,7 +6,6 @@ from single_kernel_postgresql.config.literals import ( K8S_DATA_PATH, - PATRONI_CONF_PATH, PATRONI_LOGS_PATH, PGBACKREST_CONF_PATH, POSTGRESQL_CONF_FILE, @@ -35,13 +34,13 @@ def conf(self) -> PathProtocol: @property def data(self) -> PathProtocol: """Path to the data folder of PostgreSQL.""" - return self.root / K8S_DATA_PATH + return self.root / K8S_DATA_PATH / self.versioned_path @property def logs(self) -> PathProtocol: """Path to the logs folder of PostgreSQL.""" # TODO: Update path - return self.root / "logs" + return self.root / "var" / "lib" / "pg" / "logs" @property def wal(self) -> PathProtocol: @@ -62,7 +61,7 @@ def postgresql_conf(self) -> PathProtocol: @property def patroni_conf(self) -> PathProtocol: """Path to the patroni configuration file.""" - return self.conf / PATRONI_CONF_PATH + return self.root / K8S_DATA_PATH @property def patroni_logs(self) -> PathProtocol: From 4c9ce4fc9a3ed4ccb7ebe79041b6b3109bb442fb Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 07:30:32 +0300 Subject: [PATCH 14/31] Move exceptions --- single_kernel_postgresql/config/exceptions.py | 44 ++++++++++++++++ single_kernel_postgresql/managers/patroni.py | 52 +++---------------- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/single_kernel_postgresql/config/exceptions.py b/single_kernel_postgresql/config/exceptions.py index fcd09cc..21fa31d 100644 --- a/single_kernel_postgresql/config/exceptions.py +++ b/single_kernel_postgresql/config/exceptions.py @@ -25,3 +25,47 @@ class PostgreSQLCannotConnectError(Exception): class TlsError(Exception): """TLS implementation internal exception.""" + + +class RaftPostgresqlNotUpError(Exception): + """Postgresql not yet started.""" + + +class RaftPostgresqlStillUpError(Exception): + """Postgresql not yet down.""" + + +class RaftNotPromotedError(Exception): + """Leader not yet set when reinitialising raft.""" + + +class ClusterNotPromotedError(Exception): + """Raised when a cluster is not promoted.""" + + +class NotReadyError(Exception): + """Raised when not all cluster members healthy or finished initial sync.""" + + +class EndpointNotReadyError(Exception): + """Raised when an endpoint is not ready.""" + + +class StandbyClusterAlreadyPromotedError(Exception): + """Raised when a standby cluster is already promoted.""" + + +class RemoveRaftMemberFailedError(Exception): + """Raised when a remove raft member failed for some reason.""" + + +class SwitchoverFailedError(Exception): + """Raised when a switchover failed for some reason.""" + + +class SwitchoverNotSyncError(SwitchoverFailedError): + """Raised when a switchover failed because node is not sync.""" + + +class UpdateSyncNodeCountError(Exception): + """Raised when updating synchronous_node_count failed for some reason.""" diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index fb92465..70916db 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -47,6 +47,14 @@ ) from single_kernel_postgresql.config.enums import Substrates +from single_kernel_postgresql.config.exceptions import ( + ClusterNotPromotedError, + EndpointNotReadyError, + StandbyClusterAlreadyPromotedError, + SwitchoverFailedError, + SwitchoverNotSyncError, + UpdateSyncNodeCountError, +) from single_kernel_postgresql.config.literals import ( API_REQUEST_TIMEOUT, PATRONI_CLUSTER_STATUS_ENDPOINT, @@ -68,50 +76,6 @@ logger = logging.getLogger(__name__) -class RaftPostgresqlNotUpError(Exception): - """Postgresql not yet started.""" - - -class RaftPostgresqlStillUpError(Exception): - """Postgresql not yet down.""" - - -class RaftNotPromotedError(Exception): - """Leader not yet set when reinitialising raft.""" - - -class ClusterNotPromotedError(Exception): - """Raised when a cluster is not promoted.""" - - -class NotReadyError(Exception): - """Raised when not all cluster members healthy or finished initial sync.""" - - -class EndpointNotReadyError(Exception): - """Raised when an endpoint is not ready.""" - - -class StandbyClusterAlreadyPromotedError(Exception): - """Raised when a standby cluster is already promoted.""" - - -class RemoveRaftMemberFailedError(Exception): - """Raised when a remove raft member failed for some reason.""" - - -class SwitchoverFailedError(Exception): - """Raised when a switchover failed for some reason.""" - - -class SwitchoverNotSyncError(SwitchoverFailedError): - """Raised when a switchover failed because node is not sync.""" - - -class UpdateSyncNodeCountError(Exception): - """Raised when updating synchronous_node_count failed for some reason.""" - - class ClusterMember(TypedDict): """Type for cluster member.""" From db9d422213bca8c0094c72eab0b3ca591fdcb102 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 10:04:34 +0300 Subject: [PATCH 15/31] Don't pull client class in managers --- single_kernel_postgresql/charms/abstract_charm.py | 8 ++------ single_kernel_postgresql/events/postgresql.py | 4 ++-- single_kernel_postgresql/managers/cluster.py | 11 ++++++----- single_kernel_postgresql/managers/config.py | 12 +++++++----- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/single_kernel_postgresql/charms/abstract_charm.py b/single_kernel_postgresql/charms/abstract_charm.py index be356c8..2a18407 100644 --- a/single_kernel_postgresql/charms/abstract_charm.py +++ b/single_kernel_postgresql/charms/abstract_charm.py @@ -31,12 +31,8 @@ def __init__(self, *args): # Managers self.tls_manager = TLSManager(state=self.state, workload=self.workload) self.patroni_manager = PatroniManager(state=self.state, workload=self.workload) - self.cluster_manager = ClusterManager( - state=self.state, workload=self.workload, client=self.postgresql - ) - self.config_manager = ConfigManager( - state=self.state, workload=self.workload, client=self.postgresql - ) + self.cluster_manager = ClusterManager(state=self.state, workload=self.workload) + self.config_manager = ConfigManager(state=self.state, workload=self.workload) # Events Handler self.postgresql_events_handler = PostgreSQLEventsHandler( diff --git a/single_kernel_postgresql/events/postgresql.py b/single_kernel_postgresql/events/postgresql.py index ab88620..49b09c2 100644 --- a/single_kernel_postgresql/events/postgresql.py +++ b/single_kernel_postgresql/events/postgresql.py @@ -177,8 +177,8 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: self.tls_manager.configure_internal_peer_ca() - if self.charm.substrate == Substrates.VM: - self.config_manager.update_config() + # if self.charm.substrate == Substrates.VM: + # self.config_manager.update_config() # TODO: Add next steps of leader elected diff --git a/single_kernel_postgresql/managers/cluster.py b/single_kernel_postgresql/managers/cluster.py index 49d71f5..ba0fc06 100644 --- a/single_kernel_postgresql/managers/cluster.py +++ b/single_kernel_postgresql/managers/cluster.py @@ -44,9 +44,8 @@ class ClusterManager(BaseManager): This manager is responsible for handling cluster-wide operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): + def __init__(self, state: CharmState, workload: BaseWorkload): super().__init__(state, workload, "cluster_manager") - self.postgresql_client = client def install_workload(self) -> None: """Install the workload.""" @@ -108,14 +107,16 @@ def expose_ip_and_port(self) -> None: except ModelError: logger.exception("failed to open port") - def can_connect_to_postgresql(self, retry: bool = True) -> bool: + def can_connect_to_postgresql( + self, postgresql_client: PostgreSQLClient, retry: bool = True + ) -> bool: """Whether the local PostgreSQL instance is reachable and responding.""" - if not self.postgresql_client.password or not self.postgresql_client.current_host: + if not postgresql_client.password or not postgresql_client.current_host: return False def _check_connection(): try: - if not self.postgresql_client.get_postgresql_timezones(): + if not postgresql_client.get_postgresql_timezones(): logger.debug("Cannot connect to database (CannotConnectError)") raise PostgreSQLCannotConnectError except Exception as e: diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index 5806112..08f204b 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -38,9 +38,8 @@ class ConfigManager(BaseManager): This manager is responsible for handling configuration operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): + def __init__(self, state: CharmState, workload: BaseWorkload): super().__init__(state, workload, "config_manager") - self.postgresql_client = client @staticmethod def _dict_to_hba_string(_dict: dict[str, Any]) -> str: @@ -66,7 +65,9 @@ def configure_patroni_on_unit(self): self.workload.paths.data, mode=POSTGRESQL_STORAGE_PERMISSIONS, exist_ok=True ) - def _build_postgresql_parameters(self) -> dict[str, str] | None: + def _build_postgresql_parameters( + self, postgresql_client: PostgreSQLClient + ) -> dict[str, str] | None: """Build PostgreSQL configuration parameters. Returns: @@ -77,7 +78,7 @@ def _build_postgresql_parameters(self) -> dict[str, str] | None: limit_memory = self.state.config.profile_limit_memory * 10**6 # Build PostgreSQL parameters. - pg_parameters = self.postgresql_client.build_postgresql_parameters( + pg_parameters = postgresql_client.build_postgresql_parameters( self.state.model_config, self.workload.get_available_memory(), limit_memory ) @@ -88,6 +89,7 @@ def _build_postgresql_parameters(self) -> dict[str, str] | None: def update_config( self, + postgresql_client: PostgreSQLClient, is_creating_backup: bool = False, # TODO add rel handler is_tls_enabled: bool = False, @@ -107,7 +109,7 @@ def update_config( ) -> bool: """Updates Patroni config file based on the existence of the TLS files.""" # Build PostgreSQL parameters - pg_parameters = self._build_postgresql_parameters() + pg_parameters = self._build_postgresql_parameters(postgresql_client) # replication_slots = self.logical_replication.replication_slots() replication_slots = {} From 245d2b66871529a3b5382a72962c1a21498e24be Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 12:20:42 +0300 Subject: [PATCH 16/31] Spaces ips --- single_kernel_postgresql/core/state.py | 43 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/single_kernel_postgresql/core/state.py b/single_kernel_postgresql/core/state.py index deb4210..8f365e5 100644 --- a/single_kernel_postgresql/core/state.py +++ b/single_kernel_postgresql/core/state.py @@ -6,17 +6,30 @@ import re import socket +from contextlib import suppress from functools import cached_property from typing import TYPE_CHECKING, Any, get_args from data_platform_helpers.advanced_statuses import StatusesState, StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope -from ops import ConfigData, JujuVersion, ModelError, Object, Relation, SecretNotFoundError, Unit +from ops import ( + ConfigData, + JujuVersion, + ModelError, + Object, + Relation, + RelationNotFoundError, + SecretNotFoundError, + Unit, +) from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( APP_SCOPE, + DATABASE, PEER_RELATION, + REPLICATION_CONSUMER_RELATION, + REPLICATION_OFFER_RELATION, SCOPES, STATUS_PEERS_RELATION, ) @@ -153,6 +166,27 @@ def unit_ip(self) -> str | None: if binding := self.model.get_binding(PEER_RELATION): return str(binding.network.bind_address) + @property + def database_ip(self) -> str | None: + """Database endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(DATABASE): + return str(binding.network.bind_address) + + @property + def replication_offer_ip(self) -> str | None: + """Async replication offer endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(REPLICATION_OFFER_RELATION): + return str(binding.network.bind_address) + + @property + def replication_consumer_ip(self) -> str | None: + """Async replication consumer endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(REPLICATION_CONSUMER_RELATION): + return str(binding.network.bind_address) + @property def fqdn(self) -> str | None: """Current unit fqdn.""" @@ -229,7 +263,12 @@ def listen_ips(self) -> list[str]: ips = [] if self.unit_ip: ips.append(self.unit_ip) - # TODO: Add other ips + if self.database_ip and self.database_ip not in ips: + ips.append(self.database_ip) + if self.replication_offer_ip and self.replication_offer_ip not in ips: + ips.append(self.replication_offer_ip) + if self.replication_consumer_ip and self.replication_consumer_ip not in ips: + ips.append(self.replication_consumer_ip) return ips # -- Secrets From 80f5fb6873b3609948c57035c741573f84b6e03e Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 13:23:25 +0300 Subject: [PATCH 17/31] Exception --- single_kernel_postgresql/config/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/single_kernel_postgresql/config/exceptions.py b/single_kernel_postgresql/config/exceptions.py index 21fa31d..2586653 100644 --- a/single_kernel_postgresql/config/exceptions.py +++ b/single_kernel_postgresql/config/exceptions.py @@ -59,6 +59,10 @@ class RemoveRaftMemberFailedError(Exception): """Raised when a remove raft member failed for some reason.""" +class AddRaftMemberFailedError(Exception): + """Raised when adding raft member failed for some reason.""" + + class SwitchoverFailedError(Exception): """Raised when a switchover failed for some reason.""" From bec12eb3791623f4113f5f4726539acbad1de919 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 14:19:14 +0300 Subject: [PATCH 18/31] Running members diff --- .../core/peer_relation.py | 2 +- single_kernel_postgresql/managers/patroni.py | 184 +----------------- 2 files changed, 6 insertions(+), 180 deletions(-) diff --git a/single_kernel_postgresql/core/peer_relation.py b/single_kernel_postgresql/core/peer_relation.py index 0dde0be..c03be29 100644 --- a/single_kernel_postgresql/core/peer_relation.py +++ b/single_kernel_postgresql/core/peer_relation.py @@ -320,7 +320,7 @@ def cluster_name(self) -> str: return f"patroni-{self.app.name}" return self.app.name - @property + @cached_property def planned_units(self) -> int: """Get the number of planned units for the application.""" return self.app.planned_units() diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 70916db..a6caa66 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -8,8 +8,6 @@ such as starting the service and checking its status. """ -# import shutil -# from pathlib import Path import glob import logging import os @@ -22,10 +20,7 @@ # Platform specific imports with suppress(ImportError): - # import psutil from charmlibs import snap - # from pysyncobj.utility import TcpUtility, UtilityException -# from ops import BlockedStatus import psycopg2 import psycopg2.extras import requests @@ -58,10 +53,7 @@ from single_kernel_postgresql.config.literals import ( API_REQUEST_TIMEOUT, PATRONI_CLUSTER_STATUS_ENDPOINT, - # PEER_RELATION, POSTGRESQL_STORAGE_PERMISSIONS, - # RAFT_PARTNER_PREFIX, - # RAFT_PORT, RUNNING_STATES, STARTED_STATES, TLS_CA_BUNDLE_FILE, @@ -195,7 +187,6 @@ def configure_patroni_on_unit(self): def cluster_members(self) -> set: """Get the current cluster members.""" # Request info from cluster endpoint (which returns all members of the cluster). - # Request info from cluster endpoint (which returns all members of the cluster). try: return {member["name"] for member in self.cluster_status()} except Exception: @@ -663,182 +654,17 @@ def primary_changed(self, old_primary: str) -> bool: primary = self.get_primary() return primary != old_primary - # def has_raft_quorum(self) -> bool: - # """Check if raft cluster has quorum.""" - # # Get the status of the raft cluster. - # syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) - - # raft_host = "127.0.0.1:2222" - # try: - # raft_status = syncobj_util.executeCommand(raft_host, ["status"]) - # except UtilityException: - # logger.warning("Has raft quorum: Cannot connect to raft cluster") - # return False - # if not raft_status: - # logger.warning("Has raft quorum: No status reported") - # return False - # return raft_status["has_quorum"] - - # def remove_raft_data(self) -> None: - # """Stops Patroni and removes the raft journals.""" - # logger.info("Stopping patroni") - # self.stop_patroni() - - # logger.info("Wait for postgresql to stop") - # for attempt in Retrying(wait=wait_fixed(5)): - # with attempt: - # for proc in psutil.process_iter(["name"]): - # if proc.name() == "postgres": - # raise RaftPostgresqlStillUpError() - - # logger.info("Removing raft data") - # try: - # path = Path(f"{self.workload.paths.patroni_conf}/raft") - # if path.exists() and path.is_dir(): - # shutil.rmtree(path) - # except OSError as e: - # raise Exception( - # f"Failed to remove previous cluster information with error: {e!s}" - # ) from e - # logger.info("Raft ready to reinitialise") - - # def reinitialise_raft_data(self, update_config, update_config_kwargs) -> None: - # """Reinitialise the raft journals and promoting the unit to leader. Should only be run on sync replicas.""" - # logger.info("Rerendering patroni config without peers") - # # TODO figure out cross manager update config calls - # update_config(no_peers=True, **update_config_kwargs) - # logger.info("Starting patroni") - # self.start_patroni() - - # logger.info("Waiting for new raft cluster to initialise") - # for attempt in Retrying(wait=wait_fixed(5)): - # with attempt: - # health_status = self.get_patroni_health() - # if ( - # health_status["role"] not in ["leader", "master"] - # or health_status["state"] != "running" - # ): - # raise RaftNotPromotedError() - - # logger.info("Restarting patroni") - # self.restart_patroni() - # for attempt in Retrying(wait=wait_fixed(5)): - # with attempt: - # found_postgres = False - # for proc in psutil.process_iter(["name"]): - # if proc.name() == "postgres": - # found_postgres = True - # break - # if not found_postgres: - # raise RaftPostgresqlNotUpError() - # logger.info("Raft should be unstuck") - def get_running_cluster_members(self) -> list[str]: """List running patroni members.""" try: - members = self.cluster_status() - return [member["name"] for member in members if member["state"] in STARTED_STATES] + return [ + member["name"] + for member in self.cluster_status() + if member["state"] in RUNNING_STATES + ] except Exception: return [] - # def cleanup_raft_cluster(self, watcher_addr) -> bool: - # """Cleanup RAFT members not belonging to the current cluster or not a related watcher.""" - # # Get Raft cluster status to find all members - # try: - # if not self.workload.is_patroni_running(): - # logger.warning("Raft cleanup: Patroni service not running.") - # return True - # syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) - # if raft_status := syncobj_util.executeCommand(f"127.0.0.1:{RAFT_PORT}", ["status"]): - # # Find all partner nodes in the Raft cluster - # # Keys look like: partner_node_status_server_10.131.50.142:2222 - # for key in raft_status: - # if key.startswith(RAFT_PARTNER_PREFIX) and raft_status[key] != 2: - # member_addr = key.replace(RAFT_PARTNER_PREFIX, "") - # member_ip = member_addr.split(":")[0] - - # # Check if this is a stale watcher (not a PostgreSQL node and not current watcher) - # if member_ip not in self.charm._units_ips and member_addr != watcher_addr: - # logger.info(f"Removing stale Raft member: {member_addr}") - # self.remove_raft_member(member_addr) - # self.state.application.remove_from_members_ips(member_ip) - # return True - # return False - # except Exception as e: - # logger.debug(f"Error during Raft cleanup: {e}") - # return False - - # def _set_stuck_raft_flag(self) -> None: - # self.charm.set_unit_status(BlockedStatus("Raft majority loss, run: promote-to-primary")) - # logger.warning("Remove raft member: Stuck raft cluster detected") - # data_flags = {"raft_stuck": "True"} - # self.state.peer.data.update(data_flags) - - # Leader doesn't always trigger when changing it's own peer data. - # if self.state.peer.unit.is_leader(): - # self.charm.on[PEER_RELATION].relation_changed.emit( - # unit=self.state.peer.unit, - # app=self.state.application.app, - # relation=self.state.application.relation, - # ) - - # def remove_raft_member(self, member_address: str | None) -> None: - # """Remove a member from the raft cluster. - - # The raft cluster is a different cluster from the Patroni cluster. - # It is responsible for defining which Patroni member can update - # the primary member in the DCS. - - # Raises: - # RaftMemberNotFoundError: if the member to be removed - # is not part of the raft cluster. - # """ - # if not member_address: - # logger.debug("Remove raft member: No address provided") - # return - - # if self.state.has_raft_keys(): - # logger.debug("Remove raft member: Raft already in recovery") - # return - - # # Get the status of the raft cluster. - # syncobj_util = TcpUtility(password=self.state.application.raft_password, timeout=3) - - # raft_host = "127.0.0.1:2222" - # try: - # raft_status = syncobj_util.executeCommand(raft_host, ["status"]) - # except UtilityException as e: - # logger.warning("Remove raft member: Cannot connect to raft cluster") - # raise RemoveRaftMemberFailedError() from e - # if not raft_status: - # logger.warning("Remove raft member: No raft status") - # raise RemoveRaftMemberFailedError() from None - - # # Check whether the member is still part of the raft cluster. - # if f"{RAFT_PARTNER_PREFIX}{member_address}" not in raft_status: - # return - - # # If there's no quorum and the leader left raft cluster is stuck - # if raft_status["has_quorum"] and not raft_status["leader"]: - # logger.warning("Remove raft member: No raft leader") - # raise RemoveRaftMemberFailedError() from None - # if not raft_status["has_quorum"] and ( - # not raft_status["leader"] or raft_status["leader"].address == member_address - # ): - # self._set_stuck_raft_flag() - # return - - # # Remove the member from the raft cluster. - # try: - # result = syncobj_util.executeCommand(raft_host, ["remove", member_address]) - # except UtilityException as e: - # logger.debug("Remove raft member: Remove call failed") - # raise RemoveRaftMemberFailedError() from e - - # if not result or not result.startswith("SUCCESS"): - # logger.debug(f"Remove raft member: Remove call not successful with {result}") - # raise RemoveRaftMemberFailedError() from None - @retry(stop=stop_after_attempt(20), wait=wait_exponential(multiplier=1, min=2, max=10)) def reload_patroni_configuration(self): """Reload Patroni configuration after it was changed.""" From d4dd47b300f6e9158ac4ed0d2c4598246c4c5bf2 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 15:46:17 +0300 Subject: [PATCH 19/31] Ensure slots retval --- single_kernel_postgresql/managers/patroni.py | 54 +++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index a6caa66..327d854 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -759,31 +759,41 @@ def bulk_update_parameters_controller_by_patroni( ) r.raise_for_status() - def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> None: + def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> bool: """Synchronises slots controlled by Patroni with the provided state by removing unneeded slots and creating new ones. Args: slots: dictionary of slots in the {slot: database} format. + + Returns: + True if successful, False if Patroni API is not ready. """ - for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3), reraise=True): - with attempt: - current_config = requests.get( - f"{self.state.patroni_url}/config", - verify=self.verify, - timeout=API_REQUEST_TIMEOUT, - auth=self._patroni_auth, - ) - logger.debug( - "API ensure_slots_controller_by_patroni: %s (%s)", - current_config, - current_config.elapsed.total_seconds(), - ) - if current_config.status_code != 200: - raise Exception( - f"Failed to get current Patroni config: {current_config.status_code} {current_config.text}" + try: + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): + with attempt: + current_config = requests.get( + f"{self.state.patroni_url}/config", + verify=self.verify, + timeout=API_REQUEST_TIMEOUT, + auth=self._patroni_auth, + ) + logger.debug( + "API ensure_slots_controller_by_patroni: %s (%s)", + current_config, + current_config.elapsed.total_seconds(), ) + if current_config.status_code in (502, 503): + raise Exception(f"Patroni API not ready: {current_config.status_code}") + if current_config.status_code != 200: + raise Exception( + f"Failed to get current Patroni config: {current_config.status_code} {current_config.text}" + ) + except RetryError: + logger.warning("Patroni config API not ready after retries, will retry later") + return False + slots_patch: dict[str, dict[str, str] | None] = dict.fromkeys( - current_config.json().get("slots") or {} + current_config.json().get("slots", ()) or {} ) for slot, database in slots.items(): slots_patch[slot] = { @@ -791,18 +801,14 @@ def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> None: "plugin": "pgoutput", "type": "logical", } - r = requests.patch( + requests.patch( f"{self.state.patroni_url}/config", verify=self.verify, json={"slots": slots_patch}, auth=self._patroni_auth, timeout=API_REQUEST_TIMEOUT, ) - logger.debug( - "API ensure_slots_controller_by_patroni: %s (%s)", - r, - r.elapsed.total_seconds(), - ) + return True def update_synchronous_node_count(self) -> None: """Update synchronous_node_count to the minority of the planned cluster.""" From f9b8789b1742f3d4fb199ee14d18312fa29e1acc Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 20:57:20 +0300 Subject: [PATCH 20/31] Workload status check --- single_kernel_postgresql/managers/patroni.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 327d854..0d0595b 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -97,12 +97,8 @@ def __init__( # The CA bundle file is used to validate the server certificate when # TLS is enabled, otherwise True is set because it's the default value. if self.state.substrate == Substrates.VM: - # if any([primary_endpoint]): - # raise Exception("K8s attributes set with VM substrate") self.verify = f"{self.workload.paths.patroni_conf}/{TLS_CA_BUNDLE_FILE}" else: - # if any([raft_password]): - # raise Exception("VM attributes set with K8s substrate") # CA bundle is not secret self.verify = f"/tmp/{TLS_CA_BUNDLE_FILE}" # noqa: S108 @@ -122,7 +118,7 @@ def member_started(self) -> bool: True if services is ready False otherwise. Retries over a period of 60 seconds times to allow server time to start up. """ - if self.state.substrate == Substrates.VM and not self.workload.is_patroni_running(): + if not self.workload.is_patroni_running(): return False try: response = self.cached_patroni_health From b7d76ff0d67486c530c77849496c1b92a3744835 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Mon, 29 Jun 2026 23:37:41 +0300 Subject: [PATCH 21/31] Lazy load container --- single_kernel_postgresql/charms/k8s_charm.py | 6 ++---- single_kernel_postgresql/compat/postgresql.py | 11 +++++++---- single_kernel_postgresql/workload/k8s.py | 17 +++++++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/single_kernel_postgresql/charms/k8s_charm.py b/single_kernel_postgresql/charms/k8s_charm.py index 772d210..0ef9e22 100644 --- a/single_kernel_postgresql/charms/k8s_charm.py +++ b/single_kernel_postgresql/charms/k8s_charm.py @@ -8,7 +8,7 @@ from single_kernel_postgresql.charms.abstract_charm import AbstractPostgreSQLCharm, PostgreSQL from single_kernel_postgresql.config.enums import Substrates -from single_kernel_postgresql.config.literals import CONTAINER_NAME, SYSTEM_USERS, USER +from single_kernel_postgresql.config.literals import SYSTEM_USERS, USER from single_kernel_postgresql.managers.k8s import K8sManager from single_kernel_postgresql.workload.base import BaseWorkload from single_kernel_postgresql.workload.k8s import K8sWorkload @@ -51,9 +51,7 @@ def workload(self) -> BaseWorkload: Returns: BaseWorkload: The K8sWorkload instance for this charm """ - return K8sWorkload( - charm_dir=self.charm_dir, container=self.unit.get_container(CONTAINER_NAME) - ) + return K8sWorkload(charm_dir=self.charm_dir, unit=self.unit) @property def substrate(self) -> Substrates: diff --git a/single_kernel_postgresql/compat/postgresql.py b/single_kernel_postgresql/compat/postgresql.py index 4a41d9e..832736c 100644 --- a/single_kernel_postgresql/compat/postgresql.py +++ b/single_kernel_postgresql/compat/postgresql.py @@ -23,10 +23,13 @@ from collections import OrderedDict from typing import Dict, List, Optional, Set, Tuple -import psycopg2 -import psycopg2.errors -import psycopg2.extensions -from psycopg2.sql import SQL, Identifier, Literal +try: + import psycopg2 + import psycopg2.errors + import psycopg2.extensions + from psycopg2.sql import SQL, Identifier, Literal +except ImportError: + pass from ..config.literals import ( BACKUP_USER, diff --git a/single_kernel_postgresql/workload/k8s.py b/single_kernel_postgresql/workload/k8s.py index 3b44bd2..bd23876 100644 --- a/single_kernel_postgresql/workload/k8s.py +++ b/single_kernel_postgresql/workload/k8s.py @@ -7,16 +7,18 @@ import uuid from collections.abc import Generator from contextlib import contextmanager +from functools import cached_property from pathlib import Path from types import SimpleNamespace from charmlibs import pathops from charmlibs.pathops import PathProtocol -from ops import Container, ModelError +from ops import Container, ModelError, Unit from ops.pebble import Plan, ServiceStatus from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError from single_kernel_postgresql.config.literals import ( + CONTAINER_NAME, DIR_PERMISSIONS_READONLY, K8S_POSTGRESQL_SERVICE_NAME, ) @@ -30,19 +32,22 @@ class K8sWorkload(BaseWorkload): """Kubernetes PostgreSQL Workload.""" - def __init__(self, charm_dir: Path, container: Container | None = None): + def __init__(self, charm_dir: Path, unit: Unit): """Initialize workload. Args: charm_dir: the path to charm code. - container: the Container instance. + unit: unit to lazy load the container from. """ super().__init__(charm_dir=charm_dir) - if not container: - raise AttributeError("Container is required.") - self.container = container + self._unit = unit self._paths: BasePaths | None = None + @cached_property + def container(self) -> Container: + """Returns the postgresql container.""" + return self._unit.get_container(CONTAINER_NAME) + def install(self) -> None: """Install the workload.""" pass From b3133ac71f280da284a209a4f7819e55b3b0cb3b Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Tue, 30 Jun 2026 00:15:43 +0300 Subject: [PATCH 22/31] Reduce update sync nodes retry timeout --- single_kernel_postgresql/managers/patroni.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 0d0595b..b516b0f 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -808,7 +808,7 @@ def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> bool: def update_synchronous_node_count(self) -> None: """Update synchronous_node_count to the minority of the planned cluster.""" - for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): + for attempt in Retrying(stop=stop_after_delay(15), wait=wait_fixed(3)): with attempt: r = requests.patch( f"{self.state.patroni_url}/config", From b079cb1189c35ad93cb6a7a9df1d9c0e9f0c85f1 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Tue, 30 Jun 2026 02:15:10 +0300 Subject: [PATCH 23/31] Revert container change --- single_kernel_postgresql/charms/k8s_charm.py | 6 ++++-- single_kernel_postgresql/compat/postgresql.py | 11 ++++------- single_kernel_postgresql/workload/k8s.py | 15 ++++----------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/single_kernel_postgresql/charms/k8s_charm.py b/single_kernel_postgresql/charms/k8s_charm.py index 0ef9e22..772d210 100644 --- a/single_kernel_postgresql/charms/k8s_charm.py +++ b/single_kernel_postgresql/charms/k8s_charm.py @@ -8,7 +8,7 @@ from single_kernel_postgresql.charms.abstract_charm import AbstractPostgreSQLCharm, PostgreSQL from single_kernel_postgresql.config.enums import Substrates -from single_kernel_postgresql.config.literals import SYSTEM_USERS, USER +from single_kernel_postgresql.config.literals import CONTAINER_NAME, SYSTEM_USERS, USER from single_kernel_postgresql.managers.k8s import K8sManager from single_kernel_postgresql.workload.base import BaseWorkload from single_kernel_postgresql.workload.k8s import K8sWorkload @@ -51,7 +51,9 @@ def workload(self) -> BaseWorkload: Returns: BaseWorkload: The K8sWorkload instance for this charm """ - return K8sWorkload(charm_dir=self.charm_dir, unit=self.unit) + return K8sWorkload( + charm_dir=self.charm_dir, container=self.unit.get_container(CONTAINER_NAME) + ) @property def substrate(self) -> Substrates: diff --git a/single_kernel_postgresql/compat/postgresql.py b/single_kernel_postgresql/compat/postgresql.py index 832736c..4a41d9e 100644 --- a/single_kernel_postgresql/compat/postgresql.py +++ b/single_kernel_postgresql/compat/postgresql.py @@ -23,13 +23,10 @@ from collections import OrderedDict from typing import Dict, List, Optional, Set, Tuple -try: - import psycopg2 - import psycopg2.errors - import psycopg2.extensions - from psycopg2.sql import SQL, Identifier, Literal -except ImportError: - pass +import psycopg2 +import psycopg2.errors +import psycopg2.extensions +from psycopg2.sql import SQL, Identifier, Literal from ..config.literals import ( BACKUP_USER, diff --git a/single_kernel_postgresql/workload/k8s.py b/single_kernel_postgresql/workload/k8s.py index bd23876..5bae728 100644 --- a/single_kernel_postgresql/workload/k8s.py +++ b/single_kernel_postgresql/workload/k8s.py @@ -7,18 +7,16 @@ import uuid from collections.abc import Generator from contextlib import contextmanager -from functools import cached_property from pathlib import Path from types import SimpleNamespace from charmlibs import pathops from charmlibs.pathops import PathProtocol -from ops import Container, ModelError, Unit +from ops import Container, ModelError from ops.pebble import Plan, ServiceStatus from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError from single_kernel_postgresql.config.literals import ( - CONTAINER_NAME, DIR_PERMISSIONS_READONLY, K8S_POSTGRESQL_SERVICE_NAME, ) @@ -32,22 +30,17 @@ class K8sWorkload(BaseWorkload): """Kubernetes PostgreSQL Workload.""" - def __init__(self, charm_dir: Path, unit: Unit): + def __init__(self, charm_dir: Path, container: Container): """Initialize workload. Args: charm_dir: the path to charm code. - unit: unit to lazy load the container from. + container: the Container instance. """ super().__init__(charm_dir=charm_dir) - self._unit = unit + self.container = container self._paths: BasePaths | None = None - @cached_property - def container(self) -> Container: - """Returns the postgresql container.""" - return self._unit.get_container(CONTAINER_NAME) - def install(self) -> None: """Install the workload.""" pass From 512f51af63ec14efe4d0583934a504f1e033d435 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Tue, 30 Jun 2026 03:26:02 +0300 Subject: [PATCH 24/31] Reduce timeout --- single_kernel_postgresql/managers/patroni.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index b516b0f..3b76fc4 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -765,7 +765,7 @@ def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> bool: True if successful, False if Patroni API is not ready. """ try: - for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): + for attempt in Retrying(stop=stop_after_delay(15), wait=wait_fixed(3)): with attempt: current_config = requests.get( f"{self.state.patroni_url}/config", From 1497c9a6467dd77cb894b4a54bc5ce4983274ad0 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Tue, 30 Jun 2026 04:41:45 +0300 Subject: [PATCH 25/31] [DPE-10062] Single kernel changes --- pyproject.toml | 12 +- .../charms/abstract_charm.py | 16 +- single_kernel_postgresql/charms/k8s_charm.py | 2 +- single_kernel_postgresql/config/exceptions.py | 48 ++++ single_kernel_postgresql/config/literals.py | 15 +- .../core/peer_relation.py | 72 +++++- single_kernel_postgresql/core/state.py | 90 +++++++- single_kernel_postgresql/events/postgresql.py | 4 +- single_kernel_postgresql/managers/base.py | 6 +- single_kernel_postgresql/managers/cluster.py | 21 +- single_kernel_postgresql/managers/config.py | 189 +++++++++++---- single_kernel_postgresql/managers/k8s.py | 5 +- single_kernel_postgresql/managers/patroni.py | 74 +++--- single_kernel_postgresql/managers/tls.py | 5 +- single_kernel_postgresql/workload/base.py | 5 + single_kernel_postgresql/workload/k8s.py | 21 +- .../workload/paths/base.py | 18 +- .../workload/paths/k8s.py | 19 +- single_kernel_postgresql/workload/paths/vm.py | 20 +- single_kernel_postgresql/workload/vm.py | 2 +- tests/unit/test_postgresql.py | 3 + tox.ini | 4 +- uv.lock | 218 +++++++++++------- 23 files changed, 640 insertions(+), 229 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f1d3316..aa575d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,9 @@ dependencies = [ [project.optional-dependencies] postgresql = [ - "psycopg2>=2.9.10", "httpx; python_version >= '3.12'", "data-platform-helpers>=0.1.7; python_version >= '3.12'", "charmlibs-pathops>=1.0.1; python_version >= '3.12'", - "charmlibs-snap>=1.0.1; python_version >= '3.12'", "charmlibs-interfaces-tls-certificates>=1.8.3; python_version >= '3.12'", "charm-refresh; python_version >= '3.12'", "requests; python_version >= '3.12'", @@ -35,7 +33,15 @@ postgresql = [ "pydantic>=2.0; python_version >= '3.12'", "jinja2 >=3.1.6; python_version >= '3.12'", "charmlibs-rollingops>=1.1.1; python_version >= '3.12'", - ] +] +vm = [ + "pysyncobj>=0.3.15; python_version >= '3.12'", + "psutil>=7.2.2; python_version >= '3.12'", + "charmlibs-snap>=1.0.1; python_version >= '3.12'", +] +db-driver = [ + "psycopg2>=2.9.10", +] [build-system] requires = ["uv_build>=0.11.0,<0.12.0"] diff --git a/single_kernel_postgresql/charms/abstract_charm.py b/single_kernel_postgresql/charms/abstract_charm.py index 0cf7b0a..2a18407 100644 --- a/single_kernel_postgresql/charms/abstract_charm.py +++ b/single_kernel_postgresql/charms/abstract_charm.py @@ -29,18 +29,10 @@ def __init__(self, *args): self.state = CharmState(charm=self, substrate=self.substrate) # Managers - self.cluster_manager = ClusterManager( - state=self.state, workload=self.workload, client=self.postgresql - ) - self.tls_manager = TLSManager( - state=self.state, workload=self.workload, client=self.postgresql - ) - self.config_manager = ConfigManager( - state=self.state, workload=self.workload, client=self.postgresql - ) - self.patroni_manager = PatroniManager( - state=self.state, workload=self.workload, client=self.postgresql - ) + self.tls_manager = TLSManager(state=self.state, workload=self.workload) + self.patroni_manager = PatroniManager(state=self.state, workload=self.workload) + self.cluster_manager = ClusterManager(state=self.state, workload=self.workload) + self.config_manager = ConfigManager(state=self.state, workload=self.workload) # Events Handler self.postgresql_events_handler = PostgreSQLEventsHandler( diff --git a/single_kernel_postgresql/charms/k8s_charm.py b/single_kernel_postgresql/charms/k8s_charm.py index b261ac6..772d210 100644 --- a/single_kernel_postgresql/charms/k8s_charm.py +++ b/single_kernel_postgresql/charms/k8s_charm.py @@ -25,7 +25,7 @@ def __init__(self, *args): assert isinstance(self.workload, K8sWorkload), ( # noqa: S101 "Workload must be an instance of K8sWorkload" ) - self.k8s_manager = K8sManager(self.state, self.workload, self.postgresql) + self.k8s_manager = K8sManager(self.state, self.workload) @property def postgresql(self) -> PostgreSQL: diff --git a/single_kernel_postgresql/config/exceptions.py b/single_kernel_postgresql/config/exceptions.py index fcd09cc..2586653 100644 --- a/single_kernel_postgresql/config/exceptions.py +++ b/single_kernel_postgresql/config/exceptions.py @@ -25,3 +25,51 @@ class PostgreSQLCannotConnectError(Exception): class TlsError(Exception): """TLS implementation internal exception.""" + + +class RaftPostgresqlNotUpError(Exception): + """Postgresql not yet started.""" + + +class RaftPostgresqlStillUpError(Exception): + """Postgresql not yet down.""" + + +class RaftNotPromotedError(Exception): + """Leader not yet set when reinitialising raft.""" + + +class ClusterNotPromotedError(Exception): + """Raised when a cluster is not promoted.""" + + +class NotReadyError(Exception): + """Raised when not all cluster members healthy or finished initial sync.""" + + +class EndpointNotReadyError(Exception): + """Raised when an endpoint is not ready.""" + + +class StandbyClusterAlreadyPromotedError(Exception): + """Raised when a standby cluster is already promoted.""" + + +class RemoveRaftMemberFailedError(Exception): + """Raised when a remove raft member failed for some reason.""" + + +class AddRaftMemberFailedError(Exception): + """Raised when adding raft member failed for some reason.""" + + +class SwitchoverFailedError(Exception): + """Raised when a switchover failed for some reason.""" + + +class SwitchoverNotSyncError(SwitchoverFailedError): + """Raised when a switchover failed because node is not sync.""" + + +class UpdateSyncNodeCountError(Exception): + """Raised when updating synchronous_node_count failed for some reason.""" diff --git a/single_kernel_postgresql/config/literals.py b/single_kernel_postgresql/config/literals.py index 803e8af..7bd6e7f 100644 --- a/single_kernel_postgresql/config/literals.py +++ b/single_kernel_postgresql/config/literals.py @@ -31,6 +31,9 @@ SNAP = "/snap/charmed-postgresql/current" VM_LOGS_PATH = "var/log/postgresql" VM_DATA_PATH = "var/lib/postgresql" +VM_ARCHIVE_PATH = "data/archive" +VM_DATA_LOGS_PATH = "data/logs" +VM_TEMP_PATH = "data/temp" ## K8s Paths K8S_DATA_PATH = "var/lib/pg/data" @@ -42,7 +45,9 @@ # e.g. snap_current / "/etc/postgresql" will result in "/etc/postgresql" instead of "/var/snap/postgresql/current/etc/postgresql" POSTGRESQL_CONF_PATH = "etc/postgresql" POSTGRESQL_CONF_FILE = "postgresql.conf" - +## Pgbackreest Paths +PGBACKREST_CONF_PATH = "etc/pgbackrest" +PGBACKREST_CONF_FILE = "pgbackrest.conf" ## TLS Paths TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem" @@ -161,3 +166,11 @@ WATCHER_USER = "watcher" WATCHER_PASSWORD_KEY = "watcher-password" # noqa: S105 WATCHER_SECRET_LABEL = "watcher-secret" # noqa: S105 + +# Raft +RAFT_PORT = 2222 +RAFT_PARTNER_PREFIX = "partner_node_status_server_" + +# VM services +VM_PATRONI_SERVICE_NAME = "snap.charmed-postgresql.patroni.service" +VM_PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{VM_PATRONI_SERVICE_NAME}" diff --git a/single_kernel_postgresql/core/peer_relation.py b/single_kernel_postgresql/core/peer_relation.py index 069a859..c03be29 100644 --- a/single_kernel_postgresql/core/peer_relation.py +++ b/single_kernel_postgresql/core/peer_relation.py @@ -6,6 +6,8 @@ """State objects for database-peers relation.""" import json +from collections.abc import MutableMapping +from functools import cached_property from ops import Application, BlockedStatus, Relation, Unit @@ -177,6 +179,34 @@ def peer_addresses(self) -> set[str]: peer_addrs.add(addr) return peer_addrs + @property + def is_unit_departing(self) -> bool: + """Returns whether the unit is departing.""" + if not self.relation: + return False + return "departing" in self.relation.data[self.unit] + + @property + def is_unit_stopped(self) -> bool: + """Returns whether the unit is stopped.""" + if not self.relation: + return False + return "stopped" in self.relation.data[self.unit] + + @property + def is_connectivity_enabled(self) -> bool: + """Return whether this unit can be connected externally.""" + if not self.relation: + return True + return self.relation.data[self.unit].get("connectivity", "on") == "on" + + @cached_property + def data(self) -> MutableMapping[str, str]: + """Escape hatch method to access the peer data directly.""" + if not self.relation: + return {} + return self.relation.data[self.unit] + class PostgreSQLApplication(RelationState): """An PostgreSQL Application is the peer application state. @@ -290,7 +320,7 @@ def cluster_name(self) -> str: return f"patroni-{self.app.name}" return self.app.name - @property + @cached_property def planned_units(self) -> int: """Get the number of planned units for the application.""" return self.app.planned_units() @@ -309,6 +339,39 @@ def endpoints(self) -> set[str]: return set() return set(json.loads(self.relation.data[self.app].get("endpoints", "[]"))) + @property + def is_cluster_initialised(self) -> bool: + """Returns whether the cluster is already initialised.""" + if not self.relation: + return False + return "cluster_initialised" in self.relation.data[self.app] + + @property + def is_cluster_restoring_backup(self) -> bool: + """Returns whether the cluster is restoring a backup.""" + if not self.relation: + return False + return "restoring-backup" in self.relation.data[self.app] + + @property + def is_cluster_restoring_to_time(self) -> bool: + """Returns whether the cluster is restoring a backup to a specific time.""" + if not self.relation: + return False + return "restore-to-time" in self.relation.data[self.app] + + @property + def is_ldap_charm_related(self) -> bool: + """Return whether this unit has an LDAP charm related.""" + if not self.relation: + return False + return self.relation.data[self.app].get("ldap_enabled", "False") == "True" + + @property + def is_ldap_enabled(self) -> bool: + """Return whether this unit has LDAP enabled.""" + return self.is_ldap_charm_related and self.is_cluster_initialised + def get_secret(self, key: str) -> str | None: """Get the secret value for 'key' from the peer relation data.""" if not self.relation: @@ -326,3 +389,10 @@ def remove_secret(self, key: str) -> None: if not self.relation: return self.data_interface.delete_relation_data(self.relation.id, [key]) + + @cached_property + def data(self) -> MutableMapping[str, str]: + """Escape hatch method to access the peer data directly.""" + if not self.relation: + return {} + return self.relation.data[self.app] diff --git a/single_kernel_postgresql/core/state.py b/single_kernel_postgresql/core/state.py index ab19b77..8f365e5 100644 --- a/single_kernel_postgresql/core/state.py +++ b/single_kernel_postgresql/core/state.py @@ -6,17 +6,30 @@ import re import socket +from contextlib import suppress from functools import cached_property from typing import TYPE_CHECKING, Any, get_args from data_platform_helpers.advanced_statuses import StatusesState, StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope -from ops import ConfigData, JujuVersion, ModelError, Object, Relation, SecretNotFoundError, Unit +from ops import ( + ConfigData, + JujuVersion, + ModelError, + Object, + Relation, + RelationNotFoundError, + SecretNotFoundError, + Unit, +) from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( APP_SCOPE, + DATABASE, PEER_RELATION, + REPLICATION_CONSUMER_RELATION, + REPLICATION_OFFER_RELATION, SCOPES, STATUS_PEERS_RELATION, ) @@ -153,6 +166,27 @@ def unit_ip(self) -> str | None: if binding := self.model.get_binding(PEER_RELATION): return str(binding.network.bind_address) + @property + def database_ip(self) -> str | None: + """Database endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(DATABASE): + return str(binding.network.bind_address) + + @property + def replication_offer_ip(self) -> str | None: + """Async replication offer endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(REPLICATION_OFFER_RELATION): + return str(binding.network.bind_address) + + @property + def replication_consumer_ip(self) -> str | None: + """Async replication consumer endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(REPLICATION_CONSUMER_RELATION): + return str(binding.network.bind_address) + @property def fqdn(self) -> str | None: """Current unit fqdn.""" @@ -165,7 +199,7 @@ def fqdn(self) -> str | None: def endpoint(self) -> str | None: """Current unit endpoint.""" if self.substrate == Substrates.K8S: - return self.fqdn + return f"{self.peer.unit.name.replace('/', '-')}.{self.application.app.name}-endpoints" else: return self.unit_ip @@ -173,9 +207,11 @@ def endpoint(self) -> str | None: def endpoints(self) -> set[str]: """Returns the list of endpoints of the current members of the cluster.""" if self.peer_relation: - return self.application.endpoints - else: - return {self.endpoint} if self.endpoint else set() + if self.substrate == Substrates.K8S: + return self.application.endpoints + else: + return self.peer_members_ips + return {self.endpoint} if self.endpoint else set() @property def model_name(self) -> str: @@ -185,7 +221,7 @@ def model_name(self) -> str: @cached_property def patroni_url(self) -> str: """Patroni REST API URL.""" - return f"https://{self.unit_ip}:8007" + return f"https://{self.endpoint}:8008" @property def peer_members_ips(self) -> set[str]: @@ -227,7 +263,12 @@ def listen_ips(self) -> list[str]: ips = [] if self.unit_ip: ips.append(self.unit_ip) - # TODO: Add other ips + if self.database_ip and self.database_ip not in ips: + ips.append(self.database_ip) + if self.replication_offer_ip and self.replication_offer_ip not in ips: + ips.append(self.replication_offer_ip) + if self.replication_consumer_ip and self.replication_consumer_ip not in ips: + ips.append(self.replication_consumer_ip) return ips # -- Secrets @@ -403,3 +444,38 @@ def _search_interpolated_status( if re.fullmatch(regex_pattern, present_status.message) is not None: return present_status return None + + @cached_property + def synchronous_node_count(self) -> int: + """Number of expected sync standbys.""" + planned_units = self.application.planned_units + if self.config.synchronous_node_count == "all": + return planned_units - 1 + elif self.config.synchronous_node_count == "majority": + return planned_units // 2 + # -1 for leader + return ( + self.config.synchronous_node_count + if self.config.synchronous_node_count < planned_units - 1 + else planned_units - 1 + ) + + @cached_property + def synchronous_configuration(self) -> dict[str, Any]: + """Synchronous mode configuration.""" + # Try to update synchronous_node_count. + return { + "synchronous_node_count": self.synchronous_node_count, + "synchronous_mode_strict": len(self.application.members_ips) > 1 + and self.config.synchronous_mode_strict + and self.synchronous_node_count > 0, + } + + def _build_service_name(self, service: str) -> str: + """Build a full k8s service name based on the service name.""" + return f"{self.application.app.name}-{service}.{self.model_name}.svc.cluster.local" + + @property + def primary_endpoint(self) -> str: + """Returns the endpoint of the primary instance's service.""" + return self._build_service_name("primary") diff --git a/single_kernel_postgresql/events/postgresql.py b/single_kernel_postgresql/events/postgresql.py index ab88620..49b09c2 100644 --- a/single_kernel_postgresql/events/postgresql.py +++ b/single_kernel_postgresql/events/postgresql.py @@ -177,8 +177,8 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: self.tls_manager.configure_internal_peer_ca() - if self.charm.substrate == Substrates.VM: - self.config_manager.update_config() + # if self.charm.substrate == Substrates.VM: + # self.config_manager.update_config() # TODO: Add next steps of leader elected diff --git a/single_kernel_postgresql/managers/base.py b/single_kernel_postgresql/managers/base.py index e3e8ebc..85f85ef 100644 --- a/single_kernel_postgresql/managers/base.py +++ b/single_kernel_postgresql/managers/base.py @@ -11,7 +11,6 @@ from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload logger = logging.getLogger(__name__) @@ -25,13 +24,10 @@ class BaseManager(ManagerStatusProtocol): state: CharmState - def __init__( - self, state: CharmState, workload: BaseWorkload, name: str, client: PostgreSQLClient - ): + def __init__(self, state: CharmState, workload: BaseWorkload, name: str): self.state = state self.workload = workload self.name = name - self.postgresql_client = client def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False diff --git a/single_kernel_postgresql/managers/cluster.py b/single_kernel_postgresql/managers/cluster.py index 3e1ff3b..ba0fc06 100644 --- a/single_kernel_postgresql/managers/cluster.py +++ b/single_kernel_postgresql/managers/cluster.py @@ -34,7 +34,6 @@ from single_kernel_postgresql.utils import new_password from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload -from single_kernel_postgresql.workload.vm import VMWorkload logger = logging.getLogger(__name__) @@ -45,15 +44,15 @@ class ClusterManager(BaseManager): This manager is responsible for handling cluster-wide operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "cluster_manager", client) + def __init__(self, state: CharmState, workload: BaseWorkload): + super().__init__(state, workload, "cluster_manager") def install_workload(self) -> None: """Install the workload.""" - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - self.workload.install_snap_package(revision=None) - self.workload.create_snap_alias("patronictl") - self.workload.create_snap_alias("psql") + if self.state.substrate == Substrates.VM: + self.workload.install_snap_package(revision=None) # type: ignore + self.workload.create_snap_alias("patronictl") # type:ignore + self.workload.create_snap_alias("psql") # type: ignore else: logger.debug( "No workload installation steps defined for substrate %s", self.state.substrate @@ -108,14 +107,16 @@ def expose_ip_and_port(self) -> None: except ModelError: logger.exception("failed to open port") - def can_connect_to_postgresql(self, retry: bool = True) -> bool: + def can_connect_to_postgresql( + self, postgresql_client: PostgreSQLClient, retry: bool = True + ) -> bool: """Whether the local PostgreSQL instance is reachable and responding.""" - if not self.postgresql_client.password or not self.postgresql_client.current_host: + if not postgresql_client.password or not postgresql_client.current_host: return False def _check_connection(): try: - if not self.postgresql_client.get_postgresql_timezones(): + if not postgresql_client.get_postgresql_timezones(): logger.debug("Cannot connect to database (CannotConnectError)") raise PostgreSQLCannotConnectError except Exception as e: diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index d78d261..08f204b 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -8,14 +8,18 @@ """ import logging +from typing import Any import charm_refresh from data_platform_helpers.advanced_statuses import StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope from jinja2 import Template +from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( + PGBACKREST_CONF_FILE, POSTGRESQL_STORAGE_PERMISSIONS, + REWIND_USER, USER, ) from single_kernel_postgresql.config.statuses import GeneralStatuses @@ -34,8 +38,19 @@ class ConfigManager(BaseManager): This manager is responsible for handling configuration operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "config_manager", client) + def __init__(self, state: CharmState, workload: BaseWorkload): + super().__init__(state, workload, "config_manager") + + @staticmethod + def _dict_to_hba_string(_dict: dict[str, Any]) -> str: + """Transform a dictionary into a Host Based Authentication valid string.""" + for key, value in _dict.items(): + if isinstance(value, bool): + _dict[key] = int(value) + if isinstance(value, str): + _dict[key] = f'"{value}"' + + return " ".join(f"{key}={value}" for key, value in _dict.items()) def configure_patroni_on_unit(self): """Configure Patroni (configuration files and service) on the unit.""" @@ -50,7 +65,9 @@ def configure_patroni_on_unit(self): self.workload.paths.data, mode=POSTGRESQL_STORAGE_PERMISSIONS, exist_ok=True ) - def _build_postgresql_parameters(self) -> dict[str, str] | None: + def _build_postgresql_parameters( + self, postgresql_client: PostgreSQLClient + ) -> dict[str, str] | None: """Build PostgreSQL configuration parameters. Returns: @@ -61,7 +78,7 @@ def _build_postgresql_parameters(self) -> dict[str, str] | None: limit_memory = self.state.config.profile_limit_memory * 10**6 # Build PostgreSQL parameters. - pg_parameters = self.postgresql_client.build_postgresql_parameters( + pg_parameters = postgresql_client.build_postgresql_parameters( self.state.model_config, self.workload.get_available_memory(), limit_memory ) @@ -72,25 +89,56 @@ def _build_postgresql_parameters(self) -> dict[str, str] | None: def update_config( self, + postgresql_client: PostgreSQLClient, is_creating_backup: bool = False, + # TODO add rel handler + is_tls_enabled: bool = False, + # TODO add rel handler + relations_user_databases_map: dict[str, Any] | None = None, + # TODO add rel handler + ldap_parameters: dict[str, Any] | None = None, + # TODO add rel handler + async_primary_cluster_endpoint: str | None = None, + async_partner_addresses: list[str] | None = None, + async_standby_endpoints: list[str] | None = None, + # TODO add rel handler + watcher_raft_address: str | None = None, no_peers: bool = False, *, refresh: charm_refresh.Machines | None = None, ) -> bool: """Updates Patroni config file based on the existence of the TLS files.""" - # if refresh is None: - # refresh = self.refresh - # Build PostgreSQL parameters - pg_parameters = self._build_postgresql_parameters() + pg_parameters = self._build_postgresql_parameters(postgresql_client) # replication_slots = self.logical_replication.replication_slots() + replication_slots = {} + + # TODO add rel handler + relations_user_databases_map = relations_user_databases_map or {} # Update and reload configuration based on TLS files availability. logger.debug(f"Calling render_patroni_yml_file with parameters = {pg_parameters}") self.render_patroni_yml_file( + connectivity=self.state.peer.is_connectivity_enabled, + is_creating_backup=is_creating_backup, + enable_ldap=self.state.application.is_ldap_enabled, + enable_tls=is_tls_enabled, + backup_id=self.state.application.data.get("restoring-backup"), + pitr_target=self.state.application.data.get("restore-to-time"), + restore_timeline=self.state.application.data.get("restore-timeline"), + restore_to_latest=self.state.application.data.get("restore-to-time", None) == "latest", + stanza=self.state.application.data.get("stanza", self.state.peer.data.get("stanza")), + restore_stanza=self.state.application.data.get("restore-stanza"), parameters=pg_parameters, - slots={}, + user_databases_map=relations_user_databases_map, + slots=replication_slots, + ldap_parameters=ldap_parameters, + async_primary_cluster_endpoint=async_primary_cluster_endpoint, + async_partner_addresses=async_partner_addresses, + async_standby_endpoints=async_standby_endpoints, + watcher_raft_address=watcher_raft_address, + no_peers=no_peers, ) return True @@ -100,6 +148,7 @@ def render_patroni_yml_file( is_creating_backup: bool = False, enable_ldap: bool = False, enable_tls: bool = False, + is_no_sync_member: bool = False, stanza: str | None = None, restore_stanza: str | None = None, disable_pgbackrest_archiving: bool = False, @@ -111,6 +160,14 @@ def render_patroni_yml_file( no_peers: bool = False, user_databases_map: dict[str, str] | None = None, slots: dict[str, str] | None = None, + # LDAP rel + ldap_parameters: dict[str, Any] | None = None, + # Async rel + async_primary_cluster_endpoint: str | None = None, + async_partner_addresses: list[str] | None = None, + async_standby_endpoints: list[str] | None = None, + # VM watcher rel + watcher_raft_address: str | None = None, ) -> None: """Render the Patroni configuration file. @@ -119,6 +176,8 @@ def render_patroni_yml_file( is_creating_backup: whether this unit is creating a backup. enable_ldap: whether to enable LDAP authentication. enable_tls: whether to enable client TLS. + is_no_sync_member: whether this member shouldn't be a synchronous standby + (when it's a replica). K8s only. stanza: name of the stanza created by pgBackRest. restore_stanza: name of the stanza used when restoring a backup. disable_pgbackrest_archiving: whether to force disable pgBackRest WAL archiving. @@ -130,8 +189,16 @@ def render_patroni_yml_file( no_peers: Don't include peers. user_databases_map: map of databases to be accessible by each user. slots: replication slots (keys) with assigned database name (values). + ldap_parameters: LDAP configuration. + async_primary_cluster_endpoint: Primary async cluster endpoint. + async_standby_endpoints: Primary async cluster endpoint. + async_partner_addresses: Primary async cluster endpoint. + watcher_raft_address: IP address of a related Raft watcher. """ slots = slots or {} + ldap_parameters = ldap_parameters or {} + async_partner_addresses = async_partner_addresses or [] + async_standby_endpoints = async_standby_endpoints or [] if not self._are_passwords_set: logger.warning("Passwords are not yet generated by the leader") return @@ -140,52 +207,86 @@ def render_patroni_yml_file( with open("templates/patroni.yml.j2") as file: template = Template(file.read()) - # TODO: configure LDAP - # ldap_params = self.charm.get_ldap_parameters() - - # Render the template file with the correct values. - rendered = template.render( - conf_path=self.workload.paths.patroni_conf, - connectivity=connectivity, - is_creating_backup=is_creating_backup, - log_path=self.workload.paths.patroni_logs, - postgresql_log_path=self.workload.paths.logs, - data_path=self.workload.paths.data, - enable_ldap=enable_ldap, - enable_tls=enable_tls, - member_name=self.state.peer.member_name, - partner_addrs=[], - peers_ips=set(), - scope=self.state.application.cluster_name, - self_ip=self.state.unit_ip, - listen_ips=self.state.listen_ips, - superuser=USER, - superuser_password=self.state.application.user_password, - replication_password=self.state.application.replication_password, - rewind_password=self.state.application.rewind_password, - raft_password=self.state.application.raft_password, - version=self.workload.get_postgresql_version().split(".")[0], - pg_parameters=parameters, - patroni_password=self.state.application.patroni_password, - slots=slots, - instance_password_encryption=self.state.config.instance_password_encryption, - ) + confs = { + "connectivity": connectivity, + "enable_ldap": enable_ldap, + "enable_tls": enable_tls, + "member_name": self.state.peer.member_name, + "superuser": USER, + "superuser_password": self.state.application.user_password, + "rewind_user": REWIND_USER, + "rewind_password": self.state.application.rewind_password, + "replication_password": self.state.application.replication_password, + "enable_pgbackrest_archiving": stanza is not None + and disable_pgbackrest_archiving is False, + "stanza": stanza, + "restore_stanza": restore_stanza, + "restoring_backup": backup_id is not None or pitr_target is not None, + "backup_id": backup_id, + "pitr_target": pitr_target if not restore_to_latest else None, + "restore_timeline": restore_timeline, + "restore_to_latest": restore_to_latest, + "is_creating_backup": is_creating_backup, + "version": self.workload.get_postgresql_version().split(".")[0], + "synchronous_node_count": self.state.synchronous_node_count, + "maximum_lag_on_failover": self.state.config.durability_maximum_lag_on_failover, + "pg_parameters": parameters, + "primary_cluster_endpoint": async_primary_cluster_endpoint, + "ldap_parameters": self._dict_to_hba_string(ldap_parameters), + "patroni_password": self.state.application.patroni_password, + "user_databases_map": user_databases_map, + "slots": slots, + "instance_password_encryption": self.state.config.instance_password_encryption, + "extra_replication_endpoints": async_standby_endpoints, + } + if self.state.substrate == Substrates.VM: + confs.update({ + "conf_path": str(self.workload.paths.patroni_conf), + "log_path": str(self.workload.paths.patroni_logs), + "postgresql_log_path": str(self.workload.paths.logs), + "data_path": str(self.workload.paths.data), + "wal_dir": str(self.workload.paths.wal), + "partner_addrs": async_partner_addresses if not no_peers else [], + "peers_ips": sorted(self.state.endpoints) if not no_peers else [], + "pgbackrest_configuration_file": f"--config={self.workload.paths.pgbackrest_conf / PGBACKREST_CONF_FILE}", + "scope": self.state.application.cluster_name, + "self_ip": self.state.unit_ip, + "listen_ips": self.state.listen_ips, + "raft_password": self.state.application.raft_password, + "watcher": watcher_raft_address, + }) + perms = 0o600 + else: + confs.update({ + "endpoint": self.state.endpoint, + "endpoints": list(self.state.endpoints), + "is_no_sync_member": is_no_sync_member, + "namespace": self.state.model_name, + "storage_path": str(self.workload.paths.patroni_conf), + "logs_storage_path": str(self.workload.paths.logs), + "pgdata_path": str(self.workload.paths.data), + "restoring_backup": backup_id is not None or pitr_target is not None, + }) + perms = 0o644 + rendered = template.render(**confs) render_file( self.state.substrate, - f"{self.workload.paths.patroni_conf}/patroni.yaml", + str(self.workload.paths.patroni_conf / "patroni.yaml"), rendered, - 0o600, + perms, ) @property def _are_passwords_set(self) -> bool: - return all([ + passes = [ self.state.application.user_password, self.state.application.replication_password, self.state.application.rewind_password, - self.state.application.raft_password, self.state.application.patroni_password, - ]) + ] + if self.state.substrate == Substrates.VM: + passes.append(self.state.application.raft_password) + return all(passes) def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False diff --git a/single_kernel_postgresql/managers/k8s.py b/single_kernel_postgresql/managers/k8s.py index a8c84cd..1685b41 100644 --- a/single_kernel_postgresql/managers/k8s.py +++ b/single_kernel_postgresql/managers/k8s.py @@ -32,7 +32,6 @@ from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager from single_kernel_postgresql.utils import unit_name_to_pod_name -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.k8s import K8sWorkload logger = logging.getLogger(__name__) @@ -44,8 +43,8 @@ class K8sManager(BaseManager): This manager is responsible for handling operations related to Kubernetes and Pebble. """ - def __init__(self, state: CharmState, workload: K8sWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "pebble_manager", client) + def __init__(self, state: CharmState, workload: K8sWorkload): + super().__init__(state, workload, "pebble_manager") self.workload: K8sWorkload = workload # type: ignore[assignment] def update_pebble_layers(self, replan: bool = True) -> None: diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index f850646..d0f31e3 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -9,13 +9,21 @@ """ import logging +from contextlib import suppress from functools import cached_property +from typing import TypedDict +# Platform specific imports +with suppress(ImportError): + pass import requests -from data_platform_helpers.advanced_statuses import StatusObject -from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope from requests.auth import HTTPBasicAuth -from tenacity import RetryError, Retrying, stop_after_delay, wait_fixed +from tenacity import ( + RetryError, + Retrying, + stop_after_delay, + wait_fixed, +) from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( @@ -23,33 +31,51 @@ RUNNING_STATES, TLS_CA_BUNDLE_FILE, ) -from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload -from single_kernel_postgresql.workload.vm import VMWorkload logger = logging.getLogger(__name__) +class ClusterMember(TypedDict): + """Type for cluster member.""" + + name: str + role: str + state: str + api_url: str + host: str + port: int + timeline: int + lag: int + + class PatroniManager(BaseManager): """PostgreSQL Patroni Manager. This manager is responsible for handling operations related to Patroni. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "patroni_manager", client) + def __init__( + self, + state: CharmState, + workload: BaseWorkload, + ): + super().__init__(state, workload, "patroni_manager") # Variable mapping to requests library verify parameter. # The CA bundle file is used to validate the server certificate when # TLS is enabled, otherwise True is set because it's the default value. - self.verify = f"{self.workload.paths.patroni_conf}/{TLS_CA_BUNDLE_FILE}" + if self.state.substrate == Substrates.VM: + self.verify = f"{self.workload.paths.patroni_conf}/{TLS_CA_BUNDLE_FILE}" + else: + # CA bundle is not secret + self.verify = f"/tmp/{TLS_CA_BUNDLE_FILE}" # noqa: S108 def start_patroni(self) -> bool: """Start Patroni.""" - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - return self.workload.start_patroni() + if self.state.substrate == Substrates.VM: + return self.workload.start_patroni() # type: ignore else: # TODO: Implement for other substrates return False @@ -62,19 +88,15 @@ def member_started(self) -> bool: True if services is ready False otherwise. Retries over a period of 60 seconds times to allow server time to start up. """ - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - if not self.workload.is_patroni_running(): - return False - try: - response = self.cached_patroni_health - except RetryError: - return False - - return response["state"] in RUNNING_STATES - else: - # TODO: Implement for other substrates + if not self.workload.is_patroni_running(): + return False + try: + response = self.cached_patroni_health + except RetryError: return False + return response["state"] in RUNNING_STATES + @cached_property def cached_patroni_health(self) -> dict[str, str]: """Cached local unit health.""" @@ -99,11 +121,3 @@ def get_patroni_health(self) -> dict[str, str]: def _patroni_auth(self) -> HTTPBasicAuth | None: if self.state.application.patroni_password: return HTTPBasicAuth("patroni", self.state.application.patroni_password) - - def get_statuses( - self, scope: AdvancedStatusesScope, recompute: bool = False - ) -> list[StatusObject]: - """Compute the manager's statuses.""" - # if self.workload.workload_present and self.state.substrate == Substrates.VM and not self.member_started: - # return [PatroniStatuses.WAITING_MEMBER_START.value] - return [GeneralStatuses.ACTIVE_IDLE.value] diff --git a/single_kernel_postgresql/managers/tls.py b/single_kernel_postgresql/managers/tls.py index 67681ea..8b12614 100644 --- a/single_kernel_postgresql/managers/tls.py +++ b/single_kernel_postgresql/managers/tls.py @@ -28,7 +28,6 @@ from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload logger = logging.getLogger(__name__) @@ -40,8 +39,8 @@ class TLSManager(BaseManager): This manager is responsible for handling TLS configuration operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "tls_manager", client) + def __init__(self, state: CharmState, workload: BaseWorkload): + super().__init__(state, workload, "tls_manager") def configure_internal_peer_ca(self) -> None: """Configure TLS internal peer CA.""" diff --git a/single_kernel_postgresql/workload/base.py b/single_kernel_postgresql/workload/base.py index ec6b4a0..f4e543c 100644 --- a/single_kernel_postgresql/workload/base.py +++ b/single_kernel_postgresql/workload/base.py @@ -193,6 +193,11 @@ def is_service_started(self, paused: bool | None = False) -> bool: """ pass + @abstractmethod + def is_patroni_running(self) -> bool: + """Check if the Patroni service is running.""" + pass + @abstractmethod def start_service_only(self): """Start the actual service only (snap / pebble).""" diff --git a/single_kernel_postgresql/workload/k8s.py b/single_kernel_postgresql/workload/k8s.py index 605dcf8..5bae728 100644 --- a/single_kernel_postgresql/workload/k8s.py +++ b/single_kernel_postgresql/workload/k8s.py @@ -13,7 +13,7 @@ from charmlibs import pathops from charmlibs.pathops import PathProtocol from ops import Container, ModelError -from ops.pebble import Plan +from ops.pebble import Plan, ServiceStatus from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError from single_kernel_postgresql.config.literals import ( @@ -30,7 +30,7 @@ class K8sWorkload(BaseWorkload): """Kubernetes PostgreSQL Workload.""" - def __init__(self, charm_dir: Path, container: Container | None = None): + def __init__(self, charm_dir: Path, container: Container): """Initialize workload. Args: @@ -38,8 +38,6 @@ def __init__(self, charm_dir: Path, container: Container | None = None): container: the Container instance. """ super().__init__(charm_dir=charm_dir) - if not container: - raise AttributeError("Container is required.") self.container = container self._paths: BasePaths | None = None @@ -78,6 +76,17 @@ def start_service_only(self): """Start the actual service only (snap / pebble).""" raise NotImplementedError + def is_patroni_running(self) -> bool: + """Check if the Patroni service is running.""" + if not self.container.can_connect(): + return False + + services = self.container.pebble.get_services(names=[K8S_POSTGRESQL_SERVICE_NAME]) + if len(services) == 0: + return False + + return services[0].current == ServiceStatus.ACTIVE + def run_cmd( self, command: str, @@ -136,7 +145,7 @@ def temp_file( PebbleError: if file operations fail. """ # PathProtocol exposes text operations. - temp_dir_path = directory or self.paths.tmp + temp_dir_path = directory or self.paths.temp self.mkdir( temp_dir_path, mode=DIR_PERMISSIONS_READONLY, @@ -182,7 +191,7 @@ def paths(self) -> BasePaths: # this may raise RuntimeError if container isn't set, which is expected # during initialization before container is available root_path = self.root - self._paths = K8sPaths(root_path) + self._paths = K8sPaths(root_path, self.get_postgresql_version().split(".")[0]) return self._paths @property diff --git a/single_kernel_postgresql/workload/paths/base.py b/single_kernel_postgresql/workload/paths/base.py index 7a88b4d..4b98794 100644 --- a/single_kernel_postgresql/workload/paths/base.py +++ b/single_kernel_postgresql/workload/paths/base.py @@ -18,14 +18,16 @@ class Paths(ABC): bin: Path to the bin/ folder """ - def __init__(self, root: PathProtocol): + def __init__(self, root: PathProtocol, major_version: str): """Initialize the Paths. Args: root: The root path for the PostgreSQL installation. + major_version: Postgresql major version """ super().__init__() self.root = root + self.versioned_path = f"{major_version}/main" @property @abstractmethod @@ -47,7 +49,13 @@ def logs(self) -> PathProtocol: @property @abstractmethod - def tmp(self) -> PathProtocol: + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + pass + + @property + @abstractmethod + def temp(self) -> PathProtocol: """Path to the temporary directory.""" pass @@ -68,3 +76,9 @@ def patroni_conf(self) -> PathProtocol: def patroni_logs(self) -> PathProtocol: """Path to the patroni logs.""" pass + + @property + @abstractmethod + def pgbackrest_conf(self) -> PathProtocol: + """Path to the patroni logs.""" + pass diff --git a/single_kernel_postgresql/workload/paths/k8s.py b/single_kernel_postgresql/workload/paths/k8s.py index 87defef..ffe82ce 100644 --- a/single_kernel_postgresql/workload/paths/k8s.py +++ b/single_kernel_postgresql/workload/paths/k8s.py @@ -6,8 +6,8 @@ from single_kernel_postgresql.config.literals import ( K8S_DATA_PATH, - PATRONI_CONF_PATH, PATRONI_LOGS_PATH, + PGBACKREST_CONF_PATH, POSTGRESQL_CONF_FILE, POSTGRESQL_CONF_PATH, ) @@ -34,16 +34,22 @@ def conf(self) -> PathProtocol: @property def data(self) -> PathProtocol: """Path to the data folder of PostgreSQL.""" - return self.root / K8S_DATA_PATH + return self.root / K8S_DATA_PATH / self.versioned_path @property def logs(self) -> PathProtocol: """Path to the logs folder of PostgreSQL.""" # TODO: Update path + return self.root / "var" / "lib" / "pg" / "logs" + + @property + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + # TODO: Update path return self.root / "logs" @property - def tmp(self) -> PathProtocol: + def temp(self) -> PathProtocol: """Path to the temporary directory.""" return self.root / "tmp" @@ -55,9 +61,14 @@ def postgresql_conf(self) -> PathProtocol: @property def patroni_conf(self) -> PathProtocol: """Path to the patroni configuration file.""" - return self.conf / PATRONI_CONF_PATH + return self.root / K8S_DATA_PATH @property def patroni_logs(self) -> PathProtocol: """Path to the patroni logs.""" return self.logs / PATRONI_LOGS_PATH + + @property + def pgbackrest_conf(self) -> PathProtocol: + """Path to the pgbackrest config.""" + return self.conf / PGBACKREST_CONF_PATH diff --git a/single_kernel_postgresql/workload/paths/vm.py b/single_kernel_postgresql/workload/paths/vm.py index 523f8b2..3f5e76b 100644 --- a/single_kernel_postgresql/workload/paths/vm.py +++ b/single_kernel_postgresql/workload/paths/vm.py @@ -8,13 +8,16 @@ BASE_SNAP_DIR, PATRONI_CONF_PATH, PATRONI_LOGS_PATH, + PGBACKREST_CONF_PATH, POSTGRESQL_CONF_FILE, POSTGRESQL_CONF_PATH, SNAP, SNAP_COMMON, SNAP_DATA, + VM_DATA_LOGS_PATH, VM_DATA_PATH, VM_LOGS_PATH, + VM_TEMP_PATH, ) from single_kernel_postgresql.workload.paths.base import Paths @@ -79,15 +82,24 @@ def patroni_logs(self) -> PathProtocol: @property def data(self) -> PathProtocol: """Path to the data folder of PostgreSQL.""" - return self.snap_common / VM_DATA_PATH + return self.snap_common / VM_DATA_PATH / self.versioned_path @property def logs(self) -> PathProtocol: """Path to the logs folder of PostgreSQL.""" - # TODO: Update path return self.snap_common / VM_LOGS_PATH @property - def tmp(self) -> PathProtocol: + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + return self.snap_common / VM_DATA_LOGS_PATH / self.versioned_path + + @property + def temp(self) -> PathProtocol: """Path to the temporary directory.""" - return self.root / "tmp" + return self.snap_common / VM_TEMP_PATH / self.versioned_path + + @property + def pgbackrest_conf(self) -> PathProtocol: + """Path to the pgbackrest configuration.""" + return self.snap_current / PGBACKREST_CONF_PATH diff --git a/single_kernel_postgresql/workload/vm.py b/single_kernel_postgresql/workload/vm.py index 4839876..516a602 100644 --- a/single_kernel_postgresql/workload/vm.py +++ b/single_kernel_postgresql/workload/vm.py @@ -198,7 +198,7 @@ def temp_file( @property def paths(self) -> BasePaths: """Return Workload's paths.""" - return VMPaths(self.root) + return VMPaths(self.root, self.get_postgresql_version().split(".")[0]) @property def root(self) -> PathProtocol: diff --git a/tests/unit/test_postgresql.py b/tests/unit/test_postgresql.py index a10668b..9176665 100644 --- a/tests/unit/test_postgresql.py +++ b/tests/unit/test_postgresql.py @@ -39,17 +39,20 @@ def harness(substrate, test_charm_path): meta = meta_file.read() with open(test_charm_path + "/actions.yaml") as actions_file: actions = actions_file.read() + wl_patch = patch("single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version") if substrate == "vm": harness = Harness(vm_charm.PostgreSQLVMCharm, meta=meta, actions=actions) else: harness = Harness(k8s_charm.PostgreSQLK8sCharm, meta=meta, actions=actions) # Set up the initial relation and hooks. + wl_patch.start() peer_rel_id = harness.add_relation(PEER_RELATION, "postgresql-single-kernel") harness.add_relation_unit(peer_rel_id, "postgresql-single-kernel/0") harness.begin() yield harness harness.cleanup() + wl_patch.stop() @pytest.mark.parametrize("users_exist", [True, False]) diff --git a/tox.ini b/tox.ini index d8ec751..18c352c 100644 --- a/tox.ini +++ b/tox.ini @@ -26,8 +26,8 @@ description = Apply coding style standards to code commands_pre = uv sync --active --group format --all-extras commands = - uv run --active ruff check --fix {[vars]all_path} - uv run --active ruff format {[vars]all_path} + uv run --active ruff check --fix {[vars]all_path} --exclude {[vars]src_path}/lib + uv run --active ruff format {[vars]all_path} --exclude {[vars]src_path}/lib [testenv:lint] diff --git a/uv.lock b/uv.lock index d37b3c7..ab38dc9 100644 --- a/uv.lock +++ b/uv.lock @@ -22,8 +22,8 @@ name = "anyio" version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } wheels = [ @@ -44,7 +44,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "python_full_version >= '3.12' and implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -147,7 +147,7 @@ name = "charm-json" version = "0.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charm-api", marker = "python_full_version >= '3.12'" }, + { name = "charm-api" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/11/a7adf8bffcc97d1e2f4dc4f8e6411a51871fe6931dabc34bbb86c582600f/charm_json-0.1.2.tar.gz", hash = "sha256:f91255858cd6721ec491d09450f37a0cdecf1addbfcfbd41f3e27c58dd95092e", size = 3208, upload-time = "2026-04-06T12:42:53.702Z" } wheels = [ @@ -159,14 +159,14 @@ name = "charm-refresh" version = "3.1.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charm-api", marker = "python_full_version >= '3.12'" }, - { name = "charm-json", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "lightkube", marker = "python_full_version >= '3.12'" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pyyaml", marker = "python_full_version >= '3.12'" }, - { name = "tomli", marker = "python_full_version >= '3.12'" }, + { name = "charm-api" }, + { name = "charm-json" }, + { name = "httpx" }, + { name = "lightkube" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/44/108b0623d344f024838456a7fd31b883637d6c3c936fa8a53167447fb8d3/charm_refresh-3.1.1.4.tar.gz", hash = "sha256:d33f66e5b65fd356a30bfbc634710e5538086199da93c30c86246370a66570be", size = 33278, upload-time = "2026-04-06T14:09:50.387Z" } wheels = [ @@ -178,9 +178,9 @@ name = "charmlibs-interfaces-tls-certificates" version = "1.8.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "python_full_version >= '3.12'" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "cryptography" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/b0/24342009af662f52f83d163852d43d53e9e0c3fb629feeeda5eeb739c03e/charmlibs_interfaces_tls_certificates-1.8.3.tar.gz", hash = "sha256:6bc35ee330681f2e0c58cf67e41133b144f3281f73d27293714c9ca918ee7cbc", size = 110247, upload-time = "2026-06-05T10:08:01.55Z" } wheels = [ @@ -192,7 +192,7 @@ name = "charmlibs-pathops" version = "1.3.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/1f/37cd5c74fd6adbb01f4947651af55b8ff8461eed034de3f6d018988022ab/charmlibs_pathops-1.3.0.post0.tar.gz", hash = "sha256:36ac11b0c7c55b71341a549a1803f5c8d61c2279204862b8d50736041b782f59", size = 22140, upload-time = "2026-06-16T05:39:49.177Z" } wheels = [ @@ -204,13 +204,13 @@ name = "charmlibs-rollingops" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, - { name = "charmlibs-pathops", marker = "python_full_version >= '3.12'" }, - { name = "dpcharmlibs-interfaces", marker = "python_full_version >= '3.12'" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "shortuuid", marker = "python_full_version >= '3.12'" }, - { name = "tenacity", version = "9.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "charmlibs-interfaces-tls-certificates" }, + { name = "charmlibs-pathops" }, + { name = "dpcharmlibs-interfaces" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, + { name = "shortuuid" }, + { name = "tenacity", version = "9.1.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/6c/b2/4959afe13622b68faab2827c493b090f3e84a786c2d4a284803f55a18a76/charmlibs_rollingops-1.1.1.tar.gz", hash = "sha256:831457d2ef12117794f89a483ee9a5c347cb209a87dafe82907e9dede7d4641c", size = 91238, upload-time = "2026-05-27T15:10:05.592Z" } wheels = [ @@ -222,7 +222,7 @@ name = "charmlibs-snap" version = "1.0.1.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/33/5043f1dd01e485e1c855516e41694fae48f48e94dd52e6081e5b61eee649/charmlibs_snap-1.0.1.post0.tar.gz", hash = "sha256:1e8da752ee405aa981665da8c4772016236baf80d2d902b02ec8c1c640fb2844", size = 29393, upload-time = "2026-06-16T05:39:54.911Z" } wheels = [ @@ -486,7 +486,7 @@ name = "cryptography" version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -542,9 +542,9 @@ name = "data-platform-helpers" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, + { name = "rich" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/05/9cb4b743817ae3c9bff658d5ad56a2d28907d242c2a0e2308415273f7c98/data_platform_helpers-1.1.0.tar.gz", hash = "sha256:7539ef1c1047fec36ee8298aa4c75fe2bb36419544193b691805c12ca407a797", size = 42929, upload-time = "2026-06-17T14:18:32.435Z" } wheels = [ @@ -556,7 +556,7 @@ name = "deprecated" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt", marker = "python_full_version < '3.9'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ @@ -568,8 +568,8 @@ name = "dpcharmlibs-interfaces" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/41/4cfa520d62c0aa53804599bf74506184eadc7c1e4803616383ec6bd3ab39/dpcharmlibs_interfaces-1.0.0.tar.gz", hash = "sha256:a177653019781a7a165be52cf4293134ee58912cd93686082cdd82da347a3bed", size = 203809, upload-time = "2026-03-24T17:38:47.136Z" } wheels = [ @@ -590,8 +590,8 @@ name = "h2" version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack", marker = "python_full_version >= '3.12'" }, - { name = "hyperframe", marker = "python_full_version >= '3.12'" }, + { name = "hpack" }, + { name = "hyperframe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -612,8 +612,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "h11", marker = "python_full_version >= '3.12'" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -625,10 +625,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "httpcore", marker = "python_full_version >= '3.12'" }, - { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -637,7 +637,7 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2", marker = "python_full_version >= '3.12'" }, + { name = "h2" }, ] [[package]] @@ -645,10 +645,10 @@ name = "httpx-ws" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "httpcore", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "wsproto", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "wsproto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" } wheels = [ @@ -681,7 +681,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ @@ -696,7 +696,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -717,7 +717,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "python_full_version >= '3.12'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -729,10 +729,10 @@ name = "lightkube" version = "0.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", extra = ["http2"], marker = "python_full_version >= '3.12'" }, - { name = "httpx-ws", marker = "python_full_version >= '3.12'" }, - { name = "lightkube-models", marker = "python_full_version >= '3.12'" }, - { name = "pyyaml", marker = "python_full_version >= '3.12'" }, + { name = "httpx", extra = ["http2"] }, + { name = "httpx-ws" }, + { name = "lightkube-models" }, + { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/9329c34cab5fb5738ea54c2af1952f32f5ecedbed03779d1b82eb329ba8a/lightkube-0.21.0.tar.gz", hash = "sha256:720910200de4bd88e0d7b5d2edfb45a4f6be292798b8776d7b551ef5a5ccdc15", size = 36752, upload-time = "2026-05-13T08:26:06.108Z" } wheels = [ @@ -753,7 +753,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.12'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -873,8 +873,8 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "deprecated", marker = "python_full_version < '3.9'" }, - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "deprecated" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/8d/1f5a45fbcb9a7d87809d460f09dc3399e3fbd31d7f3e14888345e9d29951/opentelemetry_api-1.33.1.tar.gz", hash = "sha256:1c6055fc0a2d3f23a50c7e17e16ef75ad489345fd3df1f8b8af7c0bbf8a109e8", size = 65002, upload-time = "2025-05-16T18:52:41.146Z" } wheels = [ @@ -889,8 +889,8 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "typing-extensions", marker = "python_full_version == '3.9.*'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ @@ -906,7 +906,7 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", ] dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ @@ -922,12 +922,12 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pyyaml" }, + { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/98/84789a5e15ad76e043301bbd70b4d39cffaa717ae6eecb662fd0dd0cc7af/ops-2.23.2.tar.gz", hash = "sha256:a69b0c5bc65ebd91720fc96459e81b969df851f3a6c9c855ee73de7004205987", size = 528074, upload-time = "2026-02-11T03:58:15.565Z" } @@ -944,9 +944,9 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", ] dependencies = [ - { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml" }, + { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/60/ad398d889fd03b1b4f950fad54ad4d0cf7a81fde21f9866a8139c8f03684/ops-3.7.1.tar.gz", hash = "sha256:1765bf6d1cff376ea27608542e183b055c89f2c5f54bca602072bcc817195abc", size = 582424, upload-time = "2026-05-28T04:13:43.906Z" } wheels = [ @@ -984,21 +984,27 @@ dependencies = [ ] [package.optional-dependencies] +db-driver = [ + { name = "psycopg2", version = "2.9.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "psycopg2", version = "2.9.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] postgresql = [ { name = "charm-refresh", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-pathops", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-rollingops", marker = "python_full_version >= '3.12'" }, - { name = "charmlibs-snap", marker = "python_full_version >= '3.12'" }, { name = "data-platform-helpers", marker = "python_full_version >= '3.12'" }, { name = "httpx", marker = "python_full_version >= '3.12'" }, { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "psycopg2", version = "2.9.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "psycopg2", version = "2.9.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pydantic", marker = "python_full_version >= '3.12'" }, { name = "requests", marker = "python_full_version >= '3.12'" }, { name = "tomli", marker = "python_full_version >= '3.12'" }, ] +vm = [ + { name = "charmlibs-snap", marker = "python_full_version >= '3.12'" }, + { name = "psutil", marker = "python_full_version >= '3.12'" }, + { name = "pysyncobj", marker = "python_full_version >= '3.12'" }, +] [package.dev-dependencies] format = [ @@ -1019,18 +1025,20 @@ requires-dist = [ { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.8.3" }, { name = "charmlibs-pathops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.0.1" }, { name = "charmlibs-rollingops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.1.1" }, - { name = "charmlibs-snap", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.0.1" }, + { name = "charmlibs-snap", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=1.0.1" }, { name = "data-platform-helpers", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=0.1.7" }, { name = "httpx", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "jinja2", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=3.1.6" }, { name = "ops", specifier = ">=2.0.0" }, - { name = "psycopg2", marker = "extra == 'postgresql'", specifier = ">=2.9.10" }, + { name = "psutil", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=7.2.2" }, + { name = "psycopg2", marker = "extra == 'db-driver'", specifier = ">=2.9.10" }, { name = "pydantic", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=2.0" }, + { name = "pysyncobj", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=0.3.15" }, { name = "requests", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "tomli", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, ] -provides-extras = ["postgresql"] +provides-extras = ["postgresql", "vm", "db-driver"] [package.metadata.requires-dev] format = [{ name = "ruff", marker = "python_full_version >= '3.12'", specifier = "==0.15.18" }] @@ -1043,6 +1051,34 @@ unit = [ { name = "pytest", marker = "python_full_version >= '3.12'", specifier = "==9.1.1" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "psycopg2" version = "2.9.10" @@ -1096,10 +1132,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "python_full_version >= '3.12'" }, - { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -1111,7 +1147,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -1245,16 +1281,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pysyncobj" +version = "0.3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/48/5f96d7f8c46446f1a1a660a594c06906b59185a1b19e334180f72fc0dc0e/pysyncobj-0.3.15.tar.gz", hash = "sha256:7b98f523bc7810203763f5a6ef569ff2dd5de889fb7f5f0ccc8c8b6c87df9680", size = 41987, upload-time = "2026-03-17T21:50:24.39Z" } + [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "iniconfig", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pluggy", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1346,10 +1388,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.12'" }, - { name = "idna", marker = "python_full_version >= '3.12'" }, - { name = "urllib3", marker = "python_full_version >= '3.12'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -1361,8 +1403,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -1533,7 +1575,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -1694,7 +1736,7 @@ name = "wsproto" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "python_full_version >= '3.12'" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ From ad37c1779ad5043f4d308179af63013f6c544313 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Tue, 30 Jun 2026 04:41:45 +0300 Subject: [PATCH 26/31] [DPE-10062] Single kernel changes --- pyproject.toml | 12 +- .../charms/abstract_charm.py | 16 +- single_kernel_postgresql/charms/k8s_charm.py | 2 +- single_kernel_postgresql/config/exceptions.py | 48 ++++ single_kernel_postgresql/config/literals.py | 15 +- .../core/peer_relation.py | 72 +++++- single_kernel_postgresql/core/state.py | 90 +++++++- single_kernel_postgresql/events/postgresql.py | 4 +- single_kernel_postgresql/managers/base.py | 6 +- single_kernel_postgresql/managers/cluster.py | 21 +- single_kernel_postgresql/managers/config.py | 189 +++++++++++---- single_kernel_postgresql/managers/k8s.py | 5 +- single_kernel_postgresql/managers/patroni.py | 59 +++-- single_kernel_postgresql/managers/tls.py | 5 +- single_kernel_postgresql/workload/base.py | 5 + single_kernel_postgresql/workload/k8s.py | 21 +- .../workload/paths/base.py | 18 +- .../workload/paths/k8s.py | 19 +- single_kernel_postgresql/workload/paths/vm.py | 20 +- single_kernel_postgresql/workload/vm.py | 2 +- tests/unit/test_postgresql.py | 3 + tox.ini | 4 +- uv.lock | 218 +++++++++++------- 23 files changed, 636 insertions(+), 218 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f1d3316..aa575d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,9 @@ dependencies = [ [project.optional-dependencies] postgresql = [ - "psycopg2>=2.9.10", "httpx; python_version >= '3.12'", "data-platform-helpers>=0.1.7; python_version >= '3.12'", "charmlibs-pathops>=1.0.1; python_version >= '3.12'", - "charmlibs-snap>=1.0.1; python_version >= '3.12'", "charmlibs-interfaces-tls-certificates>=1.8.3; python_version >= '3.12'", "charm-refresh; python_version >= '3.12'", "requests; python_version >= '3.12'", @@ -35,7 +33,15 @@ postgresql = [ "pydantic>=2.0; python_version >= '3.12'", "jinja2 >=3.1.6; python_version >= '3.12'", "charmlibs-rollingops>=1.1.1; python_version >= '3.12'", - ] +] +vm = [ + "pysyncobj>=0.3.15; python_version >= '3.12'", + "psutil>=7.2.2; python_version >= '3.12'", + "charmlibs-snap>=1.0.1; python_version >= '3.12'", +] +db-driver = [ + "psycopg2>=2.9.10", +] [build-system] requires = ["uv_build>=0.11.0,<0.12.0"] diff --git a/single_kernel_postgresql/charms/abstract_charm.py b/single_kernel_postgresql/charms/abstract_charm.py index 0cf7b0a..2a18407 100644 --- a/single_kernel_postgresql/charms/abstract_charm.py +++ b/single_kernel_postgresql/charms/abstract_charm.py @@ -29,18 +29,10 @@ def __init__(self, *args): self.state = CharmState(charm=self, substrate=self.substrate) # Managers - self.cluster_manager = ClusterManager( - state=self.state, workload=self.workload, client=self.postgresql - ) - self.tls_manager = TLSManager( - state=self.state, workload=self.workload, client=self.postgresql - ) - self.config_manager = ConfigManager( - state=self.state, workload=self.workload, client=self.postgresql - ) - self.patroni_manager = PatroniManager( - state=self.state, workload=self.workload, client=self.postgresql - ) + self.tls_manager = TLSManager(state=self.state, workload=self.workload) + self.patroni_manager = PatroniManager(state=self.state, workload=self.workload) + self.cluster_manager = ClusterManager(state=self.state, workload=self.workload) + self.config_manager = ConfigManager(state=self.state, workload=self.workload) # Events Handler self.postgresql_events_handler = PostgreSQLEventsHandler( diff --git a/single_kernel_postgresql/charms/k8s_charm.py b/single_kernel_postgresql/charms/k8s_charm.py index b261ac6..772d210 100644 --- a/single_kernel_postgresql/charms/k8s_charm.py +++ b/single_kernel_postgresql/charms/k8s_charm.py @@ -25,7 +25,7 @@ def __init__(self, *args): assert isinstance(self.workload, K8sWorkload), ( # noqa: S101 "Workload must be an instance of K8sWorkload" ) - self.k8s_manager = K8sManager(self.state, self.workload, self.postgresql) + self.k8s_manager = K8sManager(self.state, self.workload) @property def postgresql(self) -> PostgreSQL: diff --git a/single_kernel_postgresql/config/exceptions.py b/single_kernel_postgresql/config/exceptions.py index fcd09cc..2586653 100644 --- a/single_kernel_postgresql/config/exceptions.py +++ b/single_kernel_postgresql/config/exceptions.py @@ -25,3 +25,51 @@ class PostgreSQLCannotConnectError(Exception): class TlsError(Exception): """TLS implementation internal exception.""" + + +class RaftPostgresqlNotUpError(Exception): + """Postgresql not yet started.""" + + +class RaftPostgresqlStillUpError(Exception): + """Postgresql not yet down.""" + + +class RaftNotPromotedError(Exception): + """Leader not yet set when reinitialising raft.""" + + +class ClusterNotPromotedError(Exception): + """Raised when a cluster is not promoted.""" + + +class NotReadyError(Exception): + """Raised when not all cluster members healthy or finished initial sync.""" + + +class EndpointNotReadyError(Exception): + """Raised when an endpoint is not ready.""" + + +class StandbyClusterAlreadyPromotedError(Exception): + """Raised when a standby cluster is already promoted.""" + + +class RemoveRaftMemberFailedError(Exception): + """Raised when a remove raft member failed for some reason.""" + + +class AddRaftMemberFailedError(Exception): + """Raised when adding raft member failed for some reason.""" + + +class SwitchoverFailedError(Exception): + """Raised when a switchover failed for some reason.""" + + +class SwitchoverNotSyncError(SwitchoverFailedError): + """Raised when a switchover failed because node is not sync.""" + + +class UpdateSyncNodeCountError(Exception): + """Raised when updating synchronous_node_count failed for some reason.""" diff --git a/single_kernel_postgresql/config/literals.py b/single_kernel_postgresql/config/literals.py index 803e8af..7bd6e7f 100644 --- a/single_kernel_postgresql/config/literals.py +++ b/single_kernel_postgresql/config/literals.py @@ -31,6 +31,9 @@ SNAP = "/snap/charmed-postgresql/current" VM_LOGS_PATH = "var/log/postgresql" VM_DATA_PATH = "var/lib/postgresql" +VM_ARCHIVE_PATH = "data/archive" +VM_DATA_LOGS_PATH = "data/logs" +VM_TEMP_PATH = "data/temp" ## K8s Paths K8S_DATA_PATH = "var/lib/pg/data" @@ -42,7 +45,9 @@ # e.g. snap_current / "/etc/postgresql" will result in "/etc/postgresql" instead of "/var/snap/postgresql/current/etc/postgresql" POSTGRESQL_CONF_PATH = "etc/postgresql" POSTGRESQL_CONF_FILE = "postgresql.conf" - +## Pgbackreest Paths +PGBACKREST_CONF_PATH = "etc/pgbackrest" +PGBACKREST_CONF_FILE = "pgbackrest.conf" ## TLS Paths TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem" @@ -161,3 +166,11 @@ WATCHER_USER = "watcher" WATCHER_PASSWORD_KEY = "watcher-password" # noqa: S105 WATCHER_SECRET_LABEL = "watcher-secret" # noqa: S105 + +# Raft +RAFT_PORT = 2222 +RAFT_PARTNER_PREFIX = "partner_node_status_server_" + +# VM services +VM_PATRONI_SERVICE_NAME = "snap.charmed-postgresql.patroni.service" +VM_PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{VM_PATRONI_SERVICE_NAME}" diff --git a/single_kernel_postgresql/core/peer_relation.py b/single_kernel_postgresql/core/peer_relation.py index 069a859..c03be29 100644 --- a/single_kernel_postgresql/core/peer_relation.py +++ b/single_kernel_postgresql/core/peer_relation.py @@ -6,6 +6,8 @@ """State objects for database-peers relation.""" import json +from collections.abc import MutableMapping +from functools import cached_property from ops import Application, BlockedStatus, Relation, Unit @@ -177,6 +179,34 @@ def peer_addresses(self) -> set[str]: peer_addrs.add(addr) return peer_addrs + @property + def is_unit_departing(self) -> bool: + """Returns whether the unit is departing.""" + if not self.relation: + return False + return "departing" in self.relation.data[self.unit] + + @property + def is_unit_stopped(self) -> bool: + """Returns whether the unit is stopped.""" + if not self.relation: + return False + return "stopped" in self.relation.data[self.unit] + + @property + def is_connectivity_enabled(self) -> bool: + """Return whether this unit can be connected externally.""" + if not self.relation: + return True + return self.relation.data[self.unit].get("connectivity", "on") == "on" + + @cached_property + def data(self) -> MutableMapping[str, str]: + """Escape hatch method to access the peer data directly.""" + if not self.relation: + return {} + return self.relation.data[self.unit] + class PostgreSQLApplication(RelationState): """An PostgreSQL Application is the peer application state. @@ -290,7 +320,7 @@ def cluster_name(self) -> str: return f"patroni-{self.app.name}" return self.app.name - @property + @cached_property def planned_units(self) -> int: """Get the number of planned units for the application.""" return self.app.planned_units() @@ -309,6 +339,39 @@ def endpoints(self) -> set[str]: return set() return set(json.loads(self.relation.data[self.app].get("endpoints", "[]"))) + @property + def is_cluster_initialised(self) -> bool: + """Returns whether the cluster is already initialised.""" + if not self.relation: + return False + return "cluster_initialised" in self.relation.data[self.app] + + @property + def is_cluster_restoring_backup(self) -> bool: + """Returns whether the cluster is restoring a backup.""" + if not self.relation: + return False + return "restoring-backup" in self.relation.data[self.app] + + @property + def is_cluster_restoring_to_time(self) -> bool: + """Returns whether the cluster is restoring a backup to a specific time.""" + if not self.relation: + return False + return "restore-to-time" in self.relation.data[self.app] + + @property + def is_ldap_charm_related(self) -> bool: + """Return whether this unit has an LDAP charm related.""" + if not self.relation: + return False + return self.relation.data[self.app].get("ldap_enabled", "False") == "True" + + @property + def is_ldap_enabled(self) -> bool: + """Return whether this unit has LDAP enabled.""" + return self.is_ldap_charm_related and self.is_cluster_initialised + def get_secret(self, key: str) -> str | None: """Get the secret value for 'key' from the peer relation data.""" if not self.relation: @@ -326,3 +389,10 @@ def remove_secret(self, key: str) -> None: if not self.relation: return self.data_interface.delete_relation_data(self.relation.id, [key]) + + @cached_property + def data(self) -> MutableMapping[str, str]: + """Escape hatch method to access the peer data directly.""" + if not self.relation: + return {} + return self.relation.data[self.app] diff --git a/single_kernel_postgresql/core/state.py b/single_kernel_postgresql/core/state.py index ab19b77..8f365e5 100644 --- a/single_kernel_postgresql/core/state.py +++ b/single_kernel_postgresql/core/state.py @@ -6,17 +6,30 @@ import re import socket +from contextlib import suppress from functools import cached_property from typing import TYPE_CHECKING, Any, get_args from data_platform_helpers.advanced_statuses import StatusesState, StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope -from ops import ConfigData, JujuVersion, ModelError, Object, Relation, SecretNotFoundError, Unit +from ops import ( + ConfigData, + JujuVersion, + ModelError, + Object, + Relation, + RelationNotFoundError, + SecretNotFoundError, + Unit, +) from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( APP_SCOPE, + DATABASE, PEER_RELATION, + REPLICATION_CONSUMER_RELATION, + REPLICATION_OFFER_RELATION, SCOPES, STATUS_PEERS_RELATION, ) @@ -153,6 +166,27 @@ def unit_ip(self) -> str | None: if binding := self.model.get_binding(PEER_RELATION): return str(binding.network.bind_address) + @property + def database_ip(self) -> str | None: + """Database endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(DATABASE): + return str(binding.network.bind_address) + + @property + def replication_offer_ip(self) -> str | None: + """Async replication offer endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(REPLICATION_OFFER_RELATION): + return str(binding.network.bind_address) + + @property + def replication_consumer_ip(self) -> str | None: + """Async replication consumer endpoint address.""" + with suppress(RelationNotFoundError): + if binding := self.model.get_binding(REPLICATION_CONSUMER_RELATION): + return str(binding.network.bind_address) + @property def fqdn(self) -> str | None: """Current unit fqdn.""" @@ -165,7 +199,7 @@ def fqdn(self) -> str | None: def endpoint(self) -> str | None: """Current unit endpoint.""" if self.substrate == Substrates.K8S: - return self.fqdn + return f"{self.peer.unit.name.replace('/', '-')}.{self.application.app.name}-endpoints" else: return self.unit_ip @@ -173,9 +207,11 @@ def endpoint(self) -> str | None: def endpoints(self) -> set[str]: """Returns the list of endpoints of the current members of the cluster.""" if self.peer_relation: - return self.application.endpoints - else: - return {self.endpoint} if self.endpoint else set() + if self.substrate == Substrates.K8S: + return self.application.endpoints + else: + return self.peer_members_ips + return {self.endpoint} if self.endpoint else set() @property def model_name(self) -> str: @@ -185,7 +221,7 @@ def model_name(self) -> str: @cached_property def patroni_url(self) -> str: """Patroni REST API URL.""" - return f"https://{self.unit_ip}:8007" + return f"https://{self.endpoint}:8008" @property def peer_members_ips(self) -> set[str]: @@ -227,7 +263,12 @@ def listen_ips(self) -> list[str]: ips = [] if self.unit_ip: ips.append(self.unit_ip) - # TODO: Add other ips + if self.database_ip and self.database_ip not in ips: + ips.append(self.database_ip) + if self.replication_offer_ip and self.replication_offer_ip not in ips: + ips.append(self.replication_offer_ip) + if self.replication_consumer_ip and self.replication_consumer_ip not in ips: + ips.append(self.replication_consumer_ip) return ips # -- Secrets @@ -403,3 +444,38 @@ def _search_interpolated_status( if re.fullmatch(regex_pattern, present_status.message) is not None: return present_status return None + + @cached_property + def synchronous_node_count(self) -> int: + """Number of expected sync standbys.""" + planned_units = self.application.planned_units + if self.config.synchronous_node_count == "all": + return planned_units - 1 + elif self.config.synchronous_node_count == "majority": + return planned_units // 2 + # -1 for leader + return ( + self.config.synchronous_node_count + if self.config.synchronous_node_count < planned_units - 1 + else planned_units - 1 + ) + + @cached_property + def synchronous_configuration(self) -> dict[str, Any]: + """Synchronous mode configuration.""" + # Try to update synchronous_node_count. + return { + "synchronous_node_count": self.synchronous_node_count, + "synchronous_mode_strict": len(self.application.members_ips) > 1 + and self.config.synchronous_mode_strict + and self.synchronous_node_count > 0, + } + + def _build_service_name(self, service: str) -> str: + """Build a full k8s service name based on the service name.""" + return f"{self.application.app.name}-{service}.{self.model_name}.svc.cluster.local" + + @property + def primary_endpoint(self) -> str: + """Returns the endpoint of the primary instance's service.""" + return self._build_service_name("primary") diff --git a/single_kernel_postgresql/events/postgresql.py b/single_kernel_postgresql/events/postgresql.py index ab88620..49b09c2 100644 --- a/single_kernel_postgresql/events/postgresql.py +++ b/single_kernel_postgresql/events/postgresql.py @@ -177,8 +177,8 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: self.tls_manager.configure_internal_peer_ca() - if self.charm.substrate == Substrates.VM: - self.config_manager.update_config() + # if self.charm.substrate == Substrates.VM: + # self.config_manager.update_config() # TODO: Add next steps of leader elected diff --git a/single_kernel_postgresql/managers/base.py b/single_kernel_postgresql/managers/base.py index e3e8ebc..85f85ef 100644 --- a/single_kernel_postgresql/managers/base.py +++ b/single_kernel_postgresql/managers/base.py @@ -11,7 +11,6 @@ from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload logger = logging.getLogger(__name__) @@ -25,13 +24,10 @@ class BaseManager(ManagerStatusProtocol): state: CharmState - def __init__( - self, state: CharmState, workload: BaseWorkload, name: str, client: PostgreSQLClient - ): + def __init__(self, state: CharmState, workload: BaseWorkload, name: str): self.state = state self.workload = workload self.name = name - self.postgresql_client = client def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False diff --git a/single_kernel_postgresql/managers/cluster.py b/single_kernel_postgresql/managers/cluster.py index 3e1ff3b..ba0fc06 100644 --- a/single_kernel_postgresql/managers/cluster.py +++ b/single_kernel_postgresql/managers/cluster.py @@ -34,7 +34,6 @@ from single_kernel_postgresql.utils import new_password from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload -from single_kernel_postgresql.workload.vm import VMWorkload logger = logging.getLogger(__name__) @@ -45,15 +44,15 @@ class ClusterManager(BaseManager): This manager is responsible for handling cluster-wide operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "cluster_manager", client) + def __init__(self, state: CharmState, workload: BaseWorkload): + super().__init__(state, workload, "cluster_manager") def install_workload(self) -> None: """Install the workload.""" - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - self.workload.install_snap_package(revision=None) - self.workload.create_snap_alias("patronictl") - self.workload.create_snap_alias("psql") + if self.state.substrate == Substrates.VM: + self.workload.install_snap_package(revision=None) # type: ignore + self.workload.create_snap_alias("patronictl") # type:ignore + self.workload.create_snap_alias("psql") # type: ignore else: logger.debug( "No workload installation steps defined for substrate %s", self.state.substrate @@ -108,14 +107,16 @@ def expose_ip_and_port(self) -> None: except ModelError: logger.exception("failed to open port") - def can_connect_to_postgresql(self, retry: bool = True) -> bool: + def can_connect_to_postgresql( + self, postgresql_client: PostgreSQLClient, retry: bool = True + ) -> bool: """Whether the local PostgreSQL instance is reachable and responding.""" - if not self.postgresql_client.password or not self.postgresql_client.current_host: + if not postgresql_client.password or not postgresql_client.current_host: return False def _check_connection(): try: - if not self.postgresql_client.get_postgresql_timezones(): + if not postgresql_client.get_postgresql_timezones(): logger.debug("Cannot connect to database (CannotConnectError)") raise PostgreSQLCannotConnectError except Exception as e: diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index d78d261..08f204b 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -8,14 +8,18 @@ """ import logging +from typing import Any import charm_refresh from data_platform_helpers.advanced_statuses import StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope from jinja2 import Template +from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( + PGBACKREST_CONF_FILE, POSTGRESQL_STORAGE_PERMISSIONS, + REWIND_USER, USER, ) from single_kernel_postgresql.config.statuses import GeneralStatuses @@ -34,8 +38,19 @@ class ConfigManager(BaseManager): This manager is responsible for handling configuration operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "config_manager", client) + def __init__(self, state: CharmState, workload: BaseWorkload): + super().__init__(state, workload, "config_manager") + + @staticmethod + def _dict_to_hba_string(_dict: dict[str, Any]) -> str: + """Transform a dictionary into a Host Based Authentication valid string.""" + for key, value in _dict.items(): + if isinstance(value, bool): + _dict[key] = int(value) + if isinstance(value, str): + _dict[key] = f'"{value}"' + + return " ".join(f"{key}={value}" for key, value in _dict.items()) def configure_patroni_on_unit(self): """Configure Patroni (configuration files and service) on the unit.""" @@ -50,7 +65,9 @@ def configure_patroni_on_unit(self): self.workload.paths.data, mode=POSTGRESQL_STORAGE_PERMISSIONS, exist_ok=True ) - def _build_postgresql_parameters(self) -> dict[str, str] | None: + def _build_postgresql_parameters( + self, postgresql_client: PostgreSQLClient + ) -> dict[str, str] | None: """Build PostgreSQL configuration parameters. Returns: @@ -61,7 +78,7 @@ def _build_postgresql_parameters(self) -> dict[str, str] | None: limit_memory = self.state.config.profile_limit_memory * 10**6 # Build PostgreSQL parameters. - pg_parameters = self.postgresql_client.build_postgresql_parameters( + pg_parameters = postgresql_client.build_postgresql_parameters( self.state.model_config, self.workload.get_available_memory(), limit_memory ) @@ -72,25 +89,56 @@ def _build_postgresql_parameters(self) -> dict[str, str] | None: def update_config( self, + postgresql_client: PostgreSQLClient, is_creating_backup: bool = False, + # TODO add rel handler + is_tls_enabled: bool = False, + # TODO add rel handler + relations_user_databases_map: dict[str, Any] | None = None, + # TODO add rel handler + ldap_parameters: dict[str, Any] | None = None, + # TODO add rel handler + async_primary_cluster_endpoint: str | None = None, + async_partner_addresses: list[str] | None = None, + async_standby_endpoints: list[str] | None = None, + # TODO add rel handler + watcher_raft_address: str | None = None, no_peers: bool = False, *, refresh: charm_refresh.Machines | None = None, ) -> bool: """Updates Patroni config file based on the existence of the TLS files.""" - # if refresh is None: - # refresh = self.refresh - # Build PostgreSQL parameters - pg_parameters = self._build_postgresql_parameters() + pg_parameters = self._build_postgresql_parameters(postgresql_client) # replication_slots = self.logical_replication.replication_slots() + replication_slots = {} + + # TODO add rel handler + relations_user_databases_map = relations_user_databases_map or {} # Update and reload configuration based on TLS files availability. logger.debug(f"Calling render_patroni_yml_file with parameters = {pg_parameters}") self.render_patroni_yml_file( + connectivity=self.state.peer.is_connectivity_enabled, + is_creating_backup=is_creating_backup, + enable_ldap=self.state.application.is_ldap_enabled, + enable_tls=is_tls_enabled, + backup_id=self.state.application.data.get("restoring-backup"), + pitr_target=self.state.application.data.get("restore-to-time"), + restore_timeline=self.state.application.data.get("restore-timeline"), + restore_to_latest=self.state.application.data.get("restore-to-time", None) == "latest", + stanza=self.state.application.data.get("stanza", self.state.peer.data.get("stanza")), + restore_stanza=self.state.application.data.get("restore-stanza"), parameters=pg_parameters, - slots={}, + user_databases_map=relations_user_databases_map, + slots=replication_slots, + ldap_parameters=ldap_parameters, + async_primary_cluster_endpoint=async_primary_cluster_endpoint, + async_partner_addresses=async_partner_addresses, + async_standby_endpoints=async_standby_endpoints, + watcher_raft_address=watcher_raft_address, + no_peers=no_peers, ) return True @@ -100,6 +148,7 @@ def render_patroni_yml_file( is_creating_backup: bool = False, enable_ldap: bool = False, enable_tls: bool = False, + is_no_sync_member: bool = False, stanza: str | None = None, restore_stanza: str | None = None, disable_pgbackrest_archiving: bool = False, @@ -111,6 +160,14 @@ def render_patroni_yml_file( no_peers: bool = False, user_databases_map: dict[str, str] | None = None, slots: dict[str, str] | None = None, + # LDAP rel + ldap_parameters: dict[str, Any] | None = None, + # Async rel + async_primary_cluster_endpoint: str | None = None, + async_partner_addresses: list[str] | None = None, + async_standby_endpoints: list[str] | None = None, + # VM watcher rel + watcher_raft_address: str | None = None, ) -> None: """Render the Patroni configuration file. @@ -119,6 +176,8 @@ def render_patroni_yml_file( is_creating_backup: whether this unit is creating a backup. enable_ldap: whether to enable LDAP authentication. enable_tls: whether to enable client TLS. + is_no_sync_member: whether this member shouldn't be a synchronous standby + (when it's a replica). K8s only. stanza: name of the stanza created by pgBackRest. restore_stanza: name of the stanza used when restoring a backup. disable_pgbackrest_archiving: whether to force disable pgBackRest WAL archiving. @@ -130,8 +189,16 @@ def render_patroni_yml_file( no_peers: Don't include peers. user_databases_map: map of databases to be accessible by each user. slots: replication slots (keys) with assigned database name (values). + ldap_parameters: LDAP configuration. + async_primary_cluster_endpoint: Primary async cluster endpoint. + async_standby_endpoints: Primary async cluster endpoint. + async_partner_addresses: Primary async cluster endpoint. + watcher_raft_address: IP address of a related Raft watcher. """ slots = slots or {} + ldap_parameters = ldap_parameters or {} + async_partner_addresses = async_partner_addresses or [] + async_standby_endpoints = async_standby_endpoints or [] if not self._are_passwords_set: logger.warning("Passwords are not yet generated by the leader") return @@ -140,52 +207,86 @@ def render_patroni_yml_file( with open("templates/patroni.yml.j2") as file: template = Template(file.read()) - # TODO: configure LDAP - # ldap_params = self.charm.get_ldap_parameters() - - # Render the template file with the correct values. - rendered = template.render( - conf_path=self.workload.paths.patroni_conf, - connectivity=connectivity, - is_creating_backup=is_creating_backup, - log_path=self.workload.paths.patroni_logs, - postgresql_log_path=self.workload.paths.logs, - data_path=self.workload.paths.data, - enable_ldap=enable_ldap, - enable_tls=enable_tls, - member_name=self.state.peer.member_name, - partner_addrs=[], - peers_ips=set(), - scope=self.state.application.cluster_name, - self_ip=self.state.unit_ip, - listen_ips=self.state.listen_ips, - superuser=USER, - superuser_password=self.state.application.user_password, - replication_password=self.state.application.replication_password, - rewind_password=self.state.application.rewind_password, - raft_password=self.state.application.raft_password, - version=self.workload.get_postgresql_version().split(".")[0], - pg_parameters=parameters, - patroni_password=self.state.application.patroni_password, - slots=slots, - instance_password_encryption=self.state.config.instance_password_encryption, - ) + confs = { + "connectivity": connectivity, + "enable_ldap": enable_ldap, + "enable_tls": enable_tls, + "member_name": self.state.peer.member_name, + "superuser": USER, + "superuser_password": self.state.application.user_password, + "rewind_user": REWIND_USER, + "rewind_password": self.state.application.rewind_password, + "replication_password": self.state.application.replication_password, + "enable_pgbackrest_archiving": stanza is not None + and disable_pgbackrest_archiving is False, + "stanza": stanza, + "restore_stanza": restore_stanza, + "restoring_backup": backup_id is not None or pitr_target is not None, + "backup_id": backup_id, + "pitr_target": pitr_target if not restore_to_latest else None, + "restore_timeline": restore_timeline, + "restore_to_latest": restore_to_latest, + "is_creating_backup": is_creating_backup, + "version": self.workload.get_postgresql_version().split(".")[0], + "synchronous_node_count": self.state.synchronous_node_count, + "maximum_lag_on_failover": self.state.config.durability_maximum_lag_on_failover, + "pg_parameters": parameters, + "primary_cluster_endpoint": async_primary_cluster_endpoint, + "ldap_parameters": self._dict_to_hba_string(ldap_parameters), + "patroni_password": self.state.application.patroni_password, + "user_databases_map": user_databases_map, + "slots": slots, + "instance_password_encryption": self.state.config.instance_password_encryption, + "extra_replication_endpoints": async_standby_endpoints, + } + if self.state.substrate == Substrates.VM: + confs.update({ + "conf_path": str(self.workload.paths.patroni_conf), + "log_path": str(self.workload.paths.patroni_logs), + "postgresql_log_path": str(self.workload.paths.logs), + "data_path": str(self.workload.paths.data), + "wal_dir": str(self.workload.paths.wal), + "partner_addrs": async_partner_addresses if not no_peers else [], + "peers_ips": sorted(self.state.endpoints) if not no_peers else [], + "pgbackrest_configuration_file": f"--config={self.workload.paths.pgbackrest_conf / PGBACKREST_CONF_FILE}", + "scope": self.state.application.cluster_name, + "self_ip": self.state.unit_ip, + "listen_ips": self.state.listen_ips, + "raft_password": self.state.application.raft_password, + "watcher": watcher_raft_address, + }) + perms = 0o600 + else: + confs.update({ + "endpoint": self.state.endpoint, + "endpoints": list(self.state.endpoints), + "is_no_sync_member": is_no_sync_member, + "namespace": self.state.model_name, + "storage_path": str(self.workload.paths.patroni_conf), + "logs_storage_path": str(self.workload.paths.logs), + "pgdata_path": str(self.workload.paths.data), + "restoring_backup": backup_id is not None or pitr_target is not None, + }) + perms = 0o644 + rendered = template.render(**confs) render_file( self.state.substrate, - f"{self.workload.paths.patroni_conf}/patroni.yaml", + str(self.workload.paths.patroni_conf / "patroni.yaml"), rendered, - 0o600, + perms, ) @property def _are_passwords_set(self) -> bool: - return all([ + passes = [ self.state.application.user_password, self.state.application.replication_password, self.state.application.rewind_password, - self.state.application.raft_password, self.state.application.patroni_password, - ]) + ] + if self.state.substrate == Substrates.VM: + passes.append(self.state.application.raft_password) + return all(passes) def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False diff --git a/single_kernel_postgresql/managers/k8s.py b/single_kernel_postgresql/managers/k8s.py index a8c84cd..1685b41 100644 --- a/single_kernel_postgresql/managers/k8s.py +++ b/single_kernel_postgresql/managers/k8s.py @@ -32,7 +32,6 @@ from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager from single_kernel_postgresql.utils import unit_name_to_pod_name -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.k8s import K8sWorkload logger = logging.getLogger(__name__) @@ -44,8 +43,8 @@ class K8sManager(BaseManager): This manager is responsible for handling operations related to Kubernetes and Pebble. """ - def __init__(self, state: CharmState, workload: K8sWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "pebble_manager", client) + def __init__(self, state: CharmState, workload: K8sWorkload): + super().__init__(state, workload, "pebble_manager") self.workload: K8sWorkload = workload # type: ignore[assignment] def update_pebble_layers(self, replan: bool = True) -> None: diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index f850646..1dca072 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -10,12 +10,18 @@ import logging from functools import cached_property +from typing import TypedDict import requests from data_platform_helpers.advanced_statuses import StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope from requests.auth import HTTPBasicAuth -from tenacity import RetryError, Retrying, stop_after_delay, wait_fixed +from tenacity import ( + RetryError, + Retrying, + stop_after_delay, + wait_fixed, +) from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.literals import ( @@ -26,30 +32,49 @@ from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload -from single_kernel_postgresql.workload.vm import VMWorkload logger = logging.getLogger(__name__) +class ClusterMember(TypedDict): + """Type for cluster member.""" + + name: str + role: str + state: str + api_url: str + host: str + port: int + timeline: int + lag: int + + class PatroniManager(BaseManager): """PostgreSQL Patroni Manager. This manager is responsible for handling operations related to Patroni. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "patroni_manager", client) + def __init__( + self, + state: CharmState, + workload: BaseWorkload, + ): + super().__init__(state, workload, "patroni_manager") # Variable mapping to requests library verify parameter. # The CA bundle file is used to validate the server certificate when # TLS is enabled, otherwise True is set because it's the default value. - self.verify = f"{self.workload.paths.patroni_conf}/{TLS_CA_BUNDLE_FILE}" + if self.state.substrate == Substrates.VM: + self.verify = f"{self.workload.paths.patroni_conf}/{TLS_CA_BUNDLE_FILE}" + else: + # CA bundle is not secret + self.verify = f"/tmp/{TLS_CA_BUNDLE_FILE}" # noqa: S108 def start_patroni(self) -> bool: """Start Patroni.""" - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - return self.workload.start_patroni() + if self.state.substrate == Substrates.VM: + return self.workload.start_patroni() # type: ignore else: # TODO: Implement for other substrates return False @@ -62,18 +87,14 @@ def member_started(self) -> bool: True if services is ready False otherwise. Retries over a period of 60 seconds times to allow server time to start up. """ - if self.state.substrate == Substrates.VM and isinstance(self.workload, VMWorkload): - if not self.workload.is_patroni_running(): - return False - try: - response = self.cached_patroni_health - except RetryError: - return False - - return response["state"] in RUNNING_STATES - else: - # TODO: Implement for other substrates + if not self.workload.is_patroni_running(): return False + try: + response = self.cached_patroni_health + except RetryError: + return False + + return response["state"] in RUNNING_STATES @cached_property def cached_patroni_health(self) -> dict[str, str]: diff --git a/single_kernel_postgresql/managers/tls.py b/single_kernel_postgresql/managers/tls.py index 67681ea..8b12614 100644 --- a/single_kernel_postgresql/managers/tls.py +++ b/single_kernel_postgresql/managers/tls.py @@ -28,7 +28,6 @@ from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.base import BaseManager -from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient from single_kernel_postgresql.workload.base import BaseWorkload logger = logging.getLogger(__name__) @@ -40,8 +39,8 @@ class TLSManager(BaseManager): This manager is responsible for handling TLS configuration operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload, client: PostgreSQLClient): - super().__init__(state, workload, "tls_manager", client) + def __init__(self, state: CharmState, workload: BaseWorkload): + super().__init__(state, workload, "tls_manager") def configure_internal_peer_ca(self) -> None: """Configure TLS internal peer CA.""" diff --git a/single_kernel_postgresql/workload/base.py b/single_kernel_postgresql/workload/base.py index ec6b4a0..f4e543c 100644 --- a/single_kernel_postgresql/workload/base.py +++ b/single_kernel_postgresql/workload/base.py @@ -193,6 +193,11 @@ def is_service_started(self, paused: bool | None = False) -> bool: """ pass + @abstractmethod + def is_patroni_running(self) -> bool: + """Check if the Patroni service is running.""" + pass + @abstractmethod def start_service_only(self): """Start the actual service only (snap / pebble).""" diff --git a/single_kernel_postgresql/workload/k8s.py b/single_kernel_postgresql/workload/k8s.py index 605dcf8..5bae728 100644 --- a/single_kernel_postgresql/workload/k8s.py +++ b/single_kernel_postgresql/workload/k8s.py @@ -13,7 +13,7 @@ from charmlibs import pathops from charmlibs.pathops import PathProtocol from ops import Container, ModelError -from ops.pebble import Plan +from ops.pebble import Plan, ServiceStatus from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError from single_kernel_postgresql.config.literals import ( @@ -30,7 +30,7 @@ class K8sWorkload(BaseWorkload): """Kubernetes PostgreSQL Workload.""" - def __init__(self, charm_dir: Path, container: Container | None = None): + def __init__(self, charm_dir: Path, container: Container): """Initialize workload. Args: @@ -38,8 +38,6 @@ def __init__(self, charm_dir: Path, container: Container | None = None): container: the Container instance. """ super().__init__(charm_dir=charm_dir) - if not container: - raise AttributeError("Container is required.") self.container = container self._paths: BasePaths | None = None @@ -78,6 +76,17 @@ def start_service_only(self): """Start the actual service only (snap / pebble).""" raise NotImplementedError + def is_patroni_running(self) -> bool: + """Check if the Patroni service is running.""" + if not self.container.can_connect(): + return False + + services = self.container.pebble.get_services(names=[K8S_POSTGRESQL_SERVICE_NAME]) + if len(services) == 0: + return False + + return services[0].current == ServiceStatus.ACTIVE + def run_cmd( self, command: str, @@ -136,7 +145,7 @@ def temp_file( PebbleError: if file operations fail. """ # PathProtocol exposes text operations. - temp_dir_path = directory or self.paths.tmp + temp_dir_path = directory or self.paths.temp self.mkdir( temp_dir_path, mode=DIR_PERMISSIONS_READONLY, @@ -182,7 +191,7 @@ def paths(self) -> BasePaths: # this may raise RuntimeError if container isn't set, which is expected # during initialization before container is available root_path = self.root - self._paths = K8sPaths(root_path) + self._paths = K8sPaths(root_path, self.get_postgresql_version().split(".")[0]) return self._paths @property diff --git a/single_kernel_postgresql/workload/paths/base.py b/single_kernel_postgresql/workload/paths/base.py index 7a88b4d..4b98794 100644 --- a/single_kernel_postgresql/workload/paths/base.py +++ b/single_kernel_postgresql/workload/paths/base.py @@ -18,14 +18,16 @@ class Paths(ABC): bin: Path to the bin/ folder """ - def __init__(self, root: PathProtocol): + def __init__(self, root: PathProtocol, major_version: str): """Initialize the Paths. Args: root: The root path for the PostgreSQL installation. + major_version: Postgresql major version """ super().__init__() self.root = root + self.versioned_path = f"{major_version}/main" @property @abstractmethod @@ -47,7 +49,13 @@ def logs(self) -> PathProtocol: @property @abstractmethod - def tmp(self) -> PathProtocol: + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + pass + + @property + @abstractmethod + def temp(self) -> PathProtocol: """Path to the temporary directory.""" pass @@ -68,3 +76,9 @@ def patroni_conf(self) -> PathProtocol: def patroni_logs(self) -> PathProtocol: """Path to the patroni logs.""" pass + + @property + @abstractmethod + def pgbackrest_conf(self) -> PathProtocol: + """Path to the patroni logs.""" + pass diff --git a/single_kernel_postgresql/workload/paths/k8s.py b/single_kernel_postgresql/workload/paths/k8s.py index 87defef..ffe82ce 100644 --- a/single_kernel_postgresql/workload/paths/k8s.py +++ b/single_kernel_postgresql/workload/paths/k8s.py @@ -6,8 +6,8 @@ from single_kernel_postgresql.config.literals import ( K8S_DATA_PATH, - PATRONI_CONF_PATH, PATRONI_LOGS_PATH, + PGBACKREST_CONF_PATH, POSTGRESQL_CONF_FILE, POSTGRESQL_CONF_PATH, ) @@ -34,16 +34,22 @@ def conf(self) -> PathProtocol: @property def data(self) -> PathProtocol: """Path to the data folder of PostgreSQL.""" - return self.root / K8S_DATA_PATH + return self.root / K8S_DATA_PATH / self.versioned_path @property def logs(self) -> PathProtocol: """Path to the logs folder of PostgreSQL.""" # TODO: Update path + return self.root / "var" / "lib" / "pg" / "logs" + + @property + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + # TODO: Update path return self.root / "logs" @property - def tmp(self) -> PathProtocol: + def temp(self) -> PathProtocol: """Path to the temporary directory.""" return self.root / "tmp" @@ -55,9 +61,14 @@ def postgresql_conf(self) -> PathProtocol: @property def patroni_conf(self) -> PathProtocol: """Path to the patroni configuration file.""" - return self.conf / PATRONI_CONF_PATH + return self.root / K8S_DATA_PATH @property def patroni_logs(self) -> PathProtocol: """Path to the patroni logs.""" return self.logs / PATRONI_LOGS_PATH + + @property + def pgbackrest_conf(self) -> PathProtocol: + """Path to the pgbackrest config.""" + return self.conf / PGBACKREST_CONF_PATH diff --git a/single_kernel_postgresql/workload/paths/vm.py b/single_kernel_postgresql/workload/paths/vm.py index 523f8b2..3f5e76b 100644 --- a/single_kernel_postgresql/workload/paths/vm.py +++ b/single_kernel_postgresql/workload/paths/vm.py @@ -8,13 +8,16 @@ BASE_SNAP_DIR, PATRONI_CONF_PATH, PATRONI_LOGS_PATH, + PGBACKREST_CONF_PATH, POSTGRESQL_CONF_FILE, POSTGRESQL_CONF_PATH, SNAP, SNAP_COMMON, SNAP_DATA, + VM_DATA_LOGS_PATH, VM_DATA_PATH, VM_LOGS_PATH, + VM_TEMP_PATH, ) from single_kernel_postgresql.workload.paths.base import Paths @@ -79,15 +82,24 @@ def patroni_logs(self) -> PathProtocol: @property def data(self) -> PathProtocol: """Path to the data folder of PostgreSQL.""" - return self.snap_common / VM_DATA_PATH + return self.snap_common / VM_DATA_PATH / self.versioned_path @property def logs(self) -> PathProtocol: """Path to the logs folder of PostgreSQL.""" - # TODO: Update path return self.snap_common / VM_LOGS_PATH @property - def tmp(self) -> PathProtocol: + def wal(self) -> PathProtocol: + """Path to the data logs folder of PostgreSQL.""" + return self.snap_common / VM_DATA_LOGS_PATH / self.versioned_path + + @property + def temp(self) -> PathProtocol: """Path to the temporary directory.""" - return self.root / "tmp" + return self.snap_common / VM_TEMP_PATH / self.versioned_path + + @property + def pgbackrest_conf(self) -> PathProtocol: + """Path to the pgbackrest configuration.""" + return self.snap_current / PGBACKREST_CONF_PATH diff --git a/single_kernel_postgresql/workload/vm.py b/single_kernel_postgresql/workload/vm.py index 4839876..516a602 100644 --- a/single_kernel_postgresql/workload/vm.py +++ b/single_kernel_postgresql/workload/vm.py @@ -198,7 +198,7 @@ def temp_file( @property def paths(self) -> BasePaths: """Return Workload's paths.""" - return VMPaths(self.root) + return VMPaths(self.root, self.get_postgresql_version().split(".")[0]) @property def root(self) -> PathProtocol: diff --git a/tests/unit/test_postgresql.py b/tests/unit/test_postgresql.py index a10668b..9176665 100644 --- a/tests/unit/test_postgresql.py +++ b/tests/unit/test_postgresql.py @@ -39,17 +39,20 @@ def harness(substrate, test_charm_path): meta = meta_file.read() with open(test_charm_path + "/actions.yaml") as actions_file: actions = actions_file.read() + wl_patch = patch("single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version") if substrate == "vm": harness = Harness(vm_charm.PostgreSQLVMCharm, meta=meta, actions=actions) else: harness = Harness(k8s_charm.PostgreSQLK8sCharm, meta=meta, actions=actions) # Set up the initial relation and hooks. + wl_patch.start() peer_rel_id = harness.add_relation(PEER_RELATION, "postgresql-single-kernel") harness.add_relation_unit(peer_rel_id, "postgresql-single-kernel/0") harness.begin() yield harness harness.cleanup() + wl_patch.stop() @pytest.mark.parametrize("users_exist", [True, False]) diff --git a/tox.ini b/tox.ini index d8ec751..18c352c 100644 --- a/tox.ini +++ b/tox.ini @@ -26,8 +26,8 @@ description = Apply coding style standards to code commands_pre = uv sync --active --group format --all-extras commands = - uv run --active ruff check --fix {[vars]all_path} - uv run --active ruff format {[vars]all_path} + uv run --active ruff check --fix {[vars]all_path} --exclude {[vars]src_path}/lib + uv run --active ruff format {[vars]all_path} --exclude {[vars]src_path}/lib [testenv:lint] diff --git a/uv.lock b/uv.lock index d37b3c7..ab38dc9 100644 --- a/uv.lock +++ b/uv.lock @@ -22,8 +22,8 @@ name = "anyio" version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } wheels = [ @@ -44,7 +44,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "python_full_version >= '3.12' and implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -147,7 +147,7 @@ name = "charm-json" version = "0.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charm-api", marker = "python_full_version >= '3.12'" }, + { name = "charm-api" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/11/a7adf8bffcc97d1e2f4dc4f8e6411a51871fe6931dabc34bbb86c582600f/charm_json-0.1.2.tar.gz", hash = "sha256:f91255858cd6721ec491d09450f37a0cdecf1addbfcfbd41f3e27c58dd95092e", size = 3208, upload-time = "2026-04-06T12:42:53.702Z" } wheels = [ @@ -159,14 +159,14 @@ name = "charm-refresh" version = "3.1.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charm-api", marker = "python_full_version >= '3.12'" }, - { name = "charm-json", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "lightkube", marker = "python_full_version >= '3.12'" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pyyaml", marker = "python_full_version >= '3.12'" }, - { name = "tomli", marker = "python_full_version >= '3.12'" }, + { name = "charm-api" }, + { name = "charm-json" }, + { name = "httpx" }, + { name = "lightkube" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/44/108b0623d344f024838456a7fd31b883637d6c3c936fa8a53167447fb8d3/charm_refresh-3.1.1.4.tar.gz", hash = "sha256:d33f66e5b65fd356a30bfbc634710e5538086199da93c30c86246370a66570be", size = 33278, upload-time = "2026-04-06T14:09:50.387Z" } wheels = [ @@ -178,9 +178,9 @@ name = "charmlibs-interfaces-tls-certificates" version = "1.8.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "python_full_version >= '3.12'" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "cryptography" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/b0/24342009af662f52f83d163852d43d53e9e0c3fb629feeeda5eeb739c03e/charmlibs_interfaces_tls_certificates-1.8.3.tar.gz", hash = "sha256:6bc35ee330681f2e0c58cf67e41133b144f3281f73d27293714c9ca918ee7cbc", size = 110247, upload-time = "2026-06-05T10:08:01.55Z" } wheels = [ @@ -192,7 +192,7 @@ name = "charmlibs-pathops" version = "1.3.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/1f/37cd5c74fd6adbb01f4947651af55b8ff8461eed034de3f6d018988022ab/charmlibs_pathops-1.3.0.post0.tar.gz", hash = "sha256:36ac11b0c7c55b71341a549a1803f5c8d61c2279204862b8d50736041b782f59", size = 22140, upload-time = "2026-06-16T05:39:49.177Z" } wheels = [ @@ -204,13 +204,13 @@ name = "charmlibs-rollingops" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, - { name = "charmlibs-pathops", marker = "python_full_version >= '3.12'" }, - { name = "dpcharmlibs-interfaces", marker = "python_full_version >= '3.12'" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "shortuuid", marker = "python_full_version >= '3.12'" }, - { name = "tenacity", version = "9.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "charmlibs-interfaces-tls-certificates" }, + { name = "charmlibs-pathops" }, + { name = "dpcharmlibs-interfaces" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, + { name = "shortuuid" }, + { name = "tenacity", version = "9.1.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/6c/b2/4959afe13622b68faab2827c493b090f3e84a786c2d4a284803f55a18a76/charmlibs_rollingops-1.1.1.tar.gz", hash = "sha256:831457d2ef12117794f89a483ee9a5c347cb209a87dafe82907e9dede7d4641c", size = 91238, upload-time = "2026-05-27T15:10:05.592Z" } wheels = [ @@ -222,7 +222,7 @@ name = "charmlibs-snap" version = "1.0.1.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/33/5043f1dd01e485e1c855516e41694fae48f48e94dd52e6081e5b61eee649/charmlibs_snap-1.0.1.post0.tar.gz", hash = "sha256:1e8da752ee405aa981665da8c4772016236baf80d2d902b02ec8c1c640fb2844", size = 29393, upload-time = "2026-06-16T05:39:54.911Z" } wheels = [ @@ -486,7 +486,7 @@ name = "cryptography" version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -542,9 +542,9 @@ name = "data-platform-helpers" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, + { name = "rich" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/05/9cb4b743817ae3c9bff658d5ad56a2d28907d242c2a0e2308415273f7c98/data_platform_helpers-1.1.0.tar.gz", hash = "sha256:7539ef1c1047fec36ee8298aa4c75fe2bb36419544193b691805c12ca407a797", size = 42929, upload-time = "2026-06-17T14:18:32.435Z" } wheels = [ @@ -556,7 +556,7 @@ name = "deprecated" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt", marker = "python_full_version < '3.9'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ @@ -568,8 +568,8 @@ name = "dpcharmlibs-interfaces" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/41/4cfa520d62c0aa53804599bf74506184eadc7c1e4803616383ec6bd3ab39/dpcharmlibs_interfaces-1.0.0.tar.gz", hash = "sha256:a177653019781a7a165be52cf4293134ee58912cd93686082cdd82da347a3bed", size = 203809, upload-time = "2026-03-24T17:38:47.136Z" } wheels = [ @@ -590,8 +590,8 @@ name = "h2" version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack", marker = "python_full_version >= '3.12'" }, - { name = "hyperframe", marker = "python_full_version >= '3.12'" }, + { name = "hpack" }, + { name = "hyperframe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -612,8 +612,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "h11", marker = "python_full_version >= '3.12'" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -625,10 +625,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "httpcore", marker = "python_full_version >= '3.12'" }, - { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -637,7 +637,7 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2", marker = "python_full_version >= '3.12'" }, + { name = "h2" }, ] [[package]] @@ -645,10 +645,10 @@ name = "httpx-ws" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "httpcore", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "wsproto", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "wsproto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" } wheels = [ @@ -681,7 +681,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ @@ -696,7 +696,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -717,7 +717,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "python_full_version >= '3.12'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -729,10 +729,10 @@ name = "lightkube" version = "0.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", extra = ["http2"], marker = "python_full_version >= '3.12'" }, - { name = "httpx-ws", marker = "python_full_version >= '3.12'" }, - { name = "lightkube-models", marker = "python_full_version >= '3.12'" }, - { name = "pyyaml", marker = "python_full_version >= '3.12'" }, + { name = "httpx", extra = ["http2"] }, + { name = "httpx-ws" }, + { name = "lightkube-models" }, + { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/9329c34cab5fb5738ea54c2af1952f32f5ecedbed03779d1b82eb329ba8a/lightkube-0.21.0.tar.gz", hash = "sha256:720910200de4bd88e0d7b5d2edfb45a4f6be292798b8776d7b551ef5a5ccdc15", size = 36752, upload-time = "2026-05-13T08:26:06.108Z" } wheels = [ @@ -753,7 +753,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.12'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -873,8 +873,8 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "deprecated", marker = "python_full_version < '3.9'" }, - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "deprecated" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/8d/1f5a45fbcb9a7d87809d460f09dc3399e3fbd31d7f3e14888345e9d29951/opentelemetry_api-1.33.1.tar.gz", hash = "sha256:1c6055fc0a2d3f23a50c7e17e16ef75ad489345fd3df1f8b8af7c0bbf8a109e8", size = 65002, upload-time = "2025-05-16T18:52:41.146Z" } wheels = [ @@ -889,8 +889,8 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "typing-extensions", marker = "python_full_version == '3.9.*'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ @@ -906,7 +906,7 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", ] dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ @@ -922,12 +922,12 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pyyaml" }, + { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/98/84789a5e15ad76e043301bbd70b4d39cffaa717ae6eecb662fd0dd0cc7af/ops-2.23.2.tar.gz", hash = "sha256:a69b0c5bc65ebd91720fc96459e81b969df851f3a6c9c855ee73de7004205987", size = 528074, upload-time = "2026-02-11T03:58:15.565Z" } @@ -944,9 +944,9 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", ] dependencies = [ - { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml" }, + { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/60/ad398d889fd03b1b4f950fad54ad4d0cf7a81fde21f9866a8139c8f03684/ops-3.7.1.tar.gz", hash = "sha256:1765bf6d1cff376ea27608542e183b055c89f2c5f54bca602072bcc817195abc", size = 582424, upload-time = "2026-05-28T04:13:43.906Z" } wheels = [ @@ -984,21 +984,27 @@ dependencies = [ ] [package.optional-dependencies] +db-driver = [ + { name = "psycopg2", version = "2.9.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "psycopg2", version = "2.9.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] postgresql = [ { name = "charm-refresh", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-pathops", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-rollingops", marker = "python_full_version >= '3.12'" }, - { name = "charmlibs-snap", marker = "python_full_version >= '3.12'" }, { name = "data-platform-helpers", marker = "python_full_version >= '3.12'" }, { name = "httpx", marker = "python_full_version >= '3.12'" }, { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "psycopg2", version = "2.9.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "psycopg2", version = "2.9.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pydantic", marker = "python_full_version >= '3.12'" }, { name = "requests", marker = "python_full_version >= '3.12'" }, { name = "tomli", marker = "python_full_version >= '3.12'" }, ] +vm = [ + { name = "charmlibs-snap", marker = "python_full_version >= '3.12'" }, + { name = "psutil", marker = "python_full_version >= '3.12'" }, + { name = "pysyncobj", marker = "python_full_version >= '3.12'" }, +] [package.dev-dependencies] format = [ @@ -1019,18 +1025,20 @@ requires-dist = [ { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.8.3" }, { name = "charmlibs-pathops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.0.1" }, { name = "charmlibs-rollingops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.1.1" }, - { name = "charmlibs-snap", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.0.1" }, + { name = "charmlibs-snap", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=1.0.1" }, { name = "data-platform-helpers", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=0.1.7" }, { name = "httpx", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "jinja2", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=3.1.6" }, { name = "ops", specifier = ">=2.0.0" }, - { name = "psycopg2", marker = "extra == 'postgresql'", specifier = ">=2.9.10" }, + { name = "psutil", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=7.2.2" }, + { name = "psycopg2", marker = "extra == 'db-driver'", specifier = ">=2.9.10" }, { name = "pydantic", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=2.0" }, + { name = "pysyncobj", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=0.3.15" }, { name = "requests", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "tomli", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, ] -provides-extras = ["postgresql"] +provides-extras = ["postgresql", "vm", "db-driver"] [package.metadata.requires-dev] format = [{ name = "ruff", marker = "python_full_version >= '3.12'", specifier = "==0.15.18" }] @@ -1043,6 +1051,34 @@ unit = [ { name = "pytest", marker = "python_full_version >= '3.12'", specifier = "==9.1.1" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "psycopg2" version = "2.9.10" @@ -1096,10 +1132,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "python_full_version >= '3.12'" }, - { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -1111,7 +1147,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -1245,16 +1281,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pysyncobj" +version = "0.3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/48/5f96d7f8c46446f1a1a660a594c06906b59185a1b19e334180f72fc0dc0e/pysyncobj-0.3.15.tar.gz", hash = "sha256:7b98f523bc7810203763f5a6ef569ff2dd5de889fb7f5f0ccc8c8b6c87df9680", size = 41987, upload-time = "2026-03-17T21:50:24.39Z" } + [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "iniconfig", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pluggy", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1346,10 +1388,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.12'" }, - { name = "idna", marker = "python_full_version >= '3.12'" }, - { name = "urllib3", marker = "python_full_version >= '3.12'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -1361,8 +1403,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -1533,7 +1575,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -1694,7 +1736,7 @@ name = "wsproto" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "python_full_version >= '3.12'" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ From fcfbcb78ce2aa681d31e691fb972a17222dad309 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Tue, 30 Jun 2026 22:59:58 +0300 Subject: [PATCH 27/31] K8s passes unit names --- single_kernel_postgresql/managers/patroni.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 3b76fc4..cc50d61 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -597,6 +597,9 @@ def switchover( ) -> None: """Trigger a switchover.""" # Try to trigger the switchover. + if candidate is not None: + candidate = candidate.replace("/", "-") + for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): with attempt: current_primary = ( From 1bc58e493e347b1030ecd569f24428ffe4ed59ee Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Wed, 1 Jul 2026 02:19:07 +0300 Subject: [PATCH 28/31] Dead code --- single_kernel_postgresql/managers/patroni.py | 29 -------------------- 1 file changed, 29 deletions(-) diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index cc50d61..3ff533d 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -11,7 +11,6 @@ import glob import logging import os -import pathlib import re import subprocess from contextlib import suppress @@ -24,7 +23,6 @@ import psycopg2 import psycopg2.extras import requests -import tomli from data_platform_helpers.advanced_statuses import StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope from httpx import BasicAuth @@ -188,11 +186,6 @@ def cluster_members(self) -> set: except Exception: return set() - def get_postgresql_version(self) -> str: - """Return the PostgreSQL version from the system.""" - with pathlib.Path("refresh_versions.toml").open("rb") as file: - return tomli.load(file)["workload"] - @cached_property def cached_cluster_status(self): """Cached cluster status.""" @@ -676,16 +669,6 @@ def reload_patroni_configuration(self): ) logger.debug("API reload_patroni_configuration: %s (%s)", r, r.elapsed.total_seconds()) - def is_patroni_running(self) -> bool: - """Check if the Patroni service is running.""" - try: - cache = snap.SnapCache() - selected_snap = cache["charmed-postgresql"] - return selected_snap.services["patroni"]["active"] - except snap.SnapError as e: - logger.debug(f"Failed to check Patroni service: {e}") - return False - def restart_patroni(self) -> bool: """Restart Patroni. @@ -715,18 +698,6 @@ def restart_postgresql(self) -> None: ) logger.debug("API restart_postgresql: %s (%s)", r, r.elapsed.total_seconds()) - @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) - def reinitialize_postgresql(self) -> None: - """Reinitialize PostgreSQL.""" - logger.debug("Reinitializing PostgreSQL...") - r = requests.post( - f"{self.state.patroni_url}/reinitialize", - verify=self.verify, - auth=self._patroni_auth, - timeout=API_REQUEST_TIMEOUT, - ) - logger.debug("API reinitialize_postgresql: %s (%s)", r, r.elapsed.total_seconds()) - @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) def bulk_update_parameters_controller_by_patroni( self, parameters: dict[str, Any], base_parameters: dict[str, Any] | None From f1583b39612e3b9ef207ad42eae4960585cbaf14 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Wed, 1 Jul 2026 19:34:58 +0300 Subject: [PATCH 29/31] Unit tests and tweaks --- pyproject.toml | 1 + single_kernel_postgresql/managers/patroni.py | 6 +- tests/unit/test_patroni.py | 879 +++++++++++++++++++ uv.lock | 11 + 4 files changed, 895 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_patroni.py diff --git a/pyproject.toml b/pyproject.toml index 1cf9fa0..e113025 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ vm = [ "pysyncobj>=0.3.15; python_version >= '3.12'", "psutil>=7.2.2; python_version >= '3.12'", "charmlibs-snap>=1.0.1; python_version >= '3.12'", + "charmlibs-systemd>=1.0.0.post0; python_version >= '3.12'" ] db-driver = [ "psycopg2>=2.9.10", diff --git a/single_kernel_postgresql/managers/patroni.py b/single_kernel_postgresql/managers/patroni.py index 3ff533d..3cd83d2 100644 --- a/single_kernel_postgresql/managers/patroni.py +++ b/single_kernel_postgresql/managers/patroni.py @@ -12,7 +12,6 @@ import logging import os import re -import subprocess from contextlib import suppress from functools import cached_property from typing import Any, Literal, TypedDict @@ -20,6 +19,7 @@ # Platform specific imports with suppress(ImportError): from charmlibs import snap + from charmlibs.systemd import daemon_reload import psycopg2 import psycopg2.extras import requests @@ -412,6 +412,8 @@ def member_inactive(self) -> bool: True if services is not running, starting or restarting. Retries over a period of 60 seconds times to allow server time to start up. """ + if not self.workload.is_patroni_running(): + return True try: response = self.cached_patroni_health except RetryError: @@ -828,7 +830,7 @@ def update_patroni_restart_condition(self, new_condition: str) -> None: logger.debug(f"new patroni service file: {new_patroni_service}") with open(VM_PATRONI_SERVICE_DEFAULT_PATH, "w") as patroni_service_file: patroni_service_file.write(new_patroni_service) - subprocess.run(["/bin/systemctl", "daemon-reload"]) + daemon_reload() @property def primary_endpoint_ready(self) -> bool: diff --git a/tests/unit/test_patroni.py b/tests/unit/test_patroni.py new file mode 100644 index 0000000..19a899c --- /dev/null +++ b/tests/unit/test_patroni.py @@ -0,0 +1,879 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +from single_kernel_postgresql.workload.vm import VMWorkload +from single_kernel_postgresql.workload.k8s import K8sWorkload +from single_kernel_postgresql.core.state import CharmState +from single_kernel_postgresql.managers.patroni import PatroniManager +import os +from pathlib import Path +from unittest.mock import MagicMock, Mock, PropertyMock, call, mock_open, patch, sentinel + +import pytest +import requests +from charmlibs import snap +from pysyncobj.utility import UtilityException +from single_kernel_postgresql.config.enums import Substrates +from single_kernel_postgresql.config.exceptions import ( + SwitchoverFailedError, + SwitchoverNotSyncError, +) +from single_kernel_postgresql.config.literals import API_REQUEST_TIMEOUT, REWIND_USER +from tenacity import RetryError, stop_after_delay, wait_fixed + +PATRONI_SERVICE = "patroni" +CREATE_CLUSTER_CONF_PATH = "/var/snap/charmed-postgresql/current/etc/postgresql/postgresql.conf" + + +# This method will be used by the mock to replace requests.get +def mocked_requests_get(*args, **kwargs): + class MockResponse: + def __init__(self, json_data): + self.json_data = json_data + + def json(self): + return self.json_data + + elapsed = Mock() + + data = { + "http://server1/cluster": { + "members": [{"name": "postgresql-0", "host": "1.1.1.1", "role": "leader", "lag": "1"}] + }, + "http://server1/health": {"state": "running"}, + "http://server4/cluster": {"members": []}, + } + if args[0] in data: + return MockResponse(data[args[0]]) + + raise requests.exceptions.Timeout() + + +@pytest.fixture(autouse=True) +def peers_ips(): + peers_ips = {"2.2.2.2", "3.3.3.3"} + yield peers_ips + + +@pytest.fixture(autouse=True) +def patroni(peers_ips, substrate): + mock_charm = Mock() + mock_container = Mock() + workload = VMWorkload(".") if substrate == Substrates.VM else K8sWorkload(".", mock_container) + with patch( + "single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version", + return_value="16.6", + ): + patroni = PatroniManager( + state=CharmState(charm=mock_charm, substrate=substrate), workload=workload + ) + yield patroni + + +# def test_get_member_ip(peers_ips, patroni): +# with patch( +# "cluster.parallel_patroni_get_request", return_value=None +# ) as _parallel_patroni_get_request: +# # No IP if no members +# assert patroni.get_member_ip(patroni.member_name) is None + +# _parallel_patroni_get_request.return_value = { +# "members": [ +# { +# "name": "postgresql-1", +# "host": "2.2.2.2", +# }, +# { +# "name": "postgresql-0", +# "host": "1.1.1.1", +# }, +# ] +# } +# assert patroni.get_member_ip(patroni.member_name) == "1.1.1.1" + + +# def test_get_patroni_health(peers_ips, patroni): +# with ( +# patch("cluster.stop_after_delay", new_callable=PropertyMock) as _stop_after_delay, +# patch("cluster.wait_fixed", new_callable=PropertyMock) as _wait_fixed, +# patch("charm.Patroni._patroni_url", new_callable=PropertyMock) as _patroni_url, +# patch("requests.get", side_effect=mocked_requests_get) as _get, +# ): +# # Test when the Patroni API is reachable. +# _patroni_url.return_value = "http://server1" +# health = patroni.get_patroni_health() + +# # Check needed to ensure a fast charm deployment. +# _stop_after_delay.assert_called_once_with(60) +# _wait_fixed.assert_called_once_with(7) + +# assert health == {"state": "running"} + +# # Test when the Patroni API is not reachable. +# _patroni_url.return_value = "http://server2" +# with pytest.raises(RetryError): +# patroni.get_patroni_health() +# assert False + + +# def test_get_postgresql_version(peers_ips, patroni): +# assert patroni.get_postgresql_version() == "16.14" + + +# def test_dict_to_hba_string(patroni): +# mock_data = { +# "ldapbasedn": "dc=example,dc=net", +# "ldapbinddn": "cn=serviceuser,dc=example,dc=net", +# "ldapbindpasswd": "password", +# "ldaptls": False, +# "ldapurl": "ldap://0.0.0.0:3893", +# } + +# assert patroni._dict_to_hba_string(mock_data) == ( +# 'ldapbasedn="dc=example,dc=net" ' +# 'ldapbinddn="cn=serviceuser,dc=example,dc=net" ' +# 'ldapbindpasswd="password" ' +# "ldaptls=0 " +# 'ldapurl="ldap://0.0.0.0:3893"' +# ) + + +# def test_get_primary(peers_ips, patroni): +# with ( +# patch( +# "cluster.parallel_patroni_get_request", return_value=None +# ) as _parallel_patroni_get_request, +# ): +# # No primary if no members +# assert patroni.get_primary() is None + +# _parallel_patroni_get_request.return_value = { +# "members": [ +# { +# "name": "postgresql-1", +# "role": "replica", +# }, +# { +# "name": "postgresql-0", +# "role": "leader", +# }, +# ] +# } +# # Test using the current Patroni URL. +# assert patroni.get_primary() == "postgresql-0" + +# # Test requesting the primary in the unit name pattern. +# assert patroni.get_primary(unit_name_pattern=True) == "postgresql/0" + + +# def test_is_creating_backup(peers_ips, patroni): +# with patch("charm.Patroni.cluster_status") as _cluster_status: +# # Test when one member is creating a backup. +# _cluster_status.return_value = [ +# {"name": "postgresql-0"}, +# {"name": "postgresql-1", "tags": {"is_creating_backup": True}}, +# ] +# assert patroni.is_creating_backup + +# # Test when no member is creating a backup. +# del patroni.cached_cluster_status +# _cluster_status.return_value = [{"name": "postgresql-0"}, {"name": "postgresql-1"}] +# assert not patroni.is_creating_backup + + +# def test_is_replication_healthy(peers_ips, patroni): +# with ( +# patch("requests.get") as _get, +# patch("charm.Patroni.get_primary") as _get_primary, +# patch("charm.Patroni.get_standby_leader") as _get_standby_leader, +# patch("charm.Patroni.get_member_ip"), +# patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), +# ): +# # Test when replication is healthy. +# _get.return_value.status_code = 200 +# assert patroni.is_replication_healthy() + +# # Test when replication is not healthy. +# _get.side_effect = [ +# MagicMock(status_code=200), +# MagicMock(status_code=200), +# MagicMock(status_code=503), +# ] +# assert not patroni.is_replication_healthy() + +# # Test no primary +# _get.side_effect = None +# _get.return_value.status_code = 200 +# _get_primary.return_value = None +# _get_standby_leader.return_value = None +# assert not patroni.is_replication_healthy() + +# # Standby leader +# _get_standby_leader.return_value = "standby" +# assert patroni.is_replication_healthy() + + +# def test_is_member_isolated(peers_ips, patroni): +# with ( +# patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), +# patch("cluster.wait_fixed", return_value=wait_fixed(0)), +# patch("requests.get", side_effect=mocked_requests_get) as _get, +# patch("charm.Patroni._patroni_url", new_callable=PropertyMock) as _patroni_url, +# ): +# # Test when it wasn't possible to connect to the Patroni API. +# _patroni_url.return_value = "http://server3" +# assert not patroni.is_member_isolated + +# # Test when the member isn't isolated from the cluster. +# _patroni_url.return_value = "http://server1" +# assert not patroni.is_member_isolated + +# # Test when the member is isolated from the cluster. +# _patroni_url.return_value = "http://server4" +# assert patroni.is_member_isolated + + +# def test_render_patroni_yml_file(peers_ips, patroni): +# with ( +# patch( +# "relations.async_replication.PostgreSQLAsyncReplication.get_partner_addresses", +# return_value=["2.2.2.2", "3.3.3.3"], +# ), +# patch("charm.Patroni.get_postgresql_version") as _get_postgresql_version, +# patch("cluster.render_file") as _render_file, +# patch( +# "charm.PostgresqlOperatorCharm.listen_ips", +# new_callable=PropertyMock, +# return_value=["1.1.1.1", "192.168.0.1"], +# ), +# ): +# _get_postgresql_version.return_value = "16.6" + +# # Define variables to render in the template. +# member_name = "postgresql-0" +# scope = "postgresql" +# superuser_password = "fake-superuser-password" +# replication_password = "fake-replication-password" +# rewind_password = "fake-rewind-password" +# raft_password = "fake-raft-password" +# patroni_password = "fake-patroni-password" +# postgresql_version = "16" + +# # Get the expected content from a file. +# with open("templates/patroni.yml.j2") as file: +# contents = file.read() +# template = Template(contents) +# # Setup a mock for the `open` method, set returned data to patroni.yml template. +# mock = mock_open(read_data=contents) + +# expected_content = template.render( +# conf_path=PATRONI_CONF_PATH, +# data_path=POSTGRESQL_DATA_DIR, +# log_path=PATRONI_LOGS_PATH, +# postgresql_log_path=POSTGRESQL_LOGS_PATH, +# wal_dir=LOGS_DATA_DIR, +# member_name=member_name, +# partner_addrs=["2.2.2.2", "3.3.3.3"], +# peers_ips=sorted(peers_ips), +# scope=scope, +# self_ip=patroni.unit_ip, +# listen_ips=["1.1.1.1", "192.168.0.1"], +# superuser="operator", +# superuser_password=superuser_password, +# replication_password=replication_password, +# rewind_user=REWIND_USER, +# rewind_password=rewind_password, +# version=postgresql_version, +# synchronous_node_count=0, +# maximum_lag_on_failover=1048576, +# raft_password=raft_password, +# patroni_password=patroni_password, +# instance_password_encryption="scram-sha-256", +# slots={}, +# ) + +# # Patch the `open` method with our mock. +# with patch("builtins.open", mock, create=True): +# # Call the method. +# patroni.render_patroni_yml_file() + +# # Check the template is opened read-only in the call to open. +# assert mock.call_args_list[0][0] == ("templates/patroni.yml.j2",) +# # Ensure the correct rendered template is sent to _render_file method. +# _render_file.assert_called_once_with( +# Substrates.VM, +# "/var/snap/charmed-postgresql/current/etc/patroni/patroni.yaml", +# expected_content, +# 0o600, +# ) + + +# def test_start_patroni(peers_ips, patroni): +# with patch("charm.snap.SnapCache") as _snap_cache: +# _cache = _snap_cache.return_value +# _selected_snap = _cache.__getitem__.return_value +# _selected_snap.start.side_effect = [None, snap.SnapError] + +# # Test a success scenario. +# assert patroni.start_patroni() +# _cache.__getitem__.assert_called_once_with("charmed-postgresql") +# _selected_snap.start.assert_called_once_with(services=[PATRONI_SERVICE]) + +# # Test a fail scenario. +# assert not patroni.start_patroni() + + +# def test_stop_patroni(peers_ips, patroni): +# with patch("charm.snap.SnapCache") as _snap_cache: +# _cache = _snap_cache.return_value +# _selected_snap = _cache.__getitem__.return_value +# _selected_snap.stop.side_effect = [None, snap.SnapError] +# _selected_snap.services.__getitem__.return_value.__getitem__.return_value = False + +# # Test a success scenario. +# assert patroni.stop_patroni() +# _cache.__getitem__.assert_called_once_with("charmed-postgresql") +# _selected_snap.stop.assert_called_once_with(services=[PATRONI_SERVICE]) +# _selected_snap.services.__getitem__.return_value.__getitem__.assert_called_once_with( +# "active" +# ) + +# # Test a fail scenario. +# assert not patroni.stop_patroni() + + +def test_switchover(patroni): + with ( + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("requests.post") as _post, + patch( + "single_kernel_postgresql.managers.patroni.PatroniManager.get_primary" + ) as _get_primary, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + ): + # Test a successful switchover. + _get_primary.side_effect = ["postgresql-k8s-0", "postgresql-k8s-1"] + response = _post.return_value + response.status_code = 200 + patroni.switchover() + _post.assert_called_once_with( + "https://endpoint:8008/switchover", + json={"leader": "postgresql-k8s-0"}, + verify=patroni.verify, + auth=patroni._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + + # Test a successful switchover with a candidate name. + _post.reset_mock() + _get_primary.side_effect = ["postgresql-k8s-0", "postgresql-k8s-2"] + patroni.switchover("postgresql-k8s/2") + _post.assert_called_once_with( + "https://endpoint:8008/switchover", + json={"leader": "postgresql-k8s-0", "candidate": "postgresql-k8s-2"}, + verify=patroni.verify, + auth=patroni._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + + # Test failed switchovers. + _post.reset_mock() + _get_primary.side_effect = ["postgresql-k8s-0", "postgresql-k8s-1"] + with pytest.raises(SwitchoverFailedError): + patroni.switchover("postgresql-k8s/2") + assert False + _post.assert_called_once_with( + "https://endpoint:8008/switchover", + json={"leader": "postgresql-k8s-0", "candidate": "postgresql-k8s-2"}, + verify=patroni.verify, + auth=patroni._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + + _post.reset_mock() + _get_primary.side_effect = ["postgresql-k8s-0", "postgresql-k8s-2"] + response.status_code = 400 + with pytest.raises(SwitchoverFailedError): + patroni.switchover("postgresql-k8s/2") + assert False + _post.assert_called_once_with( + "https://endpoint:8008/switchover", + json={"leader": "postgresql-k8s-0", "candidate": "postgresql-k8s-2"}, + verify=patroni.verify, + auth=patroni._patroni_auth, + timeout=API_REQUEST_TIMEOUT, + ) + + # Test candidate, not sync + response = _post.return_value + response.status_code = 412 + response.text = "candidate name does not match with sync_standby" + with pytest.raises(SwitchoverNotSyncError): + patroni.switchover("candidate") + assert False + + +def test_update_synchronous_node_count(peers_ips, patroni): + with ( + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("single_kernel_postgresql.managers.patroni.wait_fixed", return_value=wait_fixed(0)), + patch("requests.patch") as _patch, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.synchronous_configuration", + new_callable=PropertyMock, + return_value=sentinel.synchronous_configuration, + ), + ): + response = _patch.return_value + response.status_code = 200 + + patroni.update_synchronous_node_count() + + _patch.assert_called_once_with( + "https://endpoint:8008/config", + json=sentinel.synchronous_configuration, + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + # Test when the request fails. + response.status_code = 500 + with pytest.raises(RetryError): + patroni.update_synchronous_node_count() + assert False + + +def test_set_max_timelines_history(peers_ips, patroni): + with ( + patch("requests.patch") as _patch, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + ): + patroni.set_max_timelines_history() + + _patch.assert_called_once_with( + "https://endpoint:8008/config", + json={"max_timelines_history": 50}, + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + +# def test_configure_patroni_on_unit(peers_ips, patroni): +# with ( +# patch("os.makedirs") as _makedirs, +# patch("os.chmod") as _chmod, +# patch("builtins.open") as _open, +# patch("os.chown") as _chown, +# patch("pwd.getpwnam") as _getpwnam, +# ): +# _getpwnam.return_value.pw_uid = sentinel.uid +# _getpwnam.return_value.pw_gid = sentinel.gid + +# patroni.configure_patroni_on_unit() + +# assert _getpwnam.call_args_list == [call("_daemon_"), call("_daemon_")] +# _makedirs.assert_called_once_with(POSTGRESQL_DATA_DIR, exist_ok=True) + +# # Parent and data dir are both chowned (parent enables Patroni reinit rename/remove). +# _chown.assert_any_call( +# os.path.dirname(POSTGRESQL_DATA_DIR), +# uid=sentinel.uid, +# gid=sentinel.gid, +# ) +# _chown.assert_any_call( +# POSTGRESQL_DATA_DIR, +# uid=sentinel.uid, +# gid=sentinel.gid, +# ) +# assert _chown.call_count == 2 + +# _open.assert_called_once_with(CREATE_CLUSTER_CONF_PATH, "a") +# _chmod.assert_called_once_with(POSTGRESQL_DATA_DIR, 448) + + +def test_member_started_true(peers_ips, patroni): + with ( + patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("single_kernel_postgresql.managers.patroni.wait_fixed", return_value=wait_fixed(0)), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + patch( + "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", return_value=True + ), + patch( + "single_kernel_postgresql.workload.k8s.K8sWorkload.is_patroni_running", + return_value=True, + ), + ): + _get.return_value.json.return_value = {"state": "running"} + + assert patroni.member_started + + _get.assert_called_once_with( + "https://endpoint:8008/health", + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + +def test_member_started_false(peers_ips, patroni): + with ( + patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("single_kernel_postgresql.managers.patroni.wait_fixed", return_value=wait_fixed(0)), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + patch( + "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", return_value=True + ), + patch( + "single_kernel_postgresql.workload.k8s.K8sWorkload.is_patroni_running", + return_value=True, + ), + ): + _get.return_value.json.return_value = {"state": "stopped"} + + assert not patroni.member_started + + _get.assert_called_once_with( + "https://endpoint:8008/health", + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + +def test_member_started_error(peers_ips, patroni): + with ( + patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("single_kernel_postgresql.managers.patroni.wait_fixed", return_value=wait_fixed(0)), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + patch( + "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", return_value=True + ), + patch( + "single_kernel_postgresql.workload.k8s.K8sWorkload.is_patroni_running", + return_value=True, + ), + ): + _get.side_effect = Exception + + assert not patroni.member_started + + _get.assert_called_once_with( + "https://endpoint:8008/health", + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + +def test_member_inactive_true(peers_ips, patroni): + with ( + patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("single_kernel_postgresql.managers.patroni.wait_fixed", return_value=wait_fixed(0)), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + patch( + "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", return_value=True + ), + patch( + "single_kernel_postgresql.workload.k8s.K8sWorkload.is_patroni_running", + return_value=True, + ), + ): + _get.return_value.json.return_value = {"state": "stopped"} + + assert patroni.member_inactive + + _get.assert_called_once_with( + "https://endpoint:8008/health", + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + +def test_member_inactive_false(peers_ips, patroni): + with ( + patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("single_kernel_postgresql.managers.patroni.wait_fixed", return_value=wait_fixed(0)), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + patch( + "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", return_value=True + ), + patch( + "single_kernel_postgresql.workload.k8s.K8sWorkload.is_patroni_running", + return_value=True, + ), + ): + _get.return_value.json.return_value = {"state": "starting"} + + assert not patroni.member_inactive + + _get.assert_called_once_with( + "https://endpoint:8008/health", + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + +def test_member_inactive_error(peers_ips, patroni): + with ( + patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("single_kernel_postgresql.managers.patroni.wait_fixed", return_value=wait_fixed(0)), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + patch( + "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", return_value=True + ), + patch( + "single_kernel_postgresql.workload.k8s.K8sWorkload.is_patroni_running", + return_value=True, + ), + ): + _get.side_effect = Exception + + assert patroni.member_inactive + + _get.assert_called_once_with( + "https://endpoint:8008/health", + verify=patroni.verify, + timeout=API_REQUEST_TIMEOUT, + auth=patroni._patroni_auth, + ) + + +def test_member_inactive_patroni_not_running(peers_ips, patroni): + with ( + patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch( + "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", + return_value=False, + ), + patch( + "single_kernel_postgresql.workload.k8s.K8sWorkload.is_patroni_running", + return_value=False, + ), + ): + # When the Patroni snap service is not running, the member is inactive + # and we must not waste time querying the Patroni REST API. + assert patroni.member_inactive + + _get.assert_not_called() + + +def test_patroni_logs(substrate, patroni): + if substrate == Substrates.K8S: + pytest.skip("VM only") + return + + with patch("single_kernel_postgresql.managers.patroni.snap.SnapCache") as _snap_cache: + # Test when the logs are returned successfully. + logs = _snap_cache.return_value.__getitem__.return_value.logs + logs.return_value = "fake-logs" + assert patroni.patroni_logs() == "fake-logs" + + # Test the charm fails to get the logs. + logs.side_effect = snap.SnapError + assert patroni.patroni_logs() == "" + + +def test_last_postgresql_logs(substrate, patroni): + if substrate == Substrates.K8S: + pytest.skip("VM only") + return + + with ( + patch("glob.glob") as _glob, + patch("builtins.open", mock_open(read_data="fake-logs")) as _open, + patch( + "single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version", + return_value="16.6", + ), + ): + # Test when there are no files to read. + assert patroni.last_postgresql_logs() == "" + _open.assert_not_called() + + # Test when there are multiple files in the logs directory. + _glob.return_value = [ + "/var/snap/charmed-postgresql/common/var/log/postgresql/postgresql.log.1", + "/var/snap/charmed-postgresql/common/var/log/postgresql/postgresql.log.2", + "/var/snap/charmed-postgresql/common/var/log/postgresql/postgresql.log.3", + ] + assert patroni.last_postgresql_logs() == "fake-logs" + _open.assert_called_once_with( + "/var/snap/charmed-postgresql/common/var/log/postgresql/postgresql.log.3" + ) + + # Test when the charm fails to read the logs. + _open.reset_mock() + _open.side_effect = OSError + assert patroni.last_postgresql_logs() == "" + _open.assert_called_with( + "/var/snap/charmed-postgresql/common/var/log/postgresql/postgresql.log.3" + ) + + +def test_get_patroni_restart_condition(substrate, patroni): + if substrate == Substrates.K8S: + pytest.skip("VM only") + return + + mock = mock_open() + with patch("builtins.open", mock) as _open: + # Test when there is a restart condition set. + _open.return_value.__enter__.return_value.read.return_value = "Restart=always" + assert patroni.get_patroni_restart_condition() == "always" + + # Test when there is no restart condition set. + _open.return_value.__enter__.return_value.read.return_value = "" + with pytest.raises(RuntimeError): + patroni.get_patroni_restart_condition() + assert False + + +@pytest.mark.parametrize("new_restart_condition", ["on-success", "on-failure"]) +def test_update_patroni_restart_condition(substrate, patroni, new_restart_condition): + if substrate == Substrates.K8S: + pytest.skip("VM only") + return + + with ( + patch("builtins.open", mock_open(read_data="Restart=always")) as _open, + patch("single_kernel_postgresql.managers.patroni.daemon_reload") as _daemon_reload, + ): + _open.return_value.__enter__.return_value.read.return_value = "Restart=always" + patroni.update_patroni_restart_condition(new_restart_condition) + _open.return_value.__enter__.return_value.write.assert_called_once_with( + f"Restart={new_restart_condition}" + ) + _daemon_reload.assert_called_once_with() + + +def test_are_replicas_up(patroni): + with patch( + "single_kernel_postgresql.managers.patroni.PatroniManager.cluster_status" + ) as _cluster_status: + _cluster_status.return_value = [ + {"host": "1.1.1.1", "state": "running"}, + {"host": "2.2.2.2", "state": "streaming"}, + {"host": "3.3.3.3", "state": "other state"}, + ] + assert patroni.are_replicas_up() == {"1.1.1.1": True, "2.2.2.2": True, "3.3.3.3": False} + + # Return None on error + _cluster_status.side_effect = Exception + assert patroni.are_replicas_up() is None diff --git a/uv.lock b/uv.lock index aef8a7d..c7974cc 100644 --- a/uv.lock +++ b/uv.lock @@ -229,6 +229,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/71/fa658e05748a2ffd1185aeec43aefb286c33c2767e28919f0445b464244a/charmlibs_snap-1.0.1.post0-py3-none-any.whl", hash = "sha256:655041096952c63e92c8d25a6183149f0594d4888be4be62a9635cb96897aa01", size = 15416, upload-time = "2026-06-16T05:39:53.629Z" }, ] +[[package]] +name = "charmlibs-systemd" +version = "1.0.0.post0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/8b/b46c17c458b109dbb9590e994de765fedfa609cee93d6e7cfabe3d599adc/charmlibs_systemd-1.0.0.post0.tar.gz", hash = "sha256:0f978a103524fe1ef34c350930b29b45677ae5c175368e86cea7a1530ccaa0b5", size = 15819, upload-time = "2026-06-16T05:39:46.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/de/97cb49c150b8c1592483bde9370ce46462be8f6f9c3a8a52748f131fdc56/charmlibs_systemd-1.0.0.post0-py3-none-any.whl", hash = "sha256:9e7578b98344ab237fdc6e48491f98fd80239b8802feb72c5c0ff3a9f70019da", size = 4951, upload-time = "2026-06-16T05:39:45.389Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -1002,6 +1011,7 @@ postgresql = [ ] vm = [ { name = "charmlibs-snap", marker = "python_full_version >= '3.12'" }, + { name = "charmlibs-systemd", marker = "python_full_version >= '3.12'" }, { name = "psutil", marker = "python_full_version >= '3.12'" }, { name = "pysyncobj", marker = "python_full_version >= '3.12'" }, ] @@ -1026,6 +1036,7 @@ requires-dist = [ { name = "charmlibs-pathops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.0.1" }, { name = "charmlibs-rollingops", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=1.1.1" }, { name = "charmlibs-snap", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=1.0.1" }, + { name = "charmlibs-systemd", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=1.0.0.post0" }, { name = "data-platform-helpers", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=0.1.7" }, { name = "httpx", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "jinja2", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=3.1.6" }, From e97be7ef16e0fb1b10a71f36724f1f8d008e3a1c Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Thu, 2 Jul 2026 16:16:48 +0300 Subject: [PATCH 30/31] VM tests --- tests/unit/test_config_manager.py | 232 ++++++ ...est_patroni.py => test_patroni_manager.py} | 680 +++++++++--------- 2 files changed, 577 insertions(+), 335 deletions(-) create mode 100644 tests/unit/test_config_manager.py rename tests/unit/{test_patroni.py => test_patroni_manager.py} (59%) diff --git a/tests/unit/test_config_manager.py b/tests/unit/test_config_manager.py new file mode 100644 index 0000000..9538ff3 --- /dev/null +++ b/tests/unit/test_config_manager.py @@ -0,0 +1,232 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. +from unittest.mock import MagicMock, Mock, PropertyMock, mock_open, patch, sentinel + +import pytest +from single_kernel_postgresql.config.enums import Substrates +from single_kernel_postgresql.config.literals import REWIND_USER +from single_kernel_postgresql.core.state import CharmState +from single_kernel_postgresql.managers.config import ConfigManager +from single_kernel_postgresql.workload.vm import VMWorkload +from single_kernel_postgresql.workload.k8s import K8sWorkload + + +@pytest.fixture(autouse=True) +def config(substrate): + mock_charm = Mock() + mock_container = Mock() + workload = VMWorkload(".") if substrate == Substrates.VM else K8sWorkload(".", mock_container) + with patch( + "single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version", + return_value="16.6", + ): + config = ConfigManager( + state=CharmState(charm=mock_charm, substrate=substrate), workload=workload + ) + yield config + + +def test_dict_to_hba_string(config): + mock_data = { + "ldapbasedn": "dc=example,dc=net", + "ldapbinddn": "cn=serviceuser,dc=example,dc=net", + "ldapbindpasswd": "password", + "ldaptls": False, + "ldapurl": "ldap://0.0.0.0:3893", + } + + assert config._dict_to_hba_string(mock_data) == ( + 'ldapbasedn="dc=example,dc=net" ' + 'ldapbinddn="cn=serviceuser,dc=example,dc=net" ' + 'ldapbindpasswd="password" ' + "ldaptls=0 " + 'ldapurl="ldap://0.0.0.0:3893"' + ) + + +def test_render_patroni_yml_file(substrate, config): + with ( + patch( + "single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version", + return_value="16.6", + ), + patch("single_kernel_postgresql.managers.config.render_file") as _render_file, + patch("single_kernel_postgresql.managers.config.Template") as _template, + patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config, + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value=sentinel.endpoint, + ), + patch( + "single_kernel_postgresql.core.state.CharmState.model_name", + new_callable=PropertyMock, + return_value=sentinel.model_name, + ), + patch( + "single_kernel_postgresql.core.state.CharmState.listen_ips", + new_callable=PropertyMock, + return_value=sentinel.listen_ips, + ), + patch( + "single_kernel_postgresql.core.state.CharmState.unit_ip", + new_callable=PropertyMock, + return_value=sentinel.unit_ip, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value=sentinel.patroni_pass, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.user_password", + new_callable=PropertyMock, + return_value=sentinel.user_pass, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.replication_password", + new_callable=PropertyMock, + return_value=sentinel.replication_pass, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.rewind_password", + new_callable=PropertyMock, + return_value=sentinel.rewind_pass, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.raft_password", + new_callable=PropertyMock, + return_value=sentinel.raft_pass, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.planned_units", + new_callable=PropertyMock, + return_value=1, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.cluster_name", + new_callable=PropertyMock, + return_value=sentinel.cluster_name, + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.endpoints", + new_callable=PropertyMock, + return_value=["endpoint1", "endpoint2", "endpoint3"], + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.members_ips", + new_callable=PropertyMock, + return_value=["ip1", "ip2", "ip3"], + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLPeer.member_name", + new_callable=PropertyMock, + return_value=sentinel.member_name, + ), + patch("builtins.open", mock_open(read_data="template")), + ): + _config.return_value.synchronous_node_count = 1 + _config.return_value.durability_maximum_lag_on_failover = ( + sentinel.durability_maximum_lag_on_failover + ) + _config.return_value.instance_password_encryption = sentinel.instance_password_encryption + _template.return_value.render.return_value = sentinel.template_output + + config.render_patroni_yml_file() + + _template.assert_called_once_with("template") + if substrate == Substrates.K8S: + _template.return_value.render.assert_called_once_with( + connectivity=False, + enable_ldap=False, + enable_tls=False, + member_name=sentinel.member_name, + superuser="operator", + superuser_password=sentinel.user_pass, + rewind_user="rewind", + rewind_password=sentinel.rewind_pass, + replication_password=sentinel.replication_pass, + enable_pgbackrest_archiving=False, + stanza=None, + restore_stanza=None, + restoring_backup=False, + backup_id=None, + pitr_target=None, + restore_timeline=None, + restore_to_latest=False, + is_creating_backup=False, + version="16", + synchronous_node_count=0, + maximum_lag_on_failover=sentinel.durability_maximum_lag_on_failover, + pg_parameters=None, + primary_cluster_endpoint=None, + ldap_parameters="", + patroni_password=sentinel.patroni_pass, + user_databases_map=None, + slots={}, + instance_password_encryption=sentinel.instance_password_encryption, + extra_replication_endpoints=[], + endpoint=sentinel.endpoint, + endpoints=["endpoint1", "endpoint2", "endpoint3"], + is_no_sync_member=False, + namespace=sentinel.model_name, + storage_path="/var/lib/pg/data", + logs_storage_path="/var/lib/pg/logs", + pgdata_path="/var/lib/pg/data/16/main", + ) + _render_file.assert_called_once_with( + substrate, "/var/lib/pg/data/patroni.yaml", sentinel.template_output, 0o644 + ) + else: + _template.return_value.render.assert_called_once_with( + connectivity=False, + enable_ldap=False, + enable_tls=False, + member_name=sentinel.member_name, + superuser="operator", + superuser_password=sentinel.user_pass, + rewind_user="rewind", + rewind_password=sentinel.rewind_pass, + replication_password=sentinel.replication_pass, + enable_pgbackrest_archiving=False, + stanza=None, + restore_stanza=None, + restoring_backup=False, + backup_id=None, + pitr_target=None, + restore_timeline=None, + restore_to_latest=False, + is_creating_backup=False, + version="16", + synchronous_node_count=0, + maximum_lag_on_failover=sentinel.durability_maximum_lag_on_failover, + pg_parameters=None, + primary_cluster_endpoint=None, + ldap_parameters="", + patroni_password=sentinel.patroni_pass, + user_databases_map=None, + slots={}, + instance_password_encryption=sentinel.instance_password_encryption, + extra_replication_endpoints=[], + conf_path="/var/snap/charmed-postgresql/current/etc/patroni", + log_path="/var/snap/charmed-postgresql/common/var/log/patroni", + postgresql_log_path="/var/snap/charmed-postgresql/common/var/log/postgresql", + data_path="/var/snap/charmed-postgresql/common/var/lib/postgresql/16/main", + wal_dir="/var/snap/charmed-postgresql/common/data/logs/16/main", + partner_addrs=[], + peers_ips=["ip1", "ip2", "ip3"], + pgbackrest_configuration_file="--config=/var/snap/charmed-postgresql/current/etc/pgbackrest/pgbackrest.conf", + scope=sentinel.cluster_name, + self_ip=sentinel.unit_ip, + listen_ips=sentinel.listen_ips, + raft_password=sentinel.raft_pass, + watcher=None, + ) + _render_file.assert_called_once_with( + substrate, + "/var/snap/charmed-postgresql/current/etc/patroni/patroni.yaml", + sentinel.template_output, + 0o600, + ) diff --git a/tests/unit/test_patroni.py b/tests/unit/test_patroni_manager.py similarity index 59% rename from tests/unit/test_patroni.py rename to tests/unit/test_patroni_manager.py index 19a899c..0731f35 100644 --- a/tests/unit/test_patroni.py +++ b/tests/unit/test_patroni_manager.py @@ -1,28 +1,24 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. - -from single_kernel_postgresql.workload.vm import VMWorkload -from single_kernel_postgresql.workload.k8s import K8sWorkload -from single_kernel_postgresql.core.state import CharmState -from single_kernel_postgresql.managers.patroni import PatroniManager import os -from pathlib import Path from unittest.mock import MagicMock, Mock, PropertyMock, call, mock_open, patch, sentinel import pytest import requests from charmlibs import snap -from pysyncobj.utility import UtilityException from single_kernel_postgresql.config.enums import Substrates from single_kernel_postgresql.config.exceptions import ( SwitchoverFailedError, SwitchoverNotSyncError, ) -from single_kernel_postgresql.config.literals import API_REQUEST_TIMEOUT, REWIND_USER +from single_kernel_postgresql.config.literals import API_REQUEST_TIMEOUT +from single_kernel_postgresql.core.state import CharmState +from single_kernel_postgresql.managers.patroni import PatroniManager +from single_kernel_postgresql.workload.k8s import K8sWorkload +from single_kernel_postgresql.workload.vm import VMWorkload from tenacity import RetryError, stop_after_delay, wait_fixed PATRONI_SERVICE = "patroni" -CREATE_CLUSTER_CONF_PATH = "/var/snap/charmed-postgresql/current/etc/postgresql/postgresql.conf" # This method will be used by the mock to replace requests.get @@ -50,13 +46,7 @@ def json(self): @pytest.fixture(autouse=True) -def peers_ips(): - peers_ips = {"2.2.2.2", "3.3.3.3"} - yield peers_ips - - -@pytest.fixture(autouse=True) -def patroni(peers_ips, substrate): +def patroni(substrate): mock_charm = Mock() mock_container = Mock() workload = VMWorkload(".") if substrate == Substrates.VM else K8sWorkload(".", mock_container) @@ -70,276 +60,286 @@ def patroni(peers_ips, substrate): yield patroni -# def test_get_member_ip(peers_ips, patroni): -# with patch( -# "cluster.parallel_patroni_get_request", return_value=None -# ) as _parallel_patroni_get_request: -# # No IP if no members -# assert patroni.get_member_ip(patroni.member_name) is None - -# _parallel_patroni_get_request.return_value = { -# "members": [ -# { -# "name": "postgresql-1", -# "host": "2.2.2.2", -# }, -# { -# "name": "postgresql-0", -# "host": "1.1.1.1", -# }, -# ] -# } -# assert patroni.get_member_ip(patroni.member_name) == "1.1.1.1" - - -# def test_get_patroni_health(peers_ips, patroni): -# with ( -# patch("cluster.stop_after_delay", new_callable=PropertyMock) as _stop_after_delay, -# patch("cluster.wait_fixed", new_callable=PropertyMock) as _wait_fixed, -# patch("charm.Patroni._patroni_url", new_callable=PropertyMock) as _patroni_url, -# patch("requests.get", side_effect=mocked_requests_get) as _get, -# ): -# # Test when the Patroni API is reachable. -# _patroni_url.return_value = "http://server1" -# health = patroni.get_patroni_health() - -# # Check needed to ensure a fast charm deployment. -# _stop_after_delay.assert_called_once_with(60) -# _wait_fixed.assert_called_once_with(7) - -# assert health == {"state": "running"} - -# # Test when the Patroni API is not reachable. -# _patroni_url.return_value = "http://server2" -# with pytest.raises(RetryError): -# patroni.get_patroni_health() -# assert False - - -# def test_get_postgresql_version(peers_ips, patroni): -# assert patroni.get_postgresql_version() == "16.14" - - -# def test_dict_to_hba_string(patroni): -# mock_data = { -# "ldapbasedn": "dc=example,dc=net", -# "ldapbinddn": "cn=serviceuser,dc=example,dc=net", -# "ldapbindpasswd": "password", -# "ldaptls": False, -# "ldapurl": "ldap://0.0.0.0:3893", -# } - -# assert patroni._dict_to_hba_string(mock_data) == ( -# 'ldapbasedn="dc=example,dc=net" ' -# 'ldapbinddn="cn=serviceuser,dc=example,dc=net" ' -# 'ldapbindpasswd="password" ' -# "ldaptls=0 " -# 'ldapurl="ldap://0.0.0.0:3893"' -# ) - - -# def test_get_primary(peers_ips, patroni): -# with ( -# patch( -# "cluster.parallel_patroni_get_request", return_value=None -# ) as _parallel_patroni_get_request, -# ): -# # No primary if no members -# assert patroni.get_primary() is None - -# _parallel_patroni_get_request.return_value = { -# "members": [ -# { -# "name": "postgresql-1", -# "role": "replica", -# }, -# { -# "name": "postgresql-0", -# "role": "leader", -# }, -# ] -# } -# # Test using the current Patroni URL. -# assert patroni.get_primary() == "postgresql-0" - -# # Test requesting the primary in the unit name pattern. -# assert patroni.get_primary(unit_name_pattern=True) == "postgresql/0" - - -# def test_is_creating_backup(peers_ips, patroni): -# with patch("charm.Patroni.cluster_status") as _cluster_status: -# # Test when one member is creating a backup. -# _cluster_status.return_value = [ -# {"name": "postgresql-0"}, -# {"name": "postgresql-1", "tags": {"is_creating_backup": True}}, -# ] -# assert patroni.is_creating_backup - -# # Test when no member is creating a backup. -# del patroni.cached_cluster_status -# _cluster_status.return_value = [{"name": "postgresql-0"}, {"name": "postgresql-1"}] -# assert not patroni.is_creating_backup - - -# def test_is_replication_healthy(peers_ips, patroni): -# with ( -# patch("requests.get") as _get, -# patch("charm.Patroni.get_primary") as _get_primary, -# patch("charm.Patroni.get_standby_leader") as _get_standby_leader, -# patch("charm.Patroni.get_member_ip"), -# patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), -# ): -# # Test when replication is healthy. -# _get.return_value.status_code = 200 -# assert patroni.is_replication_healthy() - -# # Test when replication is not healthy. -# _get.side_effect = [ -# MagicMock(status_code=200), -# MagicMock(status_code=200), -# MagicMock(status_code=503), -# ] -# assert not patroni.is_replication_healthy() - -# # Test no primary -# _get.side_effect = None -# _get.return_value.status_code = 200 -# _get_primary.return_value = None -# _get_standby_leader.return_value = None -# assert not patroni.is_replication_healthy() - -# # Standby leader -# _get_standby_leader.return_value = "standby" -# assert patroni.is_replication_healthy() - - -# def test_is_member_isolated(peers_ips, patroni): -# with ( -# patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), -# patch("cluster.wait_fixed", return_value=wait_fixed(0)), -# patch("requests.get", side_effect=mocked_requests_get) as _get, -# patch("charm.Patroni._patroni_url", new_callable=PropertyMock) as _patroni_url, -# ): -# # Test when it wasn't possible to connect to the Patroni API. -# _patroni_url.return_value = "http://server3" -# assert not patroni.is_member_isolated - -# # Test when the member isn't isolated from the cluster. -# _patroni_url.return_value = "http://server1" -# assert not patroni.is_member_isolated - -# # Test when the member is isolated from the cluster. -# _patroni_url.return_value = "http://server4" -# assert patroni.is_member_isolated - - -# def test_render_patroni_yml_file(peers_ips, patroni): -# with ( -# patch( -# "relations.async_replication.PostgreSQLAsyncReplication.get_partner_addresses", -# return_value=["2.2.2.2", "3.3.3.3"], -# ), -# patch("charm.Patroni.get_postgresql_version") as _get_postgresql_version, -# patch("cluster.render_file") as _render_file, -# patch( -# "charm.PostgresqlOperatorCharm.listen_ips", -# new_callable=PropertyMock, -# return_value=["1.1.1.1", "192.168.0.1"], -# ), -# ): -# _get_postgresql_version.return_value = "16.6" - -# # Define variables to render in the template. -# member_name = "postgresql-0" -# scope = "postgresql" -# superuser_password = "fake-superuser-password" -# replication_password = "fake-replication-password" -# rewind_password = "fake-rewind-password" -# raft_password = "fake-raft-password" -# patroni_password = "fake-patroni-password" -# postgresql_version = "16" - -# # Get the expected content from a file. -# with open("templates/patroni.yml.j2") as file: -# contents = file.read() -# template = Template(contents) -# # Setup a mock for the `open` method, set returned data to patroni.yml template. -# mock = mock_open(read_data=contents) - -# expected_content = template.render( -# conf_path=PATRONI_CONF_PATH, -# data_path=POSTGRESQL_DATA_DIR, -# log_path=PATRONI_LOGS_PATH, -# postgresql_log_path=POSTGRESQL_LOGS_PATH, -# wal_dir=LOGS_DATA_DIR, -# member_name=member_name, -# partner_addrs=["2.2.2.2", "3.3.3.3"], -# peers_ips=sorted(peers_ips), -# scope=scope, -# self_ip=patroni.unit_ip, -# listen_ips=["1.1.1.1", "192.168.0.1"], -# superuser="operator", -# superuser_password=superuser_password, -# replication_password=replication_password, -# rewind_user=REWIND_USER, -# rewind_password=rewind_password, -# version=postgresql_version, -# synchronous_node_count=0, -# maximum_lag_on_failover=1048576, -# raft_password=raft_password, -# patroni_password=patroni_password, -# instance_password_encryption="scram-sha-256", -# slots={}, -# ) - -# # Patch the `open` method with our mock. -# with patch("builtins.open", mock, create=True): -# # Call the method. -# patroni.render_patroni_yml_file() - -# # Check the template is opened read-only in the call to open. -# assert mock.call_args_list[0][0] == ("templates/patroni.yml.j2",) -# # Ensure the correct rendered template is sent to _render_file method. -# _render_file.assert_called_once_with( -# Substrates.VM, -# "/var/snap/charmed-postgresql/current/etc/patroni/patroni.yaml", -# expected_content, -# 0o600, -# ) - - -# def test_start_patroni(peers_ips, patroni): -# with patch("charm.snap.SnapCache") as _snap_cache: -# _cache = _snap_cache.return_value -# _selected_snap = _cache.__getitem__.return_value -# _selected_snap.start.side_effect = [None, snap.SnapError] - -# # Test a success scenario. -# assert patroni.start_patroni() -# _cache.__getitem__.assert_called_once_with("charmed-postgresql") -# _selected_snap.start.assert_called_once_with(services=[PATRONI_SERVICE]) - -# # Test a fail scenario. -# assert not patroni.start_patroni() - - -# def test_stop_patroni(peers_ips, patroni): -# with patch("charm.snap.SnapCache") as _snap_cache: -# _cache = _snap_cache.return_value -# _selected_snap = _cache.__getitem__.return_value -# _selected_snap.stop.side_effect = [None, snap.SnapError] -# _selected_snap.services.__getitem__.return_value.__getitem__.return_value = False - -# # Test a success scenario. -# assert patroni.stop_patroni() -# _cache.__getitem__.assert_called_once_with("charmed-postgresql") -# _selected_snap.stop.assert_called_once_with(services=[PATRONI_SERVICE]) -# _selected_snap.services.__getitem__.return_value.__getitem__.assert_called_once_with( -# "active" -# ) - -# # Test a fail scenario. -# assert not patroni.stop_patroni() +def test_get_member_ip(patroni): + with ( + patch( + "single_kernel_postgresql.managers.patroni.parallel_patroni_get_request", + return_value=None, + ) as _parallel_patroni_get_request, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.endpoints", + new_callable=PropertyMock, + return_value=["endpoint1", "endpoint2", "endpoint3"], + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.members_ips", + new_callable=PropertyMock, + return_value=["ip1", "ip2", "ip3"], + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + ): + # No IP if no members + assert patroni.get_member_ip("test") is None + + _parallel_patroni_get_request.return_value = { + "members": [ + { + "name": "postgresql-1", + "host": "2.2.2.2", + }, + { + "name": "postgresql-0", + "host": "1.1.1.1", + }, + ] + } + assert patroni.get_member_ip("postgresql-0") == "1.1.1.1" + + +def test_get_patroni_health(patroni): + with ( + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch( + "single_kernel_postgresql.managers.patroni.wait_fixed", + return_value=wait_fixed(0), + ), + patch("requests.get", side_effect=mocked_requests_get) as _get, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.patroni_url", + new_callable=PropertyMock, + ) as _patroni_url, + ): + # Test when the Patroni API is reachable. + _patroni_url.return_value = "http://server1" + health = patroni.get_patroni_health() + + assert health == {"state": "running"} + + # Test when the Patroni API is not reachable. + _patroni_url.return_value = "http://server2" + with pytest.raises(RetryError): + patroni.get_patroni_health() + assert False + + +def test_get_primary(patroni): + with ( + patch( + "single_kernel_postgresql.managers.patroni.parallel_patroni_get_request", + return_value=None, + ) as _parallel_patroni_get_request, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.endpoints", + new_callable=PropertyMock, + return_value=["endpoint1", "endpoint2", "endpoint3"], + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.members_ips", + new_callable=PropertyMock, + return_value=["ip1", "ip2", "ip3"], + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + ): + # No primary if no members + assert patroni.get_primary() is None + + _parallel_patroni_get_request.return_value = { + "members": [ + { + "name": "postgresql-1", + "role": "replica", + }, + { + "name": "postgresql-0", + "role": "leader", + }, + ] + } + # Test using the current Patroni URL. + assert patroni.get_primary() == "postgresql-0" + + # Test requesting the primary in the unit name pattern. + assert patroni.get_primary(unit_name_pattern=True) == "postgresql/0" + + +def test_is_creating_backup(patroni): + with patch( + "single_kernel_postgresql.managers.patroni.PatroniManager.cluster_status" + ) as _cluster_status: + # Test when one member is creating a backup. + _cluster_status.return_value = [ + {"name": "postgresql-0"}, + {"name": "postgresql-1", "tags": {"is_creating_backup": True}}, + ] + assert patroni.is_creating_backup + + # Test when no member is creating a backup. + del patroni.cached_cluster_status + _cluster_status.return_value = [{"name": "postgresql-0"}, {"name": "postgresql-1"}] + assert not patroni.is_creating_backup + + +def test_is_replication_healthy(patroni): + with ( + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch("requests.get") as _get, + patch( + "single_kernel_postgresql.managers.patroni.PatroniManager.get_primary" + ) as _get_primary, + patch( + "single_kernel_postgresql.managers.patroni.PatroniManager.get_standby_leader" + ) as _get_standby_leader, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.endpoints", + new_callable=PropertyMock, + return_value=["endpoint1", "endpoint2", "endpoint3"], + ), + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.members_ips", + new_callable=PropertyMock, + return_value=["ip1", "ip2", "ip3"], + ), + patch( + "single_kernel_postgresql.core.state.CharmState.endpoint", + new_callable=PropertyMock, + return_value="endpoint", + ), + ): + # Test when replication is healthy. + _get.return_value.status_code = 200 + assert patroni.is_replication_healthy() + + # Test when replication is not healthy. + _get.side_effect = [ + MagicMock(status_code=200), + MagicMock(status_code=200), + MagicMock(status_code=503), + ] + assert not patroni.is_replication_healthy() + + # Test no primary + _get.side_effect = None + _get.return_value.status_code = 200 + _get_primary.return_value = None + _get_standby_leader.return_value = None + assert not patroni.is_replication_healthy() + + # Standby leader + _get_standby_leader.return_value = "standby" + assert patroni.is_replication_healthy() + + +def test_is_member_isolated(patroni): + with ( + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch( + "single_kernel_postgresql.managers.patroni.wait_fixed", + return_value=wait_fixed(0), + ), + patch("requests.get", side_effect=mocked_requests_get) as _get, + patch( + "single_kernel_postgresql.managers.patroni.PatroniManager.get_primary" + ) as _get_primary, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch( + "single_kernel_postgresql.core.state.CharmState.patroni_url", + new_callable=PropertyMock, + ) as _patroni_url, + ): + # Test when it wasn't possible to connect to the Patroni API. + _patroni_url.return_value = "http://server3" + assert not patroni.is_member_isolated + + # Test when the member isn't isolated from the cluster. + _patroni_url.return_value = "http://server1" + assert not patroni.is_member_isolated + + # Test when the member is isolated from the cluster. + _patroni_url.return_value = "http://server4" + assert patroni.is_member_isolated + + +def test_start_patroni(substrate, patroni): + if substrate == Substrates.K8S: + pytest.skip("VM only") + return + + with patch("single_kernel_postgresql.managers.patroni.snap.SnapCache") as _snap_cache: + _cache = _snap_cache.return_value + _selected_snap = _cache.__getitem__.return_value + _selected_snap.start.side_effect = [None, snap.SnapError] + + # Test a success scenario. + assert patroni.start_patroni() + _cache.__getitem__.assert_called_once_with("charmed-postgresql") + _selected_snap.start.assert_called_once_with(services=[PATRONI_SERVICE]) + + # Test a fail scenario. + assert not patroni.start_patroni() + + +def test_stop_patroni(substrate, patroni): + if substrate == Substrates.K8S: + pytest.skip("VM only") + return + + with patch("single_kernel_postgresql.managers.patroni.snap.SnapCache") as _snap_cache: + _cache = _snap_cache.return_value + _selected_snap = _cache.__getitem__.return_value + _selected_snap.stop.side_effect = [None, snap.SnapError] + _selected_snap.services.__getitem__.return_value.__getitem__.return_value = False + + # Test a success scenario. + assert patroni.stop_patroni() + _cache.__getitem__.assert_called_once_with("charmed-postgresql") + _selected_snap.stop.assert_called_once_with(services=[PATRONI_SERVICE]) + _selected_snap.services.__getitem__.return_value.__getitem__.assert_called_once_with( + "active" + ) + + # Test a fail scenario. + assert not patroni.stop_patroni() def test_switchover(patroni): @@ -425,7 +425,7 @@ def test_switchover(patroni): assert False -def test_update_synchronous_node_count(peers_ips, patroni): +def test_update_synchronous_node_count(patroni): with ( patch( "single_kernel_postgresql.managers.patroni.stop_after_delay", @@ -469,7 +469,7 @@ def test_update_synchronous_node_count(peers_ips, patroni): assert False -def test_set_max_timelines_history(peers_ips, patroni): +def test_set_max_timelines_history(patroni): with ( patch("requests.patch") as _patch, patch( @@ -494,42 +494,52 @@ def test_set_max_timelines_history(peers_ips, patroni): ) -# def test_configure_patroni_on_unit(peers_ips, patroni): -# with ( -# patch("os.makedirs") as _makedirs, -# patch("os.chmod") as _chmod, -# patch("builtins.open") as _open, -# patch("os.chown") as _chown, -# patch("pwd.getpwnam") as _getpwnam, -# ): -# _getpwnam.return_value.pw_uid = sentinel.uid -# _getpwnam.return_value.pw_gid = sentinel.gid - -# patroni.configure_patroni_on_unit() - -# assert _getpwnam.call_args_list == [call("_daemon_"), call("_daemon_")] -# _makedirs.assert_called_once_with(POSTGRESQL_DATA_DIR, exist_ok=True) - -# # Parent and data dir are both chowned (parent enables Patroni reinit rename/remove). -# _chown.assert_any_call( -# os.path.dirname(POSTGRESQL_DATA_DIR), -# uid=sentinel.uid, -# gid=sentinel.gid, -# ) -# _chown.assert_any_call( -# POSTGRESQL_DATA_DIR, -# uid=sentinel.uid, -# gid=sentinel.gid, -# ) -# assert _chown.call_count == 2 - -# _open.assert_called_once_with(CREATE_CLUSTER_CONF_PATH, "a") -# _chmod.assert_called_once_with(POSTGRESQL_DATA_DIR, 448) - - -def test_member_started_true(peers_ips, patroni): +def test_configure_patroni_on_unit(substrate, patroni): + if substrate == Substrates.K8S: + pytest.skip("VM only") + return + + with ( + patch("os.makedirs") as _makedirs, + patch("os.chmod") as _chmod, + patch("builtins.open") as _open, + patch("os.chown") as _chown, + patch("pwd.getpwnam") as _getpwnam, + patch( + "single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version", + return_value="16.6", + ), + ): + _getpwnam.return_value.pw_uid = sentinel.uid + _getpwnam.return_value.pw_gid = sentinel.gid + + patroni.configure_patroni_on_unit() + + assert _getpwnam.call_args_list == [call("_daemon_"), call("_daemon_")] + _makedirs.assert_called_once_with(str(patroni.workload.paths.data), exist_ok=True) + + # Parent and data dir are both chowned (parent enables Patroni reinit rename/remove). + _chown.assert_any_call( + os.path.dirname(str(patroni.workload.paths.data)), + uid=sentinel.uid, + gid=sentinel.gid, + ) + _chown.assert_any_call( + str(patroni.workload.paths.data), + uid=sentinel.uid, + gid=sentinel.gid, + ) + assert _chown.call_count == 2 + + _open.assert_called_once_with( + str(patroni.workload.paths.patroni_conf / "patroni.yaml"), "a" + ) + _chmod.assert_called_once_with(str(patroni.workload.paths.data), 448) + + +def test_member_started_true(patroni): with ( - patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch("requests.get") as _get, patch( "single_kernel_postgresql.managers.patroni.stop_after_delay", return_value=stop_after_delay(0), @@ -565,9 +575,9 @@ def test_member_started_true(peers_ips, patroni): ) -def test_member_started_false(peers_ips, patroni): +def test_member_started_false(patroni): with ( - patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch("requests.get") as _get, patch( "single_kernel_postgresql.managers.patroni.stop_after_delay", return_value=stop_after_delay(0), @@ -603,9 +613,9 @@ def test_member_started_false(peers_ips, patroni): ) -def test_member_started_error(peers_ips, patroni): +def test_member_started_error(patroni): with ( - patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch("requests.get") as _get, patch( "single_kernel_postgresql.managers.patroni.stop_after_delay", return_value=stop_after_delay(0), @@ -641,9 +651,9 @@ def test_member_started_error(peers_ips, patroni): ) -def test_member_inactive_true(peers_ips, patroni): +def test_member_inactive_true(patroni): with ( - patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch("requests.get") as _get, patch( "single_kernel_postgresql.managers.patroni.stop_after_delay", return_value=stop_after_delay(0), @@ -679,9 +689,9 @@ def test_member_inactive_true(peers_ips, patroni): ) -def test_member_inactive_false(peers_ips, patroni): +def test_member_inactive_false(patroni): with ( - patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch("requests.get") as _get, patch( "single_kernel_postgresql.managers.patroni.stop_after_delay", return_value=stop_after_delay(0), @@ -717,9 +727,9 @@ def test_member_inactive_false(peers_ips, patroni): ) -def test_member_inactive_error(peers_ips, patroni): +def test_member_inactive_error(patroni): with ( - patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch("requests.get") as _get, patch( "single_kernel_postgresql.managers.patroni.stop_after_delay", return_value=stop_after_delay(0), @@ -755,9 +765,9 @@ def test_member_inactive_error(peers_ips, patroni): ) -def test_member_inactive_patroni_not_running(peers_ips, patroni): +def test_member_inactive_patroni_not_running(patroni): with ( - patch("single_kernel_postgresql.managers.patroni.requests.get") as _get, + patch("requests.get") as _get, patch( "single_kernel_postgresql.workload.vm.VMWorkload.is_patroni_running", return_value=False, From 93d1c46f7a4307f641eb9379cd427c4a2ea27f95 Mon Sep 17 00:00:00 2001 From: Dragomir Penev Date: Thu, 2 Jul 2026 16:48:20 +0300 Subject: [PATCH 31/31] K8s test --- tests/unit/test_config_manager.py | 5 ++- tests/unit/test_patroni_manager.py | 49 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_config_manager.py b/tests/unit/test_config_manager.py index 9538ff3..024f6ea 100644 --- a/tests/unit/test_config_manager.py +++ b/tests/unit/test_config_manager.py @@ -1,14 +1,13 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. -from unittest.mock import MagicMock, Mock, PropertyMock, mock_open, patch, sentinel +from unittest.mock import Mock, PropertyMock, mock_open, patch, sentinel import pytest from single_kernel_postgresql.config.enums import Substrates -from single_kernel_postgresql.config.literals import REWIND_USER from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.managers.config import ConfigManager -from single_kernel_postgresql.workload.vm import VMWorkload from single_kernel_postgresql.workload.k8s import K8sWorkload +from single_kernel_postgresql.workload.vm import VMWorkload @pytest.fixture(autouse=True) diff --git a/tests/unit/test_patroni_manager.py b/tests/unit/test_patroni_manager.py index 0731f35..5d11b99 100644 --- a/tests/unit/test_patroni_manager.py +++ b/tests/unit/test_patroni_manager.py @@ -211,6 +211,10 @@ def test_is_replication_healthy(patroni): "single_kernel_postgresql.managers.patroni.stop_after_delay", return_value=stop_after_delay(0), ), + patch( + "single_kernel_postgresql.managers.patroni.wait_fixed", + return_value=wait_fixed(0), + ), patch("requests.get") as _get, patch( "single_kernel_postgresql.managers.patroni.PatroniManager.get_primary" @@ -887,3 +891,48 @@ def test_are_replicas_up(patroni): # Return None on error _cluster_status.side_effect = Exception assert patroni.are_replicas_up() is None + + +def test_primary_endpoint_ready(substrate, patroni): + if substrate == Substrates.VM: + pytest.skip("K8s only") + return + with ( + patch( + "single_kernel_postgresql.managers.patroni.stop_after_delay", + return_value=stop_after_delay(0), + ), + patch( + "single_kernel_postgresql.managers.patroni.wait_fixed", + return_value=wait_fixed(0), + ), + patch( + "single_kernel_postgresql.core.state.CharmState.primary_endpoint", + new_callable=PropertyMock, + return_value=None, + ) as _primary_endpoint, + patch( + "single_kernel_postgresql.core.peer_relation.PostgreSQLApplication.patroni_password", + new_callable=PropertyMock, + return_value="test-pass", + ), + patch("requests.get") as _get, + ): + # No primary + assert not patroni.primary_endpoint_ready + + # Test with an issue when trying to connect to the Patroni API. + _primary_endpoint.return_value = "endpoint" + _get.side_effect = RetryError + assert not patroni.primary_endpoint_ready + + # Mock the request return values. + _get.side_effect = None + _get.return_value.json.return_value = {"state": "stopped"} + + # Test with the primary endpoint not ready yet. + assert not patroni.primary_endpoint_ready + + # Test with the primary endpoint ready. + _get.return_value.json.return_value = {"state": "running"} + assert patroni.primary_endpoint_ready