postgresql-charms-single-kernel 16.3.2__tar.gz → 16.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/PKG-INFO +1 -1
  2. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/pyproject.toml +7 -4
  3. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/abstract_charm.py +11 -1
  4. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/literals.py +4 -0
  5. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/peer_relation.py +46 -0
  6. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/state.py +53 -6
  7. postgresql_charms_single_kernel-16.3.4/single_kernel_postgresql/events/tls.py +151 -0
  8. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/patroni.py +5 -1
  9. postgresql_charms_single_kernel-16.3.4/single_kernel_postgresql/managers/tls.py +250 -0
  10. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/base.py +32 -2
  11. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/k8s.py +15 -0
  12. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/paths/base.py +6 -0
  13. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/paths/k8s.py +16 -1
  14. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/paths/vm.py +5 -0
  15. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/vm.py +10 -0
  16. postgresql_charms_single_kernel-16.3.2/single_kernel_postgresql/managers/tls.py +0 -100
  17. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/LICENSE +0 -0
  18. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/README.md +0 -0
  19. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/__init__.py +0 -0
  20. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charmcraft.yaml +0 -0
  21. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/__init__.py +0 -0
  22. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/k8s_charm.py +0 -0
  23. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/vm_charm.py +0 -0
  24. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/compat/__init__.py +0 -0
  25. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/compat/postgresql.py +0 -0
  26. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/compat/pyproject.toml +0 -0
  27. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/__init__.py +0 -0
  28. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/enums.py +0 -0
  29. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/exceptions.py +0 -0
  30. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/locales.py +0 -0
  31. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/pyproject.toml +0 -0
  32. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/statuses.py +0 -0
  33. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/__init__.py +0 -0
  34. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/config.py +0 -0
  35. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/relation_state.py +0 -0
  36. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/events/__init__.py +0 -0
  37. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/events/postgresql.py +0 -0
  38. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/events/tls_transfer.py +0 -0
  39. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/lib/charms/data_platform_libs/v0/data_interfaces.py +0 -0
  40. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/lib/charms/data_platform_libs/v1/data_models.py +0 -0
  41. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/__init__.py +0 -0
  42. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/base.py +0 -0
  43. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/cluster.py +0 -0
  44. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/config.py +0 -0
  45. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/k8s.py +0 -0
  46. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/__init__.py +0 -0
  47. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/arch.py +0 -0
  48. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/filesystem.py +0 -0
  49. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/postgresql.py +0 -0
  50. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/secret.py +0 -0
  51. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/status.py +0 -0
  52. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/__init__.py +0 -0
  53. {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/paths/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: postgresql-charms-single-kernel
3
- Version: 16.3.2
3
+ Version: 16.3.4
4
4
  Summary: Shared and reusable code for PostgreSQL-related charms
5
5
  Author: Canonical Data Platform
6
6
  Author-email: Canonical Data Platform <data-platform@lists.launchpad.net>
@@ -4,7 +4,7 @@
4
4
  [project]
5
5
  name = "postgresql-charms-single-kernel"
6
6
  description = "Shared and reusable code for PostgreSQL-related charms"
7
- version = "16.3.2"
7
+ version = "16.3.4"
8
8
  readme = "README.md"
9
9
  license = {file = "LICENSE"}
10
10
  authors = [
@@ -48,20 +48,23 @@ db-driver = [
48
48
  requires = ["uv_build>=0.11.0,<0.12.0"]
49
49
  build-backend = "uv_build"
50
50
 
51
+ [tool.uv]
52
+ exclude-newer = "1 day"
53
+
51
54
  [tool.uv.build-backend]
52
55
  module-name = "single_kernel_postgresql"
53
56
  module-root = ""
54
57
 
55
58
  [dependency-groups]
56
59
  format = [
57
- "ruff==0.15.20"
60
+ "ruff==0.15.21"
58
61
  ]
59
62
  lint = [
60
63
  "codespell==2.4.2",
61
- "ty==0.0.55"
64
+ "ty==0.0.59"
62
65
  ]
63
66
  unit = [
64
- "coverage[toml]==7.14.3",
67
+ "coverage[toml]==7.15.1",
65
68
  "pytest==9.1.1"
66
69
  ]
67
70
 
@@ -9,6 +9,7 @@ from ops.charm import CharmBase
9
9
 
10
10
  from single_kernel_postgresql.core.state import CharmState
11
11
  from single_kernel_postgresql.events.postgresql import PostgreSQLEventsHandler
12
+ from single_kernel_postgresql.events.tls import TLS
12
13
  from single_kernel_postgresql.managers.cluster import ClusterManager
13
14
  from single_kernel_postgresql.managers.config import ConfigManager
14
15
  from single_kernel_postgresql.managers.patroni import PatroniManager
@@ -28,8 +29,17 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
28
29
  # State
29
30
  self.state = CharmState(charm=self, substrate=self.substrate)
30
31
 
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)
35
+
31
36
  # 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
+ )
33
43
  self.patroni_manager = PatroniManager(state=self.state, workload=self.workload)
34
44
  self.cluster_manager = ClusterManager(state=self.state, workload=self.workload)
35
45
  self.config_manager = ConfigManager(state=self.state, workload=self.workload)
@@ -52,6 +52,10 @@ PGBACKREST_CONF_FILE = "pgbackrest.conf"
52
52
  ## TLS Paths
53
53
  TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem"
54
54
 
55
+ # TLS relation names
56
+ TLS_CLIENT_RELATION = "client-certificates"
57
+ TLS_PEER_RELATION = "peer-certificates"
58
+
55
59
  # Scopes
56
60
  SCOPES = Literal["app", "unit"]
57
61
  APP_SCOPE = "app"
@@ -100,6 +100,24 @@ class PostgreSQLPeer(RelationState):
100
100
  """Set internal private key in the peer relation."""
101
101
  self.set_secret("internal-key", value)
102
102
 
103
+ @property
104
+ def current_ca(self) -> str | None:
105
+ """Current peer CA (unit secret); part of the peer CA bundle."""
106
+ return self.get_secret("current-ca")
107
+
108
+ @current_ca.setter
109
+ def current_ca(self, value: str) -> None:
110
+ self.set_secret("current-ca", value)
111
+
112
+ @property
113
+ def old_ca(self) -> str | None:
114
+ """Previous peer CA (unit secret); retained for the rotation window."""
115
+ return self.get_secret("old-ca")
116
+
117
+ @old_ca.setter
118
+ def old_ca(self, value: str) -> None:
119
+ self.set_secret("old-ca", value)
120
+
103
121
  @property
104
122
  def ip(self) -> str | None:
105
123
  """Get the unit's IP address from the peer relation data."""
@@ -144,6 +162,13 @@ class PostgreSQLPeer(RelationState):
144
162
  return None
145
163
  return self.relation.data[self.unit].get("database-peers-address", None)
146
164
 
165
+ @property
166
+ def database_address(self) -> str | None:
167
+ """Get the client-facing database endpoint address."""
168
+ if not self.relation:
169
+ return None
170
+ return self.relation.data[self.unit].get("database-address", None)
171
+
147
172
  @property
148
173
  def replication_address(self) -> str | None:
149
174
  """Get the address to be used for replication communication."""
@@ -207,6 +232,27 @@ class PostgreSQLPeer(RelationState):
207
232
  return {}
208
233
  return self.relation.data[self.unit]
209
234
 
235
+ @property
236
+ def peer_addresses_no_ip(self) -> set[str]:
237
+ """Peer addresses excluding the ``ip`` databag key (original K8s charm behavior).
238
+
239
+ The K8s charm never wrote ``ip`` into the operator peer-cert SANs; it relied on
240
+ ``database-peers-address`` + ``replication-address`` + ``replication-offer-address``
241
+ + ``private-address``. The VM charm additionally included ``ip``. This property
242
+ exposes the K8s-shaped set so :class:`CharmState` can pick the right one per
243
+ substrate without the peer object needing to know the substrate.
244
+ """
245
+ peer_addrs: set[str] = set()
246
+ if addr := self.database_peers_address:
247
+ peer_addrs.add(addr)
248
+ if addr := self.replication_address:
249
+ peer_addrs.add(addr)
250
+ if addr := self.replication_offer_address:
251
+ peer_addrs.add(addr)
252
+ if addr := self.private_address:
253
+ peer_addrs.add(addr)
254
+ return peer_addrs
255
+
210
256
 
211
257
  class PostgreSQLApplication(RelationState):
212
258
  """An PostgreSQL Application is the peer application state.
@@ -8,11 +8,12 @@ import re
8
8
  import socket
9
9
  from contextlib import suppress
10
10
  from functools import cached_property
11
- from typing import TYPE_CHECKING, Any, get_args
11
+ from typing import Any, get_args
12
12
 
13
13
  from data_platform_helpers.advanced_statuses import StatusesState, StatusObject
14
14
  from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope
15
15
  from ops import (
16
+ CharmBase,
16
17
  ConfigData,
17
18
  JujuVersion,
18
19
  ModelError,
@@ -43,16 +44,13 @@ from single_kernel_postgresql.utils import unit_name_to_pod_name
43
44
  from single_kernel_postgresql.utils.secret import translate_field_to_secret_key
44
45
  from single_kernel_postgresql.utils.status import format_status
45
46
 
46
- if TYPE_CHECKING:
47
- from single_kernel_postgresql.charms.abstract_charm import AbstractPostgreSQLCharm
48
-
49
47
 
50
48
  class CharmState(Object):
51
49
  """The global PostgreSQL Charm State."""
52
50
 
53
51
  def __init__(
54
52
  self,
55
- charm: "AbstractPostgreSQLCharm",
53
+ charm: CharmBase,
56
54
  substrate: Substrates,
57
55
  ) -> None:
58
56
  """Initialize the CharmState object."""
@@ -245,13 +243,62 @@ class CharmState(Object):
245
243
  @property
246
244
  def common_hosts(self) -> set[str]:
247
245
  """Common hosts to be used in TLS certificate SANs."""
248
- return {self.host, self.fqdn} if self.fqdn else {self.host}
246
+ hosts = {self.host, self.fqdn} if self.fqdn else {self.host}
247
+ if self.substrate == Substrates.K8S:
248
+ namespace = self.model.name
249
+ hosts |= {
250
+ f"{self.model.app.name}-primary.{namespace}.svc.cluster.local",
251
+ f"{self.model.app.name}-replicas.{namespace}.svc.cluster.local",
252
+ # the original K8s charm also included the resolved per-pod FQDN.
253
+ socket.getfqdn(),
254
+ }
255
+ return hosts
249
256
 
250
257
  @property
251
258
  def peer_common_name(self) -> str:
252
259
  """Return the common name for the internally generated peer certificate."""
260
+ if self.substrate == Substrates.K8S:
261
+ return self._k8s_cert_common_name
253
262
  return self.peer.database_peers_address or self.host
254
263
 
264
+ @property
265
+ def client_addresses(self) -> set[str]:
266
+ """Client-facing addresses for the operator client certificate SANs."""
267
+ addrs: set[str] = set()
268
+ if addr := self.peer.database_address:
269
+ addrs.add(addr)
270
+ return addrs
271
+
272
+ @property
273
+ def client_common_name(self) -> str:
274
+ """Common name for the operator client certificate."""
275
+ if self.substrate == Substrates.K8S:
276
+ return self._k8s_cert_common_name
277
+ return self.peer.database_address or self.host
278
+
279
+ @property
280
+ def _k8s_cert_common_name(self) -> str:
281
+ """K8s operator-cert CN: the unit endpoints FQDN, wildcarded if too long.
282
+
283
+ Matches the original K8s charm: ``<app>-<unit_id>.<app>-endpoints``, collapsing to
284
+ ``*.<app>-endpoints`` when the full FQDN exceeds the 64-char CN limit.
285
+ """
286
+ full = self._get_hostname_from_unit(unit_name_to_pod_name(self.model.unit.name))
287
+ if len(full) > 64:
288
+ return f"*.{self.model.app.name}-endpoints"
289
+ return full
290
+
291
+ @property
292
+ def peer_addresses(self) -> set[str]:
293
+ """Peer addresses for the operator peer certificate SANs (substrate-aware).
294
+
295
+ K8s excludes the ``ip`` databag key (original K8s charm never added an ``ip`` SAN);
296
+ VM keeps it (unchanged from pre-migration behavior).
297
+ """
298
+ if self.substrate == Substrates.K8S:
299
+ return self.peer.peer_addresses_no_ip
300
+ return self.peer.peer_addresses
301
+
255
302
  @property
256
303
  def listen_ips(self) -> list[str]:
257
304
  """Return the IPs to listen on.
@@ -0,0 +1,151 @@
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. Only the peer CA is tracked in
40
+ state (``current-ca`` / ``old-ca``) so the CA bundle can include the previous CA
41
+ across a rotation; the requirer holds only the current cert. The manager is
42
+ constructor-injected with these requirers and reads from them at call time.
43
+
44
+ 2. **CA bundle terminology.** The ``current_ca`` term used in state mirrors the
45
+ charm's live operator-CA term. It refers to the most recent peer CA from the TLS
46
+ operator, not the internal self-signed CA.
47
+
48
+ 3. **SANs and the relation_changed trigger.** The ``relation_changed``-driven
49
+ ``refresh_tls_certificates_event`` is a stand-in for the charm's IP-change
50
+ trigger. The client/peer SANs in the certificate requests remain inert until
51
+ the cluster (address-writer) code migrates and starts writing
52
+ ``database-address`` / ``database-peers-address`` keys into the peer databag.
53
+ """
54
+
55
+ refresh_tls_certificates_event = EventSource(RefreshTLSCertificatesEvent)
56
+
57
+ def __init__(self, charm, state):
58
+ super().__init__(charm, key="tls")
59
+ self.charm = charm
60
+ self.state = state
61
+
62
+ client_addresses = self.state.client_addresses
63
+ peer_addresses = self.state.peer_addresses
64
+
65
+ self.client_certificate = TLSCertificatesRequiresV4(
66
+ self.charm,
67
+ TLS_CLIENT_RELATION,
68
+ certificate_requests=[
69
+ CertificateRequestAttributes(
70
+ common_name=self.state.client_common_name,
71
+ sans_ip=frozenset(client_addresses),
72
+ sans_dns=frozenset({*self.state.common_hosts, *client_addresses}),
73
+ ),
74
+ ],
75
+ refresh_events=[self.refresh_tls_certificates_event],
76
+ )
77
+ self.peer_certificate = TLSCertificatesRequiresV4(
78
+ self.charm,
79
+ TLS_PEER_RELATION,
80
+ certificate_requests=[
81
+ CertificateRequestAttributes(
82
+ common_name=self.state.peer_common_name,
83
+ sans_ip=frozenset(peer_addresses),
84
+ sans_dns=frozenset({*self.state.common_hosts, *peer_addresses}),
85
+ ),
86
+ ],
87
+ refresh_events=[self.refresh_tls_certificates_event],
88
+ )
89
+
90
+ # The TLS manager is constructor-injected with these requirers (built in
91
+ # AbstractPostgreSQLCharm right after this handler); its live-fetch getters
92
+ # read cert/key from them. This handler reaches the manager via self.charm.
93
+
94
+ self.framework.observe(
95
+ self.client_certificate.on.certificate_available, self._on_certificate_available
96
+ )
97
+ self.framework.observe(
98
+ self.peer_certificate.on.certificate_available, self._on_peer_certificate_available
99
+ )
100
+ self.framework.observe(
101
+ self.charm.on[TLS_CLIENT_RELATION].relation_broken, self._on_certificate_available
102
+ )
103
+ self.framework.observe(
104
+ self.charm.on[TLS_PEER_RELATION].relation_broken, self._on_peer_certificate_available
105
+ )
106
+ self.framework.observe(
107
+ self.charm.on[PEER_RELATION].relation_changed, self._on_peer_relation_changed
108
+ )
109
+
110
+ def _on_peer_relation_changed(self, event) -> None:
111
+ """Re-request certificates when peer addresses change.
112
+
113
+ Fires on any peer-databag change: the address keys this should key on
114
+ (``database-address`` / ``database-peers-address``) are only written once
115
+ the cluster address-writer code migrates (see design note 3 above), and
116
+ the requirer no-ops when the resulting SANs are unchanged.
117
+ """
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
+ if not self.state.application.internal_ca:
130
+ logger.debug("Internal CA not yet present; deferring TLS file push.")
131
+ event.defer()
132
+ return
133
+ try:
134
+ self.charm.tls_manager.push_tls_files()
135
+ except PostgreSQLFileOperationError:
136
+ logger.debug("Workload not ready for TLS file write; deferring.")
137
+ event.defer()
138
+
139
+ def _on_certificate_available(self, event) -> None:
140
+ """Push TLS files; the operator client cert/key is read live at push time."""
141
+ self._push_tls_files(event)
142
+
143
+ def _on_peer_certificate_available(self, event) -> None:
144
+ """Rotate the peer CA if it changed, then push (cert/key read live at push time)."""
145
+ certs, _ = self.peer_certificate.get_assigned_certificates()
146
+ new_ca = str(certs[0].ca) if certs else None
147
+ if new_ca is not None:
148
+ self.charm.tls_manager.rotate_peer_ca(new_ca)
149
+ else:
150
+ self.charm.tls_manager.clear_peer_ca()
151
+ self._push_tls_files(event)
@@ -251,7 +251,11 @@ class PatroniManager(BaseManager):
251
251
  couldn't be retrieved yet.
252
252
  """
253
253
  # Request info from cluster endpoint (which returns all members of the cluster).
254
- cluster_status = self.cluster_status()
254
+ try:
255
+ cluster_status = self.cluster_status()
256
+ except RetryError:
257
+ logger.debug("Unable to get member status. Cluster status unreachable")
258
+ return ""
255
259
  if cluster_status:
256
260
  for member in cluster_status:
257
261
  if member["name"] == member_name:
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2025 Canonical Ltd.
3
+ # See LICENSE file for licensing details.
4
+
5
+ """TLS Manager.
6
+
7
+ Responsible for managing the TLS configuration of the PostgreSQL instance.
8
+ """
9
+
10
+ import logging
11
+ from datetime import timedelta
12
+
13
+ from charmlibs.interfaces.tls_certificates import (
14
+ Certificate,
15
+ PrivateKey,
16
+ TLSCertificatesRequiresV4,
17
+ generate_ca,
18
+ generate_certificate,
19
+ generate_csr,
20
+ generate_private_key,
21
+ )
22
+ from data_platform_helpers.advanced_statuses import StatusObject
23
+ from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope
24
+
25
+ from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError, TlsError
26
+ from single_kernel_postgresql.config.literals import (
27
+ APP_SCOPE,
28
+ TLS_CA_BUNDLE_FILE,
29
+ TLS_CA_FILE,
30
+ TLS_CERT_FILE,
31
+ TLS_KEY_FILE,
32
+ )
33
+ from single_kernel_postgresql.config.statuses import GeneralStatuses
34
+ from single_kernel_postgresql.core.state import CharmState
35
+ from single_kernel_postgresql.managers.base import BaseManager
36
+ from single_kernel_postgresql.workload.base import BaseWorkload
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class TLSManager(BaseManager):
42
+ """PostgreSQL TLS Manager.
43
+
44
+ This manager is responsible for handling TLS configuration operations.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ state: CharmState,
50
+ workload: BaseWorkload,
51
+ client_certificate: TLSCertificatesRequiresV4,
52
+ peer_certificate: TLSCertificatesRequiresV4,
53
+ ) -> None:
54
+ 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
61
+
62
+ def configure_internal_peer_ca(self) -> None:
63
+ """Configure TLS internal peer CA."""
64
+ if not self.state.get_secret(APP_SCOPE, "internal-ca"):
65
+ self.generate_internal_peer_ca()
66
+
67
+ def configure_internal_peer_cert(self) -> None:
68
+ """Configure TLS internal peer certificate."""
69
+ if not self.state.peer.internal_cert:
70
+ self.generate_internal_peer_cert()
71
+
72
+ def generate_internal_peer_cert(self) -> None:
73
+ """Generate internal peer certificate using the tls lib."""
74
+ if not (ca_key_secret := self.state.application.internal_ca_key):
75
+ raise TlsError("No CA key content.")
76
+ ca_key = PrivateKey.from_string(ca_key_secret)
77
+ if not (ca_secret := self.state.application.internal_ca):
78
+ raise TlsError("No CA cert content.")
79
+ ca = Certificate.from_string(ca_secret)
80
+ private_key = generate_private_key()
81
+ csr = generate_csr(
82
+ private_key,
83
+ common_name=self.state.peer_common_name,
84
+ # substrate-aware: K8s excludes the ip SAN (matches the original charm).
85
+ sans_ip=frozenset(self.state.peer_addresses),
86
+ sans_dns=frozenset({
87
+ *self.state.common_hosts,
88
+ # IP address need to be part of the DNS SANs list due to
89
+ # https://github.com/pgbackrest/pgbackrest/issues/1977.
90
+ *self.state.peer_addresses,
91
+ }),
92
+ )
93
+ cert = generate_certificate(csr, ca, ca_key, validity=timedelta(days=7300))
94
+ self.state.peer.internal_cert = str(cert)
95
+ self.state.peer.internal_key = str(private_key)
96
+
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.
100
+ logger.info(
101
+ "Internal peer certificate generated. Please use a proper TLS operator if possible."
102
+ )
103
+
104
+ def generate_internal_peer_ca(self) -> None:
105
+ """Generate internal peer CA using the tls lib."""
106
+ private_key = generate_private_key()
107
+ ca = generate_ca(
108
+ private_key,
109
+ common_name=self.state.internal_peer_ca_common_name,
110
+ validity=timedelta(days=7300),
111
+ )
112
+ logger.warning("Internal peer CA generated. Please use a proper TLS operator if possible.")
113
+ self.state.set_secret(APP_SCOPE, "internal-ca-key", str(private_key))
114
+ self.state.set_secret(APP_SCOPE, "internal-ca", str(ca))
115
+
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.
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
+ """
144
+ key = ca = cert = None
145
+ if self.client_certificate is not None:
146
+ certs, private_key = self.client_certificate.get_assigned_certificates()
147
+ if private_key:
148
+ key = str(private_key)
149
+ if certs:
150
+ cert = str(certs[0].certificate)
151
+ ca = str(certs[0].ca)
152
+ return key, ca, cert
153
+
154
+ def client_tls_files_on_disk(self) -> bool:
155
+ """Whether the client TLS files this unit serves are present on disk.
156
+
157
+ The reload bridge checks this before enabling TLS in the config so it never
158
+ renders ssl:on against files the push has not yet written: on K8s the Pebble
159
+ push can defer while the local config render would still succeed. A workload
160
+ that cannot be read (container down) counts as not-on-disk, so the caller defers.
161
+ """
162
+ tls = self.workload.paths.tls
163
+ try:
164
+ return all(
165
+ self.workload.exists(tls / f) for f in (TLS_KEY_FILE, TLS_CERT_FILE, TLS_CA_FILE)
166
+ )
167
+ except PostgreSQLFileOperationError:
168
+ return False
169
+
170
+ def get_peer_ca_bundle(self) -> str:
171
+ """Compose the peer CA bundle: live operator CA, old CA, internal CA.
172
+
173
+ The current operator CA is read live from the requirer (pre-port style);
174
+ the old CA and internal CA come from state.
175
+ """
176
+ operator_ca = ""
177
+ if self.peer_certificate is not None:
178
+ certs, _ = self.peer_certificate.get_assigned_certificates()
179
+ operator_ca = str(certs[0].ca) if certs else ""
180
+ cas = [
181
+ operator_ca,
182
+ self.state.peer.old_ca,
183
+ self.state.get_secret(APP_SCOPE, "internal-ca"),
184
+ ]
185
+ return "\n".join(ca for ca in cas if ca).strip()
186
+
187
+ def get_peer_tls_files(self) -> tuple[str | None, str | None, str | None]:
188
+ """Return (key, ca, cert) for the peer certificate, operator cert fetched live.
189
+
190
+ Prefers the operator-provided peer material (read live from the requirer,
191
+ with the composed CA bundle); falls back to the internally generated peer
192
+ material.
193
+ """
194
+ key = cert = None
195
+ if self.peer_certificate is not None:
196
+ certs, private_key = self.peer_certificate.get_assigned_certificates()
197
+ if private_key:
198
+ key = str(private_key)
199
+ if certs:
200
+ cert = str(certs[0].certificate)
201
+ if not all((key, cert)):
202
+ return (
203
+ self.state.peer.internal_key,
204
+ self.state.get_secret(APP_SCOPE, "internal-ca"),
205
+ self.state.peer.internal_cert,
206
+ )
207
+ return key, self.get_peer_ca_bundle(), cert
208
+
209
+ def _write_tls_file(self, content: str, path) -> None:
210
+ """Write a TLS file with substrate-specific permissions and ownership.
211
+
212
+ The mode comes from the workload (VM 0o600, K8s 0o400, matching the
213
+ pre-migration charms). Ownership is forwarded through pathops so it works
214
+ on both VM (LocalPath uses os.chown) and K8s (ContainerPath uses Pebble push).
215
+ """
216
+ self.workload.write_text(
217
+ content,
218
+ path,
219
+ self.workload.tls_file_mode,
220
+ user=self.workload.user,
221
+ group=self.workload.group,
222
+ )
223
+
224
+ def push_tls_files(self) -> None:
225
+ """Write the client, peer, and CA-bundle TLS files to the workload."""
226
+ tls = self.workload.paths.tls
227
+
228
+ key, ca, cert = self.get_client_tls_files()
229
+ if key is not None:
230
+ self._write_tls_file(key, tls / TLS_KEY_FILE)
231
+ if ca is not None:
232
+ self._write_tls_file(ca, tls / TLS_CA_FILE)
233
+ if cert is not None:
234
+ self._write_tls_file(cert, tls / TLS_CERT_FILE)
235
+
236
+ key, ca, cert = self.get_peer_tls_files()
237
+ if key is not None:
238
+ self._write_tls_file(key, tls / f"peer_{TLS_KEY_FILE}")
239
+ if ca is not None:
240
+ self._write_tls_file(ca, tls / f"peer_{TLS_CA_FILE}")
241
+ if cert is not None:
242
+ self._write_tls_file(cert, tls / f"peer_{TLS_CERT_FILE}")
243
+
244
+ self._write_tls_file(self.get_peer_ca_bundle(), tls / TLS_CA_BUNDLE_FILE)
245
+
246
+ def get_statuses(
247
+ self, scope: AdvancedStatusesScope, recompute: bool = False
248
+ ) -> list[StatusObject]:
249
+ """Compute the manager's statuses."""
250
+ return [GeneralStatuses.ACTIVE_IDLE.value]
@@ -41,6 +41,27 @@ class BaseWorkload(ABC):
41
41
  """Return the root path."""
42
42
  pass
43
43
 
44
+ @property
45
+ @abstractmethod
46
+ def user(self) -> str:
47
+ """The OS user that owns workload files (substrate-specific)."""
48
+ pass
49
+
50
+ @property
51
+ @abstractmethod
52
+ def group(self) -> str:
53
+ """The OS group that owns workload files (substrate-specific)."""
54
+ pass
55
+
56
+ @property
57
+ def tls_file_mode(self) -> int:
58
+ """File mode for TLS material written to disk.
59
+
60
+ Defaults to 0o600 (VM); K8s overrides to 0o400 to match the
61
+ pre-migration charm.
62
+ """
63
+ return 0o600
64
+
44
65
  @abstractmethod
45
66
  def install(self) -> None:
46
67
  """Install the workload."""
@@ -59,7 +80,12 @@ class BaseWorkload(ABC):
59
80
  pass
60
81
 
61
82
  def write_text(
62
- self, content: str, path: pathops.PathProtocol, mode: int | None = None
83
+ self,
84
+ content: str,
85
+ path: pathops.PathProtocol,
86
+ mode: int | None = None,
87
+ user: str | None = None,
88
+ group: str | None = None,
63
89
  ) -> None:
64
90
  """Write content to a file on disk.
65
91
 
@@ -67,18 +93,22 @@ class BaseWorkload(ABC):
67
93
  content (str): The content to be written.
68
94
  path (pathops.PathProtocol): The file path where the content should be written.
69
95
  mode (int, optional): The mode/permissions to use when writing the file.
96
+ user (str, optional): The user to own the file (forwarded to pathops for
97
+ substrate-correct chown: os.chown on VM, Pebble push on K8s).
98
+ group (str, optional): The group to own the file (forwarded to pathops).
70
99
 
71
100
  Raises:
72
101
  PostgreSQLFileOperationError: If there is an error during the file write operation.
73
102
  """
74
103
  try:
75
- path.write_text(content, mode=mode)
104
+ path.write_text(content, mode=mode, user=user, group=group)
76
105
  except (
77
106
  FileNotFoundError,
78
107
  LookupError,
79
108
  NotADirectoryError,
80
109
  PermissionError,
81
110
  pathops.PebbleConnectionError,
111
+ PebbleError,
82
112
  ValueError,
83
113
  ) as e:
84
114
  raise PostgreSQLFileOperationError(e) from e
@@ -167,6 +167,21 @@ class K8sWorkload(BaseWorkload):
167
167
  except PostgreSQLFileOperationError as e:
168
168
  logger.warning(f"Failed to delete temporary file {file_path}: {e}")
169
169
 
170
+ @property
171
+ def user(self) -> str:
172
+ """The OS user that owns workload files in the K8s container."""
173
+ return "postgres"
174
+
175
+ @property
176
+ def group(self) -> str:
177
+ """The OS group that owns workload files in the K8s container."""
178
+ return "postgres"
179
+
180
+ @property
181
+ def tls_file_mode(self) -> int:
182
+ """K8s pushes TLS material owner-read-only, matching the original charm."""
183
+ return 0o400
184
+
170
185
  @property
171
186
  def root(self) -> PathProtocol:
172
187
  """Return the root path for container filesystem.
@@ -82,3 +82,9 @@ class Paths(ABC):
82
82
  def pgbackrest_conf(self) -> PathProtocol:
83
83
  """Path to the patroni logs."""
84
84
  pass
85
+
86
+ @property
87
+ @abstractmethod
88
+ def tls(self) -> PathProtocol:
89
+ """Directory where TLS files are written for this substrate."""
90
+ pass
@@ -60,7 +60,22 @@ class K8sPaths(Paths):
60
60
 
61
61
  @property
62
62
  def patroni_conf(self) -> PathProtocol:
63
- """Path to the patroni configuration file."""
63
+ """Path to the patroni configuration directory.
64
+
65
+ This is the data storage root (``/var/lib/pg/data``) where the Patroni
66
+ config subsystem renders ``patroni.yaml`` — matching #172's Patroni port.
67
+ (The TLS ``.pem`` files go to the ``tls`` path, which is the same dir.)
68
+ """
69
+ return self.root / K8S_DATA_PATH
70
+
71
+ @property
72
+ def tls(self) -> PathProtocol:
73
+ """Directory where TLS files are written on K8s (the data dir Patroni reads from).
74
+
75
+ This is the *unversioned* data storage root (``/var/lib/pg/data``), which is
76
+ where the charm-rendered patroni.yml references the ``.pem`` files
77
+ (``{storage_path}/*.pem``) — NOT the versioned ``data`` subdir (``.../16/main``).
78
+ """
64
79
  return self.root / K8S_DATA_PATH
65
80
 
66
81
  @property
@@ -74,6 +74,11 @@ class VMPaths(Paths):
74
74
  """Path to the patroni.yaml file."""
75
75
  return self.snap_current / PATRONI_CONF_PATH
76
76
 
77
+ @property
78
+ def tls(self) -> PathProtocol:
79
+ """Directory where TLS files are written on VM (same as patroni_conf)."""
80
+ return self.patroni_conf
81
+
77
82
  @property
78
83
  def patroni_logs(self) -> PathProtocol:
79
84
  """Path to the patroni logs."""
@@ -195,6 +195,16 @@ class VMWorkload(BaseWorkload):
195
195
  except OSError as e:
196
196
  raise e
197
197
 
198
+ @property
199
+ def user(self) -> str:
200
+ """The OS user that owns workload files on VM."""
201
+ return "_daemon_"
202
+
203
+ @property
204
+ def group(self) -> str:
205
+ """The OS group that owns workload files on VM."""
206
+ return "_daemon_"
207
+
198
208
  @property
199
209
  def paths(self) -> BasePaths:
200
210
  """Return Workload's paths."""
@@ -1,100 +0,0 @@
1
- #!/usr/bin/env python3
2
- # Copyright 2025 Canonical Ltd.
3
- # See LICENSE file for licensing details.
4
-
5
- """TLS Manager.
6
-
7
- Responsible for managing the TLS configuration of the PostgreSQL instance.
8
- """
9
-
10
- import logging
11
- from datetime import timedelta
12
-
13
- from charmlibs.interfaces.tls_certificates import (
14
- Certificate,
15
- PrivateKey,
16
- generate_ca,
17
- generate_certificate,
18
- generate_csr,
19
- generate_private_key,
20
- )
21
- from data_platform_helpers.advanced_statuses import StatusObject
22
- from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope
23
-
24
- from single_kernel_postgresql.config.exceptions import TlsError
25
- from single_kernel_postgresql.config.literals import (
26
- APP_SCOPE,
27
- )
28
- from single_kernel_postgresql.config.statuses import GeneralStatuses
29
- from single_kernel_postgresql.core.state import CharmState
30
- from single_kernel_postgresql.managers.base import BaseManager
31
- from single_kernel_postgresql.workload.base import BaseWorkload
32
-
33
- logger = logging.getLogger(__name__)
34
-
35
-
36
- class TLSManager(BaseManager):
37
- """PostgreSQL TLS Manager.
38
-
39
- This manager is responsible for handling TLS configuration operations.
40
- """
41
-
42
- def __init__(self, state: CharmState, workload: BaseWorkload):
43
- super().__init__(state, workload, "tls_manager")
44
-
45
- def configure_internal_peer_ca(self) -> None:
46
- """Configure TLS internal peer CA."""
47
- if not self.state.get_secret(APP_SCOPE, "internal-ca"):
48
- self.generate_internal_peer_ca()
49
-
50
- def configure_internal_peer_cert(self) -> None:
51
- """Configure TLS internal peer certificate."""
52
- if not self.state.peer.internal_cert:
53
- self.generate_internal_peer_cert()
54
-
55
- def generate_internal_peer_cert(self) -> None:
56
- """Generate internal peer certificate using the tls lib."""
57
- if not (ca_key_secret := self.state.application.internal_ca_key):
58
- raise TlsError("No CA key content.")
59
- ca_key = PrivateKey.from_string(ca_key_secret)
60
- if not (ca_secret := self.state.application.internal_ca):
61
- raise TlsError("No CA cert content.")
62
- ca = Certificate.from_string(ca_secret)
63
- private_key = generate_private_key()
64
- csr = generate_csr(
65
- private_key,
66
- common_name=self.state.peer_common_name,
67
- sans_ip=frozenset(self.state.peer.peer_addresses),
68
- sans_dns=frozenset({
69
- *self.state.common_hosts,
70
- # IP address need to be part of the DNS SANs list due to
71
- # https://github.com/pgbackrest/pgbackrest/issues/1977.
72
- *self.state.peer.peer_addresses,
73
- }),
74
- )
75
- cert = generate_certificate(csr, ca, ca_key, validity=timedelta(days=7300))
76
- self.state.peer.internal_cert = str(cert)
77
- self.state.peer.internal_key = str(private_key)
78
-
79
- # self.charm.push_tls_files_to_workload()
80
- logger.info(
81
- "Internal peer certificate generated. Please use a proper TLS operator if possible."
82
- )
83
-
84
- def generate_internal_peer_ca(self) -> None:
85
- """Generate internal peer CA using the tls lib."""
86
- private_key = generate_private_key()
87
- ca = generate_ca(
88
- private_key,
89
- common_name=self.state.internal_peer_ca_common_name,
90
- validity=timedelta(days=7300),
91
- )
92
- logger.warning("Internal peer CA generated. Please use a proper TLS operator if possible.")
93
- self.state.set_secret(APP_SCOPE, "internal-ca-key", str(private_key))
94
- self.state.set_secret(APP_SCOPE, "internal-ca", str(ca))
95
-
96
- def get_statuses(
97
- self, scope: AdvancedStatusesScope, recompute: bool = False
98
- ) -> list[StatusObject]:
99
- """Compute the manager's statuses."""
100
- return [GeneralStatuses.ACTIVE_IDLE.value]