Skip to content

Commit 6194b13

Browse files
dragomirprenovate[bot]sinclert-canonical
authored
[MISC] Sync main to 16/edge (#911)
* Update charmcraft.yaml build tools (#903) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Update canonical/data-platform-workflows action to v31.0.1 (#902) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * [DPE-6344] LDAP IV: Define pebble service (#897) * Update ghcr.io/canonical/charmed-postgresql:14.17-22.04_edge Docker digest to 5f8d51a (#908) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * [DPE-6344] LDAP V: Define mapping option (#900) --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Sinclert Pérez <[email protected]>
1 parent 323c2b5 commit 6194b13

File tree

8 files changed

+283
-48
lines changed

8 files changed

+283
-48
lines changed

config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ options:
6969
Enable synchronized sequential scans.
7070
type: boolean
7171
default: true
72+
ldap_map:
73+
description: |
74+
List of mapped LDAP group names to PostgreSQL group names, separated by commas.
75+
The map is used to assign LDAP synchronized users to PostgreSQL authorization groups.
76+
Example: <ldap_group_1>=<psql_group_1>,<ldap_group_2>=<psql_group_2>
77+
type: string
7278
ldap_search_filter:
7379
description: |
7480
The LDAP search filter to match users with.

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3737
# to 0 if you are raising the major API version
38-
LIBPATCH = 47
38+
LIBPATCH = 48
3939

4040
# Groups to distinguish HBA access
4141
ACCESS_GROUP_IDENTITY = "identity_access"
@@ -773,6 +773,42 @@ def is_restart_pending(self) -> bool:
773773
if connection:
774774
connection.close()
775775

776+
@staticmethod
777+
def build_postgresql_group_map(group_map: Optional[str]) -> List[Tuple]:
778+
"""Build the PostgreSQL authorization group-map.
779+
780+
Args:
781+
group_map: serialized group-map with the following format:
782+
<ldap_group_1>=<psql_group_1>,
783+
<ldap_group_2>=<psql_group_2>,
784+
...
785+
786+
Returns:
787+
List of LDAP group to PostgreSQL group tuples.
788+
"""
789+
if group_map is None:
790+
return []
791+
792+
group_mappings = group_map.split(",")
793+
group_mappings = (mapping.strip() for mapping in group_mappings)
794+
group_map_list = []
795+
796+
for mapping in group_mappings:
797+
mapping_parts = mapping.split("=")
798+
if len(mapping_parts) != 2:
799+
raise ValueError("The group-map must contain value pairs split by commas")
800+
801+
ldap_group = mapping_parts[0]
802+
psql_group = mapping_parts[1]
803+
804+
if psql_group in [*ACCESS_GROUPS, PERMISSIONS_GROUP_ADMIN]:
805+
logger.warning(f"Tried to assign LDAP users to forbidden group: {psql_group}")
806+
continue
807+
808+
group_map_list.append((ldap_group, psql_group))
809+
810+
return group_map_list
811+
776812
@staticmethod
777813
def build_postgresql_parameters(
778814
config_options: dict, available_memory: int, limit_memory: Optional[int] = None
@@ -852,3 +888,34 @@ def validate_date_style(self, date_style: str) -> bool:
852888
return True
853889
except psycopg2.Error:
854890
return False
891+
892+
def validate_group_map(self, group_map: Optional[str]) -> bool:
893+
"""Validate the PostgreSQL authorization group-map.
894+
895+
Args:
896+
group_map: serialized group-map with the following format:
897+
<ldap_group_1>=<psql_group_1>,
898+
<ldap_group_2>=<psql_group_2>,
899+
...
900+
901+
Returns:
902+
Whether the group-map is valid.
903+
"""
904+
if group_map is None:
905+
return True
906+
907+
try:
908+
group_map = self.build_postgresql_group_map(group_map)
909+
except ValueError:
910+
return False
911+
912+
for _, psql_group in group_map:
913+
with self._connect_to_database() as connection, connection.cursor() as cursor:
914+
query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")
915+
query = query.format(Literal(psql_group))
916+
cursor.execute(query)
917+
918+
if cursor.fetchone() is None:
919+
return False
920+
921+
return True

src/backups.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ def _on_restore_action(self, event): # noqa: C901
989989
# Stop the database service before performing the restore.
990990
logger.info("Stopping database service")
991991
try:
992-
self.container.stop(self.charm._postgresql_service)
992+
self.container.stop(self.charm.postgresql_service)
993993
except ChangeError as e:
994994
error_message = f"Failed to stop database service with error: {e!s}"
995995
logger.error(f"Restore failed: {error_message}")
@@ -1047,7 +1047,7 @@ def _on_restore_action(self, event): # noqa: C901
10471047

10481048
# Start the database to start the restore process.
10491049
logger.info("Configuring Patroni to restore the backup")
1050-
self.container.start(self.charm._postgresql_service)
1050+
self.container.start(self.charm.postgresql_service)
10511051

10521052
event.set_results({"restore-status": "restore started"})
10531053

@@ -1221,7 +1221,7 @@ def _restart_database(self) -> None:
12211221
"""Removes the restoring backup flag and restart the database."""
12221222
self.charm.app_peer_data.update({"restoring-backup": "", "restore-to-time": ""})
12231223
self.charm.update_config()
1224-
self.container.start(self.charm._postgresql_service)
1224+
self.container.start(self.charm.postgresql_service)
12251225

12261226
def _retrieve_s3_parameters(self) -> tuple[dict, list[str]]:
12271227
"""Retrieve S3 parameters from the S3 integrator relation."""

src/charm.py

Lines changed: 108 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import time
1515
from pathlib import Path
1616
from typing import Literal, get_args
17+
from urllib.parse import urlparse
1718

1819
# First platform-specific import, will fail on wrong architecture
1920
try:
@@ -35,6 +36,7 @@
3536
from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider
3637
from charms.loki_k8s.v1.loki_push_api import LogProxyConsumer
3738
from charms.postgresql_k8s.v0.postgresql import (
39+
ACCESS_GROUP_IDENTITY,
3840
ACCESS_GROUPS,
3941
REQUIRED_PLUGINS,
4042
PostgreSQL,
@@ -88,6 +90,7 @@
8890
APP_SCOPE,
8991
BACKUP_USER,
9092
DATABASE_DEFAULT_NAME,
93+
DATABASE_PORT,
9194
METRICS_PORT,
9295
MONITORING_PASSWORD_KEY,
9396
MONITORING_USER,
@@ -193,10 +196,11 @@ def __init__(self, *args):
193196
deleted_label=SECRET_DELETED_LABEL,
194197
)
195198

196-
self._postgresql_service = "postgresql"
199+
self.postgresql_service = "postgresql"
197200
self.rotate_logs_service = "rotate-logs"
198201
self.pgbackrest_server_service = "pgbackrest server"
199-
self._metrics_service = "metrics_server"
202+
self.ldap_sync_service = "ldap-sync"
203+
self.metrics_service = "metrics_server"
200204
self._unit = self.model.unit.name
201205
self._name = self.model.app.name
202206
self._namespace = self.model.name
@@ -586,7 +590,7 @@ def _on_peer_relation_changed(self, event: HookEvent) -> None: # noqa: C901
586590
logger.debug("on_peer_relation_changed early exit: Unit in blocked status")
587591
return
588592

589-
services = container.pebble.get_services(names=[self._postgresql_service])
593+
services = container.pebble.get_services(names=[self.postgresql_service])
590594
if (
591595
(self.is_cluster_restoring_backup or self.is_cluster_restoring_to_time)
592596
and len(services) > 0
@@ -1463,7 +1467,7 @@ def _on_update_status(self, _) -> None:
14631467
if not self._on_update_status_early_exit_checks(container):
14641468
return
14651469

1466-
services = container.pebble.get_services(names=[self._postgresql_service])
1470+
services = container.pebble.get_services(names=[self.postgresql_service])
14671471
if len(services) == 0:
14681472
# Service has not been added nor started yet, so don't try to check Patroni API.
14691473
logger.debug("on_update_status early exit: Service has not been added nor started yet")
@@ -1476,10 +1480,10 @@ def _on_update_status(self, _) -> None:
14761480
and services[0].current != ServiceStatus.ACTIVE
14771481
):
14781482
logger.warning(
1479-
f"{self._postgresql_service} pebble service inactive, restarting service"
1483+
f"{self.postgresql_service} pebble service inactive, restarting service"
14801484
)
14811485
try:
1482-
container.restart(self._postgresql_service)
1486+
container.restart(self.postgresql_service)
14831487
except ChangeError:
14841488
logger.exception("Failed to restart patroni")
14851489
# If service doesn't recover fast, exit and wait for next hook run to re-check
@@ -1576,7 +1580,7 @@ def _handle_processes_failures(self) -> bool:
15761580
# https://github.com/canonical/pebble/issues/149 is resolved.
15771581
if not self._patroni.member_started and self._patroni.is_database_running:
15781582
try:
1579-
container.restart(self._postgresql_service)
1583+
container.restart(self.postgresql_service)
15801584
logger.info("restarted Patroni because it was not running")
15811585
except ChangeError:
15821586
logger.error("failed to restart Patroni after checking that it was not running")
@@ -1713,6 +1717,40 @@ def _update_endpoints(
17131717
endpoints.remove(endpoint)
17141718
self._peers.data[self.app]["endpoints"] = json.dumps(endpoints)
17151719

1720+
def _generate_ldap_service(self) -> dict:
1721+
"""Generate the LDAP service definition."""
1722+
ldap_params = self.get_ldap_parameters()
1723+
1724+
ldap_url = urlparse(ldap_params["ldapurl"])
1725+
ldap_host = ldap_url.hostname
1726+
ldap_port = ldap_url.port
1727+
1728+
ldap_base_dn = ldap_params["ldapbasedn"]
1729+
ldap_bind_username = ldap_params["ldapbinddn"]
1730+
ldap_bing_password = ldap_params["ldapbindpasswd"]
1731+
ldap_group_mappings = self.postgresql.build_postgresql_group_map(self.config.ldap_map)
1732+
1733+
return {
1734+
"override": "replace",
1735+
"summary": "synchronize LDAP users",
1736+
"command": "/start-ldap-synchronizer.sh",
1737+
"startup": "enabled",
1738+
"environment": {
1739+
"LDAP_HOST": ldap_host,
1740+
"LDAP_PORT": ldap_port,
1741+
"LDAP_BASE_DN": ldap_base_dn,
1742+
"LDAP_BIND_USERNAME": ldap_bind_username,
1743+
"LDAP_BIND_PASSWORD": ldap_bing_password,
1744+
"LDAP_GROUP_IDENTITY": json.dumps(ACCESS_GROUP_IDENTITY),
1745+
"LDAP_GROUP_MAPPINGS": json.dumps(ldap_group_mappings),
1746+
"POSTGRES_HOST": "127.0.0.1",
1747+
"POSTGRES_PORT": DATABASE_PORT,
1748+
"POSTGRES_DATABASE": DATABASE_DEFAULT_NAME,
1749+
"POSTGRES_USERNAME": USER,
1750+
"POSTGRES_PASSWORD": self.get_secret(APP_SCOPE, USER_PASSWORD_KEY),
1751+
},
1752+
}
1753+
17161754
def _generate_metrics_service(self) -> dict:
17171755
"""Generate the metrics service definition."""
17181756
return {
@@ -1724,7 +1762,7 @@ def _generate_metrics_service(self) -> dict:
17241762
if self.get_secret("app", MONITORING_PASSWORD_KEY) is not None
17251763
else "disabled"
17261764
),
1727-
"after": [self._postgresql_service],
1765+
"after": [self.postgresql_service],
17281766
"user": WORKLOAD_OS_USER,
17291767
"group": WORKLOAD_OS_GROUP,
17301768
"environment": {
@@ -1743,7 +1781,7 @@ def _postgresql_layer(self) -> Layer:
17431781
"summary": "postgresql + patroni layer",
17441782
"description": "pebble config layer for postgresql + patroni",
17451783
"services": {
1746-
self._postgresql_service: {
1784+
self.postgresql_service: {
17471785
"override": "replace",
17481786
"summary": "entrypoint of the postgresql + patroni image",
17491787
"command": f"patroni {self._storage_path}/patroni.yml",
@@ -1773,7 +1811,13 @@ def _postgresql_layer(self) -> Layer:
17731811
"user": WORKLOAD_OS_USER,
17741812
"group": WORKLOAD_OS_GROUP,
17751813
},
1776-
self._metrics_service: self._generate_metrics_service(),
1814+
self.ldap_sync_service: {
1815+
"override": "replace",
1816+
"summary": "synchronize LDAP users",
1817+
"command": "/start-ldap-synchronizer.sh",
1818+
"startup": "disabled",
1819+
},
1820+
self.metrics_service: self._generate_metrics_service(),
17771821
self.rotate_logs_service: {
17781822
"override": "replace",
17791823
"summary": "rotate logs",
@@ -1782,7 +1826,7 @@ def _postgresql_layer(self) -> Layer:
17821826
},
17831827
},
17841828
"checks": {
1785-
self._postgresql_service: {
1829+
self.postgresql_service: {
17861830
"override": "replace",
17871831
"level": "ready",
17881832
"http": {
@@ -1885,14 +1929,59 @@ def _restart(self, event: RunWithLock) -> None:
18851929
# Start or stop the pgBackRest TLS server service when TLS certificate change.
18861930
self.backup.start_stop_pgbackrest_service()
18871931

1932+
def _restart_metrics_service(self) -> None:
1933+
"""Restart the monitoring service if the password was rotated."""
1934+
container = self.unit.get_container("postgresql")
1935+
current_layer = container.get_plan()
1936+
1937+
metrics_service = current_layer.services[self.metrics_service]
1938+
data_source_name = metrics_service.environment.get("DATA_SOURCE_NAME", "")
1939+
1940+
if metrics_service and not data_source_name.startswith(
1941+
f"user={MONITORING_USER} password={self.get_secret('app', MONITORING_PASSWORD_KEY)} "
1942+
):
1943+
container.add_layer(
1944+
self.metrics_service,
1945+
Layer({"services": {self.metrics_service: self._generate_metrics_service()}}),
1946+
combine=True,
1947+
)
1948+
container.restart(self.metrics_service)
1949+
1950+
def _restart_ldap_sync_service(self) -> None:
1951+
"""Restart the LDAP sync service in case any configuration changed."""
1952+
if not self._patroni.member_started:
1953+
logger.debug("Restart LDAP sync early exit: Patroni has not started yet")
1954+
return
1955+
1956+
container = self.unit.get_container("postgresql")
1957+
sync_service = container.pebble.get_services(names=[self.ldap_sync_service])
1958+
1959+
if not self.is_primary and sync_service[0].is_running():
1960+
logger.debug("Stopping LDAP sync service. It must only run in the primary")
1961+
container.stop(self.pg_ldap_sync_service)
1962+
1963+
if self.is_primary and not self.is_ldap_enabled:
1964+
logger.debug("Stopping LDAP sync service")
1965+
container.stop(self.ldap_sync_service)
1966+
return
1967+
1968+
if self.is_primary and self.is_ldap_enabled:
1969+
container.add_layer(
1970+
self.ldap_sync_service,
1971+
Layer({"services": {self.ldap_sync_service: self._generate_ldap_service()}}),
1972+
combine=True,
1973+
)
1974+
logger.debug("Starting LDAP sync service")
1975+
container.restart(self.ldap_sync_service)
1976+
18881977
@property
18891978
def _is_workload_running(self) -> bool:
18901979
"""Returns whether the workload is running (in an active state)."""
18911980
container = self.unit.get_container("postgresql")
18921981
if not container.can_connect():
18931982
return False
18941983

1895-
services = container.pebble.get_services(names=[self._postgresql_service])
1984+
services = container.pebble.get_services(names=[self.postgresql_service])
18961985
if len(services) == 0:
18971986
return False
18981987

@@ -1982,21 +2071,8 @@ def update_config(self, is_creating_backup: bool = False) -> bool:
19822071
})
19832072

19842073
self._handle_postgresql_restart_need()
1985-
1986-
# Restart the monitoring service if the password was rotated
1987-
container = self.unit.get_container("postgresql")
1988-
current_layer = container.get_plan()
1989-
if (
1990-
metrics_service := current_layer.services[self._metrics_service]
1991-
) and not metrics_service.environment.get("DATA_SOURCE_NAME", "").startswith(
1992-
f"user={MONITORING_USER} password={self.get_secret('app', MONITORING_PASSWORD_KEY)} "
1993-
):
1994-
container.add_layer(
1995-
self._metrics_service,
1996-
Layer({"services": {self._metrics_service: self._generate_metrics_service()}}),
1997-
combine=True,
1998-
)
1999-
container.restart(self._metrics_service)
2074+
self._restart_metrics_service()
2075+
self._restart_ldap_sync_service()
20002076

20012077
return True
20022078

@@ -2010,6 +2086,9 @@ def _validate_config_options(self) -> None:
20102086
"instance_default_text_search_config config option has an invalid value"
20112087
)
20122088

2089+
if not self.postgresql.validate_group_map(self.config.ldap_map):
2090+
raise ValueError("ldap_map config option has an invalid value")
2091+
20132092
if not self.postgresql.validate_date_style(self.config.request_date_style):
20142093
raise ValueError("request_date_style config option has an invalid value")
20152094

@@ -2081,14 +2160,14 @@ def _update_pebble_layers(self, replan: bool = True) -> None:
20812160
# Check if there are any changes to layer services.
20822161
if current_layer.services != new_layer.services:
20832162
# Changes were made, add the new layer.
2084-
container.add_layer(self._postgresql_service, new_layer, combine=True)
2163+
container.add_layer(self.postgresql_service, new_layer, combine=True)
20852164
logging.info("Added updated layer 'postgresql' to Pebble plan")
20862165
if replan:
20872166
container.replan()
20882167
logging.info("Restarted postgresql service")
20892168
if current_layer.checks != new_layer.checks:
20902169
# Changes were made, add the new layer.
2091-
container.add_layer(self._postgresql_service, new_layer, combine=True)
2170+
container.add_layer(self.postgresql_service, new_layer, combine=True)
20922171
logging.info("Updated health checks")
20932172

20942173
def _unit_name_to_pod_name(self, unit_name: str) -> str:

0 commit comments

Comments
 (0)