Skip to content

Commit 08f5993

Browse files
committed
feat(config): complete update_config with API apply, restart engine, and bridges
The library now owns the full config-update flow (steps 3-10) instead of the charm. update_config renders, applies Patroni-controlled parameters via the API, runs the TLS/pending-restart decision engine, honors the VM snap gate, and persists the config/user hashes -- all in the lib. The substrate-tangled pieces of the restart trigger, endpoint refresh, and monitoring/LDAP service restarts ride charm-side through three injected bridge callables (request_restart, refresh_endpoints, restart_services) until their own migration phases, so the two HIGH-risk substrate diffs in the restart trigger (VM pops postgresql_restarted; K8s updates the metrics scrape job) stay out of the library. is_tls_enabled and generate_config_hash internalize; user_hash stays injected. The config hash is byte-compatible with the charm's, so charm adoption does not force a spurious restart. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent cba92af commit 08f5993

7 files changed

Lines changed: 769 additions & 8 deletions

File tree

single_kernel_postgresql/charms/abstract_charm.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,15 @@ def __init__(self, *args):
4242
)
4343
self.patroni_manager = PatroniManager(state=self.state, workload=self.workload)
4444
self.cluster_manager = ClusterManager(state=self.state, workload=self.workload)
45-
self.config_manager = ConfigManager(state=self.state, workload=self.workload)
45+
self.config_manager = ConfigManager(
46+
state=self.state,
47+
workload=self.workload,
48+
tls_manager=self.tls_manager,
49+
patroni_manager=self.patroni_manager,
50+
request_restart=self.request_restart,
51+
refresh_endpoints=self.refresh_endpoints,
52+
restart_services=self.restart_services,
53+
)
4654

4755
# Events Handler
4856
self.postgresql_events_handler = PostgreSQLEventsHandler(
@@ -84,3 +92,21 @@ def workload(self) -> BaseWorkload:
8492
def substrate(self) -> Substrates:
8593
"""Access current substrate."""
8694
pass
95+
96+
# Config-update bridges: charm-side callables the ConfigManager invokes for the
97+
# substrate-tangled restart trigger, endpoint refresh and monitoring/ldap service
98+
# restarts. They stay in the charm until their own migration phases.
99+
@abstractmethod
100+
def request_restart(self) -> None:
101+
"""Run the substrate pre-restart side effect and acquire the restart lock."""
102+
pass
103+
104+
@abstractmethod
105+
def refresh_endpoints(self) -> None:
106+
"""Refresh the client relation endpoints."""
107+
pass
108+
109+
@abstractmethod
110+
def restart_services(self) -> None:
111+
"""Restart the monitoring and LDAP-sync sidecar services."""
112+
pass

single_kernel_postgresql/charms/k8s_charm.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,15 @@ def substrate(self) -> Substrates:
6666
Substrates: always Substrates.K8S for this charm
6767
"""
6868
return Substrates.K8S
69+
70+
# The concrete production charm owns these bridges (update_scrape_job_spec +
71+
# acquire_lock, update_endpoints, pebble metrics/ldap restarts). This abstract charm
72+
# is unit-test only, so they are no-ops here.
73+
def request_restart(self) -> None:
74+
"""Run the substrate pre-restart side effect and acquire the restart lock."""
75+
76+
def refresh_endpoints(self) -> None:
77+
"""Refresh the client relation endpoints."""
78+
79+
def restart_services(self) -> None:
80+
"""Restart the monitoring and LDAP-sync sidecar services."""

single_kernel_postgresql/charms/vm_charm.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,15 @@ def substrate(self) -> Substrates:
5656
Substrates: always Substrates.VM for this charm
5757
"""
5858
return Substrates.VM
59+
60+
# The concrete production charm owns these bridges (pops postgresql_restarted +
61+
# acquire_lock, update_endpoints, snap metrics/ldap restarts). This abstract charm
62+
# is unit-test only, so they are no-ops here.
63+
def request_restart(self) -> None:
64+
"""Run the substrate pre-restart side effect and acquire the restart lock."""
65+
66+
def refresh_endpoints(self) -> None:
67+
"""Refresh the client relation endpoints."""
68+
69+
def restart_services(self) -> None:
70+
"""Restart the monitoring and LDAP-sync sidecar services."""

single_kernel_postgresql/managers/config.py

Lines changed: 235 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@
99

1010
import importlib.resources
1111
import logging
12+
from collections.abc import Callable
13+
from hashlib import shake_128
1214
from typing import Any
1315

1416
import charm_refresh
17+
import psycopg2
1518
from data_platform_helpers.advanced_statuses import StatusObject
1619
from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope
1720
from jinja2 import Template
21+
from tenacity import RetryError, Retrying, stop_after_attempt, stop_after_delay, wait_fixed
1822

1923
from single_kernel_postgresql.config.enums import Substrates
24+
from single_kernel_postgresql.config.exceptions import PostgreSQLCannotConnectError
2025
from single_kernel_postgresql.config.literals import (
2126
PGBACKREST_CONF_FILE,
2227
POSTGRESQL_STORAGE_PERMISSIONS,
@@ -26,6 +31,8 @@
2631
from single_kernel_postgresql.config.statuses import GeneralStatuses
2732
from single_kernel_postgresql.core.state import CharmState
2833
from 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
2936
from single_kernel_postgresql.utils import _change_owner, render_file
3037
from single_kernel_postgresql.utils.postgresql import PostgreSQL as PostgreSQLClient
3138
from 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(

single_kernel_postgresql/workload/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,7 @@ def get_available_memory(self) -> int:
278278
def get_available_resources(self) -> tuple[int, int]:
279279
"""Returns the available (cpu_cores, memory_bytes) for the workload."""
280280
pass
281+
282+
def get_snap_revision(self) -> str:
283+
"""Returns the installed workload snap revision (VM only)."""
284+
raise NotImplementedError

single_kernel_postgresql/workload/vm.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,8 @@ def get_available_memory(self) -> int:
238238
def get_available_resources(self) -> tuple[int, int]:
239239
"""Returns the available (cpu_cores, memory_bytes) for the workload."""
240240
return os.cpu_count() or 1, self.get_available_memory()
241+
242+
def get_snap_revision(self) -> str:
243+
"""Returns the installed workload snap revision."""
244+
cache = snap.SnapCache()
245+
return cache[charm_refresh.snap_name()].revision

0 commit comments

Comments
 (0)