postgresql-charms-single-kernel 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,1793 @@
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
+ from ops import ConfigData
28
+ from psycopg2.sql import SQL, Identifier, Literal
29
+
30
+ from ..config.literals import BACKUP_USER, SYSTEM_USERS
31
+
32
+ # Groups to distinguish HBA access
33
+ ACCESS_GROUP_IDENTITY = "identity_access"
34
+ ACCESS_GROUP_INTERNAL = "internal_access"
35
+ ACCESS_GROUP_RELATION = "relation_access"
36
+
37
+ # List of access groups to filter role assignments by
38
+ ACCESS_GROUPS = [
39
+ ACCESS_GROUP_IDENTITY,
40
+ ACCESS_GROUP_INTERNAL,
41
+ ACCESS_GROUP_RELATION,
42
+ ]
43
+
44
+ ROLE_STATS = "charmed_stats"
45
+ ROLE_READ = "charmed_read"
46
+ ROLE_DML = "charmed_dml"
47
+ ROLE_BACKUP = "charmed_backup"
48
+ ROLE_DBA = "charmed_dba"
49
+ ROLE_ADMIN = "charmed_admin"
50
+ ROLE_DATABASES_OWNER = "charmed_databases_owner"
51
+ ALLOWED_ROLES = {
52
+ ROLE_STATS,
53
+ ROLE_READ,
54
+ ROLE_DML,
55
+ ROLE_ADMIN,
56
+ }
57
+
58
+ INVALID_DATABASE_NAME_BLOCKING_MESSAGE = "invalid database name"
59
+ INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE = "invalid role(s) for extra user roles"
60
+
61
+ REQUIRED_PLUGINS = {
62
+ "address_standardizer": ["postgis"],
63
+ "address_standardizer_data_us": ["postgis"],
64
+ "jsonb_plperl": ["plperl"],
65
+ "postgis_raster": ["postgis"],
66
+ "postgis_tiger_geocoder": ["postgis", "fuzzystrmatch"],
67
+ "postgis_topology": ["postgis"],
68
+ }
69
+ DEPENDENCY_PLUGINS = set()
70
+ for dependencies in REQUIRED_PLUGINS.values():
71
+ DEPENDENCY_PLUGINS |= set(dependencies)
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+
76
+ class PostgreSQLBaseError(Exception):
77
+ """Base lib exception."""
78
+
79
+ message = None
80
+
81
+
82
+ class PostgreSQLAssignGroupError(PostgreSQLBaseError):
83
+ """Exception raised when assigning to a group fails."""
84
+
85
+
86
+ class PostgreSQLCreateDatabaseError(PostgreSQLBaseError):
87
+ """Exception raised when creating a database fails."""
88
+
89
+ def __init__(self, message: Optional[str] = None):
90
+ super().__init__(message)
91
+ self.message = message
92
+
93
+
94
+ class PostgreSQLCreateGroupError(PostgreSQLBaseError):
95
+ """Exception raised when creating a group fails."""
96
+
97
+
98
+ class PostgreSQLCreateUserError(PostgreSQLBaseError):
99
+ """Exception raised when creating a user fails."""
100
+
101
+ def __init__(self, message: Optional[str] = None):
102
+ super().__init__(message)
103
+ self.message = message
104
+
105
+
106
+ class PostgreSQLUndefinedHostError(PostgreSQLBaseError):
107
+ """Exception when host is not set."""
108
+
109
+
110
+ class PostgreSQLUndefinedPasswordError(PostgreSQLBaseError):
111
+ """Exception when password is not set."""
112
+
113
+
114
+ class PostgreSQLDatabasesSetupError(PostgreSQLBaseError):
115
+ """Exception raised when the databases setup fails."""
116
+
117
+
118
+ class PostgreSQLDeleteUserError(PostgreSQLBaseError):
119
+ """Exception raised when deleting a user fails."""
120
+
121
+
122
+ class PostgreSQLEnableDisableExtensionError(PostgreSQLBaseError):
123
+ """Exception raised when enabling/disabling an extension fails."""
124
+
125
+
126
+ class PostgreSQLGetLastArchivedWALError(PostgreSQLBaseError):
127
+ """Exception raised when retrieving last archived WAL fails."""
128
+
129
+
130
+ class PostgreSQLGetCurrentTimelineError(PostgreSQLBaseError):
131
+ """Exception raised when retrieving current timeline id for the PostgreSQL unit fails."""
132
+
133
+
134
+ class PostgreSQLGetPostgreSQLVersionError(PostgreSQLBaseError):
135
+ """Exception raised when retrieving PostgreSQL version fails."""
136
+
137
+
138
+ class PostgreSQLListAccessibleDatabasesForUserError(PostgreSQLBaseError):
139
+ """Exception raised when retrieving the accessible databases for a user fails."""
140
+
141
+
142
+ class PostgreSQLListGroupsError(PostgreSQLBaseError):
143
+ """Exception raised when retrieving PostgreSQL groups list fails."""
144
+
145
+
146
+ class PostgreSQLListUsersError(PostgreSQLBaseError):
147
+ """Exception raised when retrieving PostgreSQL users list fails."""
148
+
149
+
150
+ class PostgreSQLUpdateUserPasswordError(PostgreSQLBaseError):
151
+ """Exception raised when updating a user password fails."""
152
+
153
+
154
+ class PostgreSQLCreatePredefinedRolesError(PostgreSQLBaseError):
155
+ """Exception raised when creating predefined roles."""
156
+
157
+
158
+ class PostgreSQLDatabaseExistsError(PostgreSQLBaseError):
159
+ """Exception raised during database existence check."""
160
+
161
+
162
+ class PostgreSQLTableExistsError(PostgreSQLBaseError):
163
+ """Exception raised during table existence check."""
164
+
165
+
166
+ class PostgreSQLIsTableEmptyError(PostgreSQLBaseError):
167
+ """Exception raised during table emptiness check."""
168
+
169
+
170
+ class PostgreSQLCreatePublicationError(PostgreSQLBaseError):
171
+ """Exception raised when creating PostgreSQL publication."""
172
+
173
+
174
+ class PostgreSQLPublicationExistsError(PostgreSQLBaseError):
175
+ """Exception raised during PostgreSQL publication existence check."""
176
+
177
+
178
+ class PostgreSQLAlterPublicationError(PostgreSQLBaseError):
179
+ """Exception raised when altering PostgreSQL publication."""
180
+
181
+
182
+ class PostgreSQLDropPublicationError(PostgreSQLBaseError):
183
+ """Exception raised when dropping PostgreSQL publication."""
184
+
185
+
186
+ class PostgreSQLCreateSubscriptionError(PostgreSQLBaseError):
187
+ """Exception raised when creating PostgreSQL subscription."""
188
+
189
+
190
+ class PostgreSQLSubscriptionExistsError(PostgreSQLBaseError):
191
+ """Exception raised during PostgreSQL subscription existence check."""
192
+
193
+
194
+ class PostgreSQLUpdateSubscriptionError(PostgreSQLBaseError):
195
+ """Exception raised when updating PostgreSQL subscription."""
196
+
197
+
198
+ class PostgreSQLRefreshSubscriptionError(PostgreSQLBaseError):
199
+ """Exception raised when refreshing PostgreSQL subscription."""
200
+
201
+
202
+ class PostgreSQLDropSubscriptionError(PostgreSQLBaseError):
203
+ """Exception raised when dropping PostgreSQL subscription."""
204
+
205
+
206
+ class PostgreSQLGrantDatabasePrivilegesToUserError(PostgreSQLBaseError):
207
+ """Exception raised when granting database privileges to user."""
208
+
209
+
210
+ class PostgreSQL:
211
+ """Class to encapsulate all operations related to interacting with PostgreSQL instance."""
212
+
213
+ def __init__(
214
+ self,
215
+ primary_host: Optional[str],
216
+ current_host: Optional[str],
217
+ user: str,
218
+ password: Optional[str],
219
+ database: str,
220
+ system_users: Optional[List[str]] = None,
221
+ ):
222
+ self.primary_host = primary_host
223
+ self.current_host = current_host
224
+ self.user = user
225
+ self.password = password
226
+ self.database = database
227
+ self.system_users = system_users if system_users else []
228
+
229
+ def _configure_pgaudit(self, enable: bool) -> None:
230
+ connection = None
231
+ try:
232
+ connection = self._connect_to_database()
233
+ connection.autocommit = True
234
+ with connection.cursor() as cursor:
235
+ cursor.execute("RESET ROLE;")
236
+ if enable:
237
+ cursor.execute("ALTER SYSTEM SET pgaudit.log = 'ROLE,DDL,MISC,MISC_SET';")
238
+ cursor.execute("ALTER SYSTEM SET pgaudit.log_client TO off;")
239
+ cursor.execute("ALTER SYSTEM SET pgaudit.log_parameter TO off;")
240
+ else:
241
+ cursor.execute("ALTER SYSTEM RESET pgaudit.log;")
242
+ cursor.execute("ALTER SYSTEM RESET pgaudit.log_client;")
243
+ cursor.execute("ALTER SYSTEM RESET pgaudit.log_parameter;")
244
+ cursor.execute("SELECT pg_reload_conf();")
245
+ finally:
246
+ if connection is not None:
247
+ connection.close()
248
+
249
+ def _connect_to_database(
250
+ self, database: Optional[str] = None, database_host: Optional[str] = None
251
+ ) -> psycopg2.extensions.connection:
252
+ """Creates a connection to the database.
253
+
254
+ Args:
255
+ database: database to connect to (defaults to the database
256
+ provided when the object for this class was created).
257
+ database_host: host to connect to instead of the primary host.
258
+
259
+ Returns:
260
+ psycopg2 connection object.
261
+ """
262
+ host = database_host if database_host is not None else self.primary_host
263
+ if not host:
264
+ raise PostgreSQLUndefinedHostError("Host not set")
265
+ if not self.password:
266
+ raise PostgreSQLUndefinedPasswordError("Password not set")
267
+
268
+ dbname = database if database else self.database
269
+ logger.debug(
270
+ f"New DB connection: dbname='{dbname}' user='{self.user}' host='{host}' connect_timeout=1"
271
+ )
272
+ connection = psycopg2.connect(
273
+ f"dbname='{dbname}' user='{self.user}' host='{host}'"
274
+ f"password='{self.password}' connect_timeout=1"
275
+ )
276
+ connection.autocommit = True
277
+ return connection
278
+
279
+ def create_access_groups(self) -> None:
280
+ """Create access groups to distinguish HBA authentication methods."""
281
+ connection = None
282
+ try:
283
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
284
+ for group in ACCESS_GROUPS:
285
+ cursor.execute(
286
+ SQL("SELECT TRUE FROM pg_roles WHERE rolname={};").format(Literal(group))
287
+ )
288
+ if cursor.fetchone() is not None:
289
+ continue
290
+ cursor.execute(
291
+ SQL("CREATE ROLE {} NOLOGIN;").format(
292
+ Identifier(group),
293
+ )
294
+ )
295
+ except psycopg2.Error as e:
296
+ logger.error(f"Failed to create access groups: {e}")
297
+ raise PostgreSQLCreateGroupError() from e
298
+ finally:
299
+ if connection is not None:
300
+ connection.close()
301
+
302
+ def create_database(
303
+ self,
304
+ database: str,
305
+ plugins: Optional[List[str]] = None,
306
+ ) -> None:
307
+ """Creates a new database and grant privileges to a user on it.
308
+
309
+ Args:
310
+ database: database to be created.
311
+ plugins: extensions to enable in the new database.
312
+ """
313
+ # The limit of 49 characters for the database name is due to the usernames that
314
+ # are created for each database, which have the prefix `charmed_` and a suffix
315
+ # like `_owner`, which summed to the database name must not exceed PostgreSQL
316
+ # maximum identifier length (63 characters, which is, the prefix, 8 characters,
317
+ # + database name, 49 characters maximum, + suffix, 6 characters).
318
+ if len(database) > 49:
319
+ logger.error(f"Invalid database name (it must not exceed 49 characters): {database}.")
320
+ raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
321
+ if database in ["postgres", "template0", "template1"]:
322
+ logger.error(f"Invalid database name: {database}.")
323
+ raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
324
+ plugins = plugins if plugins else []
325
+ try:
326
+ connection = self._connect_to_database()
327
+ cursor = connection.cursor()
328
+ cursor.execute(
329
+ SQL("SELECT datname FROM pg_database WHERE datname={};").format(Literal(database))
330
+ )
331
+ if cursor.fetchone() is None:
332
+ cursor.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
333
+ cursor.execute(SQL("CREATE DATABASE {};").format(Identifier(database)))
334
+ cursor.execute(
335
+ SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM PUBLIC;").format(
336
+ Identifier(database)
337
+ )
338
+ )
339
+ with self._connect_to_database(database=database) as conn, conn.cursor() as curs:
340
+ curs.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
341
+ curs.execute(SQL("SELECT set_up_predefined_catalog_roles();"))
342
+ except psycopg2.Error as e:
343
+ logger.error(f"Failed to create database: {e}")
344
+ raise PostgreSQLCreateDatabaseError() from e
345
+
346
+ # Enable preset extensions
347
+ if plugins:
348
+ self.enable_disable_extensions(dict.fromkeys(plugins, True), database)
349
+
350
+ def create_user(
351
+ self,
352
+ user: str,
353
+ password: Optional[str] = None,
354
+ admin: bool = False,
355
+ replication: bool = False,
356
+ extra_user_roles: Optional[List[str]] = None,
357
+ database: Optional[str] = None,
358
+ can_create_database: bool = False,
359
+ ) -> None:
360
+ """Creates a database user.
361
+
362
+ Args:
363
+ user: user to be created.
364
+ password: password to be assigned to the user.
365
+ admin: whether the user should have additional admin privileges.
366
+ replication: whether the user should have replication privileges.
367
+ extra_user_roles: additional privileges and/or roles to be assigned to the user.
368
+ database: optional database to allow the user to connect to.
369
+ can_create_database: whether the user should be able to create databases.
370
+ """
371
+ try:
372
+ roles, privileges = self._process_extra_user_roles(user, extra_user_roles)
373
+
374
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
375
+ # Create or update the user.
376
+ cursor.execute(
377
+ SQL("SELECT TRUE FROM pg_roles WHERE rolname={};").format(Literal(user))
378
+ )
379
+ if cursor.fetchone() is not None:
380
+ user_definition = "ALTER ROLE {} "
381
+ else:
382
+ user_definition = "CREATE ROLE {} "
383
+ user_definition += f"WITH LOGIN{' SUPERUSER' if admin else ''}{' REPLICATION' if replication else ''} ENCRYPTED PASSWORD '{password}'"
384
+ user_definition, connect_statements = self._adjust_user_definition(
385
+ user, roles, database, user_definition
386
+ )
387
+ if can_create_database:
388
+ user_definition += " CREATEDB"
389
+ if privileges:
390
+ user_definition += f" {' '.join(privileges)}"
391
+ cursor.execute(SQL("RESET ROLE;"))
392
+ cursor.execute(SQL("BEGIN;"))
393
+ cursor.execute(SQL("SET LOCAL log_statement = 'none';"))
394
+ cursor.execute(SQL(f"{user_definition};").format(Identifier(user)))
395
+ cursor.execute(SQL("COMMIT;"))
396
+ if len(connect_statements) > 0:
397
+ for connect_statement in connect_statements:
398
+ cursor.execute(connect_statement)
399
+
400
+ # Add extra user roles to the new user.
401
+ if roles:
402
+ for role in roles:
403
+ cursor.execute(
404
+ SQL("GRANT {} TO {};").format(Identifier(role), Identifier(user))
405
+ )
406
+ except psycopg2.Error as e:
407
+ logger.error(f"Failed to create user: {e}")
408
+ raise PostgreSQLCreateUserError() from e
409
+
410
+ def _adjust_user_definition(
411
+ self, user: str, roles: Optional[List[str]], database: Optional[str], user_definition: str
412
+ ) -> Tuple[str, List[str]]:
413
+ """Adjusts the user definition to include additional statements.
414
+
415
+ Returns:
416
+ A tuple containing the adjusted user definition and a list of additional statements.
417
+ """
418
+ connect_statements = []
419
+ if database:
420
+ if roles is not None and not any(
421
+ True
422
+ for role in roles
423
+ if role in [ROLE_STATS, ROLE_READ, ROLE_DML, ROLE_BACKUP, ROLE_DBA]
424
+ ):
425
+ user_definition += f' IN ROLE "charmed_{database}_admin", "charmed_{database}_dml"'
426
+ else:
427
+ connect_statements.append(
428
+ SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
429
+ Identifier(database), Identifier(user)
430
+ )
431
+ )
432
+ if roles is not None and any(
433
+ True
434
+ for role in roles
435
+ if role
436
+ in [
437
+ ROLE_STATS,
438
+ ROLE_READ,
439
+ ROLE_DML,
440
+ ROLE_BACKUP,
441
+ ROLE_DBA,
442
+ ROLE_ADMIN,
443
+ ROLE_DATABASES_OWNER,
444
+ ]
445
+ ):
446
+ for system_database in ["postgres", "template1"]:
447
+ connect_statements.append(
448
+ SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
449
+ Identifier(system_database), Identifier(user)
450
+ )
451
+ )
452
+ return user_definition, connect_statements
453
+
454
+ def _process_extra_user_roles(
455
+ self, user: str, extra_user_roles: Optional[List[str]] = None
456
+ ) -> Tuple[Optional[List[str]], Optional[Set[str]]]:
457
+ # Separate roles and privileges from the provided extra user roles.
458
+ roles = privileges = None
459
+ if extra_user_roles:
460
+ if len(extra_user_roles) > 2 and sorted(extra_user_roles) != [
461
+ ROLE_ADMIN,
462
+ "createdb",
463
+ ACCESS_GROUP_RELATION,
464
+ ]:
465
+ extra_user_roles.remove(ACCESS_GROUP_RELATION)
466
+ logger.error(
467
+ "Invalid extra user roles: "
468
+ f"{', '.join(extra_user_roles)}. "
469
+ f"Only 'createdb' and '{ROLE_ADMIN}' are allowed together."
470
+ )
471
+ raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
472
+ valid_privileges, valid_roles = self.list_valid_privileges_and_roles()
473
+ roles = [
474
+ role
475
+ for role in extra_user_roles
476
+ if (
477
+ user == BACKUP_USER
478
+ or user in SYSTEM_USERS
479
+ or role in valid_roles
480
+ or role == ACCESS_GROUP_RELATION
481
+ or role == "createdb"
482
+ )
483
+ ]
484
+ if "createdb" in extra_user_roles:
485
+ extra_user_roles.remove("createdb")
486
+ roles.remove("createdb")
487
+ extra_user_roles.append(ROLE_DATABASES_OWNER)
488
+ roles.append(ROLE_DATABASES_OWNER)
489
+ privileges = {
490
+ extra_user_role
491
+ for extra_user_role in extra_user_roles
492
+ if extra_user_role and extra_user_role not in roles
493
+ }
494
+ invalid_privileges = [
495
+ privilege for privilege in privileges if privilege not in valid_privileges
496
+ ]
497
+ if len(invalid_privileges) > 0:
498
+ logger.error(f"Invalid extra user roles: {', '.join(privileges)}")
499
+ raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
500
+ return roles, privileges
501
+
502
+ def create_predefined_instance_roles(self) -> None:
503
+ """Create predefined instance roles."""
504
+ connection = None
505
+ try:
506
+ for database in self._get_existing_databases():
507
+ with self._connect_to_database(
508
+ database=database,
509
+ ) as connection, connection.cursor() as cursor:
510
+ cursor.execute(SQL("CREATE EXTENSION IF NOT EXISTS set_user;"))
511
+ finally:
512
+ if connection is not None:
513
+ connection.close()
514
+ connection = None
515
+
516
+ role_to_queries = {
517
+ ROLE_STATS: [
518
+ f"CREATE ROLE {ROLE_STATS} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_monitor",
519
+ ],
520
+ ROLE_READ: [
521
+ f"CREATE ROLE {ROLE_READ} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_read_all_data, {ROLE_STATS}",
522
+ ],
523
+ ROLE_DML: [
524
+ f"CREATE ROLE {ROLE_DML} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_write_all_data, {ROLE_READ}",
525
+ ],
526
+ ROLE_BACKUP: [
527
+ f"CREATE ROLE {ROLE_BACKUP} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_checkpoint",
528
+ f"GRANT {ROLE_STATS} TO {ROLE_BACKUP}",
529
+ f"GRANT execute ON FUNCTION pg_backup_start TO {ROLE_BACKUP}",
530
+ f"GRANT execute ON FUNCTION pg_backup_stop TO {ROLE_BACKUP}",
531
+ f"GRANT execute ON FUNCTION pg_create_restore_point TO {ROLE_BACKUP}",
532
+ f"GRANT execute ON FUNCTION pg_switch_wal TO {ROLE_BACKUP}",
533
+ ],
534
+ ROLE_DBA: [
535
+ f"CREATE ROLE {ROLE_DBA} NOSUPERUSER CREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE {ROLE_DML};"
536
+ ],
537
+ ROLE_ADMIN: [
538
+ f"CREATE ROLE {ROLE_ADMIN} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE {ROLE_DML}",
539
+ ],
540
+ }
541
+
542
+ try:
543
+ for database in ["postgres", "template1"]:
544
+ with self._connect_to_database(
545
+ database=database,
546
+ ) as connection, connection.cursor() as cursor:
547
+ existing_roles = self.list_existing_roles()
548
+ for role, queries in role_to_queries.items():
549
+ for index, query in enumerate(queries):
550
+ if index == 0:
551
+ if role in existing_roles:
552
+ logger.debug(f"Role {role} already exists")
553
+ continue
554
+ else:
555
+ logger.info(f"Creating predefined role {role}")
556
+ cursor.execute(SQL(query))
557
+ except psycopg2.Error as e:
558
+ logger.error(f"Failed to create predefined instance roles: {e}")
559
+ raise PostgreSQLCreatePredefinedRolesError() from e
560
+ finally:
561
+ if connection is not None:
562
+ connection.close()
563
+
564
+ def grant_database_privileges_to_user(
565
+ self, user: str, database: str, privileges: List[str]
566
+ ) -> None:
567
+ """Grant the specified privileges on the provided database for the user."""
568
+ try:
569
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
570
+ cursor.execute(
571
+ SQL("GRANT {} ON DATABASE {} TO {};").format(
572
+ Identifier(", ".join(privileges)), Identifier(database), Identifier(user)
573
+ )
574
+ )
575
+ except psycopg2.Error as e:
576
+ logger.error(f"Failed to grant privileges to user: {e}")
577
+ raise PostgreSQLGrantDatabasePrivilegesToUserError() from e
578
+
579
+ def delete_user(self, user: str) -> None:
580
+ """Deletes a database user.
581
+
582
+ Args:
583
+ user: user to be deleted.
584
+ """
585
+ # First of all, check whether the user exists. Otherwise, do nothing.
586
+ users = self.list_users()
587
+ if user not in users:
588
+ return
589
+
590
+ # List all databases.
591
+ try:
592
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
593
+ cursor.execute("SELECT datname FROM pg_database WHERE datistemplate = false;")
594
+ databases = [row[0] for row in cursor.fetchall()]
595
+
596
+ # Existing objects need to be reassigned in each database
597
+ # before the user can be deleted.
598
+ for database in databases:
599
+ with self._connect_to_database(
600
+ database
601
+ ) as connection, connection.cursor() as cursor:
602
+ cursor.execute(
603
+ SQL("REASSIGN OWNED BY {} TO {};").format(
604
+ Identifier(user), Identifier(self.user)
605
+ )
606
+ )
607
+ cursor.execute(SQL("DROP OWNED BY {};").format(Identifier(user)))
608
+
609
+ # Delete the user.
610
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
611
+ cursor.execute(SQL("DROP ROLE {};").format(Identifier(user)))
612
+ except psycopg2.Error as e:
613
+ logger.error(f"Failed to delete user: {e}")
614
+ raise PostgreSQLDeleteUserError() from e
615
+
616
+ def grant_internal_access_group_memberships(self) -> None:
617
+ """Grant membership to the internal access-group to existing internal users."""
618
+ connection = None
619
+ try:
620
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
621
+ for user in self.system_users:
622
+ cursor.execute(
623
+ SQL("GRANT {} TO {};").format(
624
+ Identifier(ACCESS_GROUP_INTERNAL),
625
+ Identifier(user),
626
+ )
627
+ )
628
+ except psycopg2.Error as e:
629
+ logger.error(f"Failed to grant internal access group memberships: {e}")
630
+ raise PostgreSQLAssignGroupError() from e
631
+ finally:
632
+ if connection is not None:
633
+ connection.close()
634
+
635
+ def grant_relation_access_group_memberships(self) -> None:
636
+ """Grant membership to the relation access-group to existing relation users."""
637
+ rel_users = self.list_users_from_relation()
638
+ if not rel_users:
639
+ return
640
+
641
+ connection = None
642
+ try:
643
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
644
+ rel_groups = SQL(",").join(Identifier(group) for group in [ACCESS_GROUP_RELATION])
645
+ rel_users = SQL(",").join(Identifier(user) for user in rel_users)
646
+
647
+ cursor.execute(
648
+ SQL("GRANT {groups} TO {users};").format(
649
+ groups=rel_groups,
650
+ users=rel_users,
651
+ )
652
+ )
653
+ except psycopg2.Error as e:
654
+ logger.error(f"Failed to grant relation access group memberships: {e}")
655
+ raise PostgreSQLAssignGroupError() from e
656
+ finally:
657
+ if connection is not None:
658
+ connection.close()
659
+
660
+ def grant_replication_privileges(
661
+ self,
662
+ user: str,
663
+ database: str,
664
+ schematables: List[str],
665
+ old_schematables: Optional[List[str]] = None,
666
+ ) -> None:
667
+ """Grant CONNECT privilege on database and SELECT privilege on tables.
668
+
669
+ Args:
670
+ user: target user for privileges grant.
671
+ database: database to grant CONNECT privilege on.
672
+ schematables: list of tables with schema notation to grant SELECT privileges on.
673
+ old_schematables: list of tables with schema notation to revoke all privileges from.
674
+ """
675
+ connection = None
676
+ try:
677
+ connection = self._connect_to_database(database=database)
678
+ with connection, connection.cursor() as cursor:
679
+ cursor.execute(
680
+ SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
681
+ Identifier(database), Identifier(user)
682
+ )
683
+ )
684
+ if old_schematables:
685
+ cursor.execute(
686
+ SQL("REVOKE ALL PRIVILEGES ON TABLE {} FROM {};").format(
687
+ SQL(",").join(
688
+ Identifier(schematable.split(".")[0], schematable.split(".")[1])
689
+ for schematable in old_schematables
690
+ ),
691
+ Identifier(user),
692
+ )
693
+ )
694
+ cursor.execute(
695
+ SQL("GRANT SELECT ON TABLE {} TO {};").format(
696
+ SQL(",").join(
697
+ Identifier(schematable.split(".")[0], schematable.split(".")[1])
698
+ for schematable in schematables
699
+ ),
700
+ Identifier(user),
701
+ )
702
+ )
703
+ finally:
704
+ if connection:
705
+ connection.close()
706
+
707
+ def revoke_replication_privileges(
708
+ self, user: str, database: str, schematables: List[str]
709
+ ) -> None:
710
+ """Revoke all privileges from tables and database.
711
+
712
+ Args:
713
+ user: target user for privileges revocation.
714
+ database: database to remove all privileges from.
715
+ schematables: list of tables with schema notation to revoke all privileges from.
716
+ """
717
+ connection = None
718
+ try:
719
+ connection = self._connect_to_database(database=database)
720
+ with connection, connection.cursor() as cursor:
721
+ cursor.execute(
722
+ SQL("REVOKE ALL PRIVILEGES ON TABLE {} FROM {};").format(
723
+ SQL(",").join(
724
+ Identifier(schematable.split(".")[0], schematable.split(".")[1])
725
+ for schematable in schematables
726
+ ),
727
+ Identifier(user),
728
+ )
729
+ )
730
+ cursor.execute(
731
+ SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM {};").format(
732
+ Identifier(database), Identifier(user)
733
+ )
734
+ )
735
+ finally:
736
+ if connection:
737
+ connection.close()
738
+
739
+ def enable_disable_extensions(
740
+ self, extensions: Dict[str, bool], database: Optional[str] = None
741
+ ) -> None:
742
+ """Enables or disables a PostgreSQL extension.
743
+
744
+ Args:
745
+ extensions: the name of the extensions.
746
+ database: optional database where to enable/disable the extension.
747
+
748
+ Raises:
749
+ PostgreSQLEnableDisableExtensionError if the operation fails.
750
+ """
751
+ connection = None
752
+ try:
753
+ if database is not None:
754
+ databases = [database]
755
+ else:
756
+ # Retrieve all the databases.
757
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
758
+ cursor.execute("SELECT datname FROM pg_database WHERE NOT datistemplate;")
759
+ databases = {database[0] for database in cursor.fetchall()}
760
+
761
+ ordered_extensions = OrderedDict()
762
+ for plugin in DEPENDENCY_PLUGINS:
763
+ ordered_extensions[plugin] = extensions.get(plugin, False)
764
+ for extension, enable in extensions.items():
765
+ ordered_extensions[extension] = enable
766
+
767
+ self._configure_pgaudit(False)
768
+
769
+ # Enable/disabled the extension in each database.
770
+ for database in databases:
771
+ with self._connect_to_database(
772
+ database=database
773
+ ) as connection, connection.cursor() as cursor:
774
+ for extension, enable in ordered_extensions.items():
775
+ cursor.execute(
776
+ f"CREATE EXTENSION IF NOT EXISTS {extension};"
777
+ if enable
778
+ else f"DROP EXTENSION IF EXISTS {extension};"
779
+ )
780
+ self._configure_pgaudit(ordered_extensions.get("pgaudit", False))
781
+ except psycopg2.errors.UniqueViolation:
782
+ pass
783
+ except psycopg2.errors.DependentObjectsStillExist:
784
+ raise
785
+ except psycopg2.Error as e:
786
+ raise PostgreSQLEnableDisableExtensionError() from e
787
+ finally:
788
+ if connection is not None:
789
+ connection.close()
790
+
791
+ def get_last_archived_wal(self) -> str:
792
+ """Get the name of the last archived wal for the current PostgreSQL cluster."""
793
+ try:
794
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
795
+ # Should always be present
796
+ cursor.execute("SELECT last_archived_wal FROM pg_stat_archiver;")
797
+ return cursor.fetchone()[0] # type: ignore
798
+ except psycopg2.Error as e:
799
+ logger.error(f"Failed to get PostgreSQL last archived WAL: {e}")
800
+ raise PostgreSQLGetLastArchivedWALError() from e
801
+
802
+ def get_current_timeline(self) -> str:
803
+ """Get the timeline id for the current PostgreSQL unit."""
804
+ try:
805
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
806
+ cursor.execute("SELECT timeline_id FROM pg_control_checkpoint();")
807
+ # There should always be a timeline
808
+ return cursor.fetchone()[0] # type: ignore
809
+ except psycopg2.Error as e:
810
+ logger.error(f"Failed to get PostgreSQL current timeline id: {e}")
811
+ raise PostgreSQLGetCurrentTimelineError() from e
812
+
813
+ def get_postgresql_text_search_configs(self) -> Set[str]:
814
+ """Returns the PostgreSQL available text search configs.
815
+
816
+ Returns:
817
+ Set of PostgreSQL text search configs.
818
+ """
819
+ with self._connect_to_database(
820
+ database_host=self.current_host
821
+ ) as connection, connection.cursor() as cursor:
822
+ cursor.execute("SELECT CONCAT('pg_catalog.', cfgname) FROM pg_ts_config;")
823
+ text_search_configs = cursor.fetchall()
824
+ return {text_search_config[0] for text_search_config in text_search_configs}
825
+
826
+ def get_postgresql_timezones(self) -> Set[str]:
827
+ """Returns the PostgreSQL available timezones.
828
+
829
+ Returns:
830
+ Set of PostgreSQL timezones.
831
+ """
832
+ with self._connect_to_database(
833
+ database_host=self.current_host
834
+ ) as connection, connection.cursor() as cursor:
835
+ cursor.execute("SELECT name FROM pg_timezone_names;")
836
+ timezones = cursor.fetchall()
837
+ return {timezone[0] for timezone in timezones}
838
+
839
+ def get_postgresql_default_table_access_methods(self) -> Set[str]:
840
+ """Returns the PostgreSQL available table access methods.
841
+
842
+ Returns:
843
+ Set of PostgreSQL table access methods.
844
+ """
845
+ with self._connect_to_database(
846
+ database_host=self.current_host
847
+ ) as connection, connection.cursor() as cursor:
848
+ cursor.execute("SELECT amname FROM pg_am WHERE amtype = 't';")
849
+ access_methods = cursor.fetchall()
850
+ return {access_method[0] for access_method in access_methods}
851
+
852
+ def get_postgresql_version(self, current_host=True) -> str:
853
+ """Returns the PostgreSQL version.
854
+
855
+ Returns:
856
+ PostgreSQL version number.
857
+ """
858
+ host = self.current_host if current_host else None
859
+ try:
860
+ with self._connect_to_database(
861
+ database_host=host
862
+ ) as connection, connection.cursor() as cursor:
863
+ cursor.execute("SELECT version();")
864
+ # Split to get only the version number. There should always be a version.
865
+ return cursor.fetchone()[0].split(" ")[1] # type:ignore
866
+ except psycopg2.Error as e:
867
+ logger.error(f"Failed to get PostgreSQL version: {e}")
868
+ raise PostgreSQLGetPostgreSQLVersionError() from e
869
+
870
+ def is_tls_enabled(self, check_current_host: bool = False) -> bool:
871
+ """Returns whether TLS is enabled.
872
+
873
+ Args:
874
+ check_current_host: whether to check the current host
875
+ instead of the primary host.
876
+
877
+ Returns:
878
+ whether TLS is enabled.
879
+ """
880
+ try:
881
+ with self._connect_to_database(
882
+ database_host=self.current_host if check_current_host else None
883
+ ) as connection, connection.cursor() as cursor:
884
+ cursor.execute("SHOW ssl;")
885
+ # SSL state should always be set
886
+ return "on" in cursor.fetchone()[0] # type: ignore
887
+ except psycopg2.Error:
888
+ # Connection errors happen when PostgreSQL has not started yet.
889
+ return False
890
+
891
+ def list_access_groups(self, current_host=False) -> Set[str]:
892
+ """Returns the list of PostgreSQL database access groups.
893
+
894
+ Args:
895
+ current_host: whether to check the current host
896
+ instead of the primary host.
897
+
898
+ Returns:
899
+ List of PostgreSQL database access groups.
900
+ """
901
+ connection = None
902
+ host = self.current_host if current_host else None
903
+ try:
904
+ with self._connect_to_database(
905
+ database_host=host
906
+ ) as connection, connection.cursor() as cursor:
907
+ cursor.execute(
908
+ "SELECT groname FROM pg_catalog.pg_group WHERE groname LIKE '%_access';"
909
+ )
910
+ access_groups = cursor.fetchall()
911
+ return {group[0] for group in access_groups}
912
+ except psycopg2.Error as e:
913
+ logger.error(f"Failed to list PostgreSQL database access groups: {e}")
914
+ raise PostgreSQLListGroupsError() from e
915
+ finally:
916
+ if connection is not None:
917
+ connection.close()
918
+
919
+ def list_accessible_databases_for_user(self, user: str, current_host=False) -> Set[str]:
920
+ """Returns the list of accessible databases for a specific user.
921
+
922
+ Args:
923
+ user: the user to check.
924
+ current_host: whether to check the current host
925
+ instead of the primary host.
926
+
927
+ Returns:
928
+ List of accessible database (the ones where
929
+ the user has the CONNECT privilege).
930
+ """
931
+ connection = None
932
+ host = self.current_host if current_host else None
933
+ try:
934
+ with self._connect_to_database(
935
+ database_host=host
936
+ ) as connection, connection.cursor() as cursor:
937
+ cursor.execute(
938
+ SQL(
939
+ "SELECT TRUE FROM pg_catalog.pg_user WHERE usename = {} AND usesuper;"
940
+ ).format(Literal(user))
941
+ )
942
+ if cursor.fetchone() is not None:
943
+ return {"all"}
944
+ cursor.execute(
945
+ SQL(
946
+ "SELECT datname FROM pg_catalog.pg_database WHERE has_database_privilege({}, datname, 'CONNECT') AND NOT datistemplate;"
947
+ ).format(Literal(user))
948
+ )
949
+ databases = cursor.fetchall()
950
+ return {database[0] for database in databases}
951
+ except psycopg2.Error as e:
952
+ logger.error(f"Failed to list accessible databases for user {user}: {e}")
953
+ raise PostgreSQLListAccessibleDatabasesForUserError() from e
954
+ finally:
955
+ if connection is not None:
956
+ connection.close()
957
+
958
+ def list_users(self, group: Optional[str] = None, current_host=False) -> Set[str]:
959
+ """Returns the list of PostgreSQL database users.
960
+
961
+ Args:
962
+ group: optional group to filter the users.
963
+ current_host: whether to check the current host
964
+ instead of the primary host.
965
+
966
+ Returns:
967
+ List of PostgreSQL database users.
968
+ """
969
+ connection = None
970
+ host = self.current_host if current_host else None
971
+ try:
972
+ with self._connect_to_database(
973
+ database_host=host
974
+ ) as connection, connection.cursor() as cursor:
975
+ if group:
976
+ query = SQL(
977
+ "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;"
978
+ ).format(Literal(group))
979
+ else:
980
+ query = "SELECT usename FROM pg_catalog.pg_user;"
981
+ cursor.execute(query)
982
+ usernames = cursor.fetchall()
983
+ return {username[0] for username in usernames}
984
+ except psycopg2.Error as e:
985
+ logger.error(f"Failed to list PostgreSQL database users: {e}")
986
+ raise PostgreSQLListUsersError() from e
987
+ finally:
988
+ if connection is not None:
989
+ connection.close()
990
+
991
+ def list_users_from_relation(self, current_host=False) -> Set[str]:
992
+ """Returns the list of PostgreSQL database users that were created by a relation.
993
+
994
+ Args:
995
+ current_host: whether to check the current host
996
+ instead of the primary host.
997
+
998
+ Returns:
999
+ List of PostgreSQL database users.
1000
+ """
1001
+ connection = None
1002
+ host = self.current_host if current_host else None
1003
+ try:
1004
+ with self._connect_to_database(
1005
+ database_host=host
1006
+ ) as connection, connection.cursor() as cursor:
1007
+ cursor.execute(
1008
+ "SELECT usename "
1009
+ "FROM pg_catalog.pg_user "
1010
+ "WHERE usename LIKE 'relation_id_%' OR usename LIKE 'relation-%' "
1011
+ "OR usename LIKE 'pgbouncer_auth_relation_%' OR usename LIKE '%_user_%_%' "
1012
+ "OR usename LIKE 'logical_replication_relation_%';"
1013
+ )
1014
+ usernames = cursor.fetchall()
1015
+ return {username[0] for username in usernames}
1016
+ except psycopg2.Error as e:
1017
+ logger.error(f"Failed to list PostgreSQL database users: {e}")
1018
+ raise PostgreSQLListUsersError() from e
1019
+ finally:
1020
+ if connection is not None:
1021
+ connection.close()
1022
+
1023
+ def list_existing_roles(self) -> Set[str]:
1024
+ """Returns a set containing the existing roles.
1025
+
1026
+ Returns:
1027
+ Set containing the existing roles.
1028
+ """
1029
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
1030
+ cursor.execute("SELECT rolname FROM pg_roles;")
1031
+ return {role[0] for role in cursor.fetchall() if role[0]}
1032
+
1033
+ def list_valid_privileges_and_roles(self) -> Tuple[Set[str], Set[str]]:
1034
+ """Returns two sets with valid privileges and roles.
1035
+
1036
+ Returns:
1037
+ Tuple containing two sets: the first with valid privileges
1038
+ and the second with valid roles.
1039
+ """
1040
+ return {
1041
+ "superuser",
1042
+ }, ALLOWED_ROLES
1043
+
1044
+ def _get_existing_databases(self) -> List[str]:
1045
+ # Template1 should go first
1046
+ databases = ["template1"]
1047
+ connection = None
1048
+ cursor = None
1049
+ try:
1050
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
1051
+ cursor.execute(
1052
+ "SELECT datname FROM pg_database WHERE datname <> 'template0' AND datname <> 'template1';"
1053
+ )
1054
+ db = cursor.fetchone()
1055
+ while db:
1056
+ databases.append(db[0])
1057
+ db = cursor.fetchone()
1058
+ finally:
1059
+ if cursor:
1060
+ cursor.close()
1061
+ if connection:
1062
+ connection.close()
1063
+ return databases
1064
+
1065
+ def set_up_database(self, temp_location: Optional[str] = None) -> None:
1066
+ """Set up postgres database with the right permissions."""
1067
+ connection = None
1068
+ cursor = None
1069
+ try:
1070
+ connection = self._connect_to_database()
1071
+ cursor = connection.cursor()
1072
+
1073
+ if temp_location is not None:
1074
+ cursor.execute("SELECT TRUE FROM pg_tablespace WHERE spcname='temp';")
1075
+ if cursor.fetchone() is None:
1076
+ cursor.execute(f"CREATE TABLESPACE temp LOCATION '{temp_location}';")
1077
+ cursor.execute("GRANT CREATE ON TABLESPACE temp TO public;")
1078
+
1079
+ cursor.close()
1080
+ cursor = None
1081
+ connection.close()
1082
+ connection = None
1083
+
1084
+ with self._connect_to_database(
1085
+ database="template1"
1086
+ ) as connection, connection.cursor() as cursor:
1087
+ cursor.execute(
1088
+ f"SELECT TRUE FROM pg_roles WHERE rolname='{ROLE_DATABASES_OWNER}';" # noqa: S608
1089
+ )
1090
+ if cursor.fetchone() is None:
1091
+ self.create_user(
1092
+ ROLE_DATABASES_OWNER,
1093
+ can_create_database=True,
1094
+ extra_user_roles=[ROLE_DML],
1095
+ )
1096
+
1097
+ self.set_up_login_hook_function()
1098
+ self.set_up_predefined_catalog_roles_function()
1099
+
1100
+ connection.close()
1101
+ connection = None
1102
+
1103
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
1104
+ cursor.execute("REVOKE ALL PRIVILEGES ON DATABASE postgres FROM PUBLIC;")
1105
+ cursor.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC;")
1106
+ for user in self.system_users:
1107
+ cursor.execute(
1108
+ SQL("GRANT ALL PRIVILEGES ON DATABASE postgres TO {};").format(
1109
+ Identifier(user)
1110
+ )
1111
+ )
1112
+ except psycopg2.Error as e:
1113
+ logger.error(f"Failed to set up databases: {e}")
1114
+ raise PostgreSQLDatabasesSetupError() from e
1115
+ finally:
1116
+ if cursor is not None:
1117
+ cursor.close()
1118
+ if connection is not None:
1119
+ connection.close()
1120
+
1121
+ def set_up_login_hook_function(self) -> None:
1122
+ """Create a login hook function to set the user for the current session."""
1123
+ function_creation_statement = f"""CREATE OR REPLACE FUNCTION login_hook.login() RETURNS VOID AS $$
1124
+ DECLARE
1125
+ ex_state TEXT;
1126
+ ex_message TEXT;
1127
+ ex_detail TEXT;
1128
+ ex_hint TEXT;
1129
+ ex_context TEXT;
1130
+ cur_user TEXT;
1131
+ db_admin_role TEXT;
1132
+ db_name TEXT;
1133
+ db_owner_role TEXT;
1134
+ is_user_admin BOOLEAN;
1135
+ user_has_createdb BOOLEAN;
1136
+ BEGIN
1137
+ IF NOT login_hook.is_executing_login_hook()
1138
+ THEN
1139
+ RAISE EXCEPTION 'The login_hook.login() function should only be invoked by the login_hook code';
1140
+ END IF;
1141
+
1142
+ cur_user := (SELECT current_user);
1143
+
1144
+ EXECUTE 'SELECT current_database()' INTO db_name;
1145
+ db_admin_role = 'charmed_' || db_name || '_admin';
1146
+
1147
+ EXECUTE format('SELECT EXISTS(SELECT * FROM pg_auth_members a, pg_roles b, pg_roles c WHERE a.roleid = b.oid AND a.member = c.oid AND (b.rolname = %L OR b.rolname = %L) and c.rolname = %L)', db_admin_role, '{ROLE_ADMIN}', cur_user) INTO is_user_admin;
1148
+
1149
+ EXECUTE format('SELECT EXISTS(SELECT * FROM pg_auth_members a, pg_roles b, pg_roles c WHERE a.roleid = b.oid AND a.member = c.oid AND b.rolname = %L and c.rolname = %L)', '{ROLE_DATABASES_OWNER}', cur_user) INTO user_has_createdb;
1150
+
1151
+ BEGIN
1152
+ IF is_user_admin = true THEN
1153
+ db_owner_role = 'charmed_' || db_name || '_owner';
1154
+ EXECUTE format('SET ROLE %L', db_owner_role);
1155
+ ELSE
1156
+ IF user_has_createdb = true THEN
1157
+ EXECUTE format('SET ROLE %L', '{ROLE_DATABASES_OWNER}');
1158
+ END IF;
1159
+ END IF;
1160
+ EXCEPTION
1161
+ WHEN OTHERS THEN
1162
+ GET STACKED DIAGNOSTICS ex_state = RETURNED_SQLSTATE, ex_message = MESSAGE_TEXT, ex_detail = PG_EXCEPTION_DETAIL, ex_hint = PG_EXCEPTION_HINT, ex_context = PG_EXCEPTION_CONTEXT;
1163
+ RAISE LOG e'Error in login_hook.login()\nsqlstate %\nmessage: %\ndetail: %\nhint: %\ncontext: %', ex_state, ex_message, ex_detail, ex_hint, ex_context;
1164
+ END;
1165
+ END;
1166
+ $$ LANGUAGE plpgsql;""" # noqa: S608
1167
+ connection = None
1168
+ try:
1169
+ for database in self._get_existing_databases():
1170
+ with self._connect_to_database(
1171
+ database=database
1172
+ ) as connection, connection.cursor() as cursor:
1173
+ cursor.execute(SQL("CREATE EXTENSION IF NOT EXISTS login_hook;"))
1174
+ cursor.execute(SQL("CREATE SCHEMA IF NOT EXISTS login_hook;"))
1175
+ cursor.execute(SQL(function_creation_statement))
1176
+ cursor.execute(SQL("GRANT EXECUTE ON FUNCTION login_hook.login() TO PUBLIC;"))
1177
+ except psycopg2.Error as e:
1178
+ logger.error(f"Failed to create login hook function: {e}")
1179
+ raise e
1180
+ finally:
1181
+ if connection:
1182
+ connection.close()
1183
+
1184
+ def set_up_predefined_catalog_roles_function(self) -> None:
1185
+ """Create predefined catalog roles function."""
1186
+ function_creation_statement = f"""CREATE OR REPLACE FUNCTION set_up_predefined_catalog_roles() RETURNS VOID AS $$
1187
+ DECLARE
1188
+ database TEXT;
1189
+ current_session_user TEXT;
1190
+ owner_user TEXT;
1191
+ admin_user TEXT;
1192
+ dml_user TEXT;
1193
+ statements TEXT[];
1194
+ statement TEXT;
1195
+ BEGIN
1196
+ database := (SELECT current_database());
1197
+ current_session_user := (SELECT session_user);
1198
+ owner_user := quote_ident('charmed_' || database || '_owner');
1199
+ admin_user := quote_ident('charmed_' || database || '_admin');
1200
+ dml_user := quote_ident('charmed_' || database || '_dml');
1201
+
1202
+ IF (SELECT COUNT(rolname) FROM pg_roles WHERE rolname=FORMAT('%s', 'charmed_' || database || '_owner')) = 0 THEN
1203
+ statements := ARRAY[
1204
+ 'CREATE ROLE ' || owner_user || ' NOSUPERUSER NOCREATEDB NOCREATEROLE NOLOGIN NOREPLICATION;',
1205
+ 'CREATE ROLE ' || admin_user || ' NOSUPERUSER NOCREATEDB NOCREATEROLE NOLOGIN NOREPLICATION NOINHERIT IN ROLE ' || owner_user || ';',
1206
+ 'CREATE ROLE ' || dml_user || ' NOSUPERUSER NOCREATEDB NOCREATEROLE NOLOGIN NOREPLICATION;',
1207
+ 'GRANT ' || owner_user || ' TO {ROLE_ADMIN} WITH INHERIT FALSE;'
1208
+ ];
1209
+ FOREACH statement IN ARRAY statements
1210
+ LOOP
1211
+ EXECUTE statement;
1212
+ END LOOP;
1213
+ END IF;
1214
+
1215
+ database := quote_ident(database);
1216
+
1217
+ statements := ARRAY[
1218
+ 'REVOKE CREATE ON DATABASE ' || database || ' FROM {ROLE_DATABASES_OWNER};',
1219
+ 'ALTER SCHEMA public OWNER TO ' || owner_user || ';',
1220
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO ' || admin_user || ';',
1221
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_STATS};',
1222
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_READ};',
1223
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_DML};',
1224
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_BACKUP};',
1225
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_DBA};',
1226
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_ADMIN};',
1227
+ 'GRANT ' || admin_user || ' TO {ROLE_ADMIN} WITH INHERIT FALSE;',
1228
+ 'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_DATABASES_OWNER};'
1229
+ ];
1230
+ FOREACH statement IN ARRAY statements
1231
+ LOOP
1232
+ EXECUTE statement;
1233
+ END LOOP;
1234
+
1235
+ IF current_session_user LIKE 'relation-%' OR current_session_user LIKE 'relation_id_%' THEN
1236
+ RAISE NOTICE 'Granting % to %', admin_user, current_session_user;
1237
+ statements := ARRAY[
1238
+ 'GRANT ' || admin_user || ' TO "' || current_session_user || '" WITH INHERIT FALSE;',
1239
+ 'GRANT ' || dml_user || ' TO "' || current_session_user || '" WITH INHERIT TRUE;'
1240
+ ];
1241
+ FOREACH statement IN ARRAY statements
1242
+ LOOP
1243
+ EXECUTE statement;
1244
+ END LOOP;
1245
+ END IF;
1246
+
1247
+ statements := ARRAY[
1248
+ 'GRANT CREATE ON DATABASE ' || database || ' TO ' || owner_user || ';',
1249
+ 'GRANT TEMPORARY ON DATABASE ' || database || ' TO ' || owner_user || ';',
1250
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE ' || owner_user || ' GRANT SELECT ON TABLES TO ' || admin_user || ';',
1251
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE ' || owner_user || ' GRANT EXECUTE ON FUNCTIONS TO ' || admin_user || ';',
1252
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE ' || owner_user || ' GRANT SELECT ON SEQUENCES TO ' || admin_user || ';',
1253
+ 'GRANT EXECUTE ON FUNCTION set_user_u(text) TO charmed_dba;',
1254
+ 'REVOKE EXECUTE ON FUNCTION set_user_u(text) FROM ' || owner_user || ';',
1255
+ 'REVOKE EXECUTE ON FUNCTION set_user_u(text) FROM ' || admin_user || ';',
1256
+ 'GRANT EXECUTE ON FUNCTION set_user(text) TO charmed_dba;',
1257
+ 'REVOKE EXECUTE ON FUNCTION set_user(text) FROM ' || owner_user || ';',
1258
+ 'REVOKE EXECUTE ON FUNCTION set_user(text) FROM ' || admin_user || ';',
1259
+ 'GRANT EXECUTE ON FUNCTION set_user(text, text) TO charmed_dba;',
1260
+ 'REVOKE EXECUTE ON FUNCTION set_user(text, text) FROM ' || owner_user || ';',
1261
+ 'REVOKE EXECUTE ON FUNCTION set_user(text, text) FROM ' || admin_user || ';',
1262
+ 'GRANT EXECUTE ON FUNCTION reset_user() TO charmed_dba;',
1263
+ 'REVOKE EXECUTE ON FUNCTION reset_user() FROM ' || owner_user || ';',
1264
+ 'REVOKE EXECUTE ON FUNCTION reset_user() FROM ' || admin_user || ';',
1265
+ 'GRANT EXECUTE ON FUNCTION reset_user(text) TO charmed_dba;',
1266
+ 'REVOKE EXECUTE ON FUNCTION reset_user(text) FROM ' || owner_user || ';',
1267
+ 'REVOKE EXECUTE ON FUNCTION reset_user(text) FROM ' || admin_user || ';'
1268
+ ];
1269
+ FOREACH statement IN ARRAY statements
1270
+ LOOP
1271
+ EXECUTE statement;
1272
+ END LOOP;
1273
+ END;
1274
+ $$ LANGUAGE plpgsql security definer;""" # noqa: S608
1275
+ connection = None
1276
+ try:
1277
+ for database in self._get_existing_databases():
1278
+ with self._connect_to_database(
1279
+ database=database
1280
+ ) as connection, connection.cursor() as cursor:
1281
+ cursor.execute(SQL(function_creation_statement))
1282
+ cursor.execute(
1283
+ SQL("ALTER FUNCTION set_up_predefined_catalog_roles OWNER TO operator;")
1284
+ )
1285
+ cursor.execute(
1286
+ SQL(
1287
+ "REVOKE EXECUTE ON FUNCTION set_up_predefined_catalog_roles FROM PUBLIC;"
1288
+ )
1289
+ )
1290
+ cursor.execute(
1291
+ SQL(
1292
+ "GRANT EXECUTE ON FUNCTION set_up_predefined_catalog_roles TO {};"
1293
+ ).format(Identifier(ROLE_DATABASES_OWNER))
1294
+ )
1295
+ cursor.execute(
1296
+ SQL("REVOKE CREATE ON DATABASE {} FROM {};").format(
1297
+ Identifier("template1"), Identifier(ROLE_DATABASES_OWNER)
1298
+ )
1299
+ )
1300
+ except psycopg2.Error as e:
1301
+ logger.error(f"Failed to set up predefined catalog roles function: {e}")
1302
+ raise PostgreSQLCreatePredefinedRolesError() from e
1303
+ finally:
1304
+ if connection:
1305
+ connection.close()
1306
+
1307
+ def update_user_password(
1308
+ self, username: str, password: str, database_host: Optional[str] = None
1309
+ ) -> None:
1310
+ """Update a user password.
1311
+
1312
+ Args:
1313
+ username: the user to update the password.
1314
+ password: the new password for the user.
1315
+ database_host: the host to connect to.
1316
+
1317
+ Raises:
1318
+ PostgreSQLUpdateUserPasswordError if the password couldn't be changed.
1319
+ """
1320
+ connection = None
1321
+ try:
1322
+ with self._connect_to_database(
1323
+ database_host=database_host
1324
+ ) as connection, connection.cursor() as cursor:
1325
+ cursor.execute(SQL("RESET ROLE;"))
1326
+ cursor.execute(SQL("BEGIN;"))
1327
+ cursor.execute(SQL("SET LOCAL log_statement = 'none';"))
1328
+ cursor.execute(
1329
+ SQL("ALTER USER {} WITH ENCRYPTED PASSWORD '" + password + "';").format(
1330
+ Identifier(username)
1331
+ )
1332
+ )
1333
+ cursor.execute(SQL("COMMIT;"))
1334
+ except psycopg2.Error as e:
1335
+ logger.error(f"Failed to update user password: {e}")
1336
+ raise PostgreSQLUpdateUserPasswordError() from e
1337
+ finally:
1338
+ if connection is not None:
1339
+ connection.close()
1340
+
1341
+ def database_exists(self, db: str) -> bool:
1342
+ """Check whether specified database exists."""
1343
+ connection = None
1344
+ try:
1345
+ connection = self._connect_to_database()
1346
+ with connection, connection.cursor() as cursor:
1347
+ cursor.execute(
1348
+ SQL("SELECT datname FROM pg_database WHERE datname={};").format(Literal(db))
1349
+ )
1350
+ return cursor.fetchone() is not None
1351
+ except psycopg2.Error as e:
1352
+ logger.error(f"Failed to check Postgresql database existence: {e}")
1353
+ raise PostgreSQLDatabaseExistsError() from e
1354
+ finally:
1355
+ if connection:
1356
+ connection.close()
1357
+
1358
+ def table_exists(self, db: str, schema: str, table: str) -> bool:
1359
+ """Check whether specified table in database exists."""
1360
+ connection = None
1361
+ try:
1362
+ connection = self._connect_to_database(database=db)
1363
+ with connection, connection.cursor() as cursor:
1364
+ cursor.execute(
1365
+ SQL(
1366
+ "SELECT tablename FROM pg_tables WHERE schemaname={} AND tablename={};"
1367
+ ).format(Literal(schema), Literal(table))
1368
+ )
1369
+ return cursor.fetchone() is not None
1370
+ except psycopg2.Error as e:
1371
+ logger.error(f"Failed to check Postgresql table existence: {e}")
1372
+ raise PostgreSQLTableExistsError() from e
1373
+ finally:
1374
+ if connection:
1375
+ connection.close()
1376
+
1377
+ def is_table_empty(self, db: str, schema: str, table: str) -> bool:
1378
+ """Check whether table is empty."""
1379
+ connection = None
1380
+ try:
1381
+ connection = self._connect_to_database(database=db)
1382
+ with connection, connection.cursor() as cursor:
1383
+ cursor.execute(SQL("SELECT COUNT(1) FROM {};").format(Identifier(schema, table)))
1384
+ if result := cursor.fetchone():
1385
+ return result[0] == 0
1386
+ return True
1387
+ except psycopg2.Error as e:
1388
+ logger.error(f"Failed to check whether table is empty: {e}")
1389
+ raise PostgreSQLIsTableEmptyError() from e
1390
+ finally:
1391
+ if connection:
1392
+ connection.close()
1393
+
1394
+ def create_publication(self, db: str, name: str, schematables: List[str]) -> None:
1395
+ """Create PostgreSQL publication."""
1396
+ connection = None
1397
+ try:
1398
+ connection = self._connect_to_database(database=db)
1399
+ with connection, connection.cursor() as cursor:
1400
+ cursor.execute(
1401
+ SQL("CREATE PUBLICATION {} FOR TABLE {};").format(
1402
+ Identifier(name),
1403
+ SQL(",").join(
1404
+ Identifier(schematable.split(".")[0], schematable.split(".")[1])
1405
+ for schematable in schematables
1406
+ ),
1407
+ )
1408
+ )
1409
+ except psycopg2.Error as e:
1410
+ logger.error(f"Failed to create Postgresql publication: {e}")
1411
+ raise PostgreSQLCreatePublicationError() from e
1412
+ finally:
1413
+ if connection:
1414
+ connection.close()
1415
+
1416
+ def publication_exists(self, db: str, publication: str) -> bool:
1417
+ """Check whether specified subscription in database exists."""
1418
+ connection = None
1419
+ try:
1420
+ connection = self._connect_to_database(database=db)
1421
+ with connection, connection.cursor() as cursor:
1422
+ cursor.execute(
1423
+ SQL("SELECT pubname FROM pg_publication WHERE pubname={};").format(
1424
+ Literal(publication)
1425
+ )
1426
+ )
1427
+ return cursor.fetchone() is not None
1428
+ except psycopg2.Error as e:
1429
+ logger.error(f"Failed to check Postgresql publication existence: {e}")
1430
+ raise PostgreSQLPublicationExistsError() from e
1431
+ finally:
1432
+ if connection:
1433
+ connection.close()
1434
+
1435
+ def alter_publication(self, db: str, name: str, schematables: List[str]) -> None:
1436
+ """Alter PostgreSQL publication."""
1437
+ connection = None
1438
+ try:
1439
+ connection = self._connect_to_database(database=db)
1440
+ with connection, connection.cursor() as cursor:
1441
+ cursor.execute(
1442
+ SQL("ALTER PUBLICATION {} SET TABLE {};").format(
1443
+ Identifier(name),
1444
+ SQL(",").join(
1445
+ Identifier(schematable.split(".")[0], schematable.split(".")[1])
1446
+ for schematable in schematables
1447
+ ),
1448
+ )
1449
+ )
1450
+ except psycopg2.Error as e:
1451
+ logger.error(f"Failed to alter Postgresql publication: {e}")
1452
+ raise PostgreSQLAlterPublicationError() from e
1453
+ finally:
1454
+ if connection:
1455
+ connection.close()
1456
+
1457
+ def drop_publication(self, db: str, publication: str) -> None:
1458
+ """Drop PostgreSQL publication."""
1459
+ connection = None
1460
+ try:
1461
+ connection = self._connect_to_database(database=db)
1462
+ with connection, connection.cursor() as cursor:
1463
+ cursor.execute(
1464
+ SQL("DROP PUBLICATION IF EXISTS {};").format(
1465
+ Identifier(publication),
1466
+ )
1467
+ )
1468
+ except psycopg2.Error as e:
1469
+ logger.error(f"Failed to drop Postgresql publication: {e}")
1470
+ raise PostgreSQLDropPublicationError() from e
1471
+ finally:
1472
+ if connection:
1473
+ connection.close()
1474
+
1475
+ def create_subscription(
1476
+ self,
1477
+ subscription: str,
1478
+ host: str,
1479
+ db: str,
1480
+ user: str,
1481
+ password: str,
1482
+ publication: str,
1483
+ replication_slot: str,
1484
+ ) -> None:
1485
+ """Create PostgreSQL subscription."""
1486
+ connection = None
1487
+ try:
1488
+ connection = self._connect_to_database(database=db)
1489
+ with connection, connection.cursor() as cursor:
1490
+ cursor.execute(
1491
+ SQL(
1492
+ "CREATE SUBSCRIPTION {} CONNECTION {} PUBLICATION {} WITH (copy_data=true,create_slot=false,enabled=true,slot_name={});"
1493
+ ).format(
1494
+ Identifier(subscription),
1495
+ Literal(f"host={host} dbname={db} user={user} password={password}"),
1496
+ Identifier(publication),
1497
+ Identifier(replication_slot),
1498
+ )
1499
+ )
1500
+ except psycopg2.Error as e:
1501
+ logger.error(f"Failed to create Postgresql subscription: {e}")
1502
+ raise PostgreSQLCreateSubscriptionError() from e
1503
+ finally:
1504
+ if connection:
1505
+ connection.close()
1506
+
1507
+ def subscription_exists(self, db: str, subscription: str) -> bool:
1508
+ """Check whether specified subscription in database exists."""
1509
+ connection = None
1510
+ try:
1511
+ connection = self._connect_to_database(database=db)
1512
+ with connection, connection.cursor() as cursor:
1513
+ cursor.execute(
1514
+ SQL("SELECT subname FROM pg_subscription WHERE subname={};").format(
1515
+ Literal(subscription)
1516
+ )
1517
+ )
1518
+ return cursor.fetchone() is not None
1519
+ except psycopg2.Error as e:
1520
+ logger.error(f"Failed to check Postgresql subscription existence: {e}")
1521
+ raise PostgreSQLSubscriptionExistsError() from e
1522
+ finally:
1523
+ if connection:
1524
+ connection.close()
1525
+
1526
+ def update_subscription(self, db: str, subscription: str, host: str, user: str, password: str):
1527
+ """Update PostgreSQL subscription connection details."""
1528
+ connection = None
1529
+ try:
1530
+ connection = self._connect_to_database(database=db)
1531
+ with connection, connection.cursor() as cursor:
1532
+ cursor.execute(
1533
+ SQL("ALTER SUBSCRIPTION {} CONNECTION {}").format(
1534
+ Identifier(subscription),
1535
+ Literal(f"host={host} dbname={db} user={user} password={password}"),
1536
+ )
1537
+ )
1538
+ except psycopg2.Error as e:
1539
+ logger.error(f"Failed to update Postgresql subscription: {e}")
1540
+ raise PostgreSQLUpdateSubscriptionError() from e
1541
+ finally:
1542
+ if connection:
1543
+ connection.close()
1544
+
1545
+ def refresh_subscription(self, db: str, subscription: str):
1546
+ """Refresh PostgreSQL subscription to pull publication changes."""
1547
+ connection = None
1548
+ try:
1549
+ connection = self._connect_to_database(database=db)
1550
+ with connection.cursor() as cursor:
1551
+ cursor.execute(
1552
+ SQL("ALTER SUBSCRIPTION {} REFRESH PUBLICATION").format(
1553
+ Identifier(subscription)
1554
+ )
1555
+ )
1556
+ except psycopg2.Error as e:
1557
+ logger.error(f"Failed to refresh Postgresql subscription: {e}")
1558
+ raise PostgreSQLRefreshSubscriptionError() from e
1559
+ finally:
1560
+ if connection:
1561
+ connection.close()
1562
+
1563
+ def drop_subscription(self, db: str, subscription: str) -> None:
1564
+ """Drop PostgreSQL subscription."""
1565
+ connection = None
1566
+ try:
1567
+ connection = self._connect_to_database(database=db)
1568
+ with connection, connection.cursor() as cursor:
1569
+ cursor.execute(
1570
+ SQL("ALTER SUBSCRIPTION {} DISABLE;").format(
1571
+ Identifier(subscription),
1572
+ )
1573
+ )
1574
+ cursor.execute(
1575
+ SQL("ALTER SUBSCRIPTION {} SET (slot_name=NONE);").format(
1576
+ Identifier(subscription),
1577
+ )
1578
+ )
1579
+ cursor.execute(
1580
+ SQL("DROP SUBSCRIPTION {};").format(
1581
+ Identifier(subscription),
1582
+ )
1583
+ )
1584
+ except psycopg2.Error as e:
1585
+ logger.error(f"Failed to drop Postgresql subscription: {e}")
1586
+ raise PostgreSQLDropSubscriptionError() from e
1587
+ finally:
1588
+ if connection:
1589
+ connection.close()
1590
+
1591
+ @staticmethod
1592
+ def build_postgresql_group_map(group_map: Optional[str]) -> List[Tuple]:
1593
+ """Build the PostgreSQL authorization group-map.
1594
+
1595
+ Args:
1596
+ group_map: serialized group-map with the following format:
1597
+ <ldap_group_1>=<psql_group_1>,
1598
+ <ldap_group_2>=<psql_group_2>,
1599
+ ...
1600
+
1601
+ Returns:
1602
+ List of LDAP group to PostgreSQL group tuples.
1603
+ """
1604
+ if group_map is None:
1605
+ return []
1606
+
1607
+ group_mappings = group_map.split(",")
1608
+ group_mappings = (mapping.strip() for mapping in group_mappings)
1609
+ group_map_list = []
1610
+
1611
+ for mapping in group_mappings:
1612
+ mapping_parts = mapping.split("=")
1613
+ if len(mapping_parts) != 2:
1614
+ raise ValueError("The group-map must contain value pairs split by commas")
1615
+
1616
+ ldap_group = mapping_parts[0]
1617
+ psql_group = mapping_parts[1]
1618
+
1619
+ if psql_group in ACCESS_GROUPS:
1620
+ logger.warning(f"Tried to assign LDAP users to forbidden group: {psql_group}")
1621
+ continue
1622
+
1623
+ group_map_list.append((ldap_group, psql_group))
1624
+
1625
+ return group_map_list
1626
+
1627
+ @staticmethod
1628
+ def build_postgresql_parameters(
1629
+ config_options: ConfigData, available_memory: int, limit_memory: Optional[int] = None
1630
+ ) -> Optional[dict]:
1631
+ """Builds the PostgreSQL parameters.
1632
+
1633
+ Args:
1634
+ config_options: charm config options containing profile and PostgreSQL parameters.
1635
+ available_memory: available memory to use in calculation in bytes.
1636
+ limit_memory: (optional) limit memory to use in calculation in bytes.
1637
+
1638
+ Returns:
1639
+ Dictionary with the PostgreSQL parameters.
1640
+ """
1641
+ if limit_memory:
1642
+ available_memory = min(available_memory, limit_memory)
1643
+ profile = config_options["profile"]
1644
+ logger.debug(f"Building PostgreSQL parameters for {profile=} and {available_memory=}")
1645
+ parameters = {}
1646
+ for config, value in config_options.items():
1647
+ # Filter config option not related to PostgreSQL parameters.
1648
+ if not config.startswith((
1649
+ "connection",
1650
+ "cpu",
1651
+ "durability",
1652
+ "instance",
1653
+ "logging",
1654
+ "memory",
1655
+ "optimizer",
1656
+ "request",
1657
+ "response",
1658
+ "session",
1659
+ "storage",
1660
+ "vacuum",
1661
+ )):
1662
+ continue
1663
+ parameter = "_".join(config.split("_")[1:])
1664
+ if parameter in ["date_style", "time_zone"]:
1665
+ parameter = "".join(x.capitalize() for x in parameter.split("_"))
1666
+ parameters[parameter] = value
1667
+ shared_buffers_max_value_in_mb = int(available_memory * 0.4 / 10**6)
1668
+ shared_buffers_max_value = int(shared_buffers_max_value_in_mb * 10**3 / 8)
1669
+ if parameters.get("shared_buffers", 0) > shared_buffers_max_value:
1670
+ raise Exception(
1671
+ f"Shared buffers config option should be at most 40% of the available memory, which is {shared_buffers_max_value_in_mb}MB"
1672
+ )
1673
+ if profile == "production":
1674
+ if "shared_buffers" in parameters:
1675
+ # Convert to bytes to use in the calculation.
1676
+ shared_buffers = parameters["shared_buffers"] * 8 * 10**3
1677
+ else:
1678
+ # Use 25% of the available memory for shared_buffers.
1679
+ # and the remaining as cache memory.
1680
+ shared_buffers = int(available_memory * 0.25)
1681
+ parameters["shared_buffers"] = f"{int(shared_buffers * 128 / 10**6)}"
1682
+ effective_cache_size = int(available_memory - shared_buffers)
1683
+ parameters.update({
1684
+ "effective_cache_size": f"{int(effective_cache_size / 10**6) * 128}"
1685
+ })
1686
+ return parameters
1687
+
1688
+ def validate_date_style(self, date_style: str) -> bool:
1689
+ """Validate a date style against PostgreSQL.
1690
+
1691
+ Returns:
1692
+ Whether the date style is valid.
1693
+ """
1694
+ try:
1695
+ with self._connect_to_database(
1696
+ database_host=self.current_host
1697
+ ) as connection, connection.cursor() as cursor:
1698
+ cursor.execute(
1699
+ SQL(
1700
+ "SET DateStyle to {};",
1701
+ ).format(Identifier(date_style))
1702
+ )
1703
+ return True
1704
+ except psycopg2.Error:
1705
+ return False
1706
+
1707
+ def validate_group_map(self, group_map: Optional[str]) -> bool:
1708
+ """Validate the PostgreSQL authorization group-map.
1709
+
1710
+ Args:
1711
+ group_map: serialized group-map with the following format:
1712
+ <ldap_group_1>=<psql_group_1>,
1713
+ <ldap_group_2>=<psql_group_2>,
1714
+ ...
1715
+
1716
+ Returns:
1717
+ Whether the group-map is valid.
1718
+ """
1719
+ if group_map is None:
1720
+ return True
1721
+
1722
+ try:
1723
+ parsed_group_map = self.build_postgresql_group_map(group_map)
1724
+ except ValueError:
1725
+ return False
1726
+
1727
+ for _, psql_group in parsed_group_map:
1728
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
1729
+ query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")
1730
+ query = query.format(Literal(psql_group))
1731
+ cursor.execute(query)
1732
+
1733
+ if cursor.fetchone() is None:
1734
+ return False
1735
+
1736
+ return True
1737
+
1738
+ def is_user_in_hba(self, username: str) -> bool:
1739
+ """Check if user was added in pg_hba."""
1740
+ connection = None
1741
+ try:
1742
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
1743
+ cursor.execute(
1744
+ SQL(
1745
+ "SELECT COUNT(*) FROM pg_hba_file_rules WHERE {} = ANY(user_name);"
1746
+ ).format(Literal(username))
1747
+ )
1748
+ if result := cursor.fetchone():
1749
+ return result[0] > 0
1750
+ return False
1751
+ except psycopg2.Error as e:
1752
+ logger.debug(f"Failed to check pg_hba: {e}")
1753
+ return False
1754
+ finally:
1755
+ if connection:
1756
+ connection.close()
1757
+
1758
+ def drop_hba_triggers(self) -> None:
1759
+ """Drop pg_hba triggers on schema change."""
1760
+ try:
1761
+ with self._connect_to_database() as connection, connection.cursor() as cursor:
1762
+ cursor.execute(
1763
+ SQL(
1764
+ "SELECT datname FROM pg_database WHERE datname <> 'template0' AND datname <>'postgres';"
1765
+ )
1766
+ )
1767
+ databases = [row[0] for row in cursor.fetchall()]
1768
+ except psycopg2.Error as e:
1769
+ logger.warning(f"Failed to get databases when removing hba trigger: {e}")
1770
+ return
1771
+ finally:
1772
+ if connection:
1773
+ connection.close()
1774
+
1775
+ # Existing objects need to be reassigned in each database
1776
+ # before the user can be deleted.
1777
+
1778
+ for database in databases:
1779
+ try:
1780
+ with self._connect_to_database(
1781
+ database
1782
+ ) as connection, connection.cursor() as cursor:
1783
+ cursor.execute(
1784
+ SQL("DROP EVENT TRIGGER IF EXISTS update_pg_hba_on_create_schema;")
1785
+ )
1786
+ cursor.execute(
1787
+ SQL("DROP EVENT TRIGGER IF EXISTS update_pg_hba_on_drop_schema;")
1788
+ )
1789
+ except psycopg2.Error as e:
1790
+ logger.warning(f"Failed to remove hba trigger for {database}: {e}")
1791
+ finally:
1792
+ if connection:
1793
+ connection.close()