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.
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/PKG-INFO +1 -1
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/pyproject.toml +7 -4
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/abstract_charm.py +11 -1
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/literals.py +4 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/peer_relation.py +46 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/state.py +53 -6
- postgresql_charms_single_kernel-16.3.4/single_kernel_postgresql/events/tls.py +151 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/patroni.py +5 -1
- postgresql_charms_single_kernel-16.3.4/single_kernel_postgresql/managers/tls.py +250 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/base.py +32 -2
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/k8s.py +15 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/paths/base.py +6 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/paths/k8s.py +16 -1
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/paths/vm.py +5 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/vm.py +10 -0
- postgresql_charms_single_kernel-16.3.2/single_kernel_postgresql/managers/tls.py +0 -100
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/LICENSE +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/README.md +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charmcraft.yaml +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/k8s_charm.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/charms/vm_charm.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/compat/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/compat/postgresql.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/compat/pyproject.toml +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/enums.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/exceptions.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/locales.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/pyproject.toml +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/config/statuses.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/config.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/core/relation_state.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/events/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/events/postgresql.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/events/tls_transfer.py +0 -0
- {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
- {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
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/base.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/cluster.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/config.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/managers/k8s.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/arch.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/filesystem.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/postgresql.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/secret.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/utils/status.py +0 -0
- {postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/single_kernel_postgresql/workload/__init__.py +0 -0
- {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.
|
|
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>
|
{postgresql_charms_single_kernel-16.3.2 → postgresql_charms_single_kernel-16.3.4}/pyproject.toml
RENAMED
|
@@ -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.
|
|
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.
|
|
60
|
+
"ruff==0.15.21"
|
|
58
61
|
]
|
|
59
62
|
lint = [
|
|
60
63
|
"codespell==2.4.2",
|
|
61
|
-
"ty==0.0.
|
|
64
|
+
"ty==0.0.59"
|
|
62
65
|
]
|
|
63
66
|
unit = [
|
|
64
|
-
"coverage[toml]==7.
|
|
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(
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
@@ -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
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|