Skip to content

Commit e8aa568

Browse files
authored
[DPE-7594] Sync up pg_hba changes and remove trigger (#1007)
* More aggressive idle checks * Explicit idle * Idle period when relating to the test app * Remove second start * Remove log warning * Hold create db hook for longer * Bump the pg_hba checker timeout * Don't update config * Bump timeout * Try to just append to pg_hba * Sync hba changes before creating db resources * Force regenerate hash and config on leader * Use current host to check hba * Update libs * Compare to local hash * Cla check for 16/edge * Don't defer peer change before init * Add back app check * Revert back to just updating peer data * Only sync hba once initially set * Bump timeout * Don't filter appends to pg_hba * Append the rel users directly to the user map * Add idle timeout * Remove trigger * Sleep longer * Set extra user roles * Always update hash * Bump sleep period * Revert the trigger * Move generate_user_hash to charm * Conditional hash update * Try to sort keys * Revert to relation user hash * Try to reduce the amount of ifs * Remove trigger * Blocked test app
1 parent ac55896 commit e8aa568

19 files changed

+247
-198
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 1 addition & 78 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 = 54
3939

4040
# Groups to distinguish HBA access
4141
ACCESS_GROUP_IDENTITY = "identity_access"
@@ -782,83 +782,6 @@ def set_up_database(self) -> None:
782782
connection = None
783783
cursor = None
784784
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-
""")
862785
with self._connect_to_database() as connection, connection.cursor() as cursor:
863786
cursor.execute("SELECT TRUE FROM pg_roles WHERE rolname='admin';")
864787
if cursor.fetchone() is None:

scripts/authorisation_rules_observer.py

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,66 @@
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"
1619
PATRONI_CONFIG_STATUS_ENDPOINT = "config"
20+
PATRONI_CONF_FILE_PATH = "/var/lib/postgresql/data/patroni.yml"
21+
22+
# File path for the spawned cluster topology observer process to write logs.
23+
LOG_FILE_PATH = "/var/log/authorisation_rules_observer.log"
1724

1825

1926
class UnreachableUnitsError(Exception):
2027
"""Cannot reach any known cluster member."""
2128

2229

23-
def dispatch(run_cmd, unit, charm_dir):
24-
"""Use the input juju-run command to dispatch a :class:`AuthorisationRulesChangeEvent`."""
25-
dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/authorisation_rules_change {}/dispatch"
30+
def dispatch(run_cmd, unit, charm_dir, custom_event):
31+
"""Use the input juju-run command to dispatch a custom event."""
32+
dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/{} {}/dispatch"
2633
# Input is generated by the charm
27-
subprocess.run([run_cmd, "-u", unit, dispatch_sub_cmd.format(charm_dir)]) # noqa: S603
34+
subprocess.run([run_cmd, "-u", unit, dispatch_sub_cmd.format(custom_event, charm_dir)]) # noqa: S603
35+
36+
37+
def check_for_database_changes(run_cmd, unit, charm_dir, previous_databases):
38+
"""Check for changes in the databases.
39+
40+
If changes are detected, dispatch an event to handle them.
41+
"""
42+
with open(PATRONI_CONF_FILE_PATH) as conf_file:
43+
conf_file_contents = yaml.safe_load(conf_file)
44+
password = conf_file_contents["postgresql"]["authentication"]["superuser"]["password"]
45+
connection = None
46+
try:
47+
# Input is generated by the charm
48+
with (
49+
psycopg2.connect(
50+
"dbname='postgres' user='operator' host='localhost' "
51+
f"password='{password}' connect_timeout=1"
52+
) as connection,
53+
connection.cursor() as cursor,
54+
):
55+
cursor.execute("SELECT datname, datacl FROM pg_database;")
56+
current_databases = cursor.fetchall()
57+
except psycopg2.Error as e:
58+
with open(LOG_FILE_PATH, "a") as log_file:
59+
log_file.write(f"Failed to retrieve databases: {e}\n")
60+
return previous_databases
61+
else:
62+
# If it's the first time the databases were retrieved, then store it and use
63+
# it for subsequent checks.
64+
if not previous_databases:
65+
previous_databases = current_databases
66+
# If the databases changed, dispatch a charm event to handle this change.
67+
elif current_databases != previous_databases:
68+
previous_databases = current_databases
69+
dispatch(run_cmd, unit, charm_dir, "databases_change")
70+
return previous_databases
71+
finally:
72+
if connection:
73+
connection.close()
2874

2975

3076
def main():
@@ -34,7 +80,7 @@ def main():
3480
"""
3581
patroni_urls, run_cmd, unit, charm_dir = sys.argv[1:]
3682

37-
previous_authorisation_rules = []
83+
previous_databases = None
3884
urls = [urljoin(url, PATRONI_CLUSTER_STATUS_ENDPOINT) for url in patroni_urls.split(",")]
3985
member_name = unit.replace("/", "-")
4086
while True:
@@ -67,22 +113,9 @@ def main():
67113
break
68114

69115
if is_primary:
70-
# Read contents from the pg_hba.conf file.
71-
with open("/var/lib/postgresql/data/pgdata/pg_hba.conf") as file:
72-
current_authorisation_rules = file.read()
73-
current_authorisation_rules = [
74-
line
75-
for line in current_authorisation_rules.splitlines()
76-
if not line.startswith("#")
77-
]
78-
# If it's the first time the authorisation rules were retrieved, then store it and use
79-
# it for subsequent checks.
80-
if not previous_authorisation_rules:
81-
previous_authorisation_rules = current_authorisation_rules
82-
# If the authorisation rules changed, dispatch a charm event to handle this change.
83-
elif current_authorisation_rules != previous_authorisation_rules:
84-
previous_authorisation_rules = current_authorisation_rules
85-
dispatch(run_cmd, unit, charm_dir)
116+
previous_databases = check_for_database_changes(
117+
run_cmd, unit, charm_dir, previous_databases
118+
)
86119

87120
# Wait some time before checking again for a authorisation rules change.
88121
sleep(30)

src/authorisation_rules_observer.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import signal
99
import subprocess
1010
import typing
11+
from pathlib import Path
12+
from sys import version_info
1113

1214
from ops.charm import CharmEvents
1315
from ops.framework import EventBase, EventSource, Object
@@ -22,17 +24,17 @@
2224
LOG_FILE_PATH = "/var/log/authorisation_rules_observer.log"
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 AuthorisationRulesChangeCharmEvents(CharmEvents):
3032
"""A CharmEvents extension for authorisation rules changes.
3133
32-
Includes :class:`AuthorisationRulesChangeEvent` in those that can be handled.
34+
Includes :class:`DatabasesChangeEventt` in those that can be handled.
3335
"""
3436

35-
authorisation_rules_change = EventSource(AuthorisationRulesChangeEvent)
37+
databases_change = EventSource(DatabasesChangeEvent)
3638

3739

3840
class AuthorisationRulesObserver(Object):
@@ -74,6 +76,20 @@ def start_authorisation_rules_observer(self):
7476
# in a hook context, as Juju will disallow use of juju-run.
7577
new_env = os.environ.copy()
7678
new_env.pop("JUJU_CONTEXT_ID", None)
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 = [
7995
self._charm._patroni._patroni_url.replace(self._charm.endpoint, endpoint)

src/charm.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
"""Charmed Kubernetes Operator for the PostgreSQL database."""
66

7-
import datetime
87
import itertools
98
import json
109
import logging
@@ -13,6 +12,8 @@
1312
import shutil
1413
import sys
1514
import time
15+
from datetime import datetime
16+
from hashlib import shake_128
1617
from pathlib import Path
1718
from typing import Literal, get_args
1819
from urllib.parse import urlparse
@@ -221,9 +222,7 @@ def __init__(self, *args):
221222
"/usr/bin/juju-exec" if self.model.juju_version.major > 2 else "/usr/bin/juju-run"
222223
)
223224
self._observer = AuthorisationRulesObserver(self, run_cmd)
224-
self.framework.observe(
225-
self.on.authorisation_rules_change, self._on_authorisation_rules_change
226-
)
225+
self.framework.observe(self.on.databases_change, self._on_databases_change)
227226
self.framework.observe(self.on.config_changed, self._on_config_changed)
228227
self.framework.observe(self.on.leader_elected, self._on_leader_elected)
229228
self.framework.observe(self.on[PEER].relation_changed, self._on_peer_relation_changed)
@@ -281,9 +280,11 @@ def __init__(self, *args):
281280
self, relation_name=TRACING_RELATION_NAME, protocols=[TRACING_PROTOCOL]
282281
)
283282

284-
def _on_authorisation_rules_change(self, _):
285-
"""Handle authorisation rules change event."""
286-
timestamp = datetime.datetime.now()
283+
def _on_databases_change(self, _):
284+
"""Handle databases change event."""
285+
self.update_config()
286+
logger.debug("databases changed")
287+
timestamp = datetime.now()
287288
self._peers.data[self.unit].update({"pg_hba_needs_update_timestamp": str(timestamp)})
288289
logger.debug(f"authorisation rules changed at {timestamp}")
289290

@@ -580,14 +581,14 @@ def _on_peer_relation_changed(self, event: HookEvent) -> None: # noqa: C901
580581
if self.unit.is_leader():
581582
if self._initialize_cluster(event):
582583
logger.debug("Deferring on_peer_relation_changed: Leader initialized cluster")
584+
event.defer()
583585
else:
584586
logger.debug("_initialized_cluster failed on _peer_relation_changed")
585587
return
586588
else:
587589
logger.debug(
588-
"Deferring on_peer_relation_changed: Cluster must be initialized before members can join"
590+
"Early exit on_peer_relation_changed: Cluster must be initialized before members can join"
589591
)
590-
event.defer()
591592
return
592593

593594
# If the leader is the one receiving the event, it adds the new members,
@@ -2119,6 +2120,9 @@ def update_config(self, is_creating_backup: bool = False) -> bool:
21192120
self._restart_metrics_service()
21202121
self._restart_ldap_sync_service()
21212122

2123+
self.unit_peer_data.update({"user_hash": self.generate_user_hash})
2124+
if self.unit.is_leader():
2125+
self.app_peer_data.update({"user_hash": self.generate_user_hash})
21222126
return True
21232127

21242128
def _validate_config_options(self) -> None:
@@ -2316,8 +2320,30 @@ def relations_user_databases_map(self) -> dict:
23162320
user, current_host=self.is_connectivity_enabled
23172321
)
23182322
)
2323+
2324+
# Copy relations users directly instead of waiting for them to be created
2325+
for relation in self.model.relations[self.postgresql_client_relation.relation_name]:
2326+
user = f"relation_id_{relation.id}"
2327+
if user not in user_database_map and (
2328+
database := self.postgresql_client_relation.database_provides.fetch_relation_field(
2329+
relation.id, "database"
2330+
)
2331+
):
2332+
user_database_map[user] = database
23192333
return user_database_map
23202334

2335+
@property
2336+
def generate_user_hash(self) -> str:
2337+
"""Generate expected user and database hash."""
2338+
user_db_pairs = {}
2339+
for relation in self.model.relations[self.postgresql_client_relation.relation_name]:
2340+
if database := self.postgresql_client_relation.database_provides.fetch_relation_field(
2341+
relation.id, "database"
2342+
):
2343+
user = f"relation_id_{relation.id}"
2344+
user_db_pairs[user] = database
2345+
return shake_128(str(user_db_pairs).encode()).hexdigest(16)
2346+
23212347
def override_patroni_on_failure_condition(
23222348
self, new_condition: str, repeat_cause: str | None
23232349
) -> bool:

0 commit comments

Comments
 (0)