Skip to content

Commit 38db52c

Browse files
[DPE-1372] Add extensions/plugins support (#170)
* Add plugins support * Improve db relation test * Implement more robust tests with Indico * Fix list of requested extensions * Remove unstable mark * Use custom application name in test * Update based on PR feedback * Improve code for CI * Add helper to wait for relation removal * Fix change of the order of the requested extensions
1 parent 932bdd4 commit 38db52c

File tree

9 files changed

+407
-48
lines changed

9 files changed

+407
-48
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ jobs:
7171
matrix:
7272
tox-environments:
7373
- password-rotation-integration
74+
- plugins-integration
7475
- tls-integration
7576
agents-version: ["2.9.42"] # renovate: latest
7677
include:

config.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright 2023 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
options:
5+
plugin_citext_enable:
6+
default: false
7+
type: boolean
8+
description: Enable citext extension
9+
plugin_debversion_enable:
10+
default: false
11+
type: boolean
12+
description: Enable debversion extension
13+
plugin_hstore_enable:
14+
default: false
15+
type: boolean
16+
description: Enable hstore extension
17+
plugin_pg_trgm_enable:
18+
default: false
19+
type: boolean
20+
description: Enable pg_trgm extension
21+
plugin_plpython3u_enable:
22+
default: false
23+
type: boolean
24+
description: Enable PL/Python extension
25+
plugin_unaccent_enable:
26+
default: false
27+
type: boolean
28+
description: Enable unaccent extension

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3434
# to 0 if you are raising the major API version
35-
LIBPATCH = 8
35+
LIBPATCH = 9
3636

3737

3838
logger = logging.getLogger(__name__)
@@ -50,6 +50,10 @@ class PostgreSQLDeleteUserError(Exception):
5050
"""Exception raised when deleting a user fails."""
5151

5252

53+
class PostgreSQLEnableDisableExtensionError(Exception):
54+
"""Exception raised when enabling/disabling an extension fails."""
55+
56+
5357
class PostgreSQLGetPostgreSQLVersionError(Exception):
5458
"""Exception raised when retrieving PostgreSQL version fails."""
5559

@@ -237,6 +241,46 @@ def delete_user(self, user: str) -> None:
237241
logger.error(f"Failed to delete user: {e}")
238242
raise PostgreSQLDeleteUserError()
239243

244+
def enable_disable_extension(self, extension: str, enable: bool, database: str = None) -> None:
245+
"""Enables or disables a PostgreSQL extension.
246+
247+
Args:
248+
extension: the name of the extensions.
249+
enable: whether the extension should be enabled or disabled.
250+
database: optional database where to enable/disable the extension.
251+
252+
Raises:
253+
PostgreSQLEnableDisableExtensionError if the operation fails.
254+
"""
255+
statement = (
256+
f"CREATE EXTENSION IF NOT EXISTS {extension};"
257+
if enable
258+
else f"DROP EXTENSION IF EXISTS {extension};"
259+
)
260+
connection = None
261+
try:
262+
if database is not None:
263+
databases = [database]
264+
else:
265+
# Retrieve all the databases.
266+
with self._connect_to_database() as connection, connection.cursor() as cursor:
267+
cursor.execute("SELECT datname FROM pg_database WHERE NOT datistemplate;")
268+
databases = {database[0] for database in cursor.fetchall()}
269+
270+
# Enable/disabled the extension in each database.
271+
for database in databases:
272+
with self._connect_to_database(
273+
database=database
274+
) as connection, connection.cursor() as cursor:
275+
cursor.execute(statement)
276+
except psycopg2.errors.UniqueViolation:
277+
pass
278+
except psycopg2.Error:
279+
raise PostgreSQLEnableDisableExtensionError()
280+
finally:
281+
if connection is not None:
282+
connection.close()
283+
240284
def get_postgresql_version(self) -> str:
241285
"""Returns the PostgreSQL version.
242286

src/charm.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
1313
from charms.postgresql_k8s.v0.postgresql import (
1414
PostgreSQL,
15+
PostgreSQLEnableDisableExtensionError,
1516
PostgreSQLUpdateUserPasswordError,
1617
)
1718
from charms.postgresql_k8s.v0.postgresql_tls import PostgreSQLTLS
@@ -63,7 +64,7 @@
6364
WORKLOAD_OS_USER,
6465
)
6566
from patroni import NotReadyError, Patroni
66-
from relations.db import DbProvides
67+
from relations.db import EXTENSIONS_BLOCKING_MESSAGE, DbProvides
6768
from relations.postgresql_provider import PostgreSQLProvider
6869
from utils import new_password
6970

@@ -165,6 +166,11 @@ def set_secret(self, scope: str, key: str, value: Optional[str]) -> None:
165166
else:
166167
raise RuntimeError("Unknown secret scope.")
167168

169+
@property
170+
def is_cluster_initialised(self) -> bool:
171+
"""Returns whether the cluster is already initialised."""
172+
return "cluster_initialised" in self.app_peer_data
173+
168174
@property
169175
def postgresql(self) -> PostgreSQL:
170176
"""Returns an instance of the object used to interact with the database."""
@@ -286,9 +292,51 @@ def _on_peer_relation_changed(self, event: RelationChangedEvent) -> None:
286292
self.unit.status = ActiveStatus()
287293

288294
def _on_config_changed(self, _) -> None:
289-
"""Handle the config-changed event."""
290-
# TODO: placeholder method to implement logic specific to configuration change.
291-
pass
295+
"""Handle configuration changes, like enabling plugins."""
296+
if not self.is_cluster_initialised:
297+
logger.debug("Early exit on_config_changed: cluster not initialised yet")
298+
return
299+
300+
if not self.unit.is_leader():
301+
return
302+
303+
# Enable and/or disable the extensions.
304+
self.enable_disable_extensions()
305+
306+
# Unblock the charm after extensions are enabled (only if it's blocked due to application
307+
# charms requesting extensions).
308+
if self.unit.status.message != EXTENSIONS_BLOCKING_MESSAGE:
309+
return
310+
311+
for relation in [
312+
*self.model.relations.get("db", []),
313+
*self.model.relations.get("db-admin", []),
314+
]:
315+
if not self.legacy_db_relation.set_up_relation(relation):
316+
logger.debug(
317+
"Early exit on_config_changed: legacy relation requested extensions that are still disabled"
318+
)
319+
return
320+
321+
def enable_disable_extensions(self, database: str = None) -> None:
322+
"""Enable/disable PostgreSQL extensions set through config options.
323+
324+
Args:
325+
database: optional database where to enable/disable the extension.
326+
"""
327+
for config, enable in self.model.config.items():
328+
# Filter config option not related to plugins.
329+
if not config.startswith("plugin_"):
330+
continue
331+
332+
# Enable or disable the plugin/extension.
333+
extension = "_".join(config.split("_")[1:-1])
334+
try:
335+
self.postgresql.enable_disable_extension(extension, enable, database)
336+
except PostgreSQLEnableDisableExtensionError as e:
337+
logger.exception(
338+
f"failed to {'enable' if enable else 'disable'} {extension} plugin: %s", str(e)
339+
)
292340

293341
def _add_members(self, event) -> None:
294342
"""Add new cluster members.
@@ -463,6 +511,10 @@ def _on_postgresql_pebble_ready(self, event: WorkloadEvent) -> None:
463511
# Update the archive command and replication configurations.
464512
self.update_config()
465513

514+
# Enable/disable PostgreSQL extensions if they were set before the cluster
515+
# was fully initialised.
516+
self.enable_disable_extensions()
517+
466518
# All is well, set an ActiveStatus.
467519
self.unit.status = ActiveStatus()
468520

src/relations/db.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
import logging
8-
from typing import Iterable
8+
from typing import Iterable, List, Set, Tuple
99

1010
from charms.postgresql_k8s.v0.postgresql import (
1111
PostgreSQLCreateDatabaseError,
@@ -89,34 +89,59 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
8989

9090
logger.warning(f"DEPRECATION WARNING - `{self.relation_name}` is a legacy interface")
9191

92-
unit_relation_databag = event.relation.data[self.charm.unit]
93-
application_relation_databag = event.relation.data[self.charm.app]
92+
self.set_up_relation(event.relation)
9493

95-
# Do not allow apps requesting extensions to be installed.
96-
if "extensions" in event.relation.data.get(
97-
event.app, {}
98-
) or "extensions" in event.relation.data.get(event.unit, {}):
94+
def _get_extensions(self, relation: Relation) -> Tuple[List, Set]:
95+
"""Returns the list of required and disabled extensions."""
96+
requested_extensions = relation.data.get(relation.app, {}).get("extensions", "").split(",")
97+
for unit in relation.units:
98+
requested_extensions.extend(
99+
relation.data.get(unit, {}).get("extensions", "").split(",")
100+
)
101+
required_extensions = []
102+
for extension in requested_extensions:
103+
if extension != "" and extension not in required_extensions:
104+
required_extensions.append(extension)
105+
disabled_extensions = set()
106+
if required_extensions:
107+
for extension in required_extensions:
108+
extension_name = extension.split(":")[0]
109+
if not self.charm.model.config.get(f"plugin_{extension_name}_enable"):
110+
disabled_extensions.add(extension_name)
111+
return required_extensions, disabled_extensions
112+
113+
def set_up_relation(self, relation: Relation) -> bool:
114+
"""Set up the relation to be used by the application charm."""
115+
# Do not allow apps requesting extensions to be installed
116+
# (let them now about config options).
117+
required_extensions, disabled_extensions = self._get_extensions(relation)
118+
if disabled_extensions:
99119
logger.error(
100-
"ERROR - `extensions` cannot be requested through relations"
101-
" - they should be installed through a database charm config in the future"
120+
f"ERROR - `extensions` ({', '.join(disabled_extensions)}) cannot be requested through relations"
121+
" - Please enable extensions through `juju config` and add the relation again."
102122
)
103123
self.charm.unit.status = BlockedStatus(EXTENSIONS_BLOCKING_MESSAGE)
104-
return
124+
return False
105125

106-
# Sometimes a relation changed event is triggered,
107-
# and it doesn't have a database name in it.
108-
database = event.relation.data.get(event.app, {}).get(
109-
"database", event.relation.data.get(event.unit, {}).get("database")
110-
)
126+
database = relation.data.get(relation.app, {}).get("database")
111127
if not database:
112-
logger.warning("Deferring on_relation_changed: No database name provided")
113-
event.defer()
114-
return
128+
for unit in relation.units:
129+
unit_database = relation.data.get(unit, {}).get("database")
130+
if unit_database:
131+
database = unit_database
132+
break
133+
134+
if not database:
135+
logger.warning("Early exit on_relation_changed: No database name provided")
136+
return False
115137

116138
try:
139+
unit_relation_databag = relation.data[self.charm.unit]
140+
application_relation_databag = relation.data[self.charm.app]
141+
117142
# Creates the user and the database for this specific relation if it was not already
118143
# created in a previous relation changed event.
119-
user = f"relation_id_{event.relation.id}"
144+
user = f"relation_id_{relation.id}"
120145
password = unit_relation_databag.get("password", new_password())
121146
self.charm.postgresql.create_user(user, password, self.admin)
122147
self.charm.postgresql.create_database(database, user)
@@ -129,7 +154,7 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
129154
port=DATABASE_PORT,
130155
user=user,
131156
password=password,
132-
fallback_application_name=event.app.name,
157+
fallback_application_name=relation.app.name,
133158
)
134159
)
135160

@@ -141,7 +166,7 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
141166
port=DATABASE_PORT,
142167
user=user,
143168
password=password,
144-
fallback_application_name=event.app.name,
169+
fallback_application_name=relation.app.name,
145170
)
146171
)
147172

@@ -153,8 +178,8 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
153178
# application and this charm will not work.
154179
for databag in [application_relation_databag, unit_relation_databag]:
155180
updates = {
156-
"allowed-subnets": self._get_allowed_subnets(event.relation),
157-
"allowed-units": self._get_allowed_units(event.relation),
181+
"allowed-subnets": self._get_allowed_subnets(relation),
182+
"allowed-units": self._get_allowed_units(relation),
158183
"host": self.charm.endpoint,
159184
"master": primary,
160185
"port": DATABASE_PORT,
@@ -163,6 +188,7 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
163188
"user": user,
164189
"password": password,
165190
"database": database,
191+
"extensions": ",".join(required_extensions),
166192
}
167193
databag.update(updates)
168194
except (
@@ -173,7 +199,9 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
173199
self.charm.unit.status = BlockedStatus(
174200
f"Failed to initialize {self.relation_name} relation"
175201
)
176-
return
202+
return False
203+
204+
return True
177205

178206
def _check_for_blocking_relations(self, relation_id: int) -> bool:
179207
"""Checks if there are relations with extensions.

tests/integration/helpers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
retry_if_exception,
2424
retry_if_result,
2525
stop_after_attempt,
26+
stop_after_delay,
2627
wait_exponential,
28+
wait_fixed,
2729
)
2830

2931
CHARM_SERIES = "jammy"
@@ -529,6 +531,15 @@ async def check_tls_patroni_api(ops_test: OpsTest, unit_name: str, enabled: bool
529531
return False
530532

531533

534+
def has_relation_exited(ops_test: OpsTest, endpoint_one: str, endpoint_two: str) -> bool:
535+
"""Returns true if the relation between endpoint_one and endpoint_two has been removed."""
536+
for rel in ops_test.model.relations:
537+
endpoints = [endpoint.name for endpoint in rel.endpoints]
538+
if endpoint_one not in endpoints and endpoint_two not in endpoints:
539+
return True
540+
return False
541+
542+
532543
@retry(
533544
retry=retry_if_result(lambda x: not x),
534545
stop=stop_after_attempt(10),
@@ -640,3 +651,22 @@ async def wait_for_idle_on_blocked(
640651
),
641652
ops_test.model.block_until(lambda: unit.workload_status_message == status_message),
642653
)
654+
655+
656+
def wait_for_relation_removed_between(
657+
ops_test: OpsTest, endpoint_one: str, endpoint_two: str
658+
) -> None:
659+
"""Wait for relation to be removed before checking if it's waiting or idle.
660+
661+
Args:
662+
ops_test: running OpsTest instance
663+
endpoint_one: one endpoint of the relation. Doesn't matter if it's provider or requirer.
664+
endpoint_two: the other endpoint of the relation.
665+
"""
666+
try:
667+
for attempt in Retrying(stop=stop_after_delay(3 * 60), wait=wait_fixed(3)):
668+
with attempt:
669+
if has_relation_exited(ops_test, endpoint_one, endpoint_two):
670+
break
671+
except RetryError:
672+
assert False, "Relation failed to exit after 3 minutes."

0 commit comments

Comments
 (0)