99
1010import importlib .resources
1111import logging
12+ from collections .abc import Callable
13+ from hashlib import shake_128
1214from typing import Any
1315
1416import charm_refresh
17+ import psycopg2
1518from data_platform_helpers .advanced_statuses import StatusObject
1619from data_platform_helpers .advanced_statuses .types import Scope as AdvancedStatusesScope
1720from jinja2 import Template
21+ from tenacity import RetryError , Retrying , stop_after_attempt , stop_after_delay , wait_fixed
1822
1923from single_kernel_postgresql .config .enums import Substrates
24+ from single_kernel_postgresql .config .exceptions import PostgreSQLCannotConnectError
2025from single_kernel_postgresql .config .literals import (
2126 PGBACKREST_CONF_FILE ,
2227 POSTGRESQL_STORAGE_PERMISSIONS ,
2631from single_kernel_postgresql .config .statuses import GeneralStatuses
2732from single_kernel_postgresql .core .state import CharmState
2833from single_kernel_postgresql .managers .base import BaseManager
34+ from single_kernel_postgresql .managers .patroni import PatroniManager
35+ from single_kernel_postgresql .managers .tls import TLSManager
2936from single_kernel_postgresql .utils import _change_owner , render_file
3037from single_kernel_postgresql .utils .postgresql import PostgreSQL as PostgreSQLClient
3138from single_kernel_postgresql .workload .base import BaseWorkload
@@ -39,8 +46,24 @@ class ConfigManager(BaseManager):
3946 This manager is responsible for handling configuration operations.
4047 """
4148
42- def __init__ (self , state : CharmState , workload : BaseWorkload ):
49+ def __init__ (
50+ self ,
51+ state : CharmState ,
52+ workload : BaseWorkload ,
53+ tls_manager : TLSManager ,
54+ patroni_manager : PatroniManager ,
55+ request_restart : Callable [[], None ],
56+ refresh_endpoints : Callable [[], None ],
57+ restart_services : Callable [[], None ],
58+ ):
4359 super ().__init__ (state , workload , "config_manager" )
60+ self .tls_manager = tls_manager
61+ self .patroni_manager = patroni_manager
62+ # Charm-side bridges: the substrate-tangled restart trigger, endpoint refresh and
63+ # monitoring/ldap service restarts stay in the charm until their own migration phases.
64+ self .request_restart = request_restart
65+ self .refresh_endpoints = refresh_endpoints
66+ self .restart_services = restart_services
4467
4568 @staticmethod
4669 def _dict_to_hba_string (_dict : dict [str , Any ]) -> str :
@@ -266,13 +289,154 @@ def _build_postgresql_parameters(
266289
267290 return pg_parameters
268291
292+ @property
293+ def is_tls_enabled (self ) -> bool :
294+ """Return whether client TLS is enabled (all client TLS files are present)."""
295+ return all (self .tls_manager .get_client_tls_files ())
296+
297+ @property
298+ def generate_config_hash (self ) -> str :
299+ """Generate current configuration hash."""
300+ return shake_128 (str (self .state .config .model_dump ()).encode ()).hexdigest (16 )
301+
302+ def _can_connect_to_postgresql (self , postgresql_client : PostgreSQLClient ) -> bool :
303+ if self .state .substrate == Substrates .VM and (
304+ not postgresql_client .password or not postgresql_client .current_host
305+ ):
306+ return False
307+ try :
308+ for attempt in Retrying (stop = stop_after_delay (10 ), wait = wait_fixed (3 )):
309+ with attempt :
310+ if not postgresql_client .get_postgresql_timezones ():
311+ logger .debug ("Cannot connect to database (CannotConnectError)" )
312+ raise PostgreSQLCannotConnectError
313+ except RetryError :
314+ logger .debug ("Cannot connect to database (RetryError)" )
315+ return False
316+ return True
317+
318+ def is_restart_pending (self , postgresql_client : PostgreSQLClient ) -> bool :
319+ """Query pg_settings for pending restart."""
320+ connection = None
321+ try :
322+ with (
323+ postgresql_client ._connect_to_database (
324+ database_host = postgresql_client .current_host
325+ ) as connection ,
326+ connection .cursor () as cursor ,
327+ ):
328+ cursor .execute ("SELECT COUNT(*) FROM pg_settings WHERE pending_restart=True;" )
329+ result = cursor .fetchone ()
330+ if result is not None :
331+ return result [0 ] > 0
332+ else :
333+ return False
334+ except psycopg2 .OperationalError :
335+ logger .warning ("Failed to connect to PostgreSQL." )
336+ return False
337+ except psycopg2 .Error as e :
338+ logger .error (f"Failed to check if restart is pending: { e } " )
339+ return False
340+ finally :
341+ if connection :
342+ connection .close ()
343+
344+ def apply_api_config (
345+ self ,
346+ postgresql_client : PostgreSQLClient ,
347+ async_primary_cluster_endpoint : str | None = None ,
348+ ) -> bool :
349+ """Update the parameters controlled by Patroni via its API."""
350+ cpu_cores = self .workload .get_available_resources ()[0 ]
351+ # Use config value if set, calculate otherwise
352+ max_connections = (
353+ self .state .config .experimental_max_connections
354+ if self .state .config .experimental_max_connections
355+ else max (4 * cpu_cores , 100 )
356+ )
357+ cfg_patch : dict [str , int | str | None ] = {
358+ "max_connections" : max_connections ,
359+ "max_prepared_transactions" : self .state .config .memory_max_prepared_transactions ,
360+ "max_replication_slots" : 25 ,
361+ "max_wal_senders" : 25 ,
362+ "shared_buffers" : self .state .config .memory_shared_buffers ,
363+ "wal_keep_size" : self .state .config .durability_wal_keep_size ,
364+ }
365+
366+ # Add restart-required worker process parameters via Patroni API
367+ worker_configs = self ._calculate_worker_process_config (cpu_cores )
368+ if "max_worker_processes" in worker_configs :
369+ cfg_patch ["max_worker_processes" ] = worker_configs ["max_worker_processes" ]
370+ if "max_logical_replication_workers" in worker_configs :
371+ cfg_patch ["max_logical_replication_workers" ] = worker_configs [
372+ "max_logical_replication_workers"
373+ ]
374+
375+ base_patch = {
376+ ** self .state .synchronous_configuration ,
377+ "maximum_lag_on_failover" : self .state .config .durability_maximum_lag_on_failover ,
378+ }
379+ if async_primary_cluster_endpoint :
380+ base_patch ["standby_cluster" ] = {"host" : async_primary_cluster_endpoint }
381+ try :
382+ self .patroni_manager .bulk_update_parameters_controller_by_patroni (
383+ cfg_patch , base_patch
384+ )
385+ except RetryError :
386+ return False
387+ return True
388+
389+ def handle_restart_need (
390+ self , postgresql_client : PostgreSQLClient , config_changed : bool
391+ ) -> None :
392+ """Handle PostgreSQL restart need based on the TLS configuration and configuration changes."""
393+ if self ._can_connect_to_postgresql (postgresql_client ):
394+ # check_current_host is a VM-only precision in the live TLS probe.
395+ check_current_host = (
396+ {"check_current_host" : True } if (self .state .substrate == Substrates .VM ) else {}
397+ )
398+ restart_postgresql = self .is_tls_enabled != postgresql_client .is_tls_enabled (
399+ ** check_current_host
400+ )
401+ else :
402+ restart_postgresql = False
403+
404+ try :
405+ self .patroni_manager .reload_patroni_configuration ()
406+ except Exception as e :
407+ logger .error (f"Reload patroni call failed! error: { e !s} " )
408+
409+ if config_changed and not restart_postgresql :
410+ # Wait for some more time than the Patroni's loop_wait default value (10 seconds),
411+ # which tells how much time Patroni will wait before checking the configuration
412+ # file again to reload it.
413+ try :
414+ for attempt in Retrying (stop = stop_after_attempt (5 ), wait = wait_fixed (3 )):
415+ with attempt :
416+ restart_postgresql = restart_postgresql or self .is_restart_pending (
417+ postgresql_client
418+ )
419+ if not restart_postgresql :
420+ raise Exception
421+ except RetryError :
422+ # Ignore the error, as it happens only to indicate that the configuration has not changed.
423+ pass
424+
425+ self .state .peer .tls = self .is_tls_enabled
426+ self .refresh_endpoints ()
427+
428+ # Restart PostgreSQL if TLS configuration has changed
429+ # (so the both old and new connections use the configuration).
430+ if restart_postgresql :
431+ logger .info ("PostgreSQL restart required" )
432+ self .request_restart ()
433+
269434 def update_config (
270435 self ,
271436 postgresql_client : PostgreSQLClient ,
437+ user_hash : str ,
272438 is_creating_backup : bool = False ,
273439 # TODO add rel handler
274- is_tls_enabled : bool = False ,
275- # TODO add rel handler
276440 relations_user_databases_map : dict [str , Any ] | None = None ,
277441 # TODO add rel handler
278442 ldap_parameters : dict [str , Any ] | None = None ,
@@ -286,7 +450,12 @@ def update_config(
286450 * ,
287451 refresh : charm_refresh .Machines | None = None ,
288452 ) -> bool :
289- """Updates Patroni config file based on the existence of the TLS files."""
453+ """Updates Patroni config file based on the existence of the TLS files.
454+
455+ Raises:
456+ DeployedWithoutTrustError: on K8s when the app lacks cluster trust; the caller
457+ is expected to catch this.
458+ """
290459 # Build PostgreSQL parameters
291460 pg_parameters = self ._build_postgresql_parameters (postgresql_client )
292461
@@ -302,7 +471,7 @@ def update_config(
302471 connectivity = self .state .peer .is_connectivity_enabled ,
303472 is_creating_backup = is_creating_backup ,
304473 enable_ldap = self .state .application .is_ldap_enabled ,
305- enable_tls = is_tls_enabled ,
474+ enable_tls = self . is_tls_enabled ,
306475 backup_id = self .state .application .data .get ("restoring-backup" ),
307476 pitr_target = self .state .application .data .get ("restore-to-time" ),
308477 restore_timeline = self .state .application .data .get ("restore-timeline" ),
@@ -319,6 +488,67 @@ def update_config(
319488 watcher_raft_address = watcher_raft_address ,
320489 no_peers = no_peers ,
321490 )
491+ if no_peers :
492+ return True
493+
494+ if not self .workload .is_patroni_running ():
495+ # If Patroni/PostgreSQL has not started yet and TLS relations was initialised,
496+ # then mark TLS as enabled. This commonly happens when the charm is deployed
497+ # in a bundle together with the TLS certificates operator. This flag is used to
498+ # know when to call the Patroni API using HTTP or HTTPS.
499+ self .state .peer .tls = self .is_tls_enabled
500+ self .refresh_endpoints ()
501+ logger .debug ("Early exit update_config: Workload not started yet" )
502+ return True
503+
504+ if not self .patroni_manager .member_started :
505+ if self .is_tls_enabled :
506+ logger .debug (
507+ "Early exit update_config: patroni not responding but TLS is enabled."
508+ )
509+ self .handle_restart_need (postgresql_client , True )
510+ return True
511+ logger .debug ("Early exit update_config: Patroni not started yet" )
512+ return False
513+
514+ # Try to connect. Patroni's REST API patch (below) doesn't need the PG-client
515+ # connection, so this standalone gate is VM-only; K8s proceeds straight to it.
516+ if self .state .substrate == Substrates .VM and not self ._can_connect_to_postgresql (
517+ postgresql_client
518+ ):
519+ logger .warning ("Early exit update_config: Cannot connect to Postgresql" )
520+ return False
521+
522+ if not self .apply_api_config (postgresql_client , async_primary_cluster_endpoint ):
523+ logger .warning ("Early exit update_config: Unable to patch Patroni API" )
524+ return False
525+
526+ if self .state .substrate == Substrates .K8S and not (
527+ self .patroni_manager .ensure_slots_controller_by_patroni (replication_slots )
528+ ):
529+ logger .warning (
530+ "Failed to sync replication slots with Patroni — will retry on next config update"
531+ )
532+
533+ self .handle_restart_need (
534+ postgresql_client , self .state .peer .config_hash != self .generate_config_hash
535+ )
536+
537+ # TODO handle case of scale up while refresh in progress & `refresh` is None
538+ if (
539+ self .state .substrate == Substrates .VM
540+ and refresh is not None
541+ and self .workload .get_snap_revision () != refresh .pinned_snap_revision
542+ ):
543+ logger .debug ("Early exit: snap was not refreshed to the right version yet" )
544+ return True
545+
546+ self .restart_services ()
547+
548+ self .state .peer .user_hash = user_hash
549+ self .state .peer .config_hash = self .generate_config_hash
550+ if self .state .peer .is_app_leader :
551+ self .state .application .user_hash = user_hash
322552 return True
323553
324554 def render_patroni_yml_file (
0 commit comments