1313from charmlibs .interfaces .tls_certificates import (
1414 Certificate ,
1515 PrivateKey ,
16+ TLSCertificatesRequiresV4 ,
1617 generate_ca ,
1718 generate_certificate ,
1819 generate_csr ,
2122from data_platform_helpers .advanced_statuses import StatusObject
2223from data_platform_helpers .advanced_statuses .types import Scope as AdvancedStatusesScope
2324
24- from single_kernel_postgresql .config .exceptions import TlsError
25+ from single_kernel_postgresql .config .exceptions import PostgreSQLFileOperationError , TlsError
2526from single_kernel_postgresql .config .literals import (
2627 APP_SCOPE ,
28+ TLS_CA_BUNDLE_FILE ,
29+ TLS_CA_FILE ,
30+ TLS_CERT_FILE ,
31+ TLS_KEY_FILE ,
2732)
2833from single_kernel_postgresql .config .statuses import GeneralStatuses
2934from single_kernel_postgresql .core .state import CharmState
@@ -39,8 +44,20 @@ class TLSManager(BaseManager):
3944 This manager is responsible for handling TLS configuration operations.
4045 """
4146
42- def __init__ (self , state : CharmState , workload : BaseWorkload ):
47+ def __init__ (
48+ self ,
49+ state : CharmState ,
50+ workload : BaseWorkload ,
51+ client_certificate : TLSCertificatesRequiresV4 ,
52+ peer_certificate : TLSCertificatesRequiresV4 ,
53+ ) -> None :
4354 super ().__init__ (state , workload , "tls_manager" )
55+ # Operator-certificate requirers, constructor-injected by events.tls.TLS.
56+ # The getters below fetch cert/key LIVE from the durable relation databag — no
57+ # operator cert/key is persisted to state (matches the pre-port charm). Only the
58+ # peer CA is tracked in state (current-ca / old-ca), for rotation.
59+ self .client_certificate = client_certificate
60+ self .peer_certificate = peer_certificate
4461
4562 def configure_internal_peer_ca (self ) -> None :
4663 """Configure TLS internal peer CA."""
@@ -64,19 +81,22 @@ def generate_internal_peer_cert(self) -> None:
6481 csr = generate_csr (
6582 private_key ,
6683 common_name = self .state .peer_common_name ,
67- sans_ip = frozenset (self .state .peer .peer_addresses ),
84+ # substrate-aware: K8s excludes the ip SAN (matches the original charm).
85+ sans_ip = frozenset (self .state .peer_addresses ),
6886 sans_dns = frozenset ({
6987 * self .state .common_hosts ,
7088 # IP address need to be part of the DNS SANs list due to
7189 # https://github.com/pgbackrest/pgbackrest/issues/1977.
72- * self .state .peer . peer_addresses ,
90+ * self .state .peer_addresses ,
7391 }),
7492 )
7593 cert = generate_certificate (csr , ca , ca_key , validity = timedelta (days = 7300 ))
7694 self .state .peer .internal_cert = str (cert )
7795 self .state .peer .internal_key = str (private_key )
7896
79- # self.charm.push_tls_files_to_workload()
97+ # NOTE: pushing the internal-peer cert/CA to disk is owned by the config
98+ # subsystem (not yet migrated); operator certs are pushed via
99+ # TLSManager.push_tls_files from the events.tls handler.
80100 logger .info (
81101 "Internal peer certificate generated. Please use a proper TLS operator if possible."
82102 )
@@ -93,6 +113,138 @@ def generate_internal_peer_ca(self) -> None:
93113 self .state .set_secret (APP_SCOPE , "internal-ca-key" , str (private_key ))
94114 self .state .set_secret (APP_SCOPE , "internal-ca" , str (ca ))
95115
116+ def rotate_peer_ca (self , ca : str ) -> None :
117+ """Track the operator peer CA for rotation (current-ca -> old-ca).
118+
119+ Only the CA is tracked in state; the operator cert/key are fetched live
120+ from the requirer. Mirrors the pre-port _on_peer_certificate_available.
121+ """
122+ if ca != self .state .peer .current_ca :
123+ if self .state .peer .current_ca :
124+ self .state .peer .old_ca = self .state .peer .current_ca
125+ else :
126+ # Re-enabling after a disable cleared current-ca: nothing is being
127+ # rotated out, so drop any stale old CA left from before the disable.
128+ self .state .peer .remove_secret ("old-ca" )
129+ self .state .peer .current_ca = ca
130+
131+ def clear_peer_ca (self ) -> None :
132+ """Retire the operator peer CA into old-ca on relation removal."""
133+ current = self .state .peer .current_ca
134+ if current :
135+ self .state .peer .old_ca = current
136+ self .state .peer .remove_secret ("current-ca" )
137+
138+ def get_client_tls_files (self ) -> tuple [str | None , str | None , str | None ]:
139+ """Return (key, ca, cert) for the operator client cert, fetched live.
140+
141+ Reads from the requirer's assigned certificate (the durable relation
142+ databag) rather than persisted state — matches the pre-port charm
143+ (postgresql-operator/src/relations/tls.py get_client_tls_files).
144+ """
145+ key = ca = cert = None
146+ if self .client_certificate is not None :
147+ certs , private_key = self .client_certificate .get_assigned_certificates ()
148+ if private_key :
149+ key = str (private_key )
150+ if certs :
151+ cert = str (certs [0 ].certificate )
152+ ca = str (certs [0 ].ca )
153+ return key , ca , cert
154+
155+ def client_tls_files_on_disk (self ) -> bool :
156+ """Whether the client TLS files this unit serves are present on disk.
157+
158+ The reload bridge checks this before enabling TLS in the config so it never
159+ renders ssl:on against files the push has not yet written: on K8s the Pebble
160+ push can defer while the local config render would still succeed. A workload
161+ that cannot be read (container down) counts as not-on-disk, so the caller defers.
162+ """
163+ tls = self .workload .paths .tls
164+ try :
165+ return all (
166+ self .workload .exists (tls / f ) for f in (TLS_KEY_FILE , TLS_CERT_FILE , TLS_CA_FILE )
167+ )
168+ except PostgreSQLFileOperationError :
169+ return False
170+
171+ def get_peer_ca_bundle (self ) -> str :
172+ """Compose the peer CA bundle: live operator CA, old CA, internal CA.
173+
174+ The current operator CA is read live from the requirer (pre-port style);
175+ the old CA and internal CA come from state. Mirrors the pre-port
176+ postgresql-operator/src/relations/tls.py get_peer_ca_bundle.
177+ """
178+ operator_ca = ""
179+ if self .peer_certificate is not None :
180+ certs , _ = self .peer_certificate .get_assigned_certificates ()
181+ operator_ca = str (certs [0 ].ca ) if certs else ""
182+ cas = [
183+ operator_ca ,
184+ self .state .peer .old_ca ,
185+ self .state .get_secret (APP_SCOPE , "internal-ca" ),
186+ ]
187+ return "\n " .join (ca for ca in cas if ca ).strip ()
188+
189+ def get_peer_tls_files (self ) -> tuple [str | None , str | None , str | None ]:
190+ """Return (key, ca, cert) for the peer certificate, operator cert fetched live.
191+
192+ Prefers the operator-provided peer material (read live from the requirer,
193+ with the composed CA bundle); falls back to the internally generated peer
194+ material. Mirrors the pre-port get_peer_tls_files.
195+ """
196+ key = cert = None
197+ if self .peer_certificate is not None :
198+ certs , private_key = self .peer_certificate .get_assigned_certificates ()
199+ if private_key :
200+ key = str (private_key )
201+ if certs :
202+ cert = str (certs [0 ].certificate )
203+ if not all ((key , cert )):
204+ return (
205+ self .state .peer .internal_key ,
206+ self .state .get_secret (APP_SCOPE , "internal-ca" ),
207+ self .state .peer .internal_cert ,
208+ )
209+ return key , self .get_peer_ca_bundle (), cert
210+
211+ def _write_tls_file (self , content : str , path ) -> None :
212+ """Write a TLS file with substrate-specific permissions and ownership.
213+
214+ The mode comes from the workload (VM 0o600, K8s 0o400, matching the
215+ pre-migration charms). Ownership is forwarded through pathops so it works
216+ on both VM (LocalPath uses os.chown) and K8s (ContainerPath uses Pebble push).
217+ """
218+ self .workload .write_text (
219+ content ,
220+ path ,
221+ self .workload .tls_file_mode ,
222+ user = self .workload .user ,
223+ group = self .workload .group ,
224+ )
225+
226+ def push_tls_files (self ) -> None :
227+ """Write the client, peer, and CA-bundle TLS files to the workload."""
228+ tls = self .workload .paths .tls
229+
230+ key , ca , cert = self .get_client_tls_files ()
231+ if key is not None :
232+ self ._write_tls_file (key , tls / TLS_KEY_FILE )
233+ if ca is not None :
234+ self ._write_tls_file (ca , tls / TLS_CA_FILE )
235+ if cert is not None :
236+ self ._write_tls_file (cert , tls / TLS_CERT_FILE )
237+
238+ key , ca , cert = self .get_peer_tls_files ()
239+ if key is not None :
240+ self ._write_tls_file (key , tls / f"peer_{ TLS_KEY_FILE } " )
241+ if ca is not None :
242+ self ._write_tls_file (ca , tls / f"peer_{ TLS_CA_FILE } " )
243+ if cert is not None :
244+ self ._write_tls_file (cert , tls / f"peer_{ TLS_CERT_FILE } " )
245+
246+ self ._write_tls_file (self .get_peer_ca_bundle (), tls / TLS_CA_BUNDLE_FILE )
247+
96248 def get_statuses (
97249 self , scope : AdvancedStatusesScope , recompute : bool = False
98250 ) -> list [StatusObject ]:
0 commit comments