diff --git a/pyproject.toml b/pyproject.toml index 31420e3..bbf204b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ [project] name = "postgresql-charms-single-kernel" description = "Shared and reusable code for PostgreSQL-related charms" -version = "16.3.2" +version = "16.3.3" readme = "README.md" license = {file = "LICENSE"} authors = [ diff --git a/single_kernel_postgresql/charms/abstract_charm.py b/single_kernel_postgresql/charms/abstract_charm.py index 2a18407..671de44 100644 --- a/single_kernel_postgresql/charms/abstract_charm.py +++ b/single_kernel_postgresql/charms/abstract_charm.py @@ -9,6 +9,7 @@ from single_kernel_postgresql.core.state import CharmState from single_kernel_postgresql.events.postgresql import PostgreSQLEventsHandler +from single_kernel_postgresql.events.tls import TLS from single_kernel_postgresql.managers.cluster import ClusterManager from single_kernel_postgresql.managers.config import ConfigManager from single_kernel_postgresql.managers.patroni import PatroniManager @@ -28,8 +29,17 @@ def __init__(self, *args): # State self.state = CharmState(charm=self, substrate=self.substrate) + # TLS events handler owns the two certificate 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) + # Managers - self.tls_manager = TLSManager(state=self.state, workload=self.workload) + self.tls_manager = TLSManager( + state=self.state, + workload=self.workload, + client_certificate=self.tls.client_certificate, + peer_certificate=self.tls.peer_certificate, + ) 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) diff --git a/single_kernel_postgresql/events/tls.py b/single_kernel_postgresql/events/tls.py new file mode 100644 index 0000000..370d1d5 --- /dev/null +++ b/single_kernel_postgresql/events/tls.py @@ -0,0 +1,155 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""TLS events handler — owns the operator-certificate requirers, delegates to TLSManager.""" + +import logging + +from charmlibs.interfaces.tls_certificates import ( + CertificateRequestAttributes, + TLSCertificatesRequiresV4, +) +from ops import EventSource +from ops.framework import EventBase, Object + +from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError +from single_kernel_postgresql.config.literals import ( + PEER_RELATION, + TLS_CLIENT_RELATION, + TLS_PEER_RELATION, +) + +logger = logging.getLogger(__name__) + + +class RefreshTLSCertificatesEvent(EventBase): + """Event emitted to trigger a re-request of TLS certificates with updated SANs.""" + + +class TLS(Object): + """Owns the client/peer certificate requirers and pushes assigned certs to the workload. + + Operator-certificate handler: observes certificate_available and triggers a + file-push via TLSManager. Also owns the refresh_tls_certificates_event that + re-requests certificates whenever SANs change (emitted on peer relation_changed). + + Design notes + ------------ + 1. **Live-fetch model.** Operator cert/key are read live from the requirers + (TLSManager.get_*_tls_files call get_assigned_certificates() on demand) and are + never persisted — matching the pre-port charm (postgresql-operator/src/relations/ + tls.py). Only the peer CA is tracked in state (``current-ca`` / ``old-ca``) so the + CA bundle can include the previous CA across a rotation; the requirer holds only + the current cert. The manager is constructor-injected with these requirers and reads + from them at call time (the V4 per-call idiom, cf. #168's postgresql_client). + + 2. **CA bundle terminology.** The ``current_ca`` term used in state mirrors the + charm's live operator-CA term (postgresql-operator/src/relations/tls.py). It + refers to the most recent peer CA from the TLS operator, not the internal + self-signed CA. + + 3. **SANs and the relation_changed trigger.** The ``relation_changed``-driven + ``refresh_tls_certificates_event`` is a stand-in for the charm's IP-change + trigger. The client/peer SANs in the certificate requests remain inert until + the cluster (address-writer) code migrates and starts writing + ``database-address`` / ``database-peers-address`` keys into the peer databag. + """ + + refresh_tls_certificates_event = EventSource(RefreshTLSCertificatesEvent) + + def __init__(self, charm, state): + super().__init__(charm, key="tls") + self.charm = charm + self.state = state + + client_addresses = self.state.client_addresses + peer_addresses = self.state.peer_addresses + + self.client_certificate = TLSCertificatesRequiresV4( + self.charm, + TLS_CLIENT_RELATION, + certificate_requests=[ + CertificateRequestAttributes( + common_name=self.state.client_common_name, + sans_ip=frozenset(client_addresses), + sans_dns=frozenset({*self.state.common_hosts, *client_addresses}), + ), + ], + refresh_events=[self.refresh_tls_certificates_event], + ) + self.peer_certificate = TLSCertificatesRequiresV4( + self.charm, + TLS_PEER_RELATION, + certificate_requests=[ + CertificateRequestAttributes( + common_name=self.state.peer_common_name, + sans_ip=frozenset(peer_addresses), + sans_dns=frozenset({*self.state.common_hosts, *peer_addresses}), + ), + ], + refresh_events=[self.refresh_tls_certificates_event], + ) + + # The TLS manager is constructor-injected with these requirers (built in + # AbstractPostgreSQLCharm right after this handler); its live-fetch getters + # read cert/key from them. This handler reaches the manager via self.charm. + + 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 + ) + self.framework.observe( + self.charm.on[PEER_RELATION].relation_changed, self._on_peer_relation_changed + ) + + def _on_peer_relation_changed(self, event) -> None: + """Re-request certificates when peer addresses change. + + Fires on any peer-databag change: the address keys this should key on + (``database-address`` / ``database-peers-address``) are only written once + the cluster address-writer code migrates (see design note 3 above), and + the V4 requirer no-ops when the resulting SANs are unchanged. + """ + self.refresh_tls_certificates_event.emit() + + def _push_tls_files(self, event) -> None: + """Guard-then-push helper: defer if the workload is not yet ready. + + Two conditions must hold before files can be written: + 1. The internal CA secret must exist — it is written by the leader on + leader-elected, so non-leaders and early hooks may see it absent. + 2. The workload must accept file writes — on K8s the Pebble container + may not be ready yet, causing PostgreSQLFileOperationError. + + Mirrors postgresql-operator/src/relations/tls.py lines 157-170. + """ + if not self.state.application.internal_ca: + logger.debug("Internal CA not yet present; deferring TLS file push.") + event.defer() + return + try: + self.charm.tls_manager.push_tls_files() + except PostgreSQLFileOperationError: + logger.debug("Workload not ready for TLS file write; deferring.") + event.defer() + + def _on_certificate_available(self, event) -> None: + """Push TLS files; the operator client cert/key is read live at push time.""" + self._push_tls_files(event) + + def _on_peer_certificate_available(self, event) -> None: + """Rotate the peer CA if it changed, then push (cert/key read live at push time).""" + certs, _ = self.peer_certificate.get_assigned_certificates() + new_ca = str(certs[0].ca) if certs else None + if new_ca is not None: + self.charm.tls_manager.rotate_peer_ca(new_ca) + else: + self.charm.tls_manager.clear_peer_ca() + self._push_tls_files(event) diff --git a/single_kernel_postgresql/managers/tls.py b/single_kernel_postgresql/managers/tls.py index 8b12614..45d8ad2 100644 --- a/single_kernel_postgresql/managers/tls.py +++ b/single_kernel_postgresql/managers/tls.py @@ -13,6 +13,7 @@ from charmlibs.interfaces.tls_certificates import ( Certificate, PrivateKey, + TLSCertificatesRequiresV4, generate_ca, generate_certificate, generate_csr, @@ -21,9 +22,13 @@ from data_platform_helpers.advanced_statuses import StatusObject from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope -from single_kernel_postgresql.config.exceptions import TlsError +from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError, TlsError from single_kernel_postgresql.config.literals import ( APP_SCOPE, + TLS_CA_BUNDLE_FILE, + TLS_CA_FILE, + TLS_CERT_FILE, + TLS_KEY_FILE, ) from single_kernel_postgresql.config.statuses import GeneralStatuses from single_kernel_postgresql.core.state import CharmState @@ -39,8 +44,20 @@ class TLSManager(BaseManager): This manager is responsible for handling TLS configuration operations. """ - def __init__(self, state: CharmState, workload: BaseWorkload): + def __init__( + self, + state: CharmState, + workload: BaseWorkload, + client_certificate: TLSCertificatesRequiresV4, + peer_certificate: TLSCertificatesRequiresV4, + ) -> None: super().__init__(state, workload, "tls_manager") + # Operator-certificate requirers, constructor-injected by events.tls.TLS. + # The getters below fetch cert/key LIVE from the durable relation databag — no + # operator cert/key is persisted to state (matches the pre-port charm). Only the + # peer CA is tracked in state (current-ca / old-ca), for rotation. + self.client_certificate = client_certificate + self.peer_certificate = peer_certificate def configure_internal_peer_ca(self) -> None: """Configure TLS internal peer CA.""" @@ -64,19 +81,22 @@ def generate_internal_peer_cert(self) -> None: csr = generate_csr( private_key, common_name=self.state.peer_common_name, - sans_ip=frozenset(self.state.peer.peer_addresses), + # substrate-aware: K8s excludes the ip SAN (matches the original charm). + sans_ip=frozenset(self.state.peer_addresses), sans_dns=frozenset({ *self.state.common_hosts, # IP address need to be part of the DNS SANs list due to # https://github.com/pgbackrest/pgbackrest/issues/1977. - *self.state.peer.peer_addresses, + *self.state.peer_addresses, }), ) cert = generate_certificate(csr, ca, ca_key, validity=timedelta(days=7300)) self.state.peer.internal_cert = str(cert) self.state.peer.internal_key = str(private_key) - # self.charm.push_tls_files_to_workload() + # NOTE: pushing the internal-peer cert/CA to disk is owned by the config + # subsystem (not yet migrated); operator certs are pushed via + # TLSManager.push_tls_files from the events.tls handler. logger.info( "Internal peer certificate generated. Please use a proper TLS operator if possible." ) @@ -93,6 +113,138 @@ def generate_internal_peer_ca(self) -> None: self.state.set_secret(APP_SCOPE, "internal-ca-key", str(private_key)) self.state.set_secret(APP_SCOPE, "internal-ca", str(ca)) + def rotate_peer_ca(self, ca: str) -> None: + """Track the operator peer CA for rotation (current-ca -> old-ca). + + Only the CA is tracked in state; the operator cert/key are fetched live + from the requirer. Mirrors the pre-port _on_peer_certificate_available. + """ + if ca != self.state.peer.current_ca: + if self.state.peer.current_ca: + self.state.peer.old_ca = self.state.peer.current_ca + else: + # Re-enabling after a disable cleared current-ca: nothing is being + # rotated out, so drop any stale old CA left from before the disable. + self.state.peer.remove_secret("old-ca") + self.state.peer.current_ca = ca + + def clear_peer_ca(self) -> None: + """Retire the operator peer CA into old-ca on relation removal.""" + current = self.state.peer.current_ca + if current: + self.state.peer.old_ca = current + self.state.peer.remove_secret("current-ca") + + def get_client_tls_files(self) -> tuple[str | None, str | None, str | None]: + """Return (key, ca, cert) for the operator client cert, fetched live. + + Reads from the requirer's assigned certificate (the durable relation + databag) rather than persisted state — matches the pre-port charm + (postgresql-operator/src/relations/tls.py get_client_tls_files). + """ + key = ca = cert = None + if self.client_certificate is not 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 = str(certs[0].ca) + return key, ca, cert + + def client_tls_files_on_disk(self) -> bool: + """Whether the client TLS files this unit serves are present on disk. + + The reload bridge checks this before enabling TLS in the config so it never + renders ssl:on against files the push has not yet written: on K8s the Pebble + push can defer while the local config render would still succeed. A workload + that cannot be read (container down) counts as not-on-disk, so the caller defers. + """ + tls = self.workload.paths.tls + try: + return all( + self.workload.exists(tls / f) for f in (TLS_KEY_FILE, TLS_CERT_FILE, TLS_CA_FILE) + ) + except PostgreSQLFileOperationError: + return False + + def get_peer_ca_bundle(self) -> str: + """Compose the peer CA bundle: live operator CA, old CA, internal CA. + + The current operator CA is read live from the requirer (pre-port style); + the old CA and internal CA come from state. Mirrors the pre-port + postgresql-operator/src/relations/tls.py get_peer_ca_bundle. + """ + operator_ca = "" + if self.peer_certificate is not None: + certs, _ = self.peer_certificate.get_assigned_certificates() + operator_ca = str(certs[0].ca) if certs else "" + cas = [ + operator_ca, + self.state.peer.old_ca, + self.state.get_secret(APP_SCOPE, "internal-ca"), + ] + return "\n".join(ca for ca in cas if ca).strip() + + def get_peer_tls_files(self) -> tuple[str | None, str | None, str | None]: + """Return (key, ca, cert) for the peer certificate, operator cert fetched live. + + Prefers the operator-provided peer material (read live from the requirer, + with the composed CA bundle); falls back to the internally generated peer + material. Mirrors the pre-port get_peer_tls_files. + """ + key = cert = None + if self.peer_certificate is not None: + certs, private_key = self.peer_certificate.get_assigned_certificates() + if private_key: + key = str(private_key) + if certs: + cert = str(certs[0].certificate) + if not all((key, cert)): + return ( + self.state.peer.internal_key, + self.state.get_secret(APP_SCOPE, "internal-ca"), + self.state.peer.internal_cert, + ) + return key, self.get_peer_ca_bundle(), cert + + def _write_tls_file(self, content: str, path) -> None: + """Write a TLS file with substrate-specific permissions and ownership. + + The mode comes from the workload (VM 0o600, K8s 0o400, matching the + pre-migration charms). Ownership is forwarded through pathops so it works + on both VM (LocalPath uses os.chown) and K8s (ContainerPath uses Pebble push). + """ + self.workload.write_text( + content, + path, + self.workload.tls_file_mode, + user=self.workload.user, + group=self.workload.group, + ) + + def push_tls_files(self) -> None: + """Write the client, peer, and CA-bundle TLS files to the workload.""" + tls = self.workload.paths.tls + + key, ca, cert = self.get_client_tls_files() + if key is not None: + self._write_tls_file(key, tls / TLS_KEY_FILE) + if ca is not None: + self._write_tls_file(ca, tls / TLS_CA_FILE) + if cert is not None: + self._write_tls_file(cert, tls / TLS_CERT_FILE) + + key, ca, cert = self.get_peer_tls_files() + if key is not None: + self._write_tls_file(key, tls / f"peer_{TLS_KEY_FILE}") + if ca is not None: + self._write_tls_file(ca, tls / f"peer_{TLS_CA_FILE}") + if cert is not None: + self._write_tls_file(cert, tls / f"peer_{TLS_CERT_FILE}") + + self._write_tls_file(self.get_peer_ca_bundle(), tls / TLS_CA_BUNDLE_FILE) + def get_statuses( self, scope: AdvancedStatusesScope, recompute: bool = False ) -> list[StatusObject]: diff --git a/uv.lock b/uv.lock index 583ce35..91b9ded 100644 --- a/uv.lock +++ b/uv.lock @@ -982,7 +982,7 @@ wheels = [ [[package]] name = "postgresql-charms-single-kernel" -version = "16.3.2" +version = "16.3.3" source = { editable = "." } dependencies = [ { name = "ops", version = "2.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },