From a036312705d6956b6f9511565e1f3ac17860db5a Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 2 Jul 2026 15:46:59 -0300 Subject: [PATCH 1/9] feat(tls): migrate TLS to the single-kernel library Consume the single-kernel library's operator-certificate TLS handler (events.tls.TLS) and TLSManager instead of the charm-side src/relations/tls.py, stacked on the DPE-10062 Patroni/cluster port (#1788). The TLS events handler owns the two certificate requirers and is constructor-injected into TLSManager, whose live-fetch getters read cert/key from them. A charm-side reload bridge (_reload_tls_after_push) reloads PostgreSQL after the lib handler stores+pushes certs; it also fires on relation_broken so detaching the TLS operator re-renders Patroni with TLS disabled. Removes src/relations/tls.py and push_tls_files_to_workload (now owned by the lib), routes internal-cert regeneration through _regenerate_internal_cert, redirects the operator-cert getters to tls_manager, and pins the library to the TLS stack tip via archive URL (16.3.2 is not yet released). Signed-off-by: Marcelo Henrique Neppel --- poetry.lock | 43 ++-- pyproject.toml | 2 +- src/charm.py | 128 +++++++---- src/relations/postgresql_provider.py | 2 +- src/relations/tls.py | 259 --------------------- src/relations/watcher.py | 2 +- tests/unit/test_charm.py | 49 +--- tests/unit/test_tls.py | 321 ++++++++++++++------------- 8 files changed, 284 insertions(+), 522 deletions(-) delete mode 100644 src/relations/tls.py diff --git a/poetry.lock b/poetry.lock index e8098b5a4e..f8f0cf2f33 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1865,37 +1865,40 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "postgresql-charms-single-kernel" -version = "16.3.1" +version = "16.3.2" description = "Shared and reusable code for PostgreSQL-related charms" optional = false -python-versions = "<4.0,>=3.8" +python-versions = ">=3.8,<4.0" groups = ["main", "integration"] files = [ - {file = "postgresql_charms_single_kernel-16.3.1-py3-none-any.whl", hash = "sha256:a1c3f32fb396e69421471d64f54a43968f134fe5538708fabe37139a9fae832e"}, - {file = "postgresql_charms_single_kernel-16.3.1.tar.gz", hash = "sha256:48e028e873b00c877fd36a25ff6c1913a40d559cb4124aae888edd1262cdce3a"}, + {file = "5aeebec.zip", hash = "sha256:76c2e468de1169912416fffaa51b474f79223b5caee16207c88ec71902d7ee6f"}, ] [package.dependencies] -charm-refresh = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-interfaces-tls-certificates = {version = ">=1.8.3", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-pathops = {version = ">=1.0.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-rollingops = {version = ">=1.1.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-snap = {version = ">=1.0.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} -data-platform-helpers = {version = ">=0.1.7", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -httpx = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -jinja2 = {version = ">=3.1.6", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +charm-refresh = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-interfaces-tls-certificates = {version = ">=1.8.3", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-pathops = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-rollingops = {version = ">=1.1.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-snap = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +data-platform-helpers = {version = ">=0.1.7", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +httpx = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +jinja2 = {version = ">=3.1.6", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} ops = ">=2.0.0" -psutil = {version = ">=7.2.2", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} -pydantic = {version = ">=2.0", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -pysyncobj = {version = ">=0.3.15", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} -requests = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +psutil = {version = ">=7.2.2", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +pydantic = {version = ">=2.0", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +pysyncobj = {version = ">=0.3.15", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +requests = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} tenacity = ">=9.0.0" -tomli = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +tomli = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} [package.extras] db-driver = ["psycopg2 (>=2.9.10)"] -postgresql = ["charm-refresh ; python_full_version >= \"3.12.0\"", "charmlibs-interfaces-tls-certificates (>=1.8.3) ; python_full_version >= \"3.12.0\"", "charmlibs-pathops (>=1.0.1) ; python_full_version >= \"3.12.0\"", "charmlibs-rollingops (>=1.1.1) ; python_full_version >= \"3.12.0\"", "data-platform-helpers (>=0.1.7) ; python_full_version >= \"3.12.0\"", "httpx ; python_full_version >= \"3.12.0\"", "jinja2 (>=3.1.6) ; python_full_version >= \"3.12.0\"", "pydantic (>=2.0) ; python_full_version >= \"3.12.0\"", "requests ; python_full_version >= \"3.12.0\"", "tomli ; python_full_version >= \"3.12.0\""] -vm = ["charmlibs-snap (>=1.0.1) ; python_full_version >= \"3.12.0\"", "psutil (>=7.2.2) ; python_full_version >= \"3.12.0\"", "pysyncobj (>=0.3.15) ; python_full_version >= \"3.12.0\""] +postgresql = ["charm-refresh ; python_version >= \"3.12\"", "charmlibs-interfaces-tls-certificates (>=1.8.3) ; python_version >= \"3.12\"", "charmlibs-pathops (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-rollingops (>=1.1.1) ; python_version >= \"3.12\"", "data-platform-helpers (>=0.1.7) ; python_version >= \"3.12\"", "httpx ; python_version >= \"3.12\"", "jinja2 (>=3.1.6) ; python_version >= \"3.12\"", "pydantic (>=2.0) ; python_version >= \"3.12\"", "requests ; python_version >= \"3.12\"", "tomli ; python_version >= \"3.12\""] +vm = ["charmlibs-snap (>=1.0.1) ; python_version >= \"3.12\"", "psutil (>=7.2.2) ; python_version >= \"3.12\"", "pysyncobj (>=0.3.15) ; python_version >= \"3.12\""] + +[package.source] +type = "url" +url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5aeebec.zip" [[package]] name = "prompt-toolkit" @@ -3193,4 +3196,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "71742651a72b3bcf766b893c729952fb30c097d13a1e08e4ccf9f86d111a9d0c" +content-hash = "e49d6ddac7ca3a2d13571479e983cc73c99b15b4244ae803052c7e6d4c44d00e" diff --git a/pyproject.toml b/pyproject.toml index 441969572b..2fb0dacd1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ charm-refresh = "^3.1.0.2" charmlibs-snap = "^1.0.1" charmlibs-systemd = "^1.0.0.post0" charmlibs-interfaces-tls-certificates = "^1.8.3" -postgresql-charms-single-kernel = {extras = ["postgresql", "vm"], version = "16.3.1"} +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5aeebec.zip", extras = ["postgresql", "vm"]} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py diff --git a/src/charm.py b/src/charm.py index 0b9626453f..614b0329cd 100755 --- a/src/charm.py +++ b/src/charm.py @@ -109,10 +109,8 @@ SNAP_USER, SPI_MODULE, SYSTEM_USERS, - TLS_CA_BUNDLE_FILE, - TLS_CA_FILE, - TLS_CERT_FILE, - TLS_KEY_FILE, + TLS_CLIENT_RELATION, + TLS_PEER_RELATION, TRACING_PROTOCOL, TRACING_RELATION_NAME, UNIT_SCOPE, @@ -121,12 +119,13 @@ ) from single_kernel_postgresql.core.config import CharmConfig from single_kernel_postgresql.core.state import CharmState +from single_kernel_postgresql.events.tls import TLS from single_kernel_postgresql.events.tls_transfer import TLSTransfer from single_kernel_postgresql.managers.cluster import ClusterManager from single_kernel_postgresql.managers.config import ConfigManager from single_kernel_postgresql.managers.patroni import PatroniManager from single_kernel_postgresql.managers.tls import TLSManager -from single_kernel_postgresql.utils import label2name, new_password, render_file +from single_kernel_postgresql.utils import label2name, new_password from single_kernel_postgresql.utils.postgresql import ( ACCESS_GROUP_IDENTITY, ACCESS_GROUPS, @@ -155,7 +154,6 @@ ) from constants import ( MONITORING_SNAP_SERVICE, - PATRONI_CONF_PATH, PGBACKREST_MONITORING_SNAP_SERVICE, POSTGRESQL_DATA_DIR, RAFT_PARTNER_PREFIX, @@ -167,7 +165,6 @@ from ldap import PostgreSQLLDAP from relations.async_replication import PostgreSQLAsyncReplication from relations.postgresql_provider import PostgreSQLProvider -from relations.tls import TLS from relations.watcher import PostgreSQLWatcherRelation from rotate_logs import RotateLogs @@ -379,11 +376,10 @@ def __init__(self, *args): # TODO switch to the abstract class base # State - self.state = CharmState(charm=self, substrate=self.substrate) # type: ignore + self.state = CharmState(charm=self, substrate=self.substrate) # Managers self.patroni_manager = PatroniManager(state=self.state, workload=self.workload) - self.tls_manager = TLSManager(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) @@ -421,7 +417,31 @@ def __init__(self, *args): self.postgresql_client_relation = PostgreSQLProvider(self) self.backup = PostgreSQLBackups(self, "s3-parameters") self.ldap = PostgreSQLLDAP(self, "ldap") - self.tls = TLS(self, PEER_RELATION) + # TLS events handler owns the two cert requirers; build it before the TLS + # manager so the manager can constructor-inject them for its live-fetch getters. + self.tls = TLS(self, self.state, self.workload) + self.tls_manager = TLSManager( + state=self.state, + workload=self.workload, + client_certificate=self.tls.client_certificate, + peer_certificate=self.tls.peer_certificate, + ) + # Bridge the lib TLS handler's requirer events back into a PostgreSQL reload: + # the lib handler stores+pushes certs on certificate_available, then we reload. + # Also fires on relation_broken so detaching the TLS operator re-renders Patroni + # with TLS disabled. + self.framework.observe( + self.tls.client_certificate.on.certificate_available, self._reload_tls_after_push + ) + self.framework.observe( + self.tls.peer_certificate.on.certificate_available, self._reload_tls_after_push + ) + self.framework.observe( + self.on[TLS_CLIENT_RELATION].relation_broken, self._reload_tls_after_push + ) + self.framework.observe( + self.on[TLS_PEER_RELATION].relation_broken, self._reload_tls_after_push + ) self.tls_transfer = TLSTransfer(self, PEER_RELATION) self.async_replication = PostgreSQLAsyncReplication(self) self.watcher_offer = PostgreSQLWatcherRelation(self) @@ -498,6 +518,49 @@ def substrate(self) -> Substrates: """ return Substrates.VM + def _reload_tls_after_push(self, event) -> None: + """Reload PostgreSQL after the lib TLS handler stores+pushes certs. + + Also fires on the TLS relations' relation_broken so detaching the TLS + operator re-renders Patroni with TLS disabled and reloads. + + Mirror the handler's readiness guard: when the internal CA is absent the + handler defers its push (no files on disk), so skip the reload to avoid + rendering ssl:on against missing TLS files on an already-running unit. + + A transient config-apply failure (Patroni API unreachable, member not + started) defers and retries rather than leaving stale TLS state or failing + the hook. + """ + if not self.get_secret(APP_SCOPE, "internal-ca"): + return + # Don't enable TLS in the config until the lib has written the cert files to + # disk (its push can defer while this local render would still succeed, which + # would start Patroni ssl:on against missing files). + if self.is_tls_enabled and not self.tls_manager.client_tls_files_on_disk(): + event.defer() + return + try: + if not self.update_config(): + event.defer() + except Exception: + logger.exception("TLS reload (update_config) failed; deferring") + event.defer() + + def _regenerate_internal_cert(self, *, reload: bool = True) -> None: + """Generate the internal peer cert, push it to the workload, and (optionally) reload. + + reload=False is used at cluster bootstrap: the leader renders patroni.yml on + leader-elected and each replica renders it in _on_peer_relation_changed just + before starting Patroni, so a reload here would be redundant. The internal peer + cert does not toggle ssl in the config -- only the operator/client cert does, via + is_tls_enabled -- so skipping the reload cannot leave a stale ssl setting. + """ + self.tls_manager.generate_internal_peer_cert() + self.tls_manager.push_tls_files() + if reload: + self.update_config() + def _check_and_update_internal_cert(self) -> None: """Check if the internal cert CN matches the unit IP and regenerate if needed.""" try: @@ -509,7 +572,7 @@ def _check_and_update_internal_cert(self) -> None: != self.state.unit_ip ) ): - self.tls.generate_internal_peer_cert() + self._regenerate_internal_cert() except Exception: logger.exception("Unable to check or update internal cert") @@ -1548,7 +1611,7 @@ def is_standby_cluster(self) -> bool: @property def is_tls_enabled(self) -> bool: """Return whether TLS is enabled.""" - return all(self.tls.get_client_tls_files()) + return all(self.tls_manager.get_client_tls_files()) @property def _peer_members_ips(self) -> set[str]: @@ -1759,7 +1822,7 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: # noqa: C901 logger.debug("On leader elected failed to reconfigure cluster.") if not self.get_secret(APP_SCOPE, "internal-ca"): - self.tls.generate_internal_peer_ca() + self.tls_manager.generate_internal_peer_ca() self.update_config() # Don't update connection endpoints in the first time this event run for @@ -1946,7 +2009,7 @@ def _on_start(self, event: StartEvent) -> None: event.defer() return if not self.get_secret(UNIT_SCOPE, "internal-cert"): - self.tls.generate_internal_peer_cert() + self._regenerate_internal_cert(reload=False) self.unit_peer_data.update({"ip": self.state.unit_ip}) self._ensure_storage_layout() @@ -2497,10 +2560,12 @@ def _update_certificate(self) -> None: # Request the certificate only if there is already one. If there isn't, # the certificate will be generated in the relation joined event when # relating to the TLS Certificates Operator. - if all(self.tls.get_client_tls_files()) or all(self.tls.get_peer_tls_files()): + if all(self.tls_manager.get_client_tls_files()) or all( + self.tls_manager.get_peer_tls_files() + ): self.tls.refresh_tls_certificates_event.emit() if self.get_secret(UNIT_SCOPE, "internal-cert"): - self.tls.generate_internal_peer_cert() + self._regenerate_internal_cert() @property def is_blocked(self) -> bool: @@ -2582,37 +2647,6 @@ def _peers(self) -> Relation | None: """ return self.model.get_relation(PEER_RELATION) - def push_tls_files_to_workload(self) -> bool: - """Move TLS files to the PostgreSQL storage path and enable TLS.""" - key, ca, cert = self.tls.get_client_tls_files() - if key is not None: - render_file(Substrates.VM, f"{PATRONI_CONF_PATH}/{TLS_KEY_FILE}", key, 0o600) - if ca is not None: - render_file(Substrates.VM, f"{PATRONI_CONF_PATH}/{TLS_CA_FILE}", ca, 0o600) - if cert is not None: - render_file(Substrates.VM, f"{PATRONI_CONF_PATH}/{TLS_CERT_FILE}", cert, 0o600) - - key, ca, cert = self.tls.get_peer_tls_files() - if key is not None: - render_file(Substrates.VM, f"{PATRONI_CONF_PATH}/peer_{TLS_KEY_FILE}", key, 0o600) - if ca is not None: - render_file(Substrates.VM, f"{PATRONI_CONF_PATH}/peer_{TLS_CA_FILE}", ca, 0o600) - if cert is not None: - render_file(Substrates.VM, f"{PATRONI_CONF_PATH}/peer_{TLS_CERT_FILE}", cert, 0o600) - - render_file( - Substrates.VM, - f"{PATRONI_CONF_PATH}/{TLS_CA_BUNDLE_FILE}", - self.tls.get_peer_ca_bundle(), - 0o600, - ) - - try: - return self.update_config() - except Exception: - logger.exception("TLS files failed to push. Error in config update") - return False - def push_ca_file_into_workload(self, secret_name: str) -> bool: """Move CA certificates file into the PostgreSQL storage path.""" certs = self.get_secret(UNIT_SCOPE, secret_name) diff --git a/src/relations/postgresql_provider.py b/src/relations/postgresql_provider.py index 7b5f0bd699..a0e47d79aa 100644 --- a/src/relations/postgresql_provider.py +++ b/src/relations/postgresql_provider.py @@ -485,7 +485,7 @@ def update_endpoints(self, event: DatabaseRequestedEvent | None = None) -> None: tls = "True" if self.charm.is_tls_enabled else "False" ca = None if tls == "True": - _, ca, _ = self.charm.tls.get_client_tls_files() + _, ca, _ = self.charm.tls_manager.get_client_tls_files() if not ca: ca = "" diff --git a/src/relations/tls.py b/src/relations/tls.py deleted file mode 100644 index 2c3bc92b5a..0000000000 --- a/src/relations/tls.py +++ /dev/null @@ -1,259 +0,0 @@ -# Copyright 2022 Canonical Ltd. -# See LICENSE file for licensing details. - -"""TLS Handler.""" - -import logging -import socket -from datetime import timedelta -from typing import TYPE_CHECKING - -from charmlibs.interfaces.tls_certificates import ( - Certificate, - CertificateRequestAttributes, - PrivateKey, - TLSCertificatesRequiresV4, - generate_ca, - generate_certificate, - generate_csr, - generate_private_key, -) -from ops import ( - EventSource, -) -from ops.framework import EventBase, Object -from ops.pebble import ConnectionError as PebbleConnectionError -from ops.pebble import PathError, ProtocolError -from single_kernel_postgresql.config.literals import ( - APP_SCOPE, - UNIT_SCOPE, -) -from tenacity import RetryError - -if TYPE_CHECKING: - from charm import PostgresqlOperatorCharm - -logger = logging.getLogger(__name__) -SCOPE = "unit" -TLS_CLIENT_RELATION = "client-certificates" -TLS_PEER_RELATION = "peer-certificates" - - -class RefreshTLSCertificatesEvent(EventBase): - """Event for refreshing TLS certificates.""" - - -class TlsError(Exception): - """TLS implementation internal exception.""" - - -class TLS(Object): - """In this class we manage certificates relation.""" - - refresh_tls_certificates_event = EventSource(RefreshTLSCertificatesEvent) - - def _get_client_addrs(self) -> set[str]: - client_addrs = set() - if addr := self.charm.unit_peer_data.get("database-address"): - client_addrs.add(addr) - return client_addrs - - def _get_peer_addrs(self) -> set[str]: - peer_addrs = set() - if addr := self.charm.unit_peer_data.get("database-peers-address"): - peer_addrs.add(addr) - if addr := self.charm.unit_peer_data.get("replication-address"): - peer_addrs.add(addr) - if addr := self.charm.unit_peer_data.get("replication-offer-address"): - peer_addrs.add(addr) - if addr := self.charm.unit_peer_data.get( - "ip", self.charm.unit_peer_data.get("private-address") - ): - peer_addrs.add(addr) - return peer_addrs - - def _get_common_name(self) -> str: - return self.charm.unit_peer_data.get("database-address") or self.host - - def _get_peer_common_name(self) -> str: - return self.charm.unit_peer_data.get("database-peers-address") or self.host - - def __init__(self, charm: "PostgresqlOperatorCharm", peer_relation: str): - super().__init__(charm, "client-relations") - self.charm = charm - self.peer_relation = peer_relation - unit_id = self.charm.unit.name.split("/")[1] - self.host = f"{self.charm.app.name}-{unit_id}" - if self.charm.unit_peer_data: - client_addresses = self._get_client_addrs() - peer_addresses = self._get_peer_addrs() - else: - client_addresses = set() - peer_addresses = set() - self.common_hosts = {self.host} - if fqdn := socket.getfqdn(): - self.common_hosts.add(fqdn) - - self.client_certificate = TLSCertificatesRequiresV4( - self.charm, - TLS_CLIENT_RELATION, - certificate_requests=[ - CertificateRequestAttributes( - common_name=self._get_common_name(), - sans_ip=frozenset(client_addresses), - sans_dns=frozenset({ - *self.common_hosts, - # IP address need to be part of the DNS SANs list due to - # https://github.com/pgbackrest/pgbackrest/issues/1977. - *client_addresses, - }), - ), - ], - refresh_events=[self.refresh_tls_certificates_event], - ) - self.peer_certificate = TLSCertificatesRequiresV4( - self.charm, - TLS_PEER_RELATION, - certificate_requests=[ - CertificateRequestAttributes( - common_name=self._get_peer_common_name(), - sans_ip=frozenset(self._get_peer_addrs()), - sans_dns=frozenset({ - *self.common_hosts, - # IP address need to be part of the DNS SANs list due to - # https://github.com/pgbackrest/pgbackrest/issues/1977. - *peer_addresses, - }), - ), - ], - refresh_events=[self.refresh_tls_certificates_event], - ) - - self.framework.observe( - self.client_certificate.on.certificate_available, self._on_certificate_available - ) - self.framework.observe( - self.peer_certificate.on.certificate_available, self._on_peer_certificate_available - ) - - self.framework.observe( - self.charm.on[TLS_CLIENT_RELATION].relation_broken, self._on_certificate_available - ) - self.framework.observe( - self.charm.on[TLS_PEER_RELATION].relation_broken, self._on_peer_certificate_available - ) - - def _on_peer_certificate_available(self, event: EventBase) -> None: - certs, _ = self.peer_certificate.get_assigned_certificates() - new_ca = str(certs[0].ca) if certs else None - current_ca = self.charm.get_secret(UNIT_SCOPE, "current-ca") - # Stash the CAs in case of rotation - if new_ca != current_ca: - self.charm.set_secret(UNIT_SCOPE, "current-ca", new_ca) - self.charm.set_secret(UNIT_SCOPE, "old-ca", current_ca) - self._on_certificate_available(event) - - def _on_certificate_available(self, event: EventBase) -> None: - if not self.charm.get_secret(APP_SCOPE, "internal-ca"): - logger.debug("Charm not ready yet") - event.defer() - return - try: - if not self.charm.push_tls_files_to_workload(): - logger.debug("Cannot push TLS certificates at this moment") - event.defer() - return - except (PebbleConnectionError, PathError, ProtocolError, RetryError) as e: - logger.error("Cannot push TLS certificates: %r", e) - event.defer() - return - - def get_client_tls_files(self) -> tuple[str | None, str | None, str | None]: - """Prepare TLS files in special PostgreSQL way. - - PostgreSQL needs three files: - — CA file should have a full chain. - — Key file should have private key. - — Certificate file should have certificate without certificate chain. - """ - ca_file = None - cert = None - key = None - certs, private_key = self.client_certificate.get_assigned_certificates() - if private_key: - key = str(private_key) - if certs: - cert = str(certs[0].certificate) - ca_file = str(certs[0].ca) - return key, ca_file, cert - - def get_peer_tls_files(self) -> tuple[str | None, str | None, str | None]: - """Prepare TLS files in special PostgreSQL way. - - PostgreSQL needs three files: - — CA file should have a full chain. - — Key file should have private key. - — Certificate file should have certificate without certificate chain. - """ - ca_file = None - cert = None - key = None - certs, private_key = self.peer_certificate.get_assigned_certificates() - if private_key: - key = str(private_key) - if certs: - cert = str(certs[0].certificate) - ca_file = str(certs[0].ca) - if not all((key, ca_file, cert)): - key = self.charm.get_secret(UNIT_SCOPE, "internal-key") - cert = self.charm.get_secret(UNIT_SCOPE, "internal-cert") - ca_file = self.charm.get_secret(APP_SCOPE, "internal-ca") - return key, ca_file, cert - - def get_peer_ca_bundle(self) -> str: - """Get bundled CA certs.""" - certs, _ = self.peer_certificate.get_assigned_certificates() - operator_ca = str(certs[0].ca) if certs else "" - old_operator_ca = self.charm.get_secret(UNIT_SCOPE, "old-ca") or "" - internal_ca = self.charm.get_secret(APP_SCOPE, "internal-ca") or "" - return "\n".join((operator_ca, old_operator_ca, internal_ca)).strip() - - def generate_internal_peer_ca(self) -> None: - """Generate internal peer CA using the tls lib.""" - private_key = generate_private_key() - ca = generate_ca( - private_key, - common_name=f"{self.charm.app.name}-{self.charm.model.uuid}", - validity=timedelta(days=7300), - ) - logger.warning("Internal peer CA generated. Please use a proper TLS operator if possible.") - self.charm.set_secret(APP_SCOPE, "internal-ca-key", str(private_key)) - self.charm.set_secret(APP_SCOPE, "internal-ca", str(ca)) - - def generate_internal_peer_cert(self) -> None: - """Generate internal peer certificate using the tls lib.""" - if not (ca_key_secret := self.charm.get_secret(APP_SCOPE, "internal-ca-key")): - raise TlsError("No CA key content.") - ca_key = PrivateKey.from_string(ca_key_secret) - if not (ca_secret := self.charm.get_secret(APP_SCOPE, "internal-ca")): - raise TlsError("No CA cert content.") - ca = Certificate.from_string(ca_secret) - private_key = generate_private_key() - csr = generate_csr( - private_key, - common_name=self._get_peer_common_name(), - sans_ip=frozenset(self._get_peer_addrs()), - sans_dns=frozenset({ - *self.common_hosts, - # IP address need to be part of the DNS SANs list due to - # https://github.com/pgbackrest/pgbackrest/issues/1977. - *self._get_peer_addrs(), - }), - ) - cert = generate_certificate(csr, ca, ca_key, validity=timedelta(days=7300)) - self.charm.set_secret(UNIT_SCOPE, "internal-key", str(private_key)) - self.charm.set_secret(UNIT_SCOPE, "internal-cert", str(cert)) - self.charm.push_tls_files_to_workload() - logger.info( - "Internal peer certificate generated. Please use a proper TLS operator if possible." - ) diff --git a/src/relations/watcher.py b/src/relations/watcher.py index 9cc92a3bef..4f46e5f760 100644 --- a/src/relations/watcher.py +++ b/src/relations/watcher.py @@ -419,7 +419,7 @@ def _update_relation_data(self, relation: Relation) -> None: "raft-secret-id": secret_id, "raft-partner-addrs": json.dumps(pg_endpoints), "raft-port": str(RAFT_PORT), - "patroni-cas": self.charm.tls.get_peer_ca_bundle(), + "patroni-cas": self.charm.tls_manager.get_peer_ca_bundle(), "standby-clusters": json.dumps(self._get_standby_clusters()), "tls-enabled": "true" if self.charm.is_tls_enabled else "false", }) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 7a9db219e5..9e6c31b7a8 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -188,7 +188,7 @@ def test_on_leader_elected(harness): ) as _primary_endpoint, patch("charm.PostgresqlOperatorCharm.update_config") as _update_config, patch("charm.PostgresqlOperatorCharm._reconfigure_cluster"), - patch("charm.TLS.generate_internal_peer_cert"), + patch("charm.TLSManager.generate_internal_peer_cert"), ): # Assert that there is no password in the peer relation. assert harness.charm._peers.data[harness.charm.app].get("operator-password", None) is None @@ -444,7 +444,7 @@ def test_on_start_bootstrap_failure(harness): patch("charm.PostgresqlOperatorCharm._ensure_storage_layout"), patch("charm.PostgresqlOperatorCharm._check_detached_storage"), patch("charm.PostgresqlOperatorCharm.get_secret"), - patch("charm.TLS.generate_internal_peer_cert"), + patch("charm.TLSManager.generate_internal_peer_cert"), patch("charm.PatroniManager.bootstrap_cluster") as _bootstrap_cluster, patch("charm.PostgresqlOperatorCharm.update_config"), patch("charm.start_raft_observer"), @@ -475,7 +475,7 @@ def test_on_start_create_user_error(harness): patch("charm.PostgresqlOperatorCharm._ensure_storage_layout"), patch("charm.PostgresqlOperatorCharm._check_detached_storage"), patch("charm.PostgresqlOperatorCharm.get_secret"), - patch("charm.TLS.generate_internal_peer_cert"), + patch("charm.TLSManager.generate_internal_peer_cert"), patch("charm.PatroniManager.bootstrap_cluster") as _bootstrap_cluster, patch( "charm.PatroniManager.member_started", @@ -532,7 +532,7 @@ def test_on_start_success(harness): patch("charm.PostgresqlOperatorCharm._ensure_storage_layout"), patch("charm.PostgresqlOperatorCharm._check_detached_storage"), patch("charm.PostgresqlOperatorCharm.get_secret"), - patch("charm.TLS.generate_internal_peer_cert"), + patch("charm.TLSManager.generate_internal_peer_cert"), patch("charm.PatroniManager.bootstrap_cluster") as _bootstrap_cluster, patch( "charm.PatroniManager.member_started", @@ -661,7 +661,7 @@ def test_on_start_replica(harness): return_value=True, ) as _is_storage_attached, patch("charm.PostgresqlOperatorCharm.get_secret"), - patch("charm.TLS.generate_internal_peer_cert"), + patch("charm.TLSManager.generate_internal_peer_cert"), patch("charm.start_raft_observer"), ): _get_postgresql_version.return_value = "16.6" @@ -720,7 +720,7 @@ def test_on_start_no_patroni_member(harness): ) as _is_storage_attached, patch("charm.PostgresqlOperatorCharm.get_available_memory") as _get_available_memory, patch("charm.PostgresqlOperatorCharm.get_secret"), - patch("charm.TLS.generate_internal_peer_cert"), + patch("charm.TLSManager.generate_internal_peer_cert"), patch("charm.PostgreSQLProvider.get_username_mapping", return_value={}), patch("charm.PostgreSQLProvider.get_databases_prefix_mapping", return_value={}), patch("charm.start_raft_observer"), @@ -2344,7 +2344,7 @@ def test_reconfigure_cluster(harness): def test_update_certificate(harness): with ( - patch("charm.TLS.get_client_tls_files") as _get_client_tls_files, + patch("charm.TLSManager.get_client_tls_files") as _get_client_tls_files, patch("charm.TLS.refresh_tls_certificates_event") as _refresh_tls_certificates_event, ): # If there is no current TLS files, _request_certificate should be called @@ -2402,41 +2402,6 @@ def test_update_member_ip(harness): _update_certificate.assert_called_once() -def test_push_tls_files_to_workload(harness): - with ( - patch("charm.PostgresqlOperatorCharm.update_config") as _update_config, - patch("charm.render_file") as _render_file, - patch("charm.TLS.get_client_tls_files") as _get_client_tls_files, - patch("charm.TLS.get_peer_tls_files") as _get_peer_tls_files, - patch( - "charm.PostgresqlOperatorCharm.get_secret", return_value="internal_ca" - ) as _get_secret, - ): - _get_client_tls_files.side_effect = [ - ("key", "ca", "cert"), - ("key", "ca", None), - ("key", None, "cert"), - (None, "ca", "cert"), - ] - _get_peer_tls_files.side_effect = [ - ("key", "ca", "cert"), - ("key", "ca", None), - ("key", None, "cert"), - (None, "ca", "cert"), - ] - _update_config.side_effect = [True, False, False, False] - - # Test when all TLS files are available. - assert harness.charm.push_tls_files_to_workload() - assert _render_file.call_count == 7 - - # Test when not all TLS files are available. - for _ in range(3): - _render_file.reset_mock() - assert not (harness.charm.push_tls_files_to_workload()) - assert _render_file.call_count == 5 - - def test_push_ca_file_into_workload(harness): with ( patch("charm.PostgresqlOperatorCharm.update_config") as _update_config, diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index c8ce927080..8a67b73028 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -1,172 +1,191 @@ # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. -from datetime import timedelta -from unittest.mock import Mock, patch, sentinel +"""TLS wiring tests for the single-kernel (lib) TLS handler. + +The charm consumes the library TLS handler (single_kernel_postgresql.events.tls.TLS) +and TLS manager (single_kernel_postgresql.managers.tls.TLSManager) instead of its +own removed src/relations/tls.py. These tests exercise the lib-backed wiring: +state-backed cert storage via TLSManager, and the charm's reload bridge that calls +update_config after the handler stores+pushes certificates. +""" + +from unittest.mock import Mock, PropertyMock, patch import pytest from ops.testing import Harness -from single_kernel_postgresql.config.literals import PEER_RELATION +from single_kernel_postgresql.config.literals import ( + PEER_RELATION, + TLS_CLIENT_RELATION, + TLS_PEER_RELATION, +) +from single_kernel_postgresql.events.tls import TLS +from single_kernel_postgresql.managers.tls import TLSManager from charm import PostgresqlOperatorCharm @pytest.fixture(autouse=True) -def harness(): - with patch("relations.tls.socket") as _socket: - _socket.getfqdn.return_value = "fqdn" - harness = Harness(PostgresqlOperatorCharm) - - # Set up the initial relation and hooks. - peer_rel_id = harness.add_relation(PEER_RELATION, "postgresql") - harness.add_relation_unit(peer_rel_id, "postgresql/0") - harness.begin() - yield harness - harness.cleanup() +def _platform_machine(monkeypatch): + # refresh_versions.toml keys snap revisions by x86_64/aarch64; ensure the charm + # constructs regardless of the host arch (e.g. arm64 dev laptops). + monkeypatch.setattr("platform.machine", lambda: "x86_64") -def test_generate_internal_peer_cert(harness): - with ( - patch( - "relations.tls.generate_private_key", return_value=sentinel.cert_key - ) as _generate_private_key, - patch("relations.tls.generate_csr", return_value=sentinel.cert_csr) as _generate_csr, - patch( - "relations.tls.generate_certificate", return_value=sentinel.cert - ) as _generate_certificate, - patch("relations.tls.PrivateKey") as _private_key, - patch("relations.tls.Certificate") as _certificate, - patch("charm.PostgresqlOperatorCharm.set_secret") as _set_secret, - patch("charm.PostgresqlOperatorCharm.get_secret", return_value="secret value"), - patch( - "charm.PostgresqlOperatorCharm.push_tls_files_to_workload" - ) as _push_tls_files_to_workload, - ): - _private_key.from_string.return_value = sentinel.ca_key - _certificate.from_string.return_value = sentinel.ca_cert - - harness.charm.tls.generate_internal_peer_cert() - - _generate_csr.assert_called_once_with( - sentinel.cert_key, - common_name="postgresql-0", - sans_ip=frozenset(), - sans_dns=frozenset({"postgresql-0", "fqdn"}), - ) - _generate_certificate.assert_called_once_with( - sentinel.cert_csr, sentinel.ca_cert, sentinel.ca_key, validity=timedelta(days=7300) - ) - assert _set_secret.call_count == 2 - _set_secret.assert_any_call("unit", "internal-key", str(sentinel.cert_key)) - _set_secret.assert_any_call("unit", "internal-cert", str(sentinel.cert)) - _push_tls_files_to_workload.assert_called_once_with() - - -def test_get_client_tls_files(harness): - with patch( - "relations.tls.TLSCertificatesRequiresV4.get_assigned_certificates" - ) as _get_assigned_certificates: - cert_mock = Mock() - cert_mock.certificate = sentinel.certificate - cert_mock.ca = sentinel.ca - _get_assigned_certificates.return_value = ([cert_mock], sentinel.private_key) - - assert harness.charm.tls.get_client_tls_files() == ( - "sentinel.private_key", - "sentinel.ca", - "sentinel.certificate", - ) - - _get_assigned_certificates.return_value = (None, None) - assert harness.charm.tls.get_client_tls_files() == (None, None, None) - - -def test_get_peer_tls_files(harness): - with ( - patch( - "relations.tls.TLSCertificatesRequiresV4.get_assigned_certificates" - ) as _get_assigned_certificates, - patch( - "charm.PostgresqlOperatorCharm.get_secret", return_value=sentinel.secret - ) as _get_secret, - ): - cert_mock = Mock() - cert_mock.certificate = sentinel.certificate - cert_mock.ca = sentinel.ca - _get_assigned_certificates.return_value = ([cert_mock], sentinel.private_key) - - assert harness.charm.tls.get_peer_tls_files() == ( - "sentinel.private_key", - "sentinel.ca", - "sentinel.certificate", - ) - assert not _get_secret.called - - _get_assigned_certificates.return_value = (None, None) - assert harness.charm.tls.get_peer_tls_files() == ( - sentinel.secret, - sentinel.secret, - sentinel.secret, - ) - assert _get_secret.call_count == 3 - _get_secret.assert_any_call("unit", "internal-key") - _get_secret.assert_any_call("unit", "internal-cert") - _get_secret.assert_any_call("app", "internal-ca") - - -def test_on_client_certificate_available(harness): +@pytest.fixture(autouse=True) +def harness(_platform_machine): + harness = Harness(PostgresqlOperatorCharm) + peer_rel_id = harness.add_relation(PEER_RELATION, "postgresql") + harness.add_relation_unit(peer_rel_id, "postgresql/0") + harness.begin() + yield harness + harness.cleanup() + + +def test_tls_handler_is_lib_backed(harness): + """The charm wires the lib TLS handler + manager (not the removed relations.tls).""" + charm = harness.charm + assert isinstance(charm.tls, TLS) + assert isinstance(charm.tls_manager, TLSManager) + # The handler owns the operator client/peer requirers and the refresh event. + assert hasattr(charm.tls, "client_certificate") + assert hasattr(charm.tls, "peer_certificate") + assert hasattr(charm.tls, "refresh_tls_certificates_event") + # The removed method must not resurface anywhere. + assert not hasattr(charm, "push_tls_files_to_workload") + + +def test_is_tls_enabled_reflects_tls_manager(harness): + """is_tls_enabled is driven by TLSManager.get_client_tls_files(), not the handler.""" + with patch("charm.TLSManager.get_client_tls_files") as _get_client_tls_files: + _get_client_tls_files.return_value = (None, None, None) + assert harness.charm.is_tls_enabled is False + + _get_client_tls_files.return_value = ("key", "ca", "cert") + assert harness.charm.is_tls_enabled is True + + +def _observers_for(harness, bound_event): + """Return method names observing the given bound event, in registration order.""" + emitter_path = bound_event.emitter.handle.path + event_kind = bound_event.event_kind + return [ + method + for (_obs_path, method, e_path, e_kind) in harness.framework._observers + if e_path == emitter_path and e_kind == event_kind + ] + + +def test_reload_bridge_wired_after_handler_on_client_certificate(harness): + """The reload bridge observes the same certificate_available event as the handler. + + The lib handler's store+push observer must be registered BEFORE the charm's + reload bridge so that, when the event fires, certs are stored+pushed first and + the reload (update_config) runs afterwards (ops calls observers in order). + """ + methods = _observers_for( + harness, harness.charm.tls.client_certificate.on.certificate_available + ) + assert "_on_certificate_available" in methods, methods + assert "_reload_tls_after_push" in methods, methods + # Handler (store+push) before bridge (reload). + assert methods.index("_on_certificate_available") < methods.index("_reload_tls_after_push") + + +def test_reload_bridge_wired_after_handler_on_peer_certificate(harness): + """The reload bridge also observes the peer certificate_available event.""" + methods = _observers_for(harness, harness.charm.tls.peer_certificate.on.certificate_available) + assert "_on_peer_certificate_available" in methods, methods + assert "_reload_tls_after_push" in methods, methods + assert methods.index("_on_peer_certificate_available") < methods.index( + "_reload_tls_after_push" + ) + + +def test_reload_bridge_calls_update_config(harness): + """_reload_tls_after_push delegates to update_config when internal-ca is present.""" + with harness.hooks_disabled(): + harness.set_leader(True) + harness.charm.set_secret("app", "internal-ca", "ca-content") + + with patch("charm.PostgresqlOperatorCharm.update_config") as _update_config: + harness.charm._reload_tls_after_push(Mock()) + _update_config.assert_called_once_with() + + +def test_reload_bridge_skips_update_config_without_internal_ca(harness): + """_reload_tls_after_push is a no-op when internal-ca is absent (defer path). + + The lib TLS handler defers its push when the internal CA isn't present yet + (no files written to disk). The bridge must not call update_config in that + case, or it would render ssl:on against TLS files that don't exist yet. + """ + with patch("charm.PostgresqlOperatorCharm.update_config") as _update_config: + harness.charm._reload_tls_after_push(Mock()) + _update_config.assert_not_called() + + +def test_reload_bridge_wired_on_tls_relation_broken(harness): + """Detaching the TLS operator (relation_broken) must trigger the reload bridge. + + Regression test: the VM charm previously observed only certificate_available, so + removing a TLS relation left Patroni ssl:on against cleared certs. Both TLS + relations' relation_broken must reach the reload bridge (parity with K8s). + """ + for relation in (TLS_CLIENT_RELATION, TLS_PEER_RELATION): + methods = _observers_for(harness, harness.charm.on[relation].relation_broken) + assert "_reload_tls_after_push" in methods, (relation, methods) + + +def test_reload_bridge_defers_when_update_config_not_ready(harness): + """A transient config-apply failure must defer (retry), not leave stale TLS state.""" + with harness.hooks_disabled(): + harness.set_leader(True) + harness.charm.set_secret("app", "internal-ca", "ca-content") + + event = Mock() + with patch("charm.PostgresqlOperatorCharm.update_config", return_value=False): + harness.charm._reload_tls_after_push(event) + event.defer.assert_called_once() + + +def test_reload_bridge_defers_when_tls_files_not_yet_on_disk(harness): + """ssl:on must not be rendered until the lib has pushed the cert files to disk.""" + with harness.hooks_disabled(): + harness.set_leader(True) + harness.charm.set_secret("app", "internal-ca", "ca-content") + event = Mock() with ( - patch("charm.PostgresqlOperatorCharm.get_secret") as _get_secret, - patch( - "charm.PostgresqlOperatorCharm.push_tls_files_to_workload" - ) as _push_tls_files_to_workload, + patch("charm.PostgresqlOperatorCharm.update_config") as _uc, + # is_tls_enabled -> True via the live-fetch getter + patch("charm.TLSManager.get_client_tls_files", return_value=("K", "CA", "C")), + patch.object(harness.charm.tls_manager, "client_tls_files_on_disk", return_value=False), ): - # Defers if internal CA is not ready yet - _get_secret.return_value = None - event_mock = Mock() - - harness.charm.tls._on_certificate_available(event_mock) - - event_mock.defer.assert_called_once_with() - assert not _push_tls_files_to_workload.called - event_mock.reset_mock() - - # Defers if can't push - _get_secret.return_value = sentinel.secret - _push_tls_files_to_workload.return_value = False + harness.charm._reload_tls_after_push(event) + event.defer.assert_called_once() + _uc.assert_not_called() - harness.charm.tls._on_certificate_available(event_mock) - event_mock.defer.assert_called_once_with() - _push_tls_files_to_workload.assert_called_once_with() - event_mock.reset_mock() - - -def test_on_peer_certificate_available(harness): +def test_internal_cert_path_pushes_and_reloads(harness): + """_check_and_update_internal_cert generates, pushes, and reloads on CN mismatch.""" with ( + patch("charm.CharmState.unit_ip", new_callable=PropertyMock) as _unit_ip, patch( - "relations.tls.TLSCertificatesRequiresV4.get_assigned_certificates" - ) as _get_assigned_certificates, - patch("relations.tls.TLS._on_certificate_available") as _on_certificate_available, - patch("charm.PostgresqlOperatorCharm.get_secret") as _get_secret, - patch("charm.PostgresqlOperatorCharm.set_secret") as _set_secret, + "charm.PostgresqlOperatorCharm.get_secret", + return_value="-----BEGIN CERTIFICATE-----", + ), + patch("charm.load_pem_x509_certificate") as _load_cert, + patch("charm.TLSManager.generate_internal_peer_cert") as _generate, + patch("charm.TLSManager.push_tls_files") as _push, + patch("charm.PostgresqlOperatorCharm.update_config") as _update_config, ): - # Same ca - cert_mock = Mock() - cert_mock.ca = sentinel.ca - _get_assigned_certificates.return_value = ([cert_mock], None) - _get_secret.return_value = "sentinel.ca" - event_mock = Mock() - - harness.charm.tls._on_peer_certificate_available(event_mock) - - _on_certificate_available.assert_called_once_with(event_mock) - assert not _set_secret.called - - # Different ca - _get_secret.return_value = sentinel.old_ca + _unit_ip.return_value = "1.2.3.4" + # Make the cert CN differ from the unit IP to force regeneration. + attr = Mock() + attr.value = "9.9.9.9" + _load_cert.return_value.subject.get_attributes_for_oid.return_value = [attr] - harness.charm.tls._on_peer_certificate_available(event_mock) + harness.charm._check_and_update_internal_cert() - assert _set_secret.call_count == 2 - _set_secret.assert_any_call("unit", "current-ca", "sentinel.ca") - _set_secret.assert_any_call("unit", "old-ca", sentinel.old_ca) + _generate.assert_called_once_with() + _push.assert_called_once_with() + _update_config.assert_called_once_with() From ab21c96a23002878b4cbd7b88ad4c4ade73b9a99 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 3 Jul 2026 17:37:42 -0300 Subject: [PATCH 2/9] build(deps): bump single-kernel lib to the 16.3.3 stack tip The TLS lib stack was rebased onto current 16/edge and re-bumped to 16.3.3 because 16/edge had meanwhile shipped its own 16.3.2, and the rebase dropped the dead workload parameter from the TLS events handler constructor, so the pin and the construction call move together. Signed-off-by: Marcelo Henrique Neppel --- poetry.lock | 11 ++++++----- pyproject.toml | 2 +- src/charm.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index f8f0cf2f33..a0bb056c8c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1865,13 +1865,13 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "postgresql-charms-single-kernel" -version = "16.3.2" +version = "16.3.3" description = "Shared and reusable code for PostgreSQL-related charms" optional = false python-versions = ">=3.8,<4.0" groups = ["main", "integration"] files = [ - {file = "5aeebec.zip", hash = "sha256:76c2e468de1169912416fffaa51b474f79223b5caee16207c88ec71902d7ee6f"}, + {file = "5a9fa05.zip", hash = "sha256:0081d9b0a413bb8122d788054c175aa459611b817232542d4c01d6aaf7e8c5e3"}, ] [package.dependencies] @@ -1880,6 +1880,7 @@ charmlibs-interfaces-tls-certificates = {version = ">=1.8.3", optional = true, m charmlibs-pathops = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} charmlibs-rollingops = {version = ">=1.1.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} charmlibs-snap = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +charmlibs-systemd = {version = ">=1.0.0.post0", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} data-platform-helpers = {version = ">=0.1.7", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} httpx = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} jinja2 = {version = ">=3.1.6", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} @@ -1894,11 +1895,11 @@ tomli = {version = "*", optional = true, markers = "python_version >= \"3.12\" a [package.extras] db-driver = ["psycopg2 (>=2.9.10)"] postgresql = ["charm-refresh ; python_version >= \"3.12\"", "charmlibs-interfaces-tls-certificates (>=1.8.3) ; python_version >= \"3.12\"", "charmlibs-pathops (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-rollingops (>=1.1.1) ; python_version >= \"3.12\"", "data-platform-helpers (>=0.1.7) ; python_version >= \"3.12\"", "httpx ; python_version >= \"3.12\"", "jinja2 (>=3.1.6) ; python_version >= \"3.12\"", "pydantic (>=2.0) ; python_version >= \"3.12\"", "requests ; python_version >= \"3.12\"", "tomli ; python_version >= \"3.12\""] -vm = ["charmlibs-snap (>=1.0.1) ; python_version >= \"3.12\"", "psutil (>=7.2.2) ; python_version >= \"3.12\"", "pysyncobj (>=0.3.15) ; python_version >= \"3.12\""] +vm = ["charmlibs-snap (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-systemd (>=1.0.0.post0) ; python_version >= \"3.12\"", "psutil (>=7.2.2) ; python_version >= \"3.12\"", "pysyncobj (>=0.3.15) ; python_version >= \"3.12\""] [package.source] type = "url" -url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5aeebec.zip" +url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5a9fa05.zip" [[package]] name = "prompt-toolkit" @@ -3196,4 +3197,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "e49d6ddac7ca3a2d13571479e983cc73c99b15b4244ae803052c7e6d4c44d00e" +content-hash = "b84f2901f3732eba7616a6fe7c249d00b6de70efb92630990ca53e4e22b17a3d" diff --git a/pyproject.toml b/pyproject.toml index 2fb0dacd1c..521c86338d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ charm-refresh = "^3.1.0.2" charmlibs-snap = "^1.0.1" charmlibs-systemd = "^1.0.0.post0" charmlibs-interfaces-tls-certificates = "^1.8.3" -postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5aeebec.zip", extras = ["postgresql", "vm"]} +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5a9fa05.zip", extras = ["postgresql", "vm"]} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py diff --git a/src/charm.py b/src/charm.py index 614b0329cd..7151466c71 100755 --- a/src/charm.py +++ b/src/charm.py @@ -419,7 +419,7 @@ def __init__(self, *args): self.ldap = PostgreSQLLDAP(self, "ldap") # TLS events handler owns the two cert requirers; build it before the TLS # manager so the manager can constructor-inject them for its live-fetch getters. - self.tls = TLS(self, self.state, self.workload) + self.tls = TLS(self, self.state) self.tls_manager = TLSManager( state=self.state, workload=self.workload, From 2c02f639d8b1547d2f82e1fe04337ea00155eb3e Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 3 Jul 2026 17:39:22 -0300 Subject: [PATCH 3/9] test(tls): assert relation_broken bridge ordering and exception defer The relation_broken bridge registrations reference only self.on[...], so an __init__ reorder above the lib handler's construction would silently run the reload before the lib clears state and pushes files; the prior test asserted membership without order. The bridge's except-and-defer branch also had no coverage, so a refactor could drop the broad guard without failing CI. Signed-off-by: Marcelo Henrique Neppel --- tests/unit/test_tls.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index 8a67b73028..0b38b69b07 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -130,10 +130,24 @@ def test_reload_bridge_wired_on_tls_relation_broken(harness): Regression test: the VM charm previously observed only certificate_available, so removing a TLS relation left Patroni ssl:on against cleared certs. Both TLS relations' relation_broken must reach the reload bridge (parity with K8s). + + The ordering is also asserted: unlike the certificate_available wiring, these + registrations reference only ``self.on[...]``, so moving them above the lib + handler's construction in __init__ would silently reverse the order and run + the reload before the lib clears state and pushes files. """ - for relation in (TLS_CLIENT_RELATION, TLS_PEER_RELATION): + for relation, lib_handler in ( + (TLS_CLIENT_RELATION, "_on_certificate_available"), + (TLS_PEER_RELATION, "_on_peer_certificate_available"), + ): methods = _observers_for(harness, harness.charm.on[relation].relation_broken) + assert lib_handler in methods, (relation, methods) assert "_reload_tls_after_push" in methods, (relation, methods) + # Lib handler (clear+push) before charm bridge (reload). + assert methods.index(lib_handler) < methods.index("_reload_tls_after_push"), ( + relation, + methods, + ) def test_reload_bridge_defers_when_update_config_not_ready(harness): @@ -165,6 +179,27 @@ def test_reload_bridge_defers_when_tls_files_not_yet_on_disk(harness): _uc.assert_not_called() +def test_reload_bridge_defers_when_update_config_raises(harness): + """A raising update_config must be caught and deferred, not fail the hook. + + The bridge mirrors the original charm's broad push-failure guard: a transient + Patroni/render failure defers and retries rather than propagating out of the + observer. + """ + with harness.hooks_disabled(): + harness.set_leader(True) + harness.charm.set_secret("app", "internal-ca", "ca-content") + + event = Mock() + with patch( + "charm.PostgresqlOperatorCharm.update_config", + side_effect=RuntimeError("patroni render failed"), + ): + # Must not raise. + harness.charm._reload_tls_after_push(event) + event.defer.assert_called_once_with() + + def test_internal_cert_path_pushes_and_reloads(harness): """_check_and_update_internal_cert generates, pushes, and reloads on CN mismatch.""" with ( From 4679e059e6db0d3ae0d80d0e6a49a632d91091c2 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 7 Jul 2026 17:01:52 -0300 Subject: [PATCH 4/9] build(deps): bump single-kernel pin to a1d2178 (cleaned lib) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-pin the unreleased single-kernel library from 5a9fa05 to a1d2178, the rebased TLS-stack tip. a1d2178 includes the operator-cert manager/ events handler (the migration payload) plus the docstring/comment cleanup that dropped stale operator-repo file-path references and #168/V3-V4 idiom mentions from the lib. The 5a9fa05→a1d2178 diff is docstring/comment-only, so no charm-side code changes are required. Signed-off-by: Marcelo Henrique Neppel --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index f2f21064d8..893ca61e92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1871,7 +1871,7 @@ optional = false python-versions = ">=3.8,<4.0" groups = ["main", "integration"] files = [ - {file = "5a9fa05.zip", hash = "sha256:0081d9b0a413bb8122d788054c175aa459611b817232542d4c01d6aaf7e8c5e3"}, + {file = "a1d2178.zip", hash = "sha256:ecee7e88c9fc368d243c3cfe63a34cae43e8ac02930df584c4e5f18c24450ced"}, ] [package.dependencies] @@ -1899,7 +1899,7 @@ vm = ["charmlibs-snap (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-system [package.source] type = "url" -url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5a9fa05.zip" +url = "https://github.com/canonical/postgresql-single-kernel-library/archive/a1d2178.zip" [[package]] name = "prompt-toolkit" @@ -3197,4 +3197,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "21289ef76a90911f0b44206618fe32dec31dea906aaee6664563cf058ce7717d" +content-hash = "297ee31046108591418455efebca939191e9b55259a4ebf781f2470016edcd76" diff --git a/pyproject.toml b/pyproject.toml index 1330cc9ffc..e324c2a5a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ charm-refresh = "^3.1.0.2" charmlibs-snap = "^1.0.1" charmlibs-systemd = "^1.0.0.post0" charmlibs-interfaces-tls-certificates = "^1.8.3" -postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/5a9fa05.zip", extras = ["postgresql", "vm"]} +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/a1d2178.zip", extras = ["postgresql", "vm"]} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py From 0bf77a03f81560f97247bfc49c8478f29c635539 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 8 Jul 2026 17:02:06 -0300 Subject: [PATCH 5/9] test(tls): drop observer-wiring tests from test_tls.py Remove _platform_machine, _observers_for, and the three reload-bridge observer-ordering tests that exercised charm-side __init__ wiring now that the bridge itself is transitional and scheduled to leave the charm when update_config ports to the lib. _platform_machine was redundant: refresh_versions.toml keys both x86_64 and aarch64, so the platform.machine() snap-revision lookup succeeds natively on either arch without the monkeypatch (proven by the suite passing on aarch64 after removal). Its harness dependency and the now-unused TLS_CLIENT/TLS_PEER_RELATION imports go with it. Signed-off-by: Marcelo Henrique Neppel --- tests/unit/test_tls.py | 78 ++---------------------------------------- 1 file changed, 2 insertions(+), 76 deletions(-) diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index 0b38b69b07..b5197c8efa 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -13,11 +13,7 @@ import pytest from ops.testing import Harness -from single_kernel_postgresql.config.literals import ( - PEER_RELATION, - TLS_CLIENT_RELATION, - TLS_PEER_RELATION, -) +from single_kernel_postgresql.config.literals import PEER_RELATION from single_kernel_postgresql.events.tls import TLS from single_kernel_postgresql.managers.tls import TLSManager @@ -25,14 +21,7 @@ @pytest.fixture(autouse=True) -def _platform_machine(monkeypatch): - # refresh_versions.toml keys snap revisions by x86_64/aarch64; ensure the charm - # constructs regardless of the host arch (e.g. arm64 dev laptops). - monkeypatch.setattr("platform.machine", lambda: "x86_64") - - -@pytest.fixture(autouse=True) -def harness(_platform_machine): +def harness(): harness = Harness(PostgresqlOperatorCharm) peer_rel_id = harness.add_relation(PEER_RELATION, "postgresql") harness.add_relation_unit(peer_rel_id, "postgresql/0") @@ -64,43 +53,6 @@ def test_is_tls_enabled_reflects_tls_manager(harness): assert harness.charm.is_tls_enabled is True -def _observers_for(harness, bound_event): - """Return method names observing the given bound event, in registration order.""" - emitter_path = bound_event.emitter.handle.path - event_kind = bound_event.event_kind - return [ - method - for (_obs_path, method, e_path, e_kind) in harness.framework._observers - if e_path == emitter_path and e_kind == event_kind - ] - - -def test_reload_bridge_wired_after_handler_on_client_certificate(harness): - """The reload bridge observes the same certificate_available event as the handler. - - The lib handler's store+push observer must be registered BEFORE the charm's - reload bridge so that, when the event fires, certs are stored+pushed first and - the reload (update_config) runs afterwards (ops calls observers in order). - """ - methods = _observers_for( - harness, harness.charm.tls.client_certificate.on.certificate_available - ) - assert "_on_certificate_available" in methods, methods - assert "_reload_tls_after_push" in methods, methods - # Handler (store+push) before bridge (reload). - assert methods.index("_on_certificate_available") < methods.index("_reload_tls_after_push") - - -def test_reload_bridge_wired_after_handler_on_peer_certificate(harness): - """The reload bridge also observes the peer certificate_available event.""" - methods = _observers_for(harness, harness.charm.tls.peer_certificate.on.certificate_available) - assert "_on_peer_certificate_available" in methods, methods - assert "_reload_tls_after_push" in methods, methods - assert methods.index("_on_peer_certificate_available") < methods.index( - "_reload_tls_after_push" - ) - - def test_reload_bridge_calls_update_config(harness): """_reload_tls_after_push delegates to update_config when internal-ca is present.""" with harness.hooks_disabled(): @@ -124,32 +76,6 @@ def test_reload_bridge_skips_update_config_without_internal_ca(harness): _update_config.assert_not_called() -def test_reload_bridge_wired_on_tls_relation_broken(harness): - """Detaching the TLS operator (relation_broken) must trigger the reload bridge. - - Regression test: the VM charm previously observed only certificate_available, so - removing a TLS relation left Patroni ssl:on against cleared certs. Both TLS - relations' relation_broken must reach the reload bridge (parity with K8s). - - The ordering is also asserted: unlike the certificate_available wiring, these - registrations reference only ``self.on[...]``, so moving them above the lib - handler's construction in __init__ would silently reverse the order and run - the reload before the lib clears state and pushes files. - """ - for relation, lib_handler in ( - (TLS_CLIENT_RELATION, "_on_certificate_available"), - (TLS_PEER_RELATION, "_on_peer_certificate_available"), - ): - methods = _observers_for(harness, harness.charm.on[relation].relation_broken) - assert lib_handler in methods, (relation, methods) - assert "_reload_tls_after_push" in methods, (relation, methods) - # Lib handler (clear+push) before charm bridge (reload). - assert methods.index(lib_handler) < methods.index("_reload_tls_after_push"), ( - relation, - methods, - ) - - def test_reload_bridge_defers_when_update_config_not_ready(harness): """A transient config-apply failure must defer (retry), not leave stale TLS state.""" with harness.hooks_disabled(): From a48c0b23de05016f187e3f3c5e68e79ed646c171 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 10 Jul 2026 09:47:17 -0300 Subject: [PATCH 6/9] build(deps): pin single-kernel lib to published 16.3.3 The TLS flip branch pinned postgresql-charms-single-kernel to a GitHub archive URL (commit a1d2178) to test unreleased lib changes. 16.3.3 is now published to PyPI carrying the same TLS state + manager/events content, so restore the published-version pin form and regenerate poetry.lock against the PyPI wheel/tarball. Signed-off-by: Marcelo Henrique Neppel --- poetry.lock | 43 ++++++++++++++++++++----------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/poetry.lock b/poetry.lock index 893ca61e92..6e8a67ea33 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1868,38 +1868,35 @@ name = "postgresql-charms-single-kernel" version = "16.3.3" description = "Shared and reusable code for PostgreSQL-related charms" optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" groups = ["main", "integration"] files = [ - {file = "a1d2178.zip", hash = "sha256:ecee7e88c9fc368d243c3cfe63a34cae43e8ac02930df584c4e5f18c24450ced"}, + {file = "postgresql_charms_single_kernel-16.3.3-py3-none-any.whl", hash = "sha256:8dc0b3d93b14ebb11fc978b17c06f318221babeef9af6fb1aee1d7e423c8cb3e"}, + {file = "postgresql_charms_single_kernel-16.3.3.tar.gz", hash = "sha256:d31d0e69e0a0d5000d652bd54d002165ac88beabe37d71a01b77bdd453aedecb"}, ] [package.dependencies] -charm-refresh = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} -charmlibs-interfaces-tls-certificates = {version = ">=1.8.3", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} -charmlibs-pathops = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} -charmlibs-rollingops = {version = ">=1.1.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} -charmlibs-snap = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} -charmlibs-systemd = {version = ">=1.0.0.post0", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} -data-platform-helpers = {version = ">=0.1.7", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} -httpx = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} -jinja2 = {version = ">=3.1.6", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charm-refresh = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +charmlibs-interfaces-tls-certificates = {version = ">=1.8.3", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +charmlibs-pathops = {version = ">=1.0.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +charmlibs-rollingops = {version = ">=1.1.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +charmlibs-snap = {version = ">=1.0.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} +charmlibs-systemd = {version = ">=1.0.0.post0", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} +data-platform-helpers = {version = ">=0.1.7", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +httpx = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +jinja2 = {version = ">=3.1.6", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} ops = ">=2.0.0" -psutil = {version = ">=7.2.2", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} -pydantic = {version = ">=2.0", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} -pysyncobj = {version = ">=0.3.15", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} -requests = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +psutil = {version = ">=7.2.2", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} +pydantic = {version = ">=2.0", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +pysyncobj = {version = ">=0.3.15", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} +requests = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} tenacity = ">=9.0.0" -tomli = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +tomli = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} [package.extras] db-driver = ["psycopg2 (>=2.9.10)"] -postgresql = ["charm-refresh ; python_version >= \"3.12\"", "charmlibs-interfaces-tls-certificates (>=1.8.3) ; python_version >= \"3.12\"", "charmlibs-pathops (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-rollingops (>=1.1.1) ; python_version >= \"3.12\"", "data-platform-helpers (>=0.1.7) ; python_version >= \"3.12\"", "httpx ; python_version >= \"3.12\"", "jinja2 (>=3.1.6) ; python_version >= \"3.12\"", "pydantic (>=2.0) ; python_version >= \"3.12\"", "requests ; python_version >= \"3.12\"", "tomli ; python_version >= \"3.12\""] -vm = ["charmlibs-snap (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-systemd (>=1.0.0.post0) ; python_version >= \"3.12\"", "psutil (>=7.2.2) ; python_version >= \"3.12\"", "pysyncobj (>=0.3.15) ; python_version >= \"3.12\""] - -[package.source] -type = "url" -url = "https://github.com/canonical/postgresql-single-kernel-library/archive/a1d2178.zip" +postgresql = ["charm-refresh ; python_full_version >= \"3.12.0\"", "charmlibs-interfaces-tls-certificates (>=1.8.3) ; python_full_version >= \"3.12.0\"", "charmlibs-pathops (>=1.0.1) ; python_full_version >= \"3.12.0\"", "charmlibs-rollingops (>=1.1.1) ; python_full_version >= \"3.12.0\"", "data-platform-helpers (>=0.1.7) ; python_full_version >= \"3.12.0\"", "httpx ; python_full_version >= \"3.12.0\"", "jinja2 (>=3.1.6) ; python_full_version >= \"3.12.0\"", "pydantic (>=2.0) ; python_full_version >= \"3.12.0\"", "requests ; python_full_version >= \"3.12.0\"", "tomli ; python_full_version >= \"3.12.0\""] +vm = ["charmlibs-snap (>=1.0.1) ; python_full_version >= \"3.12.0\"", "charmlibs-systemd (>=1.0.0.post0) ; python_full_version >= \"3.12.0\"", "psutil (>=7.2.2) ; python_full_version >= \"3.12.0\"", "pysyncobj (>=0.3.15) ; python_full_version >= \"3.12.0\""] [[package]] name = "prompt-toolkit" @@ -3197,4 +3194,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "297ee31046108591418455efebca939191e9b55259a4ebf781f2470016edcd76" +content-hash = "a64334c9de13c623c9bb1e326d0d9c5c6c4d02893b727bca7c8609eab3cad9ba" diff --git a/pyproject.toml b/pyproject.toml index e324c2a5a4..557b796b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ charm-refresh = "^3.1.0.2" charmlibs-snap = "^1.0.1" charmlibs-systemd = "^1.0.0.post0" charmlibs-interfaces-tls-certificates = "^1.8.3" -postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/a1d2178.zip", extras = ["postgresql", "vm"]} +postgresql-charms-single-kernel = {extras = ["postgresql", "vm"], version = "16.3.3"} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py From 2335bf3313c11eed5619b5697eee11d8691361ca Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 10 Jul 2026 23:21:35 -0300 Subject: [PATCH 7/9] build(deps): pin single-kernel lib at update_config archive for adoption Point the charm at the lib PR-stack tip (08f5993) via its GitHub archive tarball so the migrated update_config subsystem can be exercised by the charm's own unit suite before the lib is released. The pin is provisional and swaps to a released version once the lib stack lands. Signed-off-by: Marcelo Henrique Neppel --- poetry.lock | 103 +++++++++++++++++++++++++------------------------ pyproject.toml | 4 +- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6c278307ac..2bd4ab74d6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -54,7 +54,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main", "charm-libs"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -66,7 +66,7 @@ version = "4.14.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, @@ -413,7 +413,7 @@ version = "0.1.3" description = "" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charm_api-0.1.3-py3-none-any.whl", hash = "sha256:4dba74e33f3de4608e88db2e08e41b0681221e7d3c2cd6913e0ebeb3dbdf8d92"}, {file = "charm_api-0.1.3.tar.gz", hash = "sha256:a90a926b89fce834e9fb2184b96e07ec46f4dd56f3cd50e0209c0fd0ff35a29e"}, @@ -425,7 +425,7 @@ version = "0.1.2" description = "" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charm_json-0.1.2-py3-none-any.whl", hash = "sha256:1cad028a92831c61d6dcb99127c5216c333f90ec58d06b9f73fd0fd7355d1b3c"}, {file = "charm_json-0.1.2.tar.gz", hash = "sha256:f91255858cd6721ec491d09450f37a0cdecf1addbfcfbd41f3e27c58dd95092e"}, @@ -440,7 +440,7 @@ version = "3.1.1.4" description = "In-place rolling refreshes (upgrades) of stateful charmed applications" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charm_refresh-3.1.1.4-py3-none-any.whl", hash = "sha256:6ff6e04170c03b3fe3f23bbf32a288a0de3da0d44bf2e8dd850ee02925bf2ec6"}, {file = "charm_refresh-3.1.1.4.tar.gz", hash = "sha256:d33f66e5b65fd356a30bfbc634710e5538086199da93c30c86246370a66570be"}, @@ -478,7 +478,7 @@ version = "1.8.3" description = "The charmlibs.interfaces.tls_certificates package." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charmlibs_interfaces_tls_certificates-1.8.3-py3-none-any.whl", hash = "sha256:fa0aa150b4e4513d439c7afb7eb0ceaf00363671143b27b09382758deea8a0cc"}, {file = "charmlibs_interfaces_tls_certificates-1.8.3.tar.gz", hash = "sha256:6bc35ee330681f2e0c58cf67e41133b144f3281f73d27293714c9ca918ee7cbc"}, @@ -495,7 +495,7 @@ version = "1.3.0.post0" description = "A pathlib-like interface for Juju K8s charms to interact with files in their workload container." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charmlibs_pathops-1.3.0.post0-py3-none-any.whl", hash = "sha256:c6fc3e3460ffa03a7d4273cc7784c9d74378609a098c728cd9bbb2ed90bdfa5b"}, {file = "charmlibs_pathops-1.3.0.post0.tar.gz", hash = "sha256:36ac11b0c7c55b71341a549a1803f5c8d61c2279204862b8d50736041b782f59"}, @@ -510,7 +510,7 @@ version = "1.1.1" description = "The charmlibs.rollingops package." optional = false python-versions = ">=3.12" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charmlibs_rollingops-1.1.1-py3-none-any.whl", hash = "sha256:1e61347c7792ec11afefa60d36f77a5afccf867832854d5aeef2da0b536afec4"}, {file = "charmlibs_rollingops-1.1.1.tar.gz", hash = "sha256:831457d2ef12117794f89a483ee9a5c347cb209a87dafe82907e9dede7d4641c"}, @@ -531,7 +531,7 @@ version = "1.0.1.post0" description = "The charmlibs.snap package." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charmlibs_snap-1.0.1.post0-py3-none-any.whl", hash = "sha256:655041096952c63e92c8d25a6183149f0594d4888be4be62a9635cb96897aa01"}, {file = "charmlibs_snap-1.0.1.post0.tar.gz", hash = "sha256:1e8da752ee405aa981665da8c4772016236baf80d2d902b02ec8c1c640fb2844"}, @@ -546,7 +546,7 @@ version = "1.0.0.post0" description = "The charmlibs.systemd package." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charmlibs_systemd-1.0.0.post0-py3-none-any.whl", hash = "sha256:9e7578b98344ab237fdc6e48491f98fd80239b8802feb72c5c0ff3a9f70019da"}, {file = "charmlibs_systemd-1.0.0.post0.tar.gz", hash = "sha256:0f978a103524fe1ef34c350930b29b45677ae5c175368e86cea7a1530ccaa0b5"}, @@ -880,7 +880,7 @@ version = "1.1.0" description = "" optional = false python-versions = "<4.0,>=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "data_platform_helpers-1.1.0-py3-none-any.whl", hash = "sha256:71b2c2ba31d1bc01c55c9da82d02b0cb654b99231172916722ecaf4c469d27c4"}, {file = "data_platform_helpers-1.1.0.tar.gz", hash = "sha256:7539ef1c1047fec36ee8298aa4c75fe2bb36419544193b691805c12ca407a797"}, @@ -913,7 +913,7 @@ version = "1.0.0" description = "The dpcharmlibs.interfaces package." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "dpcharmlibs_interfaces-1.0.0-py3-none-any.whl", hash = "sha256:afff80be30e3ff8c31f68557946e9d5959247f1973cbc00e96da55b095f95a21"}, {file = "dpcharmlibs_interfaces-1.0.0.tar.gz", hash = "sha256:a177653019781a7a165be52cf4293134ee58912cd93686082cdd82da347a3bed"}, @@ -987,7 +987,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -999,7 +999,7 @@ version = "4.3.0" description = "Pure-Python HTTP/2 protocol implementation" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, @@ -1015,7 +1015,7 @@ version = "4.2.0" description = "Pure-Python HPACK header encoding" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986"}, {file = "hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0"}, @@ -1027,7 +1027,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1049,7 +1049,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1075,7 +1075,7 @@ version = "0.9.0" description = "WebSockets support for HTTPX" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248"}, {file = "httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08"}, @@ -1111,7 +1111,7 @@ version = "6.1.0" description = "Pure-Python HTTP/2 framing" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, @@ -1399,7 +1399,7 @@ version = "0.21.0" description = "Lightweight kubernetes client library" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "lightkube-0.21.0-py3-none-any.whl", hash = "sha256:5218c9a7029b5ba8a3e90534141795c6bb3ec3e96894afccb55f0581ca8d59b2"}, {file = "lightkube-0.21.0.tar.gz", hash = "sha256:720910200de4bd88e0d7b5d2edfb45a4f6be292798b8776d7b551ef5a5ccdc15"}, @@ -1420,7 +1420,7 @@ version = "1.35.0.8" description = "Models and Resources for lightkube module" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "lightkube_models-1.35.0.8-py3-none-any.whl", hash = "sha256:d01fce42f96baf47a77a571bff59d6a513e96ae043fc03cfaaaaf79c609c4441"}, {file = "lightkube_models-1.35.0.8.tar.gz", hash = "sha256:dbc624596a7d94e6c43c5deda972be964202e0e8f26e2ab8e61d589d710b5e22"}, @@ -1471,7 +1471,7 @@ version = "4.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, @@ -1612,7 +1612,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1848,35 +1848,39 @@ name = "postgresql-charms-single-kernel" version = "16.3.3" description = "Shared and reusable code for PostgreSQL-related charms" optional = false -python-versions = "<4.0,>=3.8" +python-versions = ">=3.8,<4.0" groups = ["main", "integration"] files = [ - {file = "postgresql_charms_single_kernel-16.3.3-py3-none-any.whl", hash = "sha256:8dc0b3d93b14ebb11fc978b17c06f318221babeef9af6fb1aee1d7e423c8cb3e"}, - {file = "postgresql_charms_single_kernel-16.3.3.tar.gz", hash = "sha256:d31d0e69e0a0d5000d652bd54d002165ac88beabe37d71a01b77bdd453aedecb"}, + {file = "08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz", hash = "sha256:93fbe27439a68cbf1384cbe99bcee1ffb976c9cee4b7b02f516f7962ab6642ec"}, ] [package.dependencies] -charm-refresh = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-interfaces-tls-certificates = {version = ">=1.8.3", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-pathops = {version = ">=1.0.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-rollingops = {version = ">=1.1.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -charmlibs-snap = {version = ">=1.0.1", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} -charmlibs-systemd = {version = ">=1.0.0.post0", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} -data-platform-helpers = {version = ">=0.1.7", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -httpx = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -jinja2 = {version = ">=3.1.6", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +charm-refresh = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-interfaces-tls-certificates = {version = ">=1.8.3", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-pathops = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-rollingops = {version = ">=1.1.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +charmlibs-snap = {version = ">=1.0.1", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +charmlibs-systemd = {version = ">=1.0.0.post0", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +data-platform-helpers = {version = ">=0.1.7", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +httpx = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +jinja2 = {version = ">=3.1.6", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} ops = ">=2.0.0" -psutil = {version = ">=7.2.2", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} -pydantic = {version = ">=2.0", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} -pysyncobj = {version = ">=0.3.15", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"vm\""} -requests = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +psutil = {version = ">=7.2.2", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +pydantic = {version = ">=2.0", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} +pysyncobj = {version = ">=0.3.15", optional = true, markers = "python_version >= \"3.12\" and extra == \"vm\""} +requests = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} tenacity = ">=9.0.0" -tomli = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} +tomli = {version = "*", optional = true, markers = "python_version >= \"3.12\" and extra == \"postgresql\""} [package.extras] db-driver = ["psycopg2 (>=2.9.10)"] -postgresql = ["charm-refresh ; python_full_version >= \"3.12.0\"", "charmlibs-interfaces-tls-certificates (>=1.8.3) ; python_full_version >= \"3.12.0\"", "charmlibs-pathops (>=1.0.1) ; python_full_version >= \"3.12.0\"", "charmlibs-rollingops (>=1.1.1) ; python_full_version >= \"3.12.0\"", "data-platform-helpers (>=0.1.7) ; python_full_version >= \"3.12.0\"", "httpx ; python_full_version >= \"3.12.0\"", "jinja2 (>=3.1.6) ; python_full_version >= \"3.12.0\"", "pydantic (>=2.0) ; python_full_version >= \"3.12.0\"", "requests ; python_full_version >= \"3.12.0\"", "tomli ; python_full_version >= \"3.12.0\""] -vm = ["charmlibs-snap (>=1.0.1) ; python_full_version >= \"3.12.0\"", "charmlibs-systemd (>=1.0.0.post0) ; python_full_version >= \"3.12.0\"", "psutil (>=7.2.2) ; python_full_version >= \"3.12.0\"", "pysyncobj (>=0.3.15) ; python_full_version >= \"3.12.0\""] +k8s = ["lightkube (>=0.21.0) ; python_version >= \"3.12\"", "lightkube-models (>=1.28.1.4) ; python_version >= \"3.12\""] +postgresql = ["charm-refresh ; python_version >= \"3.12\"", "charmlibs-interfaces-tls-certificates (>=1.8.3) ; python_version >= \"3.12\"", "charmlibs-pathops (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-rollingops (>=1.1.1) ; python_version >= \"3.12\"", "data-platform-helpers (>=0.1.7) ; python_version >= \"3.12\"", "httpx ; python_version >= \"3.12\"", "jinja2 (>=3.1.6) ; python_version >= \"3.12\"", "pydantic (>=2.0) ; python_version >= \"3.12\"", "requests ; python_version >= \"3.12\"", "tomli ; python_version >= \"3.12\""] +vm = ["charmlibs-snap (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-systemd (>=1.0.0.post0) ; python_version >= \"3.12\"", "psutil (>=7.2.2) ; python_version >= \"3.12\"", "pysyncobj (>=0.3.15) ; python_version >= \"3.12\""] + +[package.source] +type = "url" +url = "https://github.com/canonical/postgresql-single-kernel-library/archive/08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz" [[package]] name = "prompt-toolkit" @@ -1941,7 +1945,6 @@ files = [ {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, ] -markers = {integration = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\""} [package.extras] dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] @@ -2115,7 +2118,7 @@ version = "2.13.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main", "charm-libs"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, @@ -2137,7 +2140,7 @@ version = "2.46.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main", "charm-libs"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, @@ -2598,7 +2601,7 @@ version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.9.0" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, @@ -2789,7 +2792,7 @@ version = "1.0.13" description = "A generator library for concise, unambiguous and URL-safe UUIDs." optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a"}, {file = "shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72"}, @@ -3015,7 +3018,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main", "charm-libs"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -3148,7 +3151,7 @@ version = "1.3.2" description = "Pure-Python WebSocket protocol implementation" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, @@ -3160,4 +3163,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "a64334c9de13c623c9bb1e326d0d9c5c6c4d02893b727bca7c8609eab3cad9ba" +content-hash = "3d3fca069f0a75a215f3b7bc86bb9107db4e48f6b3a9b6d97c86b6cc4dfc5df3" diff --git a/pyproject.toml b/pyproject.toml index 557b796b77..2a269ae6e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ charm-refresh = "^3.1.0.2" charmlibs-snap = "^1.0.1" charmlibs-systemd = "^1.0.0.post0" charmlibs-interfaces-tls-certificates = "^1.8.3" -postgresql-charms-single-kernel = {extras = ["postgresql", "vm"], version = "16.3.3"} +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz", extras = ["postgresql", "vm"]} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py @@ -76,7 +76,7 @@ allure-pytest-default-results = "^0.1.4" tomli = "^2.4.1" tomli-w = "^1.2.0" pysyncobj = "*" -postgresql-charms-single-kernel = "*" +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz", extras = ["postgresql", "vm"]} [tool.poetry.group.build-refresh-version] optional = true From ffe6a0f7717a1cfc4057f9e1ce1364188b58ac7b Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Sat, 11 Jul 2026 09:35:33 -0300 Subject: [PATCH 8/9] refactor(config): adopt single-kernel update_config, drop charm copy The single-kernel library now owns update_config end to end: parameter building, worker-process calculation, Patroni API patching, restart-need handling, and Patroni template rendering. Keeping the charm's copy of that logic would mean maintaining two implementations of the same behaviour, so the charm delegates to the lib's ConfigManager and its own copies (worker-calc helpers, _api_update_config, _handle_postgresql_restart_need, get_available_memory, generate_config_hash, is_restart_pending, and the charm-local Patroni template) are removed. Tests for the deleted methods are dropped rather than re-pointed at the lib, since the lib is a faithful behaviour-preserving port and their coverage now lives there; re-asserting the same behaviour charm-side would just be duplicated coverage. The three new bridge methods (request_restart, refresh_endpoints, restart_services) that the lib calls back into, plus the update_config delegation itself, get their own tests since this is new charm-side wiring with no prior coverage. Signed-off-by: Marcelo Henrique Neppel --- src/charm.py | 411 +------------- templates/patroni.yml.j2 | 250 --------- tests/unit/test_charm.py | 1120 ++------------------------------------ 3 files changed, 74 insertions(+), 1707 deletions(-) delete mode 100644 templates/patroni.yml.j2 diff --git a/src/charm.py b/src/charm.py index 7151466c71..1af80d694e 100755 --- a/src/charm.py +++ b/src/charm.py @@ -381,7 +381,6 @@ def __init__(self, *args): # Managers 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) self._observer = ClusterTopologyObserver(self, "/usr/bin/juju-exec") self._rotate_logs = RotateLogs(self) @@ -426,6 +425,15 @@ def __init__(self, *args): client_certificate=self.tls.client_certificate, peer_certificate=self.tls.peer_certificate, ) + self.config_manager = ConfigManager( + state=self.state, + workload=self.workload, + tls_manager=self.tls_manager, + patroni_manager=self.patroni_manager, + request_restart=self.request_restart, + refresh_endpoints=self.refresh_endpoints, + restart_services=self.restart_services, + ) # Bridge the lib TLS handler's requirer events back into a PostgreSQL reload: # the lib handler stores+pushes certs on certificate_available, then we reload. # Also fires on relation_broken so detaching the TLS operator re-renders Patroni @@ -2733,228 +2741,21 @@ def _can_connect_to_postgresql(self) -> bool: return False return True - def _calculate_max_worker_processes(self) -> str | None: - """Calculate cpu_max_worker_processes configuration value.""" - if self.config.cpu_max_worker_processes == "auto": - # auto = minimum(8, 2 * vCores) - return str(min(8, 2 * self.cpu_count)) - elif self.config.cpu_max_worker_processes is not None: - value = self.config.cpu_max_worker_processes - cap = 10 * self.cpu_count - if value > cap: - raise ValueError( - f"cpu_max_worker_processes value {value} exceeds maximum allowed " - f"of {cap} (10 * vCores). Please set a value <= {cap}." - ) - return str(value) - return None - - def _validate_worker_config_value(self, param_name: str, value: int) -> str: - """Shared validation logic for worker process parameters. - - Args: - param_name: The configuration parameter name (for error messages) - value: The integer value to validate - - Returns: - String representation of the validated value - - Raises: - ValidationError: If value is less than 2 - ValueError: If value exceeds 10 * vCores - """ - cap = 10 * self.cpu_count - if value > cap: - raise ValueError( - f"{param_name} value {value} exceeds maximum allowed " - f"of {cap} (10 * vCores). Please set a value <= {cap}." - ) - return str(value) - - def _calculate_max_parallel_workers(self, base_max_workers: int) -> str | None: - """Calculate cpu_max_parallel_workers configuration value.""" - if self.config.cpu_max_parallel_workers == "auto": - return str(base_max_workers) - elif self.config.cpu_max_parallel_workers is not None: - # Validate the value first - validated_value_str = self._validate_worker_config_value( - "cpu_max_parallel_workers", self.config.cpu_max_parallel_workers - ) - # Apply the min constraint with base_max_workers - return str(min(int(validated_value_str), base_max_workers)) - return None - - def _calculate_max_parallel_maintenance_workers(self, base_max_workers: int) -> str | None: - """Calculate cpu_max_parallel_maintenance_workers configuration value.""" - if self.config.cpu_max_parallel_maintenance_workers == "auto": - return str(base_max_workers) - elif self.config.cpu_max_parallel_maintenance_workers is not None: - return self._validate_worker_config_value( - "cpu_max_parallel_maintenance_workers", - self.config.cpu_max_parallel_maintenance_workers, - ) - return None - - def _calculate_max_logical_replication_workers(self, base_max_workers: int) -> str | None: - """Calculate cpu_max_logical_replication_workers configuration value.""" - if self.config.cpu_max_logical_replication_workers == "auto": - return str(base_max_workers) - elif self.config.cpu_max_logical_replication_workers is not None: - return self._validate_worker_config_value( - "cpu_max_logical_replication_workers", - self.config.cpu_max_logical_replication_workers, - ) - return None - - def _calculate_max_sync_workers_per_subscription(self, base_max_workers: int) -> str | None: - """Calculate cpu_max_sync_workers_per_subscription configuration value.""" - if self.config.cpu_max_sync_workers_per_subscription == "auto": - return str(base_max_workers) - elif self.config.cpu_max_sync_workers_per_subscription is not None: - return self._validate_worker_config_value( - "cpu_max_sync_workers_per_subscription", - self.config.cpu_max_sync_workers_per_subscription, - ) - return None - - def _calculate_max_parallel_apply_workers_per_subscription( - self, base_max_workers: int - ) -> str | None: - """Calculate cpu_max_parallel_apply_workers_per_subscription configuration value.""" - if self.config.cpu_max_parallel_apply_workers_per_subscription == "auto": - return str(base_max_workers) - elif self.config.cpu_max_parallel_apply_workers_per_subscription is not None: - return self._validate_worker_config_value( - "cpu_max_parallel_apply_workers_per_subscription", - self.config.cpu_max_parallel_apply_workers_per_subscription, - ) - return None - - def _calculate_worker_process_config(self) -> dict[str, str]: - """Calculate worker process configuration values. - - Handles 'auto' values and capping logic for worker process parameters. - Returns a dictionary with the calculated values ready for PostgreSQL. - """ - result: dict[str, str] = {} - - # Calculate cpu_max_worker_processes (baseline for other worker configs) - cpu_max_worker_processes_value = self._calculate_max_worker_processes() - if cpu_max_worker_processes_value is not None: - result["max_worker_processes"] = cpu_max_worker_processes_value - - # Get the effective cpu_max_worker_processes for dependent configs - # Use the calculated value, or fall back to PostgreSQL default (8) - base_max_workers = int(result.get("max_worker_processes", "8")) + def request_restart(self) -> None: + """Clear the restarted flag and acquire the rolling-restart lock. Bridge for ConfigManager.""" + self.unit_peer_data.pop("postgresql_restarted", None) + self.on[str(self.restart_manager.name)].acquire_lock.emit() - # Calculate other worker parameters - cpu_max_parallel_workers_value = self._calculate_max_parallel_workers(base_max_workers) - if cpu_max_parallel_workers_value is not None: - result["max_parallel_workers"] = cpu_max_parallel_workers_value - - cpu_max_parallel_maintenance_workers_value = ( - self._calculate_max_parallel_maintenance_workers(base_max_workers) - ) - if cpu_max_parallel_maintenance_workers_value is not None: - result["max_parallel_maintenance_workers"] = cpu_max_parallel_maintenance_workers_value - - cpu_max_logical_replication_workers_value = ( - self._calculate_max_logical_replication_workers(base_max_workers) - ) - if cpu_max_logical_replication_workers_value is not None: - result["max_logical_replication_workers"] = cpu_max_logical_replication_workers_value - - cpu_max_sync_workers_per_subscription_value = ( - self._calculate_max_sync_workers_per_subscription(base_max_workers) - ) - if cpu_max_sync_workers_per_subscription_value is not None: - result["max_sync_workers_per_subscription"] = ( - cpu_max_sync_workers_per_subscription_value - ) - - cpu_max_parallel_apply_workers_per_subscription_value = ( - self._calculate_max_parallel_apply_workers_per_subscription(base_max_workers) - ) - if cpu_max_parallel_apply_workers_per_subscription_value is not None: - result["max_parallel_apply_workers_per_subscription"] = ( - cpu_max_parallel_apply_workers_per_subscription_value - ) - - return result - - def _api_update_config(self) -> bool: - # Use config value if set, calculate otherwise - max_connections = ( - self.config.experimental_max_connections - if self.config.experimental_max_connections - else max(4 * self.cpu_count, 100) - ) - cfg_patch: dict[str, int | str | None] = { - "max_connections": max_connections, - "max_prepared_transactions": self.config.memory_max_prepared_transactions, - "max_replication_slots": 25, - "max_wal_senders": 25, - "shared_buffers": self.config.memory_shared_buffers, - "wal_keep_size": self.config.durability_wal_keep_size, - } - - # Add restart-required worker process parameters via Patroni API - worker_configs = self._calculate_worker_process_config() - if "max_worker_processes" in worker_configs: - cfg_patch["max_worker_processes"] = worker_configs["max_worker_processes"] - if "max_logical_replication_workers" in worker_configs: - cfg_patch["max_logical_replication_workers"] = worker_configs[ - "max_logical_replication_workers" - ] - - base_patch = { - **self.state.synchronous_configuration, - "maximum_lag_on_failover": self.config.durability_maximum_lag_on_failover, - } - if primary_endpoint := self.async_replication.get_primary_cluster_endpoint(): - base_patch["standby_cluster"] = {"host": primary_endpoint} - try: - self.patroni_manager.bulk_update_parameters_controller_by_patroni( - cfg_patch, base_patch - ) - except RetryError: - return False - return True - - def _build_postgresql_parameters(self) -> dict[str, str] | None: - """Build PostgreSQL configuration parameters. - - Returns: - Dictionary of PostgreSQL parameters or None if base parameters couldn't be built. - """ - limit_memory = None - if self.config.profile_limit_memory: - limit_memory = self.config.profile_limit_memory * 10**6 - - # Build PostgreSQL parameters. - pg_parameters = self.postgresql.build_postgresql_parameters( - self.model.config, self.get_available_memory(), limit_memory - ) - - # Calculate and merge worker process configurations - worker_configs = self._calculate_worker_process_config() - - # Add cpu_wal_compression configuration (separate from worker processes) - if self.config.cpu_wal_compression is not None: - cpu_wal_compression = "on" if self.config.cpu_wal_compression else "off" - else: - # Use config.yaml default when unset (default: true) - cpu_wal_compression = "on" - - if pg_parameters is not None: - pg_parameters.update(worker_configs) - pg_parameters["wal_compression"] = cpu_wal_compression - else: - pg_parameters = dict(worker_configs) - pg_parameters["wal_compression"] = cpu_wal_compression - logger.debug(f"pg_parameters set to worker_configs = {pg_parameters}") + def refresh_endpoints(self) -> None: + """Refresh client-relation endpoints. Bridge for ConfigManager.""" + self.postgresql_client_relation.update_endpoints() - return pg_parameters + def restart_services(self) -> None: + """Restart the monitoring and LDAP sync snap services if needed. Bridge for ConfigManager.""" + cache = snap.SnapCache() + postgres_snap = cache[charm_refresh.snap_name()] + self._restart_metrics_service(postgres_snap) + self._restart_ldap_sync_service(postgres_snap) def update_config( self, @@ -2966,101 +2767,21 @@ def update_config( """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() - - # Update and reload configuration based on TLS files availability. - logger.debug(f"Calling render_patroni_yml_file with parameters = {pg_parameters}") - # TODO move to config manager's update config - self.config_manager.render_patroni_yml_file( - connectivity=self.state.peer.is_connectivity_enabled, + return self.config_manager.update_config( + self.postgresql, + self.generate_user_hash, is_creating_backup=is_creating_backup, - enable_ldap=self.state.application.is_ldap_enabled, - # TODO add rel handler - enable_tls=self.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, - # TODO add rel handler - user_databases_map=self.relations_user_databases_map, - # TODO add rel handler + relations_user_databases_map=self.relations_user_databases_map, ldap_parameters=self.get_ldap_parameters(), - # TODO add rel handler async_primary_cluster_endpoint=self.async_replication.get_primary_cluster_endpoint(), async_partner_addresses=self.async_replication.get_partner_addresses(), async_standby_endpoints=self.async_replication.get_standby_endpoints(), - # TODO add rel handler watcher_raft_address=self.watcher_offer.watcher_raft_address if self.watcher_offer.is_active else None, no_peers=no_peers, - # slots=replication_slots, + refresh=refresh, ) - if no_peers: - return True - - if not self.workload.is_patroni_running(): - # If Patroni/PostgreSQL has not started yet and TLS relations was initialised, - # then mark TLS as enabled. This commonly happens when the charm is deployed - # in a bundle together with the TLS certificates operator. This flag is used to - # know when to call the Patroni API using HTTP or HTTPS. - self.unit_peer_data.update({ - "tls": "enabled" if self.is_tls_enabled else "", - }) - self.postgresql_client_relation.update_endpoints() - logger.debug("Early exit update_config: Workload not started yet") - return True - - if not self.patroni_manager.member_started: - if self.is_tls_enabled: - logger.debug( - "Early exit update_config: patroni not responding but TLS is enabled." - ) - self._handle_postgresql_restart_need(True) - return True - logger.debug("Early exit update_config: Patroni not started yet") - return False - - # Try to connect - if not self._can_connect_to_postgresql: - logger.warning("Early exit update_config: Cannot connect to Postgresql") - return False - - if not self._api_update_config(): - logger.warning("Early exit update_config: Unable to patch Patroni API") - return False - - # self._patroni.ensure_slots_controller_by_patroni(replication_slots) - - self._handle_postgresql_restart_need( - self.unit_peer_data.get("config_hash") != self.generate_config_hash - ) - - cache = snap.SnapCache() - postgres_snap = cache[charm_refresh.snap_name()] - - # TODO handle case of scale up while refresh in progress & `refresh` is None - if refresh is not None and postgres_snap.revision != refresh.pinned_snap_revision: - logger.debug("Early exit: snap was not refreshed to the right version yet") - return True - - self._restart_metrics_service(postgres_snap) - self._restart_ldap_sync_service(postgres_snap) - - self.unit_peer_data.update({ - "user_hash": self.generate_user_hash, - "config_hash": self.generate_config_hash, - }) - if self.unit.is_leader(): - self.app_peer_data.update({"user_hash": self.generate_user_hash}) - return True def _validate_config_options(self) -> None: """Validates specific config options that need access to the database or to the TLS status.""" @@ -3091,57 +2812,10 @@ def _validate_config_options(self) -> None: "storage_default_table_access_method config option has an invalid value" ) - def _handle_postgresql_restart_need(self, config_changed: bool) -> None: - """Handle PostgreSQL restart need based on the TLS configuration and configuration changes.""" - if self._can_connect_to_postgresql: - restart_postgresql = self.is_tls_enabled != self.postgresql.is_tls_enabled( - check_current_host=True - ) - else: - restart_postgresql = False - - try: - self.patroni_manager.reload_patroni_configuration() - except Exception as e: - logger.error(f"Reload patroni call failed! error: {e!s}") - - if config_changed and not restart_postgresql: - # Wait for some more time than the Patroni's loop_wait default value (10 seconds), - # which tells how much time Patroni will wait before checking the configuration - # file again to reload it. - try: - for attempt in Retrying(stop=stop_after_attempt(5), wait=wait_fixed(3)): - with attempt: - restart_postgresql = restart_postgresql or self.is_restart_pending() - if not restart_postgresql: - raise Exception - except RetryError: - # Ignore the error, as it happens only to indicate that the configuration has not changed. - pass - - self.unit_peer_data.update({"tls": "enabled" if self.is_tls_enabled else ""}) - self.postgresql_client_relation.update_endpoints() - - # Restart PostgreSQL if TLS configuration has changed - # (so the both old and new connections use the configuration). - if restart_postgresql: - logger.info("PostgreSQL restart required") - self.unit_peer_data.pop("postgresql_restarted", None) - self.on[str(self.restart_manager.name)].acquire_lock.emit() - def _update_relation_endpoints(self) -> None: """Updates endpoints and read-only endpoint in all relations.""" self.postgresql_client_relation.update_endpoints() - def get_available_memory(self) -> int: - """Returns the system available memory in bytes.""" - with open("/proc/meminfo") as meminfo: - for line in meminfo: - if "MemTotal" in line: - return int(line.split()[1]) * 1024 - - return 0 - @property def client_relations(self) -> list[Relation]: """Return the list of established client relations.""" @@ -3232,11 +2906,6 @@ def generate_user_hash(self) -> str: """Generate expected user and database hash.""" return shake_128(str(self._collect_user_relations()).encode()).hexdigest(16) - @cached_property - def generate_config_hash(self) -> str: - """Generate current configuration hash.""" - return shake_128(str(self.config.model_dump()).encode()).hexdigest(16) - def override_patroni_restart_condition( self, new_condition: str, repeat_cause: str | None ) -> bool: @@ -3390,32 +3059,6 @@ def get_ldap_parameters(self) -> dict: return params - def is_restart_pending(self) -> bool: - """Query pg_settings for pending restart.""" - connection = None - try: - with ( - self.postgresql._connect_to_database( - database_host=self.postgresql.current_host - ) as connection, - connection.cursor() as cursor, - ): - cursor.execute("SELECT COUNT(*) FROM pg_settings WHERE pending_restart=True;") - result = cursor.fetchone() - if result is not None: - return result[0] > 0 - else: - return False - except psycopg2.OperationalError: - logger.warning("Failed to connect to PostgreSQL.") - return False - except psycopg2.Error as e: - logger.error(f"Failed to check if restart is pending: {e}") - return False - finally: - if connection: - connection.close() - if __name__ == "__main__": main(PostgresqlOperatorCharm) diff --git a/templates/patroni.yml.j2 b/templates/patroni.yml.j2 deleted file mode 100644 index 65bdc02c6b..0000000000 --- a/templates/patroni.yml.j2 +++ /dev/null @@ -1,250 +0,0 @@ -######################################################################################### -# [ WARNING ] -# patroni configuration file maintained by the postgres-operator -# local changes may be overwritten. -######################################################################################### -# For a complete reference of all the options for this configuration file, -# please refer to https://patroni.readthedocs.io/en/latest/SETTINGS.html. - -# Name of the cluster. -scope: {{ scope }} -namespace: /db/ -name: {{ member_name }} - -log: - dir: {{ log_path }} - dateformat: "%Y-%m-%d %H:%M:%S %Z" - format: "%(asctime)s [%(process)d]: %(levelname)s: %(message)s " - file_num: 10080 - file_size: 600 - -restapi: - listen: '{{ self_ip }}:8008' - connect_address: '{{ self_ip }}:8008' - {%- if patroni_password %} - authentication: - username: patroni - password: {{ patroni_password }} - {%- endif %} - cafile: {{ conf_path }}/peer_ca.pem - certfile: {{ conf_path }}/peer_cert.pem - keyfile: {{ conf_path }}/peer_key.pem - -ctl: - cacert: {{ conf_path }}/peer_ca.pem - -raft: - data_dir: {{ conf_path }}/raft - self_addr: '{{ self_ip }}:2222' - password: {{ raft_password }} - {% if partner_addrs or watcher -%} - partner_addrs: - {% endif -%} - {% for partner_addr in partner_addrs -%} - - {{ partner_addr }}:2222 - {% endfor %} - {%- if watcher %} - - {{ watcher }} - {% endif %} - -bootstrap: - dcs: - # Settings related to the time without communication - # to the leader before triggering a failover. - ttl: 30 - loop_wait: 10 - retry_timeout: 10 - maximum_lag_on_failover: {{ maximum_lag_on_failover }} - synchronous_mode: true - synchronous_mode_strict: false - max_timelines_history: 50 - synchronous_node_count: {{ synchronous_node_count }} - postgresql: - use_pg_rewind: true - remove_data_directory_on_rewind_failure: false - remove_data_directory_on_diverged_timelines: false - parameters: - synchronous_standby_names: "*" - {%- if enable_pgbackrest_archiving %} - archive_command: 'pgbackrest {{ pgbackrest_configuration_file }} --stanza={{ stanza }} archive-push %p' - {% else %} - archive_command: /bin/true - {%- endif %} - archive_mode: on - autovacuum: true - debug_print_plan: 'off' - debug_print_parse: 'off' - debug_print_rewritten: 'off' - fsync: true - full_page_writes: true - lc_messages: 'en_US.UTF8' - log_autovacuum_min_duration: 60000 - log_checkpoints: 'on' - log_destination: 'stderr' - log_directory: '{{ postgresql_log_path }}' - log_error_verbosity: 'verbose' - log_file_mode: '0600' - log_filename: 'postgresql-%w_%H%M.log' - log_hostname: 'off' - log_line_prefix: '%t [%p]: user=%u,db=%d,app=%a,client=%h,line=%l ' - log_min_duration_sample: -1 - log_min_error_statement: 'warning' - log_recovery_conflict_waits: 'on' - log_replication_commands: 'on' - log_rotation_age: 1 - log_rotation_size: 0 - log_statement: ddl - log_statement_sample_rate: 1 - log_statement_stats: 'off' - log_temp_files: 1 - log_timezone: 'UTC' - log_truncate_on_rotation: 'on' - logging_collector: 'on' - wal_level: logical - shared_preload_libraries: 'timescaledb,pgaudit,set_user,pg_stat_statements' - session_preload_libraries: 'login_hook' - set_user.block_log_statement: 'on' - set_user.exit_on_error: 'on' - set_user.superuser_allowlist: '+charmed_dba' - slots: - {%- for slot, database in slots.items() %} - {{slot}}: - database: {{database}} - plugin: pgoutput - type: logical - {%- endfor -%} - - {%- if restoring_backup %} - method: pgbackrest - pgbackrest: - command: > - pgbackrest {{ pgbackrest_configuration_file }} --stanza={{ restore_stanza }} --pg1-path={{ data_path }} - {%- if backup_id %} --set={{ backup_id }} {%- endif %} - {%- if restore_timeline %} --target-timeline="0x{{ restore_timeline }}" {% endif %} - {%- if restore_to_latest %} --type=default {%- else %} - --target-action=promote {%- if pitr_target %} --target="{{ pitr_target }}" --type=time {%- else %} --type=immediate {%- endif %} - {%- endif %} - restore - no_params: True - keep_existing_recovery_conf: True - {% elif primary_cluster_endpoint %} - standby_cluster: - host: {{ primary_cluster_endpoint }} - port: 5432 - create_replica_methods: ["basebackup"] - {% else %} - initdb: - - encoding: UTF8 - - data-checksums - - waldir: {{ wal_dir }} - {%- endif %} - -postgresql: - listen: {% for ip in listen_ips %}{{ ip }}{%- if not loop.last %},{% endif %}{% endfor %}:5432 - basebackup: - - waldir: {{ wal_dir }} - connect_address: '{{ self_ip }}:5432' - # Path to PostgreSQL binaries used in the database bootstrap process. - bin_dir: /snap/charmed-postgresql/current/usr/lib/postgresql/{{ version }}/bin - data_dir: {{ data_path }} - parameters: - shared_preload_libraries: 'timescaledb,pgaudit,set_user,pg_stat_statements' - session_preload_libraries: 'login_hook' - set_user.block_log_statement: 'on' - set_user.exit_on_error: 'on' - set_user.superuser_allowlist: '+charmed_dba' - {%- if enable_pgbackrest_archiving %} - archive_command: 'pgbackrest {{ pgbackrest_configuration_file }} --stanza={{ stanza }} archive-push %p' - {% else %} - archive_command: /bin/true - {%- endif %} - {%- if enable_tls %} - ssl: on - ssl_ca_file: {{ conf_path }}/ca.pem - ssl_cert_file: {{ conf_path }}/cert.pem - ssl_key_file: {{ conf_path }}/key.pem - {%- endif %} - temp_tablespaces: temp - unix_socket_directories: /tmp - pg_stat_statements.max: 10000 - {%- if pg_parameters %} - {%- for key, value in pg_parameters.items() %} - {{key}}: {{value}} - {%- endfor -%} - {% endif %} - pgpass: /tmp/pgpass - pg_hba: - - local all backup peer map=operator - - local all operator scram-sha-256 - - local all monitoring password - - {{ 'hostssl' if enable_tls else 'host' }} all +charmed_stats 0.0.0.0/0 scram-sha-256 - - {{ 'hostssl' if enable_tls else 'host' }} all +charmed_read 0.0.0.0/0 scram-sha-256 - - {{ 'hostssl' if enable_tls else 'host' }} all +charmed_dml 0.0.0.0/0 scram-sha-256 - - {{ 'hostssl' if enable_tls else 'host' }} all +charmed_backup 0.0.0.0/0 scram-sha-256 - - {{ 'hostssl' if enable_tls else 'host' }} all +charmed_dba 0.0.0.0/0 scram-sha-256 - - {{ 'hostssl' if enable_tls else 'host' }} all +charmed_admin 0.0.0.0/0 scram-sha-256 - - {{ 'hostssl' if enable_tls else 'host' }} all +charmed_databases_owner 0.0.0.0/0 scram-sha-256 - {%- if not connectivity %} - - {{ 'hostssl' if enable_tls else 'host' }} all all {{ self_ip }}/32 {{ instance_password_encryption }} - - {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 reject - {%- elif enable_ldap %} - - {{ 'hostssl' if enable_tls else 'host' }} all +identity_access 0.0.0.0/0 ldap {{ ldap_parameters }} - - {{ 'hostssl' if enable_tls else 'host' }} all +internal_access 0.0.0.0/0 {{ instance_password_encryption }} - {%- for user, databases in user_databases_map.items() %} - - {{ 'hostssl' if enable_tls else 'host' }} {{ databases }} {{ user }} 0.0.0.0/0 {{ instance_password_encryption }} - {%- endfor %} - {%- else %} - - {{ 'hostssl' if enable_tls else 'host' }} all +internal_access 0.0.0.0/0 {{ instance_password_encryption }} - {%- for user, databases in user_databases_map.items() %} - {%- if 'pgbouncer_auth_relation_' in user %} - - {{ 'hostssl' if enable_tls else 'host' }} {{ databases }} {{ user }} 0.0.0.0/0 md5 - {%- else %} - - {{ 'hostssl' if enable_tls else 'host' }} {{ databases }} {{ user }} 0.0.0.0/0 {{ instance_password_encryption }} - {%- endif %} - {%- endfor %} - {%- endif %} - {%- if watcher_addr %} - # Allow watcher to connect for health checks - - {{ 'hostssl' if enable_tls else 'host' }} postgres watcher {{ watcher_addr }}/32 scram-sha-256 - {%- endif %} - - {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 scram-sha-256 - # Allow replications connections from other cluster members. - {%- for endpoint in extra_replication_endpoints %} - - {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ endpoint }}/32 scram-sha-256 - {%- endfor %} - {%- for peer_ip in peers_ips %} - - {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ peer_ip }}/0 scram-sha-256 - {%- endfor %} - - pg_ident: - - operator _daemon_ backup - authentication: - replication: - username: replication - password: {{ replication_password }} - {%- if enable_tls %} - sslrootcert: {{ conf_path }}/ca.pem - {%- endif %} - sslcert: {{ conf_path }}/nonexistent_cert.pem - sslkey: {{ conf_path }}/nonexistent_key.pem - rewind: - username: {{ rewind_user }} - password: {{ rewind_password }} - {%- if enable_tls %} - sslrootcert: {{ conf_path }}/ca.pem - {%- endif %} - sslcert: {{ conf_path }}/nonexistent_cert.pem - sslkey: {{ conf_path }}/nonexistent_key.pem - superuser: - username: {{ superuser }} - password: {{ superuser_password }} - {%- if enable_tls %} - sslrootcert: {{ conf_path }}/ca.pem - {%- endif %} - sslcert: {{ conf_path }}/nonexistent_cert.pem - sslkey: {{ conf_path }}/nonexistent_key.pem -use_unix_socket: true -{%- if is_creating_backup %} -tags: - is_creating_backup: {{ is_creating_backup }} -{%- endif %} diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 9e6c31b7a8..981e8877ec 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -9,8 +9,7 @@ import platform import subprocess from datetime import UTC, datetime -from typing import ClassVar -from unittest.mock import MagicMock, Mock, PropertyMock, call, mock_open, patch, sentinel +from unittest.mock import MagicMock, Mock, PropertyMock, call, patch, sentinel # from ops.framework import EventBase import charm_refresh @@ -718,7 +717,6 @@ def test_on_start_no_patroni_member(harness): patch( "charm.PostgresqlOperatorCharm._is_storage_attached", return_value=True ) as _is_storage_attached, - patch("charm.PostgresqlOperatorCharm.get_available_memory") as _get_available_memory, patch("charm.PostgresqlOperatorCharm.get_secret"), patch("charm.TLSManager.generate_internal_peer_cert"), patch("charm.PostgreSQLProvider.get_username_mapping", return_value={}), @@ -1351,164 +1349,63 @@ def test_restart(harness): mock_event.defer.assert_not_called() -def test_update_config(harness): - with pathlib.Path("refresh_versions.toml").open("rb") as file: - _revision = tomli.load(file)["snap"]["revisions"][platform.machine()] +def test_request_restart(harness): + """Bridge for ConfigManager: clears the restarted flag and acquires the restart lock.""" + with patch( + "charms.rolling_ops.v0.rollingops.RollingOpsManager._on_acquire_lock" + ) as _on_acquire_lock: + rel_id = harness.model.get_relation(PEER_RELATION).id + with harness.hooks_disabled(): + harness.update_relation_data( + rel_id, harness.charm.unit.name, {"postgresql_restarted": "True"} + ) + + harness.charm.request_restart() + + assert "postgresql_restarted" not in harness.charm.unit_peer_data + _on_acquire_lock.assert_called_once() + + +def test_refresh_endpoints(harness): + """Bridge for ConfigManager: refreshes the client-relation endpoints.""" + with patch("charm.PostgreSQLProvider.update_endpoints") as _update_endpoints: + harness.charm.refresh_endpoints() - class _MockSnap: - revision = _revision + _update_endpoints.assert_called_once() + +def test_restart_services(harness): + """Bridge for ConfigManager: restarts the metrics and LDAP sync snap services.""" with ( - patch("subprocess.check_output", return_value=b"C"), - patch("charm.snap.SnapCache", lambda: {"charmed-postgresql": _MockSnap()}), - patch( - "charm.PostgresqlOperatorCharm._handle_postgresql_restart_need" - ) as _handle_postgresql_restart_need, - patch("charm.PatroniManager.ensure_slots_controller_by_patroni"), + patch("charm.snap.SnapCache") as _snap_cache, patch( "charm.PostgresqlOperatorCharm._restart_metrics_service" ) as _restart_metrics_service, patch( "charm.PostgresqlOperatorCharm._restart_ldap_sync_service" ) as _restart_ldap_sync_service, - patch("charm.PatroniManager.bulk_update_parameters_controller_by_patroni"), - patch("charm.PatroniManager.member_started", new_callable=PropertyMock) as _member_started, - patch("charm.VMWorkload.is_patroni_running") as _is_patroni_running, - patch("charm.ConfigManager.render_patroni_yml_file") as _render_patroni_yml_file, - patch( - "charm.PostgresqlOperatorCharm.is_tls_enabled", new_callable=PropertyMock - ) as _is_tls_enabled, - patch.object(harness.charm, "postgresql", Mock()) as postgresql_mock, - patch("charm.PostgresqlOperatorCharm.get_available_memory") as _get_available_memory, - patch.object( - harness.charm, - "_calculate_worker_process_config", - return_value={ - "wal_compression": "on", - "max_worker_processes": "8", - "max_parallel_workers": "8", - "max_parallel_maintenance_workers": "8", - "max_logical_replication_workers": "8", - "max_sync_workers_per_subscription": "8", - "max_parallel_apply_workers_per_subscription": "8", - }, - ), ): - rel_id = harness.model.get_relation(PEER_RELATION).id - # Mock some properties. - postgresql_mock.is_tls_enabled = PropertyMock(side_effect=[False, False, False, False]) - _is_patroni_running.side_effect = [True, True, False, True] - _member_started.side_effect = [True, True, False] - postgresql_mock.build_postgresql_parameters.return_value = {"test": "test"} + postgres_snap = _snap_cache.return_value.__getitem__.return_value + + harness.charm.restart_services() + + _restart_metrics_service.assert_called_once_with(postgres_snap) + _restart_ldap_sync_service.assert_called_once_with(postgres_snap) - # Test without TLS files available. - with harness.hooks_disabled(): - harness.update_relation_data(rel_id, harness.charm.unit.name, {"tls": ""}) - _is_tls_enabled.return_value = False - harness.charm.update_config() - _render_patroni_yml_file.assert_called_once_with( - connectivity=True, - is_creating_backup=False, - enable_ldap=False, - enable_tls=False, - backup_id=None, - pitr_target=None, - restore_timeline=None, - restore_to_latest=False, - stanza=None, - restore_stanza=None, - parameters={ - "test": "test", - "wal_compression": "on", - "max_worker_processes": "8", - "max_parallel_workers": "8", - "max_parallel_maintenance_workers": "8", - "max_logical_replication_workers": "8", - "max_sync_workers_per_subscription": "8", - "max_parallel_apply_workers_per_subscription": "8", - }, - user_databases_map={"operator": "all", "replication": "all", "rewind": "all"}, - ldap_parameters={}, - async_primary_cluster_endpoint=None, - async_partner_addresses=[], - async_standby_endpoints=[], - watcher_raft_address=None, - no_peers=False, - # slots={}, - ) - _handle_postgresql_restart_need.assert_called_once_with(True) - _restart_ldap_sync_service.assert_called_once() - _restart_metrics_service.assert_called_once() - assert "tls" not in harness.get_relation_data(rel_id, harness.charm.unit.name) - - # Test with TLS files available. - _handle_postgresql_restart_need.reset_mock() - _restart_ldap_sync_service.reset_mock() - _restart_metrics_service.reset_mock() - harness.update_relation_data( - rel_id, harness.charm.unit.name, {"tls": ""} - ) # Mock some data in the relation to test that it change. - _is_tls_enabled.return_value = True - _render_patroni_yml_file.reset_mock() - harness.charm.update_config() - _render_patroni_yml_file.assert_called_once_with( - connectivity=True, - is_creating_backup=False, - enable_ldap=False, - enable_tls=True, - backup_id=None, - pitr_target=None, - restore_timeline=None, - restore_to_latest=False, - stanza=None, - restore_stanza=None, - parameters={ - "test": "test", - "wal_compression": "on", - "max_worker_processes": "8", - "max_parallel_workers": "8", - "max_parallel_maintenance_workers": "8", - "max_logical_replication_workers": "8", - "max_sync_workers_per_subscription": "8", - "max_parallel_apply_workers_per_subscription": "8", - }, - user_databases_map={"operator": "all", "replication": "all", "rewind": "all"}, - ldap_parameters={}, - async_primary_cluster_endpoint=None, - async_partner_addresses=[], - async_standby_endpoints=[], - watcher_raft_address=None, - no_peers=False, - # slots={}, - ) - _handle_postgresql_restart_need.assert_called_once() - _restart_ldap_sync_service.assert_called_once() - _restart_metrics_service.assert_called_once() - assert "tls" not in harness.get_relation_data( - rel_id, harness.charm.unit.name - ) # The "tls" flag is set in handle_postgresql_restart_need. - - # Test with workload not running yet. - harness.update_relation_data( - rel_id, harness.charm.unit.name, {"tls": ""} - ) # Mock some data in the relation to test that it change. - _handle_postgresql_restart_need.reset_mock() - _restart_ldap_sync_service.reset_mock() - _restart_metrics_service.reset_mock() - harness.charm.update_config() - _handle_postgresql_restart_need.assert_not_called() - assert harness.get_relation_data(rel_id, harness.charm.unit.name)["tls"] == "enabled" - - # Test with member not started yet. - harness.update_relation_data( - rel_id, harness.charm.unit.name, {"tls": ""} - ) # Mock some data in the relation to test that it doesn't change. - _is_tls_enabled.return_value = False - harness.charm.update_config() - _handle_postgresql_restart_need.assert_not_called() - _restart_ldap_sync_service.assert_not_called() - _restart_metrics_service.assert_not_called() - assert "tls" not in harness.get_relation_data(rel_id, harness.charm.unit.name) + +def test_update_config_delegates_to_config_manager(harness): + """update_config forwards to the lib's ConfigManager with the charm-owned user hash/refresh.""" + with patch.object(harness.charm, "config_manager") as _config_manager: + _config_manager.update_config.return_value = True + + result = harness.charm.update_config(no_peers=True) + + assert result is True + _config_manager.update_config.assert_called_once() + args, kwargs = _config_manager.update_config.call_args + assert args[1] == harness.charm.generate_user_hash + assert kwargs["no_peers"] is True + assert kwargs["refresh"] is harness.charm.refresh def test_on_cluster_topology_change(harness): @@ -1648,423 +1545,6 @@ def test_validate_config_options(harness): assert str(e.value).startswith(message) -def test_calculate_worker_process_config_auto_values(harness): - """Test worker process config calculation with 'auto' values.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - # Test with 2 vCPUs (auto should be min(8, 2*2) = 4) - _cpu_count.return_value = 2 - - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "auto", - "cpu-max-parallel-workers": "auto", - "cpu-max-parallel-maintenance-workers": "auto", - "cpu-max-logical-replication-workers": "auto", - "cpu-max-sync-workers-per-subscription": "auto", - "cpu-max-parallel-apply-workers-per-subscription": "auto", - }) - - result = harness.charm._calculate_worker_process_config() - - # All auto values should resolve to 4 (min(8, 2*2)) - assert result["max_worker_processes"] == "4" - assert result["max_parallel_workers"] == "4" - assert result["max_parallel_maintenance_workers"] == "4" - assert result["max_logical_replication_workers"] == "4" - assert result["max_sync_workers_per_subscription"] == "4" - assert result["max_parallel_apply_workers_per_subscription"] == "4" - - # Test with 8 vCPUs (auto should be min(8, 2*8) = 8) - _cpu_count.return_value = 8 - result = harness.charm._calculate_worker_process_config() - - assert result["max_worker_processes"] == "8" - assert result["max_parallel_workers"] == "8" - - # Test with 1 vCPU (auto should be min(8, 2*1) = 2) - _cpu_count.return_value = 1 - result = harness.charm._calculate_worker_process_config() - - assert result["max_worker_processes"] == "2" - - -def test_calculate_worker_process_config_numeric_values(harness): - """Test worker process config calculation with numeric values.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 - - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "10", - "cpu-max-parallel-workers": "8", - "cpu-max-parallel-maintenance-workers": "8", - }) - - result = harness.charm._calculate_worker_process_config() - - assert result["max_worker_processes"] == "10" - assert result["max_parallel_workers"] == "8" - assert result["max_parallel_maintenance_workers"] == "8" - - -def test_calculate_worker_process_config_all_workers_numeric(harness): - """Test all worker configs with numeric values within limits (not capped).""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 # Cap would be 40 - - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "20", - "cpu-max-parallel-workers": "15", - "cpu-max-parallel-maintenance-workers": "10", - "cpu-max-logical-replication-workers": "12", - "cpu-max-sync-workers-per-subscription": "8", - "cpu-max-parallel-apply-workers-per-subscription": "8", - }) - - result = harness.charm._calculate_worker_process_config() - - # All values should pass through unchanged (within cap of 40) - assert result["max_worker_processes"] == "20" - assert result["max_parallel_workers"] == "15" - assert result["max_parallel_maintenance_workers"] == "10" - assert result["max_logical_replication_workers"] == "12" - assert result["max_sync_workers_per_subscription"] == "8" - assert result["max_parallel_apply_workers_per_subscription"] == "8" - - -def test_calculate_worker_process_config_validation_blocking_max_worker_processes(harness): - """Test worker process config validation blocks max_worker_processes exceeding values instead of capping.""" - import pytest - - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 2 # Cap should be 20 - - # Test max_worker_processes exceeding cap - should raise ValueError - with harness.hooks_disabled(): - harness.update_config({"cpu-max-worker-processes": "50"}) # Exceeds cap - - with pytest.raises( - ValueError, match="max_worker_processes value 50 exceeds maximum allowed of 20" - ): - harness.charm._calculate_worker_process_config() - - -def test_calculate_worker_process_config_validation_blocking_max_parallel_workers(harness): - """Test worker process config validation blocks max_parallel_workers exceeding values instead of capping.""" - import pytest - - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 2 # Cap should be 20 - - # Test max_parallel_workers exceeding cap - should raise ValueError - with harness.hooks_disabled(): - harness.update_config({"cpu-max-parallel-workers": "30"}) # Exceeds cap - - with pytest.raises( - ValueError, match="max_parallel_workers value 30 exceeds maximum allowed of 20" - ): - harness.charm._calculate_worker_process_config() - - -def test_calculate_worker_process_config_validation_blocking_valid_value(harness): - """Test worker process config validation accepts valid values within cap.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 2 # Cap should be 20 - - # Test valid value within cap - should work - with harness.hooks_disabled(): - harness.update_config({"cpu-max-parallel-maintenance-workers": "15"}) # Within cap - - result = harness.charm._calculate_worker_process_config() - assert result["max_parallel_maintenance_workers"] == "15" # Should accept valid value - - -def test_calculate_worker_process_config_edge_cases(harness): - """Test worker process config edge cases.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 - - # Test with no worker config set (but wal_compression has default) - with harness.hooks_disabled(): - harness.update_config( - unset=[ - "max_worker_processes", - "max_parallel_workers", - "wal_compression", - ] - ) - with contextlib.suppress(AttributeError): - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - # All worker parameters default to auto, so they get calculated - expected = { - "max_worker_processes": "8", # min(8, 2 * 4) = 8 - "max_parallel_workers": "8", - "max_parallel_maintenance_workers": "8", - "max_logical_replication_workers": "8", - "max_sync_workers_per_subscription": "8", - "max_parallel_apply_workers_per_subscription": "8", - } - assert result == expected - - # Test with only one worker config set - with harness.hooks_disabled(): - harness.update_config({"cpu-max-worker-processes": "10"}) - with contextlib.suppress(AttributeError): - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - # Other worker parameters still get auto-calculated since they default to "auto" - expected = { - "max_worker_processes": "10", - "max_parallel_workers": "10", # auto = base_max_workers (10) - "max_parallel_maintenance_workers": "10", - "max_logical_replication_workers": "10", - "max_sync_workers_per_subscription": "10", - "max_parallel_apply_workers_per_subscription": "10", - } - assert result == expected - - # Test with very high vCPU count - _cpu_count.return_value = 128 - with harness.hooks_disabled(): - harness.update_config({"cpu-max-worker-processes": "auto"}) - with contextlib.suppress(AttributeError): - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - # Should be min(8, 2*128) = 8 - expected = { - "max_worker_processes": "8", - "max_parallel_workers": "8", - "max_parallel_maintenance_workers": "8", - "max_logical_replication_workers": "8", - "max_sync_workers_per_subscription": "8", - "max_parallel_apply_workers_per_subscription": "8", - } - assert result == expected - - -def test_calculate_worker_process_config_dependencies(harness): - """Test that dependent worker configs properly use max_worker_processes as base.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 - - # Set max_worker_processes to 12, others to auto - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "12", - "cpu-max-parallel-workers": "auto", - "cpu-max-parallel-maintenance-workers": "auto", - }) - - result = harness.charm._calculate_worker_process_config() - - # Auto values should use max_worker_processes (12) as base - assert result["max_worker_processes"] == "12" - assert result["max_parallel_workers"] == "12" - assert result["max_parallel_maintenance_workers"] == "12" - - -def test_calculate_worker_process_config_wal_compression(harness): - """Test that wal_compression is NOT included in worker process config.""" - # Test with wal_compression enabled - should NOT be in worker config result - with harness.hooks_disabled(): - harness.update_config({"cpu-wal-compression": True}) - - result = harness.charm._calculate_worker_process_config() - assert "wal_compression" not in result # wal_compression is not part of worker configs - - # Test with wal_compression disabled - should NOT be in worker config result - with harness.hooks_disabled(): - harness.update_config({"cpu-wal-compression": False}) - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - assert "wal_compression" not in result # wal_compression is not part of worker configs - - # Test with wal_compression not explicitly set - should NOT be in worker config result - with harness.hooks_disabled(): - harness.update_config(unset=["cpu-wal-compression"]) - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - assert "wal_compression" not in result # wal_compression is not part of worker configs - - -def test_calculate_worker_process_config_mixed_auto_and_numeric(harness): - """Test worker process config with mixed auto and numeric values.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 - - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "16", - "cpu-max-parallel-workers": "auto", # Should become 16 - "cpu-max-parallel-maintenance-workers": "8", # Explicit value - "cpu-max-logical-replication-workers": "auto", # Should become 16 - }) - with contextlib.suppress(AttributeError): - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - - assert result["max_worker_processes"] == "16" - assert result["max_parallel_workers"] == "16" # Auto resolved to base - assert result["max_parallel_maintenance_workers"] == "8" # Explicit value - assert result["max_logical_replication_workers"] == "16" # Auto resolved to base - - -def test_calculate_worker_process_config_zero_cpu_count(harness): - """Test worker process config when cpu_count is 0 (edge case).""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 0 - - with harness.hooks_disabled(): - harness.update_config({"cpu-max-worker-processes": "auto"}) - with contextlib.suppress(AttributeError): - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - - # min(8, 2*0) = 0 - assert result["max_worker_processes"] == "0" - - -def test_calculate_worker_process_config_all_workers_validation_blocking(harness): - """Test that worker configs exceeding limits raise ValueError instead of capping.""" - import pytest - - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 2 # Cap = 20 - - # Test each exceeding parameter individually (since first exception stops execution) - exceeding_configs = [ - ("cpu-max-worker-processes", "25"), - ("cpu-max-parallel-maintenance-workers", "22"), - ("cpu-max-logical-replication-workers", "30"), - ("cpu-max-sync-workers-per-subscription", "25"), - ("cpu-max-parallel-apply-workers-per-subscription", "100"), - ] - - for param_name, invalid_value in exceeding_configs: - # Reset all configs to auto first, then set the specific one we want to test - reset_config = { - "cpu-max-worker-processes": "auto", - "cpu-max-parallel-workers": "auto", - "cpu-max-parallel-maintenance-workers": "auto", - "cpu-max-logical-replication-workers": "auto", - "cpu-max-sync-workers-per-subscription": "auto", - "cpu-max-parallel-apply-workers-per-subscription": "auto", - } - reset_config[param_name] = invalid_value - - with harness.hooks_disabled(): - harness.update_config(reset_config) - - with contextlib.suppress(AttributeError): - del harness.charm.config - - with pytest.raises( - ValueError, - match=f"{param_name.replace('-', '_')} value {invalid_value} exceeds maximum allowed of 20", - ): - harness.charm._calculate_worker_process_config() - - # Test valid value within cap works - need to set max_worker_processes high enough - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "20", # Set high enough to allow max_parallel_workers = 18 - "cpu-max-parallel-workers": "18", # Within both CPU cap (20) and max_worker_processes (20) - "cpu-max-parallel-maintenance-workers": "auto", - "cpu-max-logical-replication-workers": "auto", - "cpu-max-sync-workers-per-subscription": "auto", - "cpu-max-parallel-apply-workers-per-subscription": "auto", - }) - - with contextlib.suppress(AttributeError): - del harness.charm.config - - result = harness.charm._calculate_worker_process_config() - assert result["max_parallel_workers"] == "18" # Should accept valid value - - -def test_update_config_integrates_worker_configs(harness): - """Test that update_config properly integrates worker process configurations.""" - with pathlib.Path("refresh_versions.toml").open("rb") as file: - _revision = tomli.load(file)["snap"]["revisions"][platform.machine()] - - class _MockSnap: - revision = _revision - - with ( - patch("subprocess.check_output", return_value=b"C"), - patch("charm.snap.SnapCache", lambda: {"charmed-postgresql": _MockSnap()}), - patch("charm.PostgresqlOperatorCharm._handle_postgresql_restart_need"), - patch("charm.PatroniManager.ensure_slots_controller_by_patroni"), - patch("charm.PostgresqlOperatorCharm._restart_metrics_service"), - patch("charm.PostgresqlOperatorCharm._restart_ldap_sync_service"), - patch("charm.PatroniManager.bulk_update_parameters_controller_by_patroni"), - patch("charm.PatroniManager.member_started", new_callable=PropertyMock, return_value=True), - patch("charm.VMWorkload.is_patroni_running", return_value=True), - patch("charm.ConfigManager.render_patroni_yml_file") as _render_patroni_yml_file, - patch( - "charm.PostgresqlOperatorCharm.is_tls_enabled", - new_callable=PropertyMock, - return_value=False, - ), - patch.object(harness.charm, "postgresql", Mock()) as postgresql_mock, - patch("charm.PostgresqlOperatorCharm.get_available_memory", return_value=8000000), - patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock, return_value=4 - ), - ): - postgresql_mock.is_tls_enabled = PropertyMock(return_value=False) - postgresql_mock.build_postgresql_parameters.return_value = {"shared_buffers": "128MB"} - - # Configure worker processes - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "auto", - "cpu-wal-compression": True, - }) - - harness.charm.update_config() - - # Verify render was called with merged parameters - call_args = _render_patroni_yml_file.call_args - parameters = call_args.kwargs["parameters"] - - # Should have original params plus our calculated ones - assert "shared_buffers" in parameters - assert parameters["max_worker_processes"] == "8" # min(8, 2*4) - assert parameters["wal_compression"] == "on" - - def test_config_validation_invalid_worker_values(harness): """Test that pydantic validates worker process config values.""" # Test invalid string value (not "auto" or a number) @@ -2123,59 +1603,6 @@ def test_config_validation_valid_worker_values(harness): assert harness.charm.config.cpu_max_parallel_workers == 1000 -def test_update_config_with_none_pg_parameters(harness): - """Test update_config when build_postgresql_parameters returns None.""" - with pathlib.Path("refresh_versions.toml").open("rb") as file: - _revision = tomli.load(file)["snap"]["revisions"][platform.machine()] - - class _MockSnap: - revision = _revision - - with ( - patch("subprocess.check_output", return_value=b"C"), - patch("charm.snap.SnapCache", lambda: {"charmed-postgresql": _MockSnap()}), - patch("charm.PostgresqlOperatorCharm._handle_postgresql_restart_need"), - patch("charm.PatroniManager.ensure_slots_controller_by_patroni"), - patch("charm.PostgresqlOperatorCharm._restart_metrics_service"), - patch("charm.PostgresqlOperatorCharm._restart_ldap_sync_service"), - patch("charm.PatroniManager.bulk_update_parameters_controller_by_patroni"), - patch("charm.PatroniManager.member_started", new_callable=PropertyMock, return_value=True), - patch("charm.VMWorkload.is_patroni_running", return_value=True), - patch("charm.ConfigManager.render_patroni_yml_file") as _render_patroni_yml_file, - patch( - "charm.PostgresqlOperatorCharm.is_tls_enabled", - new_callable=PropertyMock, - return_value=False, - ), - patch.object(harness.charm, "postgresql", Mock()) as postgresql_mock, - patch("charm.PostgresqlOperatorCharm.get_available_memory", return_value=8000000), - patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock, return_value=4 - ), - ): - postgresql_mock.is_tls_enabled = PropertyMock(return_value=False) - # Return None to test the case where build_postgresql_parameters returns None - postgresql_mock.build_postgresql_parameters.return_value = None - - # Configure worker processes - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "auto", - "cpu-wal-compression": True, - }) - - harness.charm.update_config() - - # Verify render was called with worker configs only (no pg_parameters) - call_args = _render_patroni_yml_file.call_args - parameters = call_args.kwargs["parameters"] - - # Should only have worker configs since pg_parameters was None - assert parameters["max_worker_processes"] == "8" # min(8, 2*4) - assert parameters["wal_compression"] == "on" - assert "shared_buffers" not in parameters # pg_parameters was None - - def test_on_peer_relation_changed(harness): with ( patch("charm.snap.SnapCache"), @@ -2438,24 +1865,6 @@ def test_clean_ca_file_from_workload(harness): _check_call.assert_called_once_with([UPDATE_CERTS_BIN_PATH]) -def test_get_available_memory(harness): - meminfo = ( - "MemTotal: 16089488 kB" - "MemFree: 799284 kB" - "MemAvailable: 3926924 kB" - "Buffers: 187232 kB" - "Cached: 4445936 kB" - "SwapCached: 156012 kB" - "Active: 11890336 kB" - ) - - with patch("builtins.open", mock_open(read_data=meminfo)): - assert harness.charm.get_available_memory() == 16475635712 - - with patch("builtins.open", mock_open(read_data="")): - assert harness.charm.get_available_memory() == 0 - - def test_juju_run_exec(harness): with ( patch("charm.ClusterTopologyObserver") as _topology_observer, @@ -2808,62 +2217,6 @@ def test_migration_from_single_secret(harness, scope, is_leader): ) -def test_handle_postgresql_restart_need(harness): - with ( - patch("charms.rolling_ops.v0.rollingops.RollingOpsManager._on_acquire_lock") as _restart, - patch("charm.wait_fixed", return_value=wait_fixed(0)), - patch( - "charm.PatroniManager.reload_patroni_configuration" - ) as _reload_patroni_configuration, - patch("charm.PostgresqlOperatorCharm.is_restart_pending") as _is_restart_pending, - patch("single_kernel_postgresql.core.state.CharmState.unit_ip"), - patch( - "charm.PostgresqlOperatorCharm.is_tls_enabled", new_callable=PropertyMock - ) as _is_tls_enabled, - patch.object(harness.charm, "postgresql", Mock()) as postgresql_mock, - ): - rel_id = harness.model.get_relation(PEER_RELATION).id - for values in itertools.product( - [True, False], [True, False], [True, False], [True, False] - ): - _reload_patroni_configuration.reset_mock() - _restart.reset_mock() - with harness.hooks_disabled(): - harness.update_relation_data(rel_id, harness.charm.unit.name, {"tls": ""}) - harness.update_relation_data( - rel_id, - harness.charm.unit.name, - {"postgresql_restarted": ("True" if values[3] else "")}, - ) - - _is_tls_enabled.return_value = values[0] - postgresql_mock.is_tls_enabled.return_value = values[1] - _is_restart_pending.return_value = values[2] - - harness.charm._handle_postgresql_restart_need(True) - _reload_patroni_configuration.assert_called_once() - if values[0]: - assert "tls" in harness.get_relation_data(rel_id, harness.charm.unit) - else: - assert "tls" not in harness.get_relation_data(rel_id, harness.charm.unit) - - if (values[0] != values[1]) or values[2]: - assert "postgresql_restarted" not in harness.get_relation_data( - rel_id, harness.charm.unit - ) - _restart.assert_called_once() - else: - if values[3]: - assert "postgresql_restarted" in harness.get_relation_data( - rel_id, harness.charm.unit - ) - else: - assert "postgresql_restarted" not in harness.get_relation_data( - rel_id, harness.charm.unit - ) - _restart.assert_not_called() - - def test_on_peer_relation_departed(harness): with ( patch( @@ -3534,382 +2887,3 @@ def test_on_secret_remove(harness): event.secret.label = None harness.charm._on_secret_remove(event) assert not event.remove_revision.called - - -def test_worker_process_validation_minimum_values(harness): - """Test that explicit worker process values below 2 are rejected by Pydantic validation.""" - import pytest - from pydantic import ValidationError - - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 - - # Test each parameter with value below minimum (2) - should fail at Pydantic level - invalid_configs = [ - ("cpu_max_worker_processes", 1), - ("cpu_max_parallel_workers", 0), - ("cpu_max_parallel_maintenance_workers", 1), - ("cpu_max_logical_replication_workers", 0), - ("cpu_max_sync_workers_per_subscription", 1), - ("cpu_max_parallel_apply_workers_per_subscription", 0), - ] - - for param_name, invalid_value in invalid_configs: - with harness.hooks_disabled(): - harness.update_config({param_name.replace("_", "-"): str(invalid_value)}) - - with pytest.raises( - ValidationError, - match=r"Input should be greater than or equal to 2", - ): - harness.charm._calculate_worker_process_config() - - -def test_worker_process_validation_maximum_values(harness): - """Test that explicit worker process values above 10*vCores are rejected.""" - import pytest - - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 2 # So max allowed is 10*2 = 20 - - # Test each parameter with value above maximum (20) - invalid_configs = [ - ("cpu_max_worker_processes", 25), - ("cpu_max_parallel_workers", 30), - ("cpu_max_parallel_maintenance_workers", 21), - ("cpu_max_logical_replication_workers", 50), - ("cpu_max_sync_workers_per_subscription", 100), - ("cpu_max_parallel_apply_workers_per_subscription", 22), - ] - - for param_name, invalid_value in invalid_configs: - # Reset all configs to auto first, then set the specific one we want to test - reset_config = { - "cpu-max-worker-processes": "auto", - "cpu-max-parallel-workers": "auto", - "cpu-max-parallel-maintenance-workers": "auto", - "cpu-max-logical-replication-workers": "auto", - "cpu-max-sync-workers-per-subscription": "auto", - "cpu-max-parallel-apply-workers-per-subscription": "auto", - } - reset_config[param_name.replace("_", "-")] = str(invalid_value) - - with harness.hooks_disabled(): - harness.update_config(reset_config) - - # Clear cached config property to ensure fresh validation - if hasattr(harness.charm, "config"): - del harness.charm.config - - with pytest.raises(ValueError, match=f"value {invalid_value} exceeds maximum allowed"): - harness.charm._calculate_worker_process_config() - - -def test_worker_process_validation_valid_range(harness): - """Test that valid worker process values (2 <= value <= 10*vCores) are accepted.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 # So valid range is 2-40 - - # Test valid values - valid_configs = { - "cpu-max-worker-processes": "16", - "cpu-max-parallel-workers": "8", - "cpu-max-parallel-maintenance-workers": "12", - "cpu-max-logical-replication-workers": "20", - "cpu-max-sync-workers-per-subscription": "40", - "cpu-max-parallel-apply-workers-per-subscription": "24", - } - - with harness.hooks_disabled(): - harness.update_config(valid_configs) - result = harness.charm._calculate_worker_process_config() - - # All should accept exact values specified - assert result["max_worker_processes"] == "16" - assert result["max_parallel_workers"] == "8" - assert result["max_parallel_maintenance_workers"] == "12" - assert result["max_logical_replication_workers"] == "20" - assert result["max_sync_workers_per_subscription"] == "40" - assert result["max_parallel_apply_workers_per_subscription"] == "24" - - -def test_worker_process_auto_mode_allows_values_below_2(harness): - """Test that auto mode can produce values below 2 (unlike explicit values).""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 1 # So auto = min(8, 2*1) = 2 - - with harness.hooks_disabled(): - harness.update_config({ - "cpu-max-worker-processes": "auto", - "cpu-max-parallel-workers": "auto", - "cpu-max-parallel-maintenance-workers": "auto", - "cpu-max-logical-replication-workers": "auto", - "cpu-max-sync-workers-per-subscription": "auto", - "cpu-max-parallel-apply-workers-per-subscription": "auto", - }) - - result = harness.charm._calculate_worker_process_config() - - # Auto mode should allow values below 2 - assert result["max_worker_processes"] == "2" - assert result["max_parallel_workers"] == "2" - assert result["max_parallel_maintenance_workers"] == "2" - assert result["max_logical_replication_workers"] == "2" - assert result["max_sync_workers_per_subscription"] == "2" - assert result["max_parallel_apply_workers_per_subscription"] == "2" - - -def test_validate_worker_config_value_success(harness): - """Test successful validation of worker config values.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 # vCores = 4, so max = 40 - - # Test successful validation - result = harness.charm._validate_worker_config_value("test_param", 8) - assert result == "8" - - # Test minimum valid value - result = harness.charm._validate_worker_config_value("test_param", 2) - assert result == "2" - - # Test maximum valid value - result = harness.charm._validate_worker_config_value("test_param", 40) - assert result == "40" - - -def test_validate_worker_config_value_exceeds_cpu_cap(harness): - """Test validation when value exceeds CPU cap.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 2 # vCores = 2, so max = 20 - - # Test value exceeding CPU cap - with pytest.raises(ValueError, match="value 25 exceeds maximum allowed of 20"): - harness.charm._validate_worker_config_value("test_param", 25) - - -def test_calculate_max_parallel_workers_with_constraint(harness): - """Test max_parallel_workers constrained by base_max_workers.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 # vCores = 4 - - # Set a high value for max_parallel_workers but low base_max_workers - with harness.hooks_disabled(): - harness.update_config({"cpu-max-parallel-workers": "20"}) # High value - - # Should be constrained by base_max_workers (which will be 8 from base) - base_max_workers = 6 - result = harness.charm._calculate_max_parallel_workers(base_max_workers) - # Should be min(20, 6) = 6 - assert result == "6" - - -def test_calculate_worker_process_config_partial_configs(harness): - """Test worker config calculation with only some configs set.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 4 - - # Set only some worker configs, others remain at default "auto" - config = { - "cpu-max-worker-processes": "12", - "cpu-max-parallel-workers": "auto", # Should use base (12) - "cpu-max-parallel-maintenance-workers": "8", - # Others remain as "auto" - } - - with harness.hooks_disabled(): - harness.update_config(config) - result = harness.charm._calculate_worker_process_config() - - assert result["max_worker_processes"] == "12" - assert result["max_parallel_workers"] == "12" # auto = base_max_workers - assert result["max_parallel_maintenance_workers"] == "8" - assert result["max_logical_replication_workers"] == "12" # auto = base - assert result["max_sync_workers_per_subscription"] == "12" # auto = base - assert result["max_parallel_apply_workers_per_subscription"] == "12" # auto = base - - -def test_calculate_worker_process_config_all_none(harness): - """Test worker config when all worker configs are None.""" - # Mock the config to return None for all worker process configs - with patch.object(harness.charm, "config") as mock_config: - mock_config.cpu_max_worker_processes = None - mock_config.cpu_max_parallel_workers = None - mock_config.cpu_max_parallel_maintenance_workers = None - mock_config.cpu_max_logical_replication_workers = None - mock_config.cpu_max_sync_workers_per_subscription = None - mock_config.cpu_max_parallel_apply_workers_per_subscription = None - - result = harness.charm._calculate_worker_process_config() - - # When all are None, should return empty dict - assert result == {} - - -def test_api_update_config_with_worker_processes(harness): - """Test _api_update_config includes worker process configurations.""" - with ( - patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count, - patch.object(harness.charm, "patroni_manager") as mock_patroni_manager, - patch.object( - harness.charm.async_replication, "get_primary_cluster_endpoint" - ) as mock_endpoint, - ): - _cpu_count.return_value = 4 - mock_endpoint.return_value = None # No primary endpoint - - # Set worker process configs - config = { - "cpu-max-worker-processes": "8", - "cpu-max-logical-replication-workers": "4", - } - - with harness.hooks_disabled(): - harness.update_config(config) - - harness.charm._api_update_config() - - # Verify that bulk_update_parameters_controller_by_patroni was called - assert mock_patroni_manager.bulk_update_parameters_controller_by_patroni.called - - # Get the arguments passed to the mock - call_args = mock_patroni_manager.bulk_update_parameters_controller_by_patroni.call_args - cfg_patch = call_args[0][0] # First argument - - # Verify worker configs are included - assert "max_worker_processes" in cfg_patch - assert "max_logical_replication_workers" in cfg_patch - assert cfg_patch["max_worker_processes"] == "8" - assert cfg_patch["max_logical_replication_workers"] == "4" - - -def test_api_update_config_with_experimental_max_connections(harness): - """Test _api_update_config with experimental_max_connections set.""" - with ( - patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count, - patch.object(harness.charm, "patroni_manager") as mock_patroni_manager, - patch.object( - harness.charm.async_replication, "get_primary_cluster_endpoint" - ) as mock_endpoint, - ): - _cpu_count.return_value = 4 - mock_endpoint.return_value = None - - # Set experimental_max_connections - with harness.hooks_disabled(): - harness.update_config({"experimental-max-connections": 500}) - - harness.charm._api_update_config() - - # Get the cfg_patch argument - call_args = mock_patroni_manager.bulk_update_parameters_controller_by_patroni.call_args - cfg_patch = call_args[0][0] - - # Should use experimental value instead of calculated value - assert cfg_patch["max_connections"] == 500 - - -def test_api_update_config_with_async_replication_primary(harness): - """Test _api_update_config when primary endpoint is available.""" - with ( - patch.object(harness.charm, "patroni_manager") as mock_patroni_manager, - patch.object( - harness.charm.async_replication, "get_primary_cluster_endpoint" - ) as mock_endpoint, - ): - # Mock primary endpoint - mock_endpoint.return_value = "primary-host:5432" - - harness.charm._api_update_config() - - # Get the arguments - call_args = mock_patroni_manager.bulk_update_parameters_controller_by_patroni.call_args - base_patch = call_args[0][1] - - # Verify standby_cluster is configured - assert "standby_cluster" in base_patch - assert base_patch["standby_cluster"]["host"] == "primary-host:5432" - - -def test_calculate_max_logical_replication_workers_with_base_value(harness): - """Test max_logical_replication_workers uses base_max_workers consistently.""" - with patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count: - _cpu_count.return_value = 3 # vCores = 3 - - # Test auto mode with specific base_max_workers value - with harness.hooks_disabled(): - harness.update_config({"cpu-max-logical-replication-workers": "auto"}) - - # Should use base_max_workers directly (like other worker params) - result = harness.charm._calculate_max_logical_replication_workers(10) - assert result == "10" - - -def test_update_config_integrates_all_worker_configs(harness): - """Test that update_config properly integrates all worker process configs.""" - - class _MockSnap: - present = True - services: ClassVar = {"patroni": {"active": True}} - - with ( - patch.object( - PostgresqlOperatorCharm, "cpu_count", new_callable=PropertyMock - ) as _cpu_count, - patch.object(harness.charm, "postgresql") as mock_postgresql, - patch.object(harness.charm, "get_available_memory") as mock_memory, - patch("charm.snap.SnapCache", lambda: {"charmed-postgresql": _MockSnap()}), - ): - _cpu_count.return_value = 4 - mock_memory.return_value = 1024 * 1024 * 1024 # 1GB - - # Mock postgresql.build_postgresql_parameters to return a dict - mock_postgresql.build_postgresql_parameters.return_value = { - "shared_buffers": "128MB", - } - - # Set comprehensive worker config - config = { - "cpu-max-worker-processes": "16", - "cpu-max-parallel-workers": "8", - "cpu-max-parallel-maintenance-workers": "4", - "cpu-max-logical-replication-workers": "12", - "cpu-max-sync-workers-per-subscription": "6", - "cpu-max-parallel-apply-workers-per-subscription": "10", - "cpu-wal-compression": True, - } - - with harness.hooks_disabled(): - harness.update_config(config) - - # Test the worker config calculation without calling the full update_config - worker_configs = harness.charm._calculate_worker_process_config() - - # Verify all worker configs are calculated properly - assert worker_configs["max_worker_processes"] == "16" - assert worker_configs["max_parallel_workers"] == "8" - assert worker_configs["max_parallel_maintenance_workers"] == "4" - assert worker_configs["max_logical_replication_workers"] == "12" - assert worker_configs["max_sync_workers_per_subscription"] == "6" - assert worker_configs["max_parallel_apply_workers_per_subscription"] == "10" From 5223b46939347d0181b16ae1555e21e8fb029dac Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Sat, 11 Jul 2026 13:35:54 -0300 Subject: [PATCH 9/9] chore(deps): re-pin single-kernel lib to 40752dd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lib's update_config stack was rebased to add the ported regression tests (resource-introspection edges + the enable_tls render assertion), orphaning the old tip 08f5993. Point the archive pin at the new #180 tip; the installed lib source is unchanged from 08f5993 — only lib test files were added. Signed-off-by: Marcelo Henrique Neppel --- poetry.lock | 6 +++--- pyproject.toml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2bd4ab74d6..0438848b16 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1851,7 +1851,7 @@ optional = false python-versions = ">=3.8,<4.0" groups = ["main", "integration"] files = [ - {file = "08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz", hash = "sha256:93fbe27439a68cbf1384cbe99bcee1ffb976c9cee4b7b02f516f7962ab6642ec"}, + {file = "40752dd71ffa1db9c15bf4a8c4f1dc0de7bf72b9.tar.gz", hash = "sha256:dd1e52f7648422a77418fcce0197160133132032d8c5d564945ce17415e72dd3"}, ] [package.dependencies] @@ -1880,7 +1880,7 @@ vm = ["charmlibs-snap (>=1.0.1) ; python_version >= \"3.12\"", "charmlibs-system [package.source] type = "url" -url = "https://github.com/canonical/postgresql-single-kernel-library/archive/08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz" +url = "https://github.com/canonical/postgresql-single-kernel-library/archive/40752dd71ffa1db9c15bf4a8c4f1dc0de7bf72b9.tar.gz" [[package]] name = "prompt-toolkit" @@ -3163,4 +3163,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "3d3fca069f0a75a215f3b7bc86bb9107db4e48f6b3a9b6d97c86b6cc4dfc5df3" +content-hash = "6a5ae64219b2bd6d922d9a5b7ee087a8d3cd4627ed381bb37c51472795472c57" diff --git a/pyproject.toml b/pyproject.toml index 2a269ae6e3..79207478f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ charm-refresh = "^3.1.0.2" charmlibs-snap = "^1.0.1" charmlibs-systemd = "^1.0.0.post0" charmlibs-interfaces-tls-certificates = "^1.8.3" -postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz", extras = ["postgresql", "vm"]} +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/40752dd71ffa1db9c15bf4a8c4f1dc0de7bf72b9.tar.gz", extras = ["postgresql", "vm"]} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py @@ -76,7 +76,7 @@ allure-pytest-default-results = "^0.1.4" tomli = "^2.4.1" tomli-w = "^1.2.0" pysyncobj = "*" -postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/08f5993677717a2d2f4a41b3a07631d44de28fba.tar.gz", extras = ["postgresql", "vm"]} +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/40752dd71ffa1db9c15bf4a8c4f1dc0de7bf72b9.tar.gz", extras = ["postgresql", "vm"]} [tool.poetry.group.build-refresh-version] optional = true