postgresql-charms-single-kernel 16.1.11__tar.gz → 16.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/PKG-INFO +3 -1
  2. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/pyproject.toml +10 -6
  3. postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/compat/postgresql.py +583 -0
  4. postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/compat/pyproject.toml +58 -0
  5. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/config/literals.py +2 -0
  6. postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/config/pyproject.toml +58 -0
  7. postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/utils/__init__.py +197 -0
  8. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/utils/postgresql.py +128 -566
  9. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/LICENSE +0 -0
  10. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/README.md +0 -0
  11. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/__init__.py +0 -0
  12. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/abstract_charm.py +0 -0
  13. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/charmcraft.yaml +0 -0
  14. {postgresql_charms_single_kernel-16.1.11/single_kernel_postgresql/events → postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/compat}/__init__.py +0 -0
  15. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/config/__init__.py +0 -0
  16. {postgresql_charms_single_kernel-16.1.11/single_kernel_postgresql/utils → postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/events}/__init__.py +0 -0
  17. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/events/tls_transfer.py +0 -0
  18. {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/utils/filesystem.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: postgresql-charms-single-kernel
3
- Version: 16.1.11
3
+ Version: 16.2.1
4
4
  Summary: Shared and reusable code for PostgreSQL-related charms
5
5
  Author: Canonical Data Platform
6
6
  Author-email: Canonical Data Platform <data-platform@lists.launchpad.net>
@@ -212,7 +212,9 @@ Classifier: Operating System :: POSIX :: Linux
212
212
  Requires-Dist: ops>=2.0.0
213
213
  Requires-Dist: psycopg2>=2.9.10
214
214
  Requires-Dist: tenacity>=9.0.0
215
+ Requires-Dist: httpx ; python_full_version >= '3.12' and extra == 'postgresql'
215
216
  Requires-Python: >=3.8, <4.0
217
+ Provides-Extra: postgresql
216
218
  Description-Content-Type: text/markdown
217
219
 
218
220
  # postgresql-single-kernel-library
@@ -4,7 +4,7 @@
4
4
  [project]
5
5
  name = "postgresql-charms-single-kernel"
6
6
  description = "Shared and reusable code for PostgreSQL-related charms"
7
- version = "16.1.11"
7
+ version = "16.2.1"
8
8
  readme = "README.md"
9
9
  license = {file = "LICENSE"}
10
10
  authors = [
@@ -22,6 +22,9 @@ dependencies = [
22
22
  "tenacity>=9.0.0",
23
23
  ]
24
24
 
25
+ [project.optional-dependencies]
26
+ postgresql = ["httpx; python_version >= '3.12'"]
27
+
25
28
  [build-system]
26
29
  requires = ["uv_build>=0.11.0,<0.12.0"]
27
30
  build-backend = "uv_build"
@@ -32,15 +35,15 @@ module-root = ""
32
35
 
33
36
  [dependency-groups]
34
37
  format = [
35
- "ruff==0.15.9"
38
+ "ruff==0.15.13"
36
39
  ]
37
40
  lint = [
38
41
  "codespell==2.4.2",
39
- "ty==0.0.29"
42
+ "ty==0.0.37"
40
43
  ]
41
44
  unit = [
42
- "coverage[toml]==7.13.5",
43
- "pytest==9.0.2"
45
+ "coverage[toml]==7.14.0",
46
+ "pytest==9.0.3"
44
47
  ]
45
48
 
46
49
 
@@ -64,7 +67,7 @@ log_cli_level = "INFO"
64
67
  [tool.ruff]
65
68
  # preview and explicit preview are enabled for CPY001
66
69
  preview = true
67
- target-version = "py38"
70
+ target-version = "py312"
68
71
  src = ["src", "."]
69
72
  line-length = 99
70
73
 
@@ -111,6 +114,7 @@ convention = "google"
111
114
 
112
115
  [tool.ty.environment]
113
116
  python = ".tox/lint/"
117
+ python-version = "3.12"
114
118
 
115
119
  [tool.ty.src]
116
120
  exclude = ["tests"]
@@ -0,0 +1,583 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """PostgreSQL helper class.
16
+
17
+ The `postgresql` module provides methods for interacting with the PostgreSQL instance.
18
+
19
+ Any charm using this library should import the `psycopg2` or `psycopg2-binary` dependency.
20
+ """
21
+
22
+ import logging
23
+ from collections import OrderedDict
24
+ from typing import Dict, List, Optional, Set, Tuple
25
+
26
+ import psycopg2
27
+ import psycopg2.errors
28
+ import psycopg2.extensions
29
+ from psycopg2.sql import SQL, Identifier, Literal
30
+
31
+ from ..config.literals import (
32
+ BACKUP_USER,
33
+ SYSTEM_USERS,
34
+ )
35
+
36
+ ACCESS_GROUP_RELATION = "relation_access"
37
+
38
+ ROLE_STATS = "charmed_stats"
39
+ ROLE_READ = "charmed_read"
40
+ ROLE_DML = "charmed_dml"
41
+ ROLE_BACKUP = "charmed_backup"
42
+ ROLE_DBA = "charmed_dba"
43
+ ROLE_ADMIN = "charmed_admin"
44
+ ROLE_DATABASES_OWNER = "charmed_databases_owner"
45
+ ALLOWED_ROLES = {
46
+ ROLE_STATS,
47
+ ROLE_READ,
48
+ ROLE_DML,
49
+ ROLE_ADMIN,
50
+ }
51
+
52
+ INVALID_DATABASE_NAME_BLOCKING_MESSAGE = "invalid database name"
53
+ INVALID_DATABASE_NAMES = ["databases", "postgres", "template0", "template1"]
54
+ INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE = "invalid role(s) for extra user roles"
55
+
56
+ REQUIRED_PLUGINS = {
57
+ "address_standardizer": ["postgis"],
58
+ "address_standardizer_data_us": ["postgis"],
59
+ "jsonb_plperl": ["plperl"],
60
+ "postgis_raster": ["postgis"],
61
+ "postgis_tiger_geocoder": ["postgis", "fuzzystrmatch"],
62
+ "postgis_topology": ["postgis"],
63
+ }
64
+ DEPENDENCY_PLUGINS = set()
65
+ for dependencies in REQUIRED_PLUGINS.values():
66
+ DEPENDENCY_PLUGINS |= set(dependencies)
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+
71
+ class PostgreSQLBaseError(Exception):
72
+ """Base lib exception."""
73
+
74
+ message = None
75
+
76
+
77
+ class PostgreSQLCreateDatabaseError(PostgreSQLBaseError):
78
+ """Exception raised when creating a database fails."""
79
+
80
+ def __init__(self, message: Optional[str] = None):
81
+ super().__init__(message)
82
+ self.message = message
83
+
84
+
85
+ class PostgreSQLCreateUserError(PostgreSQLBaseError):
86
+ """Exception raised when creating a user fails."""
87
+
88
+ def __init__(self, message: Optional[str] = None):
89
+ super().__init__(message)
90
+ self.message = message
91
+
92
+
93
+ class PostgreSQLUndefinedHostError(PostgreSQLBaseError):
94
+ """Exception when host is not set."""
95
+
96
+
97
+ class PostgreSQLUndefinedPasswordError(PostgreSQLBaseError):
98
+ """Exception when password is not set."""
99
+
100
+
101
+ class PostgreSQLDeleteUserError(PostgreSQLBaseError):
102
+ """Exception raised when deleting a user fails."""
103
+
104
+
105
+ class PostgreSQLListUsersError(PostgreSQLBaseError):
106
+ """Exception raised when retrieving PostgreSQL users list fails."""
107
+
108
+
109
+ class PostgreSQLGetPostgreSQLVersionError(PostgreSQLBaseError):
110
+ """Exception raised when retrieving PostgreSQL version fails."""
111
+
112
+
113
+ class PostgreSQLEnableDisableExtensionError(PostgreSQLBaseError):
114
+ """Exception raised when enabling/disabling an extension fails."""
115
+
116
+
117
+ class PostgreSQLBase:
118
+ """Class to encapsulate all operations related to interacting with PostgreSQL instance."""
119
+
120
+ def __init__(
121
+ self,
122
+ primary_host: Optional[str],
123
+ current_host: Optional[str],
124
+ user: str,
125
+ password: Optional[str],
126
+ database: str,
127
+ ):
128
+ """Create a PostgreSQL helper.
129
+
130
+ Args:
131
+ primary_host: hostname or address for primary database host.
132
+ current_host: hostname or address for the current database host.
133
+ user: username to connect as.
134
+ password: password for the user.
135
+ database: default database name.
136
+ """
137
+ self.primary_host = primary_host
138
+ self.current_host = current_host
139
+ self.user = user
140
+ self.password = password
141
+ self.database = database
142
+
143
+ def _configure_pgaudit(self, enable: bool) -> None:
144
+ connection = None
145
+ try:
146
+ connection = self._connect_to_database()
147
+ connection.autocommit = True
148
+ with connection.cursor() as cursor:
149
+ cursor.execute("RESET ROLE;")
150
+ if enable:
151
+ cursor.execute("ALTER SYSTEM SET pgaudit.log = 'ROLE,DDL,MISC,MISC_SET';")
152
+ cursor.execute("ALTER SYSTEM SET pgaudit.log_client TO off;")
153
+ cursor.execute("ALTER SYSTEM SET pgaudit.log_parameter TO off;")
154
+ else:
155
+ cursor.execute("ALTER SYSTEM RESET pgaudit.log;")
156
+ cursor.execute("ALTER SYSTEM RESET pgaudit.log_client;")
157
+ cursor.execute("ALTER SYSTEM RESET pgaudit.log_parameter;")
158
+ cursor.execute("SELECT pg_reload_conf();")
159
+ finally:
160
+ if connection is not None:
161
+ connection.close()
162
+
163
+ def _connect_to_database(
164
+ self, database: Optional[str] = None, database_host: Optional[str] = None
165
+ ) -> psycopg2.extensions.connection:
166
+ """Creates a connection to the database.
167
+
168
+ Args:
169
+ database: database to connect to (defaults to the database
170
+ provided when the object for this class was created).
171
+ database_host: host to connect to instead of the primary host.
172
+
173
+ Returns:
174
+ psycopg2 connection object.
175
+ """
176
+ host = database_host if database_host is not None else self.primary_host
177
+ if not host:
178
+ raise PostgreSQLUndefinedHostError("Host not set")
179
+ if not self.password:
180
+ raise PostgreSQLUndefinedPasswordError("Password not set")
181
+
182
+ dbname = database if database else self.database
183
+ logger.debug(
184
+ f"New DB connection: dbname='{dbname}' user='{self.user}' host='{host}' connect_timeout=1"
185
+ )
186
+ connection = psycopg2.connect(
187
+ f"dbname='{dbname}' user='{self.user}' host='{host}'"
188
+ f"password='{self.password}' connect_timeout=1"
189
+ )
190
+ connection.autocommit = True
191
+ return connection
192
+
193
+ def create_database(
194
+ self,
195
+ database: str,
196
+ plugins: Optional[List[str]] = None,
197
+ ) -> None:
198
+ """Creates a new database and grant privileges to a user on it.
199
+
200
+ Args:
201
+ database: database to be created.
202
+ plugins: extensions to enable in the new database.
203
+ """
204
+ # The limit of 49 characters for the database name is due to the usernames that
205
+ # are created for each database, which have the prefix `charmed_` and a suffix
206
+ # like `_owner`, which summed to the database name must not exceed PostgreSQL
207
+ # maximum identifier length (63 characters, which is, the prefix, 8 characters,
208
+ # + database name, 49 characters maximum, + suffix, 6 characters).
209
+ if len(database) > 49:
210
+ logger.error(f"Invalid database name (it must not exceed 49 characters): {database}.")
211
+ raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
212
+ if database in INVALID_DATABASE_NAMES:
213
+ logger.error(f"Invalid database name: {database}.")
214
+ raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
215
+ plugins = plugins if plugins else []
216
+ try:
217
+ connection = self._connect_to_database()
218
+ cursor = connection.cursor()
219
+ cursor.execute(
220
+ SQL("SELECT datname FROM pg_database WHERE datname={};").format(Literal(database))
221
+ )
222
+ if cursor.fetchone() is None:
223
+ cursor.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
224
+ cursor.execute(SQL("CREATE DATABASE {};").format(Identifier(database)))
225
+ cursor.execute(
226
+ SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM PUBLIC;").format(
227
+ Identifier(database)
228
+ )
229
+ )
230
+ with self._connect_to_database(database=database) as conn, conn.cursor() as curs:
231
+ curs.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
232
+ curs.execute(SQL("SELECT set_up_predefined_catalog_roles();"))
233
+ except psycopg2.Error as e:
234
+ logger.error(f"Failed to create database: {e}")
235
+ raise PostgreSQLCreateDatabaseError() from e
236
+
237
+ # Enable preset extensions
238
+ if plugins:
239
+ self.enable_disable_extensions(dict.fromkeys(plugins, True), database)
240
+
241
+ def create_user(
242
+ self,
243
+ user: str,
244
+ password: Optional[str] = None,
245
+ admin: bool = False,
246
+ replication: bool = False,
247
+ extra_user_roles: Optional[List[str]] = None,
248
+ database: Optional[str] = None,
249
+ can_create_database: bool = False,
250
+ ) -> None:
251
+ """Creates a database user.
252
+
253
+ Args:
254
+ user: user to be created.
255
+ password: password to be assigned to the user.
256
+ admin: whether the user should have additional admin privileges.
257
+ replication: whether the user should have replication privileges.
258
+ extra_user_roles: additional privileges and/or roles to be assigned to the user.
259
+ database: optional database to allow the user to connect to.
260
+ can_create_database: whether the user should be able to create databases.
261
+ """
262
+ try:
263
+ roles, privileges = self._process_extra_user_roles(user, extra_user_roles)
264
+
265
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
266
+ # Create or update the user.
267
+ cursor.execute(
268
+ SQL("SELECT TRUE FROM pg_roles WHERE rolname={};").format(Literal(user))
269
+ )
270
+ if cursor.fetchone() is not None:
271
+ user_definition = "ALTER ROLE {} "
272
+ altering = True
273
+ else:
274
+ user_definition = "CREATE ROLE {} "
275
+ altering = False
276
+ user_definition += f"WITH LOGIN{' SUPERUSER' if admin else ''}{' REPLICATION' if replication else ''} ENCRYPTED PASSWORD '{password}'"
277
+ if not altering:
278
+ user_definition, connect_statements = self._adjust_user_definition(
279
+ user, roles, database, user_definition
280
+ )
281
+ if can_create_database:
282
+ user_definition += " CREATEDB"
283
+ if privileges:
284
+ user_definition += f" {' '.join(privileges)}"
285
+ else:
286
+ db_roles, connect_statements = self._adjust_user_roles(user, roles, database)
287
+ roles = [*db_roles, *roles]
288
+ cursor.execute(SQL("RESET ROLE;"))
289
+ cursor.execute(SQL("BEGIN;"))
290
+ cursor.execute(SQL("SET LOCAL log_statement = 'none';"))
291
+ cursor.execute(SQL(f"{user_definition};").format(Identifier(user)))
292
+ cursor.execute(SQL("COMMIT;"))
293
+ if len(connect_statements) > 0:
294
+ for connect_statement in connect_statements:
295
+ cursor.execute(connect_statement)
296
+
297
+ # Add extra user roles to the new user.
298
+ for role in roles:
299
+ cursor.execute(
300
+ SQL("GRANT {} TO {};").format(Identifier(role), Identifier(user))
301
+ )
302
+ except psycopg2.Error as e:
303
+ logger.error(f"Failed to create user: {e}")
304
+ raise PostgreSQLCreateUserError() from e
305
+
306
+ def _adjust_user_definition(
307
+ self, user: str, roles: Optional[List[str]], database: Optional[str], user_definition: str
308
+ ) -> Tuple[str, List[str]]:
309
+ """Adjusts the user definition to include additional statements.
310
+
311
+ Returns:
312
+ A tuple containing the adjusted user definition and a list of additional statements.
313
+ """
314
+ db_roles, connect_statements = self._adjust_user_roles(user, roles, database)
315
+ if db_roles:
316
+ str_roles = [f'"{role}"' for role in db_roles]
317
+ user_definition += f" IN ROLE {', '.join(str_roles)}"
318
+ return user_definition, connect_statements
319
+
320
+ def _adjust_user_roles(
321
+ self, user: str, roles: Optional[List[str]], database: Optional[str]
322
+ ) -> Tuple[List[str], List[str]]:
323
+ """Adjusts the user definition to include additional statements.
324
+
325
+ Returns:
326
+ A tuple containing the adjusted user definition and a list of additional statements.
327
+ """
328
+ db_roles = []
329
+ connect_statements = []
330
+ if database:
331
+ if roles is not None and not any(
332
+ role in [ROLE_STATS, ROLE_READ, ROLE_DML, ROLE_BACKUP, ROLE_DBA] for role in roles
333
+ ):
334
+ db_roles.append(f"charmed_{database}_admin")
335
+ db_roles.append(f"charmed_{database}_dml")
336
+ else:
337
+ connect_statements.append(
338
+ SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
339
+ Identifier(database), Identifier(user)
340
+ )
341
+ )
342
+ if roles is not None and any(
343
+ role
344
+ in [
345
+ ROLE_STATS,
346
+ ROLE_READ,
347
+ ROLE_DML,
348
+ ROLE_BACKUP,
349
+ ROLE_DBA,
350
+ ROLE_ADMIN,
351
+ ROLE_DATABASES_OWNER,
352
+ ]
353
+ for role in roles
354
+ ):
355
+ for system_database in ["postgres", "template1"]:
356
+ connect_statements.append(
357
+ SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
358
+ Identifier(system_database), Identifier(user)
359
+ )
360
+ )
361
+ return db_roles, connect_statements
362
+
363
+ def _process_extra_user_roles(
364
+ self, user: str, extra_user_roles: Optional[List[str]] = None
365
+ ) -> Tuple[List[str], Set[str]]:
366
+ # Separate roles and privileges from the provided extra user roles.
367
+ roles = []
368
+ privileges = set()
369
+ if extra_user_roles:
370
+ if len(extra_user_roles) > 2 and sorted(extra_user_roles) != [
371
+ ROLE_ADMIN,
372
+ "createdb",
373
+ ACCESS_GROUP_RELATION,
374
+ ]:
375
+ extra_user_roles.remove(ACCESS_GROUP_RELATION)
376
+ logger.error(
377
+ "Invalid extra user roles: "
378
+ f"{', '.join(extra_user_roles)}. "
379
+ f"Only 'createdb' and '{ROLE_ADMIN}' are allowed together."
380
+ )
381
+ raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
382
+ valid_privileges, valid_roles = self.list_valid_privileges_and_roles()
383
+ roles = [
384
+ role
385
+ for role in extra_user_roles
386
+ if (
387
+ user == BACKUP_USER
388
+ or user in SYSTEM_USERS
389
+ or role in valid_roles
390
+ or role == ACCESS_GROUP_RELATION
391
+ or role == "createdb"
392
+ )
393
+ ]
394
+ if "createdb" in extra_user_roles:
395
+ extra_user_roles.remove("createdb")
396
+ roles.remove("createdb")
397
+ extra_user_roles.append(ROLE_DATABASES_OWNER)
398
+ roles.append(ROLE_DATABASES_OWNER)
399
+ privileges = {
400
+ extra_user_role
401
+ for extra_user_role in extra_user_roles
402
+ if extra_user_role and extra_user_role not in roles
403
+ }
404
+ invalid_privileges = [
405
+ privilege for privilege in privileges if privilege not in valid_privileges
406
+ ]
407
+ if len(invalid_privileges) > 0:
408
+ logger.error(f"Invalid extra user roles: {', '.join(privileges)}")
409
+ raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
410
+ return roles, privileges
411
+
412
+ def delete_user(self, user: str) -> None:
413
+ """Deletes a database user.
414
+
415
+ Args:
416
+ user: user to be deleted.
417
+ """
418
+ # First of all, check whether the user exists. Otherwise, do nothing.
419
+ users = self.list_users()
420
+ if user not in users:
421
+ return
422
+
423
+ # List all databases.
424
+ try:
425
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
426
+ cursor.execute("SELECT datname FROM pg_database WHERE datistemplate = false;")
427
+ databases = [row[0] for row in cursor.fetchall()]
428
+
429
+ # Existing objects need to be reassigned in each database
430
+ # before the user can be deleted.
431
+ for database in databases:
432
+ with self._connect_to_database(
433
+ database
434
+ ) as connection, connection.cursor() as cursor:
435
+ cursor.execute(SQL("RESET ROLE;"))
436
+ cursor.execute(
437
+ SQL("REASSIGN OWNED BY {} TO {};").format(
438
+ Identifier(user), Identifier(self.user)
439
+ )
440
+ )
441
+ cursor.execute(SQL("DROP OWNED BY {};").format(Identifier(user)))
442
+
443
+ # Delete the user.
444
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
445
+ cursor.execute(SQL("RESET ROLE;"))
446
+ cursor.execute(SQL("DROP ROLE {};").format(Identifier(user)))
447
+ except psycopg2.Error as e:
448
+ logger.error(f"Failed to delete user: {e}")
449
+ raise PostgreSQLDeleteUserError() from e
450
+
451
+ def enable_disable_extensions(
452
+ self, extensions: Dict[str, bool], database: Optional[str] = None
453
+ ) -> None:
454
+ """Enables or disables a PostgreSQL extension.
455
+
456
+ Args:
457
+ extensions: the name of the extensions.
458
+ database: optional database where to enable/disable the extension.
459
+
460
+ Raises:
461
+ PostgreSQLEnableDisableExtensionError if the operation fails.
462
+ """
463
+ connection = None
464
+ try:
465
+ if database is not None:
466
+ databases = [database]
467
+ else:
468
+ # Retrieve all the databases.
469
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
470
+ cursor.execute("SELECT datname FROM pg_database WHERE NOT datistemplate;")
471
+ databases = {database[0] for database in cursor.fetchall()}
472
+
473
+ ordered_extensions = OrderedDict()
474
+ for plugin in DEPENDENCY_PLUGINS:
475
+ ordered_extensions[plugin] = extensions.get(plugin, False)
476
+ for extension, enable in extensions.items():
477
+ ordered_extensions[extension] = enable
478
+
479
+ self._configure_pgaudit(False)
480
+
481
+ # Enable/disabled the extension in each database.
482
+ for database in databases:
483
+ with self._connect_to_database(
484
+ database=database
485
+ ) as connection, connection.cursor() as cursor:
486
+ for extension, enable in ordered_extensions.items():
487
+ cursor.execute(
488
+ f"CREATE EXTENSION IF NOT EXISTS {extension};"
489
+ if enable
490
+ else f"DROP EXTENSION IF EXISTS {extension};"
491
+ )
492
+ self._configure_pgaudit(ordered_extensions.get("pgaudit", False))
493
+ except psycopg2.errors.UniqueViolation: # type: ignore
494
+ pass
495
+ except psycopg2.errors.DependentObjectsStillExist: # type: ignore
496
+ raise
497
+ except psycopg2.Error as e:
498
+ raise PostgreSQLEnableDisableExtensionError() from e
499
+ finally:
500
+ if connection is not None:
501
+ connection.close()
502
+
503
+ def get_postgresql_version(self, current_host=True) -> str:
504
+ """Returns the PostgreSQL version.
505
+
506
+ Returns:
507
+ PostgreSQL version number.
508
+ """
509
+ host = self.current_host if current_host else None
510
+ try:
511
+ with self._connect_to_database(
512
+ database_host=host
513
+ ) as connection, connection.cursor() as cursor:
514
+ cursor.execute("SELECT version();")
515
+ # Split to get only the version number. There should always be a version.
516
+ return cursor.fetchone()[0].split(" ")[1]
517
+ except psycopg2.Error as e:
518
+ logger.error(f"Failed to get PostgreSQL version: {e}")
519
+ raise PostgreSQLGetPostgreSQLVersionError() from e
520
+
521
+ def list_users(self, group: Optional[str] = None, current_host=False) -> Set[str]:
522
+ """Returns the list of PostgreSQL database users.
523
+
524
+ Args:
525
+ group: optional group to filter the users.
526
+ current_host: whether to check the current host
527
+ instead of the primary host.
528
+
529
+ Returns:
530
+ List of PostgreSQL database users.
531
+ """
532
+ connection = None
533
+ host = self.current_host if current_host else None
534
+ try:
535
+ with self._connect_to_database(
536
+ database_host=host
537
+ ) as connection, connection.cursor() as cursor:
538
+ if group:
539
+ query = SQL(
540
+ "SELECT usename FROM (SELECT UNNEST(grolist) AS user_id FROM pg_catalog.pg_group WHERE groname = {}) AS g JOIN pg_catalog.pg_user AS u ON g.user_id = u.usesysid;"
541
+ ).format(Literal(group))
542
+ else:
543
+ query = "SELECT usename FROM pg_catalog.pg_user;"
544
+ cursor.execute(query)
545
+ usernames = cursor.fetchall()
546
+ return {username[0] for username in usernames}
547
+ except psycopg2.Error as e:
548
+ logger.error(f"Failed to list PostgreSQL database users: {e}")
549
+ raise PostgreSQLListUsersError() from e
550
+ finally:
551
+ if connection is not None:
552
+ connection.close()
553
+
554
+ def list_valid_privileges_and_roles(self) -> Tuple[Set[str], Set[str]]:
555
+ """Returns two sets with valid privileges and roles.
556
+
557
+ Returns:
558
+ Tuple containing two sets: the first with valid privileges
559
+ and the second with valid roles.
560
+ """
561
+ return {
562
+ "superuser",
563
+ }, ALLOWED_ROLES
564
+
565
+ def is_user_in_hba(self, username: str) -> bool:
566
+ """Check if user was added in pg_hba."""
567
+ connection = None
568
+ try:
569
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
570
+ cursor.execute(
571
+ SQL(
572
+ "SELECT COUNT(*) FROM pg_hba_file_rules WHERE {} = ANY(user_name);"
573
+ ).format(Literal(username))
574
+ )
575
+ if result := cursor.fetchone():
576
+ return result[0] > 0
577
+ return False
578
+ except psycopg2.Error as e:
579
+ logger.debug(f"Failed to check pg_hba: {e}")
580
+ return False
581
+ finally:
582
+ if connection:
583
+ connection.close()