diff --git a/pyproject.toml b/pyproject.toml index 1cf9fa0..31420e3 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.3.1" +version = "16.3.2" readme = "README.md" license = {file = "LICENSE"} authors = [ @@ -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_config_manager.py b/tests/unit/test_config_manager.py new file mode 100644 index 0000000..024f6ea --- /dev/null +++ b/tests/unit/test_config_manager.py @@ -0,0 +1,231 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. +from unittest.mock import Mock, PropertyMock, mock_open, patch, sentinel + +import pytest +from single_kernel_postgresql.config.enums import Substrates +from single_kernel_postgresql.core.state import CharmState +from single_kernel_postgresql.managers.config import ConfigManager +from single_kernel_postgresql.workload.k8s import K8sWorkload +from single_kernel_postgresql.workload.vm import VMWorkload + + +@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_manager.py b/tests/unit/test_patroni_manager.py new file mode 100644 index 0000000..5d11b99 --- /dev/null +++ b/tests/unit/test_patroni_manager.py @@ -0,0 +1,938 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. +import os +from unittest.mock import MagicMock, Mock, PropertyMock, call, mock_open, patch, sentinel + +import pytest +import requests +from charmlibs import snap +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 +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" + + +# 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 patroni(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(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( + "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" + ) 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): + 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(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(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(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("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(patroni): + with ( + patch("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(patroni): + with ( + patch("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(patroni): + with ( + patch("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(patroni): + with ( + patch("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(patroni): + with ( + patch("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(patroni): + with ( + patch("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 + + +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 diff --git a/uv.lock b/uv.lock index aef8a7d..583ce35 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" @@ -973,7 +982,7 @@ wheels = [ [[package]] name = "postgresql-charms-single-kernel" -version = "16.3.1" +version = "16.3.2" source = { editable = "." } dependencies = [ { name = "ops", version = "2.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -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" },