Skip to content

Commit 90e7728

Browse files
committed
feat(tls): operator-cert manager, events handler, and wiring
Wire the operator-certificate TLSManager and TLS events handler into the lib charm and bump the library version. The manager fetches operator cert/key live from the tls_certificates V4 requirers (constructor-injected by the handler); only the peer CA is tracked in state for rotation. Unit tests for this layer land in the stacked tests PR.
1 parent c7fcfd1 commit 90e7728

5 files changed

Lines changed: 323 additions & 8 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[project]
55
name = "postgresql-charms-single-kernel"
66
description = "Shared and reusable code for PostgreSQL-related charms"
7-
version = "16.3.0"
7+
version = "16.3.2"
88
readme = "README.md"
99
license = {file = "LICENSE"}
1010
authors = [

single_kernel_postgresql/charms/abstract_charm.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from single_kernel_postgresql.core.state import CharmState
1111
from single_kernel_postgresql.events.postgresql import PostgreSQLEventsHandler
12+
from single_kernel_postgresql.events.tls import TLS
1213
from single_kernel_postgresql.managers.cluster import ClusterManager
1314
from single_kernel_postgresql.managers.config import ConfigManager
1415
from single_kernel_postgresql.managers.patroni import PatroniManager
@@ -28,8 +29,17 @@ def __init__(self, *args):
2829
# State
2930
self.state = CharmState(charm=self, substrate=self.substrate)
3031

32+
# TLS events handler owns the two certificate requirers; build it before the
33+
# TLS manager so the manager can constructor-inject them for its live-fetch getters.
34+
self.tls = TLS(self, self.state, self.workload)
35+
3136
# Managers
32-
self.tls_manager = TLSManager(state=self.state, workload=self.workload)
37+
self.tls_manager = TLSManager(
38+
state=self.state,
39+
workload=self.workload,
40+
client_certificate=self.tls.client_certificate,
41+
peer_certificate=self.tls.peer_certificate,
42+
)
3343
self.patroni_manager = PatroniManager(state=self.state, workload=self.workload)
3444
self.cluster_manager = ClusterManager(state=self.state, workload=self.workload)
3545
self.config_manager = ConfigManager(state=self.state, workload=self.workload)
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Copyright 2026 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
"""TLS events handler — owns the operator-certificate requirers, delegates to TLSManager."""
4+
5+
import logging
6+
7+
from charmlibs.interfaces.tls_certificates import (
8+
CertificateRequestAttributes,
9+
TLSCertificatesRequiresV4,
10+
)
11+
from ops import EventSource
12+
from ops.framework import EventBase, Object
13+
14+
from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError
15+
from single_kernel_postgresql.config.literals import (
16+
PEER_RELATION,
17+
TLS_CLIENT_RELATION,
18+
TLS_PEER_RELATION,
19+
)
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class RefreshTLSCertificatesEvent(EventBase):
25+
"""Event emitted to trigger a re-request of TLS certificates with updated SANs."""
26+
27+
28+
class TLS(Object):
29+
"""Owns the client/peer certificate requirers and pushes assigned certs to the workload.
30+
31+
Operator-certificate handler: observes certificate_available and triggers a
32+
file-push via TLSManager. Also owns the refresh_tls_certificates_event that
33+
re-requests certificates whenever SANs change (emitted on peer relation_changed).
34+
35+
Design notes
36+
------------
37+
1. **Live-fetch model.** Operator cert/key are read live from the requirers
38+
(TLSManager.get_*_tls_files call get_assigned_certificates() on demand) and are
39+
never persisted — matching the pre-port charm (postgresql-operator/src/relations/
40+
tls.py). Only the peer CA is tracked in state (``current-ca`` / ``old-ca``) so the
41+
CA bundle can include the previous CA across a rotation; the requirer holds only
42+
the current cert. The manager is constructor-injected with these requirers and reads
43+
from them at call time (the V4 per-call idiom, cf. #168's postgresql_client).
44+
45+
2. **CA bundle terminology.** The ``current_ca`` term used in state mirrors the
46+
charm's live operator-CA term (postgresql-operator/src/relations/tls.py). It
47+
refers to the most recent peer CA from the TLS operator, not the internal
48+
self-signed CA.
49+
50+
3. **SANs and the relation_changed trigger.** The ``relation_changed``-driven
51+
``refresh_tls_certificates_event`` is a stand-in for the charm's IP-change
52+
trigger. The client/peer SANs in the certificate requests remain inert until
53+
the cluster (address-writer) code migrates and starts writing
54+
``database-address`` / ``database-peers-address`` keys into the peer databag.
55+
"""
56+
57+
refresh_tls_certificates_event = EventSource(RefreshTLSCertificatesEvent)
58+
59+
def __init__(self, charm, state, workload):
60+
super().__init__(charm, key="tls")
61+
self.charm = charm
62+
self.state = state
63+
self.workload = workload
64+
65+
client_addresses = self.state.client_addresses
66+
peer_addresses = self.state.peer_addresses
67+
68+
self.client_certificate = TLSCertificatesRequiresV4(
69+
self.charm,
70+
TLS_CLIENT_RELATION,
71+
certificate_requests=[
72+
CertificateRequestAttributes(
73+
common_name=self.state.client_common_name,
74+
sans_ip=frozenset(client_addresses),
75+
sans_dns=frozenset({*self.state.common_hosts, *client_addresses}),
76+
),
77+
],
78+
refresh_events=[self.refresh_tls_certificates_event],
79+
)
80+
self.peer_certificate = TLSCertificatesRequiresV4(
81+
self.charm,
82+
TLS_PEER_RELATION,
83+
certificate_requests=[
84+
CertificateRequestAttributes(
85+
common_name=self.state.peer_common_name,
86+
sans_ip=frozenset(peer_addresses),
87+
sans_dns=frozenset({*self.state.common_hosts, *peer_addresses}),
88+
),
89+
],
90+
refresh_events=[self.refresh_tls_certificates_event],
91+
)
92+
93+
# The TLS manager is constructor-injected with these requirers (built in
94+
# AbstractPostgreSQLCharm right after this handler); its live-fetch getters
95+
# read cert/key from them. This handler reaches the manager via self.charm.
96+
97+
self.framework.observe(
98+
self.client_certificate.on.certificate_available, self._on_certificate_available
99+
)
100+
self.framework.observe(
101+
self.peer_certificate.on.certificate_available, self._on_peer_certificate_available
102+
)
103+
self.framework.observe(
104+
self.charm.on[TLS_CLIENT_RELATION].relation_broken, self._on_certificate_available
105+
)
106+
self.framework.observe(
107+
self.charm.on[TLS_PEER_RELATION].relation_broken, self._on_peer_certificate_available
108+
)
109+
self.framework.observe(
110+
self.charm.on[PEER_RELATION].relation_changed, self._on_peer_relation_changed
111+
)
112+
113+
def _on_peer_relation_changed(self, event) -> None:
114+
"""Re-request certificates when peer addresses change."""
115+
# TODO: narrow this to fire only on address changes (database-address /
116+
# database-peers-address key diffs) once the cluster code lands and starts
117+
# writing those keys. Currently fires on any peer-databag change.
118+
self.refresh_tls_certificates_event.emit()
119+
120+
def _push_tls_files(self, event) -> None:
121+
"""Guard-then-push helper: defer if the workload is not yet ready.
122+
123+
Two conditions must hold before files can be written:
124+
1. The internal CA secret must exist — it is written by the leader on
125+
leader-elected, so non-leaders and early hooks may see it absent.
126+
2. The workload must accept file writes — on K8s the Pebble container
127+
may not be ready yet, causing PostgreSQLFileOperationError.
128+
129+
Mirrors postgresql-operator/src/relations/tls.py lines 157-170.
130+
"""
131+
if not self.state.application.internal_ca:
132+
logger.debug("Internal CA not yet present; deferring TLS file push.")
133+
event.defer()
134+
return
135+
try:
136+
self.charm.tls_manager.push_tls_files()
137+
except PostgreSQLFileOperationError:
138+
logger.debug("Workload not ready for TLS file write; deferring.")
139+
event.defer()
140+
141+
def _on_certificate_available(self, event) -> None:
142+
"""Push TLS files; the operator client cert/key is read live at push time."""
143+
self._push_tls_files(event)
144+
145+
def _on_peer_certificate_available(self, event) -> None:
146+
"""Rotate the peer CA if it changed, then push (cert/key read live at push time)."""
147+
certs, _ = self.peer_certificate.get_assigned_certificates()
148+
new_ca = str(certs[0].ca) if certs else None
149+
if new_ca is not None:
150+
self.charm.tls_manager.rotate_peer_ca(new_ca)
151+
else:
152+
self.charm.tls_manager.clear_peer_ca()
153+
self._push_tls_files(event)

single_kernel_postgresql/managers/tls.py

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from charmlibs.interfaces.tls_certificates import (
1414
Certificate,
1515
PrivateKey,
16+
TLSCertificatesRequiresV4,
1617
generate_ca,
1718
generate_certificate,
1819
generate_csr,
@@ -21,9 +22,13 @@
2122
from data_platform_helpers.advanced_statuses import StatusObject
2223
from 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
2526
from 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
)
2833
from single_kernel_postgresql.config.statuses import GeneralStatuses
2934
from 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

Comments
 (0)