Skip to content

Commit 24b0d01

Browse files
Handle tables ownership (#334)
Signed-off-by: Marcelo Henrique Neppel <[email protected]>
1 parent 588b7ab commit 24b0d01

File tree

10 files changed

+613
-88
lines changed

10 files changed

+613
-88
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
from typing import Dict, List, Optional, Set, Tuple
2323

2424
import psycopg2
25+
from ops.model import Relation
2526
from psycopg2 import sql
27+
from psycopg2.sql import Composed
2628

2729
# The unique Charmhub library identifier, never change it
2830
LIBID = "24ee217a54e840a598ff21a079c3e678"
@@ -32,7 +34,7 @@
3234

3335
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3436
# to 0 if you are raising the major API version
35-
LIBPATCH = 20
37+
LIBPATCH = 21
3638

3739
INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE = "invalid role(s) for extra user roles"
3840

@@ -117,13 +119,20 @@ def _connect_to_database(
117119
connection.autocommit = True
118120
return connection
119121

120-
def create_database(self, database: str, user: str, plugins: List[str] = []) -> None:
122+
def create_database(
123+
self,
124+
database: str,
125+
user: str,
126+
plugins: List[str] = [],
127+
client_relations: List[Relation] = [],
128+
) -> None:
121129
"""Creates a new database and grant privileges to a user on it.
122130
123131
Args:
124132
database: database to be created.
125133
user: user that will have access to the database.
126134
plugins: extensions to enable in the new database.
135+
client_relations: current established client relations.
127136
"""
128137
try:
129138
connection = self._connect_to_database()
@@ -142,29 +151,20 @@ def create_database(self, database: str, user: str, plugins: List[str] = []) ->
142151
sql.Identifier(database), sql.Identifier(user_to_grant_access)
143152
)
144153
)
154+
relations_accessing_this_database = 0
155+
for relation in client_relations:
156+
for data in relation.data.values():
157+
if data.get("database") == database:
158+
relations_accessing_this_database += 1
145159
with self._connect_to_database(database=database) as conn:
146160
with conn.cursor() as curs:
147-
statements = []
148161
curs.execute(
149162
"SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' and schema_name <> 'information_schema';"
150163
)
151-
for row in curs:
152-
schema = sql.Identifier(row[0])
153-
statements.append(
154-
sql.SQL(
155-
"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA {} TO {};"
156-
).format(schema, sql.Identifier(user))
157-
)
158-
statements.append(
159-
sql.SQL(
160-
"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {} TO {};"
161-
).format(schema, sql.Identifier(user))
162-
)
163-
statements.append(
164-
sql.SQL(
165-
"GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA {} TO {};"
166-
).format(schema, sql.Identifier(user))
167-
)
164+
schemas = [row[0] for row in curs.fetchall()]
165+
statements = self._generate_database_privileges_statements(
166+
relations_accessing_this_database, schemas, user
167+
)
168168
for statement in statements:
169169
curs.execute(statement)
170170
except psycopg2.Error as e:
@@ -308,6 +308,55 @@ def enable_disable_extensions(self, extensions: Dict[str, bool], database: str =
308308
if connection is not None:
309309
connection.close()
310310

311+
def _generate_database_privileges_statements(
312+
self, relations_accessing_this_database: int, schemas: List[str], user: str
313+
) -> List[Composed]:
314+
"""Generates a list of databases privileges statements."""
315+
statements = []
316+
if relations_accessing_this_database == 1:
317+
statements.append(
318+
sql.SQL(
319+
"""DO $$
320+
DECLARE r RECORD;
321+
BEGIN
322+
FOR r IN (SELECT statement FROM (SELECT 1 AS index,'ALTER TABLE '|| schemaname || '."' || tablename ||'" OWNER TO {};' AS statement
323+
FROM pg_tables WHERE NOT schemaname IN ('pg_catalog', 'information_schema')
324+
UNION SELECT 2 AS index,'ALTER SEQUENCE '|| sequence_schema || '."' || sequence_name ||'" OWNER TO {};' AS statement
325+
FROM information_schema.sequences WHERE NOT sequence_schema IN ('pg_catalog', 'information_schema')
326+
UNION SELECT 3 AS index,'ALTER FUNCTION '|| nsp.nspname || '."' || p.proname ||'"('||pg_get_function_identity_arguments(p.oid)||') OWNER TO {};' AS statement
327+
FROM pg_proc p JOIN pg_namespace nsp ON p.pronamespace = nsp.oid WHERE NOT nsp.nspname IN ('pg_catalog', 'information_schema')
328+
UNION SELECT 4 AS index,'ALTER VIEW '|| schemaname || '."' || viewname ||'" OWNER TO {};' AS statement
329+
FROM pg_catalog.pg_views WHERE NOT schemaname IN ('pg_catalog', 'information_schema')) AS statements ORDER BY index) LOOP
330+
EXECUTE format(r.statement);
331+
END LOOP;
332+
END; $$;"""
333+
).format(
334+
sql.Identifier(user),
335+
sql.Identifier(user),
336+
sql.Identifier(user),
337+
sql.Identifier(user),
338+
)
339+
)
340+
else:
341+
for schema in schemas:
342+
schema = sql.Identifier(schema)
343+
statements.append(
344+
sql.SQL("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA {} TO {};").format(
345+
schema, sql.Identifier(user)
346+
)
347+
)
348+
statements.append(
349+
sql.SQL("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {} TO {};").format(
350+
schema, sql.Identifier(user)
351+
)
352+
)
353+
statements.append(
354+
sql.SQL("GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA {} TO {};").format(
355+
schema, sql.Identifier(user)
356+
)
357+
)
358+
return statements
359+
311360
def get_postgresql_text_search_configs(self) -> Set[str]:
312361
"""Returns the PostgreSQL available text search configs.
313362

src/charm.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,15 @@ def get_available_resources(self) -> Tuple[int, int]:
15121512

15131513
return cpu_cores, allocable_memory
15141514

1515+
@property
1516+
def client_relations(self) -> List[Relation]:
1517+
"""Return the list of established client relations."""
1518+
relations = []
1519+
for relation_name in ["database", "db", "db-admin"]:
1520+
for relation in self.model.relations.get(relation_name, []):
1521+
relations.append(relation)
1522+
return relations
1523+
15151524

15161525
if __name__ == "__main__":
15171526
main(PostgresqlOperatorCharm, use_juju_for_storage=True)

src/relations/db.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ def set_up_relation(self, relation: Relation) -> bool:
150150
if self.charm.config[plugin]
151151
]
152152

153-
self.charm.postgresql.create_database(database, user, plugins=plugins)
153+
self.charm.postgresql.create_database(
154+
database, user, plugins=plugins, client_relations=self.charm.client_relations
155+
)
154156

155157
# Build the primary's connection string.
156158
primary = str(

src/relations/postgresql_provider.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
9191
if self.charm.config[plugin]
9292
]
9393

94-
self.charm.postgresql.create_database(database, user, plugins=plugins)
94+
self.charm.postgresql.create_database(
95+
database, user, plugins=plugins, client_relations=self.charm.client_relations
96+
)
9597

9698
# Share the credentials with the application.
9799
self.database_provides.set_credentials(event.relation.id, user, password)

tests/integration/new_relations/test_new_relations.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import secrets
77
import string
8+
from asyncio import gather
89
from pathlib import Path
910

1011
import psycopg2
@@ -30,6 +31,8 @@
3031
DATABASE_APP_NAME = "database"
3132
ANOTHER_DATABASE_APP_NAME = "another-database"
3233
DATA_INTEGRATOR_APP_NAME = "data-integrator"
34+
DISCOURSE_APP_NAME = "discourse-k8s"
35+
REDIS_APP_NAME = "redis-k8s"
3336
APP_NAMES = [APPLICATION_APP_NAME, DATABASE_APP_NAME, ANOTHER_DATABASE_APP_NAME]
3437
DATABASE_APP_METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
3538
FIRST_DATABASE_RELATION_NAME = "first-database"
@@ -85,7 +88,9 @@ async def test_database_relation_with_charm_libraries(ops_test: OpsTest, databas
8588
await ops_test.model.add_relation(
8689
f"{APPLICATION_APP_NAME}:{FIRST_DATABASE_RELATION_NAME}", DATABASE_APP_NAME
8790
)
88-
await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", raise_on_blocked=True)
91+
await ops_test.model.wait_for_idle(
92+
apps=[DATABASE_APP_NAME], status="active", raise_on_blocked=True
93+
)
8994

9095
# Check that on juju 3 we have secrets and no username and password in the rel databag
9196
if hasattr(ops_test.model, "list_secrets"):
@@ -571,6 +576,81 @@ async def test_invalid_extra_user_roles(ops_test: OpsTest):
571576
)
572577

573578

579+
async def test_discourse(ops_test: OpsTest):
580+
# Deploy Discourse and Redis.
581+
await gather(
582+
ops_test.model.deploy(DISCOURSE_APP_NAME, application_name=DISCOURSE_APP_NAME),
583+
ops_test.model.deploy(
584+
REDIS_APP_NAME, application_name=REDIS_APP_NAME, channel="latest/edge"
585+
),
586+
)
587+
588+
async with ops_test.fast_forward():
589+
# Enable the plugins/extensions required by Discourse.
590+
logger.info("Enabling the plugins/extensions required by Discourse")
591+
config = {"plugin_hstore_enable": "True", "plugin_pg_trgm_enable": "True"}
592+
await ops_test.model.applications[DATABASE_APP_NAME].set_config(config)
593+
await gather(
594+
ops_test.model.wait_for_idle(apps=[DISCOURSE_APP_NAME], status="waiting"),
595+
ops_test.model.wait_for_idle(
596+
apps=[DATABASE_APP_NAME, REDIS_APP_NAME], status="active"
597+
),
598+
)
599+
# Add both relations to Discourse (PostgreSQL and Redis)
600+
# and wait for it to be ready.
601+
logger.info("Adding relations")
602+
await gather(
603+
ops_test.model.add_relation(DATABASE_APP_NAME, DISCOURSE_APP_NAME),
604+
ops_test.model.add_relation(REDIS_APP_NAME, DISCOURSE_APP_NAME),
605+
)
606+
await gather(
607+
ops_test.model.wait_for_idle(apps=[DISCOURSE_APP_NAME], timeout=2000),
608+
ops_test.model.wait_for_idle(
609+
apps=[DATABASE_APP_NAME, REDIS_APP_NAME], status="active"
610+
),
611+
)
612+
logger.info("Configuring Discourse")
613+
config = {
614+
"developer_emails": "[email protected]",
615+
"external_hostname": "discourse-k8s",
616+
"smtp_address": "test.local",
617+
"smtp_domain": "test.local",
618+
"s3_install_cors_rule": "false",
619+
}
620+
await ops_test.model.applications[DISCOURSE_APP_NAME].set_config(config)
621+
await ops_test.model.wait_for_idle(apps=[DISCOURSE_APP_NAME], status="active")
622+
623+
# Deploy a new discourse application (https://github.com/canonical/data-platform-libs/issues/118
624+
# prevents from re-relating the same Discourse application; Discourse uses the old secret and fails).
625+
await ops_test.model.applications[DISCOURSE_APP_NAME].remove()
626+
other_discourse_app_name = f"other-{DISCOURSE_APP_NAME}"
627+
await ops_test.model.deploy(DISCOURSE_APP_NAME, application_name=other_discourse_app_name)
628+
629+
# Add both relations to Discourse (PostgreSQL and Redis)
630+
# and wait for it to be ready.
631+
logger.info("Adding relations")
632+
await gather(
633+
ops_test.model.add_relation(DATABASE_APP_NAME, other_discourse_app_name),
634+
ops_test.model.add_relation(REDIS_APP_NAME, other_discourse_app_name),
635+
)
636+
await gather(
637+
ops_test.model.wait_for_idle(apps=[other_discourse_app_name], timeout=2000),
638+
ops_test.model.wait_for_idle(
639+
apps=[DATABASE_APP_NAME, REDIS_APP_NAME], status="active"
640+
),
641+
)
642+
logger.info("Configuring Discourse")
643+
config = {
644+
"developer_emails": "[email protected]",
645+
"external_hostname": "discourse-k8s",
646+
"smtp_address": "test.local",
647+
"smtp_domain": "test.local",
648+
"s3_install_cors_rule": "false",
649+
}
650+
await ops_test.model.applications[other_discourse_app_name].set_config(config)
651+
await ops_test.model.wait_for_idle(apps=[other_discourse_app_name], status="active")
652+
653+
574654
async def test_indico_datatabase(ops_test: OpsTest) -> None:
575655
"""Tests deploying and relating to the Indico charm."""
576656
async with ops_test.fast_forward(fast_interval="30s"):

tests/integration/test_db.py

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import logging
55
from asyncio import gather
66

7-
import pytest
87
from pytest_operator.plugin import OpsTest
98

109
from tests.integration.helpers import (
@@ -16,15 +15,12 @@
1615
check_database_users_existence,
1716
deploy_and_relate_application_with_postgresql,
1817
get_leader_unit,
19-
get_primary,
2018
wait_for_relation_removed_between,
2119
)
2220

2321
EXTENSIONS_BLOCKING_MESSAGE = "extensions requested through relation"
2422
FINOS_WALTZ_APP_NAME = "finos-waltz"
2523
ANOTHER_FINOS_WALTZ_APP_NAME = "another-finos-waltz"
26-
DISCOURSE_APP_NAME = "discourse-k8s"
27-
REDIS_APP_NAME = "redis-k8s"
2824
APPLICATION_UNITS = 1
2925
DATABASE_UNITS = 3
3026

@@ -176,62 +172,3 @@ async def test_extensions_blocking(ops_test: OpsTest) -> None:
176172
raise_on_blocked=False,
177173
timeout=2000,
178174
)
179-
180-
181-
@pytest.mark.skip(reason="Should be ported and moved to the new relation tests")
182-
async def test_discourse(ops_test: OpsTest):
183-
database_application_name = f"extensions-{DATABASE_APP_NAME}"
184-
if database_application_name not in ops_test.model.applications:
185-
await build_and_deploy(ops_test, 1, database_application_name)
186-
187-
# Deploy Discourse and Redis.
188-
await gather(
189-
ops_test.model.deploy(DISCOURSE_APP_NAME, application_name=DISCOURSE_APP_NAME),
190-
ops_test.model.deploy(REDIS_APP_NAME, application_name=REDIS_APP_NAME),
191-
)
192-
193-
# Add both relations to Discourse (PostgreSQL and Redis)
194-
# and wait for it to be ready.
195-
relation = await ops_test.model.add_relation(
196-
f"{database_application_name}:db",
197-
DISCOURSE_APP_NAME,
198-
)
199-
await ops_test.model.add_relation(
200-
REDIS_APP_NAME,
201-
DISCOURSE_APP_NAME,
202-
)
203-
204-
# Discourse requests extensions through relation, so check that the PostgreSQL charm
205-
# becomes blocked.
206-
primary_name = await get_primary(ops_test, database_app_name=database_application_name)
207-
async with ops_test.fast_forward():
208-
await gather(
209-
ops_test.model.block_until(
210-
lambda: ops_test.model.units[primary_name].workload_status == "blocked", timeout=60
211-
),
212-
ops_test.model.wait_for_idle(
213-
apps=[REDIS_APP_NAME], status="active", timeout=2000
214-
), # Redis sometimes takes a longer time to become active.
215-
)
216-
assert (
217-
ops_test.model.units[primary_name].workload_status_message
218-
== EXTENSIONS_BLOCKING_MESSAGE
219-
)
220-
221-
# Enable the plugins/extensions required by Discourse.
222-
config = {"plugin_hstore_enable": "True", "plugin_pg_trgm_enable": "True"}
223-
await ops_test.model.applications[database_application_name].set_config(config)
224-
await ops_test.model.wait_for_idle(
225-
apps=[database_application_name, DISCOURSE_APP_NAME, REDIS_APP_NAME],
226-
status="active",
227-
timeout=2000,
228-
)
229-
230-
# Check for the correct databases and users creation.
231-
await check_database_creation(
232-
ops_test, "discourse", database_app_name=database_application_name
233-
)
234-
discourse_users = [f"relation_id_{relation.id}"]
235-
await check_database_users_existence(
236-
ops_test, discourse_users, [], database_app_name=database_application_name
237-
)

tests/unit/test_charm.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,21 @@ def test_on_stop(self, _client):
716716
self.assertEqual(_client.return_value.apply.call_count, 2)
717717
self.assertIn("failed to patch k8s MagicMock", "".join(logs.output))
718718

719+
def test_client_relations(self):
720+
# Test when the charm has no relations.
721+
self.assertEqual(self.charm.client_relations, [])
722+
723+
# Test when the charm has some relations.
724+
self.harness.add_relation("database", "application")
725+
self.harness.add_relation("db", "legacy-application")
726+
self.harness.add_relation("db-admin", "legacy-admin-application")
727+
database_relation = self.harness.model.get_relation("database")
728+
db_relation = self.harness.model.get_relation("db")
729+
db_admin_relation = self.harness.model.get_relation("db-admin")
730+
self.assertEqual(
731+
self.charm.client_relations, [database_relation, db_relation, db_admin_relation]
732+
)
733+
719734
@parameterized.expand([("app"), ("unit")])
720735
@pytest.mark.usefixtures("only_with_juju_secrets")
721736
def test_set_secret_returning_secret_label(self, scope):

0 commit comments

Comments
 (0)