Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[project]
name = "postgresql-charms-single-kernel"
description = "Shared and reusable code for PostgreSQL-related charms"
version = "16.3.0"
version = "16.3.2"
readme = "README.md"
license = {file = "LICENSE"}
authors = [
Expand Down
12 changes: 11 additions & 1 deletion single_kernel_postgresql/charms/abstract_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, self.workload)

# 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)
Expand Down
153 changes: 153 additions & 0 deletions single_kernel_postgresql/events/tls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# 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, workload):
super().__init__(charm, key="tls")
self.charm = charm
self.state = state
self.workload = workload

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."""
# TODO: narrow this to fire only on address changes (database-address /
# database-peers-address key diffs) once the cluster code lands and starts
# writing those keys. Currently fires on any peer-databag change.
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)
162 changes: 157 additions & 5 deletions single_kernel_postgresql/managers/tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from charmlibs.interfaces.tls_certificates import (
Certificate,
PrivateKey,
TLSCertificatesRequiresV4,
generate_ca,
generate_certificate,
generate_csr,
Expand All @@ -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
Expand All @@ -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."""
Expand All @@ -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."
)
Expand All @@ -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]:
Expand Down
Loading