Skip to content

Commit 79a372e

Browse files
authored
[DPE-7594] Sync up pg_hba changes and remove trigger (#1070)
* Port user hash * Blocking test app
1 parent 94530a1 commit 79a372e

18 files changed

+161
-148
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 3 additions & 79 deletions
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 = 53
38+
LIBPATCH = 55
3939

4040
# Groups to distinguish HBA access
4141
ACCESS_GROUP_IDENTITY = "identity_access"
@@ -267,7 +267,8 @@ def create_database(
267267
raise PostgreSQLCreateDatabaseError() from e
268268

269269
# Enable preset extensions
270-
self.enable_disable_extensions(dict.fromkeys(plugins, True), database)
270+
if plugins:
271+
self.enable_disable_extensions(dict.fromkeys(plugins, True), database)
271272

272273
def create_user(
273274
self,
@@ -782,83 +783,6 @@ def set_up_database(self) -> None:
782783
connection = None
783784
cursor = None
784785
try:
785-
with self._connect_to_database(
786-
database="template1"
787-
) as connection, connection.cursor() as cursor:
788-
# Create database function and event trigger to identify users created by PgBouncer.
789-
cursor.execute(
790-
"SELECT TRUE FROM pg_event_trigger WHERE evtname = 'update_pg_hba_on_create_schema';"
791-
)
792-
if cursor.fetchone() is None:
793-
cursor.execute("""
794-
CREATE OR REPLACE FUNCTION update_pg_hba()
795-
RETURNS event_trigger
796-
LANGUAGE plpgsql
797-
AS $$
798-
DECLARE
799-
hba_file TEXT;
800-
copy_command TEXT;
801-
connection_type TEXT;
802-
rec record;
803-
insert_value TEXT;
804-
changes INTEGER = 0;
805-
BEGIN
806-
-- Don't execute on replicas.
807-
IF NOT pg_is_in_recovery() THEN
808-
-- Load the current authorisation rules.
809-
DROP TABLE IF EXISTS pg_hba;
810-
CREATE TEMPORARY TABLE pg_hba (lines TEXT);
811-
SELECT setting INTO hba_file FROM pg_settings WHERE name = 'hba_file';
812-
IF hba_file IS NOT NULL THEN
813-
copy_command='COPY pg_hba FROM ''' || hba_file || '''' ;
814-
EXECUTE copy_command;
815-
-- Build a list of the relation users and the databases they can access.
816-
DROP TABLE IF EXISTS relation_users;
817-
CREATE TEMPORARY TABLE relation_users AS
818-
SELECT t.user, STRING_AGG(DISTINCT t.database, ',') AS databases FROM( SELECT u.usename AS user, CASE WHEN u.usesuper THEN 'all' ELSE d.datname END AS database FROM ( SELECT usename, usesuper FROM pg_catalog.pg_user WHERE usename NOT IN ('backup', 'monitoring', 'operator', 'postgres', 'replication', 'rewind')) AS u JOIN ( SELECT datname FROM pg_catalog.pg_database WHERE NOT datistemplate ) AS d ON has_database_privilege(u.usename, d.datname, 'CONNECT') ) AS t GROUP BY 1;
819-
IF (SELECT COUNT(lines) FROM pg_hba WHERE lines LIKE 'hostssl %') > 0 THEN
820-
connection_type := 'hostssl';
821-
ELSE
822-
connection_type := 'host';
823-
END IF;
824-
-- Add the new users to the pg_hba file.
825-
FOR rec IN SELECT * FROM relation_users
826-
LOOP
827-
insert_value := connection_type || ' ' || rec.databases || ' ' || rec.user || ' 0.0.0.0/0 md5';
828-
IF (SELECT COUNT(lines) FROM pg_hba WHERE lines = insert_value) = 0 THEN
829-
INSERT INTO pg_hba (lines) VALUES (insert_value);
830-
changes := changes + 1;
831-
END IF;
832-
END LOOP;
833-
-- Remove users that don't exist anymore from the pg_hba file.
834-
FOR rec IN SELECT h.lines FROM pg_hba AS h LEFT JOIN relation_users AS r ON SPLIT_PART(h.lines, ' ', 3) = r.user WHERE r.user IS NULL AND (SPLIT_PART(h.lines, ' ', 3) LIKE 'relation_id_%' OR SPLIT_PART(h.lines, ' ', 3) LIKE 'pgbouncer_auth_relation_%' OR SPLIT_PART(h.lines, ' ', 3) LIKE '%_user_%_%')
835-
LOOP
836-
DELETE FROM pg_hba WHERE lines = rec.lines;
837-
changes := changes + 1;
838-
END LOOP;
839-
-- Apply the changes to the pg_hba file.
840-
IF changes > 0 THEN
841-
copy_command='COPY pg_hba TO ''' || hba_file || '''' ;
842-
EXECUTE copy_command;
843-
PERFORM pg_reload_conf();
844-
END IF;
845-
END IF;
846-
END IF;
847-
END;
848-
$$;
849-
""")
850-
cursor.execute("""
851-
CREATE EVENT TRIGGER update_pg_hba_on_create_schema
852-
ON ddl_command_end
853-
WHEN TAG IN ('CREATE SCHEMA')
854-
EXECUTE FUNCTION update_pg_hba();
855-
""")
856-
cursor.execute("""
857-
CREATE EVENT TRIGGER update_pg_hba_on_drop_schema
858-
ON ddl_command_end
859-
WHEN TAG IN ('DROP SCHEMA')
860-
EXECUTE FUNCTION update_pg_hba();
861-
""")
862786
with self._connect_to_database() as connection, connection.cursor() as cursor:
863787
cursor.execute("SELECT TRUE FROM pg_roles WHERE rolname='admin';")
864788
if cursor.fetchone() is None:

scripts/cluster_topology_observer.py

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@
1111
from urllib.parse import urljoin
1212
from urllib.request import urlopen
1313

14+
import psycopg2
15+
import yaml
16+
1417
API_REQUEST_TIMEOUT = 5
1518
PATRONI_CLUSTER_STATUS_ENDPOINT = "cluster"
19+
SNAP_CURRENT_PATH = "/var/snap/charmed-postgresql/current"
20+
SNAP_CONF_PATH = f"{SNAP_CURRENT_PATH}/etc"
21+
PATRONI_CONF_PATH = f"{SNAP_CONF_PATH}/patroni"
22+
PATRONI_CONF_FILE_PATH = f"{PATRONI_CONF_PATH}/patroni.yaml"
1623

1724
# File path for the spawned cluster topology observer process to write logs.
1825
LOG_FILE_PATH = "/var/log/cluster_topology_observer.log"
@@ -22,28 +29,6 @@ class UnreachableUnitsError(Exception):
2229
"""Cannot reach any known cluster member."""
2330

2431

25-
def check_for_authorisation_rules_changes(run_cmd, unit, charm_dir, previous_authorisation_rules):
26-
"""Check for changes in the authorisation rules.
27-
28-
If changes are detected, dispatch an event to handle them.
29-
"""
30-
# Read contents from the pg_hba.conf file.
31-
with open("/var/snap/charmed-postgresql/common/var/lib/postgresql/pg_hba.conf") as file:
32-
current_authorisation_rules = file.read()
33-
current_authorisation_rules = [
34-
line for line in current_authorisation_rules.splitlines() if not line.startswith("#")
35-
]
36-
# If it's the first time the authorisation rules were retrieved, then store it and use
37-
# it for subsequent checks.
38-
if not previous_authorisation_rules:
39-
previous_authorisation_rules = current_authorisation_rules
40-
# If the authorisation rules changed, dispatch a charm event to handle this change.
41-
elif current_authorisation_rules != previous_authorisation_rules:
42-
previous_authorisation_rules = current_authorisation_rules
43-
dispatch(run_cmd, unit, charm_dir, "authorisation_rules_change")
44-
return previous_authorisation_rules
45-
46-
4732
def check_for_cluster_topology_changes(
4833
run_cmd, unit, charm_dir, previous_cluster_topology, current_cluster_topology
4934
):
@@ -62,6 +47,52 @@ def check_for_cluster_topology_changes(
6247
return previous_cluster_topology
6348

6449

50+
def check_for_database_changes(run_cmd, unit, charm_dir, previous_databases):
51+
"""Check for changes in the databases.
52+
53+
If changes are detected, dispatch an event to handle them.
54+
"""
55+
with open(PATRONI_CONF_FILE_PATH) as conf_file:
56+
conf_file_contents = yaml.safe_load(conf_file)
57+
password = conf_file_contents["postgresql"]["authentication"]["superuser"]["password"]
58+
listen_addr = conf_file_contents["postgresql"]["listen"]
59+
if not listen_addr or ":" not in listen_addr:
60+
with open(LOG_FILE_PATH, "a") as log_file:
61+
log_file.write("Failed to retrieve databases: Host not set yet.\n")
62+
return previous_databases
63+
host, _ = listen_addr.split(":")
64+
65+
connection = None
66+
try:
67+
# Input is generated by the charm
68+
with (
69+
psycopg2.connect(
70+
f"dbname='postgres' user='operator' host='{host}' "
71+
f"password='{password}' connect_timeout=1"
72+
) as connection,
73+
connection.cursor() as cursor,
74+
):
75+
cursor.execute("SELECT datname, datacl FROM pg_database;")
76+
current_databases = cursor.fetchall()
77+
except psycopg2.Error as e:
78+
with open(LOG_FILE_PATH, "a") as log_file:
79+
log_file.write(f"Failed to retrieve databases: {e}\n")
80+
return previous_databases
81+
else:
82+
# If it's the first time the databases were retrieved, then store it and use
83+
# it for subsequent checks.
84+
if not previous_databases:
85+
previous_databases = current_databases
86+
# If the databases changed, dispatch a charm event to handle this change.
87+
elif current_databases != previous_databases:
88+
previous_databases = current_databases
89+
dispatch(run_cmd, unit, charm_dir, "databases_change")
90+
return previous_databases
91+
finally:
92+
if connection:
93+
connection.close()
94+
95+
6596
def dispatch(run_cmd, unit, charm_dir, custom_event):
6697
"""Use the input juju-run command to dispatch a custom event."""
6798
dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/{} {}/dispatch"
@@ -77,7 +108,7 @@ def main():
77108
patroni_urls, run_cmd, unit, charm_dir = sys.argv[1:]
78109

79110
previous_cluster_topology = {}
80-
previous_authorisation_rules = []
111+
previous_databases = None
81112
urls = [urljoin(url, PATRONI_CLUSTER_STATUS_ENDPOINT) for url in patroni_urls.split(",")]
82113
member_name = unit.replace("/", "-")
83114
while True:
@@ -122,8 +153,8 @@ def main():
122153
)
123154

124155
if is_primary:
125-
previous_authorisation_rules = check_for_authorisation_rules_changes(
126-
run_cmd, unit, charm_dir, previous_authorisation_rules
156+
previous_databases = check_for_database_changes(
157+
run_cmd, unit, charm_dir, previous_databases
127158
)
128159

129160
# Wait some time before checking again for a cluster topology change.

src/charm.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import sys
1515
import time
1616
from datetime import datetime
17+
from hashlib import shake_128
1718
from pathlib import Path
1819
from typing import Literal, get_args
1920
from urllib.parse import urlparse
@@ -185,10 +186,8 @@ def __init__(self, *args):
185186
)
186187
self._observer = ClusterTopologyObserver(self, run_cmd)
187188
self._rotate_logs = RotateLogs(self)
188-
self.framework.observe(
189-
self.on.authorisation_rules_change, self._on_authorisation_rules_change
190-
)
191189
self.framework.observe(self.on.cluster_topology_change, self._on_cluster_topology_change)
190+
self.framework.observe(self.on.databases_change, self._on_databases_change)
192191
self.framework.observe(self.on.install, self._on_install)
193192
self.framework.observe(self.on.leader_elected, self._on_leader_elected)
194193
self.framework.observe(self.on.config_changed, self._on_config_changed)
@@ -239,8 +238,10 @@ def __init__(self, *args):
239238
)
240239
self._tracing_endpoint_config, _ = charm_tracing_config(self._grafana_agent, None)
241240

242-
def _on_authorisation_rules_change(self, _):
243-
"""Handle authorisation rules change event."""
241+
def _on_databases_change(self, _):
242+
"""Handle databases change event."""
243+
self.update_config()
244+
logger.debug("databases changed")
244245
timestamp = datetime.now()
245246
self._peers.data[self.unit].update({"pg_hba_needs_update_timestamp": str(timestamp)})
246247
logger.debug(f"authorisation rules changed at {timestamp}")
@@ -2072,6 +2073,9 @@ def update_config(self, is_creating_backup: bool = False, no_peers: bool = False
20722073
self._restart_metrics_service(postgres_snap)
20732074
self._restart_ldap_sync_service(postgres_snap)
20742075

2076+
self.unit_peer_data.update({"user_hash": self.generate_user_hash})
2077+
if self.unit.is_leader():
2078+
self.app_peer_data.update({"user_hash": self.generate_user_hash})
20752079
return True
20762080

20772081
def _validate_config_options(self) -> None:
@@ -2190,11 +2194,34 @@ def relations_user_databases_map(self) -> dict:
21902194
REPLICATION_USER: "all",
21912195
REWIND_USER: "all",
21922196
})
2197+
2198+
# Copy relations users directly instead of waiting for them to be created
2199+
for relation in self.model.relations[self.postgresql_client_relation.relation_name]:
2200+
user = f"relation-{relation.id}"
2201+
if user not in user_database_map and (
2202+
database
2203+
:= self.postgresql_client_relation.database_provides.fetch_relation_field(
2204+
relation.id, "database"
2205+
)
2206+
):
2207+
user_database_map[user] = database
21932208
return user_database_map
21942209
except PostgreSQLListUsersError:
21952210
logger.debug("relations_user_databases_map: Unable to get users")
21962211
return {USER: "all", REPLICATION_USER: "all", REWIND_USER: "all"}
21972212

2213+
@property
2214+
def generate_user_hash(self) -> str:
2215+
"""Generate expected user and database hash."""
2216+
user_db_pairs = {}
2217+
for relation in self.model.relations[self.postgresql_client_relation.relation_name]:
2218+
if database := self.postgresql_client_relation.database_provides.fetch_relation_field(
2219+
relation.id, "database"
2220+
):
2221+
user = f"relation_id_{relation.id}"
2222+
user_db_pairs[user] = database
2223+
return shake_128(str(user_db_pairs).encode()).hexdigest(16)
2224+
21982225
def override_patroni_restart_condition(
21992226
self, new_condition: str, repeat_cause: str | None
22002227
) -> bool:

src/cluster_topology_observer.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import os
88
import signal
99
import subprocess
10+
from pathlib import Path
11+
from sys import version_info
1012

1113
from ops.charm import CharmBase, CharmEvents
1214
from ops.framework import EventBase, EventSource, Object
@@ -22,8 +24,8 @@ class ClusterTopologyChangeEvent(EventBase):
2224
"""A custom event for cluster topology changes."""
2325

2426

25-
class AuthorisationRulesChangeEvent(EventBase):
26-
"""A custom event for authorisation rules changes."""
27+
class DatabasesChangeEvent(EventBase):
28+
"""A custom event for databases changes."""
2729

2830

2931
class ClusterTopologyChangeCharmEvents(CharmEvents):
@@ -32,8 +34,8 @@ class ClusterTopologyChangeCharmEvents(CharmEvents):
3234
Includes :class:`ClusterTopologyChangeEvent` in those that can be handled.
3335
"""
3436

35-
authorisation_rules_change = EventSource(AuthorisationRulesChangeEvent)
3637
cluster_topology_change = EventSource(ClusterTopologyChangeEvent)
38+
databases_change = EventSource(DatabasesChangeEvent)
3739

3840

3941
class ClusterTopologyObserver(Object):
@@ -74,6 +76,20 @@ def start_observer(self):
7476
new_env = os.environ.copy()
7577
if "JUJU_CONTEXT_ID" in new_env:
7678
new_env.pop("JUJU_CONTEXT_ID")
79+
# Generate the venv path based on the existing lib path
80+
for loc in new_env["PYTHONPATH"].split(":"):
81+
path = Path(loc)
82+
venv_path = (
83+
path
84+
/ ".."
85+
/ "venv"
86+
/ "lib"
87+
/ f"python{version_info.major}.{version_info.minor}"
88+
/ "site-packages"
89+
)
90+
if path.stem == "lib":
91+
new_env["PYTHONPATH"] = f"{venv_path.resolve()}:{new_env['PYTHONPATH']}"
92+
break
7793

7894
urls = [self._charm._patroni._patroni_url] + [
7995
self._charm._patroni._patroni_url.replace(self._charm._patroni.unit_ip, peer)

0 commit comments

Comments
 (0)