Skip to content

Commit 5049562

Browse files
authored
[DPE-10062] Single kernel changes (#168)
1 parent 1611b16 commit 5049562

23 files changed

Lines changed: 554 additions & 136 deletions

File tree

pyproject.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,25 @@ dependencies = [
2323

2424
[project.optional-dependencies]
2525
postgresql = [
26-
"psycopg2>=2.9.10",
2726
"httpx; python_version >= '3.12'",
2827
"data-platform-helpers>=0.1.7; python_version >= '3.12'",
2928
"charmlibs-pathops>=1.0.1; python_version >= '3.12'",
30-
"charmlibs-snap>=1.0.1; python_version >= '3.12'",
3129
"charmlibs-interfaces-tls-certificates>=1.8.3; python_version >= '3.12'",
3230
"charm-refresh; python_version >= '3.12'",
3331
"requests; python_version >= '3.12'",
3432
"tomli; python_version >= '3.12'",
3533
"pydantic>=2.0; python_version >= '3.12'",
3634
"jinja2 >=3.1.6; python_version >= '3.12'",
3735
"charmlibs-rollingops>=1.1.1; python_version >= '3.12'",
38-
]
36+
]
37+
vm = [
38+
"pysyncobj>=0.3.15; python_version >= '3.12'",
39+
"psutil>=7.2.2; python_version >= '3.12'",
40+
"charmlibs-snap>=1.0.1; python_version >= '3.12'",
41+
]
42+
db-driver = [
43+
"psycopg2>=2.9.10",
44+
]
3945

4046
[build-system]
4147
requires = ["uv_build>=0.11.0,<0.12.0"]

single_kernel_postgresql/charms/abstract_charm.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,10 @@ def __init__(self, *args):
2929
self.state = CharmState(charm=self, substrate=self.substrate)
3030

3131
# Managers
32-
self.cluster_manager = ClusterManager(
33-
state=self.state, workload=self.workload, client=self.postgresql
34-
)
35-
self.tls_manager = TLSManager(
36-
state=self.state, workload=self.workload, client=self.postgresql
37-
)
38-
self.config_manager = ConfigManager(
39-
state=self.state, workload=self.workload, client=self.postgresql
40-
)
41-
self.patroni_manager = PatroniManager(
42-
state=self.state, workload=self.workload, client=self.postgresql
43-
)
32+
self.tls_manager = TLSManager(state=self.state, workload=self.workload)
33+
self.patroni_manager = PatroniManager(state=self.state, workload=self.workload)
34+
self.cluster_manager = ClusterManager(state=self.state, workload=self.workload)
35+
self.config_manager = ConfigManager(state=self.state, workload=self.workload)
4436

4537
# Events Handler
4638
self.postgresql_events_handler = PostgreSQLEventsHandler(

single_kernel_postgresql/charms/k8s_charm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self, *args):
2525
assert isinstance(self.workload, K8sWorkload), ( # noqa: S101
2626
"Workload must be an instance of K8sWorkload"
2727
)
28-
self.k8s_manager = K8sManager(self.state, self.workload, self.postgresql)
28+
self.k8s_manager = K8sManager(self.state, self.workload)
2929

3030
@property
3131
def postgresql(self) -> PostgreSQL:

single_kernel_postgresql/config/exceptions.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,51 @@ class PostgreSQLCannotConnectError(Exception):
2525

2626
class TlsError(Exception):
2727
"""TLS implementation internal exception."""
28+
29+
30+
class RaftPostgresqlNotUpError(Exception):
31+
"""Postgresql not yet started."""
32+
33+
34+
class RaftPostgresqlStillUpError(Exception):
35+
"""Postgresql not yet down."""
36+
37+
38+
class RaftNotPromotedError(Exception):
39+
"""Leader not yet set when reinitialising raft."""
40+
41+
42+
class ClusterNotPromotedError(Exception):
43+
"""Raised when a cluster is not promoted."""
44+
45+
46+
class NotReadyError(Exception):
47+
"""Raised when not all cluster members healthy or finished initial sync."""
48+
49+
50+
class EndpointNotReadyError(Exception):
51+
"""Raised when an endpoint is not ready."""
52+
53+
54+
class StandbyClusterAlreadyPromotedError(Exception):
55+
"""Raised when a standby cluster is already promoted."""
56+
57+
58+
class RemoveRaftMemberFailedError(Exception):
59+
"""Raised when a remove raft member failed for some reason."""
60+
61+
62+
class AddRaftMemberFailedError(Exception):
63+
"""Raised when adding raft member failed for some reason."""
64+
65+
66+
class SwitchoverFailedError(Exception):
67+
"""Raised when a switchover failed for some reason."""
68+
69+
70+
class SwitchoverNotSyncError(SwitchoverFailedError):
71+
"""Raised when a switchover failed because node is not sync."""
72+
73+
74+
class UpdateSyncNodeCountError(Exception):
75+
"""Raised when updating synchronous_node_count failed for some reason."""

single_kernel_postgresql/config/literals.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
SNAP = "/snap/charmed-postgresql/current"
3232
VM_LOGS_PATH = "var/log/postgresql"
3333
VM_DATA_PATH = "var/lib/postgresql"
34+
VM_ARCHIVE_PATH = "data/archive"
35+
VM_DATA_LOGS_PATH = "data/logs"
36+
VM_TEMP_PATH = "data/temp"
3437

3538
## K8s Paths
3639
K8S_DATA_PATH = "var/lib/pg/data"
@@ -42,7 +45,9 @@
4245
# e.g. snap_current / "/etc/postgresql" will result in "/etc/postgresql" instead of "/var/snap/postgresql/current/etc/postgresql"
4346
POSTGRESQL_CONF_PATH = "etc/postgresql"
4447
POSTGRESQL_CONF_FILE = "postgresql.conf"
45-
48+
## Pgbackreest Paths
49+
PGBACKREST_CONF_PATH = "etc/pgbackrest"
50+
PGBACKREST_CONF_FILE = "pgbackrest.conf"
4651

4752
## TLS Paths
4853
TLS_CA_BUNDLE_FILE = "peer_ca_bundle.pem"
@@ -161,3 +166,11 @@
161166
WATCHER_USER = "watcher"
162167
WATCHER_PASSWORD_KEY = "watcher-password" # noqa: S105
163168
WATCHER_SECRET_LABEL = "watcher-secret" # noqa: S105
169+
170+
# Raft
171+
RAFT_PORT = 2222
172+
RAFT_PARTNER_PREFIX = "partner_node_status_server_"
173+
174+
# VM services
175+
VM_PATRONI_SERVICE_NAME = "snap.charmed-postgresql.patroni.service"
176+
VM_PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{VM_PATRONI_SERVICE_NAME}"

single_kernel_postgresql/core/peer_relation.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"""State objects for database-peers relation."""
77

88
import json
9+
from collections.abc import MutableMapping
10+
from functools import cached_property
911

1012
from ops import Application, BlockedStatus, Relation, Unit
1113

@@ -177,6 +179,34 @@ def peer_addresses(self) -> set[str]:
177179
peer_addrs.add(addr)
178180
return peer_addrs
179181

182+
@property
183+
def is_unit_departing(self) -> bool:
184+
"""Returns whether the unit is departing."""
185+
if not self.relation:
186+
return False
187+
return "departing" in self.relation.data[self.unit]
188+
189+
@property
190+
def is_unit_stopped(self) -> bool:
191+
"""Returns whether the unit is stopped."""
192+
if not self.relation:
193+
return False
194+
return "stopped" in self.relation.data[self.unit]
195+
196+
@property
197+
def is_connectivity_enabled(self) -> bool:
198+
"""Return whether this unit can be connected externally."""
199+
if not self.relation:
200+
return True
201+
return self.relation.data[self.unit].get("connectivity", "on") == "on"
202+
203+
@cached_property
204+
def data(self) -> MutableMapping[str, str]:
205+
"""Escape hatch method to access the peer data directly."""
206+
if not self.relation:
207+
return {}
208+
return self.relation.data[self.unit]
209+
180210

181211
class PostgreSQLApplication(RelationState):
182212
"""An PostgreSQL Application is the peer application state.
@@ -290,7 +320,7 @@ def cluster_name(self) -> str:
290320
return f"patroni-{self.app.name}"
291321
return self.app.name
292322

293-
@property
323+
@cached_property
294324
def planned_units(self) -> int:
295325
"""Get the number of planned units for the application."""
296326
return self.app.planned_units()
@@ -309,6 +339,39 @@ def endpoints(self) -> set[str]:
309339
return set()
310340
return set(json.loads(self.relation.data[self.app].get("endpoints", "[]")))
311341

342+
@property
343+
def is_cluster_initialised(self) -> bool:
344+
"""Returns whether the cluster is already initialised."""
345+
if not self.relation:
346+
return False
347+
return "cluster_initialised" in self.relation.data[self.app]
348+
349+
@property
350+
def is_cluster_restoring_backup(self) -> bool:
351+
"""Returns whether the cluster is restoring a backup."""
352+
if not self.relation:
353+
return False
354+
return "restoring-backup" in self.relation.data[self.app]
355+
356+
@property
357+
def is_cluster_restoring_to_time(self) -> bool:
358+
"""Returns whether the cluster is restoring a backup to a specific time."""
359+
if not self.relation:
360+
return False
361+
return "restore-to-time" in self.relation.data[self.app]
362+
363+
@property
364+
def is_ldap_charm_related(self) -> bool:
365+
"""Return whether this unit has an LDAP charm related."""
366+
if not self.relation:
367+
return False
368+
return self.relation.data[self.app].get("ldap_enabled", "False") == "True"
369+
370+
@property
371+
def is_ldap_enabled(self) -> bool:
372+
"""Return whether this unit has LDAP enabled."""
373+
return self.is_ldap_charm_related and self.is_cluster_initialised
374+
312375
def get_secret(self, key: str) -> str | None:
313376
"""Get the secret value for 'key' from the peer relation data."""
314377
if not self.relation:
@@ -326,3 +389,10 @@ def remove_secret(self, key: str) -> None:
326389
if not self.relation:
327390
return
328391
self.data_interface.delete_relation_data(self.relation.id, [key])
392+
393+
@cached_property
394+
def data(self) -> MutableMapping[str, str]:
395+
"""Escape hatch method to access the peer data directly."""
396+
if not self.relation:
397+
return {}
398+
return self.relation.data[self.app]

single_kernel_postgresql/core/state.py

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,30 @@
66

77
import re
88
import socket
9+
from contextlib import suppress
910
from functools import cached_property
1011
from typing import TYPE_CHECKING, Any, get_args
1112

1213
from data_platform_helpers.advanced_statuses import StatusesState, StatusObject
1314
from data_platform_helpers.advanced_statuses.types import Scope as AdvancedStatusesScope
14-
from ops import ConfigData, JujuVersion, ModelError, Object, Relation, SecretNotFoundError, Unit
15+
from ops import (
16+
ConfigData,
17+
JujuVersion,
18+
ModelError,
19+
Object,
20+
Relation,
21+
RelationNotFoundError,
22+
SecretNotFoundError,
23+
Unit,
24+
)
1525

1626
from single_kernel_postgresql.config.enums import Substrates
1727
from single_kernel_postgresql.config.literals import (
1828
APP_SCOPE,
29+
DATABASE,
1930
PEER_RELATION,
31+
REPLICATION_CONSUMER_RELATION,
32+
REPLICATION_OFFER_RELATION,
2033
SCOPES,
2134
STATUS_PEERS_RELATION,
2235
)
@@ -153,6 +166,27 @@ def unit_ip(self) -> str | None:
153166
if binding := self.model.get_binding(PEER_RELATION):
154167
return str(binding.network.bind_address)
155168

169+
@property
170+
def database_ip(self) -> str | None:
171+
"""Database endpoint address."""
172+
with suppress(RelationNotFoundError):
173+
if binding := self.model.get_binding(DATABASE):
174+
return str(binding.network.bind_address)
175+
176+
@property
177+
def replication_offer_ip(self) -> str | None:
178+
"""Async replication offer endpoint address."""
179+
with suppress(RelationNotFoundError):
180+
if binding := self.model.get_binding(REPLICATION_OFFER_RELATION):
181+
return str(binding.network.bind_address)
182+
183+
@property
184+
def replication_consumer_ip(self) -> str | None:
185+
"""Async replication consumer endpoint address."""
186+
with suppress(RelationNotFoundError):
187+
if binding := self.model.get_binding(REPLICATION_CONSUMER_RELATION):
188+
return str(binding.network.bind_address)
189+
156190
@property
157191
def fqdn(self) -> str | None:
158192
"""Current unit fqdn."""
@@ -165,17 +199,19 @@ def fqdn(self) -> str | None:
165199
def endpoint(self) -> str | None:
166200
"""Current unit endpoint."""
167201
if self.substrate == Substrates.K8S:
168-
return self.fqdn
202+
return f"{self.peer.unit.name.replace('/', '-')}.{self.application.app.name}-endpoints"
169203
else:
170204
return self.unit_ip
171205

172206
@property
173207
def endpoints(self) -> set[str]:
174208
"""Returns the list of endpoints of the current members of the cluster."""
175209
if self.peer_relation:
176-
return self.application.endpoints
177-
else:
178-
return {self.endpoint} if self.endpoint else set()
210+
if self.substrate == Substrates.K8S:
211+
return self.application.endpoints
212+
else:
213+
return self.peer_members_ips
214+
return {self.endpoint} if self.endpoint else set()
179215

180216
@property
181217
def model_name(self) -> str:
@@ -185,7 +221,7 @@ def model_name(self) -> str:
185221
@cached_property
186222
def patroni_url(self) -> str:
187223
"""Patroni REST API URL."""
188-
return f"https://{self.unit_ip}:8007"
224+
return f"https://{self.endpoint}:8008"
189225

190226
@property
191227
def peer_members_ips(self) -> set[str]:
@@ -227,7 +263,12 @@ def listen_ips(self) -> list[str]:
227263
ips = []
228264
if self.unit_ip:
229265
ips.append(self.unit_ip)
230-
# TODO: Add other ips
266+
if self.database_ip and self.database_ip not in ips:
267+
ips.append(self.database_ip)
268+
if self.replication_offer_ip and self.replication_offer_ip not in ips:
269+
ips.append(self.replication_offer_ip)
270+
if self.replication_consumer_ip and self.replication_consumer_ip not in ips:
271+
ips.append(self.replication_consumer_ip)
231272
return ips
232273

233274
# -- Secrets
@@ -403,3 +444,38 @@ def _search_interpolated_status(
403444
if re.fullmatch(regex_pattern, present_status.message) is not None:
404445
return present_status
405446
return None
447+
448+
@cached_property
449+
def synchronous_node_count(self) -> int:
450+
"""Number of expected sync standbys."""
451+
planned_units = self.application.planned_units
452+
if self.config.synchronous_node_count == "all":
453+
return planned_units - 1
454+
elif self.config.synchronous_node_count == "majority":
455+
return planned_units // 2
456+
# -1 for leader
457+
return (
458+
self.config.synchronous_node_count
459+
if self.config.synchronous_node_count < planned_units - 1
460+
else planned_units - 1
461+
)
462+
463+
@cached_property
464+
def synchronous_configuration(self) -> dict[str, Any]:
465+
"""Synchronous mode configuration."""
466+
# Try to update synchronous_node_count.
467+
return {
468+
"synchronous_node_count": self.synchronous_node_count,
469+
"synchronous_mode_strict": len(self.application.members_ips) > 1
470+
and self.config.synchronous_mode_strict
471+
and self.synchronous_node_count > 0,
472+
}
473+
474+
def _build_service_name(self, service: str) -> str:
475+
"""Build a full k8s service name based on the service name."""
476+
return f"{self.application.app.name}-{service}.{self.model_name}.svc.cluster.local"
477+
478+
@property
479+
def primary_endpoint(self) -> str:
480+
"""Returns the endpoint of the primary instance's service."""
481+
return self._build_service_name("primary")

0 commit comments

Comments
 (0)