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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ options:
Enable synchronized sequential scans.
type: boolean
default: true
ldap_map:
description: |
List of mapped LDAP group names to PostgreSQL group names, separated by commas.
The map is used to assign LDAP synchronized users to PostgreSQL authorization groups.
Example: <ldap_group_1>=<psql_group_1>,<ldap_group_2>=<psql_group_2>
type: string
ldap_search_filter:
description: |
The LDAP search filter to match users with.
Expand Down
69 changes: 68 additions & 1 deletion lib/charms/postgresql_k8s/v0/postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

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

# Groups to distinguish HBA access
ACCESS_GROUP_IDENTITY = "identity_access"
Expand Down Expand Up @@ -773,6 +773,42 @@ def is_restart_pending(self) -> bool:
if connection:
connection.close()

@staticmethod
def build_postgresql_group_map(group_map: Optional[str]) -> List[Tuple]:
"""Build the PostgreSQL authorization group-map.

Args:
group_map: serialized group-map with the following format:
<ldap_group_1>=<psql_group_1>,
<ldap_group_2>=<psql_group_2>,
...

Returns:
List of LDAP group to PostgreSQL group tuples.
"""
if group_map is None:
return []

group_mappings = group_map.split(",")
group_mappings = (mapping.strip() for mapping in group_mappings)
group_map_list = []

for mapping in group_mappings:
mapping_parts = mapping.split("=")
if len(mapping_parts) != 2:
raise ValueError("The group-map must contain value pairs split by commas")

ldap_group = mapping_parts[0]
psql_group = mapping_parts[1]

if psql_group in [*ACCESS_GROUPS, PERMISSIONS_GROUP_ADMIN]:
logger.warning(f"Tried to assign LDAP users to forbidden group: {psql_group}")
continue

group_map_list.append((ldap_group, psql_group))

return group_map_list

@staticmethod
def build_postgresql_parameters(
config_options: dict, available_memory: int, limit_memory: Optional[int] = None
Expand Down Expand Up @@ -852,3 +888,34 @@ def validate_date_style(self, date_style: str) -> bool:
return True
except psycopg2.Error:
return False

def validate_group_map(self, group_map: Optional[str]) -> bool:
"""Validate the PostgreSQL authorization group-map.

Args:
group_map: serialized group-map with the following format:
<ldap_group_1>=<psql_group_1>,
<ldap_group_2>=<psql_group_2>,
...

Returns:
Whether the group-map is valid.
"""
if group_map is None:
return True

try:
group_map = self.build_postgresql_group_map(group_map)
except ValueError:
return False

for _, psql_group in group_map:
with self._connect_to_database() as connection, connection.cursor() as cursor:
query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")
query = query.format(Literal(psql_group))
cursor.execute(query)

if cursor.fetchone() is None:
return False

return True
6 changes: 3 additions & 3 deletions src/backups.py
Original file line number Diff line number Diff line change
Expand Up @@ -989,7 +989,7 @@ def _on_restore_action(self, event): # noqa: C901
# Stop the database service before performing the restore.
logger.info("Stopping database service")
try:
self.container.stop(self.charm._postgresql_service)
self.container.stop(self.charm.postgresql_service)
except ChangeError as e:
error_message = f"Failed to stop database service with error: {e!s}"
logger.error(f"Restore failed: {error_message}")
Expand Down Expand Up @@ -1047,7 +1047,7 @@ def _on_restore_action(self, event): # noqa: C901

# Start the database to start the restore process.
logger.info("Configuring Patroni to restore the backup")
self.container.start(self.charm._postgresql_service)
self.container.start(self.charm.postgresql_service)

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

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

def _retrieve_s3_parameters(self) -> tuple[dict, list[str]]:
"""Retrieve S3 parameters from the S3 integrator relation."""
Expand Down
137 changes: 108 additions & 29 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import time
from pathlib import Path
from typing import Literal, get_args
from urllib.parse import urlparse

# First platform-specific import, will fail on wrong architecture
try:
Expand All @@ -35,6 +36,7 @@
from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider
from charms.loki_k8s.v1.loki_push_api import LogProxyConsumer
from charms.postgresql_k8s.v0.postgresql import (
ACCESS_GROUP_IDENTITY,
ACCESS_GROUPS,
REQUIRED_PLUGINS,
PostgreSQL,
Expand Down Expand Up @@ -88,6 +90,7 @@
APP_SCOPE,
BACKUP_USER,
DATABASE_DEFAULT_NAME,
DATABASE_PORT,
METRICS_PORT,
MONITORING_PASSWORD_KEY,
MONITORING_USER,
Expand Down Expand Up @@ -193,10 +196,11 @@ def __init__(self, *args):
deleted_label=SECRET_DELETED_LABEL,
)

self._postgresql_service = "postgresql"
self.postgresql_service = "postgresql"
self.rotate_logs_service = "rotate-logs"
self.pgbackrest_server_service = "pgbackrest server"
self._metrics_service = "metrics_server"
self.ldap_sync_service = "ldap-sync"
self.metrics_service = "metrics_server"
self._unit = self.model.unit.name
self._name = self.model.app.name
self._namespace = self.model.name
Expand Down Expand Up @@ -586,7 +590,7 @@ def _on_peer_relation_changed(self, event: HookEvent) -> None: # noqa: C901
logger.debug("on_peer_relation_changed early exit: Unit in blocked status")
return

services = container.pebble.get_services(names=[self._postgresql_service])
services = container.pebble.get_services(names=[self.postgresql_service])
if (
(self.is_cluster_restoring_backup or self.is_cluster_restoring_to_time)
and len(services) > 0
Expand Down Expand Up @@ -1463,7 +1467,7 @@ def _on_update_status(self, _) -> None:
if not self._on_update_status_early_exit_checks(container):
return

services = container.pebble.get_services(names=[self._postgresql_service])
services = container.pebble.get_services(names=[self.postgresql_service])
if len(services) == 0:
# Service has not been added nor started yet, so don't try to check Patroni API.
logger.debug("on_update_status early exit: Service has not been added nor started yet")
Expand All @@ -1476,10 +1480,10 @@ def _on_update_status(self, _) -> None:
and services[0].current != ServiceStatus.ACTIVE
):
logger.warning(
f"{self._postgresql_service} pebble service inactive, restarting service"
f"{self.postgresql_service} pebble service inactive, restarting service"
)
try:
container.restart(self._postgresql_service)
container.restart(self.postgresql_service)
except ChangeError:
logger.exception("Failed to restart patroni")
# If service doesn't recover fast, exit and wait for next hook run to re-check
Expand Down Expand Up @@ -1576,7 +1580,7 @@ def _handle_processes_failures(self) -> bool:
# https://github.com/canonical/pebble/issues/149 is resolved.
if not self._patroni.member_started and self._patroni.is_database_running:
try:
container.restart(self._postgresql_service)
container.restart(self.postgresql_service)
logger.info("restarted Patroni because it was not running")
except ChangeError:
logger.error("failed to restart Patroni after checking that it was not running")
Expand Down Expand Up @@ -1713,6 +1717,40 @@ def _update_endpoints(
endpoints.remove(endpoint)
self._peers.data[self.app]["endpoints"] = json.dumps(endpoints)

def _generate_ldap_service(self) -> dict:
"""Generate the LDAP service definition."""
ldap_params = self.get_ldap_parameters()

ldap_url = urlparse(ldap_params["ldapurl"])
ldap_host = ldap_url.hostname
ldap_port = ldap_url.port

ldap_base_dn = ldap_params["ldapbasedn"]
ldap_bind_username = ldap_params["ldapbinddn"]
ldap_bing_password = ldap_params["ldapbindpasswd"]
ldap_group_mappings = self.postgresql.build_postgresql_group_map(self.config.ldap_map)

return {
"override": "replace",
"summary": "synchronize LDAP users",
"command": "/start-ldap-synchronizer.sh",
"startup": "enabled",
"environment": {
"LDAP_HOST": ldap_host,
"LDAP_PORT": ldap_port,
"LDAP_BASE_DN": ldap_base_dn,
"LDAP_BIND_USERNAME": ldap_bind_username,
"LDAP_BIND_PASSWORD": ldap_bing_password,
"LDAP_GROUP_IDENTITY": json.dumps(ACCESS_GROUP_IDENTITY),
"LDAP_GROUP_MAPPINGS": json.dumps(ldap_group_mappings),
"POSTGRES_HOST": "127.0.0.1",
"POSTGRES_PORT": DATABASE_PORT,
"POSTGRES_DATABASE": DATABASE_DEFAULT_NAME,
"POSTGRES_USERNAME": USER,
"POSTGRES_PASSWORD": self.get_secret(APP_SCOPE, USER_PASSWORD_KEY),
},
}

def _generate_metrics_service(self) -> dict:
"""Generate the metrics service definition."""
return {
Expand All @@ -1724,7 +1762,7 @@ def _generate_metrics_service(self) -> dict:
if self.get_secret("app", MONITORING_PASSWORD_KEY) is not None
else "disabled"
),
"after": [self._postgresql_service],
"after": [self.postgresql_service],
"user": WORKLOAD_OS_USER,
"group": WORKLOAD_OS_GROUP,
"environment": {
Expand All @@ -1743,7 +1781,7 @@ def _postgresql_layer(self) -> Layer:
"summary": "postgresql + patroni layer",
"description": "pebble config layer for postgresql + patroni",
"services": {
self._postgresql_service: {
self.postgresql_service: {
"override": "replace",
"summary": "entrypoint of the postgresql + patroni image",
"command": f"patroni {self._storage_path}/patroni.yml",
Expand Down Expand Up @@ -1773,7 +1811,13 @@ def _postgresql_layer(self) -> Layer:
"user": WORKLOAD_OS_USER,
"group": WORKLOAD_OS_GROUP,
},
self._metrics_service: self._generate_metrics_service(),
self.ldap_sync_service: {
"override": "replace",
"summary": "synchronize LDAP users",
"command": "/start-ldap-synchronizer.sh",
"startup": "disabled",
},
self.metrics_service: self._generate_metrics_service(),
self.rotate_logs_service: {
"override": "replace",
"summary": "rotate logs",
Expand All @@ -1782,7 +1826,7 @@ def _postgresql_layer(self) -> Layer:
},
},
"checks": {
self._postgresql_service: {
self.postgresql_service: {
"override": "replace",
"level": "ready",
"http": {
Expand Down Expand Up @@ -1885,14 +1929,59 @@ def _restart(self, event: RunWithLock) -> None:
# Start or stop the pgBackRest TLS server service when TLS certificate change.
self.backup.start_stop_pgbackrest_service()

def _restart_metrics_service(self) -> None:
"""Restart the monitoring service if the password was rotated."""
container = self.unit.get_container("postgresql")
current_layer = container.get_plan()

metrics_service = current_layer.services[self.metrics_service]
data_source_name = metrics_service.environment.get("DATA_SOURCE_NAME", "")

if metrics_service and not data_source_name.startswith(
f"user={MONITORING_USER} password={self.get_secret('app', MONITORING_PASSWORD_KEY)} "
):
container.add_layer(
self.metrics_service,
Layer({"services": {self.metrics_service: self._generate_metrics_service()}}),
combine=True,
)
container.restart(self.metrics_service)

def _restart_ldap_sync_service(self) -> None:
"""Restart the LDAP sync service in case any configuration changed."""
if not self._patroni.member_started:
logger.debug("Restart LDAP sync early exit: Patroni has not started yet")
return

container = self.unit.get_container("postgresql")
sync_service = container.pebble.get_services(names=[self.ldap_sync_service])

if not self.is_primary and sync_service[0].is_running():
logger.debug("Stopping LDAP sync service. It must only run in the primary")
container.stop(self.pg_ldap_sync_service)

if self.is_primary and not self.is_ldap_enabled:
logger.debug("Stopping LDAP sync service")
container.stop(self.ldap_sync_service)
return

if self.is_primary and self.is_ldap_enabled:
container.add_layer(
self.ldap_sync_service,
Layer({"services": {self.ldap_sync_service: self._generate_ldap_service()}}),
combine=True,
)
logger.debug("Starting LDAP sync service")
container.restart(self.ldap_sync_service)

@property
def _is_workload_running(self) -> bool:
"""Returns whether the workload is running (in an active state)."""
container = self.unit.get_container("postgresql")
if not container.can_connect():
return False

services = container.pebble.get_services(names=[self._postgresql_service])
services = container.pebble.get_services(names=[self.postgresql_service])
if len(services) == 0:
return False

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

self._handle_postgresql_restart_need()

# Restart the monitoring service if the password was rotated
container = self.unit.get_container("postgresql")
current_layer = container.get_plan()
if (
metrics_service := current_layer.services[self._metrics_service]
) and not metrics_service.environment.get("DATA_SOURCE_NAME", "").startswith(
f"user={MONITORING_USER} password={self.get_secret('app', MONITORING_PASSWORD_KEY)} "
):
container.add_layer(
self._metrics_service,
Layer({"services": {self._metrics_service: self._generate_metrics_service()}}),
combine=True,
)
container.restart(self._metrics_service)
self._restart_metrics_service()
self._restart_ldap_sync_service()

return True

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

if not self.postgresql.validate_group_map(self.config.ldap_map):
raise ValueError("ldap_map config option has an invalid value")

if not self.postgresql.validate_date_style(self.config.request_date_style):
raise ValueError("request_date_style config option has an invalid value")

Expand Down Expand Up @@ -2081,14 +2160,14 @@ def _update_pebble_layers(self, replan: bool = True) -> None:
# Check if there are any changes to layer services.
if current_layer.services != new_layer.services:
# Changes were made, add the new layer.
container.add_layer(self._postgresql_service, new_layer, combine=True)
container.add_layer(self.postgresql_service, new_layer, combine=True)
logging.info("Added updated layer 'postgresql' to Pebble plan")
if replan:
container.replan()
logging.info("Restarted postgresql service")
if current_layer.checks != new_layer.checks:
# Changes were made, add the new layer.
container.add_layer(self._postgresql_service, new_layer, combine=True)
container.add_layer(self.postgresql_service, new_layer, combine=True)
logging.info("Updated health checks")

def _unit_name_to_pod_name(self, unit_name: str) -> str:
Expand Down
Loading