mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Postgres on Windows is using different signals, backslashes as file separators and some of the functions and syscalls are not available there.
1946 lines
86 KiB
Python
1946 lines
86 KiB
Python
import logging
|
|
import os
|
|
import psycopg2
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
|
|
from collections import defaultdict
|
|
from contextlib import contextmanager
|
|
from patroni.callback_executor import CallbackExecutor
|
|
from patroni.exceptions import PostgresConnectionException, PostgresException
|
|
from patroni.utils import compare_values, parse_bool, parse_int, Retry, RetryFailedError, polling_loop, split_host_port
|
|
from patroni.postmaster import PostmasterProcess
|
|
from patroni.dcs import RemoteMember
|
|
from requests.structures import CaseInsensitiveDict
|
|
from six import string_types
|
|
from six.moves.urllib.parse import quote_plus
|
|
from threading import current_thread, Lock
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ACTION_ON_START = "on_start"
|
|
ACTION_ON_STOP = "on_stop"
|
|
ACTION_ON_RESTART = "on_restart"
|
|
ACTION_ON_RELOAD = "on_reload"
|
|
ACTION_ON_ROLE_CHANGE = "on_role_change"
|
|
|
|
STATE_RUNNING = 'running'
|
|
STATE_REJECT = 'rejecting connections'
|
|
STATE_NO_RESPONSE = 'not responding'
|
|
STATE_UNKNOWN = 'unknown'
|
|
|
|
STOP_POLLING_INTERVAL = 1
|
|
REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECK': 1, 'NEED': 2, 'NOT_NEED': 3, 'SUCCESS': 4, 'FAILED': 5})
|
|
sync_standby_name_re = re.compile('^[A-Za-z_][A-Za-z_0-9\$]*$')
|
|
|
|
cluster_info_query = ("SELECT CASE WHEN pg_is_in_recovery() THEN 0 "
|
|
"ELSE ('x' || SUBSTR(pg_{0}file_name(pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, "
|
|
"CASE WHEN pg_is_in_recovery() THEN GREATEST("
|
|
" pg_{0}_{1}_diff(COALESCE(pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint,"
|
|
" pg_{0}_{1}_diff(pg_last_{0}_replay_{1}(), '0/0')::bigint)"
|
|
"ELSE pg_{0}_{1}_diff(pg_current_{0}_{1}(), '0/0')::bigint END")
|
|
|
|
|
|
def quote_ident(value):
|
|
"""Very simplified version of quote_ident"""
|
|
return value if sync_standby_name_re.match(value) else '"' + value + '"'
|
|
|
|
|
|
def slot_name_from_member_name(member_name):
|
|
"""Translate member name to valid PostgreSQL slot name.
|
|
|
|
PostgreSQL replication slot names must be valid PostgreSQL names. This function maps the wider space of
|
|
member names to valid PostgreSQL names. Names are lowercased, dashes and periods common in hostnames
|
|
are replaced with underscores, other characters are encoded as their unicode codepoint. Name is truncated
|
|
to 64 characters. Multiple different member names may map to a single slot name."""
|
|
|
|
def replace_char(match):
|
|
c = match.group(0)
|
|
return '_' if c in '-.' else "u{:04d}".format(ord(c))
|
|
|
|
slot_name = re.sub('[^a-z0-9_]', replace_char, member_name.lower())
|
|
return slot_name[0:63]
|
|
|
|
|
|
@contextmanager
|
|
def null_context():
|
|
yield
|
|
|
|
|
|
class Postgresql(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, lambda _: False, 90100),
|
|
'port': (None, lambda _: False, 90100),
|
|
'cluster_name': (None, lambda _: False, 90500),
|
|
'wal_level': ('hot_standby', lambda v: v.lower() in ('hot_standby', 'replica', 'logical'), 90100),
|
|
'hot_standby': ('on', lambda _: False, 90100),
|
|
'max_connections': (100, lambda v: int(v) >= 100, 90100),
|
|
'max_wal_senders': (10, lambda v: int(v) >= 10, 90100),
|
|
'wal_keep_segments': (8, lambda v: int(v) >= 8, 90100),
|
|
'max_prepared_transactions': (0, lambda v: int(v) >= 0, 90100),
|
|
'max_locks_per_transaction': (64, lambda v: int(v) >= 64, 90100),
|
|
'track_commit_timestamp': ('off', lambda v: parse_bool(v) is not None, 90500),
|
|
'max_replication_slots': (10, lambda v: int(v) >= 10, 90400),
|
|
'max_worker_processes': (8, lambda v: int(v) >= 8, 90400),
|
|
'wal_log_hints': ('on', lambda _: False, 90400)
|
|
})
|
|
|
|
_CONFIG_WARNING_HEADER = '# Do not edit this file manually!\n# It will be overwritten by Patroni!\n'
|
|
|
|
def __init__(self, config):
|
|
self.config = config
|
|
self.name = config['name']
|
|
self.scope = config['scope']
|
|
self._bin_dir = config.get('bin_dir') or ''
|
|
self._database = config.get('database', 'postgres')
|
|
self._data_dir = config['data_dir']
|
|
self._config_dir = os.path.abspath(config.get('config_dir') or self._data_dir)
|
|
self._pending_restart = False
|
|
self.bootstrapping = False
|
|
self._running_custom_bootstrap = False
|
|
self.__thread_ident = current_thread().ident
|
|
|
|
self._version_file = os.path.join(self._data_dir, 'PG_VERSION')
|
|
self._synchronous_standby_names = None
|
|
self._configure_server_parameters()
|
|
|
|
self._connect_address = config.get('connect_address')
|
|
self._superuser = config['authentication'].get('superuser', {})
|
|
self.resolve_connection_addresses()
|
|
|
|
self._rewind_state = REWIND_STATUS.INITIAL
|
|
self._use_slots = config.get('use_slots', True)
|
|
self._schedule_load_slots = self.use_slots
|
|
|
|
self._pgpass = config.get('pgpass') or os.path.join(os.path.expanduser('~'), 'pgpass')
|
|
self._callback_executor = CallbackExecutor()
|
|
self.__cb_called = False
|
|
self.__cb_pending = None
|
|
config_base_name = config.get('config_base_name', 'postgresql')
|
|
self._postgresql_conf = os.path.join(self._config_dir, config_base_name + '.conf')
|
|
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._recovery_conf = os.path.join(self._data_dir, 'recovery.conf')
|
|
self._trigger_file = config.get('recovery_conf', {}).get('trigger_file') or 'promote'
|
|
self._trigger_file = os.path.abspath(os.path.join(self._data_dir, self._trigger_file))
|
|
|
|
self._is_cancelled = False
|
|
self._cancellable = None
|
|
self._cancellable_lock = Lock()
|
|
|
|
self._connection_lock = Lock()
|
|
self._connection = None
|
|
self._cursor_holder = None
|
|
self._sysid = None
|
|
self._replication_slots = [] # list of already existing replication slots
|
|
self.retry = Retry(max_tries=-1, deadline=config['retry_timeout']/2.0, max_delay=1,
|
|
retry_exceptions=PostgresConnectionException)
|
|
|
|
# Retry 'pg_is_in_recovery()' only once
|
|
self._is_leader_retry = Retry(max_tries=1, deadline=config['retry_timeout']/2.0, max_delay=1,
|
|
retry_exceptions=PostgresConnectionException)
|
|
|
|
self._state_lock = Lock()
|
|
self.set_state('stopped')
|
|
self._role_lock = Lock()
|
|
self.set_role(self.get_postgres_role_from_data_directory())
|
|
|
|
self._state_entry_timestamp = None
|
|
|
|
self._cluster_info_state = {}
|
|
self._cached_replica_timeline = None
|
|
|
|
# Last known running process
|
|
self._postmaster_proc = None
|
|
|
|
if self.is_running():
|
|
self.set_state('running')
|
|
self.set_role('master' if self.is_leader() else 'replica')
|
|
self._write_postgresql_conf() # we are "joining" already running postgres
|
|
if self._replace_pg_hba():
|
|
self.reload()
|
|
elif self.role == 'master':
|
|
self.set_role('demoted')
|
|
|
|
@property
|
|
def _create_replica_methods(self):
|
|
return (self.config.get('create_replica_methods', []) or
|
|
self.config.get('create_replica_method', []))
|
|
|
|
@property
|
|
def _configuration_to_save(self):
|
|
configuration = [os.path.basename(self._postgresql_conf)]
|
|
if 'custom_conf' not in self.config:
|
|
configuration.append(os.path.basename(self._postgresql_base_conf))
|
|
if not self._server_parameters.get('hba_file'):
|
|
configuration.append('pg_hba.conf')
|
|
if not self._server_parameters.get('ident_file'):
|
|
configuration.append('pg_ident.conf')
|
|
return configuration
|
|
|
|
@property
|
|
def use_slots(self):
|
|
return self._use_slots and self._major_version >= 90400
|
|
|
|
@property
|
|
def _replication(self):
|
|
return self.config['authentication']['replication']
|
|
|
|
@property
|
|
def callback(self):
|
|
return self.config.get('callbacks') or {}
|
|
|
|
@staticmethod
|
|
def _wal_name(version):
|
|
return 'wal' if version >= 100000 else 'xlog'
|
|
|
|
@property
|
|
def wal_name(self):
|
|
return self._wal_name(self._major_version)
|
|
|
|
@property
|
|
def lsn_name(self):
|
|
return 'lsn' if self._major_version >= 100000 else 'location'
|
|
|
|
def _version_file_exists(self):
|
|
return not self.data_directory_empty() and os.path.isfile(self._version_file)
|
|
|
|
def get_major_version(self):
|
|
if self._version_file_exists():
|
|
try:
|
|
with open(self._version_file) as f:
|
|
return self.postgres_major_version_to_int(f.read().strip())
|
|
except Exception:
|
|
logger.exception('Failed to read PG_VERSION from %s', self._data_dir)
|
|
return 0
|
|
|
|
def get_server_parameters(self, config):
|
|
parameters = config['parameters'].copy()
|
|
listen_addresses, port = split_host_port(config['listen'], 5432)
|
|
parameters.update({'cluster_name': self.scope, 'listen_addresses': listen_addresses, 'port': str(port)})
|
|
if config.get('synchronous_mode', False):
|
|
if self._synchronous_standby_names is None:
|
|
if config.get('synchronous_mode_strict', False):
|
|
parameters['synchronous_standby_names'] = '*'
|
|
else:
|
|
parameters.pop('synchronous_standby_names', None)
|
|
else:
|
|
parameters['synchronous_standby_names'] = self._synchronous_standby_names
|
|
if self._major_version >= 90600 and parameters['wal_level'] == 'hot_standby':
|
|
parameters['wal_level'] = 'replica'
|
|
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._major_version or
|
|
self._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
|
|
|
|
def resolve_connection_addresses(self):
|
|
port = self._server_parameters['port']
|
|
tcp_local_address = self._get_tcp_local_address()
|
|
|
|
local_address = {'port': port}
|
|
if self.config.get('use_unix_socket'):
|
|
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 sutable values
|
|
local_address['host'] = self._get_unix_local_address(unix_socket_directories) or tcp_local_address
|
|
|
|
# if unix_socket_directories is not specified, but use_unix_socket is set to true - do our best
|
|
# to use default value, i.e. don't specify a host neither in connection url nor arguments
|
|
else:
|
|
local_address['host'] = tcp_local_address
|
|
|
|
self._local_address = local_address
|
|
self._local_replication_address = {'host': tcp_local_address, 'port': port}
|
|
|
|
self.connection_string = 'postgres://{0}/{1}'.format(
|
|
self._connect_address or tcp_local_address + ':' + port, self._database)
|
|
|
|
def _pgcommand(self, cmd):
|
|
"""Returns path to the specified PostgreSQL command"""
|
|
return os.path.join(self._bin_dir, cmd)
|
|
|
|
def pg_ctl(self, cmd, *args, **kwargs):
|
|
"""Builds and executes pg_ctl command
|
|
|
|
:returns: `!True` when return_code == 0, otherwise `!False`"""
|
|
|
|
pg_ctl = [self._pgcommand('pg_ctl'), cmd]
|
|
return subprocess.call(pg_ctl + ['-D', self._data_dir] + list(args), **kwargs) == 0
|
|
|
|
def pg_isready(self):
|
|
"""Runs pg_isready to see if PostgreSQL is accepting connections.
|
|
|
|
:returns: 'ok' if PostgreSQL is up, 'reject' if starting up, 'no_resopnse' if not up."""
|
|
|
|
cmd = [self._pgcommand('pg_isready'), '-p', self._local_address['port'], '-d', self._database]
|
|
|
|
# Host is not set if we are connecting via default unix socket
|
|
if 'host' in self._local_address:
|
|
cmd.extend(['-h', self._local_address['host']])
|
|
|
|
# We only need the username because pg_isready does not try to authenticate
|
|
if 'username' in self._superuser:
|
|
cmd.extend(['-U', self._superuser['username']])
|
|
|
|
ret = subprocess.call(cmd)
|
|
return_codes = {0: STATE_RUNNING,
|
|
1: STATE_REJECT,
|
|
2: STATE_NO_RESPONSE,
|
|
3: STATE_UNKNOWN}
|
|
return return_codes.get(ret, STATE_UNKNOWN)
|
|
|
|
def reload_config(self, config):
|
|
self._superuser = config['authentication'].get('superuser', {})
|
|
server_parameters = self.get_server_parameters(config)
|
|
|
|
conf_changed = hba_changed = local_connection_address_changed = pending_restart = False
|
|
if self.state == 'running':
|
|
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items() if '.' not in p})
|
|
changes.update({p: None for p in self._server_parameters.keys() if not ('.' in p or p in changes)})
|
|
if changes:
|
|
if 'wal_segment_size' not in changes:
|
|
changes['wal_segment_size'] = '16384kB'
|
|
# XXX: query can raise an exception
|
|
for r in self.query("""SELECT name, setting, unit, vartype, context
|
|
FROM pg_settings
|
|
WHERE LOWER(name) IN (""" + ', '.join(['%s'] * len(changes)) + """)
|
|
ORDER BY 1 DESC""", *(k.lower() for k in changes.keys())):
|
|
if r[4] == 'internal':
|
|
if r[0] == 'wal_segment_size':
|
|
server_parameters.pop(r[0], None)
|
|
wal_segment_size = parse_int(r[2], 'kB')
|
|
if wal_segment_size is not None:
|
|
changes['wal_segment_size'] = '{0}kB'.format(int(r[1]) * wal_segment_size)
|
|
elif r[0] in changes:
|
|
unit = changes['wal_segment_size'] if r[0] in ('min_wal_size', 'max_wal_size') else r[2]
|
|
new_value = changes.pop(r[0])
|
|
if new_value is None or not compare_values(r[3], unit, r[1], new_value):
|
|
if r[4] == 'postmaster':
|
|
pending_restart = True
|
|
logger.info('Changed %s from %s to %s (restart 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)
|
|
conf_changed = True
|
|
for param in changes:
|
|
if param in server_parameters:
|
|
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
|
|
server_parameters.pop(param)
|
|
|
|
# Check that user-defined-paramters have changed (parameters with period in name)
|
|
if not conf_changed:
|
|
for p, v in server_parameters.items():
|
|
if '.' in p and (p not in self._server_parameters or str(v) != str(self._server_parameters[p])):
|
|
logger.info('Changed %s from %s to %s', p, self._server_parameters.get(p), v)
|
|
conf_changed = True
|
|
break
|
|
if not conf_changed:
|
|
for p, v in self._server_parameters.items():
|
|
if '.' in p and (p not in server_parameters or str(v) != str(server_parameters[p])):
|
|
logger.info('Changed %s from %s to %s', p, v, server_parameters.get(p))
|
|
conf_changed = True
|
|
break
|
|
|
|
if not server_parameters.get('hba_file') and config.get('pg_hba'):
|
|
hba_changed = self.config.get('pg_hba', []) != config['pg_hba']
|
|
|
|
self.config = config
|
|
self._pending_restart = pending_restart
|
|
self._server_parameters = server_parameters
|
|
self._connect_address = config.get('connect_address')
|
|
|
|
if not local_connection_address_changed:
|
|
self.resolve_connection_addresses()
|
|
|
|
if conf_changed:
|
|
self._write_postgresql_conf()
|
|
|
|
if hba_changed:
|
|
self._replace_pg_hba()
|
|
|
|
if conf_changed or hba_changed:
|
|
logger.info('PostgreSQL configuration items changed, reloading configuration.')
|
|
self.reload()
|
|
elif not pending_restart:
|
|
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
|
|
|
|
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout']/2.0
|
|
|
|
@property
|
|
def pending_restart(self):
|
|
return self._pending_restart
|
|
|
|
@staticmethod
|
|
def configuration_allows_rewind(data):
|
|
return data.get('wal_log_hints setting', 'off') == 'on' \
|
|
or data.get('Data page checksum version', '0') != '0'
|
|
|
|
@property
|
|
def can_rewind(self):
|
|
""" check if pg_rewind executable is there and that pg_controldata indicates
|
|
we have either wal_log_hints or checksums turned on
|
|
"""
|
|
# low-hanging fruit: check if pg_rewind configuration is there
|
|
if not (self.config.get('use_pg_rewind') and all(self._superuser.get(n) for n in ('username', 'password'))):
|
|
return False
|
|
|
|
cmd = [self._pgcommand('pg_rewind'), '--help']
|
|
try:
|
|
ret = subprocess.call(cmd, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
|
if ret != 0: # pg_rewind is not there, close up the shop and go home
|
|
return False
|
|
except OSError:
|
|
return False
|
|
return self.configuration_allows_rewind(self.controldata())
|
|
|
|
@property
|
|
def sysid(self):
|
|
if not self._sysid and not self.bootstrapping:
|
|
data = self.controldata()
|
|
self._sysid = data.get('Database system identifier', "")
|
|
return self._sysid
|
|
|
|
@staticmethod
|
|
def _get_unix_local_address(unix_socket_directories):
|
|
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):
|
|
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 get_postgres_role_from_data_directory(self):
|
|
if self.data_directory_empty():
|
|
return 'uninitialized'
|
|
elif os.path.exists(self._recovery_conf):
|
|
return 'replica'
|
|
else:
|
|
return 'master'
|
|
|
|
@property
|
|
def _local_connect_kwargs(self):
|
|
ret = self._local_address.copy()
|
|
ret.update({'database': self._database,
|
|
'fallback_application_name': 'Patroni',
|
|
'connect_timeout': 3,
|
|
'options': '-c statement_timeout=2000'})
|
|
if 'username' in self._superuser:
|
|
ret['user'] = self._superuser['username']
|
|
if 'password' in self._superuser:
|
|
ret['password'] = self._superuser['password']
|
|
return ret
|
|
|
|
def connection(self):
|
|
with self._connection_lock:
|
|
if not self._connection or self._connection.closed != 0:
|
|
self._connection = psycopg2.connect(**self._local_connect_kwargs)
|
|
self._connection.autocommit = True
|
|
self.server_version = self._connection.server_version
|
|
return self._connection
|
|
|
|
def _cursor(self):
|
|
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
|
|
logger.info("establishing a new patroni connection to the postgres cluster")
|
|
self._cursor_holder = self.connection().cursor()
|
|
return self._cursor_holder
|
|
|
|
def close_connection(self):
|
|
if self._connection and self._connection.closed == 0:
|
|
self._connection.close()
|
|
logger.info("closed patroni connection to the postgresql cluster")
|
|
self._cursor_holder = self._connection = None
|
|
|
|
def _query(self, sql, *params):
|
|
"""We are always using the same cursor, therefore this method is not thread-safe!!!
|
|
You can call it from different threads only if you are holding explicit `AsyncExecutor` lock,
|
|
because the main thread is always holding this lock when running HA cycle."""
|
|
cursor = None
|
|
try:
|
|
cursor = self._cursor()
|
|
cursor.execute(sql, params)
|
|
return cursor
|
|
except psycopg2.Error as e:
|
|
if cursor and cursor.connection.closed == 0:
|
|
# When connected via unix socket, psycopg2 can't recoginze 'connection lost'
|
|
# and leaves `_cursor_holder.connection.closed == 0`, but psycopg2.OperationalError
|
|
# is still raised (what is correct). It doesn't make sense to continiue with existing
|
|
# connection and we will close it, to avoid its reuse by the `_cursor` method.
|
|
if isinstance(e, psycopg2.OperationalError):
|
|
self.close_connection()
|
|
else:
|
|
raise e
|
|
if self.state == 'restarting':
|
|
raise RetryFailedError('cluster is being restarted')
|
|
raise PostgresConnectionException('connection problems')
|
|
|
|
def query(self, sql, *params):
|
|
try:
|
|
return self.retry(self._query, sql, *params)
|
|
except RetryFailedError as e:
|
|
raise PostgresConnectionException(str(e))
|
|
|
|
def data_directory_empty(self):
|
|
return not os.path.exists(self._data_dir) or os.listdir(self._data_dir) == []
|
|
|
|
@staticmethod
|
|
def process_user_options(tool, options, not_allowed_options, error_handler):
|
|
user_options = []
|
|
|
|
def option_is_allowed(name):
|
|
ret = name not in not_allowed_options
|
|
if not ret:
|
|
error_handler('{0} option for {1} is not allowed'.format(name, tool))
|
|
return ret
|
|
|
|
if isinstance(options, dict):
|
|
for k, v in options.items():
|
|
if k and v:
|
|
user_options.append('--{0}={1}'.format(k, v))
|
|
elif isinstance(options, list):
|
|
for opt in options:
|
|
if isinstance(opt, string_types) and option_is_allowed(opt):
|
|
user_options.append('--{0}'.format(opt))
|
|
elif isinstance(opt, dict):
|
|
keys = list(opt.keys())
|
|
if len(keys) != 1 or not isinstance(opt[keys[0]], string_types) or not option_is_allowed(keys[0]):
|
|
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
|
|
' and value should be a string'.format(tool, opt[keys[0]]))
|
|
user_options.append('--{0}={1}'.format(keys[0], opt[keys[0]]))
|
|
else:
|
|
error_handler('Error when parsing {0} option {1}: value should be string value'
|
|
' or a single key-value pair'.format(tool, opt))
|
|
else:
|
|
error_handler('{0} options must be list ot dict'.format(tool))
|
|
return user_options
|
|
|
|
def _initdb(self, config):
|
|
self.set_state('initalizing new cluster')
|
|
not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version')
|
|
|
|
def error_handler(e):
|
|
raise Exception(e)
|
|
|
|
options = self.process_user_options('initdb', config.get('initdb') or [], not_allowed_options, error_handler)
|
|
pwfile = None
|
|
|
|
if self._superuser:
|
|
if 'username' in self._superuser:
|
|
options.append('--username={0}'.format(self._superuser['username']))
|
|
if 'password' in self._superuser:
|
|
(fd, pwfile) = tempfile.mkstemp()
|
|
os.write(fd, self._superuser['password'].encode('utf-8'))
|
|
os.close(fd)
|
|
options.append('--pwfile={0}'.format(pwfile))
|
|
options = ['-o', ' '.join(options)] if options else []
|
|
|
|
ret = self.pg_ctl('initdb', *options)
|
|
if pwfile:
|
|
os.remove(pwfile)
|
|
if not ret:
|
|
self.set_state('initdb failed')
|
|
return ret
|
|
|
|
def _custom_bootstrap(self, config):
|
|
self.set_state('running custom bootstrap script')
|
|
params = ['--scope=' + self.scope, '--datadir=' + self._data_dir]
|
|
try:
|
|
logger.info('Running custom bootstrap script: %s', config['command'])
|
|
if self.cancellable_subprocess_call(shlex.split(config['command']) + params) != 0:
|
|
self.set_state('custom bootstrap failed')
|
|
return False
|
|
except Exception:
|
|
logger.exception('Exception during custom bootstrap')
|
|
return False
|
|
self._post_restore()
|
|
|
|
if 'recovery_conf' in config:
|
|
self.write_recovery_conf(config['recovery_conf'])
|
|
elif (os.path.isfile(self._recovery_conf) or os.path.islink(self._recovery_conf)) and \
|
|
not config.get('keep_existing_recovery_conf'):
|
|
os.unlink(self._recovery_conf)
|
|
return True
|
|
|
|
def run_bootstrap_post_init(self, config):
|
|
"""
|
|
runs a script after initdb or custom bootstrap script is called and waits until completion.
|
|
"""
|
|
cmd = config.get('post_bootstrap') or config.get('post_init')
|
|
if cmd:
|
|
r = self._local_connect_kwargs
|
|
|
|
if 'host' in r:
|
|
# '/tmp' => '%2Ftmp' for unix socket path
|
|
host = quote_plus(r['host']) if r['host'].startswith('/') else r['host']
|
|
else:
|
|
host = ''
|
|
|
|
# https://www.postgresql.org/docs/current/static/libpq-pgpass.html
|
|
# A host name of localhost matches both TCP (host name localhost) and Unix domain socket
|
|
# (pghost empty or the default socket directory) connections coming from the local machine.
|
|
r['host'] = 'localhost' # set it to localhost to write into pgpass
|
|
|
|
if 'user' in r:
|
|
user = r['user'] + '@'
|
|
else:
|
|
user = ''
|
|
if 'password' in r:
|
|
import getpass
|
|
r.setdefault('user', os.environ.get('PGUSER', getpass.getuser()))
|
|
|
|
connstring = 'postgres://{0}{1}:{2}/{3}'.format(user, host, r['port'], r['database'])
|
|
env = self.write_pgpass(r) if 'password' in r else None
|
|
|
|
try:
|
|
ret = self.cancellable_subprocess_call(shlex.split(cmd) + [connstring], env=env)
|
|
except OSError:
|
|
logger.error('post_init script %s failed', cmd)
|
|
return False
|
|
if ret != 0:
|
|
logger.error('post_init script %s returned non-zero code %d', cmd, ret)
|
|
return False
|
|
return True
|
|
|
|
def delete_trigger_file(self):
|
|
if os.path.exists(self._trigger_file):
|
|
os.unlink(self._trigger_file)
|
|
|
|
def write_pgpass(self, record):
|
|
if 'user' not in record or 'password' not in record:
|
|
return os.environ.copy()
|
|
|
|
with open(self._pgpass, 'w') as f:
|
|
if os.name != 'nt':
|
|
os.fchmod(f.fileno(), 0o600)
|
|
f.write('{host}:{port}:*:{user}:{password}\n'.format(**record))
|
|
|
|
env = os.environ.copy()
|
|
env['PGPASSFILE'] = self._pgpass
|
|
return env
|
|
|
|
def replica_method_can_work_without_replication_connection(self, method):
|
|
return method != 'basebackup' and self.config and self.config.get(method, {}).get('no_master')
|
|
|
|
def can_create_replica_without_replication_connection(self):
|
|
""" go through the replication methods to see if there are ones
|
|
that does not require a working replication connection.
|
|
"""
|
|
replica_methods = self._create_replica_methods
|
|
return any(self.replica_method_can_work_without_replication_connection(method) for method in replica_methods)
|
|
|
|
def create_replica(self, clone_member):
|
|
"""
|
|
create the replica according to the replica_method
|
|
defined by the user. this is a list, so we need to
|
|
loop through all methods the user supplies
|
|
"""
|
|
|
|
self.set_state('creating replica')
|
|
self._sysid = None
|
|
|
|
is_remote_master = isinstance(clone_member, RemoteMember)
|
|
create_replica_methods = is_remote_master and clone_member.create_replica_methods
|
|
|
|
# get list of replica methods either from clone member or from
|
|
# the config. If there is no configuration key, or no value is
|
|
# specified, use basebackup
|
|
replica_methods = (
|
|
create_replica_methods
|
|
or self._create_replica_methods
|
|
or ['basebackup']
|
|
)
|
|
|
|
if clone_member and clone_member.conn_url:
|
|
r = clone_member.conn_kwargs(self._replication)
|
|
connstring = 'postgres://{user}@{host}:{port}/{database}'.format(**r)
|
|
# add the credentials to connect to the replica origin to pgpass.
|
|
env = self.write_pgpass(r)
|
|
else:
|
|
connstring = ''
|
|
env = os.environ.copy()
|
|
# if we don't have any source, leave only replica methods that work without it
|
|
replica_methods = \
|
|
[r for r in replica_methods if self.replica_method_can_work_without_replication_connection(r)]
|
|
|
|
# go through them in priority order
|
|
ret = 1
|
|
for replica_method in replica_methods:
|
|
with self._cancellable_lock:
|
|
if self._is_cancelled:
|
|
break
|
|
# if the method is basebackup, then use the built-in
|
|
if replica_method == "basebackup":
|
|
ret = self.basebackup(connstring, env, self.config.get(replica_method, {}))
|
|
if ret == 0:
|
|
logger.info("replica has been created using basebackup")
|
|
# if basebackup succeeds, exit with success
|
|
break
|
|
else:
|
|
if not self.data_directory_empty():
|
|
self.remove_data_directory()
|
|
|
|
cmd = replica_method
|
|
method_config = {}
|
|
# user-defined method; check for configuration
|
|
# not required, actually
|
|
if self.config.get(replica_method, {}):
|
|
method_config = self.config[replica_method].copy()
|
|
# look to see if the user has supplied a full command path
|
|
# if not, use the method name as the command
|
|
cmd = method_config.pop('command', cmd)
|
|
|
|
# add the default parameters
|
|
method_config.update({"scope": self.scope,
|
|
"role": "replica",
|
|
"datadir": self._data_dir,
|
|
"connstring": connstring})
|
|
params = ["--{0}={1}".format(arg, val) for arg, val in method_config.items()]
|
|
try:
|
|
# call script with the full set of parameters
|
|
ret = self.cancellable_subprocess_call(shlex.split(cmd) + params, env=env)
|
|
# if we succeeded, stop
|
|
if ret == 0:
|
|
logger.info('replica has been created using %s', replica_method)
|
|
break
|
|
else:
|
|
logger.error('Error creating replica using method %s: %s exited with code=%s',
|
|
replica_method, cmd, ret)
|
|
except Exception:
|
|
logger.exception('Error creating replica using method %s', replica_method)
|
|
ret = 1
|
|
|
|
self.set_state('stopped')
|
|
return ret
|
|
|
|
def reset_cluster_info_state(self):
|
|
self._cluster_info_state = {}
|
|
|
|
def _cluster_info_state_get(self, name):
|
|
if not self._cluster_info_state:
|
|
stmt = cluster_info_query.format(self.wal_name, self.lsn_name)
|
|
try:
|
|
result = self._is_leader_retry(self._query, stmt).fetchone()
|
|
self._cluster_info_state = dict(zip(['timeline', 'wal_position'], result))
|
|
except RetryFailedError as e: # SELECT failed two times
|
|
self._cluster_info_state = {'error': str(e)}
|
|
if not self.is_starting() and self.pg_isready() == STATE_REJECT:
|
|
self.set_state('starting')
|
|
|
|
if 'error' in self._cluster_info_state:
|
|
raise PostgresConnectionException(self._cluster_info_state['error'])
|
|
|
|
return self._cluster_info_state.get(name)
|
|
|
|
def is_leader(self):
|
|
return bool(self._cluster_info_state_get('timeline'))
|
|
|
|
def is_running(self):
|
|
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
|
|
is running updates the cached process based on pid file."""
|
|
if self._postmaster_proc:
|
|
if self._postmaster_proc.is_running():
|
|
return self._postmaster_proc
|
|
self._postmaster_proc = None
|
|
|
|
# we noticed that postgres was restarted, force syncing of replication
|
|
self._schedule_load_slots = self.use_slots
|
|
|
|
self._postmaster_proc = PostmasterProcess.from_pidfile(self._data_dir)
|
|
return self._postmaster_proc
|
|
|
|
@property
|
|
def cb_called(self):
|
|
return self.__cb_called
|
|
|
|
def call_nowait(self, cb_name):
|
|
""" pick a callback command and call it without waiting for it to finish """
|
|
if self.bootstrapping:
|
|
return
|
|
if cb_name in (ACTION_ON_START, ACTION_ON_STOP, ACTION_ON_RESTART, ACTION_ON_ROLE_CHANGE):
|
|
self.__cb_called = True
|
|
|
|
if self.callback and cb_name in self.callback:
|
|
cmd = self.callback[cb_name]
|
|
try:
|
|
cmd = shlex.split(self.callback[cb_name]) + [cb_name, self.role, self.scope]
|
|
self._callback_executor.call(cmd)
|
|
except Exception:
|
|
logger.exception('callback %s %s %s %s failed', cmd, cb_name, self.role, self.scope)
|
|
|
|
@property
|
|
def role(self):
|
|
with self._role_lock:
|
|
return self._role
|
|
|
|
def set_role(self, value):
|
|
with self._role_lock:
|
|
self._role = value
|
|
|
|
@property
|
|
def state(self):
|
|
with self._state_lock:
|
|
return self._state
|
|
|
|
def set_state(self, value):
|
|
with self._state_lock:
|
|
self._state = value
|
|
self._state_entry_timestamp = time.time()
|
|
|
|
def time_in_state(self):
|
|
return time.time() - self._state_entry_timestamp
|
|
|
|
def is_starting(self):
|
|
return self.state == 'starting'
|
|
|
|
def wait_for_port_open(self, postmaster, timeout):
|
|
"""Waits until PostgreSQL opens ports."""
|
|
for _ in polling_loop(timeout):
|
|
with self._cancellable_lock:
|
|
if self._is_cancelled:
|
|
return False
|
|
|
|
if not postmaster.is_running():
|
|
logger.error('postmaster is not running')
|
|
self.set_state('start failed')
|
|
return False
|
|
|
|
isready = self.pg_isready()
|
|
if isready != STATE_NO_RESPONSE:
|
|
if isready not in [STATE_REJECT, STATE_RUNNING]:
|
|
logger.warning("Can't determine PostgreSQL startup status, assuming running")
|
|
return True
|
|
|
|
logger.warning("Timed out waiting for PostgreSQL to start")
|
|
return False
|
|
|
|
def _build_effective_configuration(self):
|
|
"""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."""
|
|
|
|
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._major_version >= 90400:
|
|
OPTIONS_MAPPING['max_worker_processes'] = 'max_worker_processes setting'
|
|
|
|
data = self.controldata()
|
|
effective_configuration = self._server_parameters.copy()
|
|
|
|
for name, cname in OPTIONS_MAPPING.items():
|
|
value = parse_int(effective_configuration[name])
|
|
cvalue = parse_int(data[cname])
|
|
if cvalue > value:
|
|
effective_configuration[name] = cvalue
|
|
self._pending_restart = True
|
|
return effective_configuration
|
|
|
|
def start(self, timeout=None, block_callbacks=False, task=None):
|
|
"""Start PostgreSQL
|
|
|
|
Waits for postmaster to open ports or terminate so pg_isready can be used to check startup completion
|
|
or failure.
|
|
|
|
:returns: True if start was initiated and postmaster ports are open, False if start failed"""
|
|
# make sure we close all connections established against
|
|
# the former node, otherwise, we might get a stalled one
|
|
# after kill -9, which would report incorrect data to
|
|
# patroni.
|
|
self.close_connection()
|
|
|
|
if self.is_running():
|
|
logger.error('Cannot start PostgreSQL because one is already running.')
|
|
return True
|
|
|
|
if not block_callbacks:
|
|
self.__cb_pending = ACTION_ON_START
|
|
|
|
self.set_role(self.get_postgres_role_from_data_directory())
|
|
|
|
self.set_state('starting')
|
|
self._pending_restart = False
|
|
|
|
configuration = self._server_parameters if self.role == 'master' else self._build_effective_configuration()
|
|
self._write_postgresql_conf(configuration)
|
|
self.resolve_connection_addresses()
|
|
self._replace_pg_hba()
|
|
|
|
options = ['--{0}={1}'.format(p, configuration[p]) for p in self.CMDLINE_OPTIONS
|
|
if p in configuration and p != 'wal_keep_segments']
|
|
|
|
with self._cancellable_lock:
|
|
if self._is_cancelled:
|
|
return False
|
|
|
|
with task or null_context():
|
|
if task and task.is_cancelled:
|
|
logger.info("PostgreSQL start cancelled.")
|
|
return False
|
|
|
|
self._postmaster_proc = PostmasterProcess.start(self._pgcommand('postgres'),
|
|
self._data_dir,
|
|
self._postgresql_conf,
|
|
options)
|
|
|
|
if task:
|
|
task.complete(self._postmaster_proc)
|
|
|
|
start_timeout = timeout
|
|
if not start_timeout:
|
|
try:
|
|
start_timeout = float(self.config.get('pg_ctl_timeout', 60))
|
|
except ValueError:
|
|
start_timeout = 60
|
|
|
|
# We want postmaster to open ports before we continue
|
|
if not self._postmaster_proc or not self.wait_for_port_open(self._postmaster_proc, start_timeout):
|
|
return False
|
|
|
|
ret = self.wait_for_startup(start_timeout)
|
|
if ret is not None:
|
|
return ret
|
|
elif timeout is not None:
|
|
return False
|
|
else:
|
|
return None
|
|
|
|
def checkpoint(self, connect_kwargs=None):
|
|
check_not_is_in_recovery = connect_kwargs is not None
|
|
connect_kwargs = connect_kwargs or self._local_connect_kwargs
|
|
for p in ['connect_timeout', 'options']:
|
|
connect_kwargs.pop(p, None)
|
|
try:
|
|
with self._get_connection_cursor(**connect_kwargs) as cur:
|
|
cur.execute("SET statement_timeout = 0")
|
|
if check_not_is_in_recovery:
|
|
cur.execute('SELECT pg_is_in_recovery()')
|
|
if cur.fetchone()[0]:
|
|
return 'is_in_recovery=true'
|
|
return cur.execute('CHECKPOINT')
|
|
except psycopg2.Error:
|
|
logger.exception('Exception during CHECKPOINT')
|
|
return 'not accessible or not healty'
|
|
|
|
def stop(self, mode='fast', block_callbacks=False, checkpoint=None, on_safepoint=None):
|
|
"""Stop PostgreSQL
|
|
|
|
Supports a callback when a safepoint is reached. A safepoint is when no user backend can return a successful
|
|
commit to users. Currently this means we wait for user backends to close. But in the future alternate mechanisms
|
|
could be added.
|
|
|
|
:param on_safepoint: This callback is called when no user backends are running.
|
|
"""
|
|
if checkpoint is None:
|
|
checkpoint = False if mode == 'immediate' else True
|
|
|
|
success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint)
|
|
if success:
|
|
# block_callbacks is used during restart to avoid
|
|
# running start/stop callbacks in addition to restart ones
|
|
if not block_callbacks:
|
|
self.set_state('stopped')
|
|
if pg_signaled:
|
|
self.call_nowait(ACTION_ON_STOP)
|
|
else:
|
|
logger.warning('pg_ctl stop failed')
|
|
self.set_state('stop failed')
|
|
return success
|
|
|
|
def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint):
|
|
postmaster = self.is_running()
|
|
if not postmaster:
|
|
if on_safepoint:
|
|
on_safepoint()
|
|
return True, False
|
|
|
|
if checkpoint and not self.is_starting():
|
|
self.checkpoint()
|
|
|
|
if not block_callbacks:
|
|
self.set_state('stopping')
|
|
|
|
# Send signal to postmaster to stop
|
|
success = postmaster.signal_stop(mode)
|
|
if success is not None:
|
|
if success and on_safepoint:
|
|
on_safepoint()
|
|
return success, True
|
|
|
|
# We can skip safepoint detection if we don't have a callback
|
|
if on_safepoint:
|
|
# Wait for our connection to terminate so we can be sure that no new connections are being initiated
|
|
self._wait_for_connection_close(postmaster)
|
|
postmaster.wait_for_user_backends_to_close()
|
|
on_safepoint()
|
|
|
|
postmaster.wait()
|
|
|
|
return True, True
|
|
|
|
@staticmethod
|
|
def terminate_starting_postmaster(postmaster):
|
|
"""Terminates a postmaster that has not yet opened ports or possibly even written a pid file. Blocks
|
|
until the process goes away."""
|
|
postmaster.signal_stop('immediate')
|
|
postmaster.wait()
|
|
|
|
def _wait_for_connection_close(self, postmaster):
|
|
try:
|
|
with self.connection().cursor() as cur:
|
|
while postmaster.is_running(): # Need a timeout here?
|
|
cur.execute("SELECT 1")
|
|
time.sleep(STOP_POLLING_INTERVAL)
|
|
except psycopg2.Error:
|
|
pass
|
|
|
|
def reload(self):
|
|
ret = self.pg_ctl('reload')
|
|
if ret:
|
|
self.call_nowait(ACTION_ON_RELOAD)
|
|
return ret
|
|
|
|
def check_for_startup(self):
|
|
"""Checks PostgreSQL status and returns if PostgreSQL is in the middle of startup."""
|
|
return self.is_starting() and not self.check_startup_state_changed()
|
|
|
|
def check_startup_state_changed(self):
|
|
"""Checks if PostgreSQL has completed starting up or failed or still starting.
|
|
|
|
Should only be called when state == 'starting'
|
|
|
|
:returns: True if state was changed from 'starting'
|
|
"""
|
|
ready = self.pg_isready()
|
|
|
|
if ready == STATE_REJECT:
|
|
return False
|
|
elif ready == STATE_NO_RESPONSE:
|
|
self.set_state('start failed')
|
|
self._schedule_load_slots = False # TODO: can remove this?
|
|
if not self._running_custom_bootstrap:
|
|
self.save_configuration_files() # TODO: maybe remove this?
|
|
return True
|
|
else:
|
|
if ready != STATE_RUNNING:
|
|
# Bad configuration or unexpected OS error. No idea of PostgreSQL status.
|
|
# Let the main loop of run cycle clean up the mess.
|
|
logger.warning("%s status returned from pg_isready",
|
|
"Unknown" if ready == STATE_UNKNOWN else "Invalid")
|
|
self.set_state('running')
|
|
self._schedule_load_slots = self.use_slots
|
|
if not self._running_custom_bootstrap:
|
|
self.save_configuration_files()
|
|
# TODO: __cb_pending can be None here after PostgreSQL restarts on its own. Do we want to call the callback?
|
|
# Previously we didn't even notice.
|
|
action = self.__cb_pending or ACTION_ON_START
|
|
self.call_nowait(action)
|
|
self.__cb_pending = None
|
|
|
|
return True
|
|
|
|
def wait_for_startup(self, timeout=None):
|
|
"""Waits for PostgreSQL startup to complete or fail.
|
|
|
|
:returns: True if start was successful, False otherwise"""
|
|
if not self.is_starting():
|
|
# Should not happen
|
|
logger.warning("wait_for_startup() called when not in starting state")
|
|
|
|
while not self.check_startup_state_changed():
|
|
with self._cancellable_lock:
|
|
if self._is_cancelled:
|
|
return None
|
|
if timeout and self.time_in_state() > timeout:
|
|
return None
|
|
time.sleep(1)
|
|
|
|
return self.state == 'running'
|
|
|
|
def restart(self, timeout=None, task=None):
|
|
"""Restarts PostgreSQL.
|
|
|
|
When timeout parameter is set the call will block either until PostgreSQL has started, failed to start or
|
|
timeout arrives.
|
|
|
|
:returns: True when restart was successful and timeout did not expire when waiting.
|
|
"""
|
|
self.set_state('restarting')
|
|
self.__cb_pending = ACTION_ON_RESTART
|
|
ret = self.stop(block_callbacks=True) and self.start(timeout=timeout, block_callbacks=True, task=task)
|
|
if not ret and not self.is_starting():
|
|
self.set_state('restart failed ({0})'.format(self.state))
|
|
return ret
|
|
|
|
def _write_postgresql_conf(self, configuration=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)
|
|
|
|
with open(self._postgresql_conf, 'w') as f:
|
|
f.write(self._CONFIG_WARNING_HEADER)
|
|
f.write("include '{0}'\n\n".format(self.config.get('custom_conf') or self._postgresql_base_conf_name))
|
|
for name, value in sorted((configuration or self._server_parameters).items()):
|
|
if not self._running_custom_bootstrap or name != 'hba_file':
|
|
f.write("{0} = '{1}'\n".format(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 overriden
|
|
# after changing superuser password we will "revert" all these "changes"
|
|
if self._running_custom_bootstrap or 'hba_file' not in self._server_parameters:
|
|
f.write("hba_file = '{0}'\n".format(self._pg_hba_conf.replace('\\', '\\\\')))
|
|
if 'ident_file' not in self._server_parameters:
|
|
s = "ident_file = '{0}'\n".format(os.path.join(self._config_dir, 'pg_ident.conf').replace('\\', '\\\\'))
|
|
f.write(s)
|
|
|
|
def is_healthy(self):
|
|
if not self.is_running():
|
|
logger.warning('Postgresql is not running.')
|
|
return False
|
|
return True
|
|
|
|
def write_pg_hba(self, config):
|
|
if not self._server_parameters.get('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)))
|
|
return True
|
|
|
|
def _replace_pg_hba(self):
|
|
"""
|
|
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._running_custom_bootstrap:
|
|
addresses = {'': 'local'}
|
|
if 'host' in self._local_address and not self._local_address['host'].startswith('/'):
|
|
for _, _, _, _, sa in socket.getaddrinfo(self._local_address['host'], self._local_address['port'],
|
|
0, socket.SOCK_STREAM, socket.IPPROTO_TCP):
|
|
addresses[sa[0] + '/32'] = 'host'
|
|
|
|
with open(self._pg_hba_conf, 'w') as f:
|
|
f.write(self._CONFIG_WARNING_HEADER)
|
|
for address, t in addresses.items():
|
|
f.write('{0}\t{1}\t{2}\t{3}\ttrust\n'.format(t, 'all',
|
|
self._superuser.get('username') or 'all', address))
|
|
elif not self._server_parameters.get('hba_file') and self.config.get('pg_hba'):
|
|
with open(self._pg_hba_conf, 'w') as f:
|
|
f.write(self._CONFIG_WARNING_HEADER)
|
|
for line in self.config['pg_hba']:
|
|
f.write('{0}\n'.format(line))
|
|
return True
|
|
|
|
def primary_conninfo(self, member):
|
|
if not (member and member.conn_url) or member.name == self.name:
|
|
return None
|
|
r = member.conn_kwargs(self._replication)
|
|
r.update({'application_name': self.name, 'sslmode': 'prefer', 'sslcompression': '1'})
|
|
keywords = 'user password host port sslmode sslcompression application_name'.split()
|
|
return ' '.join('{0}={{{0}}}'.format(kw) for kw in keywords).format(**r)
|
|
|
|
def check_recovery_conf(self, member):
|
|
# TODO: recovery.conf could be stale, would be nice to detect that.
|
|
primary_conninfo = self.primary_conninfo(member)
|
|
|
|
if not os.path.isfile(self._recovery_conf):
|
|
return False
|
|
|
|
with open(self._recovery_conf, 'r') as f:
|
|
for line in f:
|
|
if line.startswith('primary_conninfo'):
|
|
return primary_conninfo and (primary_conninfo in line)
|
|
return not primary_conninfo
|
|
|
|
def write_recovery_conf(self, recovery_params):
|
|
with open(self._recovery_conf, 'w') as f:
|
|
for name, value in recovery_params.items():
|
|
f.write("{0} = '{1}'\n".format(name, value))
|
|
|
|
def pg_rewind(self, r):
|
|
# prepare pg_rewind connection
|
|
env = self.write_pgpass(r)
|
|
dsn_attrs = [
|
|
('user', r.get('user')),
|
|
('host', r.get('host')),
|
|
('port', r.get('port')),
|
|
('dbname', r.get('database')),
|
|
('sslmode', 'prefer'),
|
|
('sslcompression', '1'),
|
|
]
|
|
dsn = " ".join("{0}={1}".format(k, v) for k, v in dsn_attrs if v is not None)
|
|
logger.info('running pg_rewind from %s', dsn)
|
|
try:
|
|
return self.cancellable_subprocess_call([self._pgcommand('pg_rewind'),
|
|
'-D', self._data_dir,
|
|
'--source-server', dsn], env=env) == 0
|
|
except OSError:
|
|
return False
|
|
|
|
def controldata(self):
|
|
""" return the contents of pg_controldata, or non-True value if pg_controldata call failed """
|
|
result = {}
|
|
# Don't try to call pg_controldata during backup restore
|
|
if self._version_file_exists() and self.state != 'creating replica':
|
|
try:
|
|
env = {'LANG': 'C', 'LC_ALL': 'C', 'PATH': os.getenv('PATH')}
|
|
if os.getenv('SYSTEMROOT') is not None:
|
|
env['SYSTEMROOT'] = os.getenv('SYSTEMROOT')
|
|
data = subprocess.check_output([self._pgcommand('pg_controldata'), self._data_dir], env=env)
|
|
if data:
|
|
data = data.decode('utf-8').splitlines()
|
|
# pg_controldata output depends on major verion. Some of parameters are prefixed by 'Current '
|
|
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':', 1)[1].strip() for l in data
|
|
if l and ':' in l}
|
|
except subprocess.CalledProcessError:
|
|
logger.exception("Error when calling pg_controldata")
|
|
return result
|
|
|
|
@property
|
|
def need_rewind(self):
|
|
return self._rewind_state in (REWIND_STATUS.CHECK, REWIND_STATUS.NEED)
|
|
|
|
@staticmethod
|
|
@contextmanager
|
|
def _get_connection_cursor(**kwargs):
|
|
with psycopg2.connect(**kwargs) as conn:
|
|
conn.autocommit = True
|
|
with conn.cursor() as cur:
|
|
yield cur
|
|
|
|
@contextmanager
|
|
def _get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs):
|
|
with self._get_connection_cursor(host=host, port=int(port), database=self._database, replication=1,
|
|
user=self._replication['username'], password=self._replication['password'],
|
|
connect_timeout=3, options='-c statement_timeout=2000') as cur:
|
|
yield cur
|
|
|
|
def check_leader_is_not_in_recovery(self, **kwargs):
|
|
try:
|
|
with self._get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **kwargs) as cur:
|
|
cur.execute('SELECT pg_is_in_recovery()')
|
|
if not cur.fetchone()[0]:
|
|
return True
|
|
logger.info('Leader is still in_recovery and therefore can\'t be used for rewind')
|
|
except Exception:
|
|
return logger.exception('Exception when working with leader')
|
|
|
|
def _get_local_timeline_lsn_from_replication_connection(self):
|
|
timeline = lsn = None
|
|
try:
|
|
with self._get_replication_connection_cursor(**self._local_replication_address) as cur:
|
|
cur.execute('IDENTIFY_SYSTEM')
|
|
timeline, lsn = cur.fetchone()[1:3]
|
|
except Exception:
|
|
logger.exception('Can not fetch local timeline and lsn from replication connection')
|
|
return timeline, lsn
|
|
|
|
def _get_local_timeline_lsn_from_controldata(self):
|
|
timeline = lsn = None
|
|
data = self.controldata()
|
|
try:
|
|
if data.get('Database cluster state') == 'shut down in recovery':
|
|
lsn = data.get('Minimum recovery ending location')
|
|
timeline = int(data.get("Min recovery ending loc's timeline"))
|
|
if lsn == '0/0' or timeline == 0: # it was a master when it crashed
|
|
data['Database cluster state'] = 'shut down'
|
|
if data.get('Database cluster state') == 'shut down':
|
|
lsn = data.get('Latest checkpoint location')
|
|
timeline = int(data.get("Latest checkpoint's TimeLineID"))
|
|
except (TypeError, ValueError):
|
|
logger.exception('Failed to get local timeline and lsn from pg_controldata output')
|
|
return timeline, lsn
|
|
|
|
def _get_local_timeline_lsn(self):
|
|
if self.is_running(): # if postgres is running - get timeline and lsn from replication connection
|
|
timeline, lsn = self._get_local_timeline_lsn_from_replication_connection()
|
|
else: # otherwise analyze pg_controldata output
|
|
timeline, lsn = self._get_local_timeline_lsn_from_controldata()
|
|
logger.info('Local timeline=%s lsn=%s', timeline, lsn)
|
|
return timeline, lsn
|
|
|
|
@staticmethod
|
|
def parse_lsn(lsn):
|
|
t = lsn.split('/')
|
|
return int(t[0], 16) * 0x100000000 + int(t[1], 16)
|
|
|
|
@staticmethod
|
|
def parse_history(data):
|
|
for line in data.split('\n'):
|
|
values = line.strip().split('\t')
|
|
if len(values) == 3:
|
|
try:
|
|
values[0] = int(values[0])
|
|
values[1] = Postgresql.parse_lsn(values[1])
|
|
yield values
|
|
except (IndexError, ValueError):
|
|
logger.exception('Exception when parsing timeline history line "%s"', values)
|
|
|
|
def _check_timeline_and_lsn(self, leader):
|
|
local_timeline, local_lsn = self._get_local_timeline_lsn()
|
|
if local_timeline is None or local_lsn is None:
|
|
return
|
|
|
|
if not self.check_leader_is_not_in_recovery(**leader.conn_kwargs(self._superuser)):
|
|
return
|
|
|
|
history = need_rewind = None
|
|
try:
|
|
with self._get_replication_connection_cursor(**leader.conn_kwargs()) as cur:
|
|
cur.execute('IDENTIFY_SYSTEM')
|
|
master_timeline = cur.fetchone()[1]
|
|
logger.info('master_timeline=%s', master_timeline)
|
|
if local_timeline > master_timeline: # Not always supported by pg_rewind
|
|
need_rewind = True
|
|
elif master_timeline > 1:
|
|
cur.execute('TIMELINE_HISTORY %s', (master_timeline,))
|
|
history = bytes(cur.fetchone()[1]).decode('utf-8')
|
|
logger.info('master: history=%s', history)
|
|
else: # local_timeline == master_timeline == 1
|
|
need_rewind = False
|
|
except Exception:
|
|
return logger.exception('Exception when working with master via replication connection')
|
|
|
|
if history is not None:
|
|
for parent_timeline, switchpoint, _ in self.parse_history(history):
|
|
if parent_timeline == local_timeline:
|
|
try:
|
|
need_rewind = self.parse_lsn(local_lsn) >= switchpoint
|
|
except (IndexError, ValueError):
|
|
logger.exception('Exception when parsing lsn')
|
|
break
|
|
elif parent_timeline > local_timeline:
|
|
break
|
|
|
|
self._rewind_state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED
|
|
|
|
def get_replica_timeline(self):
|
|
return self._get_local_timeline_lsn_from_replication_connection()[0]
|
|
|
|
def replica_cached_timeline(self, master_timeline):
|
|
if not self._cached_replica_timeline or not master_timeline or self._cached_replica_timeline != master_timeline:
|
|
self._cached_replica_timeline = self.get_replica_timeline()
|
|
return self._cached_replica_timeline
|
|
|
|
def get_master_timeline(self):
|
|
return self._cluster_info_state_get('timeline')
|
|
|
|
def get_history(self, timeline):
|
|
history_path = 'pg_{0}/{1:08X}.history'.format(self.wal_name, timeline)
|
|
try:
|
|
cursor = self._cursor()
|
|
cursor.execute('SELECT isdir, modification FROM pg_stat_file(%s)', (history_path,))
|
|
isdir, modification = cursor.fetchone()
|
|
if not isdir:
|
|
cursor.execute('SELECT pg_read_file(%s)', (history_path,))
|
|
history = list(self.parse_history(cursor.fetchone()[0]))
|
|
if history[-1][0] == timeline - 1:
|
|
history[-1].append(modification.isoformat())
|
|
return history
|
|
except Exception:
|
|
logger.exception('Failed to read and parse %s', (history_path,))
|
|
|
|
def rewind(self, leader):
|
|
if self.is_running() and not self.stop(checkpoint=False):
|
|
return logger.warning('Can not run pg_rewind because postgres is still running')
|
|
|
|
# prepare pg_rewind connection
|
|
r = leader.conn_kwargs(self._superuser)
|
|
|
|
# first make sure that we are really trying to rewind
|
|
# from the master and run a checkpoint on it in order to
|
|
# make it store the new timeline ([email protected])
|
|
leader_status = self.checkpoint(r)
|
|
if leader_status:
|
|
return logger.warning('Can not use %s for rewind: %s', leader.name, leader_status)
|
|
|
|
if self.pg_rewind(r):
|
|
self._rewind_state = REWIND_STATUS.SUCCESS
|
|
elif not self.check_leader_is_not_in_recovery(**r):
|
|
logger.warning('Failed to rewind because master %s become unreachable', leader.name)
|
|
else:
|
|
logger.error('Failed to rewind from healty master: %s', leader.name)
|
|
|
|
if self.config.get('remove_data_directory_on_rewind_failure', False):
|
|
logger.warning('remove_data_directory_on_rewind_failure is set. removing...')
|
|
self.remove_data_directory()
|
|
self._rewind_state = REWIND_STATUS.INITIAL
|
|
else:
|
|
self._rewind_state = REWIND_STATUS.FAILED
|
|
return False
|
|
|
|
def trigger_check_diverged_lsn(self):
|
|
if self.can_rewind and self._rewind_state != REWIND_STATUS.NEED:
|
|
self._rewind_state = REWIND_STATUS.CHECK
|
|
|
|
def rewind_needed_and_possible(self, leader):
|
|
if leader and leader.name != self.name and leader.conn_url and self._rewind_state == REWIND_STATUS.CHECK:
|
|
self._check_timeline_and_lsn(leader)
|
|
return leader and leader.conn_url and self._rewind_state == REWIND_STATUS.NEED
|
|
|
|
@property
|
|
def rewind_executed(self):
|
|
return self._rewind_state > REWIND_STATUS.NOT_NEED
|
|
|
|
@property
|
|
def rewind_failed(self):
|
|
return self._rewind_state == REWIND_STATUS.FAILED
|
|
|
|
def follow(self, member, timeout=None):
|
|
is_remote_master = isinstance(member, RemoteMember)
|
|
no_replication_slot = is_remote_master and member.no_replication_slot
|
|
restore_command = is_remote_master and member.restore_command
|
|
min_apply_delay = is_remote_master and member.recovery_min_apply_delay
|
|
archive_cleanup = is_remote_master and member.archive_cleanup_command
|
|
|
|
primary_conninfo = self.primary_conninfo(member)
|
|
change_role = self.role in ('master', 'demoted')
|
|
|
|
recovery_params = self.config.get('recovery_conf', {}).copy()
|
|
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
|
|
if primary_conninfo:
|
|
recovery_params['primary_conninfo'] = primary_conninfo
|
|
if self.use_slots and not no_replication_slot:
|
|
required_name = is_remote_master and member.data.get('primary_slot_name')
|
|
name = required_name or slot_name_from_member_name(self.name)
|
|
recovery_params['primary_slot_name'] = name
|
|
if restore_command:
|
|
recovery_params['restore_command'] = restore_command
|
|
if min_apply_delay:
|
|
recovery_params['recovery_min_apply_delay'] = min_apply_delay
|
|
if archive_cleanup:
|
|
recovery_params['archive_cleanup_command'] = archive_cleanup
|
|
|
|
self.write_recovery_conf(recovery_params)
|
|
|
|
if self.is_running():
|
|
self.restart()
|
|
else:
|
|
self.start(timeout=timeout)
|
|
self.set_role('replica')
|
|
|
|
if change_role:
|
|
# TODO: postpone this until start completes, or maybe do even earlier
|
|
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
|
return True
|
|
|
|
def save_configuration_files(self):
|
|
"""
|
|
copy postgresql.conf to postgresql.conf.backup to be able to retrive 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)
|
|
"""
|
|
try:
|
|
for f in self._configuration_to_save:
|
|
config_file = os.path.join(self._config_dir, f)
|
|
backup_file = os.path.join(self._data_dir, f + '.backup')
|
|
if os.path.isfile(config_file):
|
|
shutil.copy(config_file, backup_file)
|
|
except IOError:
|
|
logger.exception('unable to create backup copies of configuration files')
|
|
return True
|
|
|
|
def restore_configuration_files(self):
|
|
""" 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._data_dir, f + '.backup')
|
|
if not os.path.isfile(config_file):
|
|
if os.path.isfile(backup_file):
|
|
shutil.copy(backup_file, 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()
|
|
except IOError:
|
|
logger.exception('unable to restore configuration files from backup')
|
|
|
|
def _wait_promote(self, wait_seconds):
|
|
for _ in polling_loop(wait_seconds - 1):
|
|
data = self.controldata()
|
|
if data.get('Database cluster state') == 'in production':
|
|
return True
|
|
|
|
def promote(self, wait_seconds):
|
|
if self.role == 'master':
|
|
return True
|
|
ret = self.pg_ctl('promote', '-W')
|
|
if ret:
|
|
self.set_role('master')
|
|
logger.info("cleared rewind state after becoming the leader")
|
|
self._rewind_state = REWIND_STATUS.INITIAL
|
|
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
|
ret = self._wait_promote(wait_seconds)
|
|
return ret
|
|
|
|
def create_or_update_role(self, name, password, options):
|
|
options = list(map(str.upper, options))
|
|
if 'NOLOGIN' not in options and 'LOGIN' not in options:
|
|
options.append('LOGIN')
|
|
|
|
self.query("""DO $$
|
|
BEGIN
|
|
SET local synchronous_commit = 'local';
|
|
PERFORM * FROM pg_authid WHERE rolname = %s;
|
|
IF FOUND THEN
|
|
ALTER ROLE "{0}" WITH {1} PASSWORD %s;
|
|
ELSE
|
|
CREATE ROLE "{0}" WITH {1} PASSWORD %s;
|
|
END IF;
|
|
END;
|
|
$$""".format(name, ' '.join(options)), name, password, password)
|
|
|
|
def timeline_wal_position(self):
|
|
# This method could be called from different threads (simultaneously with some other `_query` calls).
|
|
# If it is called not from main thread we will create a new cursor to execute statement.
|
|
if current_thread().ident == self.__thread_ident:
|
|
return self._cluster_info_state_get('timeline'), self._cluster_info_state_get('wal_position')
|
|
|
|
with self.connection().cursor() as cursor:
|
|
cursor.execute(cluster_info_query.format(self.wal_name, self.lsn_name))
|
|
return cursor.fetchone()[:2]
|
|
|
|
def load_replication_slots(self):
|
|
if self.use_slots and self._schedule_load_slots:
|
|
cursor = self._query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'")
|
|
self._replication_slots = [r[0] for r in cursor]
|
|
self._schedule_load_slots = False
|
|
|
|
def postmaster_start_time(self):
|
|
try:
|
|
cursor = self.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ')""")
|
|
return cursor.fetchone()[0]
|
|
except psycopg2.Error:
|
|
return None
|
|
|
|
def sync_replication_slots(self, cluster):
|
|
if self.use_slots:
|
|
try:
|
|
self.load_replication_slots()
|
|
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
|
|
# the current master, because that member would replicate from elsewhere. We still create the slot if
|
|
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
|
|
# master), or if replicatefrom destination member happens to be the current master
|
|
if self.role in ('master', 'standby_leader'):
|
|
slot_members = [m.name for m in cluster.members if m.name != self.name and
|
|
(m.replicatefrom is None or m.replicatefrom == self.name or
|
|
not cluster.has_member(m.replicatefrom))]
|
|
else:
|
|
# only manage slots for replicas that replicate from this one, except for the leader among them
|
|
slot_members = [m.name for m in cluster.members if m.replicatefrom == self.name and
|
|
m.name != cluster.leader.name]
|
|
slots = set(slot_name_from_member_name(name) for name in slot_members)
|
|
|
|
if len(slots) < len(slot_members):
|
|
# Find which names are conflicting for a nicer error message
|
|
slot_conflicts = defaultdict(list)
|
|
for name in slot_members:
|
|
slot_conflicts[slot_name_from_member_name(name)].append(name)
|
|
logger.error("Following cluster members share a replication slot name: %s",
|
|
"; ".join("{} map to {}".format(", ".join(v), k)
|
|
for k, v in slot_conflicts.items() if len(v) > 1))
|
|
|
|
# drop unused slots
|
|
for slot in set(self._replication_slots) - slots:
|
|
cursor = self._query("""SELECT pg_drop_replication_slot(%s)
|
|
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
|
|
WHERE slot_name = %s AND NOT active)""", slot, slot)
|
|
|
|
if cursor.rowcount != 1: # Either slot doesn't exists or it is still active
|
|
self._schedule_load_slots = True # schedule load_replication_slots on the next iteration
|
|
|
|
immediately_reserve = ', true' if self._major_version >= 90600 else ''
|
|
|
|
# create new slots
|
|
for slot in slots - set(self._replication_slots):
|
|
self._query("""SELECT pg_create_physical_replication_slot(%s{0})
|
|
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
|
|
WHERE slot_name = %s)""".format(immediately_reserve), slot, slot)
|
|
|
|
self._replication_slots = slots
|
|
except Exception:
|
|
logger.exception('Exception when changing replication slots')
|
|
self._schedule_load_slots = True
|
|
|
|
def last_operation(self):
|
|
return str(self._cluster_info_state_get('wal_position'))
|
|
|
|
def _post_restore(self):
|
|
self.delete_trigger_file()
|
|
self.restore_configuration_files()
|
|
|
|
def _configure_server_parameters(self):
|
|
self._major_version = self.get_major_version()
|
|
self._server_parameters = self.get_server_parameters(self.config)
|
|
return True
|
|
|
|
def clone(self, clone_member):
|
|
"""
|
|
- initialize the replica from an existing member (master or replica)
|
|
- initialize the replica using the replica creation method that
|
|
works without the replication connection (i.e. restore from on-disk
|
|
base backup)
|
|
"""
|
|
|
|
ret = self.create_replica(clone_member) == 0
|
|
if ret:
|
|
self._post_restore()
|
|
self._configure_server_parameters()
|
|
return ret
|
|
|
|
def bootstrap(self, config):
|
|
""" Initialize a new node from scratch and start it. """
|
|
pg_hba = config.get('pg_hba', [])
|
|
method = config.get('method') or 'initdb'
|
|
self._running_custom_bootstrap = method != 'initdb' and method in config and 'command' in config[method]
|
|
if self._running_custom_bootstrap:
|
|
do_initialize = self._custom_bootstrap
|
|
config = config[method]
|
|
else:
|
|
do_initialize = self._initdb
|
|
return do_initialize(config) and self.write_pg_hba(pg_hba) and self.save_configuration_files() \
|
|
and self._configure_server_parameters() and self.start()
|
|
|
|
def post_bootstrap(self, config, task):
|
|
try:
|
|
self.create_or_update_role(self._superuser['username'], self._superuser['password'], ['SUPERUSER'])
|
|
|
|
task.complete(self.run_bootstrap_post_init(config))
|
|
if task.result:
|
|
self.create_or_update_role(self._replication['username'],
|
|
self._replication['password'], ['REPLICATION'])
|
|
for name, value in (config.get('users') or {}).items():
|
|
if name not in (self._superuser.get('username'), self._replication['username']):
|
|
self.create_or_update_role(name, value['password'], value.get('options', []))
|
|
|
|
# We were doing a custom bootstrap instead of running initdb, therefore we opened trust
|
|
# access from certain addresses to be able to reach cluster and change password
|
|
if self._running_custom_bootstrap:
|
|
self._running_custom_bootstrap = False
|
|
# If we don't have custom configuration for pg_hba.conf we need to restore original file
|
|
if not self.config.get('pg_hba'):
|
|
os.unlink(self._pg_hba_conf)
|
|
self.restore_configuration_files()
|
|
self._write_postgresql_conf()
|
|
if self._server_parameters.get('hba_file') and \
|
|
self._server_parameters['hba_file'] != self._pg_hba_conf:
|
|
self.restart()
|
|
else:
|
|
self._replace_pg_hba()
|
|
if self.pending_restart:
|
|
self.restart()
|
|
else:
|
|
self.reload()
|
|
time.sleep(1) # give a time to postgres to "reload" configuration files
|
|
self.close_connection() # close connection to reconnect with a new password
|
|
except Exception:
|
|
logger.exception('post_bootstrap')
|
|
task.complete(False)
|
|
return task.result
|
|
|
|
def move_data_directory(self):
|
|
if os.path.isdir(self._data_dir) and not self.is_running():
|
|
try:
|
|
new_name = '{0}_{1}'.format(self._data_dir, time.strftime('%Y-%m-%d-%H-%M-%S'))
|
|
logger.info('renaming data directory to %s', new_name)
|
|
os.rename(self._data_dir, new_name)
|
|
except OSError:
|
|
logger.exception("Could not rename data directory %s", self._data_dir)
|
|
|
|
def remove_data_directory(self):
|
|
self.set_role('uninitialized')
|
|
logger.info('Removing data directory: %s', self._data_dir)
|
|
try:
|
|
if os.path.islink(self._data_dir):
|
|
os.unlink(self._data_dir)
|
|
elif not os.path.exists(self._data_dir):
|
|
return
|
|
elif os.path.isfile(self._data_dir):
|
|
os.remove(self._data_dir)
|
|
elif os.path.isdir(self._data_dir):
|
|
shutil.rmtree(self._data_dir)
|
|
except (IOError, OSError):
|
|
logger.exception('Could not remove data directory %s', self._data_dir)
|
|
self.move_data_directory()
|
|
|
|
def basebackup(self, conn_url, env, options):
|
|
# creates a replica data dir using pg_basebackup.
|
|
# this is the default, built-in create_replica_methods
|
|
# tries twice, then returns failure (as 1)
|
|
# uses "stream" as the xlog-method to avoid sync issues
|
|
# supports additional user-supplied options, those are not validated
|
|
maxfailures = 2
|
|
ret = 1
|
|
not_allowed_options = ('pgdata', 'format', 'wal-method', 'xlog-method', 'gzip',
|
|
'version', 'compress', 'dbname', 'host', 'port', 'username', 'password')
|
|
user_options = self.process_user_options('basebackup', options, not_allowed_options, logger.error)
|
|
|
|
for bbfailures in range(0, maxfailures):
|
|
with self._cancellable_lock:
|
|
if self._is_cancelled:
|
|
break
|
|
if not self.data_directory_empty():
|
|
self.remove_data_directory()
|
|
try:
|
|
ret = self.cancellable_subprocess_call([self._pgcommand('pg_basebackup'), '--pgdata=' + self._data_dir,
|
|
'-X', 'stream', '--dbname=' + conn_url] + user_options, env=env)
|
|
if ret == 0:
|
|
break
|
|
else:
|
|
logger.error('Error when fetching backup: pg_basebackup exited with code=%s', ret)
|
|
|
|
except Exception as e:
|
|
logger.error('Error when fetching backup with pg_basebackup: %s', e)
|
|
|
|
if bbfailures < maxfailures - 1:
|
|
logger.warning('Trying again in 5 seconds')
|
|
time.sleep(5)
|
|
|
|
return ret
|
|
|
|
def pick_synchronous_standby(self, cluster):
|
|
"""Finds the best candidate to be the synchronous standby.
|
|
|
|
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
|
|
synchronous standby any longer.
|
|
|
|
:returns tuple of candidate name or None, and bool showing if the member is the active synchronous standby.
|
|
"""
|
|
current = cluster.sync.sync_standby
|
|
current = current.lower() if current else current
|
|
members = {m.name.lower(): m for m in cluster.members}
|
|
candidates = []
|
|
# Pick candidates based on who has flushed WAL farthest.
|
|
# TODO: for synchronous_commit = remote_write we actually want to order on write_location
|
|
for app_name, state, sync_state in self.query(
|
|
"""SELECT LOWER(application_name), state, sync_state
|
|
FROM pg_stat_replication
|
|
ORDER BY flush_{0} DESC""".format(self.lsn_name)):
|
|
member = members.get(app_name)
|
|
if state != 'streaming' or not member or member.tags.get('nosync', False):
|
|
continue
|
|
if sync_state == 'sync':
|
|
return app_name, True
|
|
if sync_state == 'potential' and app_name == current:
|
|
# Prefer current even if not the best one any more to avoid indecisivness and spurious swaps.
|
|
return current, False
|
|
if sync_state in ('async', 'potential'):
|
|
candidates.append(app_name)
|
|
|
|
if candidates:
|
|
return candidates[0], False
|
|
return None, False
|
|
|
|
def set_synchronous_standby(self, name):
|
|
"""Sets a node to be synchronous standby and if changed does a reload for PostgreSQL."""
|
|
if name and name != '*':
|
|
name = quote_ident(name)
|
|
if name != self._synchronous_standby_names:
|
|
if name is None:
|
|
self._server_parameters.pop('synchronous_standby_names', None)
|
|
else:
|
|
self._server_parameters['synchronous_standby_names'] = name
|
|
self._synchronous_standby_names = name
|
|
if self.state == 'running':
|
|
self._write_postgresql_conf()
|
|
self.reload()
|
|
|
|
@staticmethod
|
|
def postgres_version_to_int(pg_version):
|
|
"""Convert the server_version to integer
|
|
|
|
>>> Postgresql.postgres_version_to_int('9.5.3')
|
|
90503
|
|
>>> Postgresql.postgres_version_to_int('9.3.13')
|
|
90313
|
|
>>> Postgresql.postgres_version_to_int('10.1')
|
|
100001
|
|
>>> Postgresql.postgres_version_to_int('10') # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
Traceback (most recent call last):
|
|
...
|
|
PostgresException: 'Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: 10'
|
|
>>> Postgresql.postgres_version_to_int('9.6') # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
Traceback (most recent call last):
|
|
...
|
|
PostgresException: 'Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: 9.6'
|
|
>>> Postgresql.postgres_version_to_int('a.b.c') # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
Traceback (most recent call last):
|
|
...
|
|
PostgresException: 'Invalid PostgreSQL version: a.b.c'
|
|
"""
|
|
|
|
try:
|
|
components = list(map(int, pg_version.split('.')))
|
|
except ValueError:
|
|
raise PostgresException('Invalid PostgreSQL version: {0}'.format(pg_version))
|
|
|
|
if len(components) < 2 or len(components) == 2 and components[0] < 10 or len(components) > 3:
|
|
raise PostgresException('Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: {0}'
|
|
.format(pg_version))
|
|
|
|
if len(components) == 2:
|
|
# new style verion numbers, i.e. 10.1 becomes 100001
|
|
components.insert(1, 0)
|
|
|
|
return int(''.join('{0:02d}'.format(c) for c in components))
|
|
|
|
@staticmethod
|
|
def postgres_major_version_to_int(pg_version):
|
|
"""
|
|
>>> Postgresql.postgres_major_version_to_int('10')
|
|
100000
|
|
>>> Postgresql.postgres_major_version_to_int('9.6')
|
|
90600
|
|
"""
|
|
return Postgresql.postgres_version_to_int(pg_version + '.0')
|
|
|
|
def read_postmaster_opts(self):
|
|
"""returns the list of option names/values from postgres.opts, Empty dict if read failed or no file"""
|
|
result = {}
|
|
try:
|
|
with open(os.path.join(self._data_dir, 'postmaster.opts')) as f:
|
|
data = f.read()
|
|
for opt in data.split('" "'):
|
|
if '=' in opt and opt.startswith('--'):
|
|
name, val = opt.split('=', 1)
|
|
result[name.strip('-')] = val.rstrip('"\n')
|
|
except IOError:
|
|
logger.exception('Error when reading postmaster.opts')
|
|
return result
|
|
|
|
def single_user_mode(self, command=None, options=None):
|
|
"""run a given command in a single-user mode. If the command is empty - then just start and stop"""
|
|
cmd = [self._pgcommand('postgres'), '--single', '-D', self._data_dir]
|
|
for opt, val in sorted((options or {}).items()):
|
|
cmd.extend(['-c', '{0}={1}'.format(opt, val)])
|
|
# need a database name to connect
|
|
cmd.append(self._database)
|
|
return self.cancellable_subprocess_call(cmd, communicate_input=command)
|
|
|
|
def cleanup_archive_status(self):
|
|
status_dir = os.path.join(self._data_dir, 'pg_' + self.wal_name, 'archive_status')
|
|
try:
|
|
for f in os.listdir(status_dir):
|
|
path = os.path.join(status_dir, f)
|
|
try:
|
|
if os.path.islink(path):
|
|
os.unlink(path)
|
|
elif os.path.isfile(path):
|
|
os.remove(path)
|
|
except OSError:
|
|
logger.exception('Unable to remove %s', path)
|
|
except OSError:
|
|
logger.exception('Unable to list %s', status_dir)
|
|
|
|
def fix_cluster_state(self):
|
|
self.cleanup_archive_status()
|
|
|
|
# Start in a single user mode and stop to produce a clean shutdown
|
|
opts = self.read_postmaster_opts()
|
|
opts.update({'archive_mode': 'on', 'archive_command': 'false'})
|
|
if os.path.isfile(self._recovery_conf) or os.path.islink(self._recovery_conf):
|
|
os.unlink(self._recovery_conf)
|
|
return self.single_user_mode(options=opts) == 0 or None
|
|
|
|
def cancellable_subprocess_call(self, *args, **kwargs):
|
|
for s in ('stdin', 'stdout', 'stderr'):
|
|
kwargs.pop(s, None)
|
|
|
|
communicate_input = 'communicate_input' in kwargs
|
|
if communicate_input:
|
|
input_data = kwargs.pop('communicate_input', None)
|
|
if not isinstance(input_data, string_types):
|
|
input_data = ''
|
|
if input_data and input_data[-1] != '\n':
|
|
input_data += '\n'
|
|
kwargs['stdin'] = subprocess.PIPE
|
|
kwargs['stdout'] = open(os.devnull, 'w')
|
|
kwargs['stderr'] = subprocess.STDOUT
|
|
|
|
try:
|
|
with self._cancellable_lock:
|
|
if self._is_cancelled:
|
|
raise PostgresException('cancelled')
|
|
|
|
self._is_cancelled = False
|
|
self._cancellable = subprocess.Popen(*args, **kwargs)
|
|
|
|
if communicate_input:
|
|
if input_data:
|
|
self._cancellable.communicate(input_data)
|
|
self._cancellable.stdin.close()
|
|
|
|
return self._cancellable.wait()
|
|
finally:
|
|
with self._cancellable_lock:
|
|
self._cancellable = None
|
|
|
|
def reset_is_cancelled(self):
|
|
with self._cancellable_lock:
|
|
self._is_cancelled = False
|
|
|
|
def cancel(self):
|
|
with self._cancellable_lock:
|
|
self._is_cancelled = True
|
|
if self._cancellable is None or self._cancellable.returncode is not None:
|
|
return
|
|
self._cancellable.terminate()
|
|
|
|
for _ in polling_loop(10):
|
|
with self._cancellable_lock:
|
|
if self._cancellable is None or self._cancellable.returncode is not None:
|
|
return
|
|
|
|
with self._cancellable_lock:
|
|
if self._cancellable is not None and self._cancellable.returncode is None:
|
|
self._cancellable.kill()
|
|
|
|
def schedule_sanity_checks_after_pause(self):
|
|
"""
|
|
After coming out of pause we have to:
|
|
1. sync replication slots, because it might happen that slots were removed
|
|
2. get new 'Database system identifier' to make sure that it wasn't changed
|
|
"""
|
|
self._schedule_load_slots = self.use_slots
|
|
self._sysid = None
|