Files
patroni/patroni/postgresql/config.py
T
Polina BunginaandGitHub 266cdc4810 Fixes around pending_restart flag (#3003)
* Do not set pending_restart flag if hot_standby is set to 'off' during a custom bootstrap (even though we will have this flag actually set in PG, this configuration parameter is irrelevant on primary and there is no actual need for restart)
* Skip hot_standby and wal_log_hints when querying parameters pending restart on config reload. They actually can be changed manually (e.g. via ALTER SYSTEM) and it will cause the pending_restart state in PG but Patroni anyway always passes those params to postmaster as command line options. And there they only can have one value - 'on' (except on primary when performing custom bootstrap)
2024-01-16 10:32:28 +01:00

1287 lines
63 KiB
Python

import logging
import os
import re
import shutil
import socket
import stat
import time
from contextlib import contextmanager
from urllib.parse import urlparse, parse_qsl, unquote
from types import TracebackType
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
from .. import global_config
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException, PostgresConnectionException
from ..file_perm import pg_perm
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
from ..validator import IntValidator, EnumValidator
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
logger = logging.getLogger(__name__)
PARAMETER_RE = re.compile(r'([a-z_]+)\s*=\s*')
def conninfo_uri_parse(dsn: str) -> Dict[str, str]:
ret: Dict[str, str] = {}
r = urlparse(dsn)
if r.username:
ret['user'] = r.username
if r.password:
ret['password'] = r.password
if r.path[1:]:
ret['dbname'] = r.path[1:]
hosts: List[str] = []
ports: List[str] = []
for netloc in r.netloc.split('@')[-1].split(','):
host = None
if '[' in netloc and ']' in netloc:
tmp = netloc.split(']') + ['']
host = tmp[0][1:]
netloc = ':'.join(tmp[:2])
tmp = netloc.rsplit(':', 1)
if host is None:
host = tmp[0]
hosts.append(host)
ports.append(tmp[1] if len(tmp) == 2 else '')
if hosts:
ret['host'] = ','.join(hosts)
if ports:
ret['port'] = ','.join(ports)
ret = {name: unquote(value) for name, value in ret.items()}
ret.update({name: value for name, value in parse_qsl(r.query)})
if ret.get('ssl') == 'true':
del ret['ssl']
ret['sslmode'] = 'require'
return ret
def read_param_value(value: str) -> Union[Tuple[None, None], Tuple[str, int]]:
length = len(value)
ret = ''
is_quoted = value[0] == "'"
i = int(is_quoted)
while i < length:
if is_quoted:
if value[i] == "'":
return ret, i + 1
elif value[i].isspace():
break
if value[i] == '\\':
i += 1
if i >= length:
break
ret += value[i]
i += 1
return (None, None) if is_quoted else (ret, i)
def conninfo_parse(dsn: str) -> Optional[Dict[str, str]]:
ret: Dict[str, str] = {}
length = len(dsn)
i = 0
while i < length:
if dsn[i].isspace():
i += 1
continue
param_match = PARAMETER_RE.match(dsn[i:])
if not param_match:
return
param = param_match.group(1)
i += param_match.end()
if i >= length:
return
value, end = read_param_value(dsn[i:])
if value is None or end is None:
return
i += end
ret[param] = value
return ret
def parse_dsn(value: str) -> Optional[Dict[str, str]]:
"""
Very simple equivalent of `psycopg2.extensions.parse_dsn` introduced in 2.7.0.
We are not using psycopg2 function in order to remain compatible with 2.5.4+.
There is one minor difference though, this function removes `dbname` from the result
and sets the `sslmode`, 'gssencmode', and `channel_binding` to `prefer` if it is not present in
the connection string. This is necessary to simplify comparison of the old and the new values.
>>> r = parse_dsn('postgresql://u%2Fse:pass@:%2f123,[::1]/db%2Fsdf?application_name=mya%2Fpp&ssl=true')
>>> r == {'application_name': 'mya/pp', 'host': ',::1', 'sslmode': 'require',\
'password': 'pass', 'port': '/123,', 'user': 'u/se', 'gssencmode': 'prefer', 'channel_binding': 'prefer'}
True
>>> r = parse_dsn(" host = 'host' dbname = db\\\\ name requiressl=1 ")
>>> r == {'host': 'host', 'sslmode': 'require', 'gssencmode': 'prefer', 'channel_binding': 'prefer'}
True
>>> parse_dsn('requiressl = 0\\\\') == {'sslmode': 'prefer', 'gssencmode': 'prefer', 'channel_binding': 'prefer'}
True
>>> parse_dsn("host=a foo = '") is None
True
>>> parse_dsn("host=a foo = ") is None
True
>>> parse_dsn("1") is None
True
"""
if value.startswith('postgres://') or value.startswith('postgresql://'):
ret = conninfo_uri_parse(value)
else:
ret = conninfo_parse(value)
if ret:
if 'sslmode' not in ret: # allow sslmode to take precedence over requiressl
requiressl = ret.pop('requiressl', None)
if requiressl == '1':
ret['sslmode'] = 'require'
elif requiressl is not None:
ret['sslmode'] = 'prefer'
ret.setdefault('sslmode', 'prefer')
if 'dbname' in ret:
del ret['dbname']
ret.setdefault('gssencmode', 'prefer')
ret.setdefault('channel_binding', 'prefer')
return ret
def strip_comment(value: str) -> str:
i = value.find('#')
if i > -1:
value = value[:i].strip()
return value
def read_recovery_param_value(value: str) -> Optional[str]:
"""
>>> read_recovery_param_value('') is None
True
>>> read_recovery_param_value("'") is None
True
>>> read_recovery_param_value("''a") is None
True
>>> read_recovery_param_value('a b') is None
True
>>> read_recovery_param_value("'''") is None
True
>>> read_recovery_param_value("'\\\\") is None
True
>>> read_recovery_param_value("'a' s#") is None
True
>>> read_recovery_param_value("'\\\\'''' #a")
"''"
>>> read_recovery_param_value('asd')
'asd'
"""
value = value.strip()
length = len(value)
if length == 0:
return None
elif value[0] == "'":
if length == 1:
return None
ret = ''
i = 1
while i < length:
if value[i] == '\\':
i += 1
if i >= length:
return None
elif value[i] == "'":
i += 1
if i >= length:
break
if value[i] in ('#', ' '):
if strip_comment(value[i:]):
return None
break
if value[i] != "'":
return None
ret += value[i]
i += 1
else:
return None
return ret
else:
value = strip_comment(value)
if not value or ' ' in value or '\\' in value:
return None
return value
def mtime(filename: str) -> Optional[float]:
try:
return os.stat(filename).st_mtime
except OSError:
return None
class ConfigWriter(object):
def __init__(self, filename: str) -> None:
self._filename = filename
self._fd = None
def __enter__(self) -> 'ConfigWriter':
self._fd = open(self._filename, 'w')
self.writeline('# Do not edit this file manually!\n# It will be overwritten by Patroni!')
return self
def __exit__(self, exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
if self._fd:
self._fd.close()
def writeline(self, line: str) -> None:
if self._fd:
self._fd.write(line)
self._fd.write('\n')
def writelines(self, lines: List[Optional[str]]) -> None:
for line in lines:
if isinstance(line, str):
self.writeline(line)
@staticmethod
def escape(value: Any) -> str: # Escape (by doubling) any single quotes or backslashes in given string
return re.sub(r'([\'\\])', r'\1\1', str(value))
def write_param(self, param: str, value: Any) -> None:
self.writeline("{0} = '{1}'".format(param, self.escape(value)))
def _false_validator(value: Any) -> bool:
return False
def _bool_validator(value: Any) -> bool:
return parse_bool(value) is not None
def _bool_is_true_validator(value: Any) -> bool:
return parse_bool(value) is True
class ConfigHandler(object):
# List of parameters which must be always passed to postmaster as command line options
# to make it not possible to change them with 'ALTER SYSTEM'.
# Some of these parameters have sane default value assigned and Patroni doesn't allow
# to decrease this value. E.g. 'wal_level' can't be lower then 'hot_standby' and so on.
# These parameters could be changed only globally, i.e. via DCS.
# P.S. 'listen_addresses' and 'port' are added here just for convenience, to mark them
# as a parameters which should always be passed through command line.
#
# Format:
# key - parameter name
# value - tuple(default_value, check_function, min_version)
# default_value -- some sane default value
# check_function -- if the new value is not correct must return `!False`
# min_version -- major version of PostgreSQL when parameter was introduced
CMDLINE_OPTIONS = CaseInsensitiveDict({
'listen_addresses': (None, _false_validator, 90100),
'port': (None, _false_validator, 90100),
'cluster_name': (None, _false_validator, 90500),
'wal_level': ('hot_standby', EnumValidator(('hot_standby', 'replica', 'logical')), 90100),
'hot_standby': ('on', _bool_is_true_validator, 90100),
'max_connections': (100, IntValidator(min=25), 90100),
'max_wal_senders': (10, IntValidator(min=3), 90100),
'wal_keep_segments': (8, IntValidator(min=1), 90100),
'wal_keep_size': ('128MB', IntValidator(min=16, base_unit='MB'), 130000),
'max_prepared_transactions': (0, IntValidator(min=0), 90100),
'max_locks_per_transaction': (64, IntValidator(min=32), 90100),
'track_commit_timestamp': ('off', _bool_validator, 90500),
'max_replication_slots': (10, IntValidator(min=4), 90400),
'max_worker_processes': (8, IntValidator(min=2), 90400),
'wal_log_hints': ('on', _bool_is_true_validator, 90400)
})
_RECOVERY_PARAMETERS = CaseInsensitiveSet(recovery_parameters.keys())
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Any]) -> None:
self._postgresql = postgresql
self._config_dir = os.path.abspath(config.get('config_dir', '') or postgresql.data_dir)
config_base_name = config.get('config_base_name', 'postgresql')
self._postgresql_conf = os.path.join(self._config_dir, config_base_name + '.conf')
self._postgresql_conf_mtime = None
self._postgresql_base_conf_name = config_base_name + '.base.conf'
self._postgresql_base_conf = os.path.join(self._config_dir, self._postgresql_base_conf_name)
self._pg_hba_conf = os.path.join(self._config_dir, 'pg_hba.conf')
self._pg_ident_conf = os.path.join(self._config_dir, 'pg_ident.conf')
self._recovery_conf = os.path.join(postgresql.data_dir, 'recovery.conf')
self._recovery_conf_mtime = None
self._recovery_signal = os.path.join(postgresql.data_dir, 'recovery.signal')
self._standby_signal = os.path.join(postgresql.data_dir, 'standby.signal')
self._auto_conf = os.path.join(postgresql.data_dir, 'postgresql.auto.conf')
self._auto_conf_mtime = None
self._pgpass = os.path.abspath(config.get('pgpass') or os.path.join(os.path.expanduser('~'), 'pgpass'))
if os.path.exists(self._pgpass) and not os.path.isfile(self._pgpass):
raise PatroniFatalException("'{0}' exists and it's not a file, check your `postgresql.pgpass` configuration"
.format(self._pgpass))
self._passfile = None
self._passfile_mtime = None
self._postmaster_ctime = None
self._current_recovery_params: Optional[CaseInsensitiveDict] = None
self._config = {}
self._recovery_params = CaseInsensitiveDict()
self._server_parameters: CaseInsensitiveDict = CaseInsensitiveDict()
self.reload_config(config)
def load_current_server_parameters(self) -> None:
"""Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running."""
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator]
keep_values = {k: self._server_parameters[k] for k in exclude}
server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
"SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings"
" WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)"
" AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)})
recovery_params = CaseInsensitiveDict({k: server_parameters.pop(k) for k in self._RECOVERY_PARAMETERS
if k in server_parameters})
# We also want to load current settings of recovery parameters, including primary_conninfo
# and primary_slot_name, otherwise patronictl restart will update postgresql.conf
# and remove them, what in the worst case will cause another restart.
# We are doing it only for PostgresSQL v12 onwards, because older version still have recovery.conf
if not self._postgresql.is_primary() and self._postgresql.major_version >= 120000:
# primary_conninfo is expected to be a dict, therefore we need to parse it
recovery_params['primary_conninfo'] = parse_dsn(recovery_params.pop('primary_conninfo', '')) or {}
self._recovery_params = recovery_params
self._server_parameters = CaseInsensitiveDict({**server_parameters, **keep_values})
def setup_server_parameters(self) -> None:
self._server_parameters = self.get_server_parameters(self._config)
self._adjust_recovery_parameters()
def try_to_create_dir(self, d: str, msg: str) -> None:
d = os.path.join(self._postgresql.data_dir, d)
if (not is_subpath(self._postgresql.data_dir, d) or not self._postgresql.data_directory_empty()):
validate_directory(d, msg)
def check_directories(self) -> None:
if "unix_socket_directories" in self._server_parameters:
for d in self._server_parameters["unix_socket_directories"].split(","):
self.try_to_create_dir(d.strip(), "'{}' is defined in unix_socket_directories, {}")
if "stats_temp_directory" in self._server_parameters:
self.try_to_create_dir(self._server_parameters["stats_temp_directory"],
"'{}' is defined in stats_temp_directory, {}")
if not self._krbsrvname:
self.try_to_create_dir(os.path.dirname(self._pgpass),
"'{}' is defined in `postgresql.pgpass`, {}")
@property
def config_dir(self) -> str:
return self._config_dir
@property
def _configuration_to_save(self) -> List[str]:
configuration = [os.path.basename(self._postgresql_conf)]
if 'custom_conf' not in self._config:
configuration.append(os.path.basename(self._postgresql_base_conf_name))
if not self.hba_file:
configuration.append('pg_hba.conf')
if not self.ident_file:
configuration.append('pg_ident.conf')
return configuration
def set_file_permissions(self, filename: str) -> None:
"""Set permissions of file *filename* according to the expected permissions if it resides under PGDATA.
.. note::
Do nothing if the file is not under PGDATA.
:param filename: path to a file which permissions might need to be adjusted.
"""
if is_subpath(self._postgresql.data_dir, filename):
pg_perm.set_permissions_from_data_directory(self._postgresql.data_dir)
os.chmod(filename, pg_perm.file_create_mode)
@contextmanager
def config_writer(self, filename: str) -> Iterator[ConfigWriter]:
"""Create :class:`ConfigWriter` object and set permissions on a *filename*.
:param filename: path to a config file.
:yields: :class:`ConfigWriter` object.
"""
with ConfigWriter(filename) as writer:
yield writer
self.set_file_permissions(filename)
def save_configuration_files(self, check_custom_bootstrap: bool = False) -> bool:
"""
copy postgresql.conf to postgresql.conf.backup to be able to retrieve configuration files
- originally stored as symlinks, those are normally skipped by pg_basebackup
- in case of WAL-E basebackup (see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239)
"""
if not (check_custom_bootstrap and self._postgresql.bootstrap.running_custom_bootstrap):
try:
for f in self._configuration_to_save:
config_file = os.path.join(self._config_dir, f)
backup_file = os.path.join(self._postgresql.data_dir, f + '.backup')
if os.path.isfile(config_file):
shutil.copy(config_file, backup_file)
self.set_file_permissions(backup_file)
except IOError:
logger.exception('unable to create backup copies of configuration files')
return True
def restore_configuration_files(self) -> None:
""" restore a previously saved postgresql.conf """
try:
for f in self._configuration_to_save:
config_file = os.path.join(self._config_dir, f)
backup_file = os.path.join(self._postgresql.data_dir, f + '.backup')
if not os.path.isfile(config_file):
if os.path.isfile(backup_file):
shutil.copy(backup_file, config_file)
self.set_file_permissions(config_file)
# Previously we didn't backup pg_ident.conf, if file is missing just create empty
elif f == 'pg_ident.conf':
open(config_file, 'w').close()
self.set_file_permissions(config_file)
except IOError:
logger.exception('unable to restore configuration files from backup')
def write_postgresql_conf(self, configuration: Optional[CaseInsensitiveDict] = None) -> None:
# rename the original configuration if it is necessary
if 'custom_conf' not in self._config and not os.path.exists(self._postgresql_base_conf):
os.rename(self._postgresql_conf, self._postgresql_base_conf)
configuration = configuration or self._server_parameters.copy()
# Due to the permanent logical replication slots configured we have to enable hot_standby_feedback
if self._postgresql.enforce_hot_standby_feedback:
configuration['hot_standby_feedback'] = 'on'
with self.config_writer(self._postgresql_conf) as f:
include = self._config.get('custom_conf') or self._postgresql_base_conf_name
f.writeline("include '{0}'\n".format(ConfigWriter.escape(include)))
for name, value in sorted((configuration).items()):
value = transform_postgresql_parameter_value(self._postgresql.major_version, name, value,
self._postgresql.available_gucs)
if value is not None and\
(name != 'hba_file' or not self._postgresql.bootstrap.running_custom_bootstrap):
f.write_param(name, value)
# when we are doing custom bootstrap we assume that we don't know superuser password
# and in order to be able to change it, we are opening trust access from a certain address
# therefore we need to make sure that hba_file is not overridden
# after changing superuser password we will "revert" all these "changes"
if self._postgresql.bootstrap.running_custom_bootstrap or 'hba_file' not in self._server_parameters:
f.write_param('hba_file', self._pg_hba_conf)
if 'ident_file' not in self._server_parameters:
f.write_param('ident_file', self._pg_ident_conf)
if self._postgresql.major_version >= 120000:
if self._recovery_params:
f.writeline('\n# recovery.conf')
self._write_recovery_params(f, self._recovery_params)
if not self._postgresql.bootstrap.keep_existing_recovery_conf:
self._sanitize_auto_conf()
def append_pg_hba(self, config: List[str]) -> bool:
if not self.hba_file and not self._config.get('pg_hba'):
with open(self._pg_hba_conf, 'a') as f:
f.write('\n{}\n'.format('\n'.join(config)))
self.set_file_permissions(self._pg_hba_conf)
return True
def replace_pg_hba(self) -> Optional[bool]:
"""
Replace pg_hba.conf content in the PGDATA if hba_file is not defined in the
`postgresql.parameters` and pg_hba is defined in `postgresql` configuration section.
:returns: True if pg_hba.conf was rewritten.
"""
# when we are doing custom bootstrap we assume that we don't know superuser password
# and in order to be able to change it, we are opening trust access from a certain address
if self._postgresql.bootstrap.running_custom_bootstrap:
addresses = {} if os.name == 'nt' else {'': 'local'} # windows doesn't yet support unix-domain sockets
if 'host' in self.local_replication_address and not self.local_replication_address['host'].startswith('/'):
addresses.update({sa[0] + '/32': 'host' for _, _, _, _, sa in socket.getaddrinfo(
self.local_replication_address['host'], self.local_replication_address['port'],
0, socket.SOCK_STREAM, socket.IPPROTO_TCP)})
with self.config_writer(self._pg_hba_conf) as f:
for address, t in addresses.items():
f.writeline((
'{0}\treplication\t{1}\t{3}\ttrust\n'
'{0}\tall\t{2}\t{3}\ttrust'
).format(t, self.replication['username'], self._superuser.get('username') or 'all', address))
elif not self.hba_file and self._config.get('pg_hba'):
with self.config_writer(self._pg_hba_conf) as f:
f.writelines(self._config['pg_hba'])
return True
def replace_pg_ident(self) -> Optional[bool]:
"""
Replace pg_ident.conf content in the PGDATA if ident_file is not defined in the
`postgresql.parameters` and pg_ident is defined in the `postgresql` section.
:returns: True if pg_ident.conf was rewritten.
"""
if not self.ident_file and self._config.get('pg_ident'):
with self.config_writer(self._pg_ident_conf) as f:
f.writelines(self._config['pg_ident'])
return True
def primary_conninfo_params(self, member: Union[Leader, Member, None]) -> Optional[Dict[str, Any]]:
if not member or not member.conn_url or member.name == self._postgresql.name:
return None
ret = member.conn_kwargs(self.replication)
ret['application_name'] = self._postgresql.name
ret.setdefault('sslmode', 'prefer')
if self._postgresql.major_version >= 120000:
ret.setdefault('gssencmode', 'prefer')
if self._postgresql.major_version >= 130000:
ret.setdefault('channel_binding', 'prefer')
if self._krbsrvname:
ret['krbsrvname'] = self._krbsrvname
if 'dbname' in ret:
del ret['dbname']
return ret
def format_dsn(self, params: Dict[str, Any], include_dbname: bool = False) -> str:
# A list of keywords that can be found in a conninfo string. Follows what is acceptable by libpq
keywords = ('dbname', 'user', 'passfile' if params.get('passfile') else 'password', 'host', 'port',
'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslpassword', 'sslrootcert', 'sslcrl',
'sslcrldir', 'application_name', 'krbsrvname', 'gssencmode', 'channel_binding',
'target_session_attrs')
if include_dbname:
params = params.copy()
if 'dbname' not in params:
params['dbname'] = self._postgresql.database
# we are abusing information about the necessity of dbname
# dsn should contain passfile or password only if there is no dbname in it (it is used in recovery.conf)
skip = {'passfile', 'password'}
else:
skip = {'dbname'}
def escape(value: Any) -> str:
return re.sub(r'([\'\\ ])', r'\\\1', str(value))
return ' '.join('{0}={1}'.format(kw, escape(params[kw])) for kw in keywords
if kw not in skip and params.get(kw) is not None)
def _write_recovery_params(self, fd: ConfigWriter, recovery_params: CaseInsensitiveDict) -> None:
if self._postgresql.major_version >= 90500:
pause_at_recovery_target = parse_bool(recovery_params.pop('pause_at_recovery_target', None))
if pause_at_recovery_target is not None:
recovery_params.setdefault('recovery_target_action', 'pause' if pause_at_recovery_target else 'promote')
else:
if str(recovery_params.pop('recovery_target_action', None)).lower() == 'promote':
recovery_params.setdefault('pause_at_recovery_target', 'false')
for name, value in sorted(recovery_params.items()):
if name == 'primary_conninfo':
if 'password' in value and self._postgresql.major_version >= 100000:
self.write_pgpass(value)
value['passfile'] = self._passfile = self._pgpass
self._passfile_mtime = mtime(self._pgpass)
value = self.format_dsn(value)
else:
value = transform_recovery_parameter_value(self._postgresql.major_version, name, value,
self._postgresql.available_gucs)
if value is None:
continue
fd.write_param(name, value)
def build_recovery_params(self, member: Union[Leader, Member, None]) -> CaseInsensitiveDict:
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or {}).items()
if not p.lower().startswith('recovery_target')
and p.lower() not in ('primary_conninfo', 'primary_slot_name')})
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
if self._postgresql.major_version >= 120000:
# on pg12 we want to protect from following params being set in one of included files
# not doing so might result in a standby being paused, promoted or shutted down.
recovery_params.update({'recovery_target': '', 'recovery_target_name': '', 'recovery_target_time': '',
'recovery_target_xid': '', 'recovery_target_lsn': ''})
is_remote_member = isinstance(member, RemoteMember)
primary_conninfo = self.primary_conninfo_params(member)
if primary_conninfo:
use_slots = global_config.use_slots and self._postgresql.major_version >= 90400
if use_slots and not (is_remote_member and member.no_replication_slot):
primary_slot_name = member.primary_slot_name if is_remote_member else self._postgresql.name
recovery_params['primary_slot_name'] = slot_name_from_member_name(primary_slot_name)
# We are a standby leader and are using a replication slot. Make sure we connect to
# the leader of the main cluster (in case more than one host is specified in the
# connstr) by adding 'target_session_attrs=read-write' to primary_conninfo.
if is_remote_member and 'target_sesions_attrs' not in primary_conninfo and\
self._postgresql.major_version >= 100000:
primary_conninfo['target_session_attrs'] = 'read-write'
recovery_params['primary_conninfo'] = primary_conninfo
# standby_cluster config might have different parameters, we want to override them
standby_cluster_params = ['restore_command', 'archive_cleanup_command']\
+ (['recovery_min_apply_delay'] if is_remote_member else [])
recovery_params.update({p: member.data.get(p) for p in standby_cluster_params if member and member.data.get(p)})
return recovery_params
def recovery_conf_exists(self) -> bool:
if self._postgresql.major_version >= 120000:
return os.path.exists(self._standby_signal) or os.path.exists(self._recovery_signal)
return os.path.exists(self._recovery_conf)
@property
def triggerfile_good_name(self) -> str:
return 'trigger_file' if self._postgresql.major_version < 120000 else 'promote_trigger_file'
@property
def _triggerfile_wrong_name(self) -> str:
return 'trigger_file' if self._postgresql.major_version >= 120000 else 'promote_trigger_file'
@property
def _recovery_parameters_to_compare(self) -> CaseInsensitiveSet:
skip_params = CaseInsensitiveSet({'pause_at_recovery_target', 'recovery_target_inclusive',
'recovery_target_action', 'standby_mode', self._triggerfile_wrong_name})
return CaseInsensitiveSet(self._RECOVERY_PARAMETERS - skip_params)
def _read_recovery_params(self) -> Tuple[Optional[CaseInsensitiveDict], bool]:
"""Read current recovery parameters values.
.. note::
We query Postgres only if we detected that Postgresql was restarted
or when at least one of the following files was updated:
* ``postgresql.conf``;
* ``postgresql.auto.conf``;
* ``passfile`` that is used in the ``primary_conninfo``.
:returns: a tuple with two elements:
* :class:`CaseInsensitiveDict` object with current values of recovery parameters,
or ``None`` if no configuration files were updated;
* ``True`` if new values of recovery parameters were queried, ``False`` otherwise.
"""
if self._postgresql.is_starting():
return None, False
pg_conf_mtime = mtime(self._postgresql_conf)
auto_conf_mtime = mtime(self._auto_conf)
passfile_mtime = mtime(self._passfile) if self._passfile else False
postmaster_ctime = self._postgresql.is_running()
if postmaster_ctime:
postmaster_ctime = postmaster_ctime.create_time()
if self._postgresql_conf_mtime == pg_conf_mtime and self._auto_conf_mtime == auto_conf_mtime \
and self._passfile_mtime == passfile_mtime and self._postmaster_ctime == postmaster_ctime:
return None, False
try:
values = self._get_pg_settings(self._recovery_parameters_to_compare).values()
values = CaseInsensitiveDict({p[0]: [p[1], p[4] == 'postmaster', p[5]] for p in values})
self._postgresql_conf_mtime = pg_conf_mtime
self._auto_conf_mtime = auto_conf_mtime
self._postmaster_ctime = postmaster_ctime
except Exception as exc:
if all((isinstance(exc, PostgresConnectionException),
self._postgresql_conf_mtime == pg_conf_mtime,
self._auto_conf_mtime == auto_conf_mtime,
self._passfile_mtime == passfile_mtime,
self._postmaster_ctime != postmaster_ctime)):
# We detected that the connection to postgres fails, but the process creation time of the postmaster
# doesn't match the old value. It is an indicator that Postgres crashed and either doing crash
# recovery or down. In this case we return values like nothing changed in the config.
return None, False
values = None
return values, True
def _read_recovery_params_pre_v12(self) -> Tuple[Optional[CaseInsensitiveDict], bool]:
recovery_conf_mtime = mtime(self._recovery_conf)
passfile_mtime = mtime(self._passfile) if self._passfile else False
if recovery_conf_mtime == self._recovery_conf_mtime and passfile_mtime == self._passfile_mtime:
return None, False
values = CaseInsensitiveDict()
with open(self._recovery_conf, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
value = None
match = PARAMETER_RE.match(line)
if match:
value = read_recovery_param_value(line[match.end():])
if match is None or value is None:
return None, True
values[match.group(1)] = [value, True]
self._recovery_conf_mtime = recovery_conf_mtime
values.setdefault('recovery_min_apply_delay', ['0', True])
values['recovery_min_apply_delay'][0] = parse_int(values['recovery_min_apply_delay'][0], 'ms')
values.update({param: ['', True] for param in self._recovery_parameters_to_compare if param not in values})
return values, True
def _check_passfile(self, passfile: str, wanted_primary_conninfo: Dict[str, Any]) -> bool:
# If there is a passfile in the primary_conninfo try to figure out that
# the passfile contains the line(s) allowing connection to the given node.
# We assume that the passfile was created by Patroni and therefore doing
# the full match and not covering cases when host, port or user are set to '*'
passfile_mtime = mtime(passfile)
if passfile_mtime:
try:
with open(passfile) as f:
wanted_lines = (self._pgpass_line(wanted_primary_conninfo) or '').splitlines()
file_lines = f.read().splitlines()
if set(wanted_lines) == set(file_lines):
self._passfile = passfile
self._passfile_mtime = passfile_mtime
return True
except Exception:
logger.info('Failed to read %s', passfile)
return False
def _check_primary_conninfo(self, primary_conninfo: Dict[str, Any],
wanted_primary_conninfo: Dict[str, Any]) -> bool:
# first we will cover corner cases, when we are replicating from somewhere while shouldn't
# or there is no primary_conninfo but we should replicate from some specific node.
if not wanted_primary_conninfo:
return not primary_conninfo
elif not primary_conninfo:
return False
if not self._postgresql.is_starting():
wal_receiver_primary_conninfo = self._postgresql.primary_conninfo()
if wal_receiver_primary_conninfo:
wal_receiver_primary_conninfo = parse_dsn(wal_receiver_primary_conninfo)
# when wal receiver is alive use primary_conninfo from pg_stat_wal_receiver for comparison
if wal_receiver_primary_conninfo:
primary_conninfo = wal_receiver_primary_conninfo
# There could be no password in the primary_conninfo or it is masked.
# Just copy the "desired" value in order to make comparison succeed.
if 'password' in wanted_primary_conninfo:
primary_conninfo['password'] = wanted_primary_conninfo['password']
if 'passfile' in primary_conninfo and 'password' not in primary_conninfo \
and 'password' in wanted_primary_conninfo:
if self._check_passfile(primary_conninfo['passfile'], wanted_primary_conninfo):
primary_conninfo['password'] = wanted_primary_conninfo['password']
else:
return False
return all(str(primary_conninfo.get(p)) == str(v) for p, v in wanted_primary_conninfo.items() if v is not None)
def check_recovery_conf(self, member: Union[Leader, Member, None]) -> Tuple[bool, bool]:
"""Returns a tuple. The first boolean element indicates that recovery params don't match
and the second is set to `True` if the restart is required in order to apply new values"""
# TODO: recovery.conf could be stale, would be nice to detect that.
if self._postgresql.major_version >= 120000:
if not os.path.exists(self._standby_signal):
return True, True
_read_recovery_params = self._read_recovery_params
else:
if not self.recovery_conf_exists():
return True, True
_read_recovery_params = self._read_recovery_params_pre_v12
params, updated = _read_recovery_params()
# updated indicates that mtime of postgresql.conf, postgresql.auto.conf, or recovery.conf
# was changed and params were read either from the config or from the database connection.
if updated:
if params is None: # exception or unparsable config
return True, True
# We will cache parsed value until the next config change.
self._current_recovery_params = params
primary_conninfo = params['primary_conninfo']
if primary_conninfo[0]:
primary_conninfo[0] = parse_dsn(params['primary_conninfo'][0])
# If we failed to parse non-empty connection string this indicates that config if broken.
if not primary_conninfo[0]:
return True, True
else: # empty string, primary_conninfo is not in the config
primary_conninfo[0] = {}
if not self._postgresql.is_starting() and self._current_recovery_params:
# when wal receiver is alive take primary_slot_name from pg_stat_wal_receiver
wal_receiver_primary_slot_name = self._postgresql.primary_slot_name()
if not wal_receiver_primary_slot_name and self._postgresql.primary_conninfo():
wal_receiver_primary_slot_name = ''
if wal_receiver_primary_slot_name is not None:
self._current_recovery_params['primary_slot_name'][0] = wal_receiver_primary_slot_name
# Increment the 'reload' to enforce write of postgresql.conf when joining the running postgres
required = {'restart': 0,
'reload': int(self._postgresql.major_version >= 120000
and not self._postgresql.cb_called
and not self._postgresql.is_starting())}
def record_missmatch(mtype: bool) -> None:
required['restart' if mtype else 'reload'] += 1
wanted_recovery_params = self.build_recovery_params(member)
for param, value in (self._current_recovery_params or {}).items():
# Skip certain parameters defined in the included postgres config files
# if we know that they are not specified in the patroni configuration.
if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \
param in ('archive_cleanup_command', 'promote_trigger_file', 'recovery_end_command',
'recovery_min_apply_delay', 'restore_command') and param not in wanted_recovery_params:
continue
if param == 'recovery_min_apply_delay':
if not compare_values('integer', 'ms', value[0], wanted_recovery_params.get(param, 0)):
record_missmatch(value[1])
elif param == 'standby_mode':
if not compare_values('bool', None, value[0], wanted_recovery_params.get(param, 'on')):
record_missmatch(value[1])
elif param == 'primary_conninfo':
if not self._check_primary_conninfo(value[0], wanted_recovery_params.get('primary_conninfo', {})):
record_missmatch(value[1])
elif (param != 'primary_slot_name' or wanted_recovery_params.get('primary_conninfo')) \
and str(value[0]) != str(wanted_recovery_params.get(param, '')):
record_missmatch(value[1])
return required['restart'] + required['reload'] > 0, required['restart'] > 0
@staticmethod
def _remove_file_if_exists(name: str) -> None:
if os.path.isfile(name) or os.path.islink(name):
os.unlink(name)
@staticmethod
def _pgpass_line(record: Dict[str, Any]) -> Optional[str]:
if 'password' in record:
def escape(value: Any) -> str:
return re.sub(r'([:\\])', r'\\\1', str(value))
record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')}
# 'host' could be several comma-separated hostnames, in this case
# we need to write on pgpass line per host
line = ''
for hostname in record['host'].split(','):
line += hostname + ':{port}:*:{user}:{password}'.format(**record) + '\n'
return line.rstrip()
def write_pgpass(self, record: Dict[str, Any]) -> Dict[str, str]:
line = self._pgpass_line(record)
if not line:
return os.environ.copy()
with open(self._pgpass, 'w') as f:
os.chmod(self._pgpass, stat.S_IWRITE | stat.S_IREAD)
f.write(line)
return {**os.environ, 'PGPASSFILE': self._pgpass}
def write_recovery_conf(self, recovery_params: CaseInsensitiveDict) -> None:
self._recovery_params = recovery_params
if self._postgresql.major_version >= 120000:
if parse_bool(recovery_params.pop('standby_mode', None)):
open(self._standby_signal, 'w').close()
self.set_file_permissions(self._standby_signal)
else:
self._remove_file_if_exists(self._standby_signal)
open(self._recovery_signal, 'w').close()
self.set_file_permissions(self._recovery_signal)
def restart_required(name: str) -> bool:
if self._postgresql.major_version >= 140000:
return False
return name == 'restore_command' or (self._postgresql.major_version < 130000
and name in ('primary_conninfo', 'primary_slot_name'))
self._current_recovery_params = CaseInsensitiveDict({n: [v, restart_required(n), self._postgresql_conf]
for n, v in recovery_params.items()})
else:
with self.config_writer(self._recovery_conf) as f:
self._write_recovery_params(f, recovery_params)
def remove_recovery_conf(self) -> None:
for name in (self._recovery_conf, self._standby_signal, self._recovery_signal):
self._remove_file_if_exists(name)
self._recovery_params = CaseInsensitiveDict()
self._current_recovery_params = None
def _sanitize_auto_conf(self) -> None:
overwrite = False
lines: List[str] = []
if os.path.exists(self._auto_conf):
try:
with open(self._auto_conf) as f:
for raw_line in f:
line = raw_line.strip()
match = PARAMETER_RE.match(line)
if match and match.group(1).lower() in self._RECOVERY_PARAMETERS:
overwrite = True
else:
lines.append(raw_line)
except Exception:
logger.info('Failed to read %s', self._auto_conf)
if overwrite:
try:
with open(self._auto_conf, 'w') as f:
self.set_file_permissions(self._auto_conf)
for raw_line in lines:
f.write(raw_line)
except Exception:
logger.exception('Failed to remove some unwanted parameters from %s', self._auto_conf)
def _adjust_recovery_parameters(self) -> None:
# It is not strictly necessary, but we can make patroni configs crossi-compatible with all postgres versions.
recovery_conf = {n: v for n, v in self._server_parameters.items() if n.lower() in self._RECOVERY_PARAMETERS}
if recovery_conf:
self._config['recovery_conf'] = recovery_conf
if self.get('recovery_conf'):
value = self._config['recovery_conf'].pop(self._triggerfile_wrong_name, None)
if self.triggerfile_good_name not in self._config['recovery_conf'] and value:
self._config['recovery_conf'][self.triggerfile_good_name] = value
def get_server_parameters(self, config: Dict[str, Any]) -> CaseInsensitiveDict:
parameters = config['parameters'].copy()
listen_addresses, port = split_host_port(config['listen'], 5432)
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
if global_config.is_synchronous_mode:
synchronous_standby_names = self._server_parameters.get('synchronous_standby_names')
if synchronous_standby_names is None:
if global_config.is_synchronous_mode_strict\
and self._postgresql.role in ('master', 'primary', 'promoted'):
parameters['synchronous_standby_names'] = '*'
else:
parameters.pop('synchronous_standby_names', None)
else:
parameters['synchronous_standby_names'] = synchronous_standby_names
# Handle hot_standby <-> replica rename
if parameters.get('wal_level') == ('hot_standby' if self._postgresql.major_version >= 90600 else 'replica'):
parameters['wal_level'] = 'replica' if self._postgresql.major_version >= 90600 else 'hot_standby'
# Try to recalcualte wal_keep_segments <-> wal_keep_size assuming that typical wal_segment_size is 16MB.
# The real segment size could be estimated from pg_control, but we don't really care, because the only goal of
# this exercise is improving cross version compatibility and user must set the correct parameter in the config.
if self._postgresql.major_version >= 130000:
wal_keep_segments = parameters.pop('wal_keep_segments', self.CMDLINE_OPTIONS['wal_keep_segments'][0])
parameters.setdefault('wal_keep_size', str(int(wal_keep_segments) * 16) + 'MB')
elif self._postgresql.major_version:
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
parameters.setdefault('wal_keep_segments', int(((wal_keep_size or 0) + 8) / 16))
self._postgresql.citus_handler.adjust_postgres_gucs(parameters)
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version
or self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
ret.update({k: os.path.join(self._config_dir, ret[k]) for k in ('hba_file', 'ident_file') if k in ret})
return ret
@staticmethod
def _get_unix_local_address(unix_socket_directories: str) -> str:
for d in unix_socket_directories.split(','):
d = d.strip()
if d.startswith('/'): # Only absolute path can be used to connect via unix-socket
return d
return ''
def _get_tcp_local_address(self) -> str:
listen_addresses = self._server_parameters['listen_addresses'].split(',')
for la in listen_addresses:
if la.strip().lower() in ('*', '0.0.0.0', '127.0.0.1', 'localhost'): # we are listening on '*' or localhost
return 'localhost' # connection via localhost is preferred
return listen_addresses[0].strip() # can't use localhost, take first address from listen_addresses
def resolve_connection_addresses(self) -> None:
"""Calculates and sets local and remote connection urls and options.
This method sets:
* :attr:`Postgresql.connection_string <patroni.postgresql.Postgresql.connection_string>` attribute, which
is later written to the member key in DCS as ``conn_url``.
* :attr:`ConfigHandler.local_replication_address` attribute, which is used for replication connections to
local postgres.
* :attr:`ConnectionPool.conn_kwargs <patroni.postgresql.connection.ConnectionPool.conn_kwargs>` attribute,
which is used for superuser connections to local postgres.
.. note::
If there is a valid directory in ``postgresql.parameters.unix_socket_directories`` in the Patroni
configuration and ``postgresql.use_unix_socket`` and/or ``postgresql.use_unix_socket_repl``
are set to ``True``, we respectively use unix sockets for superuser and replication connections
to local postgres.
If there is a requirement to use unix sockets, but nothing is set in the
``postgresql.parameters.unix_socket_directories``, we omit a ``host`` in connection parameters relying
on the ability of ``libpq`` to connect via some default unix socket directory.
If unix sockets are not requested we "switch" to TCP, prefering to use ``localhost`` if it is possible
to deduce that Postgres is listening on a local interface address.
Otherwise we just used the first address specified in the ``listen_addresses`` GUC.
"""
port = self._server_parameters['port']
tcp_local_address = self._get_tcp_local_address()
netloc = self._config.get('connect_address') or tcp_local_address + ':' + port
unix_local_address = {'port': port}
unix_socket_directories = self._server_parameters.get('unix_socket_directories')
if unix_socket_directories is not None:
# fallback to tcp if unix_socket_directories is set, but there are no suitable values
unix_local_address['host'] = self._get_unix_local_address(unix_socket_directories) or tcp_local_address
tcp_local_address = {'host': tcp_local_address, 'port': port}
self.local_replication_address = unix_local_address\
if self._config.get('use_unix_socket_repl') else tcp_local_address
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database)
local_address = unix_local_address if self._config.get('use_unix_socket') else tcp_local_address
local_conn_kwargs = {
**local_address,
**self._superuser,
'dbname': self._postgresql.database,
'fallback_application_name': 'Patroni',
'connect_timeout': 3,
'options': '-c statement_timeout=2000'
}
# if the "username" parameter is present, it actually needs to be "user" for connecting to PostgreSQL
if 'username' in local_conn_kwargs:
local_conn_kwargs['user'] = local_conn_kwargs.pop('username')
# "notify" connection_pool about the "new" local connection address
self._postgresql.connection_pool.conn_kwargs = local_conn_kwargs
def _get_pg_settings(self, names: Collection[str]) -> Dict[Any, Tuple[Any, ...]]:
return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile'
+ ' FROM pg_catalog.pg_settings '
+ ' WHERE pg_catalog.lower(name) = ANY(%s)'),
[n.lower() for n in names])}
@staticmethod
def _handle_wal_buffers(old_values: Dict[Any, Tuple[Any, ...]], changes: CaseInsensitiveDict) -> None:
wal_block_size = parse_int(old_values['wal_block_size'][1]) or 8192
wal_segment_size = old_values['wal_segment_size']
wal_segment_unit = parse_int(wal_segment_size[2], 'B') or 8192 \
if wal_segment_size[2] is not None and wal_segment_size[2][0].isdigit() else 1
wal_segment_size = parse_int(wal_segment_size[1]) or (16777216 if wal_segment_size[2] is None else 2048)
wal_segment_size *= wal_segment_unit / wal_block_size
default_wal_buffers = min(max((parse_int(old_values['shared_buffers'][1]) or 16384) / 32, 8), wal_segment_size)
wal_buffers = old_values['wal_buffers']
new_value = str(changes['wal_buffers'] or -1)
new_value = default_wal_buffers if new_value == '-1' else parse_int(new_value, wal_buffers[2])
old_value = default_wal_buffers if wal_buffers[1] == '-1' else parse_int(*wal_buffers[1:3])
if new_value == old_value:
del changes['wal_buffers']
def reload_config(self, config: Dict[str, Any], sighup: bool = False) -> None:
self._superuser = config['authentication'].get('superuser', {})
server_parameters = self.get_server_parameters(config)
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
conf_changed = hba_changed = ident_changed = local_connection_address_changed = pending_restart = False
if self._postgresql.state == 'running':
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
if p not in params_skip_changes})
changes.update({p: None for p in self._server_parameters.keys()
if not (p in changes or p in params_skip_changes)})
if changes:
undef = []
if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers
undef = [p for p in ('shared_buffers', 'wal_segment_size', 'wal_block_size') if p not in changes]
changes.update({p: None for p in undef})
# XXX: query can raise an exception
old_values = self._get_pg_settings(changes.keys())
if 'wal_buffers' in changes:
self._handle_wal_buffers(old_values, changes)
for p in undef:
del changes[p]
for r in old_values.values():
if r[4] != 'internal' and r[0] in changes:
new_value = changes.pop(r[0])
if new_value is None or not compare_values(r[3], r[2], r[1], new_value):
conf_changed = True
if r[4] == 'postmaster':
pending_restart = True
logger.info('Changed %s from %s to %s (restart might be required)',
r[0], r[1], new_value)
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
or r[0] in ('listen_addresses', 'port'):
local_connection_address_changed = True
else:
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
elif r[0] in self._server_parameters \
and not compare_values(r[3], r[2], r[1], self._server_parameters[r[0]]):
# Check if any parameter was set back to the current pg_settings value
# We can use pg_settings value here, as it is proved to be equal to new_value
logger.info('Changed %s from %s to %s', r[0], self._server_parameters[r[0]], r[1])
conf_changed = True
for param, value in changes.items():
if '.' in param:
# Check that user-defined-paramters have changed (parameters with period in name)
if value is None or param not in self._server_parameters \
or str(value) != str(self._server_parameters[param]):
logger.info('Changed %s from %s to %s', param, self._server_parameters.get(param), value)
conf_changed = True
elif param in server_parameters:
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
server_parameters.pop(param)
if (not server_parameters.get('hba_file') or server_parameters['hba_file'] == self._pg_hba_conf) \
and config.get('pg_hba'):
hba_changed = self._config.get('pg_hba', []) != config['pg_hba']
if (not server_parameters.get('ident_file') or server_parameters['ident_file'] == self._pg_hba_conf) \
and config.get('pg_ident'):
ident_changed = self._config.get('pg_ident', []) != config['pg_ident']
self._config = config
self._postgresql.set_pending_restart(pending_restart)
self._server_parameters = server_parameters
self._adjust_recovery_parameters()
self._krbsrvname = config.get('krbsrvname')
# for not so obvious connection attempts that may happen outside of pyscopg2
if self._krbsrvname:
os.environ['PGKRBSRVNAME'] = self._krbsrvname
if not local_connection_address_changed:
self.resolve_connection_addresses()
proxy_addr = config.get('proxy_address')
self._postgresql.proxy_url = uri('postgres', proxy_addr, self._postgresql.database) if proxy_addr else None
if conf_changed:
self.write_postgresql_conf()
if hba_changed:
self.replace_pg_hba()
if ident_changed:
self.replace_pg_ident()
if sighup or conf_changed or hba_changed or ident_changed:
logger.info('Reloading PostgreSQL configuration.')
self._postgresql.reload()
if self._postgresql.major_version >= 90500:
time.sleep(1)
try:
pending_restart = self._postgresql.query(
'SELECT COUNT(*) FROM pg_catalog.pg_settings'
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in params_skip_changes])[0][0] > 0
self._postgresql.set_pending_restart(pending_restart)
except Exception as e:
logger.warning('Exception %r when running query', e)
else:
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
"""Updates synchronous_standby_names and reloads if necessary.
:returns: True if value was updated."""
if value != self._server_parameters.get('synchronous_standby_names'):
if value is None:
self._server_parameters.pop('synchronous_standby_names', None)
else:
self._server_parameters['synchronous_standby_names'] = value
if self._postgresql.state == 'running':
self.write_postgresql_conf()
self._postgresql.reload()
return True
@property
def effective_configuration(self) -> CaseInsensitiveDict:
"""It might happen that the current value of one (or more) below parameters stored in
the controldata is higher than the value stored in the global cluster configuration.
Example: max_connections in global configuration is 100, but in controldata
`Current max_connections setting: 200`. If we try to start postgres with
max_connections=100, it will immediately exit.
As a workaround we will start it with the values from controldata and set `pending_restart`
to true as an indicator that current values of parameters are not matching expectations."""
if self._postgresql.role in ('master', 'primary'):
return self._server_parameters
options_mapping = {
'max_connections': 'max_connections setting',
'max_prepared_transactions': 'max_prepared_xacts setting',
'max_locks_per_transaction': 'max_locks_per_xact setting'
}
if self._postgresql.major_version >= 90400:
options_mapping['max_worker_processes'] = 'max_worker_processes setting'
if self._postgresql.major_version >= 120000:
options_mapping['max_wal_senders'] = 'max_wal_senders setting'
data = self._postgresql.controldata()
effective_configuration = self._server_parameters.copy()
for name, cname in options_mapping.items():
value = parse_int(effective_configuration[name])
if cname not in data:
logger.warning('%s is missing from pg_controldata output', cname)
continue
cvalue = parse_int(data[cname])
if cvalue is not None and value is not None and cvalue > value:
effective_configuration[name] = cvalue
self._postgresql.set_pending_restart(True)
# If we are using custom bootstrap with PITR it could fail when values like max_connections
# are increased, therefore we disable hot_standby if recovery_target_action == 'promote'.
if self._postgresql.bootstrap.running_custom_bootstrap:
disable_hot_standby = False
if self._postgresql.bootstrap.keep_existing_recovery_conf:
disable_hot_standby = True # trust that pgBackRest does the right thing
# `pause_at_recovery_target` has no effect if hot_standby is not enabled, therefore we consider only 9.5+
elif self._postgresql.major_version >= 90500 and self._recovery_params:
pause_at_recovery_target = parse_bool(self._recovery_params.get('pause_at_recovery_target'))
recovery_target_action = self._recovery_params.get(
'recovery_target_action', 'promote' if pause_at_recovery_target is False else 'pause')
disable_hot_standby = recovery_target_action == 'promote'
if disable_hot_standby:
effective_configuration['hot_standby'] = 'off'
return effective_configuration
@property
def replication(self) -> Dict[str, Any]:
return self._config['authentication']['replication']
@property
def superuser(self) -> Dict[str, Any]:
return self._superuser
@property
def rewind_credentials(self) -> Dict[str, Any]:
return self._config['authentication'].get('rewind', self._superuser) \
if self._postgresql.major_version >= 110000 else self._superuser
@property
def ident_file(self) -> Optional[str]:
ident_file = self._server_parameters.get('ident_file')
return None if ident_file == self._pg_ident_conf else ident_file
@property
def hba_file(self) -> Optional[str]:
hba_file = self._server_parameters.get('hba_file')
return None if hba_file == self._pg_hba_conf else hba_file
@property
def pg_hba_conf(self) -> str:
return self._pg_hba_conf
@property
def postgresql_conf(self) -> str:
return self._postgresql_conf
def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]:
return self._config.get(key, default)
def restore_command(self) -> Optional[str]:
return (self.get('recovery_conf') or {}).get('restore_command')