mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 07:30:14 +00:00
Compare commits
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -5,7 +5,6 @@ import subprocess
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
|
||||
@@ -18,30 +17,36 @@ def install_requirements(what):
|
||||
finally:
|
||||
sys.path = old_path
|
||||
requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave']
|
||||
requirements += ['psycopg2-binary', 'coverage']
|
||||
requirements += ['coverage']
|
||||
# try to split tests between psycopg2 and psycopg3
|
||||
requirements += ['psycopg[binary]'] if sys.version_info >= (3, 6, 0) and\
|
||||
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary']
|
||||
for r in read('requirements.txt').split('\n'):
|
||||
r = r.strip()
|
||||
if r != '':
|
||||
extras = {e for e, v in EXTRAS_REQUIRE.items() if v and r.startswith(v[0])}
|
||||
extras = {e for e, v in EXTRAS_REQUIRE.items() if v and any(r.startswith(x) for x in v)}
|
||||
if not extras or what == 'all' or what in extras:
|
||||
requirements.append(r)
|
||||
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'wheel'])
|
||||
r = subprocess.call([sys.executable, '-m', 'pip', 'install'] + requirements)
|
||||
s = subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'setuptools'])
|
||||
return s | r
|
||||
|
||||
|
||||
def install_packages(what):
|
||||
from mapping import versions
|
||||
|
||||
packages = {
|
||||
'zookeeper': ['zookeeper', 'zookeeper-bin', 'zookeeperd'],
|
||||
'consul': ['consul'],
|
||||
}
|
||||
packages['exhibitor'] = packages['zookeeper']
|
||||
packages = packages.get(what, [])
|
||||
ver = str({'etcd': '9.6', 'etcd3': '9.6', 'consul': 10, 'exhibitor': 11, 'kubernetes': 12, 'raft': 13}.get(what))
|
||||
ver = versions.get(what)
|
||||
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
|
||||
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev', 'wget'] + packages)
|
||||
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
|
||||
|
||||
|
||||
def get_file(url, name):
|
||||
@@ -103,7 +108,7 @@ def install_etcd():
|
||||
|
||||
|
||||
def install_postgres():
|
||||
version = os.environ.get('PGVERSION', '12.1-1')
|
||||
version = os.environ.get('PGVERSION', '14.1-1')
|
||||
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
|
||||
name = 'postgresql-{0}-{1}-binaries.zip'.format(version, platform)
|
||||
get_file('http://get.enterprisedb.com/postgresql/' + name, name)
|
||||
@@ -115,57 +120,12 @@ def install_postgres():
|
||||
return 0
|
||||
|
||||
|
||||
def setup_kubernetes():
|
||||
get_file('https://storage.googleapis.com/minikube/k8sReleases/v1.7.0/localkube-linux-amd64', 'localkube')
|
||||
chmod_755('localkube')
|
||||
|
||||
devnull = open(os.devnull, 'w')
|
||||
subprocess.Popen(['sudo', 'nohup', './localkube', '--logtostderr=true', '--enable-dns=false'],
|
||||
stdout=devnull, stderr=devnull)
|
||||
for _ in range(0, 120):
|
||||
if subprocess.call(['wget', '-qO', '-', 'http://127.0.0.1:8080/'], stdout=devnull, stderr=devnull) == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
print('localkube did not start')
|
||||
return 1
|
||||
|
||||
subprocess.call('sudo chmod 644 /var/lib/localkube/certs/*', shell=True)
|
||||
print('Set up .kube/config')
|
||||
kube = os.path.join(os.path.expanduser('~'), '.kube')
|
||||
os.makedirs(kube)
|
||||
with open(os.path.join(kube, 'config'), 'w') as f:
|
||||
f.write("""apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority: /var/lib/localkube/certs/ca.crt
|
||||
server: https://127.0.0.1:8443
|
||||
name: local
|
||||
contexts:
|
||||
- context:
|
||||
cluster: local
|
||||
user: myself
|
||||
name: local
|
||||
current-context: local
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: myself
|
||||
user:
|
||||
client-certificate: /var/lib/localkube/certs/apiserver.crt
|
||||
client-key: /var/lib/localkube/certs/apiserver.key
|
||||
""")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
what = os.environ.get('DCS', sys.argv[1] if len(sys.argv) > 1 else 'all')
|
||||
|
||||
if what != 'all':
|
||||
if sys.platform.startswith('linux'):
|
||||
r = install_packages(what)
|
||||
if r == 0 and what == 'kubernetes':
|
||||
r = setup_kubernetes()
|
||||
else:
|
||||
r = install_postgres()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Publish Patroni distributions to PyPI and TestPyPI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish Patroni distributions to PyPI and TestPyPI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: python setup.py sdist bdist_wheel
|
||||
|
||||
- name: Publish distribution to Test PyPI
|
||||
if: github.event_name == 'push'
|
||||
uses: pypa/[email protected]
|
||||
with:
|
||||
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
repository_url: https://test.pypi.org/legacy/
|
||||
|
||||
- name: Publish distribution to PyPI
|
||||
if: github.event_name == 'release'
|
||||
uses: pypa/[email protected]
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
@@ -23,9 +23,11 @@ def main():
|
||||
|
||||
env = os.environ.copy()
|
||||
if sys.platform.startswith('linux'):
|
||||
version = {'etcd': '9.6', 'etcd3': '9.6', 'consul': 10, 'exhibitor': 11, 'kubernetes': 12, 'raft': 13}.get(what)
|
||||
from mapping import versions
|
||||
|
||||
version = versions.get(what)
|
||||
path = '/usr/lib/postgresql/{0}/bin:.'.format(version)
|
||||
unbuffer = ['timeout', '600', 'unbuffer']
|
||||
unbuffer = ['timeout', '900', 'unbuffer']
|
||||
else:
|
||||
path = os.path.abspath(os.path.join('pgsql', 'bin'))
|
||||
if sys.platform == 'darwin':
|
||||
@@ -33,6 +35,8 @@ def main():
|
||||
unbuffer = []
|
||||
env['PATH'] = path + os.pathsep + env['PATH']
|
||||
env['DCS'] = what
|
||||
if what == 'kubernetes':
|
||||
env['PATRONI_KUBERNETES_CONTEXT'] = 'k3d-k3s-default'
|
||||
|
||||
ret = subprocess.call(unbuffer + [sys.executable, '-m', 'behave'], env=env)
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@ on:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
runs-on: ${{ fromJson('{"ubuntu":"ubuntu-20.04","windows":"windows-latest","macos":"macos-latest"}')[matrix.os] }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu, windows, macos]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 2.7
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 2.7
|
||||
if: matrix.os != 'windows'
|
||||
@@ -30,17 +30,8 @@ jobs:
|
||||
run: python .github/workflows/run_tests.py
|
||||
if: matrix.os != 'windows'
|
||||
|
||||
- name: Set up Python 3.5
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.5
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.6
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.6
|
||||
- name: Install dependencies
|
||||
@@ -49,7 +40,7 @@ jobs:
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install dependencies
|
||||
@@ -58,7 +49,7 @@ jobs:
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install dependencies
|
||||
@@ -67,7 +58,7 @@ jobs:
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
@@ -75,6 +66,15 @@ jobs:
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Combine coverage
|
||||
run: python .github/workflows/run_tests.py combine
|
||||
|
||||
@@ -88,51 +88,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.github_token }}
|
||||
run: python -m coveralls --service=github
|
||||
|
||||
- name: Run codacy-coverage-reporter
|
||||
uses: codacy/codacy-coverage-reporter-action@master
|
||||
env:
|
||||
SECRETS_AVAILABLE: ${{ secrets.CODACY_PROJECT_TOKEN != '' }}
|
||||
with:
|
||||
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
coverage-reports: coverage.xml
|
||||
if: ${{ matrix.os == 'ubuntu' && env.SECRETS_AVAILABLE == 'true' }}
|
||||
|
||||
behave:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
env:
|
||||
DCS: ${{ matrix.dcs }}
|
||||
ETCDVERSION: 3.3.13
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu]
|
||||
python-version: [2.7, 3.5, 3.8]
|
||||
dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run behave tests
|
||||
run: python .github/workflows/run_tests.py
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install coveralls
|
||||
run: python -m pip install coveralls
|
||||
- name: Upload Coverage
|
||||
env:
|
||||
COVERALLS_FLAG_NAME: behave-${{ matrix.os }}-${{ matrix.dcs }}-${{ matrix.python-version }}
|
||||
COVERALLS_PARALLEL: 'true'
|
||||
GITHUB_TOKEN: ${{ secrets.github_token }}
|
||||
run: python -m coveralls --service=github
|
||||
|
||||
behavem:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
runs-on: ${{ fromJson('{"ubuntu":"ubuntu-20.04","windows":"windows-latest","macos":"macos-latest"}')[matrix.os] }}
|
||||
env:
|
||||
DCS: ${{ matrix.dcs }}
|
||||
ETCDVERSION: 3.3.13
|
||||
@@ -140,20 +97,45 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos] #, windows]
|
||||
python-version: [3.7]
|
||||
dcs: [etcd, etcd3, raft]
|
||||
os: [ubuntu]
|
||||
python-version: [2.7, 3.6, 3.9]
|
||||
dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft]
|
||||
exclude:
|
||||
- dcs: kubernetes
|
||||
python-version: 2.7
|
||||
include:
|
||||
- os: macos
|
||||
python-version: 3.7
|
||||
dcs: raft
|
||||
- os: macos
|
||||
python-version: 3.8
|
||||
dcs: etcd
|
||||
- os: macos
|
||||
python-version: '3.10'
|
||||
dcs: etcd3
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: nolar/setup-k3d-k3s@v1
|
||||
if: matrix.dcs == 'kubernetes'
|
||||
- name: Add postgresql apt repo
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y wget ca-certificates gnupg
|
||||
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
sudo sh -c 'wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg'
|
||||
if: matrix.os == 'ubuntu'
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run behave tests
|
||||
run: python .github/workflows/run_tests.py
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install coveralls
|
||||
run: python -m pip install coveralls
|
||||
- name: Upload Coverage
|
||||
@@ -165,10 +147,10 @@ jobs:
|
||||
|
||||
coveralls-finish:
|
||||
name: Finalize coveralls.io
|
||||
needs: [unit, behave, behavem]
|
||||
needs: [unit, behave]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: actions/setup-python@v4
|
||||
- run: python -m pip install coveralls
|
||||
- run: python -m coveralls --service=github --finish
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# global owners
|
||||
* @CyberDem0n @hughcapet
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
||||
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||
ARG PG_MAJOR=10
|
||||
ARG PG_MAJOR=15
|
||||
ARG COMPRESS=false
|
||||
ARG PGHOME=/home/postgres
|
||||
ARG PGDATA=$PGHOME/data
|
||||
@@ -50,11 +50,11 @@ RUN set -ex \
|
||||
&& chown -R postgres:postgres /var/log \
|
||||
\
|
||||
# Download etcd
|
||||
&& curl -sL https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
|
||||
&& curl -sL https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-$(dpkg --print-architecture).tar.gz \
|
||||
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \
|
||||
\
|
||||
# Download confd
|
||||
&& curl -sL https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-amd64 \
|
||||
&& curl -sL https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-$(dpkg --print-architecture) \
|
||||
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \
|
||||
\
|
||||
# Clean up all useless packages and some files
|
||||
@@ -90,7 +90,7 @@ RUN set -ex \
|
||||
&& find /usr/bin -xtype l -delete \
|
||||
&& find /var/log -type f -exec truncate --size 0 {} \; \
|
||||
&& find /usr/lib/python3/dist-packages -name '*test*' | xargs rm -fr \
|
||||
&& find /lib/x86_64-linux-gnu/security -type f ! -name pam_env.so ! -name pam_permit.so ! -name pam_unix.so -delete
|
||||
&& find /lib/$(uname -m)-linux-gnu/security -type f ! -name pam_env.so ! -name pam_permit.so ! -name pam_unix.so -delete
|
||||
|
||||
# perform compression if it is necessary
|
||||
ARG COMPRESS
|
||||
@@ -99,8 +99,10 @@ RUN if [ "$COMPRESS" = "true" ]; then \
|
||||
# Allow certain sudo commands from postgres
|
||||
&& echo 'postgres ALL=(ALL) NOPASSWD: /bin/tar xpJf /a.tar.xz -C /, /bin/rm /a.tar.xz, /bin/ln -snf dash /bin/sh' >> /etc/sudoers \
|
||||
&& ln -snf busybox /bin/sh \
|
||||
&& files="/bin/sh /usr/bin/sudo /usr/lib/sudo/sudoers.so /lib/x86_64-linux-gnu/security/pam_*.so" \
|
||||
&& libs="$(ldd $files | awk '{print $3;}' | grep '^/' | sort -u) /lib/x86_64-linux-gnu/ld-linux-x86-64.so.* /lib/x86_64-linux-gnu/libnsl.so.* /lib/x86_64-linux-gnu/libnss_compat.so.*" \
|
||||
&& arch=$(uname -m) \
|
||||
&& darch=$(uname -m | sed 's/_/-/') \
|
||||
&& files="/bin/sh /usr/bin/sudo /usr/lib/sudo/sudoers.so /lib/$arch-linux-gnu/security/pam_*.so" \
|
||||
&& libs="$(ldd $files | awk '{print $3;}' | grep '^/' | sort -u) /lib/ld-linux-$darch.so.* /lib/$arch-linux-gnu/ld-linux-$darch.so.* /lib/$arch-linux-gnu/libnsl.so.* /lib/$arch-linux-gnu/libnss_compat.so.* /lib/$arch-linux-gnu/libnss_files.so.*" \
|
||||
&& (echo /var/run $files $libs | tr ' ' '\n' && realpath $files $libs) | sort -u | sed 's/^\///' > /exclude \
|
||||
&& find /etc/alternatives -xtype l -delete \
|
||||
&& save_dirs="usr lib var bin sbin etc/ssl etc/init.d etc/alternatives etc/apt" \
|
||||
@@ -117,7 +119,7 @@ RUN if [ "$COMPRESS" = "true" ]; then \
|
||||
FROM scratch
|
||||
COPY --from=builder / /
|
||||
|
||||
LABEL maintainer="Alexander Kukushkin <alexander.kukushkin@zalando.de>"
|
||||
LABEL maintainer="Alexander Kukushkin <akukushkin@microsoft.com>"
|
||||
|
||||
ARG PG_MAJOR
|
||||
ARG COMPRESS
|
||||
|
||||
+2
-3
@@ -1,3 +1,2 @@
|
||||
Alexander Kukushkin <alexander.kukushkin@zalando.de>
|
||||
Feike Steenbergen <feike.steenbergen@zalando.de>
|
||||
Oleksii Kliukin <[email protected]>
|
||||
Alexander Kukushkin <akukushkin@microsoft.com>
|
||||
Polina Bungina <polina.bungina@zalando.de>
|
||||
|
||||
+13
-7
@@ -1,4 +1,4 @@
|
||||
|Build Status| |Coverage Status|
|
||||
|Tests Status| |Coverage Status|
|
||||
|
||||
Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul
|
||||
--------------------------------------------------------------------
|
||||
@@ -12,7 +12,7 @@ Patroni is a template for you to create your own customized, high-availability s
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
|
||||
|
||||
Currently supported PostgreSQL versions: 9.3 to 13.
|
||||
Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
|
||||
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
|
||||
|
||||
@@ -61,7 +61,7 @@ To install requirements on a Mac, run the following:
|
||||
|
||||
brew install postgresql etcd haproxy libyaml python
|
||||
|
||||
**Psycopg2**
|
||||
**Psycopg**
|
||||
|
||||
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
|
||||
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
|
||||
@@ -88,6 +88,12 @@ There are a few options available:
|
||||
|
||||
pip install psycopg2>=2.5.4
|
||||
|
||||
4. Use psycopg 3.0 instead of psycopg2
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg[binary]
|
||||
|
||||
**General installation for pip**
|
||||
|
||||
Patroni can be installed with pip:
|
||||
@@ -119,7 +125,7 @@ For example, the command in order to install Patroni together with dependencies
|
||||
|
||||
pip install patroni[etcd,aws]
|
||||
|
||||
Note that external tools to call in the replica creation or custom bootstap scripts (i.e. WAL-E) should be installed independently of Patroni.
|
||||
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni.
|
||||
|
||||
=======================
|
||||
Running and Configuring
|
||||
@@ -171,7 +177,7 @@ Applications Should Not Use Superusers
|
||||
|
||||
When connecting from an application, always use a non-superuser. Patroni requires access to the database to function properly. By using a superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable.
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
|
||||
:target: https://travis-ci.org/zalando/patroni
|
||||
.. |Tests Status| image:: https://github.com/zalando/patroni/actions/workflows/tests.yaml/badge.svg
|
||||
:target: https://github.com/zalando/patroni/actions/workflows/tests.yaml?query=branch%3Amaster
|
||||
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
|
||||
:target: https://coveralls.io/r/zalando/patroni?branch=master
|
||||
:target: https://coveralls.io/github/zalando/patroni?branch=master
|
||||
|
||||
+12
-2
@@ -49,6 +49,7 @@ Consul
|
||||
- **PATRONI\_CONSUL\_CHECKS**: (optional) list of Consul health checks used for the session. By default an empty list is used.
|
||||
- **PATRONI\_CONSUL\_REGISTER\_SERVICE**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, replica or standby-leader depending on the node's role. Defaults to **false**
|
||||
- **PATRONI\_CONSUL\_SERVICE\_CHECK\_INTERVAL**: (optional) how often to perform health check against registered url
|
||||
- **PATRONI\_CONSUL\_SERVICE\_CHECK\_TLS\_SERVER\_NAME**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
|
||||
|
||||
Etcd
|
||||
----
|
||||
@@ -59,7 +60,8 @@ Etcd
|
||||
- **PATRONI\_ETCD\_USE\_PROXIES**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster but stick to a fixed list of **hosts**.
|
||||
- **PATRONI\_ETCD\_PROTOCOL**: http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||
- **PATRONI\_ETCD\_HOST**: the host:port for the etcd endpoint.
|
||||
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery.
|
||||
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
|
||||
- **PATRONI\_ETCD\_SRV\_SUFFIX**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **PATRONI\_ETCD\_SRV**. For example, if ``PATRONI_ETCD_SRV_SUFFIX=foo`` and ``PATRONI_ETCD_SRV=example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
|
||||
- **PATRONI\_ETCD\_USERNAME**: username for etcd authentication.
|
||||
- **PATRONI\_ETCD\_PASSWORD**: password for etcd authentication.
|
||||
- **PATRONI\_ETCD\_CACERT**: The ca certificate. If present it will enable validation.
|
||||
@@ -83,6 +85,7 @@ ZooKeeper
|
||||
- **PATRONI\_ZOOKEEPER\_KEY**: (optional) File with the client key.
|
||||
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
|
||||
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||
- **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||
|
||||
.. note::
|
||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||
@@ -105,6 +108,7 @@ Kubernetes
|
||||
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
||||
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
|
||||
- **PATRONI\_KUBERNETES\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
|
||||
|
||||
Raft
|
||||
----
|
||||
@@ -119,11 +123,12 @@ PostgreSQL
|
||||
----------
|
||||
- **PATRONI\_POSTGRESQL\_LISTEN**: IP address + port that Postgres listens to. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||
- **PATRONI\_POSTGRESQL\_CONNECT\_ADDRESS**: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||
- **PATRONI\_POSTGRESQL\_PROXY\_ADDRESS**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
|
||||
- **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
|
||||
- **PATRONI\_POSTGRESQL\_CONFIG\_DIR**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
|
||||
- **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **PATRONI\_POSTGRESQL\_PGPASS**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup and under some other circumstances. The location must be writable by Patroni.
|
||||
- **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access master via streaming replication
|
||||
- **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
|
||||
- **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization.
|
||||
- **PATRONI\_REPLICATION\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **PATRONI\_REPLICATION\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
@@ -131,6 +136,7 @@ PostgreSQL
|
||||
- **PATRONI\_REPLICATION\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **PATRONI\_REPLICATION\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_REPLICATION\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REPLICATION\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REPLICATION\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **PATRONI\_REPLICATION\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind.
|
||||
@@ -141,6 +147,7 @@ PostgreSQL
|
||||
- **PATRONI\_SUPERUSER\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **PATRONI\_SUPERUSER\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_SUPERUSER\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
|
||||
@@ -151,6 +158,7 @@ PostgreSQL
|
||||
- **PATRONI\_REWIND\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **PATRONI\_REWIND\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_REWIND\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REWIND\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REWIND\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **PATRONI\_REWIND\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
|
||||
@@ -166,6 +174,8 @@ REST API
|
||||
- **PATRONI\_RESTAPI\_CAFILE**: Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
|
||||
- **PATRONI\_RESTAPI\_CIPHERS**: (optional) Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
|
||||
- **PATRONI\_RESTAPI\_VERIFY\_CLIENT**: ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
|
||||
- **PATRONI\_RESTAPI\_ALLOWLIST**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
|
||||
- **PATRONI\_RESTAPI\_ALLOWLIST\_INCLUDE\_MEMBERS**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
|
||||
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
|
||||
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
|
||||
|
||||
|
||||
+10
-4
@@ -35,7 +35,7 @@ To install requirements on a Mac, run the following:
|
||||
|
||||
.. _psycopg2_install_options:
|
||||
|
||||
**Psycopg2**
|
||||
**Psycopg**
|
||||
|
||||
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
|
||||
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
|
||||
@@ -62,6 +62,12 @@ There are a few options available:
|
||||
|
||||
pip install psycopg2>=2.5.4
|
||||
|
||||
4. Use psycopg 3.0 instead of psycopg2
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg[binary]>=3.0.0
|
||||
|
||||
**General installation for pip**
|
||||
|
||||
Patroni can be installed with pip:
|
||||
@@ -73,7 +79,7 @@ Patroni can be installed with pip:
|
||||
where dependencies can be either empty, or consist of one or more of the following:
|
||||
|
||||
etcd or etcd3
|
||||
`python-etcd` module in order to use Etcd as DCS
|
||||
`python-etcd` module in order to use Etcd as Distributed Configuration Store (DCS)
|
||||
consul
|
||||
`python-consul` module in order to use Consul as DCS
|
||||
zookeeper
|
||||
@@ -103,7 +109,7 @@ Planning the Number of PostgreSQL Nodes
|
||||
---------------------------------------
|
||||
|
||||
Patroni/PostgreSQL nodes are decoupled from DCS nodes (except when Patroni implements RAFT on its own) and therefore
|
||||
there is no requirement on the minimal number of nodes. Running a cluster consisting of one master and one standby is
|
||||
there is no requirement on the minimal number of nodes. Running a cluster consisting of one primary and one standby is
|
||||
perfectly fine. You can add more standby nodes later.
|
||||
|
||||
Running and Configuring
|
||||
@@ -166,7 +172,7 @@ When connecting from an application, always use a non-superuser. Patroni require
|
||||
|
||||
Testing Your HA Solution
|
||||
--------------------------------------
|
||||
Testing an HA solution is a time consuming process, with many variables. This is particularly true considering a cross-platform application. You need a trained system administrator or a consultant to do this work. It is not something we can cover in depth in the documentaiton.
|
||||
Testing an HA solution is a time consuming process, with many variables. This is particularly true considering a cross-platform application. You need a trained system administrator or a consultant to do this work. It is not something we can cover in depth in the documentation.
|
||||
|
||||
That said, here are some pieces of your infrastructure you should be sure to test:
|
||||
|
||||
|
||||
+39
-16
@@ -17,24 +17,24 @@ Dynamic configuration is stored in the DCS (Distributed Configuration Store) and
|
||||
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
||||
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
|
||||
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
|
||||
- **master\_start\_timeout**: the amount of time a master is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for master failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||
- **master\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||
- **master\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by master_stop_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, master_stop_timeout does not apply.
|
||||
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the master. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
- **postgresql**:
|
||||
- **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
|
||||
- **use\_slots**: whether or not to use replication slots. Defaults to `true` on PostgreSQL 9.4+.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
|
||||
- **parameters**: list of configuration settings for Postgres.
|
||||
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
|
||||
- **host**: an address of remote master
|
||||
- **port**: a port of remote master
|
||||
- **primary\_slot\_name**: which slot on the remote master to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
|
||||
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote master, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **restore\_command**: command to restore WAL records from the remote master to standby leader, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **host**: an address of remote node
|
||||
- **port**: a port of remote node
|
||||
- **primary\_slot\_name**: which slot on the remote node to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
|
||||
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote primary, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **restore\_command**: command to restore WAL records from the remote primary to nodes in a standby cluster, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **archive\_cleanup\_command**: cleanup command for standby leader
|
||||
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Patroni will try to create slots before opening connections to the cluster.
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
|
||||
- **my_slot_name**: the name of replication slot. If the permanent slot name matches with the name of the current primary it will not be created. Everything else is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
|
||||
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
|
||||
- **database**: the database name where logical slots should be created.
|
||||
@@ -131,7 +131,8 @@ Most of the parameters are optional, but you have to specify one of the **host**
|
||||
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
|
||||
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, replica or standby-leader depending on the node's role. Defaults to **false**.
|
||||
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``replica``/``standby-leader``). By default an empty list is used.
|
||||
- **service\_check\_interval**: (optional) how often to perform health check against registered url.
|
||||
- **service\_check\_interval**: (optional) how often to perform health check against registered url. Defaults to '5s'.
|
||||
- **service\_check\_tls\_server\_name**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
|
||||
|
||||
The ``token`` needs to have the following ACL permissions:
|
||||
|
||||
@@ -156,7 +157,8 @@ Most of the parameters are optional, but you have to specify one of the **host**
|
||||
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
|
||||
- **url**: url for the etcd.
|
||||
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
|
||||
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery.
|
||||
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
|
||||
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
|
||||
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||
- **username**: (optional) username for etcd authentication.
|
||||
- **password**: (optional) password for etcd authentication.
|
||||
@@ -181,6 +183,7 @@ ZooKeeper
|
||||
- **key**: (optional) File with the client key.
|
||||
- **key_password**: (optional) The client key password.
|
||||
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||
|
||||
.. note::
|
||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||
@@ -204,6 +207,7 @@ Kubernetes
|
||||
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
||||
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
|
||||
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
|
||||
|
||||
|
||||
.. _raft_settings:
|
||||
@@ -220,7 +224,7 @@ Raft
|
||||
|
||||
- Q: How to list all the nodes providing consensus?
|
||||
|
||||
A: ``syncobj_admin -conn host:port`` -status where the host:port is the address of one of the cluster nodes
|
||||
A: ``syncobj_admin -conn host:port -status`` where the host:port is the address of one of the cluster nodes
|
||||
|
||||
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
|
||||
|
||||
@@ -228,7 +232,7 @@ Raft
|
||||
|
||||
- Q: Where to get the ``syncobj_admin`` utility?
|
||||
|
||||
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependancy.
|
||||
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependency.
|
||||
|
||||
- Q: it is possible to run Patroni node without adding in to the consensus?
|
||||
|
||||
@@ -254,10 +258,11 @@ PostgreSQL
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **replication**:
|
||||
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access master via streaming replication
|
||||
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
|
||||
- **password**: replication password; the user will be created during initialization.
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
@@ -265,6 +270,7 @@ PostgreSQL
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **rewind**:
|
||||
@@ -276,6 +282,7 @@ PostgreSQL
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
|
||||
@@ -285,6 +292,7 @@ PostgreSQL
|
||||
- **on\_start**: run this script when the postgres starts.
|
||||
- **on\_stop**: run this script when the postgres stops.
|
||||
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||
- **proxy\_address**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
|
||||
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
|
||||
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
|
||||
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
|
||||
@@ -293,6 +301,7 @@ PostgreSQL
|
||||
- **bin\_dir**: Path to PostgreSQL binaries (pg_ctl, pg_rewind, pg_basebackup, postgres). The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **use\_unix\_socket\_repl**: specifies that Patroni should prefer to use unix sockets for replication user cluster connection. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details.
|
||||
@@ -306,14 +315,14 @@ PostgreSQL
|
||||
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
|
||||
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
|
||||
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
|
||||
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former master can not start streaming from the new master. This option is useful when ``pg_rewind`` can not be used. Default value is **false**.
|
||||
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former primary can not start streaming from the new primary. This option is useful when ``pg_rewind`` can not be used. While performing timelines divergence check on PostgreSQL v10 and older Patroni will try to connect with replication credential to the "postgres" database. Hence, such access should be allowed in the pg_hba.conf. Default value is **false**.
|
||||
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
|
||||
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
|
||||
|
||||
REST API
|
||||
--------
|
||||
- **restapi**:
|
||||
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the master is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
|
||||
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the leader is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
|
||||
|
||||
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
|
||||
|
||||
@@ -322,10 +331,12 @@ REST API
|
||||
- **password**: Basic-auth password to protect unsafe REST API endpoints.
|
||||
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
|
||||
- **keyfile_password**: (optional): Specifies a password for decrypting the keyfile.
|
||||
- **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
|
||||
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
|
||||
- **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
|
||||
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
|
||||
- **allowlist**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
|
||||
- **allowlist\_include\_members**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
|
||||
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
|
||||
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
|
||||
|
||||
@@ -358,6 +369,7 @@ CTL
|
||||
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
|
||||
- **certfile**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
|
||||
- **keyfile**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter.
|
||||
- **keyfile\_password**: Specifies a password for decrypting the keyfile. If not provided patronictl will use the value provided for REST API "keyfile\_password" parameter.
|
||||
|
||||
Watchdog
|
||||
--------
|
||||
@@ -365,6 +377,8 @@ Watchdog
|
||||
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
|
||||
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
|
||||
|
||||
.. _tags_settings:
|
||||
|
||||
Tags
|
||||
----
|
||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
|
||||
@@ -372,3 +386,12 @@ Tags
|
||||
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
|
||||
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
|
||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||
|
||||
In addition to these predefined tags, you can also add your own ones:
|
||||
|
||||
- **key1**: ``true``
|
||||
- **key2**: ``false``
|
||||
- **key3**: ``1.4``
|
||||
- **key4**: ``"RandomString"``
|
||||
|
||||
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
|
||||
+4
-1
@@ -194,4 +194,7 @@ intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
# A possibility to have an own stylesheet, to add new rules or override existing ones
|
||||
# For the latter case, the CSS specificity of the rules should be higher than the default ones
|
||||
def setup(app):
|
||||
app.add_stylesheet("custom.css")
|
||||
if hasattr(app, 'add_css_file'):
|
||||
app.add_css_file('custom.css')
|
||||
else:
|
||||
app.add_stylesheet('custom.css')
|
||||
|
||||
@@ -22,7 +22,7 @@ Patroni configuration is stored in the DCS (Distributed Configuration Store). Th
|
||||
|
||||
The local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
|
||||
|
||||
Some of the PostgreSQL parameters must hold the same values on the master and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
|
||||
Some of the PostgreSQL parameters must hold the same values on the primary and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
|
||||
|
||||
- max_connections: 100
|
||||
- max_locks_per_transaction: 64
|
||||
@@ -32,7 +32,7 @@ Some of the PostgreSQL parameters must hold the same values on the master and th
|
||||
- wal_log_hints: on
|
||||
- track_commit_timestamp: off
|
||||
|
||||
For the parameters below, PostgreSQL does not require equal values among the master and all the replicas. However, considering the possibility of a replica to become the master at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration
|
||||
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration
|
||||
|
||||
- max_wal_senders: 5
|
||||
- max_replication_slots: 5
|
||||
@@ -86,4 +86,4 @@ Also, the following Patroni configuration options can be changed only dynamicall
|
||||
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its
|
||||
run-time values.
|
||||
|
||||
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the master is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
|
||||
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
|
||||
|
||||
@@ -29,11 +29,11 @@ Major Upgrade of PostgreSQL Version
|
||||
The only possible way to do a major upgrade currently is:
|
||||
|
||||
1. Stop Patroni
|
||||
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the master node
|
||||
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
|
||||
3. Update patroni.yml
|
||||
4. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
|
||||
5. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
|
||||
6. Start Patroni on the master node.
|
||||
6. Start Patroni on the primary node.
|
||||
7. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
|
||||
8. Start Patroni on the standby nodes and wait for the replication to complete.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Patroni is a template for you to create your own customized, high-availability s
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
|
||||
|
||||
Currently supported PostgreSQL versions: 9.3 to 13.
|
||||
Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
|
||||
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the :ref:`Kubernetes <kubernetes>` chapter of the Patroni documentation.
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ Use ConfigMaps
|
||||
In this mode, Patroni will create ConfigMaps instead of Endpoints and store keys inside meta-data of those ConfigMaps.
|
||||
Changing the leader takes at least two updates, one to the leader ConfigMap and another to the respective Endpoint.
|
||||
|
||||
There are two ways to direct the traffic to the Postgres master:
|
||||
There are two ways to direct the traffic to the Postgres leader:
|
||||
|
||||
- use the `callback script <https://github.com/zalando/patroni/blob/master/kubernetes/callback.py>`_ provided by Patroni
|
||||
- configure the Kubernetes Postgres service to use the label selector with the `role_label` (configured in patroni configuration).
|
||||
|
||||
+7
-5
@@ -6,7 +6,7 @@ Pause/Resume mode for the cluster
|
||||
The goal
|
||||
--------
|
||||
|
||||
Under certain circumstances Patroni needs to temporary step down from managing the cluster, while still retaining the cluster state in DCS. Possible use cases are uncommon activities on the cluster, such as major version upgrades or corruption recovery. During those activities nodes are often started and stopped for the reason unknown to Patroni, some nodes can be even temporary promoted, violating the assumption of running only one master. Therefore, Patroni needs to be able to "detach" from the running cluster, implementing an equivalent of the maintenance mode in Pacemaker.
|
||||
Under certain circumstances Patroni needs to temporarily step down from managing the cluster, while still retaining the cluster state in DCS. Possible use cases are uncommon activities on the cluster, such as major version upgrades or corruption recovery. During those activities nodes are often started and stopped for reasons unknown to Patroni, some nodes can be even temporarily promoted, violating the assumption of running only one primary. Therefore, Patroni needs to be able to "detach" from the running cluster, implementing an equivalent of the maintenance mode in Pacemaker.
|
||||
|
||||
|
||||
|
||||
@@ -17,16 +17,18 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
|
||||
|
||||
- For each node, the member key in DCS is updated with the current information about the cluster. This causes Patroni to run read-only queries on a member node if the member is running.
|
||||
|
||||
- For the Postgres master with the leader lock Patroni updates the lock. If the node with the leader lock stops being the master (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back.
|
||||
- For the Postgres primary with the leader lock Patroni updates the lock. If the node with the leader lock stops being the primary (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back.
|
||||
|
||||
- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running master node.
|
||||
- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running primary node.
|
||||
|
||||
- If 'parallel' masters are detected by Patroni, it emits a warning, but does not demote the masters without the leader lock.
|
||||
- If 'parallel' primaries are detected by Patroni, it emits a warning, but does not demote the primary without the leader lock.
|
||||
|
||||
- If there is no leader lock in the cluster, the running master acquires the lock. If there is more than one master node, then the first master to acquire the lock wins. If there are no masters altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old master has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
|
||||
- If there is no leader lock in the cluster, the running primary acquires the lock. If there is more than one primary node, then the first primary to acquire the lock wins. If there are no primary altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old primary has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
|
||||
|
||||
- When Postgres is stopped, Patroni does not try to start it. When Patroni is stopped, it does not try to stop the Postgres instance it is managing.
|
||||
|
||||
- Patroni will not try to remove replication slots that don't represent the other cluster member or are not listed in the configuration of the permanent slots.
|
||||
|
||||
User guide
|
||||
----------
|
||||
|
||||
|
||||
+544
-11
@@ -3,6 +3,539 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 2.1.7
|
||||
-------------
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed little incompatibilities with legacy python modules (Alexander Kukushkin)
|
||||
|
||||
They prevented from building/running Patroni on Debian buster/Ubuntu bionic.
|
||||
|
||||
|
||||
Version 2.1.6
|
||||
-------------
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Fix annoying exceptions on ssl socket shutdown (Alexander Kukushkin)
|
||||
|
||||
The HAProxy is closing connections as soon as it got the HTTP Status code leaving no time for Patroni to properly shutdown SSL connection.
|
||||
|
||||
- Adjust example Dockerfile for arm64 (Polina Bungina)
|
||||
|
||||
Remove explicit ``amd64`` and ``x86_64``, don't remove ``libnss_files.so.*``.
|
||||
|
||||
|
||||
**Security improvements**
|
||||
|
||||
- Enforce ``search_path=pg_catalog`` for non-replication connections (Alexander)
|
||||
|
||||
Since Patroni is heavily relying on superuser connections, we want to protect it from the possible attacks carried out using user-defined functions and/or operators in ``public`` schema with the same name and signature as the corresponding objects in ``pg_catalog``. For that, ``search_path=pg_catalog`` is enforced for all connections created by Patroni (except replication connections).
|
||||
|
||||
- Prevent passwords from being recorded in ``pg_stat_statements`` (Feike Steenbergen)
|
||||
|
||||
It is achieved by setting ``pg_stat_statements.track_utility=off`` when creating users.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Declare ``proxy_address`` as optional (Denis Laxalde)
|
||||
|
||||
As it is effectively a non-required option.
|
||||
|
||||
- Improve behaviour of the insecure option (Alexander)
|
||||
|
||||
Ctl's ``insecure`` option didn't work properly when client certificates were used for REST API requests.
|
||||
|
||||
- Take watchdog configuration from ``bootstrap.dcs`` when the new cluster is bootstrapped (Matt Baker)
|
||||
|
||||
Patroni used to initially configure watchdog with defaults when bootstrapping a new cluster rather than taking configuration used to bootstrap the DCS.
|
||||
|
||||
- Fix the way file extensions are treated while finding executables in WIN32 (Martín Marqués)
|
||||
|
||||
Only add ``.exe`` to a file name if it has no extension yet.
|
||||
|
||||
- Fix Consul TTL setup (Alexander)
|
||||
|
||||
We used ``ttl/2.0`` when setting the value on the HTTPClient, but forgot to multiply the current value by 2 in the class' property. It was resulting in Consul TTL off by twice.
|
||||
|
||||
|
||||
**Removed functionality**
|
||||
|
||||
- Remove ``patronictl configure`` (Polina)
|
||||
|
||||
There is no more need for a separate ``patronictl`` config creation.
|
||||
|
||||
|
||||
Version 2.1.5
|
||||
-------------
|
||||
|
||||
This version enhances compatibility with PostgreSQL 15 and declares Etcd v3 support as production ready. The Patroni on Raft remains in Beta.
|
||||
|
||||
**New features**
|
||||
|
||||
- Improve ``patroni --validate-config`` (Denis Laxalde)
|
||||
|
||||
Exit with code 1 if config is invalid and print errors to stderr.
|
||||
|
||||
- Don't drop replication slots in pause (Alexander Kukushkin)
|
||||
|
||||
Patroni is automatically creating/removing physical replication slots when members are joining/leaving the cluster. In pause slots will no longer be removed.
|
||||
|
||||
- Support the ``HEAD`` request method for monitoring endpoints (Robert Cutajar)
|
||||
|
||||
If used instead of ``GET`` Patroni will return only the HTTP Status Code.
|
||||
|
||||
- Support behave tests on Windows (Alexander)
|
||||
|
||||
Emulate graceful Patroni shutdown (``SIGTERM``) on Windows by introduce the new REST API endpoint ``POST /sigterm``.
|
||||
|
||||
- Introduce ``postgresql.proxy_address`` (Alexander)
|
||||
|
||||
It will be written to the member key in DCS as the ``proxy_url`` and could be used/useful for service discovery.
|
||||
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Call ``pg_replication_slot_advance()`` from a thread (Alexander)
|
||||
|
||||
On busy clusters with many logical replication slots the ``pg_replication_slot_advance()`` call was affecting the main HA loop and could result in the member key expiration.
|
||||
|
||||
- Archive possibly missing WALs before calling ``pg_rewind`` on the old primary (Polina Bungina)
|
||||
|
||||
If the primary crashed and was down during considerable time, some WAL files could be missing from archive and from the new primary. There is a chance that ``pg_rewind`` could remove these WAL files from the old primary making it impossible to start it as a standby. By archiving ``ready`` WAL files we not only mitigate this problem but in general improving continues archiving experience.
|
||||
|
||||
- Ignore ``403`` errors when trying to create Kubernetes Service (Nick Hudson, Polina)
|
||||
|
||||
Patroni was spamming logs by unsuccessful attempts to create the service, which in fact could already exist.
|
||||
|
||||
- Improve liveness probe (Alexander)
|
||||
|
||||
The liveness problem will start failing if the heartbeat loop is running longer than `ttl` on the primary or `2*ttl` on the replica. That will allow us to use it as an alternative for :ref:`watchdog <watchdog>` on Kubernetes.
|
||||
|
||||
- Make sure only sync node tries to grab the lock when switchover (Alexander, Polina)
|
||||
|
||||
Previously there was a slim chance that up-to-date async member could become the leader if the manual switchover was performed without specifying the target.
|
||||
|
||||
- Avoid cloning while bootstrap is running (Ants Aasma)
|
||||
|
||||
Do not allow a create replica method that does not require a leader to be triggered while the cluster bootstrap is running.
|
||||
|
||||
- Compatibility with kazoo-2.9.0 (Alexander)
|
||||
|
||||
Depending on python version the ``SequentialThreadingHandler.select()`` method may raise ``TypeError`` and ``IOError`` exceptions if ``select()`` is called on the closed socket.
|
||||
|
||||
- Explicitly shut down SSL connection before socket shutdown (Alexander)
|
||||
|
||||
Not doing it resulted in ``unexpected eof while reading`` errors with OpenSSL 3.0.
|
||||
|
||||
- Compatibility with `prettytable>=2.2.0` (Alexander)
|
||||
|
||||
Due to the internal API changes the cluster name header was shown on the incorrect line.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Handle expired token for Etcd lease_grant (monsterxx03)
|
||||
|
||||
In case of error get the new token and retry request.
|
||||
|
||||
- Fix bug in the ``GET /read-only-sync`` endpoint (Alexander)
|
||||
|
||||
It was introduced in previous release and effectively never worked.
|
||||
|
||||
- Handle the case when data dir storage disappeared (Alexander)
|
||||
|
||||
Patroni is periodically checking that the PGDATA is there and not empty, but in case of issues with storage the ``os.listdir()`` is raising the ``OSError`` exception, breaking the heart-beat loop.
|
||||
|
||||
- Apply ``master_stop_timeout`` when waiting for user backends to close (Alexander)
|
||||
|
||||
Something that looks like user backend could be in fact a background worker (e.g., Citus Maintenance Daemon) that is failing to stop.
|
||||
|
||||
- Accept ``*:<port>`` for ``postgresql.listen`` (Denis)
|
||||
|
||||
The ``patroni --validate-config`` was complaining about it being invalid.
|
||||
|
||||
- Timeouts fixes in Raft (Alexander)
|
||||
|
||||
When Patroni or patronictl are starting they try to get Raft cluster topology from known members. These calls were made without proper timeouts.
|
||||
|
||||
- Forcefully update consul service if token was changed (John A. Lotoski)
|
||||
|
||||
Not doing so results in errors "rpc error making call: rpc error making call: ACL not found".
|
||||
|
||||
|
||||
Version 2.1.4
|
||||
-------------
|
||||
|
||||
**New features**
|
||||
|
||||
- Improve ``pg_rewind`` behavior on typical Debian/Ubuntu systems (Gunnar "Nick" Bluth)
|
||||
|
||||
On Postgres setups that keep `postgresql.conf` outside of the data directory (e.g. Ubuntu/Debian packages), ``pg_rewind --restore-target-wal`` fails to figure out the value of the ``restore_command``.
|
||||
|
||||
- Allow setting ``TLSServerName`` on Consul service checks (Michael Gmelin)
|
||||
|
||||
Useful when checks are performed by IP and the Consul ``node_name`` is not a FQDN.
|
||||
|
||||
- Added ``ppc64le`` support in watchdog (Jean-Michel Scheiwiler)
|
||||
|
||||
And fixed watchdog support on some non-x86 platforms.
|
||||
|
||||
- Switched aws.py callback from ``boto`` to ``boto3`` (Alexander Kukushkin)
|
||||
|
||||
``boto`` 2.x is abandoned since 2018 and fails with python 3.9.
|
||||
|
||||
- Periodically refresh service account token on K8s (Haitao Li)
|
||||
|
||||
Since Kubernetes v1.21 service account tokens expire in 1 hour.
|
||||
|
||||
- Added ``/read-only-sync`` monitoring endpoint (Dennis4b)
|
||||
|
||||
It is similar to the ``/read-only`` but includes only synchronous replicas.
|
||||
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Don't copy the logical replication slot to a replica if there is a configuration mismatch in the logical decoding setup with the primary (Alexander)
|
||||
|
||||
A replica won't copy a logical replication slot from the primary anymore if the slot doesn't match the ``plugin`` or ``database`` configuration options. Previously, the check for whether the slot matches those configuration options was not performed until after the replica copied the slot and started with it, resulting in unnecessary and repeated restarts.
|
||||
|
||||
- Special handling of recovery configuration parameters for PostgreSQL v12+ (Alexander)
|
||||
|
||||
While starting as replica Patroni should be able to update ``postgresql.conf`` and restart/reload if the leader address has changed by caching current parameters values instead of querying them from ``pg_settings``.
|
||||
|
||||
- Better handling of IPv6 addresses in the ``postgresql.listen`` parameters (Alexander)
|
||||
|
||||
Since the ``listen`` parameter has a port, people try to put IPv6 addresses into square brackets, which were not correctly stripped when there is more than one IP in the list.
|
||||
|
||||
- Use ``replication`` credentials when performing divergence check only on PostgreSQL v10 and older (Alexander)
|
||||
|
||||
If ``rewind`` is enabled, Patroni will again use either ``superuser`` or ``rewind`` credentials on newer Postgres versions.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed missing import of ``dateutil.parser`` (Wesley Mendes)
|
||||
|
||||
Tests weren't failing only because it was also imported from other modules.
|
||||
|
||||
- Ensure that ``optime`` annotation is a string (Sebastian Hasler)
|
||||
|
||||
In certain cases Patroni was trying to pass it as numeric.
|
||||
|
||||
- Better handling of failed ``pg_rewind`` attempt (Alexander)
|
||||
|
||||
If the primary becomes unavailable during ``pg_rewind``, ``$PGDATA`` will be left in a broken state. Following that, Patroni will remove the data directory even if this is not allowed by the configuration.
|
||||
|
||||
- Don't remove ``slots`` annotations from the leader ``ConfigMap``/``Endpoint`` when PostgreSQL isn't ready (Alexander)
|
||||
|
||||
If ``slots`` value isn't passed the annotation will keep the current value.
|
||||
|
||||
- Handle concurrency problem with K8s API watchers (Alexander)
|
||||
|
||||
Under certain (unknown) conditions watchers might become stale; as a result, ``attempt_to_acquire_leader()`` method could fail due to the HTTP status code 409. In that case we reset watchers connections and restart from scratch.
|
||||
|
||||
|
||||
Version 2.1.3
|
||||
-------------
|
||||
|
||||
**New features**
|
||||
|
||||
- Added support for encrypted TLS keys for ``patronictl`` (Alexander Kukushkin)
|
||||
|
||||
It could be configured via ``ctl.keyfile_password`` or the ``PATRONI_CTL_KEYFILE_PASSWORD`` environment variable.
|
||||
|
||||
- Added more metrics to the /metrics endpoint (Alexandre Pereira)
|
||||
|
||||
Specifically, ``patroni_pending_restart`` and ``patroni_is_paused``.
|
||||
|
||||
- Make it possible to specify multiple hosts in the standby cluster configuration (Michael Banck)
|
||||
|
||||
If the standby cluster is replicating from the Patroni cluster it might be nice to rely on client-side failover which is available in ``libpq`` since PostgreSQL v10. That is, the ``primary_conninfo`` on the standby leader and ``pg_rewind`` setting ``target_session_attrs=read-write`` in the connection string. The ``pgpass`` file will be generated with multiple lines (one line per host), and instead of calling ``CHECKPOINT`` on the primary cluster nodes the standby cluster will wait for ``pg_control`` to be updated.
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Compatibility with legacy ``psycopg2`` (Alexander)
|
||||
|
||||
For example, the ``psycopg2`` installed from Ubuntu 18.04 packages doesn't have the ``UndefinedFile`` exception yet.
|
||||
|
||||
- Restart ``etcd3`` watcher if all Etcd nodes don't respond (Alexander)
|
||||
|
||||
If the watcher is alive the ``get_cluster()`` method continues returning stale information even if all Etcd nodes are failing.
|
||||
|
||||
- Don't remove the leader lock in the standby cluster while paused (Alexander)
|
||||
|
||||
Previously the lock was maintained only by the node that was running as a primary and not a standby leader.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed bug in the standby-leader bootstrap (Alexander)
|
||||
|
||||
Patroni was considering bootstrap as failed if Postgres didn't start accepting connections after 60 seconds. The bug was introduced in the 2.1.2 release.
|
||||
|
||||
- Fixed bug with failover to a cascading standby (Alexander)
|
||||
|
||||
When figuring out which slots should be created on cascading standby we forgot to take into account that the leader might be absent.
|
||||
|
||||
- Fixed small issues in Postgres config validator (Alexander)
|
||||
|
||||
Integer parameters introduced in PostgreSQL v14 were failing to validate because min and max values were quoted in the validator.py
|
||||
|
||||
- Use replication credentials when checking leader status (Alexander)
|
||||
|
||||
It could be that the ``remove_data_directory_on_diverged_timelines`` is set, but there is no ``rewind_credentials`` defined and superuser access between nodes is not allowed.
|
||||
|
||||
- Fixed "port in use" error on REST API certificate replacement (Ants Aasma)
|
||||
|
||||
When switching certificates there was a race condition with a concurrent API request. If there is one active during the replacement period then the replacement will error out with a port in use error and Patroni gets stuck in a state without an active API server.
|
||||
|
||||
- Fixed a bug in cluster bootstrap if passwords contain ``%`` characters (Bastien Wirtz)
|
||||
|
||||
The bootstrap method executes the ``DO`` block, with all parameters properly quoted, but the ``cursor.execute()`` method didn't like an empty list with parameters passed.
|
||||
|
||||
- Fixed the "AttributeError: no attribute 'leader'" exception (Hrvoje Milković)
|
||||
|
||||
It could happen if the synchronous mode is enabled and the DCS content was wiped out.
|
||||
|
||||
- Fix bug in divergence timeline check (Alexander)
|
||||
|
||||
Patroni was falsely assuming that timelines have diverged. For pg_rewind it didn't create any problem, but if pg_rewind is not allowed and the ``remove_data_directory_on_diverged_timelines`` is set, it resulted in reinitializing the former leader.
|
||||
|
||||
|
||||
Version 2.1.2
|
||||
-------------
|
||||
|
||||
**New features**
|
||||
|
||||
- Compatibility with ``psycopg>=3.0`` (Alexander Kukushkin)
|
||||
|
||||
By default ``psycopg2`` is preferred. `psycopg>=3.0` will be used only if ``psycopg2`` is not available or its version is too old.
|
||||
|
||||
- Add ``dcs_last_seen`` field to the REST API (Michael Banck)
|
||||
|
||||
This field notes the last time (as unix epoch) a cluster member has successfully communicated with the DCS. This is useful to identify and/or analyze network partitions.
|
||||
|
||||
- Release the leader lock when ``pg_controldata`` reports "shut down" (Alexander)
|
||||
|
||||
To solve the problem of slow switchover/shutdown in case ``archive_command`` is slow/failing, Patroni will remove the leader key immediately after ``pg_controldata`` started reporting PGDATA as ``shut down`` cleanly and it verified that there is at least one replica that received all changes. If there are no replicas that fulfill this condition the leader key is not removed and the old behavior is retained, i.e. Patroni will keep updating the lock.
|
||||
|
||||
- Add ``sslcrldir`` connection parameter support (Kostiantyn Nemchenko)
|
||||
|
||||
The new connection parameter was introduced in the PostgreSQL v14.
|
||||
|
||||
- Allow setting ACLs for ZNodes in Zookeeper (Alwyn Davis)
|
||||
|
||||
Introduce a new configuration option ``zookeeper.set_acls`` so that Kazoo will apply a default ACL for each ZNode that it creates.
|
||||
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Delay the next attempt of recovery till next HA loop (Alexander)
|
||||
|
||||
If Postgres crashed due to out of disk space (for example) and fails to start because of that Patroni is too eagerly trying to recover it flooding logs.
|
||||
|
||||
- Add log before demoting, which can take some time (Michael)
|
||||
|
||||
It can take some time for the demote to finish and it might not be obvious from looking at the logs what exactly is going on.
|
||||
|
||||
- Improve "I am" status messages (Michael)
|
||||
|
||||
``no action. I am a secondary ({0})`` vs ``no action. I am ({0}), a secondary``
|
||||
|
||||
- Cast to int ``wal_keep_segments`` when converting to ``wal_keep_size`` (Jorge Solórzano)
|
||||
|
||||
It is possible to specify ``wal_keep_segments`` as a string in the global :ref:`dynamic configuration <dynamic_configuration>` and due to Python being a dynamically typed language the string was simply multiplied. Example: ``wal_keep_segments: "100"`` was converted to ``100100100100100100100100100100100100100100100100MB``.
|
||||
|
||||
- Allow switchover only to sync nodes when synchronous replication is enabled (Alexander)
|
||||
|
||||
In addition to that do the leader race only against known synchronous nodes.
|
||||
|
||||
- Use cached role as a fallback when Postgres is slow (Alexander)
|
||||
|
||||
In some extreme cases Postgres could be so slow that the normal monitoring query does not finish in a few seconds. The ``statement_timeout`` exception not being properly handled could lead to the situation where Postgres was not demoted on time when the leader key expired or the update failed. In case of such exception Patroni will use the cached ``role`` to determine whether Postgres is running as a primary.
|
||||
|
||||
- Avoid unnecessary updates of the member ZNode (Alexander)
|
||||
|
||||
If no values have changed in the members data, the update should not happen.
|
||||
|
||||
- Optimize checkpoint after promote (Alexander)
|
||||
|
||||
Avoid doing ``CHECKPOINT`` if the latest timeline is already stored in ``pg_control``. It helps to avoid unnecessary ``CHECKPOINT`` right after initializing the new cluster with ``initdb``.
|
||||
|
||||
- Prefer members without ``nofailover`` when picking sync nodes (Alexander)
|
||||
|
||||
Previously sync nodes were selected only based on the replication lag, hence the node with ``nofailover`` tag had the same chances to become synchronous as any other node. That behavior was confusing and dangerous at the same time because in case of a failed primary the failover could not happen automatically.
|
||||
|
||||
- Remove duplicate hosts from the etcd machine cache (Michael)
|
||||
|
||||
Advertised client URLs in the etcd cluster could be misconfigured. Removing duplicates in Patroni in this case is a low-hanging fruit.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Skip temporary replication slots while doing slot management (Alexander)
|
||||
|
||||
Starting from v10 ``pg_basebackup`` creates a temporary replication slot for WAL streaming and Patroni was trying to drop it because the slot name looks unknown. In order to fix it, we skip all temporary slots when querying ``pg_stat_replication_slots`` view.
|
||||
|
||||
- Ensure ``pg_replication_slot_advance()`` doesn't timeout (Alexander)
|
||||
|
||||
Patroni was using the default ``statement_timeout`` in this case and once the call failed there are very high chances that it will never recover, resulting in increased size of ``pg_wal`` and ``pg_catalog`` bloat.
|
||||
|
||||
- The ``/status`` wasn't updated on demote (Alexander)
|
||||
|
||||
After demoting PostgreSQL the old leader updates the last LSN in DCS. Starting from ``2.1.0`` the new ``/status`` key was introduced, but the optime was still written to the ``/optime/leader``.
|
||||
|
||||
- Handle DCS exceptions when demoting (Alexander)
|
||||
|
||||
While demoting the master due to failure to update the leader lock it could happen that DCS goes completely down and the ``get_cluster()`` call raises an exception. Not being handled properly it results in Postgres remaining stopped until DCS recovers.
|
||||
|
||||
- The ``use_unix_socket_repl`` didn't work is some cases (Alexander)
|
||||
|
||||
Specifically, if ``postgresql.unix_socket_directories`` is not set. In this case Patroni is supposed to use the default value from ``libpq``.
|
||||
|
||||
- Fix a few issues with Patroni REST API (Alexander)
|
||||
|
||||
The ``clusters_unlocked`` sometimes could be not defined, what resulted in exceptions in the ``GET /metrics`` endpoint. In addition to that the error handling method was assuming that the ``connect_address`` tuple always has two elements, while in fact there could be more in case of IPv6.
|
||||
|
||||
- Wait for newly promoted node to finish recovery before deciding to rewind (Alexander)
|
||||
|
||||
It could take some time before the actual promote happens and the new timeline is created. Without waiting replicas could come to the conclusion that rewind isn't required.
|
||||
|
||||
- Handle missing timelines in a history file when deciding to rewind (Alexander)
|
||||
|
||||
If the current replica timeline is missing in the history file on the primary the replica was falsely assuming that rewind isn't required.
|
||||
|
||||
|
||||
Version 2.1.1
|
||||
-------------
|
||||
|
||||
**New features**
|
||||
|
||||
- Support for ETCD SRV name suffix (David Pavlicek)
|
||||
|
||||
Etcd allows to differentiate between multiple Etcd clusters under the same domain and from now on Patroni also supports it.
|
||||
|
||||
- Enrich history with the new leader (huiyalin525)
|
||||
|
||||
It adds the new column to the ``patronictl history`` output.
|
||||
|
||||
- Make the CA bundle configurable for in-cluster Kubernetes config (Aron Parsons)
|
||||
|
||||
By default Patroni is using ``/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`` and this new feature allows specifying the custom ``kubernetes.cacert``.
|
||||
|
||||
- Support dynamically registering/deregistering as a Consul service and changing tags (Tommy Li)
|
||||
|
||||
Previously it required Patroni restart.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Avoid unnecessary reload of REST API (Alexander Kukushkin)
|
||||
|
||||
The previous release added a feature of reloading REST API certificates if changed on disk. Unfortunately, the reload was happening unconditionally right after the start.
|
||||
|
||||
- Don't resolve cluster members when ``etcd.use_proxies`` is set (Alexander)
|
||||
|
||||
When starting up Patroni checks the healthiness of Etcd cluster by querying the list of members. In addition to that, it also tried to resolve their hostnames, which is not necessary when working with Etcd via proxy and was causing unnecessary warnings.
|
||||
|
||||
- Skip rows with NULL values in the ``pg_stat_replication`` (Alexander)
|
||||
|
||||
It seems that the ``pg_stat_replication`` view could contain NULL values in the ``replay_lsn``, ``flush_lsn``, or ``write_lsn`` fields even when ``state = 'streaming'``.
|
||||
|
||||
|
||||
Version 2.1.0
|
||||
-------------
|
||||
|
||||
This version adds compatibility with PostgreSQL v14, makes logical replication slots to survive failover/switchover, implements support of allowlist for REST API, and also reducing the number of logs to one line per heart-beat.
|
||||
|
||||
**New features**
|
||||
|
||||
- Compatibility with PostgreSQL v14 (Alexander Kukushkin)
|
||||
|
||||
Unpause WAL replay if Patroni is not in a "pause" mode itself. It could be "paused" due to the change of certain parameters like for example ``max_connections`` on the primary.
|
||||
|
||||
- Failover logical slots (Alexander)
|
||||
|
||||
Make logical replication slots survive failover/switchover on PostgreSQL v11+. The replication slot if copied from the primary to the replica with restart and later the `pg_replication_slot_advance() <https://www.postgresql.org/docs/11/functions-admin.html#id-1.5.8.31.8.5.2.2.8.1.1>`__ function is used to move it forward. As a result, the slot will already exist before the failover and no events should be lost, but, there is a chance that some events could be delivered more than once.
|
||||
|
||||
- Implemented allowlist for Patroni REST API (Alexander)
|
||||
|
||||
If configured, only IP's that matching rules would be allowed to call unsafe endpoints. In addition to that, it is possible to automatically include IP's of members of the cluster to the list.
|
||||
|
||||
- Added support of replication connections via unix socket (Mohamad El-Rifai)
|
||||
|
||||
Previously Patroni was always using TCP for replication connection what could cause some issues with SSL verification. Using unix sockets allows exempt replication user from SSL verification.
|
||||
|
||||
- Health check on user-defined tags (Arman Jafari Tehrani)
|
||||
|
||||
Along with :ref:`predefined tags: <tags_settings>` it is possible to specify any number of custom tags that become visible in the ``patronictl list`` output and in the REST API. From now on it is possible to use custom tags in health checks.
|
||||
|
||||
- Added Prometheus ``/metrics`` endpoint (Mark Mercado, Michael Banck)
|
||||
|
||||
The endpoint exposing the same metrics as ``/patroni``.
|
||||
|
||||
- Reduced chattiness of Patroni logs (Alexander)
|
||||
|
||||
When everything goes normal, only one line will be written for every run of HA loop.
|
||||
|
||||
|
||||
**Breaking changes**
|
||||
|
||||
- The old ``permanent logical replication slots`` feature will no longer work with PostgreSQL v10 and older (Alexander)
|
||||
|
||||
The strategy of creating the logical slots after performing a promotion can't guaranty that no logical events are lost and therefore disabled.
|
||||
|
||||
- The ``/leader`` endpoint always returns 200 if the node holds the lock (Alexander)
|
||||
|
||||
Promoting the standby cluster requires updating load-balancer health checks, which is not very convenient and easy to forget. To solve it, we change the behavior of the ``/leader`` health check endpoint. It will return 200 without taking into account whether the cluster is normal or the ``standby_cluster``.
|
||||
|
||||
|
||||
**Improvements in Raft support**
|
||||
|
||||
- Reliable support of Raft traffic encryption (Alexander)
|
||||
|
||||
Due to the different issues in the ``PySyncObj`` the encryption support was very unstable
|
||||
|
||||
- Handle DNS issues in Raft implementation (Alexander)
|
||||
|
||||
If ``self_addr`` and/or ``partner_addrs`` are configured using the DNS name instead of IP's the ``PySyncObj`` was effectively doing resolve only once when the object is created. It was causing problems when the same node was coming back online with a different IP.
|
||||
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Compatibility with ``psycopg2-2.9+`` (Alexander)
|
||||
|
||||
In ``psycopg2`` the ``autocommit = True`` is ignored in the ``with connection`` block, which breaks replication protocol connections.
|
||||
|
||||
- Fix excessive HA loop runs with Zookeeper (Alexander)
|
||||
|
||||
Update of member ZNodes was causing a chain reaction and resulted in running the HA loops multiple times in a row.
|
||||
|
||||
- Reload if REST API certificate is changed on disk (Michael Todorovic)
|
||||
|
||||
If the REST API certificate file was updated in place Patroni didn't perform a reload.
|
||||
|
||||
- Don't create pgpass dir if kerberos auth is used (Kostiantyn Nemchenko)
|
||||
|
||||
Kerberos and password authentication are mutually exclusive.
|
||||
|
||||
- Fixed little issues with custom bootstrap (Alexander)
|
||||
|
||||
Start Postgres with ``hot_standby=off`` only when we do a PITR and restart it after PITR is done.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Compatibility with ``kazoo-2.7+`` (Alexander)
|
||||
|
||||
Since Patroni is handling retries on its own, it is relying on the old behavior of ``kazoo`` that requests to a Zookeeper cluster are immediately discarded when there are no connections available.
|
||||
|
||||
- Explicitly request the version of Etcd v3 cluster when it is known that we are connecting via proxy (Alexander)
|
||||
|
||||
Patroni is working with Etcd v3 cluster via gPRC-gateway and it depending on the cluster version different endpoints (``/v3``, ``/v3beta``, or ``/v3alpha``) must be used. The version was resolved only together with the cluster topology, but since the latter was never done when connecting via proxy.
|
||||
|
||||
|
||||
Version 2.0.2
|
||||
-------------
|
||||
|
||||
@@ -682,7 +1215,7 @@ Version 1.6.1
|
||||
|
||||
- Some improvements in logging infrastructure (Alexander Kukushkin)
|
||||
|
||||
Previously threre was a possibility to loose the last few log lines on shutdown because the logging thread was a ``daemon`` thread.
|
||||
Previously there was a possibility to loose the last few log lines on shutdown because the logging thread was a ``daemon`` thread.
|
||||
|
||||
- Use ``spawn`` multiprocessing start method on python 3.4+ (Maciej Kowalczyk)
|
||||
|
||||
@@ -732,7 +1265,7 @@ Version 1.6.1
|
||||
|
||||
If the method is executed from the REST API thread, it requires a separate cursor object to be created.
|
||||
|
||||
- Fix the problem of not promoting the sync standby that had a name contaning upper case letters (Alexander Kukushkin)
|
||||
- Fix the problem of not promoting the sync standby that had a name containing upper case letters (Alexander Kukushkin)
|
||||
|
||||
We converted the name to the lower case because Postgres was doing the same while comparing the ``application_name`` with the value in ``synchronous_standby_names``.
|
||||
|
||||
@@ -1008,7 +1541,7 @@ Compatibility and bugfix release.
|
||||
|
||||
- Fix broken compatibility with postgres 9.3 (Alexander)
|
||||
|
||||
When opening a replication connection we should specify replication=1, beacuse 9.3 does not understand replication='database'
|
||||
When opening a replication connection we should specify replication=1, because 9.3 does not understand replication='database'
|
||||
|
||||
- Make sure we refresh Consul session at least once per HA loop and improve handling of consul sessions exceptions (Alexander)
|
||||
|
||||
@@ -1093,7 +1626,7 @@ This version enables Patroni HA cluster to operate in a standby mode, introduces
|
||||
|
||||
- Immediately reserve the WAL position upon creation of the replication slot (Alexander Kukushkin)
|
||||
|
||||
Starting from 9.6, `pg_create_physical_replication_slot` function provides an additional boolean parameter `immediately_reserve`. When it is set to `false`, which is also the default, the slot doesn't reserve the WAL position until it receives the first client connection, potentially losing some segments required by the client in a time window between the slot creation and the intiial client connection.
|
||||
Starting from 9.6, `pg_create_physical_replication_slot` function provides an additional boolean parameter `immediately_reserve`. When it is set to `false`, which is also the default, the slot doesn't reserve the WAL position until it receives the first client connection, potentially losing some segments required by the client in a time window between the slot creation and the initial client connection.
|
||||
|
||||
- Fix bug in strict synchronous replication (Alexander Kukushkin)
|
||||
|
||||
@@ -1333,7 +1866,7 @@ This version adds support for using Kubernetes as a DCS, allowing to run Patroni
|
||||
|
||||
**Upgrade notice**
|
||||
|
||||
Installing Patroni via pip will no longer bring in dependencies for (such as libraries for Etcd, Zookeper, Consul or Kubernetes, or support for AWS). In order to enable them one need to list them in pip install command explicitely, for instance `pip install patroni[etcd,kubernetes]`.
|
||||
Installing Patroni via pip will no longer bring in dependencies for (such as libraries for Etcd, Zookeper, Consul or Kubernetes, or support for AWS). In order to enable them one need to list them in pip install command explicitly, for instance `pip install patroni[etcd,kubernetes]`.
|
||||
|
||||
**Kubernetes support**
|
||||
|
||||
@@ -1352,7 +1885,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Remove leader key on shutdown only when we have the lock (Ants)
|
||||
|
||||
Unconditional removal was generating unnecessary and missleading exceptions.
|
||||
Unconditional removal was generating unnecessary and misleading exceptions.
|
||||
|
||||
**Improvements in patronictl**
|
||||
|
||||
@@ -1383,7 +1916,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Alter the behavior of ``patronictl failover`` (Alexander)
|
||||
|
||||
It will work even if there is no leader, but in that case you will have to explicitely specify a node which should become the new leader.
|
||||
It will work even if there is no leader, but in that case you will have to explicitly specify a node which should become the new leader.
|
||||
|
||||
**Expose information about timeline and history**
|
||||
|
||||
@@ -1399,7 +1932,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Add new /sync and /async endpoints (Alexander, Oleksii Kliukin)
|
||||
|
||||
Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchornous replicas correspondingly (exclusing those marked as `noloadbalance`).
|
||||
Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchronous replicas correspondingly (exclusing those marked as `noloadbalance`).
|
||||
|
||||
**Allow multiple hosts for Etcd**
|
||||
|
||||
@@ -1487,7 +2020,7 @@ Version 1.3.4
|
||||
|
||||
- Pass the consul token as a header (Andrew Colin Kissa)
|
||||
|
||||
Headers are now the prefered way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__.
|
||||
Headers are now the preferred way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__.
|
||||
|
||||
|
||||
- Advanced configuration for Consul (Alexander Kukushkin)
|
||||
@@ -1805,7 +2338,7 @@ In addition, patronictl supports new ``pause`` and ``resume`` commands to toggle
|
||||
Originally, ping_timeout and connect_timeout values were calculated from the negotiated session timeout. Patroni loop_wait was not taken into account. As
|
||||
a result, a single retry could take more time than the session timeout, forcing Patroni to release the lock and demote.
|
||||
|
||||
This change set ping and connect timeout to half of the value of loop_wait, speeding up detection of connection issues and leaving enough time to retry the connection attempt before loosing the lock.
|
||||
This change set ping and connect timeout to half of the value of loop_wait, speeding up detection of connection issues and leaving enough time to retry the connection attempt before losing the lock.
|
||||
|
||||
- Update Etcd topology only after original request succeed (Alexander)
|
||||
|
||||
@@ -1883,7 +2416,7 @@ When upgrading from v0.90 or below, always upgrade all replicas before the maste
|
||||
|
||||
See the :ref:`dynamic configuration <dynamic_configuration>` for the details on which parameters can be changed and the order of processing difference configuration sources.
|
||||
|
||||
The configuration file format *has changed* since the v0.90. Patroni is still compatible with the old configuration files, but in order to take advantage of the bootstrap parameters one needs to change it. Users are encourage to update them by referring to the :ref:`dynamic configuraton documentation page <dynamic_configuration>`.
|
||||
The configuration file format *has changed* since the v0.90. Patroni is still compatible with the old configuration files, but in order to take advantage of the bootstrap parameters one needs to change it. Users are encourage to update them by referring to the :ref:`dynamic configuration documentation page <dynamic_configuration>`.
|
||||
|
||||
**More flexible configuration***
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ Building replicas
|
||||
-----------------
|
||||
|
||||
Patroni uses tried and proven ``pg_basebackup`` in order to create new replicas. One downside of it is that it requires
|
||||
a running master node. Another one is the lack of 'on-the-fly' compression for the backup data and no built-in cleanup
|
||||
a running leader node. Another one is the lack of 'on-the-fly' compression for the backup data and no built-in cleanup
|
||||
for outdated backup files. Some people prefer other backup solutions, such as ``WAL-E``, ``pgBackRest``, ``Barman`` and
|
||||
others, or simply roll their own scripts. In order to accommodate all those use-cases Patroni supports running custom
|
||||
scripts to clone a new replica. Those are configured in the ``postgresql`` configuration block:
|
||||
@@ -123,11 +123,11 @@ to execute and any custom parameters that should be passed to that command. All
|
||||
--role
|
||||
Always 'replica'
|
||||
--connstring
|
||||
Connection string to connect to the cluster member to clone from (master or other replica). The user in the
|
||||
Connection string to connect to the cluster member to clone from (primary or other replica). The user in the
|
||||
connection string can execute SQL and replication protocol commands.
|
||||
|
||||
A special ``no_master`` parameter, if defined, allows Patroni to call the replica creation method even if there is no
|
||||
running master or replicas. In that case, an empty string will be passed in a connection string. This is useful for
|
||||
running leader or replicas. In that case, an empty string will be passed in a connection string. This is useful for
|
||||
restoring the formerly running cluster from the binary backup.
|
||||
|
||||
A special ``keep_data`` parameter, if defined, will instruct Patroni to not clean PGDATA folder before calling restore.
|
||||
@@ -137,7 +137,7 @@ A special ``no_params`` parameter, if defined, restricts passing parameters to c
|
||||
A ``basebackup`` method is a special case: it will be used if
|
||||
``create_replica_methods`` is empty, although it is possible
|
||||
to list it explicitly among the ``create_replica_methods`` methods. This method initializes a new replica with the
|
||||
``pg_basebackup``, the base backup is taken from the master unless there are replicas with ``clonefrom`` tag, in which case one
|
||||
``pg_basebackup``, the base backup is taken from the leader unless there are replicas with ``clonefrom`` tag, in which case one
|
||||
of such replicas will be used as the origin for pg_basebackup. It works without any configuration; however, it is
|
||||
possible to specify a ``basebackup`` configuration section. Same rules as with the other method configuration apply,
|
||||
namely, only long (with --) options should be specified there. Not all parameters make sense, if you override a connection
|
||||
@@ -176,10 +176,10 @@ Standby cluster
|
||||
---------------
|
||||
|
||||
Another available option is to run a "standby cluster", that contains only of
|
||||
standby nodes replicating from some remote master. This type of clusters has:
|
||||
standby nodes replicating from some remote node. This type of clusters has:
|
||||
|
||||
* "standby leader", that behaves pretty much like a regular cluster leader,
|
||||
except it replicates from a remote master.
|
||||
except it replicates from a remote node.
|
||||
|
||||
* cascade replicas, that are replicating from standby leader.
|
||||
|
||||
@@ -187,6 +187,13 @@ Standby leader holds and updates a leader lock in DCS. If the leader lock
|
||||
expires, cascade replicas will perform an election to choose another leader
|
||||
from the standbys.
|
||||
|
||||
There is no further relationship between the standby cluster and the primary
|
||||
cluster it replicates from, in particular, they must not share the same DCS
|
||||
scope if they use the same DCS. They do not know anything else from each other
|
||||
apart from replication information. Also, the standby cluster is not being
|
||||
displayed in ``patronictl list`` or ``patronictl topology`` output on the
|
||||
primary cluster.
|
||||
|
||||
For the sake of flexibility, you can specify methods of creating a replica and
|
||||
recovery WAL records when a cluster is in the "standby mode" by providing
|
||||
`create_replica_methods` key in `standby_cluster` section. It is distinct from
|
||||
@@ -212,4 +219,9 @@ in a patroni configuration:
|
||||
Note, that these options will be applied only once during cluster bootstrap,
|
||||
and the only way to change them afterwards is through DCS.
|
||||
|
||||
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
|
||||
of the remote primary and will not start if it does not find it after a
|
||||
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
|
||||
your responsibility to copy it to PGDATA.
|
||||
|
||||
If you use replication slots on the standby cluster, you must also create the corresponding replication slot on the primary cluster. It will not be done automatically by the standby cluster implementation. You can use Patroni's permanent replication slots feature on the primary cluster to maintain a replication slot with the same name as ``primary_slot_name``, or its default value if ``primary_slot_name`` is not provided.
|
||||
|
||||
@@ -13,7 +13,7 @@ In asynchronous mode the cluster is allowed to lose some committed transactions
|
||||
|
||||
The amount of transactions that can be lost is controlled via ``maximum_lag_on_failover`` parameter. Because the primary transaction log position is not sampled in real time, in reality the amount of lost data on failover is worst case bounded by ``maximum_lag_on_failover`` bytes of transaction log plus the amount that is written in the last ``ttl`` seconds (``loop_wait``/2 seconds in the average case). However typical steady state replication delay is well under a second.
|
||||
|
||||
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former master become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
|
||||
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former primary become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
|
||||
|
||||
PostgreSQL synchronous replication
|
||||
----------------------------------
|
||||
|
||||
+22
-7
@@ -7,16 +7,19 @@ Patroni has a rich REST API, which is used by Patroni itself during the leader r
|
||||
|
||||
Health check endpoints
|
||||
----------------------
|
||||
For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``OPTIONS`` method instead of ``GET``.
|
||||
For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``HEAD`` or ``OPTIONS`` method instead of ``GET``.
|
||||
|
||||
- The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the leader:
|
||||
- The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the primary with leader lock:
|
||||
|
||||
- ``GET /``
|
||||
- ``GET /master``
|
||||
- ``GET /leader``
|
||||
- ``GET /primary``
|
||||
- ``GET /read-write``
|
||||
|
||||
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`.
|
||||
|
||||
- ``GET /leader``: returns HTTP status code **200** when the Patroni node has the leader lock. The major difference from the two previous endpoints is that it doesn't take into account whether PostgreSQL is running as the ``primary`` or the ``standby_leader``.
|
||||
|
||||
- ``GET /replica``: replica health check endpoint. It returns HTTP status code **200** only when the Patroni node is in the state ``running``, the role is ``replica`` and ``noloadbalance`` tag is not set.
|
||||
|
||||
- ``GET /replica?lag=<max-lag>``: replica check endpoint. In addition to checks from ``replica``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB.
|
||||
@@ -26,14 +29,26 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
|
||||
- ``GET /replica?lag=10MB``
|
||||
- ``GET /replica?lag=1GB``
|
||||
|
||||
- ``GET /read-only``: like the above endpoint, but also includes the primary.
|
||||
- ``GET /replica?tag_key1=value1&tag_key2=value2``: replica check endpoint. In addition, It will also check for user defined tags ``key1`` and ``key2`` and their respective values in the **tags** section of the yaml configuration management. If the tag isn't defined for an instance, or if the value in the yaml configuration doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
|
||||
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`.
|
||||
In the following requests, since we are checking for the leader or standby-leader status, Patroni doesn't apply any of the user defined tags and they will be ignored.
|
||||
- ``GET /?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /master?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /leader?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /primary?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /read-write?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /standby_leader?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /standby-leader?tag_key1=value1&tag_key2=value2``
|
||||
|
||||
- ``GET /read-only``: like the above endpoint, but also includes the primary.
|
||||
|
||||
- ``GET /synchronous`` or ``GET /sync``: returns HTTP status code **200** only when the Patroni node is running as a synchronous standby.
|
||||
|
||||
- ``GET /read-only-sync``: like the above endpoint, but also includes the primary.
|
||||
|
||||
- ``GET /asynchronous`` or ``GET /async``: returns HTTP status code **200** only when the Patroni node is running as an asynchronous standby.
|
||||
|
||||
|
||||
- ``GET /asynchronous?lag=<max-lag>`` or ``GET /async?lag=<max-lag>``: asynchronous standby check endpoint. In addition to checks from ``asynchronous`` or ``async``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB.
|
||||
|
||||
- ``GET /async?lag=1048576``
|
||||
@@ -43,9 +58,9 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
|
||||
|
||||
- ``GET /health``: returns HTTP status code **200** only when PostgreSQL is up and running.
|
||||
|
||||
- ``GET /liveness``: always returns HTTP status code **200** what only indicates that Patroni is running. Could be used for ``livenessProbe``.
|
||||
- ``GET /liveness``: returns HTTP status code **200** if Patroni heartbeat loop is properly running and **503** if the last run was more than ``ttl`` seconds ago on the primary or ``2*ttl`` on the replica. Could be used for ``livenessProbe``.
|
||||
|
||||
- ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubenetes endpoints for leader elections (OpenShift).
|
||||
- ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubernetes endpoints for leader elections (OpenShift).
|
||||
|
||||
Both, ``readiness`` and ``liveness`` endpoints are very light-weight and not executing any SQL. Probes should be configured in such a way that they start failing about time when the leader key is expiring. With the default value of ``ttl``, which is ``30s`` example probes would look like:
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
Watchdog support
|
||||
================
|
||||
|
||||
Having multiple PostgreSQL servers running as master can result in transactions lost due to diverging timelines. This situation is also called a split-brain problem. To avoid split-brain Patroni needs to ensure PostgreSQL will not accept any transaction commits after leader key expires in the DCS. Under normal circumstances Patroni will try to achieve this by stopping PostgreSQL when leader lock update fails for any reason. However, this may fail to happen due to various reasons:
|
||||
Having multiple PostgreSQL servers running as primary can result in transactions lost due to diverging timelines. This situation is also called a split-brain problem. To avoid split-brain Patroni needs to ensure PostgreSQL will not accept any transaction commits after leader key expires in the DCS. Under normal circumstances Patroni will try to achieve this by stopping PostgreSQL when leader lock update fails for any reason. However, this may fail to happen due to various reasons:
|
||||
|
||||
- Patroni has crashed due to a bug, out-of-memory condition or by being accidentally killed by a system administrator.
|
||||
|
||||
@@ -13,7 +13,7 @@ Having multiple PostgreSQL servers running as master can result in transactions
|
||||
|
||||
To guarantee correct behavior under these conditions Patroni supports watchdog devices. Watchdog devices are software or hardware mechanisms that will reset the whole system when they do not get a keepalive heartbeat within a specified timeframe. This adds an additional layer of fail safe in case usual Patroni split-brain protection mechanisms fail.
|
||||
|
||||
Patroni will try to activate the watchdog before promoting PostgreSQL to master. If watchdog activation fails and watchdog mode is ``required`` then the node will refuse to become master. When deciding to participate in leader election Patroni will also check that watchdog configuration will allow it to become leader at all. After demoting PostgreSQL (for example due to a manual failover) Patroni will disable the watchdog again. Watchdog will also be disabled while Patroni is in paused state.
|
||||
Patroni will try to activate the watchdog before promoting PostgreSQL to primary. If watchdog activation fails and watchdog mode is ``required`` then the node will refuse to become leader. When deciding to participate in leader election Patroni will also check that watchdog configuration will allow it to become leader at all. After demoting PostgreSQL (for example due to a manual failover) Patroni will disable the watchdog again. Watchdog will also be disabled while Patroni is in paused state.
|
||||
|
||||
By default Patroni will set up the watchdog to expire 5 seconds before TTL expires. With the default setup of ``loop_wait=10`` and ``ttl=30`` this gives HA loop at least 15 seconds (``ttl`` - ``safety_margin`` - ``loop_wait``) to complete before the system gets forcefully reset. By default accessing DCS is configured to time out after 10 seconds. This means that when DCS is unavailable, for example due to network issues, Patroni and PostgreSQL will have at least 5 seconds (``ttl`` - ``safety_margin`` - ``loop_wait`` - ``retry_timeout``) to come to a state where all client connections are terminated.
|
||||
|
||||
|
||||
@@ -18,14 +18,14 @@ listen stats
|
||||
|
||||
listen master
|
||||
bind *:5000
|
||||
option httpchk OPTIONS /master
|
||||
option httpchk HEAD /master
|
||||
http-check expect status 200
|
||||
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
|
||||
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
||||
{{end}}
|
||||
listen replicas
|
||||
bind *:5001
|
||||
option httpchk OPTIONS /replica
|
||||
option httpchk HEAD /replica
|
||||
http-check expect status 200
|
||||
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
|
||||
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
||||
|
||||
@@ -14,7 +14,7 @@ Group=postgres
|
||||
# Read in configuration file if it exists, otherwise proceed
|
||||
EnvironmentFile=-/etc/patroni_env.conf
|
||||
|
||||
# the default is the user's home directory, and if you want to change it, you must provide an absolute path.
|
||||
# The default is the user's home directory, and if you want to change it, you must provide an absolute path.
|
||||
# WorkingDirectory=/home/sameuser
|
||||
|
||||
# Where to send early-startup messages from the server
|
||||
@@ -32,14 +32,14 @@ ExecStart=/bin/patroni /etc/patroni.yml
|
||||
# Send HUP to reload from patroni.yml
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
|
||||
# only kill the patroni process, not it's children, so it will gracefully stop postgres
|
||||
# Only kill the patroni process, not it's children, so it will gracefully stop postgres
|
||||
KillMode=process
|
||||
|
||||
# Give a reasonable amount of time for the server to start up/shut down
|
||||
TimeoutSec=30
|
||||
|
||||
# Do not restart the service if it crashes, we want to manually inspect database on failure
|
||||
Restart=no
|
||||
# Restart the service if it crashed
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -5,7 +5,7 @@ Feature: basic replication
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "loop_wait": 2, "synchronous_mode": true}
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "synchronous_mode": true}
|
||||
Then I receive a response code 200
|
||||
When I start postgres1
|
||||
And I configure and start postgres2 with a tag replicatefrom postgres0
|
||||
@@ -29,7 +29,7 @@ Feature: basic replication
|
||||
Then I receive a response code 200
|
||||
|
||||
Scenario: check stuck sync replica
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"maximum_lag_on_syncnode": 15000000, "postgresql": {"parameters": {"synchronous_commit": "remote_apply"}}}
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"pause": true, "maximum_lag_on_syncnode": 15000000, "postgresql": {"parameters": {"synchronous_commit": "remote_apply"}}}
|
||||
Then I receive a response code 200
|
||||
And I create table on postgres0
|
||||
And table mytest is present on postgres1 after 2 seconds
|
||||
@@ -43,7 +43,7 @@ Feature: basic replication
|
||||
Then I receive a response code 200
|
||||
When I issue a GET request to http://127.0.0.1:8010/async
|
||||
Then I receive a response code 200
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"maximum_lag_on_syncnode": -1, "postgresql": {"parameters": {"synchronous_commit": "on"}}}
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"pause": null, "maximum_lag_on_syncnode": -1, "postgresql": {"parameters": {"synchronous_commit": "on"}}}
|
||||
Then I receive a response code 200
|
||||
And I drop table on postgres0
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import psycopg2
|
||||
import sys
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if not (len(sys.argv) >= 3 and sys.argv[3] == "master"):
|
||||
sys.exit(1)
|
||||
|
||||
os.environ['PGPASSWORD'] = 'zalando'
|
||||
connection = psycopg2.connect(host='127.0.0.1', port=sys.argv[1], user='postgres')
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("SELECT slot_name FROM pg_replication_slots WHERE slot_type = 'logical'")
|
||||
|
||||
with open("data/postgres0/label", "w") as label:
|
||||
label.write(next(iter(cursor.fetchone()), ""))
|
||||
+137
-41
@@ -1,8 +1,8 @@
|
||||
import abc
|
||||
import datetime
|
||||
import os
|
||||
import psycopg2
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import six
|
||||
@@ -13,6 +13,9 @@ import threading
|
||||
import time
|
||||
import yaml
|
||||
|
||||
import patroni.psycopg as psycopg
|
||||
|
||||
from patroni.request import PatroniRequest
|
||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
@@ -137,12 +140,24 @@ class PatroniController(AbstractController):
|
||||
def _start(self):
|
||||
if self.watchdog:
|
||||
self.watchdog.start()
|
||||
env = os.environ.copy()
|
||||
if isinstance(self._context.dcs_ctl, KubernetesController):
|
||||
self._context.dcs_ctl.create_pod(self._name[8:], self._scope)
|
||||
os.environ['PATRONI_KUBERNETES_POD_IP'] = '10.0.0.' + self._name[-1]
|
||||
return subprocess.Popen([sys.executable, '-m', 'coverage', 'run',
|
||||
'--source=patroni', '-p', 'patroni.py', self._config],
|
||||
stdout=self._log, stderr=subprocess.STDOUT, cwd=self._work_directory)
|
||||
env['PATRONI_KUBERNETES_POD_IP'] = '10.0.0.' + self._name[-1]
|
||||
if os.name == 'nt':
|
||||
env['BEHAVE_DEBUG'] = 'true'
|
||||
patroni = subprocess.Popen([sys.executable, '-m', 'coverage', 'run',
|
||||
'--source=patroni', '-p', 'patroni.py', self._config], env=env,
|
||||
stdout=self._log, stderr=subprocess.STDOUT, cwd=self._work_directory)
|
||||
if os.name == 'nt':
|
||||
patroni.terminate = self.terminate
|
||||
return patroni
|
||||
|
||||
def terminate(self):
|
||||
try:
|
||||
self._context.request_executor.request('POST', self._restapi_url + '/sigterm')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self, kill=False, timeout=15, postgres=False):
|
||||
if postgres:
|
||||
@@ -163,7 +178,7 @@ class PatroniController(AbstractController):
|
||||
patroni_config_name = self.PATRONI_CONFIG.format(name)
|
||||
patroni_config_path = os.path.join(self._output_dir, patroni_config_name)
|
||||
|
||||
with open(patroni_config_name) as f:
|
||||
with open('postgres0.yml') as f:
|
||||
config = yaml.safe_load(f)
|
||||
config.pop('etcd', None)
|
||||
|
||||
@@ -172,19 +187,48 @@ class PatroniController(AbstractController):
|
||||
os.environ['RAFT_PORT'] = str(int(raft_port) + 1)
|
||||
config['raft'] = {'data_dir': self._output_dir, 'self_addr': 'localhost:' + os.environ['RAFT_PORT']}
|
||||
|
||||
host = config['postgresql']['listen'].split(':')[0]
|
||||
host = config['restapi']['listen'].rsplit(':', 1)[0]
|
||||
config['restapi']['listen'] = config['restapi']['connect_address'] = '{0}:{1}'.format(host, 8008+int(name[-1]))
|
||||
|
||||
host = config['postgresql']['listen'].rsplit(':', 1)[0]
|
||||
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
|
||||
|
||||
config['name'] = name
|
||||
config['postgresql']['data_dir'] = self._data_dir
|
||||
config['postgresql']['data_dir'] = self._data_dir.replace('\\', '/')
|
||||
config['postgresql']['basebackup'] = [{'checkpoint': 'fast'}]
|
||||
config['postgresql']['use_unix_socket'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
|
||||
config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name)
|
||||
config['postgresql']['use_unix_socket_repl'] = os.name != 'nt'
|
||||
config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name).replace('\\', '/')
|
||||
config['postgresql']['parameters'].update({
|
||||
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
|
||||
'logging_collector': 'on', 'log_destination': 'csvlog',
|
||||
'log_directory': self._output_dir.replace('\\', '/'),
|
||||
'log_filename': name + '.log', 'log_statement': 'all', 'log_min_messages': 'debug1',
|
||||
'unix_socket_directories': self._data_dir})
|
||||
'shared_buffers': '1MB', 'unix_socket_directories': tempfile.gettempdir().replace('\\', '/')})
|
||||
config['postgresql']['pg_hba'] = [
|
||||
'local all all trust',
|
||||
'local replication all trust',
|
||||
'host replication replicator all md5',
|
||||
'host all all all md5'
|
||||
]
|
||||
|
||||
if self._context.postgres_supports_ssl and self._context.certfile:
|
||||
config['postgresql']['parameters'].update({
|
||||
'ssl': 'on',
|
||||
'ssl_ca_file': self._context.certfile.replace('\\', '/'),
|
||||
'ssl_cert_file': self._context.certfile.replace('\\', '/'),
|
||||
'ssl_key_file': self._context.keyfile.replace('\\', '/')
|
||||
})
|
||||
for user in config['postgresql'].get('authentication').keys():
|
||||
config['postgresql'].get('authentication', {}).get(user, {}).update({
|
||||
'sslmode': 'verify-ca',
|
||||
'sslrootcert': self._context.certfile,
|
||||
'sslcert': self._context.certfile,
|
||||
'sslkey': self._context.keyfile
|
||||
})
|
||||
for i, line in enumerate(list(config['postgresql']['pg_hba'])):
|
||||
if line.endswith('md5'):
|
||||
# we want to verify client cert first and than password
|
||||
config['postgresql']['pg_hba'][i] = 'hostssl' + line[4:] + ' clientcert=verify-ca'
|
||||
|
||||
if 'bootstrap' in config:
|
||||
config['bootstrap']['post_bootstrap'] = 'psql -w -c "SELECT 1"'
|
||||
@@ -195,26 +239,28 @@ class PatroniController(AbstractController):
|
||||
self.recursive_update(config, custom_config)
|
||||
|
||||
self.recursive_update(config, {
|
||||
'bootstrap': {'dcs': {'postgresql': {'parameters': {'wal_keep_segments': 100}}}}})
|
||||
'bootstrap': {'dcs': {'loop_wait': 2, 'postgresql': {'parameters': {'wal_keep_segments': 100}}}}})
|
||||
if config['postgresql'].get('callbacks', {}).get('on_role_change'):
|
||||
config['postgresql']['callbacks']['on_role_change'] += ' ' + str(self.__PORT)
|
||||
|
||||
with open(patroni_config_path, 'w') as f:
|
||||
yaml.safe_dump(config, f, default_flow_style=False)
|
||||
|
||||
user = config['postgresql'].get('authentication', config['postgresql']).get('superuser', {})
|
||||
self._connkwargs = {k: user[n] for n, k in [('username', 'user'), ('password', 'password')] if n in user}
|
||||
self._connkwargs.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
|
||||
self._connkwargs = config['postgresql'].get('authentication', config['postgresql']).get('superuser', {})
|
||||
self._connkwargs.update({'host': host, 'port': self.__PORT, 'dbname': 'postgres',
|
||||
'user': self._connkwargs.pop('username', None)})
|
||||
|
||||
self._replication = config['postgresql'].get('authentication', config['postgresql']).get('replication', {})
|
||||
self._replication.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
|
||||
self._replication.update({'host': host, 'port': self.__PORT, 'user': self._replication.pop('username', None)})
|
||||
self._restapi_url = 'http://{0}'.format(config['restapi']['connect_address'])
|
||||
if self._context.certfile:
|
||||
self._restapi_url = self._restapi_url.replace('http://', 'https://')
|
||||
|
||||
return patroni_config_path
|
||||
|
||||
def _connection(self):
|
||||
if not self._conn or self._conn.closed != 0:
|
||||
self._conn = psycopg2.connect(**self._connkwargs)
|
||||
self._conn.autocommit = True
|
||||
self._conn = psycopg.connect(**self._connkwargs)
|
||||
return self._conn
|
||||
|
||||
def _cursor(self):
|
||||
@@ -227,7 +273,7 @@ class PatroniController(AbstractController):
|
||||
cursor = self._cursor()
|
||||
cursor.execute(query)
|
||||
return cursor
|
||||
except psycopg2.Error:
|
||||
except psycopg.Error:
|
||||
if not fail_ok:
|
||||
raise
|
||||
|
||||
@@ -267,7 +313,10 @@ class PatroniController(AbstractController):
|
||||
|
||||
@property
|
||||
def backup_source(self):
|
||||
return 'postgres://{username}:{password}@{host}:{port}/{database}'.format(**self._replication)
|
||||
def escape(value):
|
||||
return re.sub(r'([\'\\ ])', r'\\\1', str(value))
|
||||
|
||||
return ' '.join('{0}={1}'.format(k, escape(v)) for k, v in self._replication.items())
|
||||
|
||||
def backup(self, dest=os.path.join('data', 'basebackup')):
|
||||
subprocess.call(PatroniPoolController.BACKUP_SCRIPT + ['--walmethod=none',
|
||||
@@ -392,7 +441,7 @@ class AbstractEtcdController(AbstractDcsController):
|
||||
self._client_cls = client_cls
|
||||
|
||||
def _start(self):
|
||||
return subprocess.Popen(["etcd", "--debug", "--data-dir", self._work_directory],
|
||||
return subprocess.Popen(["etcd", "--enable-v2=true", "--data-dir", self._work_directory],
|
||||
stdout=self._log, stderr=subprocess.STDOUT)
|
||||
|
||||
def _is_running(self):
|
||||
@@ -459,10 +508,10 @@ class KubernetesController(AbstractDcsController):
|
||||
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
|
||||
os.environ['PATRONI_KUBERNETES_LABELS'] = json.dumps(self._labels)
|
||||
os.environ['PATRONI_KUBERNETES_USE_ENDPOINTS'] = 'true'
|
||||
os.environ['PATRONI_KUBERNETES_BYPASS_API_SERVICE'] = 'true'
|
||||
os.environ.setdefault('PATRONI_KUBERNETES_BYPASS_API_SERVICE', 'true')
|
||||
|
||||
from patroni.dcs.kubernetes import k8s_client, k8s_config
|
||||
k8s_config.load_kube_config(context='local')
|
||||
k8s_config.load_kube_config(context=os.environ.setdefault('PATRONI_KUBERNETES_CONTEXT', 'kind-kind'))
|
||||
self._client = k8s_client
|
||||
self._api = self._client.CoreV1Api()
|
||||
|
||||
@@ -590,10 +639,12 @@ class ExhibitorController(ZooKeeperController):
|
||||
class RaftController(AbstractDcsController):
|
||||
|
||||
CONTROLLER_ADDR = 'localhost:1234'
|
||||
PASSWORD = '12345'
|
||||
|
||||
def __init__(self, context):
|
||||
super(RaftController, self).__init__(context)
|
||||
os.environ.update(PATRONI_RAFT_PARTNER_ADDRS="'" + self.CONTROLLER_ADDR + "'", RAFT_PORT='1234')
|
||||
os.environ.update(PATRONI_RAFT_PARTNER_ADDRS="'" + self.CONTROLLER_ADDR + "'",
|
||||
PATRONI_RAFT_PASSWORD=self.PASSWORD, RAFT_PORT='1234')
|
||||
self._raft = None
|
||||
|
||||
def _start(self):
|
||||
@@ -614,25 +665,25 @@ class RaftController(AbstractDcsController):
|
||||
|
||||
def cleanup_service_tree(self):
|
||||
from patroni.dcs.raft import KVStoreTTL
|
||||
from pysyncobj import SyncObjConf
|
||||
|
||||
if self._raft:
|
||||
self._raft.destroy()
|
||||
self._raft._SyncObj__thread.join()
|
||||
self.stop()
|
||||
os.makedirs(self._work_directory)
|
||||
self.start()
|
||||
|
||||
ready_event = threading.Event()
|
||||
conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, onReady=ready_event.set)
|
||||
self._raft = KVStoreTTL(None, [self.CONTROLLER_ADDR], conf)
|
||||
self._raft = KVStoreTTL(ready_event.set, None, None,
|
||||
partner_addrs=[self.CONTROLLER_ADDR], password=self.PASSWORD)
|
||||
self._raft.startAutoTick()
|
||||
ready_event.wait()
|
||||
|
||||
|
||||
class PatroniPoolController(object):
|
||||
|
||||
BACKUP_SCRIPT = [sys.executable, 'features/backup_create.py']
|
||||
ARCHIVE_RESTORE_SCRIPT = ' '.join((sys.executable, os.path.abspath('features/archive-restore.py')))
|
||||
PYTHON = sys.executable.replace('\\', '/')
|
||||
BACKUP_SCRIPT = [PYTHON, 'features/backup_create.py']
|
||||
ARCHIVE_RESTORE_SCRIPT = ' '.join((PYTHON, os.path.abspath('features/archive-restore.py')))
|
||||
|
||||
def __init__(self, context):
|
||||
self._context = context
|
||||
@@ -641,8 +692,17 @@ class PatroniPoolController(object):
|
||||
self._patroni_path = None
|
||||
self._processes = {}
|
||||
self.create_and_set_output_directory('')
|
||||
self._check_postgres_ssl()
|
||||
self.known_dcs = {subclass.name(): subclass for subclass in AbstractDcsController.get_subclasses()}
|
||||
|
||||
def _check_postgres_ssl(self):
|
||||
try:
|
||||
subprocess.check_output(['postgres', '-D', os.devnull, '-c', 'ssl=on'], stderr=subprocess.STDOUT)
|
||||
raise Exception # this one should never happen because the previous line will always raise and exception
|
||||
except Exception as e:
|
||||
self._context.postgres_supports_ssl = isinstance(e, subprocess.CalledProcessError)\
|
||||
and 'SSL is not supported by this build' not in e.output.decode()
|
||||
|
||||
@property
|
||||
def patroni_path(self):
|
||||
if self._patroni_path is None:
|
||||
@@ -658,7 +718,7 @@ class PatroniPoolController(object):
|
||||
def output_dir(self):
|
||||
return self._output_dir
|
||||
|
||||
def start(self, name, max_wait_limit=20, custom_config=None):
|
||||
def start(self, name, max_wait_limit=40, custom_config=None):
|
||||
if name not in self._processes:
|
||||
self._processes[name] = PatroniController(self._context, name, self.patroni_path,
|
||||
self._output_dir, custom_config)
|
||||
@@ -693,7 +753,8 @@ class PatroniPoolController(object):
|
||||
'bootstrap': {
|
||||
'method': 'pg_basebackup',
|
||||
'pg_basebackup': {
|
||||
'command': " ".join(self.BACKUP_SCRIPT) + ' --walmethod=stream --dbname=' + f.backup_source
|
||||
'command': " ".join(self.BACKUP_SCRIPT +
|
||||
['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)])
|
||||
},
|
||||
'dcs': {
|
||||
'postgresql': {
|
||||
@@ -708,7 +769,7 @@ class PatroniPoolController(object):
|
||||
'archive_mode': 'on',
|
||||
'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive ' +
|
||||
'--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive'))
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
|
||||
},
|
||||
'authentication': {
|
||||
'superuser': {'password': 'zalando1'},
|
||||
@@ -724,14 +785,14 @@ class PatroniPoolController(object):
|
||||
'bootstrap': {
|
||||
'method': 'backup_restore',
|
||||
'backup_restore': {
|
||||
'command': (sys.executable + ' features/backup_restore.py --sourcedir=' +
|
||||
os.path.join(self.patroni_path, 'data', 'basebackup')),
|
||||
'command': (self.PYTHON + ' features/backup_restore.py --sourcedir=' +
|
||||
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
|
||||
'recovery_conf': {
|
||||
'recovery_target_action': 'promote',
|
||||
'recovery_target_timeline': 'latest',
|
||||
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' +
|
||||
'--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive'))
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -868,12 +929,34 @@ class WatchdogMonitor(object):
|
||||
return triggered
|
||||
|
||||
|
||||
# actions to execute on start/stop of the tests and before running invidual features
|
||||
# actions to execute on start/stop of the tests and before running individual features
|
||||
def before_all(context):
|
||||
os.environ.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
|
||||
context.ci = any(a in os.environ for a in ('TRAVIS_BUILD_NUMBER', 'BUILD_NUMBER', 'GITHUB_ACTIONS'))
|
||||
context.ci = os.name == 'nt' or\
|
||||
any(a in os.environ for a in ('TRAVIS_BUILD_NUMBER', 'BUILD_NUMBER', 'GITHUB_ACTIONS'))
|
||||
context.timeout_multiplier = 5 if context.ci else 1 # MacOS sometimes is VERY slow
|
||||
context.pctl = PatroniPoolController(context)
|
||||
|
||||
context.keyfile = os.path.join(context.pctl.output_dir, 'patroni.key')
|
||||
context.certfile = os.path.join(context.pctl.output_dir, 'patroni.crt')
|
||||
try:
|
||||
with open(os.devnull, 'w') as null:
|
||||
ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni',
|
||||
'-keyout', context.keyfile, '-out', context.certfile], stdout=null, stderr=null)
|
||||
if ret != 0:
|
||||
raise Exception
|
||||
except Exception:
|
||||
context.keyfile = context.certfile = None
|
||||
|
||||
os.environ.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
|
||||
ctl = {'auth': os.environ['PATRONI_RESTAPI_USERNAME'] + ':' + os.environ['PATRONI_RESTAPI_PASSWORD']}
|
||||
if context.certfile:
|
||||
os.environ.update({'PATRONI_RESTAPI_CAFILE': context.certfile,
|
||||
'PATRONI_RESTAPI_CERTFILE': context.certfile,
|
||||
'PATRONI_RESTAPI_KEYFILE': context.keyfile,
|
||||
'PATRONI_RESTAPI_VERIFY_CLIENT': 'required',
|
||||
'PATRONI_CTL_INSECURE': 'on'})
|
||||
ctl.update({'cacert': context.certfile, 'certfile': context.certfile, 'keyfile': context.keyfile})
|
||||
context.request_executor = PatroniRequest({'ctl': ctl}, True)
|
||||
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
|
||||
context.dcs_ctl.start()
|
||||
try:
|
||||
@@ -891,13 +974,26 @@ def after_all(context):
|
||||
|
||||
def before_feature(context, feature):
|
||||
""" create per-feature output directory to collect Patroni and PostgreSQL logs """
|
||||
context.pctl.create_and_set_output_directory(feature.name)
|
||||
if feature.name == 'watchdog' and os.name == 'nt':
|
||||
feature.skip("Watchdog isn't supported on Windows")
|
||||
else:
|
||||
context.pctl.create_and_set_output_directory(feature.name)
|
||||
|
||||
|
||||
def after_feature(context, feature):
|
||||
""" stop all Patronis, remove their data directory and cleanup the keys in etcd """
|
||||
context.pctl.stop_all()
|
||||
shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data'))
|
||||
data = os.path.join(context.pctl.patroni_path, 'data')
|
||||
if os.path.exists(data):
|
||||
shutil.rmtree(data)
|
||||
context.dcs_ctl.cleanup_service_tree()
|
||||
if feature.status == 'failed':
|
||||
shutil.copytree(context.pctl.output_dir, context.pctl.output_dir + '_failed')
|
||||
|
||||
|
||||
def before_scenario(context, scenario):
|
||||
if 'slot-advance' in scenario.effective_tags:
|
||||
for p in context.pctl._processes.values():
|
||||
if p._conn and p._conn.server_version < 110000:
|
||||
scenario.skip('pg_replication_slot_advance() is not supported on {0}'.format(p._conn.server_version))
|
||||
break
|
||||
|
||||
@@ -3,7 +3,7 @@ Feature: ignored slots
|
||||
Given I start postgres1
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"loop_wait": 2, "ignore_slots": [{"name": "unmanaged_slot_0", "database": "postgres", "plugin": "test_decoding", "type": "logical"}, {"name": "unmanaged_slot_1", "database": "postgres", "plugin": "test_decoding"}, {"name": "unmanaged_slot_2", "database": "postgres"}, {"name": "unmanaged_slot_3"}], "postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"ignore_slots": [{"name": "unmanaged_slot_0", "database": "postgres", "plugin": "test_decoding", "type": "logical"}, {"name": "unmanaged_slot_1", "database": "postgres", "plugin": "test_decoding"}, {"name": "unmanaged_slot_2", "database": "postgres"}, {"name": "unmanaged_slot_3"}], "postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8009/config contains ignore_slots after 10 seconds
|
||||
# Make sure the wal_level has been changed.
|
||||
|
||||
@@ -35,13 +35,13 @@ Scenario: check local configuration reload
|
||||
Then I receive a response code 202
|
||||
|
||||
Scenario: check dynamic configuration change via DCS
|
||||
Given I run patronictl.py edit-config -s 'ttl=10' -s 'loop_wait=2' -p 'max_connections=101' --force batman
|
||||
Given I run patronictl.py edit-config -s 'ttl=10' -p 'max_connections=101' --force batman
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "+loop_wait: 2"
|
||||
And I receive a response output "+ttl: 10"
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/config
|
||||
Then I receive a response code 200
|
||||
And I receive a response loop_wait 2
|
||||
And I receive a response ttl 10
|
||||
When I issue a GET request to http://127.0.0.1:8008/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response tags {'new_tag': 'new_value'}
|
||||
@@ -51,11 +51,11 @@ Scenario: check the scheduled restart
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"role": "replica"}
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"}
|
||||
Then I receive a response code 202
|
||||
And I sleep for 4 seconds
|
||||
And I sleep for 8 seconds
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"restart_pending": "True"}
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"}
|
||||
Then I receive a response code 202
|
||||
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
@@ -71,6 +71,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
When I run patronictl.py restart batman postgres1 --force
|
||||
Then I receive a response returncode 0
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
And I sleep for 2 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 200
|
||||
And I receive a response state running
|
||||
@@ -103,12 +104,12 @@ Scenario: check the switchover via the API in the pause mode
|
||||
Then I receive a response code 503
|
||||
|
||||
Scenario: check the scheduled switchover
|
||||
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds
|
||||
Given I issue a scheduled switchover from postgres1 to postgres0 in 10 seconds
|
||||
Then I receive a response returncode 1
|
||||
And I receive a response output "Can't schedule switchover in the paused state"
|
||||
When I run patronictl.py resume batman
|
||||
Then I receive a response returncode 0
|
||||
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds
|
||||
Given I issue a scheduled switchover from postgres1 to postgres0 in 10 seconds
|
||||
Then I receive a response returncode 0
|
||||
And postgres0 is a leader after 20 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
Feature: standby cluster
|
||||
Scenario: check permanent logical slots are preserved on failover/switchover
|
||||
Scenario: prepare the cluster with logical slots
|
||||
Given I start postgres1
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"loop_wait": 2, "slots": {"pm_1": {"type": "physical"}}, "postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"pm_1": {"type": "physical"}}, "postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8009/config contains slots after 10 seconds
|
||||
And I sleep for 3 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"test_logical": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
|
||||
Then I receive a response code 200
|
||||
And I do a backup of postgres1
|
||||
When I start postgres0 with callback configured
|
||||
When I start postgres0
|
||||
Then "members/postgres0" key in DCS has state=running after 10 seconds
|
||||
And replication works from postgres1 to postgres0 after 15 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check permanent logical slots are synced to the replica
|
||||
Given I run patronictl.py restart batman postgres1 --force
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
When I add the table replicate_me to postgres1
|
||||
And I get all changes from logical slot test_logical on postgres1
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
Scenario: Detach exiting node from the cluster
|
||||
When I shut down postgres1
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And "members/postgres0" key in DCS has role=master after 3 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/
|
||||
Then I receive a response code 200
|
||||
And there is a label with "test_logical" in postgres0 data directory
|
||||
|
||||
Scenario: check replication of a single table in a standby cluster
|
||||
Given I start postgres1 in a standby cluster batman1 as a clone of postgres0
|
||||
@@ -35,6 +44,7 @@ Feature: standby cluster
|
||||
When I start postgres2 in a cluster batman1
|
||||
Then postgres2 role is the replica after 24 seconds
|
||||
And table foo is present on postgres2 after 20 seconds
|
||||
And postgres1 does not have a logical replication slot named test_logical
|
||||
|
||||
Scenario: check failover
|
||||
When I kill postgres1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import psycopg2 as pg
|
||||
import patroni.psycopg as pg
|
||||
|
||||
from behave import step, then
|
||||
from time import sleep, time
|
||||
@@ -28,7 +28,7 @@ def stop_postgres(context, name):
|
||||
def add_table(context, table_name, pg_name):
|
||||
# parse the configuration file and get the port
|
||||
try:
|
||||
context.pctl.query(pg_name, "CREATE TABLE {0}()".format(table_name))
|
||||
context.pctl.query(pg_name, "CREATE TABLE public.{0}()".format(table_name))
|
||||
except pg.Error as e:
|
||||
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
|
||||
|
||||
@@ -37,9 +37,9 @@ def add_table(context, table_name, pg_name):
|
||||
def toggle_wal_replay(context, action, pg_name):
|
||||
# pause or resume the wal replay process
|
||||
try:
|
||||
version = context.pctl.query(pg_name, "select pg_catalog.pg_read_file('PG_VERSION', 0, 2)").fetchone()
|
||||
wal = version and version[0] and int(version[0].split('.')[0]) < 10 and "xlog" or "wal"
|
||||
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal, action))
|
||||
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
|
||||
wal_name = 'xlog' if int(version)/10000 < 10 else 'wal'
|
||||
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
|
||||
except pg.Error as e:
|
||||
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
|
||||
|
||||
@@ -47,10 +47,10 @@ def toggle_wal_replay(context, action, pg_name):
|
||||
@step('I {action:w} table on {pg_name:w}')
|
||||
def crdr_mytest(context, action, pg_name):
|
||||
try:
|
||||
if (action == "create"):
|
||||
context.pctl.query(pg_name, "create table if not exists mytest(id Numeric)")
|
||||
else:
|
||||
context.pctl.query(pg_name, "drop table if exists mytest")
|
||||
if (action == "create"):
|
||||
context.pctl.query(pg_name, "create table if not exists public.mytest(id numeric)")
|
||||
else:
|
||||
context.pctl.query(pg_name, "drop table if exists public.mytest")
|
||||
except pg.Error as e:
|
||||
assert False, "Error {0} table mytest on {1}: {2}".format(action, pg_name, e)
|
||||
|
||||
@@ -59,7 +59,7 @@ def crdr_mytest(context, action, pg_name):
|
||||
def initiate_load(context, pg_name):
|
||||
# perform dummy load
|
||||
try:
|
||||
context.pctl.query(pg_name, "begin; insert into mytest select r::numeric from generate_series(1, 350000) r; commit;")
|
||||
context.pctl.query(pg_name, "insert into public.mytest select r::numeric from generate_series(1, 350000) r")
|
||||
except pg.Error as e:
|
||||
assert False, "Error loading test data on {0}: {1}".format(pg_name, e)
|
||||
|
||||
@@ -68,7 +68,7 @@ def initiate_load(context, pg_name):
|
||||
def table_is_present_on(context, table_name, pg_name, max_replication_delay):
|
||||
max_replication_delay *= context.timeout_multiplier
|
||||
for _ in range(int(max_replication_delay)):
|
||||
if context.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None:
|
||||
if context.pctl.query(pg_name, "SELECT 1 FROM public.{0}".format(table_name), fail_ok=True) is not None:
|
||||
break
|
||||
sleep(1)
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import parse
|
||||
import shlex
|
||||
import subprocess
|
||||
@@ -10,10 +9,8 @@ import yaml
|
||||
from behave import register_type, step, then
|
||||
from dateutil import tz
|
||||
from datetime import datetime, timedelta
|
||||
from patroni.request import PatroniRequest
|
||||
|
||||
tzutc = tz.tzutc()
|
||||
request_executor = PatroniRequest({'ctl': {'auth': 'username:password'}})
|
||||
|
||||
|
||||
@parse.with_pattern(r'https?://(?:\w|\.|:|/)+')
|
||||
@@ -73,9 +70,13 @@ def do_post_empty(context, url):
|
||||
|
||||
@step('I issue a {request_method:w} request to {url:url} with {data}')
|
||||
def do_request(context, request_method, url, data):
|
||||
if context.certfile:
|
||||
url = url.replace('http://', 'https://')
|
||||
data = data and json.loads(data)
|
||||
try:
|
||||
r = request_executor.request(request_method, url, data)
|
||||
r = context.request_executor.request(request_method, url, data)
|
||||
if request_method == 'PATCH' and r.status == 409:
|
||||
r = context.request_executor.request(request_method, url, data)
|
||||
except Exception:
|
||||
context.status_code = context.response = None
|
||||
else:
|
||||
@@ -86,10 +87,7 @@ def do_request(context, request_method, url, data):
|
||||
def do_run(context, cmd):
|
||||
cmd = [sys.executable, '-m', 'coverage', 'run', '--source=patroni', '-p'] + shlex.split(cmd)
|
||||
try:
|
||||
# XXX: Dirty hack! We need to take name/passwd from the config!
|
||||
env = os.environ.copy()
|
||||
env.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
|
||||
response = subprocess.check_output(cmd, stderr=subprocess.STDOUT, env=env)
|
||||
response = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
|
||||
context.status_code = 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
response = e.output
|
||||
@@ -135,9 +133,11 @@ def add_tag_to_config(context, tag, value, pg_name):
|
||||
|
||||
@then('Response on GET {url} contains {value} after {timeout:d} seconds')
|
||||
def check_http_response(context, url, value, timeout, negate=False):
|
||||
if context.certfile:
|
||||
url = url.replace('http://', 'https://')
|
||||
timeout *= context.timeout_multiplier
|
||||
for _ in range(int(timeout)):
|
||||
r = request_executor.request('GET', url)
|
||||
r = context.request_executor.request('GET', url)
|
||||
if (value in r.data.decode('utf-8')) != negate:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
+25
-1
@@ -1,5 +1,7 @@
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
import psycopg2 as pg
|
||||
import patroni.psycopg as pg
|
||||
|
||||
|
||||
@step('I create a logical replication slot {slot_name} on {pg_name:w} with the {plugin:w} plugin')
|
||||
@@ -34,3 +36,25 @@ def does_not_have_logical_replication_slot(context, pg_name, slot_name):
|
||||
assert not row, "Found unexpected replication slot named {0}".format(slot_name)
|
||||
except pg.Error:
|
||||
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
|
||||
|
||||
|
||||
@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
|
||||
def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name)
|
||||
slot1 = context.pctl.query(pg_name1, query).fetchone()
|
||||
slot2 = context.pctl.query(pg_name2, query).fetchone()
|
||||
if slot1[0] == slot2[0]:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2)
|
||||
|
||||
|
||||
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
|
||||
def logical_slot_get_changes(context, slot_name, pg_name):
|
||||
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from behave import step
|
||||
|
||||
|
||||
select_replication_query = """
|
||||
SELECT * FROM pg_catalog.pg_stat_replication
|
||||
WHERE application_name = '{0}'
|
||||
"""
|
||||
|
||||
executable = sys.executable if os.name != 'nt' else sys.executable.replace('\\', '/')
|
||||
callback = executable + " features/callback2.py "
|
||||
|
||||
|
||||
@step('I start {name:w} with callback configured')
|
||||
def start_patroni_with_callbacks(context, name):
|
||||
return context.pctl.start(name, custom_config={
|
||||
"postgresql": {
|
||||
"callbacks": {
|
||||
"on_role_change": executable + " features/callback.py"
|
||||
}
|
||||
}
|
||||
})
|
||||
def callbacks(context, name):
|
||||
return {c: '{0} features/callback2.py {1}'.format(context.pctl.PYTHON, name)
|
||||
for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')}
|
||||
|
||||
|
||||
@step('I start {name:w} in a cluster {cluster_name:w}')
|
||||
@@ -30,10 +14,10 @@ def start_patroni(context, name, cluster_name):
|
||||
return context.pctl.start(name, custom_config={
|
||||
"scope": cluster_name,
|
||||
"postgresql": {
|
||||
"callbacks": {c: callback + name for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')},
|
||||
"callbacks": callbacks(context, name),
|
||||
"backup_restore": {
|
||||
"command": (executable + " features/backup_restore.py --sourcedir=" +
|
||||
os.path.join(context.pctl.patroni_path, 'data', 'basebackup'))}
|
||||
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir=" +
|
||||
os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,11 +39,12 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
|
||||
"port": port,
|
||||
"primary_slot_name": "pm_1",
|
||||
"create_replica_methods": ["backup_restore", "basebackup"]
|
||||
}
|
||||
},
|
||||
"postgresql": {"parameters": {"wal_level": "logical"}}
|
||||
}
|
||||
},
|
||||
"postgresql": {
|
||||
"callbacks": {c: callback + name for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')}
|
||||
"callbacks": callbacks(context, name)
|
||||
}
|
||||
})
|
||||
return context.pctl.start(name)
|
||||
@@ -67,12 +52,12 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
|
||||
|
||||
@step('{pg_name1:w} is replicating from {pg_name2:w} after {timeout:d} seconds')
|
||||
def check_replication_status(context, pg_name1, pg_name2, timeout):
|
||||
bound_time = time.time() + timeout
|
||||
bound_time = time.time() + timeout * context.timeout_multiplier
|
||||
|
||||
while time.time() < bound_time:
|
||||
cur = context.pctl.query(
|
||||
pg_name2,
|
||||
select_replication_query.format(pg_name1),
|
||||
"SELECT * FROM pg_catalog.pg_stat_replication WHERE application_name = '{0}'".format(pg_name1),
|
||||
fail_ok=True
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ def polling_loop(timeout, interval=1):
|
||||
|
||||
@step('I start {name:w} with watchdog')
|
||||
def start_patroni_with_watchdog(context, name):
|
||||
return context.pctl.start(name, custom_config={'watchdog': True})
|
||||
return context.pctl.start(name, custom_config={'watchdog': True, 'bootstrap': {'dcs': {'ttl': 20}}})
|
||||
|
||||
|
||||
@step('{name:w} watchdog has been pinged after {timeout:d} seconds')
|
||||
@@ -31,6 +31,11 @@ def watchdog_was_closed(context, name):
|
||||
assert context.pctl.get_watchdog(name).was_closed
|
||||
|
||||
|
||||
@step('{name:w} watchdog has a {timeout:d} second timeout')
|
||||
def watchdog_has_timeout(context, name, timeout):
|
||||
assert context.pctl.get_watchdog(name).timeout == timeout
|
||||
|
||||
|
||||
@step('I reset {name:w} watchdog state')
|
||||
def watchdog_reset_pinged(context, name):
|
||||
context.pctl.get_watchdog(name).reset()
|
||||
|
||||
@@ -6,6 +6,14 @@ Feature: watchdog
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
And postgres0 watchdog has been pinged after 10 seconds
|
||||
And postgres0 watchdog has a 15 second timeout
|
||||
|
||||
Scenario: watchdog is reconfigured after global ttl changed
|
||||
Given I run patronictl.py edit-config batman -s ttl=30 --force
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "+ttl: 30"
|
||||
When I sleep for 4 seconds
|
||||
Then postgres0 watchdog has a 25 second timeout
|
||||
|
||||
Scenario: watchdog is disabled during pause
|
||||
Given I run patronictl.py pause batman
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
FROM postgres:11
|
||||
MAINTAINER Alexander Kukushkin <alexander.kukushkin@zalando.de>
|
||||
FROM postgres:15
|
||||
LABEL maintainer="Alexander Kukushkin <akukushkin@microsoft.com>"
|
||||
|
||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
|
||||
|
||||
@@ -20,6 +20,10 @@ metadata:
|
||||
spec:
|
||||
replicas: 3
|
||||
serviceName: *cluster_name
|
||||
selector:
|
||||
matchLabels:
|
||||
application: patroni
|
||||
cluster-name: *cluster_name
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
from patroni import main
|
||||
from patroni.__main__ import main
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+21
-185
@@ -1,141 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .daemon import AbstractPatroniDaemon, abstract_main
|
||||
from .version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PATRONI_ENV_PREFIX = 'PATRONI_'
|
||||
KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
|
||||
|
||||
|
||||
class Patroni(AbstractPatroniDaemon):
|
||||
|
||||
def __init__(self, config):
|
||||
from patroni.api import RestApiServer
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.ha import Ha
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.request import PatroniRequest
|
||||
from patroni.watchdog import Watchdog
|
||||
|
||||
super(Patroni, self).__init__(config)
|
||||
|
||||
self.version = __version__
|
||||
self.dcs = get_dcs(self.config)
|
||||
self.watchdog = Watchdog(self.config)
|
||||
self.load_dynamic_configuration()
|
||||
|
||||
self.postgresql = Postgresql(self.config['postgresql'])
|
||||
self.api = RestApiServer(self, self.config['restapi'])
|
||||
self.request = PatroniRequest(self.config, True)
|
||||
self.ha = Ha(self)
|
||||
|
||||
self.tags = self.get_tags()
|
||||
self.next_run = time.time()
|
||||
self.scheduled_restart = {}
|
||||
|
||||
def load_dynamic_configuration(self):
|
||||
from patroni.exceptions import DCSError
|
||||
while True:
|
||||
try:
|
||||
cluster = self.dcs.get_cluster()
|
||||
if cluster and cluster.config and cluster.config.data:
|
||||
if self.config.set_dynamic_configuration(cluster.config):
|
||||
self.dcs.reload_config(self.config)
|
||||
self.watchdog.reload_config(self.config)
|
||||
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
|
||||
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
|
||||
self.dcs.reload_config(self.config)
|
||||
break
|
||||
except DCSError:
|
||||
logger.warning('Can not get cluster from dcs')
|
||||
time.sleep(5)
|
||||
|
||||
def get_tags(self):
|
||||
return {tag: value for tag, value in self.config.get('tags', {}).items()
|
||||
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
|
||||
|
||||
@property
|
||||
def nofailover(self):
|
||||
return bool(self.tags.get('nofailover', False))
|
||||
|
||||
@property
|
||||
def nosync(self):
|
||||
return bool(self.tags.get('nosync', False))
|
||||
|
||||
def reload_config(self, sighup=False, local=False):
|
||||
try:
|
||||
super(Patroni, self).reload_config(sighup, local)
|
||||
if local:
|
||||
self.tags = self.get_tags()
|
||||
self.request.reload_config(self.config)
|
||||
self.api.reload_config(self.config['restapi'])
|
||||
self.watchdog.reload_config(self.config)
|
||||
self.postgresql.reload_config(self.config['postgresql'], sighup)
|
||||
self.dcs.reload_config(self.config)
|
||||
except Exception:
|
||||
logger.exception('Failed to reload config_file=%s', self.config.config_file)
|
||||
|
||||
@property
|
||||
def replicatefrom(self):
|
||||
return self.tags.get('replicatefrom')
|
||||
|
||||
@property
|
||||
def noloadbalance(self):
|
||||
return bool(self.tags.get('noloadbalance', False))
|
||||
|
||||
def schedule_next_run(self):
|
||||
self.next_run += self.dcs.loop_wait
|
||||
current_time = time.time()
|
||||
nap_time = self.next_run - current_time
|
||||
if nap_time <= 0:
|
||||
self.next_run = current_time
|
||||
# Release the GIL so we don't starve anyone waiting on async_executor lock
|
||||
time.sleep(0.001)
|
||||
# Warn user that Patroni is not keeping up
|
||||
logger.warning("Loop time exceeded, rescheduling immediately.")
|
||||
elif self.ha.watch(nap_time):
|
||||
self.next_run = time.time()
|
||||
|
||||
def run(self):
|
||||
self.api.start()
|
||||
self.next_run = time.time()
|
||||
super(Patroni, self).run()
|
||||
|
||||
def _run_cycle(self):
|
||||
logger.info(self.ha.run_cycle())
|
||||
|
||||
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
|
||||
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
|
||||
self.reload_config()
|
||||
|
||||
if self.postgresql.role != 'uninitialized':
|
||||
self.config.save_cache()
|
||||
|
||||
self.schedule_next_run()
|
||||
|
||||
def _shutdown(self):
|
||||
try:
|
||||
self.api.shutdown()
|
||||
except Exception:
|
||||
logger.exception('Exception during RestApi.shutdown')
|
||||
try:
|
||||
self.ha.shutdown()
|
||||
except Exception:
|
||||
logger.exception('Exception during Ha.shutdown')
|
||||
|
||||
|
||||
def patroni_main():
|
||||
from multiprocessing import freeze_support
|
||||
from patroni.validator import schema
|
||||
|
||||
freeze_support()
|
||||
abstract_main(Patroni, schema)
|
||||
MIN_PSYCOPG2 = (2, 5, 4)
|
||||
|
||||
|
||||
def fatal(string, *args):
|
||||
@@ -143,63 +10,32 @@ def fatal(string, *args):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_psycopg2():
|
||||
min_psycopg2 = (2, 5, 4)
|
||||
min_psycopg2_str = '.'.join(map(str, min_psycopg2))
|
||||
|
||||
def parse_version(version):
|
||||
def parse_version(version):
|
||||
def _parse_version(version):
|
||||
for e in version.split('.'):
|
||||
try:
|
||||
yield int(e)
|
||||
except ValueError:
|
||||
break
|
||||
return tuple(_parse_version(version.split(' ')[0]))
|
||||
|
||||
|
||||
# We pass MIN_PSYCOPG2 and parse_version as arguments to simplify usage of check_psycopg from the setup.py
|
||||
def check_psycopg(_min_psycopg2=MIN_PSYCOPG2, _parse_version=parse_version):
|
||||
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
version_str = psycopg2.__version__.split(' ')[0]
|
||||
version = tuple(parse_version(version_str))
|
||||
if version < min_psycopg2:
|
||||
fatal('Patroni requires psycopg2>={0}, but only {1} is available', min_psycopg2_str, version_str)
|
||||
from psycopg2 import __version__
|
||||
if _parse_version(__version__) >= _min_psycopg2:
|
||||
return
|
||||
version_str = __version__.split(' ')[0]
|
||||
except ImportError:
|
||||
fatal('Patroni requires psycopg2>={0} or psycopg2-binary', min_psycopg2_str)
|
||||
version_str = None
|
||||
|
||||
|
||||
def main():
|
||||
if os.getpid() != 1:
|
||||
check_psycopg2()
|
||||
return patroni_main()
|
||||
|
||||
# Patroni started with PID=1, it looks like we are in the container
|
||||
pid = 0
|
||||
|
||||
# Looks like we are in a docker, so we will act like init
|
||||
def sigchld_handler(signo, stack_frame):
|
||||
try:
|
||||
while True:
|
||||
ret = os.waitpid(-1, os.WNOHANG)
|
||||
if ret == (0, 0):
|
||||
break
|
||||
elif ret[0] != pid:
|
||||
logger.info('Reaped pid=%s, exit status=%s', *ret)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def passtochild(signo, stack_frame):
|
||||
if pid:
|
||||
os.kill(pid, signo)
|
||||
|
||||
if os.name != 'nt':
|
||||
signal.signal(signal.SIGCHLD, sigchld_handler)
|
||||
signal.signal(signal.SIGHUP, passtochild)
|
||||
signal.signal(signal.SIGQUIT, passtochild)
|
||||
signal.signal(signal.SIGUSR1, passtochild)
|
||||
signal.signal(signal.SIGUSR2, passtochild)
|
||||
signal.signal(signal.SIGINT, passtochild)
|
||||
signal.signal(signal.SIGABRT, passtochild)
|
||||
signal.signal(signal.SIGTERM, passtochild)
|
||||
|
||||
import multiprocessing
|
||||
patroni = multiprocessing.Process(target=patroni_main)
|
||||
patroni.start()
|
||||
pid = patroni.pid
|
||||
patroni.join()
|
||||
try:
|
||||
from psycopg import __version__
|
||||
except ImportError:
|
||||
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
|
||||
if version_str:
|
||||
error += ', but only psycopg2=={0} is available'.format(version_str)
|
||||
fatal(error)
|
||||
|
||||
+179
-1
@@ -1,4 +1,182 @@
|
||||
from patroni import main
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
from .daemon import AbstractPatroniDaemon, abstract_main
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Patroni(AbstractPatroniDaemon):
|
||||
|
||||
def __init__(self, config):
|
||||
from .api import RestApiServer
|
||||
from .dcs import get_dcs
|
||||
from .ha import Ha
|
||||
from .postgresql import Postgresql
|
||||
from .request import PatroniRequest
|
||||
from .version import __version__
|
||||
from .watchdog import Watchdog
|
||||
|
||||
super(Patroni, self).__init__(config)
|
||||
|
||||
self.version = __version__
|
||||
self.dcs = get_dcs(self.config)
|
||||
self.watchdog = Watchdog(self.config)
|
||||
self.load_dynamic_configuration()
|
||||
|
||||
self.postgresql = Postgresql(self.config['postgresql'])
|
||||
self.api = RestApiServer(self, self.config['restapi'])
|
||||
self.request = PatroniRequest(self.config, True)
|
||||
self.ha = Ha(self)
|
||||
|
||||
self.tags = self.get_tags()
|
||||
self.next_run = time.time()
|
||||
self.scheduled_restart = {}
|
||||
|
||||
def load_dynamic_configuration(self):
|
||||
from patroni.exceptions import DCSError
|
||||
while True:
|
||||
try:
|
||||
cluster = self.dcs.get_cluster()
|
||||
if cluster and cluster.config and cluster.config.data:
|
||||
if self.config.set_dynamic_configuration(cluster.config):
|
||||
self.dcs.reload_config(self.config)
|
||||
self.watchdog.reload_config(self.config)
|
||||
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
|
||||
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
|
||||
self.dcs.reload_config(self.config)
|
||||
self.watchdog.reload_config(self.config)
|
||||
break
|
||||
except DCSError:
|
||||
logger.warning('Can not get cluster from dcs')
|
||||
time.sleep(5)
|
||||
|
||||
def get_tags(self):
|
||||
return {tag: value for tag, value in self.config.get('tags', {}).items()
|
||||
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
|
||||
|
||||
@property
|
||||
def nofailover(self):
|
||||
return bool(self.tags.get('nofailover', False))
|
||||
|
||||
@property
|
||||
def nosync(self):
|
||||
return bool(self.tags.get('nosync', False))
|
||||
|
||||
def reload_config(self, sighup=False, local=False):
|
||||
try:
|
||||
super(Patroni, self).reload_config(sighup, local)
|
||||
if local:
|
||||
self.tags = self.get_tags()
|
||||
self.request.reload_config(self.config)
|
||||
if local or sighup and self.api.reload_local_certificate():
|
||||
self.api.reload_config(self.config['restapi'])
|
||||
self.watchdog.reload_config(self.config)
|
||||
self.postgresql.reload_config(self.config['postgresql'], sighup)
|
||||
self.dcs.reload_config(self.config)
|
||||
except Exception:
|
||||
logger.exception('Failed to reload config_file=%s', self.config.config_file)
|
||||
|
||||
@property
|
||||
def replicatefrom(self):
|
||||
return self.tags.get('replicatefrom')
|
||||
|
||||
@property
|
||||
def noloadbalance(self):
|
||||
return bool(self.tags.get('noloadbalance', False))
|
||||
|
||||
def schedule_next_run(self):
|
||||
self.next_run += self.dcs.loop_wait
|
||||
current_time = time.time()
|
||||
nap_time = self.next_run - current_time
|
||||
if nap_time <= 0:
|
||||
self.next_run = current_time
|
||||
# Release the GIL so we don't starve anyone waiting on async_executor lock
|
||||
time.sleep(0.001)
|
||||
# Warn user that Patroni is not keeping up
|
||||
logger.warning("Loop time exceeded, rescheduling immediately.")
|
||||
elif self.ha.watch(nap_time):
|
||||
self.next_run = time.time()
|
||||
|
||||
def run(self):
|
||||
self.api.start()
|
||||
self.next_run = time.time()
|
||||
super(Patroni, self).run()
|
||||
|
||||
def _run_cycle(self):
|
||||
logger.info(self.ha.run_cycle())
|
||||
|
||||
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
|
||||
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
|
||||
self.reload_config()
|
||||
|
||||
if self.postgresql.role != 'uninitialized':
|
||||
self.config.save_cache()
|
||||
|
||||
self.schedule_next_run()
|
||||
|
||||
def _shutdown(self):
|
||||
try:
|
||||
self.api.shutdown()
|
||||
except Exception:
|
||||
logger.exception('Exception during RestApi.shutdown')
|
||||
try:
|
||||
self.ha.shutdown()
|
||||
except Exception:
|
||||
logger.exception('Exception during Ha.shutdown')
|
||||
|
||||
|
||||
def patroni_main():
|
||||
from multiprocessing import freeze_support
|
||||
from patroni.validator import schema
|
||||
|
||||
freeze_support()
|
||||
abstract_main(Patroni, schema)
|
||||
|
||||
|
||||
def main():
|
||||
if os.getpid() != 1:
|
||||
from . import check_psycopg
|
||||
|
||||
check_psycopg()
|
||||
return patroni_main()
|
||||
|
||||
# Patroni started with PID=1, it looks like we are in the container
|
||||
pid = 0
|
||||
|
||||
# Looks like we are in a docker, so we will act like init
|
||||
def sigchld_handler(signo, stack_frame):
|
||||
try:
|
||||
while True:
|
||||
ret = os.waitpid(-1, os.WNOHANG)
|
||||
if ret == (0, 0):
|
||||
break
|
||||
elif ret[0] != pid:
|
||||
logger.info('Reaped pid=%s, exit status=%s', *ret)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def passtochild(signo, stack_frame):
|
||||
if pid:
|
||||
os.kill(pid, signo)
|
||||
|
||||
if os.name != 'nt':
|
||||
signal.signal(signal.SIGCHLD, sigchld_handler)
|
||||
signal.signal(signal.SIGHUP, passtochild)
|
||||
signal.signal(signal.SIGQUIT, passtochild)
|
||||
signal.signal(signal.SIGUSR1, passtochild)
|
||||
signal.signal(signal.SIGUSR2, passtochild)
|
||||
signal.signal(signal.SIGINT, passtochild)
|
||||
signal.signal(signal.SIGABRT, passtochild)
|
||||
signal.signal(signal.SIGTERM, passtochild)
|
||||
|
||||
import multiprocessing
|
||||
patroni = multiprocessing.Process(target=patroni_main)
|
||||
patroni.start()
|
||||
pid = patroni.pid
|
||||
patroni.join()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+261
-51
@@ -2,7 +2,6 @@ import base64
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import psycopg2
|
||||
import time
|
||||
import traceback
|
||||
import dateutil.parser
|
||||
@@ -12,11 +11,13 @@ import six
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from ipaddress import ip_address, ip_network as _ip_network
|
||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
from six.moves.socketserver import ThreadingMixIn
|
||||
from six.moves.urllib_parse import urlparse, parse_qs
|
||||
from threading import Thread
|
||||
|
||||
from . import psycopg
|
||||
from .exceptions import PostgresConnectionException, PostgresException
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
|
||||
@@ -25,6 +26,10 @@ from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Ret
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ip_network(value):
|
||||
return _ip_network(value.decode('utf-8') if six.PY2 else value, False)
|
||||
|
||||
|
||||
class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _write_status_code_only(self, status_code):
|
||||
@@ -33,6 +38,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.log_request(status_code)
|
||||
|
||||
def _write_response(self, status_code, body, content_type='text/html', headers=None):
|
||||
# TODO: try-catch ConnectionResetError: [Errno 104] Connection reset by peer and log it in DEBUG level
|
||||
self.send_response(status_code)
|
||||
headers = headers or {}
|
||||
if content_type:
|
||||
@@ -45,19 +51,19 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body.encode('utf-8'))
|
||||
|
||||
def _write_json_response(self, status_code, response):
|
||||
self._write_response(status_code, json.dumps(response), content_type='application/json')
|
||||
self._write_response(status_code, json.dumps(response, default=str), content_type='application/json')
|
||||
|
||||
def check_auth(func):
|
||||
"""Decorator function to check authorization header or client certificates
|
||||
def check_access(func):
|
||||
"""Decorator function to check the source ip, authorization header. or client certificates
|
||||
|
||||
Usage example:
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_PUT_foo():
|
||||
pass
|
||||
"""
|
||||
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self.server.check_auth(self):
|
||||
if self.server.check_access(self):
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -97,7 +103,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
patroni = self.server.patroni
|
||||
cluster = patroni.dcs.cluster
|
||||
|
||||
leader_optime = cluster and cluster.last_leader_operation or 0
|
||||
leader_optime = cluster and cluster.last_lsn or 0
|
||||
replayed_location = response.get('xlog', {}).get('replayed_location', 0)
|
||||
max_replica_lag = parse_int(self.path_query.get('lag', [sys.maxsize])[0], 'B')
|
||||
if max_replica_lag is None:
|
||||
@@ -108,9 +114,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
response.get('role') == 'replica' and response.get('state') == 'running' else 503
|
||||
|
||||
if not cluster and patroni.ha.is_paused():
|
||||
leader_status_code = 200 if response.get('role') in ('master', 'standby_leader') else 503
|
||||
primary_status_code = 200 if response.get('role') == 'master' else 503
|
||||
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
|
||||
elif patroni.ha.is_leader():
|
||||
leader_status_code = 200
|
||||
if patroni.ha.is_standby_cluster():
|
||||
primary_status_code = replica_status_code = 503
|
||||
standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
|
||||
@@ -118,17 +126,23 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
primary_status_code = 200
|
||||
standby_leader_status_code = 503
|
||||
else:
|
||||
primary_status_code = standby_leader_status_code = 503
|
||||
leader_status_code = primary_status_code = standby_leader_status_code = 503
|
||||
|
||||
status_code = 503
|
||||
|
||||
ignore_tags = False
|
||||
if 'standby_leader' in path or 'standby-leader' in path:
|
||||
status_code = standby_leader_status_code
|
||||
elif 'master' in path or 'leader' in path or 'primary' in path or 'read-write' in path:
|
||||
ignore_tags = True
|
||||
elif 'leader' in path:
|
||||
status_code = leader_status_code
|
||||
ignore_tags = True
|
||||
elif 'master' in path or 'primary' in path or 'read-write' in path:
|
||||
status_code = primary_status_code
|
||||
ignore_tags = True
|
||||
elif 'replica' in path:
|
||||
status_code = replica_status_code
|
||||
elif 'read-only' in path:
|
||||
elif 'read-only' in path and 'sync' not in path:
|
||||
status_code = 200 if 200 in (primary_status_code, standby_leader_status_code) else replica_status_code
|
||||
elif 'health' in path:
|
||||
status_code = 200 if response.get('state') == 'running' else 503
|
||||
@@ -139,6 +153,30 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
status_code = replica_status_code
|
||||
elif path in ('/async', '/asynchronous') and not is_synchronous:
|
||||
status_code = replica_status_code
|
||||
elif path in ('/read-only-sync', '/read-only-synchronous'):
|
||||
if 200 in (primary_status_code, standby_leader_status_code):
|
||||
status_code = 200
|
||||
elif is_synchronous:
|
||||
status_code = replica_status_code
|
||||
|
||||
# check for user defined tags in query params
|
||||
if not ignore_tags and status_code == 200:
|
||||
qs_tag_prefix = "tag_"
|
||||
for qs_key, qs_value in self.path_query.items():
|
||||
if not qs_key.startswith(qs_tag_prefix):
|
||||
continue
|
||||
qs_key = qs_key[len(qs_tag_prefix):]
|
||||
qs_value = qs_value[0]
|
||||
instance_tag_value = patroni.tags.get(qs_key)
|
||||
# tag not registered for instance
|
||||
if instance_tag_value is None:
|
||||
status_code = 503
|
||||
break
|
||||
if not isinstance(instance_tag_value, six.string_types):
|
||||
instance_tag_value = str(instance_tag_value).lower()
|
||||
if instance_tag_value != qs_value:
|
||||
status_code = 503
|
||||
break
|
||||
|
||||
if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more
|
||||
self._write_status_code_only(status_code)
|
||||
@@ -148,8 +186,20 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_OPTIONS(self):
|
||||
self.do_GET(write_status_code_only=True)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.do_GET(write_status_code_only=True)
|
||||
|
||||
def do_GET_liveness(self):
|
||||
self._write_status_code_only(200)
|
||||
patroni = self.server.patroni
|
||||
is_primary = patroni.postgresql.role == 'master' and patroni.postgresql.is_running()
|
||||
# We can tolerate Patroni problems longer on the replica.
|
||||
# On the primary the liveness probe most likely will start failing only after the leader key expired.
|
||||
# It should not be a big problem because replicas will see that the primary is still alive via REST API call.
|
||||
liveness_threshold = patroni.dcs.ttl * (1 if is_primary else 2)
|
||||
|
||||
# In maintenance mode (pause) we are fine if heartbeat loop stuck.
|
||||
status_code = 200 if patroni.ha.is_paused() or patroni.next_run + liveness_threshold > time.time() else 503
|
||||
self._write_status_code_only(status_code)
|
||||
|
||||
def do_GET_readiness(self):
|
||||
patroni = self.server.patroni
|
||||
@@ -180,6 +230,99 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_error(502)
|
||||
|
||||
def do_GET_metrics(self):
|
||||
postgres = self.get_postgresql_status(True)
|
||||
patroni = self.server.patroni
|
||||
epoch = datetime.datetime(1970, 1, 1, tzinfo=tzutc)
|
||||
|
||||
metrics = []
|
||||
|
||||
scope_label = '{{scope="{0}"}}'.format(patroni.postgresql.scope)
|
||||
metrics.append("# HELP patroni_version Patroni semver without periods.")
|
||||
metrics.append("# TYPE patroni_version gauge")
|
||||
padded_semver = ''.join([x.zfill(2) for x in patroni.version.split('.')]) # 2.0.2 => 020002
|
||||
metrics.append("patroni_version{0} {1}".format(scope_label, padded_semver))
|
||||
|
||||
metrics.append("# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_running gauge")
|
||||
metrics.append("patroni_postgres_running{0} {1}".format(scope_label, int(postgres['state'] == 'running')))
|
||||
|
||||
metrics.append("# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.")
|
||||
metrics.append("# TYPE patroni_postmaster_start_time gauge")
|
||||
postmaster_start_time = postgres.get('postmaster_start_time')
|
||||
postmaster_start_time = (postmaster_start_time - epoch).total_seconds() if postmaster_start_time else 0
|
||||
metrics.append("patroni_postmaster_start_time{0} {1}".format(scope_label, postmaster_start_time))
|
||||
|
||||
metrics.append("# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_master gauge")
|
||||
metrics.append("patroni_master{0} {1}".format(scope_label, int(postgres['role'] == 'master')))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_location Current location of the Postgres"
|
||||
" transaction log, 0 if this node is not the leader.")
|
||||
metrics.append("# TYPE patroni_xlog_location counter")
|
||||
metrics.append("patroni_xlog_location{0} {1}".format(scope_label, postgres.get('xlog', {}).get('location', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_standby_leader gauge")
|
||||
metrics.append("patroni_standby_leader{0} {1}".format(scope_label, int(postgres['role'] == 'standby_leader')))
|
||||
|
||||
metrics.append("# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_replica gauge")
|
||||
metrics.append("patroni_replica{0} {1}".format(scope_label, int(postgres['role'] == 'replica')))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_received_location Current location of the received"
|
||||
" Postgres transaction log, 0 if this node is not a replica.")
|
||||
metrics.append("# TYPE patroni_xlog_received_location counter")
|
||||
metrics.append("patroni_xlog_received_location{0} {1}"
|
||||
.format(scope_label, postgres.get('xlog', {}).get('received_location', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_replayed_location Current location of the replayed"
|
||||
" Postgres transaction log, 0 if this node is not a replica.")
|
||||
metrics.append("# TYPE patroni_xlog_replayed_location counter")
|
||||
metrics.append("patroni_xlog_replayed_location{0} {1}"
|
||||
.format(scope_label, postgres.get('xlog', {}).get('replayed_location', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed"
|
||||
" Postgres transaction log, 0 if null.")
|
||||
metrics.append("# TYPE patroni_xlog_replayed_timestamp gauge")
|
||||
replayed_timestamp = postgres.get('xlog', {}).get('replayed_timestamp')
|
||||
replayed_timestamp = (replayed_timestamp - epoch).total_seconds() if replayed_timestamp else 0
|
||||
metrics.append("patroni_xlog_replayed_timestamp{0} {1}".format(scope_label, replayed_timestamp))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_xlog_paused gauge")
|
||||
metrics.append("patroni_xlog_paused{0} {1}"
|
||||
.format(scope_label, int(postgres.get('xlog', {}).get('paused', False) is True)))
|
||||
|
||||
metrics.append("# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_server_version gauge")
|
||||
metrics.append("patroni_postgres_server_version {0} {1}".format(scope_label, postgres.get('server_version', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.")
|
||||
metrics.append("# TYPE patroni_cluster_unlocked gauge")
|
||||
metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres.get('cluster_unlocked', 0))))
|
||||
|
||||
metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_timeline counter")
|
||||
metrics.append("patroni_postgres_timeline{0} {1}".format(scope_label, postgres.get('timeline', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully"
|
||||
" by Patroni.")
|
||||
metrics.append("# TYPE patroni_dcs_last_seen gauge")
|
||||
metrics.append("patroni_dcs_last_seen{0} {1}".format(scope_label, postgres.get('dcs_last_seen', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_pending_restart gauge")
|
||||
metrics.append("patroni_pending_restart{0} {1}"
|
||||
.format(scope_label, int(patroni.postgresql.pending_restart)))
|
||||
|
||||
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_is_paused gauge")
|
||||
metrics.append("patroni_is_paused{0} {1}"
|
||||
.format(scope_label, int(patroni.ha.is_paused())))
|
||||
|
||||
self._write_response(200, '\n'.join(metrics)+'\n', content_type='text/plain')
|
||||
|
||||
def _read_json_content(self, body_is_optional=False):
|
||||
if 'content-length' not in self.headers:
|
||||
return self.send_error(411) if not body_is_optional else {}
|
||||
@@ -194,7 +337,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
logger.exception('Bad request')
|
||||
self.send_error(400)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_PATCH_config(self):
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
@@ -209,7 +352,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.server.patroni.ha.wakeup()
|
||||
self._write_json_response(200, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_PUT_config(self):
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
@@ -220,11 +363,19 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
return self.send_error(502)
|
||||
self._write_json_response(200, request)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_reload(self):
|
||||
self.server.patroni.sighup_handler()
|
||||
self._write_response(202, 'reload scheduled')
|
||||
|
||||
@check_access
|
||||
def do_POST_sigterm(self):
|
||||
"""Only for behave testing on windows"""
|
||||
|
||||
if os.name == 'nt' and os.getenv('BEHAVE_DEBUG'):
|
||||
self.server.patroni.api_sigterm()
|
||||
self._write_response(202, 'shutdown scheduled')
|
||||
|
||||
@staticmethod
|
||||
def parse_schedule(schedule, action):
|
||||
""" parses the given schedule and validates at """
|
||||
@@ -246,7 +397,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
status_code = 422
|
||||
return (status_code, error, scheduled_at)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_restart(self):
|
||||
status_code = 500
|
||||
data = 'restart failed'
|
||||
@@ -307,7 +458,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
status_code = 409
|
||||
self._write_response(status_code, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_DELETE_restart(self):
|
||||
if self.server.patroni.ha.delete_future_restart():
|
||||
data = "scheduled restart deleted"
|
||||
@@ -317,7 +468,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
code = 404
|
||||
self._write_response(code, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_DELETE_switchover(self):
|
||||
failover = self.server.patroni.dcs.get_cluster().failover
|
||||
if failover and failover.scheduled_at:
|
||||
@@ -331,7 +482,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
code = 404
|
||||
self._write_response(code, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_reinitialize(self):
|
||||
request = self._read_json_content(body_is_optional=True)
|
||||
|
||||
@@ -363,7 +514,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if not cluster.failover:
|
||||
return 503, action.title() + ' failed'
|
||||
except Exception as e:
|
||||
logger.debug('Exception occured during polling %s result: %s', action, e)
|
||||
logger.debug('Exception occurred during polling %s result: %s', action, e)
|
||||
return 503, action.title() + ' status unknown'
|
||||
|
||||
def is_failover_possible(self, cluster, leader, candidate, action):
|
||||
@@ -388,7 +539,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
return None
|
||||
return action + ' is not possible: no good candidates have been found'
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_failover(self, action='failover'):
|
||||
request = self._read_json_content()
|
||||
(status_code, data) = (400, '')
|
||||
@@ -476,9 +627,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if postgresql.state not in ('running', 'restarting', 'starting'):
|
||||
raise RetryFailedError('')
|
||||
stmt = ("SELECT " + postgresql.POSTMASTER_START_TIME + ", " + postgresql.TL_LSN + ","
|
||||
" pg_catalog.to_char(pg_catalog.pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
|
||||
" pg_catalog.pg_last_xact_replay_timestamp(),"
|
||||
" pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) "
|
||||
"FROM (SELECT (SELECT rolname FROM pg_authid WHERE oid = usesysid) AS usename,"
|
||||
"FROM (SELECT (SELECT rolname FROM pg_catalog.pg_authid WHERE oid = usesysid) AS usename,"
|
||||
" application_name, client_addr, w.state, sync_state, sync_priority"
|
||||
" FROM pg_catalog.pg_stat_get_wal_senders() w, pg_catalog.pg_stat_get_activity(pid)) AS ri")
|
||||
|
||||
@@ -489,7 +640,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
'postmaster_start_time': row[0],
|
||||
'role': 'replica' if row[1] == 0 else 'master',
|
||||
'server_version': postgresql.server_version,
|
||||
'cluster_unlocked': bool(not cluster or cluster.is_unlocked()),
|
||||
'xlog': ({
|
||||
'received_location': row[4] or row[3],
|
||||
'replayed_location': row[3],
|
||||
@@ -511,13 +661,17 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if row[7]:
|
||||
result['replication'] = row[7]
|
||||
|
||||
return result
|
||||
except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
|
||||
except (psycopg.Error, RetryFailedError, PostgresConnectionException):
|
||||
state = postgresql.state
|
||||
if state == 'running':
|
||||
logger.exception('get_postgresql_status')
|
||||
state = 'unknown'
|
||||
return {'state': state, 'role': postgresql.role}
|
||||
result = {'state': state, 'role': postgresql.role}
|
||||
|
||||
if not cluster or cluster.is_unlocked():
|
||||
result['cluster_unlocked'] = True
|
||||
result['dcs_last_seen'] = self.server.patroni.dcs.last_seen
|
||||
return result
|
||||
|
||||
def handle_one_request(self):
|
||||
self.__start_time = time.time()
|
||||
@@ -536,7 +690,8 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
self.patroni = patroni
|
||||
self.__listen = None
|
||||
self.__ssl_options = None
|
||||
self.http_extra_headers = {}
|
||||
self.__ssl_serial_number = None
|
||||
self._received_new_cert = False
|
||||
self.reload_config(config)
|
||||
self.daemon = True
|
||||
|
||||
@@ -546,7 +701,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
with self.patroni.postgresql.connection().cursor() as cursor:
|
||||
cursor.execute(sql, params)
|
||||
return [r for r in cursor]
|
||||
except psycopg2.Error as e:
|
||||
except psycopg.Error as e:
|
||||
if cursor and cursor.connection.closed == 0:
|
||||
raise e
|
||||
raise PostgresConnectionException('connection problems')
|
||||
@@ -568,7 +723,35 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]):
|
||||
return 'not authenticated'
|
||||
|
||||
def check_auth(self, rh):
|
||||
@staticmethod
|
||||
def __resolve_ips(host, port):
|
||||
try:
|
||||
for _, _, _, _, sa in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP):
|
||||
yield ip_network(sa[0])
|
||||
except Exception as e:
|
||||
logger.error('Failed to resolve %s: %r', host, e)
|
||||
|
||||
def __members_ips(self):
|
||||
cluster = self.patroni.dcs.cluster
|
||||
if self.__allowlist_include_members and cluster:
|
||||
for member in cluster.members:
|
||||
if member.api_url:
|
||||
try:
|
||||
r = urlparse(member.api_url)
|
||||
host = r.hostname
|
||||
port = r.port or (443 if r.scheme == 'https' else 80)
|
||||
for ip in self.__resolve_ips(host, port):
|
||||
yield ip
|
||||
except Exception as e:
|
||||
logger.debug('Failed to parse url %s: %r', member.api_url, e)
|
||||
|
||||
def check_access(self, rh):
|
||||
if self.__allowlist or self.__allowlist_include_members:
|
||||
incoming_ip = rh.client_address[0]
|
||||
incoming_ip = ip_address(incoming_ip.decode('utf-8') if six.PY2 else incoming_ip)
|
||||
if not any(incoming_ip in net for net in self.__allowlist + tuple(self.__members_ips())):
|
||||
return rh._write_response(403, 'Access is denied')
|
||||
|
||||
if not hasattr(rh.request, 'getpeercert') or not rh.request.getpeercert(): # valid client cert isn't present
|
||||
if self.__protocol == 'https' and self.__ssl_options.get('verify_client') in ('required', 'optional'):
|
||||
return rh._write_response(403, 'client certificate required')
|
||||
@@ -621,9 +804,12 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
reloading_config = self.__listen is not None # changing config in runtime
|
||||
if reloading_config:
|
||||
self.shutdown()
|
||||
# Rely on ThreadingMixIn.server_close() to have all requests terminate before we continue
|
||||
self.server_close()
|
||||
|
||||
self.__listen = listen
|
||||
self.__ssl_options = ssl_options
|
||||
self._received_new_cert = False # reset to False after reload_config()
|
||||
|
||||
self.__httpserver_init(host, port)
|
||||
Thread.__init__(self, target=self.serve_forever)
|
||||
@@ -646,37 +832,61 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
ctx.verify_mode = modes[verify_client]
|
||||
else:
|
||||
logger.error('Bad value in the "restapi.verify_client": %s', verify_client)
|
||||
self.socket = ctx.wrap_socket(self.socket, server_side=True)
|
||||
self.__ssl_serial_number = self.get_certificate_serial_number()
|
||||
self.socket = ctx.wrap_socket(self.socket, server_side=True, do_handshake_on_connect=False)
|
||||
if reloading_config:
|
||||
self.start()
|
||||
|
||||
def process_request_thread(self, request, client_address):
|
||||
if isinstance(request, tuple):
|
||||
sock, newsock = request
|
||||
try:
|
||||
request = sock.context.wrap_socket(newsock, do_handshake_on_connect=sock.do_handshake_on_connect,
|
||||
suppress_ragged_eofs=sock.suppress_ragged_eofs, server_side=True)
|
||||
except socket.error:
|
||||
return
|
||||
enable_keepalive(request, 10, 3)
|
||||
if hasattr(request, 'context'): # SSLSocket
|
||||
request.do_handshake()
|
||||
super(RestApiServer, self).process_request_thread(request, client_address)
|
||||
|
||||
def get_request(self):
|
||||
sock = self.socket
|
||||
newsock, addr = socket.socket.accept(sock)
|
||||
enable_keepalive(newsock, 10, 3)
|
||||
if hasattr(sock, 'context'): # SSLSocket, we want to do the deferred handshake from a thread
|
||||
newsock = (sock, newsock)
|
||||
return newsock, addr
|
||||
|
||||
def shutdown_request(self, request):
|
||||
if isinstance(request, tuple):
|
||||
_, request = request # SSLSocket
|
||||
return super(RestApiServer, self).shutdown_request(request)
|
||||
if hasattr(request, 'context'): # SSLSocket
|
||||
try:
|
||||
request.unwrap()
|
||||
except Exception as e:
|
||||
logger.debug('Failed to shutdown SSL connection: %r', e)
|
||||
super(RestApiServer, self).shutdown_request(request)
|
||||
|
||||
def get_certificate_serial_number(self):
|
||||
if self.__ssl_options.get('certfile'):
|
||||
import ssl
|
||||
try:
|
||||
crt = ssl._ssl._test_decode_cert(self.__ssl_options['certfile'])
|
||||
return crt.get('serialNumber')
|
||||
except ssl.SSLError as e:
|
||||
logger.error('Failed to get serial number from certificate %s: %r', self.__ssl_options['certfile'], e)
|
||||
|
||||
def reload_local_certificate(self):
|
||||
if self.__protocol == 'https':
|
||||
on_disk_cert_serial_number = self.get_certificate_serial_number()
|
||||
if on_disk_cert_serial_number != self.__ssl_serial_number:
|
||||
self._received_new_cert = True
|
||||
self.__ssl_serial_number = on_disk_cert_serial_number
|
||||
return True
|
||||
|
||||
def _build_allowlist(self, value):
|
||||
if isinstance(value, list):
|
||||
for v in value:
|
||||
if '/' in v: # netmask
|
||||
try:
|
||||
yield ip_network(v)
|
||||
except Exception as e:
|
||||
logger.error('Invalid value "%s" in the allowlist: %r', v, e)
|
||||
else: # ip or hostname, try to resolve it
|
||||
for ip in self.__resolve_ips(v, 8080):
|
||||
yield ip
|
||||
|
||||
def reload_config(self, config):
|
||||
if 'listen' not in config: # changing config in runtime
|
||||
raise ValueError('Can not find "restapi.listen" config')
|
||||
|
||||
self.__allowlist = tuple(self._build_allowlist(config.get('allowlist')))
|
||||
self.__allowlist_include_members = config.get('allowlist_include_members')
|
||||
|
||||
ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'keyfile_password',
|
||||
'cafile', 'ciphers') if n in config}
|
||||
|
||||
@@ -686,7 +896,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
if isinstance(config.get('verify_client'), six.string_types):
|
||||
ssl_options['verify_client'] = config['verify_client'].lower()
|
||||
|
||||
if self.__listen != config['listen'] or self.__ssl_options != ssl_options:
|
||||
if self.__listen != config['listen'] or self.__ssl_options != ssl_options or self._received_new_cert:
|
||||
self.__initialize(config['listen'], ssl_options)
|
||||
|
||||
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')) if 'auth' in config else None
|
||||
@@ -694,6 +904,6 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
|
||||
@staticmethod
|
||||
def handle_error(request, client_address):
|
||||
address, port = client_address
|
||||
logger.warning('Exception happened during processing of request from {}:{}'.format(address, port))
|
||||
logger.warning('Exception happened during processing of request from %s:%s',
|
||||
client_address[0], client_address[1])
|
||||
logger.warning(traceback.format_exc())
|
||||
|
||||
+56
-33
@@ -24,6 +24,7 @@ _AUTH_ALLOWED_PARAMETERS = (
|
||||
'sslpassword',
|
||||
'sslrootcert',
|
||||
'sslcrl',
|
||||
'sslcrldir',
|
||||
'gssencmode',
|
||||
'channel_binding'
|
||||
)
|
||||
@@ -31,7 +32,7 @@ _AUTH_ALLOWED_PARAMETERS = (
|
||||
|
||||
def default_validator(conf):
|
||||
if not conf:
|
||||
return "Config is empty."
|
||||
raise ConfigParseError("Config is empty.")
|
||||
|
||||
|
||||
class Config(object):
|
||||
@@ -101,9 +102,9 @@ class Config(object):
|
||||
config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None)
|
||||
self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration
|
||||
if validator:
|
||||
error = validator(self._local_configuration)
|
||||
if error:
|
||||
raise ConfigParseError(error)
|
||||
errors = validator(self._local_configuration)
|
||||
if errors:
|
||||
raise ConfigParseError("\n".join(errors))
|
||||
|
||||
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
|
||||
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
|
||||
@@ -226,13 +227,14 @@ class Config(object):
|
||||
for name, value in (value or {}).items():
|
||||
if name == 'parameters':
|
||||
config['postgresql'][name].update(self._process_postgresql_parameters(value))
|
||||
elif name not in ('connect_address', 'listen', 'data_dir', 'pgpass', 'authentication'):
|
||||
elif name not in ('connect_address', 'proxy_address', 'listen',
|
||||
'config_dir', 'data_dir', 'pgpass', 'authentication'):
|
||||
config['postgresql'][name] = deepcopy(value)
|
||||
elif name == 'standby_cluster':
|
||||
for name, value in (value or {}).items():
|
||||
if name in self.__DEFAULT_CONFIG['standby_cluster']:
|
||||
config['standby_cluster'][name] = deepcopy(value)
|
||||
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overriden from DCS
|
||||
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
|
||||
if name in ('synchronous_mode', 'synchronous_mode_strict'):
|
||||
config[name] = value
|
||||
else:
|
||||
@@ -268,11 +270,43 @@ class Config(object):
|
||||
|
||||
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
|
||||
'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
|
||||
'https_extra_headers'])
|
||||
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile'])
|
||||
_set_section_values('postgresql', ['listen', 'connect_address', 'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
|
||||
'https_extra_headers', 'allowlist', 'allowlist_include_members'])
|
||||
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
|
||||
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
|
||||
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
|
||||
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
|
||||
'dir', 'file_size', 'file_num', 'loggers'])
|
||||
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
|
||||
|
||||
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = parse_bool(value)
|
||||
if value is not None:
|
||||
ret[first][second] = value
|
||||
|
||||
for second in ('max_queue_size', 'file_size', 'file_num'):
|
||||
value = ret.get('log', {}).pop(second, None)
|
||||
if value:
|
||||
value = parse_int(value)
|
||||
if value is not None:
|
||||
ret['log'][second] = value
|
||||
|
||||
def _parse_list(value):
|
||||
if not (value.strip().startswith('-') or '[' in value):
|
||||
value = '[{0}]'.format(value)
|
||||
try:
|
||||
return yaml.safe_load(value)
|
||||
except Exception:
|
||||
logger.exception('Exception when parsing list %s', value)
|
||||
return None
|
||||
|
||||
for first, second in (('raft', 'partner_addrs'), ('restapi', 'allowlist')):
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = _parse_list(value)
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
def _parse_dict(value):
|
||||
if not value.strip().startswith('{'):
|
||||
@@ -283,11 +317,13 @@ class Config(object):
|
||||
logger.exception('Exception when parsing dict %s', value)
|
||||
return None
|
||||
|
||||
value = ret.get('log', {}).pop('loggers', None)
|
||||
if value:
|
||||
value = _parse_dict(value)
|
||||
if value:
|
||||
ret['log']['loggers'] = value
|
||||
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
|
||||
for second in params:
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = _parse_dict(value)
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
def _get_auth(name, params=None):
|
||||
ret = {}
|
||||
@@ -310,36 +346,23 @@ class Config(object):
|
||||
if authentication:
|
||||
ret['postgresql']['authentication'] = authentication
|
||||
|
||||
def _parse_list(value):
|
||||
if not (value.strip().startswith('-') or '[' in value):
|
||||
value = '[{0}]'.format(value)
|
||||
try:
|
||||
return yaml.safe_load(value)
|
||||
except Exception:
|
||||
logger.exception('Exception when parsing list %s', value)
|
||||
return None
|
||||
|
||||
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
|
||||
if 'raft' in ret and 'partner_addrs' in ret['raft']:
|
||||
ret['raft']['partner_addrs'] = _parse_list(ret['raft']['partner_addrs'])
|
||||
|
||||
for param in list(os.environ.keys()):
|
||||
if param.startswith(PATRONI_ENV_PREFIX):
|
||||
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
|
||||
name, suffix = (param[8:].split('_', 1) + [''])[:2]
|
||||
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'URL', 'PROXY',
|
||||
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'SRV_SUFFIX', 'URL', 'PROXY',
|
||||
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
|
||||
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'NAMESPACE', 'CONTEXT',
|
||||
'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS',
|
||||
'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL') and name:
|
||||
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME',
|
||||
'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP',
|
||||
'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL', 'SET_ACLS') and name:
|
||||
value = os.environ.pop(param)
|
||||
if suffix == 'PORT':
|
||||
value = value and parse_int(value)
|
||||
elif suffix in ('HOSTS', 'PORTS', 'CHECKS'):
|
||||
value = value and _parse_list(value)
|
||||
elif suffix == 'LABELS':
|
||||
elif suffix in ('LABELS', 'SET_ACLS'):
|
||||
value = _parse_dict(value)
|
||||
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE'):
|
||||
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
|
||||
value = parse_bool(value)
|
||||
if value:
|
||||
ret[name.lower()][suffix.lower()] = value
|
||||
|
||||
+36
-39
@@ -60,6 +60,18 @@ class PatronictlPrettyTable(PrettyTable):
|
||||
self.__hline_num = 0
|
||||
self.__hline = None
|
||||
|
||||
def __build_header(self, line):
|
||||
header = self.__table_header[:len(line) - 2]
|
||||
return "".join([line[0], header, line[1 + len(header):]])
|
||||
|
||||
def _stringify_hrule(self, *args, **kwargs):
|
||||
ret = super(PatronictlPrettyTable, self)._stringify_hrule(*args, **kwargs)
|
||||
where = args[1] if len(args) > 1 else kwargs.get('where')
|
||||
if where == 'top_' and self.__table_header:
|
||||
ret = self.__build_header(ret)
|
||||
self.__hline_num += 1
|
||||
return ret
|
||||
|
||||
def _is_first_hline(self):
|
||||
return self.__hline_num == 0
|
||||
|
||||
@@ -71,8 +83,7 @@ class PatronictlPrettyTable(PrettyTable):
|
||||
|
||||
# Inject nice table header
|
||||
if self._is_first_hline() and self.__table_header:
|
||||
header = self.__table_header[:len(ret) - 2]
|
||||
ret = "".join([ret[0], header, ret[1 + len(header):]])
|
||||
ret = self.__build_header(ret)
|
||||
|
||||
self.__hline_num += 1
|
||||
return ret
|
||||
@@ -99,7 +110,7 @@ def parse_dcs(dcs):
|
||||
return yaml.safe_load(default['template'].format(host=parsed.hostname or 'localhost', port=port or default['port']))
|
||||
|
||||
|
||||
def load_config(path, dcs):
|
||||
def load_config(path, dcs_url):
|
||||
from patroni.config import Config
|
||||
|
||||
if not (os.path.exists(path) and os.access(path, os.R_OK)):
|
||||
@@ -112,22 +123,14 @@ def load_config(path, dcs):
|
||||
logging.debug('Loading configuration from file %s', path)
|
||||
config = Config(path, validator=None).copy()
|
||||
|
||||
dcs = parse_dcs(dcs) or parse_dcs(config.get('dcs_api')) or {}
|
||||
if dcs:
|
||||
dcs_url = parse_dcs(dcs_url) or {}
|
||||
if dcs_url:
|
||||
for d in DCS_DEFAULTS:
|
||||
config.pop(d, None)
|
||||
config.update(dcs)
|
||||
config.update(dcs_url)
|
||||
return config
|
||||
|
||||
|
||||
def store_config(config, path):
|
||||
dir_path = os.path.dirname(path)
|
||||
if dir_path and not os.path.isdir(dir_path):
|
||||
os.makedirs(dir_path)
|
||||
with open(path, 'w') as fd:
|
||||
yaml.dump(config, fd)
|
||||
|
||||
|
||||
option_format = click.option('--format', '-f', 'fmt', help='Output format (pretty, tsv, json, yaml)', default='pretty')
|
||||
option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds')
|
||||
option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds')
|
||||
@@ -140,16 +143,16 @@ option_insecure = click.option('-k', '--insecure', is_flag=True, help='Allow con
|
||||
@click.group()
|
||||
@click.option('--config-file', '-c', help='Configuration file',
|
||||
envvar='PATRONICTL_CONFIG_FILE', default=CONFIG_FILE_PATH)
|
||||
@click.option('--dcs', '-d', help='Use this DCS', envvar='DCS')
|
||||
@click.option('--dcs-url', '--dcs', '-d', 'dcs_url', help='The DCS connect url', envvar='DCS_URL')
|
||||
@option_insecure
|
||||
@click.pass_context
|
||||
def ctl(ctx, config_file, dcs, insecure):
|
||||
def ctl(ctx, config_file, dcs_url, insecure):
|
||||
level = 'WARNING'
|
||||
for name in ('LOGLEVEL', 'PATRONI_LOGLEVEL', 'PATRONI_LOG_LEVEL'):
|
||||
level = os.environ.get(name, level)
|
||||
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=level)
|
||||
logging.captureWarnings(True) # Capture eventual SSL warning
|
||||
ctx.obj = load_config(config_file, dcs)
|
||||
ctx.obj = load_config(config_file, dcs_url)
|
||||
# backward compatibility for configuration file where ctl section is not define
|
||||
ctx.obj.setdefault('ctl', {})['insecure'] = ctx.obj.get('ctl', {}).get('insecure') or insecure
|
||||
|
||||
@@ -264,14 +267,13 @@ def get_cursor(cluster, connect_parameters, role='master', member=None):
|
||||
|
||||
params = member.conn_kwargs(connect_parameters)
|
||||
params.update({'fallback_application_name': 'Patroni ctl', 'connect_timeout': '5'})
|
||||
if 'database' in connect_parameters:
|
||||
params['database'] = connect_parameters['database']
|
||||
if 'dbname' in connect_parameters:
|
||||
params['dbname'] = connect_parameters['dbname']
|
||||
else:
|
||||
params.pop('database')
|
||||
params.pop('dbname')
|
||||
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(**params)
|
||||
conn.autocommit = True
|
||||
from . import psycopg
|
||||
conn = psycopg.connect(**params)
|
||||
cursor = conn.cursor()
|
||||
if role == 'any':
|
||||
return cursor
|
||||
@@ -401,7 +403,7 @@ def query(
|
||||
if password:
|
||||
connect_parameters['password'] = click.prompt('Password', hide_input=True, type=str)
|
||||
if dbname:
|
||||
connect_parameters['database'] = dbname
|
||||
connect_parameters['dbname'] = dbname
|
||||
|
||||
if p_file is not None:
|
||||
command = p_file.read()
|
||||
@@ -418,7 +420,7 @@ def query(
|
||||
|
||||
|
||||
def query_member(cluster, cursor, member, role, command, connect_parameters):
|
||||
import psycopg2
|
||||
from . import psycopg
|
||||
try:
|
||||
if cursor is None:
|
||||
cursor = get_cursor(cluster, connect_parameters, role=role, member=member)
|
||||
@@ -433,11 +435,11 @@ def query_member(cluster, cursor, member, role, command, connect_parameters):
|
||||
|
||||
cursor.execute(command)
|
||||
return cursor.fetchall(), [d.name for d in cursor.description]
|
||||
except (psycopg2.OperationalError, psycopg2.DatabaseError) as oe:
|
||||
logging.debug(oe)
|
||||
except psycopg.DatabaseError as de:
|
||||
logging.debug(de)
|
||||
if cursor is not None and not cursor.connection.closed:
|
||||
cursor.connection.close()
|
||||
message = oe.pgcode or oe.pgerror or str(oe)
|
||||
message = de.diag.sqlstate or str(de)
|
||||
message = message.replace('\n', ' ')
|
||||
return [[timestamp(0), 'ERROR, SQLSTATE: {0}'.format(message)]], None
|
||||
|
||||
@@ -886,14 +888,6 @@ def timestamp(precision=6):
|
||||
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:precision - 7]
|
||||
|
||||
|
||||
@ctl.command('configure', help='Create configuration file')
|
||||
@click.option('--config-file', '-c', help='Configuration file', prompt='Configuration file', default=CONFIG_FILE_PATH)
|
||||
@click.option('--dcs', '-d', help='The DCS connect url', prompt='DCS connect url', default='etcd://localhost:2379')
|
||||
@click.option('--namespace', '-n', help='The namespace', prompt='Namespace', default='/service/')
|
||||
def configure(config_file, dcs, namespace):
|
||||
store_config({'dcs_api': str(dcs), 'namespace': str(namespace)}, config_file)
|
||||
|
||||
|
||||
def touch_member(config, dcs):
|
||||
''' Rip-off of the ha.touch_member without inter-class dependencies '''
|
||||
p = Postgresql(config['postgresql'])
|
||||
@@ -1299,10 +1293,13 @@ def version(obj, cluster_name, member_names):
|
||||
def history(obj, cluster_name, fmt):
|
||||
cluster = get_dcs(obj, cluster_name).get_cluster()
|
||||
history = cluster.history and cluster.history.lines or []
|
||||
table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader']
|
||||
for line in history:
|
||||
if len(line) < 4:
|
||||
line.append('')
|
||||
print_output(['TL', 'LSN', 'Reason', 'Timestamp'], history, {'TL': 'r', 'LSN': 'r'}, fmt)
|
||||
if len(line) < len(table_header_row):
|
||||
add_column_num = len(table_header_row) - len(line)
|
||||
for _ in range(add_column_num):
|
||||
line.append('')
|
||||
print_output(table_header_row, history, {'TL': 'r', 'LSN': 'r'}, fmt)
|
||||
|
||||
|
||||
def format_pg_version(version):
|
||||
|
||||
+13
-5
@@ -1,3 +1,5 @@
|
||||
from __future__ import print_function
|
||||
|
||||
import abc
|
||||
import os
|
||||
import signal
|
||||
@@ -22,11 +24,15 @@ class AbstractPatroniDaemon(object):
|
||||
def sighup_handler(self, *args):
|
||||
self._received_sighup = True
|
||||
|
||||
def sigterm_handler(self, *args):
|
||||
def api_sigterm(self):
|
||||
with self._sigterm_lock:
|
||||
if not self._received_sigterm:
|
||||
self._received_sigterm = True
|
||||
sys.exit()
|
||||
return True
|
||||
|
||||
def sigterm_handler(self, *args):
|
||||
if self.api_sigterm():
|
||||
sys.exit()
|
||||
|
||||
def setup_signal_handlers(self):
|
||||
self._received_sighup = False
|
||||
@@ -83,16 +89,18 @@ def abstract_main(cls, validator=None):
|
||||
help='Patroni may also read the configuration from the {0} environment variable'
|
||||
.format(Config.PATRONI_CONFIG_VARIABLE))
|
||||
args = parser.parse_args()
|
||||
validate_config = validator and args.validate_config
|
||||
try:
|
||||
if validator and args.validate_config:
|
||||
if validate_config:
|
||||
Config(args.configfile, validator=validator)
|
||||
sys.exit()
|
||||
|
||||
config = Config(args.configfile)
|
||||
except ConfigParseError as e:
|
||||
if e.value:
|
||||
print(e.value)
|
||||
parser.print_help()
|
||||
print(e.value, file=sys.stderr)
|
||||
if not validate_config:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
controller = cls(config)
|
||||
|
||||
+201
-51
@@ -1,5 +1,5 @@
|
||||
import abc
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
@@ -13,12 +13,13 @@ import time
|
||||
|
||||
from collections import defaultdict, namedtuple
|
||||
from copy import deepcopy
|
||||
from patroni.exceptions import PatroniFatalException
|
||||
from patroni.utils import parse_bool, uri
|
||||
from random import randint
|
||||
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
||||
from threading import Event, Lock
|
||||
|
||||
from ..exceptions import PatroniFatalException
|
||||
from ..utils import deep_compare, parse_bool, uri
|
||||
|
||||
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,7 +68,11 @@ def dcs_modules():
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
toc = set()
|
||||
for importer in pkgutil.iter_importers(dcs_dirname):
|
||||
# dcs_dirname may contain a dot, which causes pkgutil.iter_importers()
|
||||
# to misinterpret the path as a package name. This can be avoided
|
||||
# altogether by not passing a path at all, because PyInstaller's
|
||||
# FrozenImporter is a singleton and registered as top-level finder.
|
||||
for importer in pkgutil.iter_importers():
|
||||
if hasattr(importer, 'toc'):
|
||||
toc |= importer.toc
|
||||
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == 2]
|
||||
@@ -133,6 +138,8 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
else:
|
||||
try:
|
||||
data = json.loads(data)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (TypeError, ValueError):
|
||||
data = {}
|
||||
return Member(index, name, session, data)
|
||||
@@ -153,7 +160,7 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
defaults = {
|
||||
"host": None,
|
||||
"port": None,
|
||||
"database": None
|
||||
"dbname": None
|
||||
}
|
||||
ret = self.data.get('conn_kwargs')
|
||||
if ret:
|
||||
@@ -167,7 +174,7 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
ret = {
|
||||
'host': r.hostname,
|
||||
'port': r.port or 5432,
|
||||
'database': r.path[1:]
|
||||
'dbname': r.path[1:]
|
||||
}
|
||||
self.data['conn_kwargs'] = ret.copy()
|
||||
|
||||
@@ -206,6 +213,15 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
def is_running(self):
|
||||
return self.state == 'running'
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
version = self.data.get('version')
|
||||
if version:
|
||||
try:
|
||||
return tuple(map(int, version.split('.')))
|
||||
except Exception:
|
||||
logger.debug('Failed to parse Patroni version %s', version)
|
||||
|
||||
|
||||
class RemoteMember(Member):
|
||||
""" Represents a remote master for a standby cluster
|
||||
@@ -259,14 +275,10 @@ class Leader(namedtuple('Leader', 'index,session,member')):
|
||||
"""
|
||||
>>> Leader(1, '', Member.from_node(1, '', '', '{"version":"z"}')).checkpoint_after_promote
|
||||
"""
|
||||
version = self.data.get('version')
|
||||
if version:
|
||||
try:
|
||||
# 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false
|
||||
if tuple(map(int, version.split('.'))) > (1, 5, 6):
|
||||
return self.data['role'] == 'master' and 'checkpoint_after_promote' not in self.data
|
||||
except Exception:
|
||||
logger.debug('Failed to parse Patroni version %s', version)
|
||||
version = self.member.version
|
||||
# 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false
|
||||
if version and version > (1, 5, 6):
|
||||
return self.data.get('role') == 'master' and 'checkpoint_after_promote' not in self.data
|
||||
|
||||
|
||||
class Failover(namedtuple('Failover', 'index,leader,candidate,scheduled_at')):
|
||||
@@ -432,23 +444,28 @@ class TimelineHistory(namedtuple('TimelineHistory', 'index,value,lines')):
|
||||
return TimelineHistory(index, value, lines)
|
||||
|
||||
|
||||
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operation,members,failover,sync,history')):
|
||||
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,failover,sync,history,slots,failsafe')):
|
||||
|
||||
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
|
||||
Consists of the following fields:
|
||||
:param initialize: shows whether this cluster has initialization key stored in DC or not.
|
||||
:param config: global dynamic configuration, reference to `ClusterConfig` object
|
||||
:param leader: `Leader` object which represents current leader of the cluster
|
||||
:param last_leader_operation: int or long object containing position of last known leader operation.
|
||||
This value is stored in `/optime/leader` key
|
||||
:param last_lsn: int or long object containing position of last known leader LSN.
|
||||
This value is stored in the `/status` key or `/optime/leader` (legacy) key
|
||||
:param members: list of Member object, all PostgreSQL cluster members including leader
|
||||
:param failover: reference to `Failover` object
|
||||
:param sync: reference to `SyncState` object, last observed synchronous replication state.
|
||||
:param history: reference to `TimelineHistory` object
|
||||
:param slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}
|
||||
"""
|
||||
|
||||
@property
|
||||
def leader_name(self):
|
||||
return self.leader and self.leader.name
|
||||
|
||||
def is_unlocked(self):
|
||||
return not (self.leader and self.leader.name)
|
||||
return not self.leader_name
|
||||
|
||||
def has_member(self, member_name):
|
||||
return any(m for m in self.members if m.name == member_name)
|
||||
@@ -470,22 +487,41 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
def is_synchronous_mode(self):
|
||||
return self.check_mode('synchronous_mode')
|
||||
|
||||
def get_replication_slots(self, my_name, role):
|
||||
@property
|
||||
def __permanent_slots(self):
|
||||
return self.config and self.config.permanent_slots or {}
|
||||
|
||||
@property
|
||||
def __permanent_physical_slots(self):
|
||||
return {name: value for name, value in self.__permanent_slots.items()
|
||||
if not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'}
|
||||
|
||||
@property
|
||||
def __permanent_logical_slots(self):
|
||||
return {name: value for name, value in self.__permanent_slots.items() if isinstance(value, dict)
|
||||
and value.get('type', 'logical') == 'logical' and value.get('database') and value.get('plugin')}
|
||||
|
||||
@property
|
||||
def use_slots(self):
|
||||
return self.config and (self.config.data.get('postgresql') or {}).get('use_slots', True)
|
||||
|
||||
def get_replication_slots(self, my_name, role, nofailover, major_version, show_error=False):
|
||||
# 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
|
||||
use_slots = self.config and self.config.data.get('postgresql', {}).get('use_slots', True)
|
||||
use_slots = self.use_slots
|
||||
if role in ('master', 'standby_leader'):
|
||||
slot_members = [m.name for m in self.members if use_slots and m.name != my_name and
|
||||
(m.replicatefrom is None or m.replicatefrom == my_name or
|
||||
not self.has_member(m.replicatefrom))]
|
||||
permanent_slots = (self.config and self.config.permanent_slots or {}).copy()
|
||||
permanent_slots = self.__permanent_slots if use_slots and \
|
||||
role == 'master' else self.__permanent_physical_slots
|
||||
else:
|
||||
# only manage slots for replicas that replicate from this one, except for the leader among them
|
||||
slot_members = [m.name for m in self.members if use_slots and
|
||||
m.replicatefrom == my_name and m.name != self.leader.name]
|
||||
permanent_slots = {}
|
||||
m.replicatefrom == my_name and m.name != self.leader_name]
|
||||
permanent_slots = self.__permanent_logical_slots if use_slots and not nofailover else {}
|
||||
|
||||
slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
|
||||
|
||||
@@ -499,6 +535,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
for k, v in slot_conflicts.items() if len(v) > 1))
|
||||
|
||||
# "merge" replication slots for members with permanent_replication_slots
|
||||
disabled_permanent_logical_slots = []
|
||||
for name, value in permanent_slots.items():
|
||||
if not slot_name_re.match(name):
|
||||
logger.error("Invalid permanent replication slot name '%s'", name)
|
||||
@@ -516,7 +553,9 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
slots[name] = value
|
||||
continue
|
||||
elif value['type'] == 'logical' and value.get('database') and value.get('plugin'):
|
||||
if name in slots:
|
||||
if major_version < 110000:
|
||||
disabled_permanent_logical_slots.append(name)
|
||||
elif name in slots:
|
||||
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with" +
|
||||
" physical replication slot for cluster member", name, value)
|
||||
else:
|
||||
@@ -525,20 +564,53 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
|
||||
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
|
||||
|
||||
if disabled_permanent_logical_slots and show_error:
|
||||
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
|
||||
"Following slots will not be created: %s.", disabled_permanent_logical_slots)
|
||||
|
||||
return slots
|
||||
|
||||
def has_permanent_logical_slots(self, name):
|
||||
slots = self.get_replication_slots(name, 'master').values()
|
||||
def has_permanent_logical_slots(self, my_name, nofailover, major_version=110000):
|
||||
if major_version < 110000:
|
||||
return False
|
||||
slots = self.get_replication_slots(my_name, 'replica', nofailover, major_version).values()
|
||||
return any(v for v in slots if v.get("type") == "logical")
|
||||
|
||||
def should_enforce_hot_standby_feedback(self, my_name, nofailover, major_version):
|
||||
"""
|
||||
The hot_standby_feedback must be enabled if the current replica has logical slots
|
||||
or it is working as a cascading replica for the other node that has logical slots.
|
||||
"""
|
||||
|
||||
if major_version < 110000:
|
||||
return False
|
||||
|
||||
if self.has_permanent_logical_slots(my_name, nofailover, major_version):
|
||||
return True
|
||||
|
||||
if self.use_slots:
|
||||
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader_name]
|
||||
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members)
|
||||
return False
|
||||
|
||||
def get_my_slot_name_on_primary(self, my_name, replicatefrom):
|
||||
"""
|
||||
P <-- I <-- L
|
||||
In case of cascading replication we have to check not our physical slot,
|
||||
but slot of the replica that connects us to the primary.
|
||||
"""
|
||||
|
||||
m = self.get_member(replicatefrom, False) if replicatefrom else None
|
||||
return self.get_my_slot_name_on_primary(m.name, m.replicatefrom) if m else slot_name_from_member_name(my_name)
|
||||
|
||||
@property
|
||||
def timeline(self):
|
||||
"""
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0).timeline
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0, 0, None).timeline
|
||||
0
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]')).timeline
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]'), 0, None).timeline
|
||||
1
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]')).timeline
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), 0, None).timeline
|
||||
0
|
||||
"""
|
||||
if self.history:
|
||||
@@ -551,6 +623,24 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
return 1
|
||||
return 0
|
||||
|
||||
@property
|
||||
def min_version(self):
|
||||
return next(iter(sorted(filter(lambda v: v, [m.version for m in self.members])) + [None]))
|
||||
|
||||
|
||||
class ReturnFalseException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def catch_return_false_exception(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except ReturnFalseException:
|
||||
return False
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractDCS(object):
|
||||
@@ -562,8 +652,10 @@ class AbstractDCS(object):
|
||||
_HISTORY = 'history'
|
||||
_MEMBERS = 'members/'
|
||||
_OPTIME = 'optime'
|
||||
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
|
||||
_STATUS = 'status' # JSON, contains "leader_lsn" and confirmed_flush_lsn of logical "slots" on the leader
|
||||
_LEADER_OPTIME = _OPTIME + '/' + _LEADER # legacy
|
||||
_SYNC = 'sync'
|
||||
_FAILSAFE = 'failsafe'
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
@@ -578,7 +670,10 @@ class AbstractDCS(object):
|
||||
self._cluster = None
|
||||
self._cluster_valid_till = 0
|
||||
self._cluster_thread_lock = Lock()
|
||||
self._last_leader_operation = ''
|
||||
self._last_lsn = ''
|
||||
self._last_seen = 0
|
||||
self._last_status = {}
|
||||
self._last_failsafe = {}
|
||||
self.event = Event()
|
||||
|
||||
def client_path(self, path):
|
||||
@@ -612,6 +707,10 @@ class AbstractDCS(object):
|
||||
def history_path(self):
|
||||
return self.client_path(self._HISTORY)
|
||||
|
||||
@property
|
||||
def status_path(self):
|
||||
return self.client_path(self._STATUS)
|
||||
|
||||
@property
|
||||
def leader_optime_path(self):
|
||||
return self.client_path(self._LEADER_OPTIME)
|
||||
@@ -620,6 +719,10 @@ class AbstractDCS(object):
|
||||
def sync_path(self):
|
||||
return self.client_path(self._SYNC)
|
||||
|
||||
@property
|
||||
def failsafe_path(self):
|
||||
return self.client_path(self._FAILSAFE)
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_ttl(self, ttl):
|
||||
"""Set the new ttl value for leader key"""
|
||||
@@ -644,6 +747,10 @@ class AbstractDCS(object):
|
||||
def loop_wait(self):
|
||||
return self._loop_wait
|
||||
|
||||
@property
|
||||
def last_seen(self):
|
||||
return self._last_seen
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load_cluster(self):
|
||||
"""Internally this method should build `Cluster` object which
|
||||
@@ -666,6 +773,10 @@ class AbstractDCS(object):
|
||||
self.reset_cluster()
|
||||
raise
|
||||
|
||||
self._last_seen = int(time.time())
|
||||
self._last_status = {self._OPTIME: cluster.last_lsn, 'slots': cluster.slots}
|
||||
self._last_failsafe = cluster.failsafe
|
||||
|
||||
with self._cluster_thread_lock:
|
||||
self._cluster = cluster
|
||||
self._cluster_valid_till = time.time() + self.ttl
|
||||
@@ -682,35 +793,71 @@ class AbstractDCS(object):
|
||||
self._cluster_valid_till = 0
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_leader_optime(self, last_operation):
|
||||
"""write current xlog location into `/optime/leader` key in DCS
|
||||
:param last_operation: absolute xlog location in bytes
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
"""write current WAL LSN into `/optime/leader` key in DCS
|
||||
|
||||
:param last_lsn: absolute WAL LSN in bytes
|
||||
:returns: `!True` on success."""
|
||||
|
||||
def write_leader_optime(self, last_operation):
|
||||
if self._last_leader_operation != last_operation and self._write_leader_optime(last_operation):
|
||||
self._last_leader_operation = last_operation
|
||||
def write_leader_optime(self, last_lsn):
|
||||
self.write_status({self._OPTIME: last_lsn})
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_status(self, value):
|
||||
"""write current WAL LSN and confirmed_flush_lsn of permanent slots into the `/status` key in DCS
|
||||
|
||||
:param value: status serialized in JSON forman
|
||||
:returns: `!True` on success."""
|
||||
|
||||
def write_status(self, value):
|
||||
if not deep_compare(self._last_status, value) and self._write_status(json.dumps(value, separators=(',', ':'))):
|
||||
self._last_status = value
|
||||
cluster = self.cluster
|
||||
min_version = cluster and cluster.min_version
|
||||
if min_version and min_version < (2, 1, 0) and self._last_lsn != value[self._OPTIME]:
|
||||
self._last_lsn = value[self._OPTIME]
|
||||
self._write_leader_optime(str(value[self._OPTIME]))
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_failsafe(self, value):
|
||||
"""Write current cluster topology to DCS that will be used by failsafe mechanism (if enabled).
|
||||
|
||||
:param value: failsafe topology serialized in JSON format
|
||||
:returns: `!True` on success."""
|
||||
|
||||
def write_failsafe(self, value):
|
||||
if not (isinstance(self._last_failsafe, dict) and deep_compare(self._last_failsafe, value))\
|
||||
and self._write_failsafe(json.dumps(value, separators=(',', ':'))):
|
||||
self._last_failsafe = value
|
||||
|
||||
@abc.abstractmethod
|
||||
def _update_leader(self):
|
||||
"""Update leader key (or session) ttl
|
||||
|
||||
:returns: `!True` if leader key (or session) has been updated successfully.
|
||||
If not, `!False` must be returned and current instance would be demoted.
|
||||
|
||||
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
||||
for example for etcd `prevValue` parameter must be used."""
|
||||
for example for etcd `prevValue` parameter must be used.
|
||||
If update fails due to DCS not being accessible or because it is not able to
|
||||
process requests (hopefuly temporary), the ~DCSError exception should be raised."""
|
||||
|
||||
def update_leader(self, last_operation, access_is_restricted=False):
|
||||
def update_leader(self, last_lsn, slots=None, failsafe=None):
|
||||
"""Update leader key (or session) ttl and optime/leader
|
||||
|
||||
:param last_operation: absolute xlog location in bytes
|
||||
:returns: `!True` if leader key (or session) has been updated successfully.
|
||||
If not, `!False` must be returned and current instance would be demoted."""
|
||||
:param last_lsn: absolute WAL LSN in bytes
|
||||
:param slots: dict with permanent slots confirmed_flush_lsn
|
||||
:returns: `!True` if leader key (or session) has been updated successfully."""
|
||||
|
||||
ret = self._update_leader()
|
||||
if ret and last_operation:
|
||||
self.write_leader_optime(last_operation)
|
||||
if ret and last_lsn:
|
||||
status = {self._OPTIME: last_lsn}
|
||||
if slots:
|
||||
status['slots'] = slots
|
||||
self.write_status(status)
|
||||
|
||||
if ret and failsafe is not None:
|
||||
self.write_failsafe(failsafe)
|
||||
|
||||
return ret
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -722,7 +869,10 @@ class AbstractDCS(object):
|
||||
:returns: `!True` if key has been created successfully.
|
||||
|
||||
Key must be created atomically. In case if key already exists it should not be
|
||||
overwritten and `!False` must be returned"""
|
||||
overwritten and `!False` must be returned.
|
||||
|
||||
If key creation fails due to DCS not being accessible or because it is not able to
|
||||
process requests (hopefuly temporary), the ~DCSError exception should be raised"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_failover_value(self, value, index=None):
|
||||
@@ -779,13 +929,13 @@ class AbstractDCS(object):
|
||||
"""Remove leader key from DCS.
|
||||
This method should remove leader key if current instance is the leader"""
|
||||
|
||||
def delete_leader(self, last_operation=None):
|
||||
def delete_leader(self, last_lsn=None):
|
||||
"""Update optime/leader and voluntarily remove leader key from DCS.
|
||||
This method should remove leader key if current instance is the leader.
|
||||
:param last_operation: latest checkpoint location in bytes"""
|
||||
:param last_lsn: latest checkpoint location in bytes"""
|
||||
|
||||
if last_operation:
|
||||
self.write_leader_optime(last_operation)
|
||||
if last_lsn:
|
||||
self.write_status({self._OPTIME: last_lsn})
|
||||
return self._delete_leader()
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -828,4 +978,4 @@ class AbstractDCS(object):
|
||||
:returns: `!True` if you would like to reschedule the next run of ha cycle"""
|
||||
|
||||
self.event.wait(timeout)
|
||||
return self.event.isSet()
|
||||
return self.event.is_set()
|
||||
|
||||
+138
-39
@@ -14,7 +14,8 @@ from urllib3.exceptions import HTTPError
|
||||
from six.moves.urllib.parse import urlencode, urlparse, quote
|
||||
from six.moves.http_client import HTTPException
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member,\
|
||||
SyncState, TimelineHistory, ReturnFalseException, catch_return_false_exception
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
|
||||
@@ -111,7 +112,7 @@ class HTTPClient(object):
|
||||
# According to the documentation a small random amount of additional wait time is added to the
|
||||
# supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds
|
||||
# up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a
|
||||
# response rather read timeout we will add to the timeout a sligtly bigger value.
|
||||
# response rather read timeout we will add to the timeout a slightly bigger value.
|
||||
kwargs['timeout'] = timeout + max(timeout/15.0, 1)
|
||||
else:
|
||||
kwargs['timeout'] = self._read_timeout
|
||||
@@ -227,15 +228,16 @@ class Consul(AbstractDCS):
|
||||
self._last_session_refresh = 0
|
||||
self.__session_checks = config.get('checks', [])
|
||||
self._register_service = config.get('register_service', False)
|
||||
self._previous_loop_register_service = self._register_service
|
||||
self._service_tags = sorted(config.get('service_tags', []))
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
if self._register_service:
|
||||
self._service_tags = config.get('service_tags', [])
|
||||
self._service_name = service_name_from_scope_name(self._scope)
|
||||
if self._scope != self._service_name:
|
||||
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name,
|
||||
self._scope)
|
||||
self._set_service_name()
|
||||
self._service_check_interval = config.get('service_check_interval', '5s')
|
||||
self._service_check_tls_server_name = config.get('service_check_tls_server_name', None)
|
||||
if not self._ctl:
|
||||
self.create_session()
|
||||
self._previous_loop_token = self._client.token
|
||||
|
||||
def retry(self, *args, **kwargs):
|
||||
return self._retry.copy()(*args, **kwargs)
|
||||
@@ -250,7 +252,18 @@ class Consul(AbstractDCS):
|
||||
|
||||
def reload_config(self, config):
|
||||
super(Consul, self).reload_config(config)
|
||||
self._client.reload_config(config.get('consul', {}))
|
||||
|
||||
consul_config = config.get('consul', {})
|
||||
self._client.reload_config(consul_config)
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
self._service_tags = sorted(consul_config.get('service_tags', []))
|
||||
|
||||
should_register_service = consul_config.get('register_service', False)
|
||||
if should_register_service and not self._register_service:
|
||||
self._set_service_name()
|
||||
|
||||
self._previous_loop_register_service = self._register_service
|
||||
self._register_service = should_register_service
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x
|
||||
@@ -259,7 +272,7 @@ class Consul(AbstractDCS):
|
||||
|
||||
@property
|
||||
def ttl(self):
|
||||
return self._client.http.ttl
|
||||
return self._client.http.ttl * 2 # we multiply the value by 2 because it was divided in the `set_ttl()` method
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
self._retry.deadline = retry_timeout
|
||||
@@ -274,9 +287,9 @@ class Consul(AbstractDCS):
|
||||
except Exception:
|
||||
logger.exception('adjust_ttl')
|
||||
|
||||
def _do_refresh_session(self):
|
||||
def _do_refresh_session(self, force=False):
|
||||
""":returns: `!True` if it had to create new session"""
|
||||
if self._session and self._last_session_refresh + self._loop_wait > time.time():
|
||||
if not force and self._session and self._last_session_refresh + self._loop_wait > time.time():
|
||||
return False
|
||||
|
||||
if self._session:
|
||||
@@ -337,20 +350,30 @@ class Consul(AbstractDCS):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['Value'])
|
||||
# get last known leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['Value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['Value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
|
||||
# get leader
|
||||
leader = nodes.get(self._LEADER)
|
||||
if not self._ctl and leader and leader['Value'] == self._name \
|
||||
and self._session != leader.get('Session', 'x'):
|
||||
logger.info('I am leader but not owner of the session. Removing leader node')
|
||||
self._client.kv.delete(self.leader_path, cas=leader['ModifyIndex'])
|
||||
leader = None
|
||||
|
||||
if leader:
|
||||
member = Member(-1, leader['Value'], None, {})
|
||||
@@ -366,9 +389,16 @@ class Consul(AbstractDCS):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync['ModifyIndex'], sync and sync['Value'])
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
# get failsafe topology
|
||||
failsafe = nodes.get(self._FAILSAFE)
|
||||
try:
|
||||
failsafe = json.loads(failsafe['Value']) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
except NotFound:
|
||||
return Cluster(None, None, None, None, [], None, None, None)
|
||||
return Cluster(None, None, None, None, [], None, None, None, None, None)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise ConsulError('Consul is not responding properly')
|
||||
@@ -387,14 +417,18 @@ class Consul(AbstractDCS):
|
||||
self._client.kv.delete(self.member_path)
|
||||
create_member = True
|
||||
|
||||
if self._register_service or self._previous_loop_register_service:
|
||||
try:
|
||||
self.update_service(not create_member and member and member.data or {}, data)
|
||||
except Exception:
|
||||
logger.exception('update_service')
|
||||
|
||||
if not create_member and member and deep_compare(data, member.data):
|
||||
return True
|
||||
|
||||
try:
|
||||
args = {} if permanent else {'acquire': self._session}
|
||||
self._client.kv.put(self.member_path, json.dumps(data, separators=(',', ':')), **args)
|
||||
if self._register_service:
|
||||
self.update_service(not create_member and member and member.data or {}, data)
|
||||
return True
|
||||
except InvalidSession:
|
||||
self._session = None
|
||||
@@ -403,6 +437,11 @@ class Consul(AbstractDCS):
|
||||
logger.exception('touch_member')
|
||||
return False
|
||||
|
||||
def _set_service_name(self):
|
||||
self._service_name = service_name_from_scope_name(self._scope)
|
||||
if self._scope != self._service_name:
|
||||
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope)
|
||||
|
||||
@catch_consul_errors
|
||||
def register_service(self, service_name, **kwargs):
|
||||
logger.info('Register service %s, params %s', service_name, kwargs)
|
||||
@@ -424,19 +463,27 @@ class Consul(AbstractDCS):
|
||||
conn_parts = urlparse(data['conn_url'])
|
||||
check = base.Check.http(api_parts.geturl(), self._service_check_interval,
|
||||
deregister='{0}s'.format(self._client.http.ttl * 10))
|
||||
if self._service_check_tls_server_name is not None:
|
||||
check['TLSServerName'] = self._service_check_tls_server_name
|
||||
tags = self._service_tags[:]
|
||||
tags.append(role)
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
self._previous_loop_token = self._client.token
|
||||
|
||||
params = {
|
||||
'service_id': '{0}/{1}'.format(self._scope, self._name),
|
||||
'address': conn_parts.hostname,
|
||||
'port': conn_parts.port,
|
||||
'check': check,
|
||||
'tags': tags
|
||||
'tags': tags,
|
||||
'enable_tag_override': True,
|
||||
}
|
||||
|
||||
if state == 'stopped':
|
||||
if state == 'stopped' or (not self._register_service and self._previous_loop_register_service):
|
||||
self._previous_loop_register_service = self._register_service
|
||||
return self.deregister_service(params['service_id'])
|
||||
|
||||
self._previous_loop_register_service = self._register_service
|
||||
if role in ['master', 'replica', 'standby-leader']:
|
||||
if state != 'running':
|
||||
return
|
||||
@@ -455,25 +502,41 @@ class Consul(AbstractDCS):
|
||||
if old_data.get(key) != new_data[key]:
|
||||
update = True
|
||||
|
||||
if force or update:
|
||||
if (
|
||||
force or update or self._register_service != self._previous_loop_register_service
|
||||
or self._service_tags != self._previous_loop_service_tags
|
||||
or self._client.token != self._previous_loop_token
|
||||
):
|
||||
return self._update_service(new_data)
|
||||
|
||||
@catch_consul_errors
|
||||
def _do_attempt_to_acquire_leader(self, permanent):
|
||||
def _do_attempt_to_acquire_leader(self, permanent, retry):
|
||||
try:
|
||||
kwargs = {} if permanent else {'acquire': self._session}
|
||||
return self.retry(self._client.kv.put, self.leader_path, self._name, **kwargs)
|
||||
return retry(self._client.kv.put, self.leader_path, self._name, **kwargs)
|
||||
except InvalidSession:
|
||||
self._session = None
|
||||
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
|
||||
self.refresh_session()
|
||||
return self.retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||
self._session = None
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
|
||||
retry(self._do_refresh_session)
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise ConsulError('_do_attempt_to_acquire_leader timeout')
|
||||
|
||||
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||
|
||||
@catch_return_false_exception
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
if not self._session and not permanent:
|
||||
self.refresh_session()
|
||||
retry = self._retry.copy()
|
||||
if not permanent:
|
||||
self._run_and_handle_exceptions(self._do_refresh_session, retry=retry)
|
||||
|
||||
ret = self._do_attempt_to_acquire_leader(permanent)
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise ConsulError('attempt_to_acquire_leader timeout')
|
||||
|
||||
ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, permanent, retry, retry=None)
|
||||
if not ret:
|
||||
logger.info('Could not take out TTL lock')
|
||||
|
||||
@@ -491,14 +554,50 @@ class Consul(AbstractDCS):
|
||||
return self._client.kv.put(self.config_path, value, cas=index)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.kv.put(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._client.kv.put(self.leader_optime_path, last_lsn)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_status(self, value):
|
||||
return self._client.kv.put(self.status_path, value)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_failsafe(self, value):
|
||||
return self._client.kv.put(self.failsafe_path, value)
|
||||
|
||||
@staticmethod
|
||||
def _run_and_handle_exceptions(method, *args, **kwargs):
|
||||
retry = kwargs.pop('retry', None)
|
||||
try:
|
||||
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
|
||||
except (RetryFailedError, InvalidSession, HTTPException, HTTPError, socket.error, socket.timeout) as e:
|
||||
raise ConsulError(e)
|
||||
except ConsulException:
|
||||
raise ReturnFalseException
|
||||
|
||||
@catch_return_false_exception
|
||||
def _update_leader(self):
|
||||
retry = self._retry.copy()
|
||||
|
||||
self._run_and_handle_exceptions(self._do_refresh_session, True, retry=retry)
|
||||
|
||||
if self._session:
|
||||
self.retry(self._client.session.renew, self._session)
|
||||
self._last_session_refresh = time.time()
|
||||
cluster = self.cluster
|
||||
leader_session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
|
||||
if leader_session != self._session:
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise ConsulError('update_leader timeout')
|
||||
logger.warning('Recreating the leader key due to session mismatch')
|
||||
if cluster.leader:
|
||||
self._run_and_handle_exceptions(self._client.kv.delete, self.leader_path, cas=cluster.leader.index)
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 0.5:
|
||||
raise ConsulError('update_leader timeout')
|
||||
self._run_and_handle_exceptions(self._client.kv.put, self.leader_path,
|
||||
self._name, acquire=self._session)
|
||||
|
||||
return bool(self._session)
|
||||
|
||||
@catch_consul_errors
|
||||
|
||||
+82
-23
@@ -19,7 +19,8 @@ from six.moves.http_client import HTTPException
|
||||
from six.moves.urllib_parse import urlparse
|
||||
from threading import Thread
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member,\
|
||||
SyncState, TimelineHistory, ReturnFalseException, catch_return_false_exception
|
||||
from ..exceptions import DCSError
|
||||
from ..request import get as requests_get
|
||||
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
@@ -184,11 +185,12 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
|
||||
for base_uri in machines_cache:
|
||||
try:
|
||||
machines = list(self._get_members(base_uri, **kwargs))
|
||||
machines = list(set(self._get_members(base_uri, **kwargs)))
|
||||
logger.debug("Retrieved list of machines: %s", machines)
|
||||
if machines:
|
||||
random.shuffle(machines)
|
||||
self._update_dns_cache(self._dns_resolver.resolve_async, machines)
|
||||
if not self._use_proxies:
|
||||
self._update_dns_cache(self._dns_resolver.resolve_async, machines)
|
||||
return machines
|
||||
except Exception as e:
|
||||
self.http.clear()
|
||||
@@ -215,10 +217,13 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
return response
|
||||
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
|
||||
self.http.clear()
|
||||
# switch to the next etcd node because we don't know exactly what happened,
|
||||
# whether the key didn't received an update or there is a network problem.
|
||||
if not retry and i + 1 < len(machines_cache):
|
||||
self.set_base_uri(machines_cache[i + 1])
|
||||
if not retry:
|
||||
if len(machines_cache) == 1:
|
||||
self.set_base_uri(self._base_uri) # trigger Etcd3 watcher restart
|
||||
# switch to the next etcd node because we don't know exactly what happened,
|
||||
# whether the key didn't received an update or there is a network problem.
|
||||
elif i + 1 < len(machines_cache):
|
||||
self.set_base_uri(machines_cache[i + 1])
|
||||
if (isinstance(fields, dict) and fields.get("wait") == "true" and
|
||||
isinstance(e, (ReadTimeoutError, ProtocolError))):
|
||||
logger.debug("Watch timed out.")
|
||||
@@ -268,6 +273,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time)
|
||||
if nodes == 0:
|
||||
self._update_machines_cache = True
|
||||
self.set_base_uri(self._base_uri) # trigger Etcd3 watcher restart
|
||||
raise ex
|
||||
retry.sleep_func(sleeptime)
|
||||
retry.update_delay()
|
||||
@@ -282,13 +288,14 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
except DNSException:
|
||||
return []
|
||||
|
||||
def _get_machines_cache_from_srv(self, srv):
|
||||
def _get_machines_cache_from_srv(self, srv, srv_suffix=None):
|
||||
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
|
||||
This record should contain list of host and peer ports which could be used to run
|
||||
'GET http://{host}:{port}/members' request (peer protocol)"""
|
||||
|
||||
ret = []
|
||||
for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
|
||||
r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r
|
||||
protocol = 'https' if '-ssl' in r else 'http'
|
||||
endpoint = '/members' if '-server' in r else ''
|
||||
for host, port in self.get_srv_record('_etcd{0}._tcp.{1}'.format(r, srv)):
|
||||
@@ -325,7 +332,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
|
||||
machines_cache = []
|
||||
if 'srv' in self._config:
|
||||
machines_cache = self._get_machines_cache_from_srv(self._config['srv'])
|
||||
machines_cache = self._get_machines_cache_from_srv(self._config['srv'], self._config.get('srv_suffix'))
|
||||
|
||||
if not machines_cache and 'hosts' in self._config:
|
||||
machines_cache = list(self._config['hosts'])
|
||||
@@ -392,8 +399,9 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
self._machines_cache_updated = time.time()
|
||||
|
||||
def set_base_uri(self, value):
|
||||
logger.info('Selected new etcd server %s', value)
|
||||
self._base_uri = value
|
||||
if self._base_uri != value:
|
||||
logger.info('Selected new etcd server %s', value)
|
||||
self._base_uri = value
|
||||
|
||||
|
||||
class EtcdClient(AbstractEtcdClientWithFailover):
|
||||
@@ -453,6 +461,18 @@ class AbstractEtcd(AbstractDCS):
|
||||
if isinstance(raise_ex, Exception):
|
||||
raise raise_ex
|
||||
|
||||
def _run_and_handle_exceptions(self, method, *args, **kwargs):
|
||||
retry = kwargs.pop('retry', self.retry)
|
||||
try:
|
||||
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
|
||||
except (RetryFailedError, etcd.EtcdConnectionFailed) as e:
|
||||
raise self._client.ERROR_CLS(e)
|
||||
except etcd.EtcdException as e:
|
||||
self._handle_exception(e)
|
||||
raise ReturnFalseException
|
||||
except Exception as e:
|
||||
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
|
||||
|
||||
@staticmethod
|
||||
def set_socket_options(sock, socket_options):
|
||||
if socket_options:
|
||||
@@ -602,9 +622,24 @@ class Etcd(AbstractEtcd):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status.value)
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn.value
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -626,9 +661,16 @@ class Etcd(AbstractEtcd):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync.modifiedIndex, sync and sync.value)
|
||||
|
||||
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
# get failsafe topology
|
||||
failsafe = nodes.get(self._FAILSAFE)
|
||||
try:
|
||||
failsafe = json.loads(failsafe.value) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
cluster = Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
cluster = Cluster(None, None, None, None, [], None, None, None)
|
||||
cluster = Cluster(None, None, None, None, [], None, None, None, None, None)
|
||||
except Exception as e:
|
||||
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
|
||||
self._has_failed = False
|
||||
@@ -643,7 +685,7 @@ class Etcd(AbstractEtcd):
|
||||
def take_leader(self):
|
||||
return self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl)
|
||||
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
def _do_attempt_to_acquire_leader(self, permanent=False):
|
||||
try:
|
||||
return bool(self.retry(self._client.write,
|
||||
self.leader_path,
|
||||
@@ -652,9 +694,11 @@ class Etcd(AbstractEtcd):
|
||||
prevExist=False))
|
||||
except etcd.EtcdAlreadyExist:
|
||||
logger.info('Could not take out TTL lock')
|
||||
except (RetryFailedError, etcd.EtcdException):
|
||||
pass
|
||||
return False
|
||||
return False
|
||||
|
||||
@catch_return_false_exception
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
return self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, permanent=permanent, retry=None)
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_failover_value(self, value, index=None):
|
||||
@@ -665,12 +709,27 @@ class Etcd(AbstractEtcd):
|
||||
return self._client.write(self.config_path, value, prevIndex=index or 0)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.set(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._client.set(self.leader_optime_path, last_lsn)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_status(self, value):
|
||||
return self._client.set(self.status_path, value)
|
||||
|
||||
def _do_update_leader(self):
|
||||
try:
|
||||
return self.retry(self._client.write, self.leader_path, self._name,
|
||||
prevValue=self._name, ttl=self._ttl) is not None
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return self._do_attempt_to_acquire_leader()
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_failsafe(self, value):
|
||||
return self._client.set(self.failsafe_path, value)
|
||||
|
||||
@catch_return_false_exception
|
||||
def _update_leader(self):
|
||||
return self.retry(self._client.write, self.leader_path, self._name, prevValue=self._name, ttl=self._ttl)
|
||||
return self._run_and_handle_exceptions(self._do_update_leader, retry=None)
|
||||
|
||||
@catch_etcd_errors
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
|
||||
+112
-33
@@ -11,8 +11,10 @@ import time
|
||||
import urllib3
|
||||
|
||||
from threading import Condition, Lock, Thread
|
||||
from urllib3.exceptions import ReadTimeoutError, ProtocolError
|
||||
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member,\
|
||||
SyncState, TimelineHistory, ReturnFalseException, catch_return_false_exception
|
||||
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors
|
||||
from ..exceptions import DCSError, PatroniException
|
||||
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
|
||||
@@ -262,6 +264,9 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
|
||||
|
||||
def authenticate(self):
|
||||
if self._use_proxies and self._cluster_version is None:
|
||||
kwargs = self._prepare_common_parameters(1)
|
||||
self._ensure_version_prefix(self._base_uri, **kwargs)
|
||||
if self._cluster_version >= (3, 3) and self.username and self.password:
|
||||
logger.info('Trying to authenticate on Etcd...')
|
||||
old_token, self._token = self._token, None
|
||||
@@ -312,6 +317,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
def prefix(self, key, retry=None):
|
||||
return self.range(key, prefix_range_end(key), retry)
|
||||
|
||||
@_handle_auth_errors
|
||||
def lease_grant(self, ttl, retry=None):
|
||||
return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID']
|
||||
|
||||
@@ -346,7 +352,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
def deleteprefix(self, key, retry=None):
|
||||
return self.deleterange(key, prefix_range_end(key), retry=retry)
|
||||
|
||||
def watchrange(self, key, range_end=None, start_revision=None, filters=None):
|
||||
def watchrange(self, key, range_end=None, start_revision=None, filters=None, read_timeout=None):
|
||||
"""returns: response object"""
|
||||
params = build_range_request(key, range_end)
|
||||
if start_revision is not None:
|
||||
@@ -354,11 +360,11 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
params['filters'] = filters or []
|
||||
kwargs = self._prepare_common_parameters(1, self.read_timeout)
|
||||
request_executor = self._prepare_request(kwargs, {'create_request': params})
|
||||
kwargs.update(timeout=urllib3.Timeout(connect=kwargs['timeout']), retries=0)
|
||||
kwargs.update(timeout=urllib3.Timeout(connect=kwargs['timeout'], read=read_timeout), retries=0)
|
||||
return request_executor(self._MPOST, self._base_uri + self.version_prefix + '/watch', **kwargs)
|
||||
|
||||
def watchprefix(self, key, start_revision=None, filters=None):
|
||||
return self.watchrange(key, prefix_range_end(key), start_revision, filters)
|
||||
def watchprefix(self, key, start_revision=None, filters=None, read_timeout=None):
|
||||
return self.watchrange(key, prefix_range_end(key), start_revision, filters, read_timeout)
|
||||
|
||||
|
||||
class KVCache(Thread):
|
||||
@@ -372,6 +378,7 @@ class KVCache(Thread):
|
||||
self._config_key = base64_encode(dcs.config_path)
|
||||
self._leader_key = base64_encode(dcs.leader_path)
|
||||
self._optime_key = base64_encode(dcs.leader_optime_path)
|
||||
self._status_key = base64_encode(dcs.status_path)
|
||||
self._name = base64_encode(dcs._name)
|
||||
self._is_ready = False
|
||||
self._response = None
|
||||
@@ -418,14 +425,15 @@ class KVCache(Thread):
|
||||
new_value = kv.get('value')
|
||||
|
||||
value_changed = old_value != new_value and \
|
||||
(key == self._leader_key or key == self._optime_key and new_value is not None or
|
||||
(key == self._leader_key or key in (self._optime_key, self._status_key) and new_value is not None or
|
||||
key == self._config_key and old_value is not None and new_value is not None)
|
||||
|
||||
if value_changed:
|
||||
logger.debug('%s changed from %s to %s', key, old_value, new_value)
|
||||
|
||||
# We also want to wake up HA loop on replicas if leader optime was updated
|
||||
if value_changed and (key != self._optime_key or self.get(self._leader_key) != self._name):
|
||||
# We also want to wake up HA loop on replicas if leader optime (or status key) was updated
|
||||
if value_changed and (key not in (self._optime_key, self._status_key) or
|
||||
(self.get(self._leader_key) or {}).get('value') != self._name):
|
||||
self._dcs.event.set()
|
||||
|
||||
def _process_message(self, message):
|
||||
@@ -445,7 +453,14 @@ class KVCache(Thread):
|
||||
def _do_watch(self, revision):
|
||||
with self._response_lock:
|
||||
self._response = None
|
||||
response = self._client.watchprefix(self._dcs.cluster_prefix, revision)
|
||||
# We do most of requests with timeouts. The only exception /watch requests to Etcd v3.
|
||||
# In order to interrupt the /watch request we do socket.shutdown() from the main thread,
|
||||
# which doesn't work on Windows. Therefore we want to use the last resort, `read_timeout`.
|
||||
# Setting it to TTL will help to partially mitigate the problem.
|
||||
# Setting it to lower value is not nice because for idling clusters it will increase
|
||||
# the numbers of interrupts and reconnects.
|
||||
read_timeout = self._dcs.ttl if os.name == 'nt' else None
|
||||
response = self._client.watchprefix(self._dcs.cluster_prefix, revision, read_timeout=read_timeout)
|
||||
with self._response_lock:
|
||||
if self._response is None:
|
||||
self._response = response
|
||||
@@ -467,7 +482,9 @@ class KVCache(Thread):
|
||||
try:
|
||||
self._do_watch(result['header']['revision'])
|
||||
except Exception as e:
|
||||
logger.error('watchprefix failed: %r', e)
|
||||
# Following exceptions are expected on Windows because the /watch request is done with `read_timeout`
|
||||
if not (os.name == 'nt' and isinstance(e, (ReadTimeoutError, ProtocolError))):
|
||||
logger.error('watchprefix failed: %r', e)
|
||||
finally:
|
||||
with self.condition:
|
||||
self._is_ready = False
|
||||
@@ -593,8 +610,8 @@ class Etcd3(AbstractEtcd):
|
||||
if self.__do_not_watch:
|
||||
self._lease = None
|
||||
|
||||
def _do_refresh_lease(self, retry=None):
|
||||
if self._lease and self._last_lease_refresh + self._loop_wait > time.time():
|
||||
def _do_refresh_lease(self, force=False, retry=None):
|
||||
if not force and self._lease and self._last_lease_refresh + self._loop_wait > time.time():
|
||||
return False
|
||||
|
||||
if self._lease and not self._client.lease_keepalive(self._lease, retry):
|
||||
@@ -612,7 +629,7 @@ class Etcd3(AbstractEtcd):
|
||||
return self.retry(self._do_refresh_lease)
|
||||
except (Etcd3ClientError, RetryFailedError):
|
||||
logger.exception('refresh_lease')
|
||||
raise Etcd3Error('Failed ro keepalive/grant lease')
|
||||
raise Etcd3Error('Failed to keepalive/grant lease')
|
||||
|
||||
def create_lease(self):
|
||||
while not self._lease:
|
||||
@@ -654,9 +671,24 @@ class Etcd3(AbstractEtcd):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history['mod_revision'], history['value'])
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value'])
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -680,7 +712,14 @@ class Etcd3(AbstractEtcd):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync['mod_revision'], sync and sync['value'])
|
||||
|
||||
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
# get failsafe topology
|
||||
failsafe = nodes.get(self._FAILSAFE)
|
||||
try:
|
||||
failsafe = json.loads(failsafe['value']) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
cluster = Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
except UnsupportedEtcdVersion:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -713,21 +752,42 @@ class Etcd3(AbstractEtcd):
|
||||
def take_leader(self):
|
||||
return self.retry(self._client.put, self.leader_path, self._name, self._lease)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _do_attempt_to_acquire_leader(self, permanent):
|
||||
def _do_attempt_to_acquire_leader(self, permanent, retry):
|
||||
def _retry(*args, **kwargs):
|
||||
kwargs['retry'] = retry
|
||||
return retry(*args, **kwargs)
|
||||
|
||||
try:
|
||||
return self.retry(self._client.put, self.leader_path, self._name, None if permanent else self._lease, 0)
|
||||
return _retry(self._client.put, self.leader_path, self._name, None if permanent else self._lease, 0)
|
||||
except LeaseNotFound:
|
||||
self._lease = None
|
||||
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
|
||||
self.refresh_lease()
|
||||
return self.retry(self._client.put, self.leader_path, self._name, None if permanent else self._lease, 0)
|
||||
self._lease = None
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
|
||||
_retry(self._do_refresh_lease)
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise Etcd3Error('_do_attempt_to_acquire_leader timeout')
|
||||
|
||||
return _retry(self._client.put, self.leader_path, self._name, None if permanent else self._lease, 0)
|
||||
|
||||
@catch_return_false_exception
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
if not self._lease and not permanent:
|
||||
self.refresh_lease()
|
||||
retry = self._retry.copy()
|
||||
|
||||
ret = self._do_attempt_to_acquire_leader(permanent)
|
||||
def _retry(*args, **kwargs):
|
||||
kwargs['retry'] = retry
|
||||
return retry(*args, **kwargs)
|
||||
|
||||
if not permanent:
|
||||
self._run_and_handle_exceptions(self._do_refresh_lease, retry=_retry)
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise Etcd3Error('attempt_to_acquire_leader timeout')
|
||||
|
||||
ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, permanent, retry, retry=None)
|
||||
if not ret:
|
||||
logger.info('Could not take out TTL lock')
|
||||
return ret
|
||||
@@ -741,21 +801,40 @@ class Etcd3(AbstractEtcd):
|
||||
return self._client.put(self.config_path, value, mod_revision=index)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.put(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._client.put(self.leader_optime_path, last_lsn)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_status(self, value):
|
||||
return self._client.put(self.status_path, value)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_failsafe(self, value):
|
||||
return self._client.put(self.failsafe_path, value)
|
||||
|
||||
@catch_return_false_exception
|
||||
def _update_leader(self):
|
||||
if not self._lease:
|
||||
self.refresh_lease()
|
||||
elif self.retry(self._client.lease_keepalive, self._lease):
|
||||
self._last_lease_refresh = time.time()
|
||||
retry = self._retry.copy()
|
||||
|
||||
def _retry(*args, **kwargs):
|
||||
kwargs['retry'] = retry
|
||||
return retry(*args, **kwargs)
|
||||
|
||||
self._run_and_handle_exceptions(self._do_refresh_lease, True, retry=_retry)
|
||||
|
||||
if self._lease:
|
||||
cluster = self.cluster
|
||||
leader_lease = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
|
||||
if leader_lease != self._lease:
|
||||
self.take_leader()
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise Etcd3Error('update_leader timeout')
|
||||
|
||||
try:
|
||||
self._run_and_handle_exceptions(self._client.put, self.leader_path,
|
||||
self._name, self._lease, retry=_retry)
|
||||
except ReturnFalseException:
|
||||
pass
|
||||
return bool(self._lease)
|
||||
|
||||
@catch_etcd_errors
|
||||
|
||||
+236
-92
@@ -1,3 +1,5 @@
|
||||
import atexit
|
||||
import base64
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
@@ -7,6 +9,7 @@ import random
|
||||
import socket
|
||||
import six
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib3
|
||||
import yaml
|
||||
@@ -28,12 +31,34 @@ SERVICE_HOST_ENV_NAME = 'KUBERNETES_SERVICE_HOST'
|
||||
SERVICE_PORT_ENV_NAME = 'KUBERNETES_SERVICE_PORT'
|
||||
SERVICE_TOKEN_FILENAME = '/var/run/secrets/kubernetes.io/serviceaccount/token'
|
||||
SERVICE_CERT_FILENAME = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
|
||||
__temp_files = []
|
||||
|
||||
|
||||
class KubernetesError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_temp_files():
|
||||
global __temp_files
|
||||
for temp_file in __temp_files:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except OSError:
|
||||
pass
|
||||
__temp_files = []
|
||||
|
||||
|
||||
def _create_temp_file(content):
|
||||
if len(__temp_files) == 0:
|
||||
atexit.register(_cleanup_temp_files)
|
||||
|
||||
fd, name = tempfile.mkstemp()
|
||||
os.write(fd, content)
|
||||
os.close(fd)
|
||||
__temp_files.append(name)
|
||||
return name
|
||||
|
||||
|
||||
# this function does the same mapping of snake_case => camelCase for > 97% of cases as autogenerated swagger code
|
||||
def to_camel_case(value):
|
||||
reserved = {'api', 'apiv3', 'cidr', 'cpu', 'csi', 'id', 'io', 'ip', 'ipc', 'pid', 'tls', 'uri', 'url', 'uuid'}
|
||||
@@ -48,31 +73,43 @@ class K8sConfig(object):
|
||||
|
||||
def __init__(self):
|
||||
self.pool_config = {'maxsize': 10, 'num_pools': 10} # configuration for urllib3.PoolManager
|
||||
self._token_expires_at = datetime.datetime.max
|
||||
self._make_headers()
|
||||
|
||||
def _set_token(self, token):
|
||||
self._headers['authorization'] = 'Bearer ' + token
|
||||
|
||||
def _make_headers(self, token=None, **kwargs):
|
||||
self._headers = urllib3.make_headers(user_agent=USER_AGENT, **kwargs)
|
||||
if token:
|
||||
self._headers['authorization'] = 'Bearer ' + token
|
||||
self._set_token(token)
|
||||
|
||||
def load_incluster_config(self):
|
||||
if SERVICE_HOST_ENV_NAME not in os.environ or SERVICE_PORT_ENV_NAME not in os.environ:
|
||||
raise self.ConfigException('Service host/port is not set.')
|
||||
if not os.environ[SERVICE_HOST_ENV_NAME] or not os.environ[SERVICE_PORT_ENV_NAME]:
|
||||
raise self.ConfigException('Service host/port is set but empty.')
|
||||
if not os.path.isfile(SERVICE_CERT_FILENAME):
|
||||
raise self.ConfigException('Service certificate file does not exists.')
|
||||
with open(SERVICE_CERT_FILENAME) as f:
|
||||
if not f.read():
|
||||
raise self.ConfigException('Cert file exists but empty.')
|
||||
def _read_token_file(self):
|
||||
if not os.path.isfile(SERVICE_TOKEN_FILENAME):
|
||||
raise self.ConfigException('Service token file does not exists.')
|
||||
with open(SERVICE_TOKEN_FILENAME) as f:
|
||||
token = f.read()
|
||||
if not token:
|
||||
raise self.ConfigException('Token file exists but empty.')
|
||||
self._make_headers(token=token)
|
||||
self.pool_config['ca_certs'] = SERVICE_CERT_FILENAME
|
||||
self._token_expires_at = datetime.datetime.now() + self._token_refresh_interval
|
||||
return token
|
||||
|
||||
def load_incluster_config(self, ca_certs=SERVICE_CERT_FILENAME,
|
||||
token_refresh_interval=datetime.timedelta(minutes=1)):
|
||||
if SERVICE_HOST_ENV_NAME not in os.environ or SERVICE_PORT_ENV_NAME not in os.environ:
|
||||
raise self.ConfigException('Service host/port is not set.')
|
||||
if not os.environ[SERVICE_HOST_ENV_NAME] or not os.environ[SERVICE_PORT_ENV_NAME]:
|
||||
raise self.ConfigException('Service host/port is set but empty.')
|
||||
|
||||
if not os.path.isfile(ca_certs):
|
||||
raise self.ConfigException('Service certificate file does not exists.')
|
||||
with open(ca_certs) as f:
|
||||
if not f.read():
|
||||
raise self.ConfigException('Cert file exists but empty.')
|
||||
self.pool_config['ca_certs'] = ca_certs
|
||||
self._token_refresh_interval = token_refresh_interval
|
||||
token = self._read_token_file()
|
||||
self._make_headers(token=token)
|
||||
self._server = uri('https', (os.environ[SERVICE_HOST_ENV_NAME], os.environ[SERVICE_PORT_ENV_NAME]))
|
||||
|
||||
@staticmethod
|
||||
@@ -81,6 +118,13 @@ class K8sConfig(object):
|
||||
if c['name'] == name:
|
||||
return c[section]
|
||||
|
||||
def _pool_config_from_file_or_data(self, config, file_key_name, pool_key_name):
|
||||
data_key_name = file_key_name + '-data'
|
||||
if data_key_name in config:
|
||||
self.pool_config[pool_key_name] = _create_temp_file(base64.b64decode(config[data_key_name]))
|
||||
elif file_key_name in config:
|
||||
self.pool_config[pool_key_name] = config[file_key_name]
|
||||
|
||||
def load_kube_config(self, context=None):
|
||||
with open(os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION)) as f:
|
||||
config = yaml.safe_load(f)
|
||||
@@ -91,10 +135,9 @@ class K8sConfig(object):
|
||||
|
||||
self._server = cluster['server'].rstrip('/')
|
||||
if self._server.startswith('https'):
|
||||
self.pool_config.update({v: user[k] for k, v in {'client-certificate': 'cert_file',
|
||||
'client-key': 'key_file'}.items() if k in user})
|
||||
if 'certificate-authority' in cluster:
|
||||
self.pool_config['ca_certs'] = cluster['certificate-authority']
|
||||
self._pool_config_from_file_or_data(user, 'client-certificate', 'cert_file')
|
||||
self._pool_config_from_file_or_data(user, 'client-key', 'key_file')
|
||||
self._pool_config_from_file_or_data(cluster, 'certificate-authority', 'ca_certs')
|
||||
self.pool_config['cert_reqs'] = 'CERT_NONE' if cluster.get('insecure-skip-tls-verify') else 'CERT_REQUIRED'
|
||||
if user.get('token'):
|
||||
self._make_headers(token=user['token'])
|
||||
@@ -107,6 +150,11 @@ class K8sConfig(object):
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
if self._token_expires_at <= datetime.datetime.now():
|
||||
try:
|
||||
self._set_token(self._read_token_file())
|
||||
except Exception as e:
|
||||
logger.error('Failed to refresh service account token: %r', e)
|
||||
return self._headers.copy()
|
||||
|
||||
|
||||
@@ -343,12 +391,12 @@ class K8sClient(object):
|
||||
try:
|
||||
self._load_api_servers_cache()
|
||||
api_servers_cache = self.api_servers_cache
|
||||
api_servers = len(api_servers)
|
||||
api_servers = len(api_servers_cache)
|
||||
except Exception as e:
|
||||
logger.debug('Failed to update list of K8s master nodes: %r', e)
|
||||
|
||||
sleeptime = retry.sleeptime
|
||||
remaining_time = retry.stoptime - sleeptime - time.time()
|
||||
remaining_time = (retry.stoptime or time.time()) - sleeptime - time.time()
|
||||
nodes, timeout, retries = self._calculate_timeouts(api_servers, remaining_time)
|
||||
if nodes == 0:
|
||||
self._update_api_servers_cache = True
|
||||
@@ -476,16 +524,10 @@ class CoreV1ApiProxy(object):
|
||||
|
||||
|
||||
def catch_kubernetes_errors(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
def wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except k8s_client.rest.ApiException as e:
|
||||
if e.status == 403:
|
||||
logger.exception('Permission denied')
|
||||
elif e.status != 409: # Object exists or conflict in resource_version
|
||||
logger.exception('Unexpected error from Kubernetes API')
|
||||
return False
|
||||
except (RetryFailedError, K8sException):
|
||||
return self._run_and_handle_exceptions(func, self, *args, **kwargs)
|
||||
except KubernetesError:
|
||||
return False
|
||||
return wrapper
|
||||
|
||||
@@ -501,6 +543,8 @@ class ObjectCache(Thread):
|
||||
self._condition = condition
|
||||
self._name = name # name of this pod
|
||||
self._is_ready = False
|
||||
self._response = None # needs to be accessible from the `kill_stream()` method
|
||||
self._response_lock = Lock() # protect the `self._response` from concurrent access
|
||||
self._object_cache = {}
|
||||
self._object_cache_lock = Lock()
|
||||
self._annotations_map = {self._dcs.leader_path: self._dcs._LEADER, self._dcs.config_path: self._dcs._CONFIG}
|
||||
@@ -541,63 +585,99 @@ class ObjectCache(Thread):
|
||||
with self._object_cache_lock:
|
||||
return self._object_cache.get(name)
|
||||
|
||||
def _process_event(self, event):
|
||||
ev_type = event['type']
|
||||
obj = event['object']
|
||||
name = obj['metadata']['name']
|
||||
|
||||
if ev_type in ('ADDED', 'MODIFIED'):
|
||||
obj = K8sObject(obj)
|
||||
success, old_value = self.set(name, obj)
|
||||
if success:
|
||||
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
|
||||
elif ev_type == 'DELETED':
|
||||
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
|
||||
new_value = None
|
||||
else:
|
||||
return logger.warning('Unexpected event type: %s', ev_type)
|
||||
|
||||
if success and obj.get('kind') != 'Pod':
|
||||
if old_value:
|
||||
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
|
||||
|
||||
value_changed = old_value != new_value and \
|
||||
(name != self._dcs.config_path or old_value is not None and new_value is not None)
|
||||
|
||||
if value_changed:
|
||||
logger.debug('%s changed from %s to %s', name, old_value, new_value)
|
||||
|
||||
# Do not wake up HA loop if we run as leader and received leader object update event
|
||||
if value_changed or name == self._dcs.leader_path and self._name != new_value:
|
||||
self._dcs.event.set()
|
||||
|
||||
@staticmethod
|
||||
def _finish_response(response):
|
||||
try:
|
||||
response.close()
|
||||
finally:
|
||||
response.release_conn()
|
||||
|
||||
def _do_watch(self, resource_version):
|
||||
with self._response_lock:
|
||||
self._response = None
|
||||
response = self._watch(resource_version)
|
||||
with self._response_lock:
|
||||
if self._response is None:
|
||||
self._response = response
|
||||
|
||||
if not self._response:
|
||||
return self._finish_response(response)
|
||||
|
||||
for event in iter_response_objects(response):
|
||||
if event['object'].get('code') == 410:
|
||||
break
|
||||
self._process_event(event)
|
||||
|
||||
def _build_cache(self):
|
||||
objects = self._list()
|
||||
return_type = 'V1' + objects.kind[:-4]
|
||||
with self._object_cache_lock:
|
||||
self._object_cache = {item.metadata.name: item for item in objects.items}
|
||||
with self._condition:
|
||||
self._is_ready = True
|
||||
self._condition.notify()
|
||||
|
||||
response = self._watch(objects.metadata.resource_version)
|
||||
try:
|
||||
for event in iter_response_objects(response):
|
||||
obj = event['object']
|
||||
if obj.get('code') == 410:
|
||||
break
|
||||
|
||||
ev_type = event['type']
|
||||
name = obj['metadata']['name']
|
||||
|
||||
if ev_type in ('ADDED', 'MODIFIED'):
|
||||
obj = K8sObject(obj)
|
||||
success, old_value = self.set(name, obj)
|
||||
if success:
|
||||
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
|
||||
elif ev_type == 'DELETED':
|
||||
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
|
||||
new_value = None
|
||||
else:
|
||||
logger.warning('Unexpected event type: %s', ev_type)
|
||||
continue
|
||||
|
||||
if success and return_type != 'V1Pod':
|
||||
if old_value:
|
||||
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
|
||||
|
||||
value_changed = old_value != new_value and \
|
||||
(name != self._dcs.config_path or old_value is not None and new_value is not None)
|
||||
|
||||
if value_changed:
|
||||
logger.debug('%s changed from %s to %s', name, old_value, new_value)
|
||||
|
||||
# Do not wake up HA loop if we run as leader and received leader object update event
|
||||
if value_changed or name == self._dcs.leader_path and self._name != new_value:
|
||||
self._dcs.event.set()
|
||||
self._do_watch(objects.metadata.resource_version)
|
||||
finally:
|
||||
with self._condition:
|
||||
self._is_ready = False
|
||||
response.close()
|
||||
response.release_conn()
|
||||
with self._response_lock:
|
||||
response, self._response = self._response, None
|
||||
if response:
|
||||
self._finish_response(response)
|
||||
|
||||
def kill_stream(self):
|
||||
sock = None
|
||||
with self._response_lock:
|
||||
if self._response:
|
||||
try:
|
||||
sock = self._response.connection.sock
|
||||
except Exception:
|
||||
sock = None
|
||||
else:
|
||||
self._response = False
|
||||
if sock:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
sock.close()
|
||||
except Exception as e:
|
||||
logger.debug('Error on socket.shutdown: %r', e)
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
try:
|
||||
self._build_cache()
|
||||
except Exception as e:
|
||||
with self._condition:
|
||||
self._is_ready = False
|
||||
logger.error('ObjectCache.run %r', e)
|
||||
|
||||
def is_ready(self):
|
||||
@@ -613,15 +693,16 @@ class Kubernetes(AbstractDCS):
|
||||
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
|
||||
self._namespace = config.get('namespace') or 'default'
|
||||
self._role_label = config.get('role_label', 'role')
|
||||
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
|
||||
config['namespace'] = ''
|
||||
super(Kubernetes, self).__init__(config)
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=KubernetesRetriableException)
|
||||
self._ttl = None
|
||||
try:
|
||||
k8s_config.load_incluster_config()
|
||||
k8s_config.load_incluster_config(ca_certs=self._ca_certs)
|
||||
except k8s_config.ConfigException:
|
||||
k8s_config.load_kube_config(context=config.get('context', 'local'))
|
||||
k8s_config.load_kube_config(context=config.get('context', 'kind-kind'))
|
||||
|
||||
self.__my_pod = None
|
||||
self.__ips = [] if config.get('patronictl') else [config.get('pod_ip')]
|
||||
@@ -656,6 +737,19 @@ class Kubernetes(AbstractDCS):
|
||||
kwargs['_retry'] = retry
|
||||
return retry(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _run_and_handle_exceptions(method, *args, **kwargs):
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except k8s_client.rest.ApiException as e:
|
||||
if e.status == 403:
|
||||
logger.exception('Permission denied')
|
||||
elif e.status != 409: # Object exists or conflict in resource_version
|
||||
logger.exception('Unexpected error from Kubernetes API')
|
||||
return False
|
||||
except (RetryFailedError, K8sException) as e:
|
||||
raise KubernetesError(e)
|
||||
|
||||
def client_path(self, path):
|
||||
return super(Kubernetes, self).client_path(path)[1:].replace('/', '-')
|
||||
|
||||
@@ -724,9 +818,26 @@ class Kubernetes(AbstractDCS):
|
||||
self._leader_resource_version = metadata.resource_version if metadata else None
|
||||
annotations = metadata and metadata.annotations or {}
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = annotations.get(self._OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation)
|
||||
# get last known leader lsn
|
||||
last_lsn = annotations.get(self._OPTIME)
|
||||
try:
|
||||
last_lsn = 0 if last_lsn is None else int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get permanent slots state (confirmed_flush_lsn)
|
||||
slots = annotations.get('slots')
|
||||
try:
|
||||
slots = slots and json.loads(slots)
|
||||
except Exception:
|
||||
slots = None
|
||||
|
||||
# get failsafe topology
|
||||
failsafe = annotations.get(self._FAILSAFE)
|
||||
try:
|
||||
failsafe = json.loads(failsafe) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
# get leader
|
||||
leader_record = {n: annotations.get(n) for n in (self._LEADER, 'acquireTime',
|
||||
@@ -760,7 +871,7 @@ class Kubernetes(AbstractDCS):
|
||||
metadata = sync and sync.metadata
|
||||
sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise KubernetesError('Kubernetes API is not responding properly')
|
||||
@@ -861,7 +972,14 @@ class Kubernetes(AbstractDCS):
|
||||
def patch_or_create(self, name, annotations, resource_version=None, patch=False, retry=True, ips=None):
|
||||
if retry is True:
|
||||
retry = self.retry
|
||||
return self._patch_or_create(name, annotations, resource_version, patch, retry, ips)
|
||||
try:
|
||||
return self._patch_or_create(name, annotations, resource_version, patch, retry, ips)
|
||||
except k8s_client.rest.ApiException as e:
|
||||
if e.status == 409 and resource_version: # Conflict in resource_version
|
||||
# Terminate watchers, it could be a sign that K8s API is in a failed state
|
||||
self._kinds.kill_stream()
|
||||
self._pods.kill_stream()
|
||||
raise e
|
||||
|
||||
def patch_or_create_config(self, annotations, resource_version=None, patch=False, retry=True):
|
||||
# SCOPE-config endpoint requires corresponding service otherwise it might be "cleaned" by k8s master
|
||||
@@ -877,11 +995,18 @@ class Kubernetes(AbstractDCS):
|
||||
if not self._api.create_namespaced_service(self._namespace, body):
|
||||
return
|
||||
except Exception as e:
|
||||
if not isinstance(e, k8s_client.rest.ApiException) or e.status != 409: # Service already exists
|
||||
# 409 - service already exists, 403 - creation forbidden
|
||||
if not isinstance(e, k8s_client.rest.ApiException) or e.status not in (409, 403):
|
||||
return logger.exception('create_config_service failed')
|
||||
self._should_create_config_service = False
|
||||
|
||||
def _write_leader_optime(self, last_operation):
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
"""Unused"""
|
||||
|
||||
def _write_status(self, value):
|
||||
"""Unused"""
|
||||
|
||||
def _write_failsafe(self, value):
|
||||
"""Unused"""
|
||||
|
||||
def _update_leader(self):
|
||||
@@ -902,16 +1027,19 @@ class Kubernetes(AbstractDCS):
|
||||
else:
|
||||
logger.exception('Permission denied' if e.status == 403 else 'Unexpected error from Kubernetes API')
|
||||
return False
|
||||
except (RetryFailedError, K8sException):
|
||||
return False
|
||||
except (RetryFailedError, K8sException) as e:
|
||||
raise KubernetesError(e)
|
||||
|
||||
# if we are here, that means update failed with 409
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
return False
|
||||
return False # No time for retry. Tell ha.py that we have to demote due to failed update.
|
||||
|
||||
# Try to get the latest version directly from K8s API instead of relying on async cache
|
||||
try:
|
||||
kind = retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
|
||||
kind = _retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
|
||||
except (RetryFailedError, K8sException) as e:
|
||||
raise KubernetesError(e)
|
||||
except Exception as e:
|
||||
logger.error('Failed to get the leader object "%s": %r', self.leader_path, e)
|
||||
return False
|
||||
@@ -929,9 +1057,10 @@ class Kubernetes(AbstractDCS):
|
||||
if kind and (kind_annotations.get(self._LEADER) != self._name or kind_resource_version == resource_version):
|
||||
return False
|
||||
|
||||
return self.patch_or_create(self.leader_path, annotations, kind_resource_version, ips=ips, retry=_retry)
|
||||
return self._run_and_handle_exceptions(self._patch_or_create, self.leader_path, annotations,
|
||||
kind_resource_version, ips=ips, retry=_retry)
|
||||
|
||||
def update_leader(self, last_operation, access_is_restricted=False):
|
||||
def update_leader(self, last_lsn, slots=None, failsafe=None):
|
||||
kind = self._kinds.get(self.leader_path)
|
||||
kind_annotations = kind and kind.metadata.annotations or {}
|
||||
|
||||
@@ -943,12 +1072,15 @@ class Kubernetes(AbstractDCS):
|
||||
annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now,
|
||||
'acquireTime': leader_observed_record.get('acquireTime') or now,
|
||||
'transitions': leader_observed_record.get('transitions') or '0'}
|
||||
if last_operation:
|
||||
annotations[self._OPTIME] = last_operation
|
||||
if last_lsn:
|
||||
annotations[self._OPTIME] = str(last_lsn)
|
||||
annotations['slots'] = json.dumps(slots, separators=(',', ':')) if slots else None
|
||||
|
||||
if failsafe is not None:
|
||||
annotations[self._FAILSAFE] = json.dumps(failsafe, separators=(',', ':')) if failsafe else None
|
||||
|
||||
resource_version = kind and kind.metadata.resource_version
|
||||
ips = [] if access_is_restricted else self.__ips
|
||||
return self._update_leader_with_retry(annotations, resource_version, ips)
|
||||
return self._update_leader_with_retry(annotations, resource_version, self.__ips)
|
||||
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
now = datetime.datetime.now(tzutc).isoformat()
|
||||
@@ -966,7 +1098,19 @@ class Kubernetes(AbstractDCS):
|
||||
annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
|
||||
annotations['transitions'] = str(transitions)
|
||||
ips = [] if self._api.use_endpoints else None
|
||||
ret = self.patch_or_create(self.leader_path, annotations, self._leader_resource_version, ips=ips)
|
||||
|
||||
try:
|
||||
ret = self._patch_or_create(self.leader_path, annotations,
|
||||
self._leader_resource_version, retry=self.retry, ips=ips)
|
||||
except k8s_client.rest.ApiException as e:
|
||||
if e.status == 409 and self._leader_resource_version: # Conflict in resource_version
|
||||
# Terminate watchers, it could be a sign that K8s API is in a failed state
|
||||
self._kinds.kill_stream()
|
||||
self._pods.kill_stream()
|
||||
ret = False
|
||||
except (RetryFailedError, K8sException) as e:
|
||||
raise KubernetesError(e)
|
||||
|
||||
if not ret:
|
||||
logger.info('Could not take out TTL lock')
|
||||
return ret
|
||||
@@ -995,7 +1139,7 @@ class Kubernetes(AbstractDCS):
|
||||
def touch_member(self, data, permanent=False):
|
||||
cluster = self.cluster
|
||||
if cluster and cluster.leader and cluster.leader.name == self._name:
|
||||
role = 'promoted' if data['role'] in ('replica', 'promoted') else 'master'
|
||||
role = 'master'
|
||||
elif data['state'] == 'running' and data['role'] != 'master':
|
||||
role = data['role']
|
||||
else:
|
||||
@@ -1024,17 +1168,17 @@ class Kubernetes(AbstractDCS):
|
||||
def _delete_leader(self):
|
||||
"""Unused"""
|
||||
|
||||
def delete_leader(self, last_operation=None):
|
||||
def delete_leader(self, last_lsn=None):
|
||||
kind = self._kinds.get(self.leader_path)
|
||||
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
|
||||
annotations = {self._LEADER: None}
|
||||
if last_operation:
|
||||
annotations[self._OPTIME] = last_operation
|
||||
if last_lsn:
|
||||
annotations[self._OPTIME] = str(last_lsn)
|
||||
self.patch_or_create(self.leader_path, annotations, kind.metadata.resource_version, True, False, [])
|
||||
self.reset_cluster()
|
||||
|
||||
def cancel_initialization(self):
|
||||
self.patch_or_create_config({self._INITIALIZE: None}, self._config_resource_version, True)
|
||||
return self.patch_or_create_config({self._INITIALIZE: None}, None, True)
|
||||
|
||||
@catch_kubernetes_errors
|
||||
def delete_cluster(self):
|
||||
|
||||
+167
-143
@@ -4,130 +4,129 @@ import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from ..utils import validate_directory
|
||||
from pysyncobj import SyncObj, SyncObjConf, replicated, FAIL_REASON
|
||||
from pysyncobj.transport import Node, TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.dns_resolver import globalDnsResolver
|
||||
from pysyncobj.node import TCPNode
|
||||
from pysyncobj.transport import TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.utility import TcpUtility
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import validate_directory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageNode(Node):
|
||||
|
||||
def __init__(self, address):
|
||||
self.address = address
|
||||
class RaftError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
class UtilityTransport(TCPTransport):
|
||||
class _TCPTransport(TCPTransport):
|
||||
|
||||
def __init__(self, syncObj, selfNode, otherNodes):
|
||||
super(UtilityTransport, self).__init__(syncObj, selfNode, otherNodes)
|
||||
self._selfIsReadonlyNode = False
|
||||
super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes)
|
||||
self.setOnUtilityMessageCallback('members', syncObj.getMembers)
|
||||
|
||||
def _connectIfNecessarySingle(self, node):
|
||||
pass
|
||||
|
||||
def connectionState(self, node):
|
||||
return self._connections[node].state
|
||||
|
||||
def isDisconnected(self, node):
|
||||
return self.connectionState(node) == CONNECTION_STATE.DISCONNECTED
|
||||
|
||||
def connectIfRequiredSingle(self, node):
|
||||
if self.isDisconnected(node):
|
||||
return self._connections[node].connect(node.ip, node.port)
|
||||
|
||||
def disconnectSingle(self, node):
|
||||
self._connections[node].disconnect()
|
||||
try:
|
||||
return super(_TCPTransport, self)._connectIfNecessarySingle(node)
|
||||
except Exception as e:
|
||||
logger.debug('Connection to %s failed: %r', node, e)
|
||||
return False
|
||||
|
||||
|
||||
class SyncObjUtility(SyncObj):
|
||||
|
||||
def __init__(self, otherNodes, conf):
|
||||
autoTick = conf.autoTick
|
||||
conf.autoTick = False
|
||||
super(SyncObjUtility, self).__init__(None, otherNodes, conf, transportClass=UtilityTransport)
|
||||
conf.autoTick = autoTick
|
||||
self._SyncObj__transport.setOnMessageReceivedCallback(self._onMessageReceived)
|
||||
self.__result = None
|
||||
|
||||
def setPartnerNode(self, partner):
|
||||
self.__node = partner
|
||||
|
||||
def sendMessage(self, message):
|
||||
# Abuse the fact that node address is send as a first message
|
||||
self._SyncObj__transport._selfNode = MessageNode(message)
|
||||
self._SyncObj__transport.connectIfRequiredSingle(self.__node)
|
||||
while not self._SyncObj__transport.isDisconnected(self.__node):
|
||||
self._poller.poll(0.5)
|
||||
return self.__result
|
||||
|
||||
def _onMessageReceived(self, _, message):
|
||||
self.__result = message
|
||||
self._SyncObj__transport.disconnectSingle(self.__node)
|
||||
def resolve_host(self):
|
||||
return globalDnsResolver().resolve(self.host)
|
||||
|
||||
|
||||
class MyTCPTransport(TCPTransport):
|
||||
setattr(TCPNode, 'ip', property(resolve_host))
|
||||
|
||||
def _onIncomingMessageReceived(self, conn, message):
|
||||
if self._syncObj.encryptor and not conn.sendRandKey:
|
||||
conn.sendRandKey = message
|
||||
conn.recvRandKey = os.urandom(32)
|
||||
conn.send(conn.recvRandKey)
|
||||
return
|
||||
|
||||
# Utility messages
|
||||
if isinstance(message, list) and message[0] == 'members':
|
||||
conn.send(self._syncObj._get_members())
|
||||
return True
|
||||
class SyncObjUtility(object):
|
||||
|
||||
return super(MyTCPTransport, self)._onIncomingMessageReceived(conn, message)
|
||||
def __init__(self, otherNodes, conf, retry_timeout=10):
|
||||
self._nodes = otherNodes
|
||||
self._utility = TcpUtility(conf.password, retry_timeout/max(1, len(otherNodes)))
|
||||
|
||||
def executeCommand(self, command):
|
||||
try:
|
||||
return self._utility.executeCommand(self.__node, command)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def getMembers(self):
|
||||
for self.__node in self._nodes:
|
||||
response = self.executeCommand(['members'])
|
||||
if response:
|
||||
return [member['addr'] for member in response]
|
||||
|
||||
|
||||
class DynMemberSyncObj(SyncObj):
|
||||
|
||||
def __init__(self, selfAddress, partnerAddrs, conf):
|
||||
add_self = False
|
||||
utility = SyncObjUtility(partnerAddrs, conf)
|
||||
for node in utility._SyncObj__otherNodes:
|
||||
utility.setPartnerNode(node)
|
||||
response = utility.sendMessage(['members'])
|
||||
if response:
|
||||
partnerAddrs = [member['addr'] for member in response if member['addr'] != selfAddress]
|
||||
add_self = selfAddress and len(partnerAddrs) == len(response)
|
||||
break
|
||||
def __init__(self, selfAddress, partnerAddrs, conf, retry_timeout=10):
|
||||
self.__early_apply_local_log = selfAddress is not None
|
||||
self.applied_local_log = False
|
||||
|
||||
utility = SyncObjUtility(partnerAddrs, conf, retry_timeout)
|
||||
members = utility.getMembers()
|
||||
add_self = members and selfAddress not in members
|
||||
|
||||
partnerAddrs = [member for member in (members or partnerAddrs) if member != selfAddress]
|
||||
|
||||
super(DynMemberSyncObj, self).__init__(selfAddress, partnerAddrs, conf, transportClass=_TCPTransport)
|
||||
|
||||
super(DynMemberSyncObj, self).__init__(selfAddress, partnerAddrs, conf, transportClass=MyTCPTransport)
|
||||
if add_self:
|
||||
threading.Thread(target=utility.sendMessage, args=(['add', selfAddress],)).start()
|
||||
thread = threading.Thread(target=utility.executeCommand, args=(['add', selfAddress],))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def _get_members(self):
|
||||
ret = [{'addr': node.id, 'leader': node == self._getLeader(),
|
||||
'status': CONNECTION_STATE.CONNECTED if node in self._SyncObj__connectedNodes
|
||||
else CONNECTION_STATE.DISCONNECTED} for node in self._SyncObj__otherNodes]
|
||||
ret.append({'addr': self._SyncObj__selfNode.id, 'leader': self._isLeader(),
|
||||
'status': CONNECTION_STATE.CONNECTED})
|
||||
return ret
|
||||
def getMembers(self, args, callback):
|
||||
callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED
|
||||
if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes] +
|
||||
[{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None)
|
||||
|
||||
def _SyncObj__doChangeCluster(self, request, reverse=False):
|
||||
ret = False
|
||||
if not self._SyncObj__selfNode or request[0] != 'add' or reverse or request[1] != self._SyncObj__selfNode.id:
|
||||
ret = super(DynMemberSyncObj, self)._SyncObj__doChangeCluster(request, reverse)
|
||||
if ret:
|
||||
self.forceLogCompaction()
|
||||
return ret
|
||||
def _onTick(self, timeToWait=0.0):
|
||||
super(DynMemberSyncObj, self)._onTick(timeToWait)
|
||||
|
||||
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
|
||||
# In some cases for us it is safe to "signal" the Raft object when the local log is fully applied.
|
||||
# We are using the `applied_local_log` property for that, but not calling the callback function.
|
||||
if self.__early_apply_local_log and not self.applied_local_log and self.raftLastApplied == self.raftCommitIndex:
|
||||
self.applied_local_log = True
|
||||
|
||||
|
||||
class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
def __init__(self, selfAddress, partnerAddrs, conf, on_set=None, on_delete=None):
|
||||
def __init__(self, on_ready, on_set, on_delete, **config):
|
||||
self.__thread = None
|
||||
self.__on_set = on_set
|
||||
self.__on_delete = on_delete
|
||||
self.__limb = {}
|
||||
self.__retry_timeout = None
|
||||
self.__early_apply_local_log = selfAddress is not None
|
||||
self.applied_local_log = False
|
||||
super(KVStoreTTL, self).__init__(selfAddress, partnerAddrs, conf)
|
||||
self.set_retry_timeout(int(config.get('retry_timeout') or 10))
|
||||
|
||||
self_addr = config.get('self_addr')
|
||||
partner_addrs = set(config.get('partner_addrs', []))
|
||||
if config.get('patronictl'):
|
||||
if self_addr:
|
||||
partner_addrs.add(self_addr)
|
||||
self_addr = None
|
||||
|
||||
# Create raft data_dir if necessary
|
||||
raft_data_dir = config.get('data_dir', '')
|
||||
if raft_data_dir != '':
|
||||
validate_directory(raft_data_dir)
|
||||
|
||||
file_template = (self_addr or '')
|
||||
file_template = file_template.replace(':', '_') if os.name == 'nt' else file_template
|
||||
file_template = os.path.join(raft_data_dir, file_template)
|
||||
conf = SyncObjConf(password=config.get('password'), autoTick=False, appendEntriesUseBatch=False,
|
||||
bindAddress=config.get('bind_addr'), dnsFailCacheTime=(config.get('loop_wait') or 10),
|
||||
dnsCacheTime=(config.get('ttl') or 30), commandsWaitLeader=config.get('commandsWaitLeader'),
|
||||
fullDumpFile=(file_template + '.dump' if self_addr else None),
|
||||
journalFile=(file_template + '.journal' if self_addr else None),
|
||||
onReady=on_ready, dynamicMembershipChange=True)
|
||||
|
||||
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf, self.__retry_timeout)
|
||||
self.__data = {}
|
||||
|
||||
@staticmethod
|
||||
@@ -162,7 +161,7 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
elif deadline:
|
||||
timeout = deadline - time.time()
|
||||
if timeout <= 0:
|
||||
break
|
||||
raise RaftError('timeout')
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
@@ -174,14 +173,14 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
if old_value and old_value['created'] != value['created']:
|
||||
value['created'] = value['updated']
|
||||
value['index'] = self._SyncObj__raftLastApplied + 1
|
||||
value['index'] = self.raftLastApplied + 1
|
||||
|
||||
self.__data[key] = value
|
||||
if self.__on_set:
|
||||
self.__on_set(key, value)
|
||||
return True
|
||||
|
||||
def set(self, key, value, ttl=None, **kwargs):
|
||||
def set(self, key, value, ttl=None, handle_raft_error=True, **kwargs):
|
||||
old_value = self.__data.get(key, {})
|
||||
if not self.__check_requirements(old_value, **kwargs):
|
||||
return False
|
||||
@@ -190,7 +189,12 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
value['created'] = old_value.get('created', value['updated'])
|
||||
if ttl:
|
||||
value['expire'] = value['updated'] + ttl
|
||||
return self.retry(self._set, key, value, **kwargs)
|
||||
try:
|
||||
return self.retry(self._set, key, value, **kwargs)
|
||||
except RaftError:
|
||||
if not handle_raft_error:
|
||||
raise
|
||||
return False
|
||||
|
||||
def __pop(self, key):
|
||||
self.__data.pop(key)
|
||||
@@ -212,7 +216,10 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
def delete(self, key, recursive=False, **kwargs):
|
||||
if not recursive and not self.__check_requirements(self.__data.get(key, {}), **kwargs):
|
||||
return False
|
||||
return self.retry(self._delete, key, recursive=recursive, **kwargs)
|
||||
try:
|
||||
return self.retry(self._delete, key, recursive=recursive, **kwargs)
|
||||
except RaftError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def __values_match(old, new):
|
||||
@@ -241,27 +248,29 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
return {k: v for k, v in self.__data.items() if k.startswith(key)}
|
||||
|
||||
def _onTick(self, timeToWait=0.0):
|
||||
# The SyncObj starts applying the local log only when there is at least one node connected.
|
||||
# We want to change this behavior and apply the local log even when there is nobody except us.
|
||||
# It gives us at least some picture about the last known cluster state.
|
||||
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__needLoadDumpFile:
|
||||
self._SyncObj__raftCommitIndex = self._SyncObj__getCurrentLogIndex()
|
||||
self._SyncObj__raftCurrentTerm = self._SyncObj__getCurrentLogTerm()
|
||||
|
||||
super(KVStoreTTL, self)._onTick(timeToWait)
|
||||
|
||||
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
|
||||
# In some cases for us it is safe to "signal" the Raft object when the local log is fully applied.
|
||||
# We are using the `applied_local_log` property for that, but not calling the callback function.
|
||||
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__raftCommitIndex != 1 and \
|
||||
self._SyncObj__raftLastApplied == self._SyncObj__raftCommitIndex:
|
||||
self.applied_local_log = True
|
||||
|
||||
if self._isLeader():
|
||||
self.__expire_keys()
|
||||
else:
|
||||
self.__limb.clear()
|
||||
|
||||
def _autoTickThread(self):
|
||||
self.__destroying = False
|
||||
while not self.__destroying:
|
||||
self.doTick(self.conf.autoTickPeriod)
|
||||
|
||||
def startAutoTick(self):
|
||||
self.__thread = threading.Thread(target=self._autoTickThread)
|
||||
self.__thread.daemon = True
|
||||
self.__thread.start()
|
||||
|
||||
def destroy(self):
|
||||
if self.__thread:
|
||||
self.__destroying = True
|
||||
self.__thread.join()
|
||||
super(KVStoreTTL, self).destroy()
|
||||
|
||||
|
||||
class Raft(AbstractDCS):
|
||||
|
||||
@@ -269,41 +278,23 @@ class Raft(AbstractDCS):
|
||||
super(Raft, self).__init__(config)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
|
||||
self_addr = config.get('self_addr')
|
||||
partner_addrs = config.get('partner_addrs', [])
|
||||
if self._ctl:
|
||||
if self_addr:
|
||||
partner_addrs.append(self_addr)
|
||||
self_addr = None
|
||||
|
||||
# Create raft data_dir if necessary
|
||||
raft_data_dir = config.get('data_dir', '')
|
||||
if raft_data_dir != '':
|
||||
validate_directory(raft_data_dir)
|
||||
|
||||
ready_event = threading.Event()
|
||||
file_template = os.path.join(config.get('data_dir', ''), (self_addr or ''))
|
||||
conf = SyncObjConf(password=config.get('password'), appendEntriesUseBatch=False,
|
||||
bindAddress=config.get('bind_addr'), commandsWaitLeader=False,
|
||||
fullDumpFile=(file_template + '.dump' if self_addr else None),
|
||||
journalFile=(file_template + '.journal' if self_addr else None),
|
||||
onReady=ready_event.set, dynamicMembershipChange=True)
|
||||
self._sync_obj = KVStoreTTL(ready_event.set, self._on_set, self._on_delete, commandsWaitLeader=False, **config)
|
||||
self._sync_obj.startAutoTick()
|
||||
|
||||
self._sync_obj = KVStoreTTL(self_addr, partner_addrs, conf, self._on_set, self._on_delete)
|
||||
while True:
|
||||
ready_event.wait(5)
|
||||
if ready_event.isSet() or self._sync_obj.applied_local_log:
|
||||
if ready_event.is_set() or self._sync_obj.applied_local_log:
|
||||
break
|
||||
else:
|
||||
logger.info('waiting on raft')
|
||||
self._sync_obj.forceLogCompaction()
|
||||
self.set_retry_timeout(int(config.get('retry_timeout') or 10))
|
||||
|
||||
def _on_set(self, key, value):
|
||||
leader = (self._sync_obj.get(self.leader_path) or {}).get('value')
|
||||
if key == value['created'] == value['updated'] and \
|
||||
(key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \
|
||||
key == self.leader_optime_path and leader != self._name or key in (self.config_path, self.sync_path):
|
||||
key in (self.leader_optime_path, self.status_path) and leader != self._name or \
|
||||
key in (self.config_path, self.sync_path):
|
||||
self.event.set()
|
||||
|
||||
def _on_delete(self, key):
|
||||
@@ -320,6 +311,10 @@ class Raft(AbstractDCS):
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
self._sync_obj.set_retry_timeout(retry_timeout)
|
||||
|
||||
def reload_config(self, config):
|
||||
super(Raft, self).reload_config(config)
|
||||
globalDnsResolver().setTimeouts(self.ttl, self.loop_wait)
|
||||
|
||||
@staticmethod
|
||||
def member(key, value):
|
||||
return Member.from_node(value['index'], os.path.basename(key), None, value['value'])
|
||||
@@ -328,7 +323,7 @@ class Raft(AbstractDCS):
|
||||
prefix = self.client_path('')
|
||||
response = self._sync_obj.get(prefix, recursive=True)
|
||||
if not response:
|
||||
return Cluster(None, None, None, None, [], None, None, None)
|
||||
return Cluster(None, None, None, None, [], None, None, None, None, None)
|
||||
nodes = {os.path.relpath(key, prefix).replace('\\', '/'): value for key, value in response.items()}
|
||||
|
||||
# get initialize flag
|
||||
@@ -343,9 +338,24 @@ class Raft(AbstractDCS):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history['index'], history['value'])
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value'])
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -366,20 +376,34 @@ class Raft(AbstractDCS):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync['index'], sync and sync['value'])
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
# get failsafe topology
|
||||
failsafe = nodes.get(self._FAILSAFE)
|
||||
try:
|
||||
failsafe = json.loads(failsafe['value']) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._sync_obj.set(self.leader_optime_path, last_operation, timeout=1)
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1)
|
||||
|
||||
def _write_status(self, value):
|
||||
return self._sync_obj.set(self.status_path, value, timeout=1)
|
||||
|
||||
def _write_failsafe(self, value):
|
||||
return self._sync_obj.set(self.failsafe_path, value, timeout=1)
|
||||
|
||||
def _update_leader(self):
|
||||
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, prevValue=self._name)
|
||||
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl,
|
||||
handle_raft_error=False, prevValue=self._name)
|
||||
if not ret and self._sync_obj.get(self.leader_path) is None:
|
||||
ret = self.attempt_to_acquire_leader()
|
||||
return ret
|
||||
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
return self._sync_obj.set(self.leader_path, self._name, prevExist=False,
|
||||
ttl=None if permanent else self._ttl)
|
||||
return self._sync_obj.set(self.leader_path, self._name, ttl=None if permanent else self._ttl,
|
||||
handle_raft_error=False, prevExist=False)
|
||||
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self._sync_obj.set(self.failover_path, value, prevIndex=index)
|
||||
|
||||
+163
-62
@@ -1,14 +1,19 @@
|
||||
import json
|
||||
import logging
|
||||
import select
|
||||
import six
|
||||
import time
|
||||
|
||||
from kazoo.client import KazooClient, KazooState, KazooRetry
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
from kazoo.exceptions import ConnectionClosedError, NoNodeError, NodeExistsError, SessionExpiredError
|
||||
from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.utils import deep_compare
|
||||
from kazoo.protocol.states import KeeperState
|
||||
from kazoo.retry import RetryFailedError
|
||||
from kazoo.security import make_acl
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,11 +52,35 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
|
||||
return super(PatroniSequentialThreadingHandler, self).create_connection(*args, **kwargs)
|
||||
|
||||
def select(self, *args, **kwargs):
|
||||
"""Python3 raises `ValueError` if socket is closed, because fd == -1"""
|
||||
"""
|
||||
Python 3.XY may raise following exceptions if select/poll are called with an invalid socket:
|
||||
- `ValueError`: because fd == -1
|
||||
- `TypeError`: Invalid file descriptor: -1 (starting from kazoo 2.9)
|
||||
Python 2.7 may raise the `IOError` instead of `socket.error` (starting from kazoo 2.9)
|
||||
|
||||
When it is appropriate we map these exceptions to `socket.error`.
|
||||
"""
|
||||
|
||||
try:
|
||||
return super(PatroniSequentialThreadingHandler, self).select(*args, **kwargs)
|
||||
except ValueError as e:
|
||||
raise select.error(9, str(e))
|
||||
except IOError as e:
|
||||
raise (select.error(e.errno, e.strerror) if six.PY2 else e)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise (e if six.PY2 and isinstance(e, TypeError) else select.error(9, str(e)))
|
||||
|
||||
|
||||
class PatroniKazooClient(KazooClient):
|
||||
|
||||
def _call(self, request, async_object):
|
||||
# Before kazoo==2.7.0 it wasn't possible to send requests to zookeeper if
|
||||
# the connection is in the SUSPENDED state and Patroni was strongly relying on it.
|
||||
# The https://github.com/python-zk/kazoo/pull/588 changed it, and now such requests are queued.
|
||||
# We override the `_call()` method in order to keep the old behavior.
|
||||
|
||||
if self._state == KeeperState.CONNECTING:
|
||||
async_object.set_exception(SessionExpiredError())
|
||||
return False
|
||||
return super(PatroniKazooClient, self)._call(request, async_object)
|
||||
|
||||
|
||||
class ZooKeeper(AbstractDCS):
|
||||
@@ -67,14 +96,28 @@ class ZooKeeper(AbstractDCS):
|
||||
'cert': 'certfile', 'key': 'keyfile', 'key_password': 'keyfile_password'}
|
||||
kwargs = {v: config[k] for k, v in mapping.items() if k in config}
|
||||
|
||||
self._client = KazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
||||
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
sleep_func=time.sleep), command_retry=KazooRetry(deadline=config['retry_timeout'],
|
||||
max_delay=1, max_tries=-1, sleep_func=time.sleep), **kwargs)
|
||||
if 'set_acls' in config:
|
||||
kwargs['default_acl'] = []
|
||||
for principal, permissions in config['set_acls'].items():
|
||||
normalizedPermissions = [p.upper() for p in permissions]
|
||||
kwargs['default_acl'].append(make_acl(scheme='x509',
|
||||
credential=principal,
|
||||
read='READ' in normalizedPermissions,
|
||||
write='WRITE' in normalizedPermissions,
|
||||
create='CREATE' in normalizedPermissions,
|
||||
delete='DELETE' in normalizedPermissions,
|
||||
admin='ADMIN' in normalizedPermissions,
|
||||
all='ALL' in normalizedPermissions))
|
||||
|
||||
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
||||
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
|
||||
self._client.add_listener(self.session_listener)
|
||||
|
||||
self._fetch_cluster = True
|
||||
self._fetch_optime = True
|
||||
self._fetch_status = True
|
||||
self.__last_member_data = None
|
||||
|
||||
self._orig_kazoo_connect = self._client._connection._connect
|
||||
self._client._connection._connect = self._kazoo_connect
|
||||
@@ -100,13 +143,13 @@ class ZooKeeper(AbstractDCS):
|
||||
if state in [KazooState.SUSPENDED, KazooState.LOST]:
|
||||
self.cluster_watcher(None)
|
||||
|
||||
def optime_watcher(self, event):
|
||||
self._fetch_optime = True
|
||||
def status_watcher(self, event):
|
||||
self._fetch_status = True
|
||||
self.event.set()
|
||||
|
||||
def cluster_watcher(self, event):
|
||||
self._fetch_cluster = True
|
||||
self.optime_watcher(event)
|
||||
self.status_watcher(event)
|
||||
|
||||
def reload_config(self, config):
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
@@ -151,11 +194,29 @@ class ZooKeeper(AbstractDCS):
|
||||
except NoNodeError:
|
||||
return None
|
||||
|
||||
def get_leader_optime(self, leader):
|
||||
watch = self.optime_watcher if not leader or leader.name != self._name else None
|
||||
optime = self.get_node(self.leader_optime_path, watch)
|
||||
self._fetch_optime = False
|
||||
return optime and int(optime[0]) or 0
|
||||
def get_status(self, leader):
|
||||
watch = self.status_watcher if not leader or leader.name != self._name else None
|
||||
|
||||
status = self.get_node(self.status_path, watch)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status[0])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = self.get_node(self.leader_optime_path, watch)
|
||||
last_lsn = last_lsn and last_lsn[0]
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
self._fetch_status = False
|
||||
return last_lsn, slots
|
||||
|
||||
@staticmethod
|
||||
def member(name, value, znode):
|
||||
@@ -167,11 +228,10 @@ class ZooKeeper(AbstractDCS):
|
||||
except NoNodeError:
|
||||
return []
|
||||
|
||||
def load_members(self, sync_standby):
|
||||
def load_members(self):
|
||||
members = []
|
||||
for member in self.get_children(self.members_path, self.cluster_watcher):
|
||||
watch = member in sync_standby and self.cluster_watcher or None
|
||||
data = self.get_node(self.members_path + member, watch)
|
||||
data = self.get_node(self.members_path + member)
|
||||
if data is not None:
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
@@ -199,33 +259,31 @@ class ZooKeeper(AbstractDCS):
|
||||
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
|
||||
|
||||
# get list of members
|
||||
sync_standby = sync.leader == self._name and sync.members or []
|
||||
members = self.load_members(sync_standby) if self._MEMBERS[:-1] in nodes else []
|
||||
members = self.load_members() if self._MEMBERS[:-1] in nodes else []
|
||||
|
||||
# get leader
|
||||
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
|
||||
if leader:
|
||||
client_id = self._client.client_id
|
||||
if not self._ctl and leader[0] == self._name and client_id is not None \
|
||||
and client_id[0] != leader[1].ephemeralOwner:
|
||||
logger.info('I am leader but not owner of the session. Removing leader node')
|
||||
self._client.delete(self.leader_path)
|
||||
leader = None
|
||||
member = Member(-1, leader[0], None, {})
|
||||
member = ([m for m in members if m.name == leader[0]] or [member])[0]
|
||||
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||
self._fetch_cluster = member.index == -1
|
||||
|
||||
if leader:
|
||||
member = Member(-1, leader[0], None, {})
|
||||
member = ([m for m in members if m.name == leader[0]] or [member])[0]
|
||||
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||
self._fetch_cluster = member.index == -1
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = self._OPTIME in nodes and self.get_leader_optime(leader)
|
||||
# get last known leader lsn and slots
|
||||
last_lsn, slots = self.get_status(leader)
|
||||
|
||||
# failover key
|
||||
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
|
||||
failover = failover and Failover.from_node(failover[1].version, failover[0])
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
# get failsafe topology
|
||||
failsafe = self.get_node(self.failsafe_path, watch=self.cluster_watcher) if self._FAILSAFE in nodes else None
|
||||
try:
|
||||
failsafe = json.loads(failsafe[0]) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
|
||||
def _load_cluster(self):
|
||||
cluster = self.cluster
|
||||
@@ -236,15 +294,20 @@ class ZooKeeper(AbstractDCS):
|
||||
logger.exception('get_cluster')
|
||||
self.cluster_watcher(None)
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
# Optime ZNode was updated or doesn't exist and we are not leader
|
||||
elif (self._fetch_optime and not self._fetch_cluster or not cluster.last_leader_operation) and\
|
||||
not (cluster.leader and cluster.leader.name == self._name):
|
||||
try:
|
||||
optime = self.get_leader_optime(cluster.leader)
|
||||
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, optime,
|
||||
cluster.members, cluster.failover, cluster.sync, cluster.history)
|
||||
except Exception:
|
||||
pass
|
||||
# The /status ZNode was updated or doesn't exist
|
||||
elif self._fetch_status and not self._fetch_cluster or not cluster.last_lsn \
|
||||
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots:
|
||||
# If current node is the leader just clear the event without fetching anything (we are updating the /status)
|
||||
if cluster.leader and cluster.leader.name == self._name:
|
||||
self.event.clear()
|
||||
else:
|
||||
try:
|
||||
last_lsn, slots = self.get_status(cluster.leader)
|
||||
self.event.clear()
|
||||
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, last_lsn, cluster.members,
|
||||
cluster.failover, cluster.sync, cluster.history, slots, cluster.failsafe)
|
||||
except Exception:
|
||||
pass
|
||||
return cluster
|
||||
|
||||
def _bypass_caches(self):
|
||||
@@ -262,10 +325,17 @@ class ZooKeeper(AbstractDCS):
|
||||
return False
|
||||
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
ret = self._create(self.leader_path, self._name.encode('utf-8'), retry=True, ephemeral=not permanent)
|
||||
if not ret:
|
||||
logger.info('Could not take out TTL lock')
|
||||
return ret
|
||||
try:
|
||||
self._client.retry(self._client.create, self.leader_path, self._name.encode('utf-8'),
|
||||
makepath=True, ephemeral=not permanent)
|
||||
return True
|
||||
except (ConnectionClosedError, RetryFailedError) as e:
|
||||
raise ZooKeeperError(e)
|
||||
except Exception as e:
|
||||
if not isinstance(e, NodeExistsError):
|
||||
logger.error('Failed to create %s: %r', self.leader_path, e)
|
||||
logger.info('Could not take out TTL lock')
|
||||
return False
|
||||
|
||||
def _set_or_create(self, key, value, index=None, retry=False, do_not_create_empty=False):
|
||||
value = value.encode('utf-8')
|
||||
@@ -300,11 +370,11 @@ class ZooKeeper(AbstractDCS):
|
||||
def touch_member(self, data, permanent=False):
|
||||
cluster = self.cluster
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
|
||||
member_data = self.__last_member_data or member and member.data
|
||||
if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or
|
||||
not (deep_compare(member.data.get('tags', {}), data.get('tags', {})) and
|
||||
member.data.get('version') == data.get('version') and
|
||||
member.data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))):
|
||||
not (deep_compare(member_data.get('tags', {}), data.get('tags', {})) and
|
||||
member_data.get('version') == data.get('version') and
|
||||
member_data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))):
|
||||
try:
|
||||
self._client.delete_async(self.member_path).get(timeout=1)
|
||||
except NoNodeError:
|
||||
@@ -313,13 +383,15 @@ class ZooKeeper(AbstractDCS):
|
||||
return False
|
||||
member = None
|
||||
|
||||
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
|
||||
if member:
|
||||
if deep_compare(data, member.data):
|
||||
if deep_compare(data, member_data):
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
self._client.create_async(self.member_path, encoded_data, makepath=True,
|
||||
ephemeral=not permanent).get(timeout=1)
|
||||
self.__last_member_data = data
|
||||
return True
|
||||
except Exception as e:
|
||||
if not isinstance(e, NodeExistsError):
|
||||
@@ -327,6 +399,7 @@ class ZooKeeper(AbstractDCS):
|
||||
return False
|
||||
try:
|
||||
self._client.set_async(self.member_path, encoded_data).get(timeout=1)
|
||||
self.__last_member_data = data
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('touch_member')
|
||||
@@ -336,10 +409,38 @@ class ZooKeeper(AbstractDCS):
|
||||
def take_leader(self):
|
||||
return self.attempt_to_acquire_leader()
|
||||
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._set_or_create(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._set_or_create(self.leader_optime_path, last_lsn)
|
||||
|
||||
def _write_status(self, value):
|
||||
return self._set_or_create(self.status_path, value)
|
||||
|
||||
def _write_failsafe(self, value):
|
||||
return self._set_or_create(self.failsafe_path, value)
|
||||
|
||||
def _update_leader(self):
|
||||
cluster = self.cluster
|
||||
session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
|
||||
if self._client.client_id and self._client.client_id[0] != session:
|
||||
logger.warning('Recreating the leader ZNode due to ownership mismatch')
|
||||
try:
|
||||
self._client.retry(self._client.delete, self.leader_path)
|
||||
except NoNodeError:
|
||||
pass
|
||||
except (ConnectionClosedError, RetryFailedError) as e:
|
||||
raise ZooKeeperError(e)
|
||||
except Exception as e:
|
||||
logger.error('Failed to remove %s: %r', self.leader_path, e)
|
||||
return False
|
||||
|
||||
try:
|
||||
self._client.retry(self._client.create, self.leader_path,
|
||||
self._name.encode('utf-8'), makepath=True, ephemeral=True)
|
||||
except (ConnectionClosedError, RetryFailedError) as e:
|
||||
raise ZooKeeperError(e)
|
||||
except Exception as e:
|
||||
logger.error('Failed to create %s: %r', self.leader_path, e)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _delete_leader(self):
|
||||
@@ -373,7 +474,7 @@ class ZooKeeper(AbstractDCS):
|
||||
return self.set_sync_state_value("{}", index)
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
ret = super(ZooKeeper, self).watch(leader_index, timeout)
|
||||
if ret and not self._fetch_optime:
|
||||
ret = super(ZooKeeper, self).watch(leader_index, timeout + 0.5)
|
||||
if ret and not self._fetch_status:
|
||||
self._fetch_cluster = True
|
||||
return ret or self._fetch_cluster
|
||||
|
||||
+223
-105
@@ -2,32 +2,36 @@ import datetime
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import psycopg2
|
||||
import six
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from collections import namedtuple
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from patroni.async_executor import AsyncExecutor, CriticalTask
|
||||
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniFatalException
|
||||
from patroni.postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
|
||||
from patroni.postgresql.misc import postgres_version_to_int
|
||||
from patroni.postgresql.rewind import Rewind
|
||||
from patroni.utils import polling_loop, tzutc, is_standby_cluster as _is_standby_cluster, parse_int
|
||||
from patroni.dcs import RemoteMember
|
||||
from threading import RLock
|
||||
|
||||
from . import psycopg
|
||||
from .async_executor import AsyncExecutor, CriticalTask
|
||||
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
|
||||
from .postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
from .postgresql.rewind import Rewind
|
||||
from .utils import polling_loop, tzutc, is_standby_cluster as _is_standby_cluster, parse_int
|
||||
from .dcs import RemoteMember
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_recovery', 'timeline',
|
||||
'wal_position', 'tags', 'watchdog_failed'])):
|
||||
class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_recovery',
|
||||
'dcs_last_seen', 'timeline', 'wal_position',
|
||||
'tags', 'watchdog_failed'])):
|
||||
"""Node status distilled from API response:
|
||||
|
||||
member - dcs.Member object of the node
|
||||
reachable - `!False` if the node is not reachable or is not responding with correct JSON
|
||||
in_recovery - `!True` if pg_is_in_recovery() == true
|
||||
dcs_last_seen - timestamp from JSON of last succesful communication with DCS
|
||||
timeline - timeline value from JSON
|
||||
wal_position - maximum value of `replayed_location` or `received_location` from JSON
|
||||
tags - dictionary with values of different tags (i.e. nofailover)
|
||||
@@ -35,14 +39,25 @@ class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_reco
|
||||
"""
|
||||
@classmethod
|
||||
def from_api_response(cls, member, json):
|
||||
is_master = json['role'] == 'master'
|
||||
"""
|
||||
:param member: dcs.Member object
|
||||
:param json: RestApiHandler.get_postgresql_status() result
|
||||
:returns: _MemberStatus object
|
||||
"""
|
||||
# If one of those is not in a response we want to count the node as not healthy/reachable
|
||||
assert 'wal' in json or 'xlog' in json
|
||||
|
||||
wal = json.get('wal', json.get('xlog'))
|
||||
in_recovery = not bool(wal.get('location')) # abuse difference in primary/replica response format
|
||||
timeline = json.get('timeline', 0)
|
||||
wal = not is_master and max(json['xlog'].get('received_location', 0), json['xlog'].get('replayed_location', 0))
|
||||
return cls(member, True, not is_master, timeline, wal, json.get('tags', {}), json.get('watchdog_failed', False))
|
||||
dcs_last_seen = json.get('dcs_last_seen', 0)
|
||||
wal = in_recovery and max(wal.get('received_location', 0), wal.get('replayed_location', 0))
|
||||
return cls(member, True, in_recovery, dcs_last_seen, timeline, wal,
|
||||
json.get('tags', {}), json.get('watchdog_failed', False))
|
||||
|
||||
@classmethod
|
||||
def unknown(cls, member):
|
||||
return cls(member, False, None, 0, 0, {}, False)
|
||||
return cls(member, False, None, 0, 0, 0, {}, False)
|
||||
|
||||
def failover_limitation(self):
|
||||
"""Returns reason why this node can't promote or None if everything is ok."""
|
||||
@@ -66,7 +81,6 @@ class Ha(object):
|
||||
self.old_cluster = None
|
||||
self._is_leader = False
|
||||
self._is_leader_lock = RLock()
|
||||
self._leader_access_is_restricted = False
|
||||
self._was_paused = False
|
||||
self._leader_timeline = None
|
||||
self.recovering = False
|
||||
@@ -85,7 +99,7 @@ class Ha(object):
|
||||
self._disable_sync = 0
|
||||
|
||||
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
|
||||
# already running as replica was aborted due to cluster not beeing initialized in DCS.
|
||||
# already running as replica was aborted due to cluster not being initialized in DCS.
|
||||
self._join_aborted = False
|
||||
|
||||
# used only in backoff after failing a pre_promote script
|
||||
@@ -121,16 +135,12 @@ class Ha(object):
|
||||
|
||||
def is_leader(self):
|
||||
with self._is_leader_lock:
|
||||
return self._is_leader > time.time() and not self._leader_access_is_restricted
|
||||
return self._is_leader > time.time()
|
||||
|
||||
def set_is_leader(self, value):
|
||||
with self._is_leader_lock:
|
||||
self._is_leader = time.time() + self.dcs.ttl if value else 0
|
||||
|
||||
def set_leader_access_is_restricted(self, value):
|
||||
with self._is_leader_lock:
|
||||
self._leader_access_is_restricted = value
|
||||
|
||||
def load_cluster_from_dcs(self):
|
||||
cluster = self.dcs.get_cluster()
|
||||
|
||||
@@ -145,20 +155,28 @@ class Ha(object):
|
||||
self._leader_timeline = None if cluster.is_unlocked() else cluster.leader.timeline
|
||||
|
||||
def acquire_lock(self):
|
||||
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
|
||||
ret = self.dcs.attempt_to_acquire_leader()
|
||||
try:
|
||||
ret = self.dcs.attempt_to_acquire_leader()
|
||||
except DCSError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Unexpected exception raised from attempt_to_acquire_leader, please report it as a BUG')
|
||||
ret = False
|
||||
self.set_is_leader(ret)
|
||||
return ret
|
||||
|
||||
def update_lock(self, write_leader_optime=False):
|
||||
last_operation = None
|
||||
last_lsn = slots = None
|
||||
if write_leader_optime:
|
||||
try:
|
||||
last_operation = self.state_handler.last_operation()
|
||||
last_lsn = self.state_handler.last_operation()
|
||||
slots = self.state_handler.slots()
|
||||
except Exception:
|
||||
logger.exception('Exception when called state_handler.last_operation()')
|
||||
try:
|
||||
ret = self.dcs.update_leader(last_operation, self._leader_access_is_restricted)
|
||||
ret = self.dcs.update_leader(last_lsn, slots)
|
||||
except DCSError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Unexpected exception raised from update_leader, please report it as a BUG')
|
||||
ret = False
|
||||
@@ -191,9 +209,10 @@ class Ha(object):
|
||||
'version': self.patroni.version
|
||||
}
|
||||
|
||||
# following two lines are mainly necessary for consul, to avoid creation of master service
|
||||
if data['role'] == 'master' and not self.is_leader():
|
||||
data['role'] = 'promoted'
|
||||
proxy_url = self.state_handler.proxy_url
|
||||
if proxy_url:
|
||||
data['proxy_url'] = proxy_url
|
||||
|
||||
if self.is_leader() and not self._rewind.checkpoint_after_promote():
|
||||
data['checkpoint_after_promote'] = False
|
||||
tags = self.get_effective_tags()
|
||||
@@ -243,7 +262,7 @@ class Ha(object):
|
||||
logger.info('bootstrapped %s', msg)
|
||||
cluster = self.dcs.get_cluster()
|
||||
node_to_follow = self._get_node_to_follow(cluster)
|
||||
return self.state_handler.follow(node_to_follow)
|
||||
return self.state_handler.follow(node_to_follow) is not False
|
||||
else:
|
||||
logger.error('failed to bootstrap %s', msg)
|
||||
self.state_handler.remove_data_directory()
|
||||
@@ -275,7 +294,9 @@ class Ha(object):
|
||||
else:
|
||||
create_replica_methods = self.get_standby_cluster_config().get('create_replica_methods', []) \
|
||||
if self.is_standby_cluster() else None
|
||||
if self.state_handler.can_create_replica_without_replication_connection(create_replica_methods):
|
||||
can_bootstrap = self.state_handler.can_create_replica_without_replication_connection(create_replica_methods)
|
||||
concurrent_bootstrap = self.cluster.initialize == ""
|
||||
if can_bootstrap and not concurrent_bootstrap:
|
||||
msg = 'bootstrap (without leader)'
|
||||
return self._async_executor.try_run_async(msg, self.clone) or 'trying to ' + msg
|
||||
return 'waiting for {0}leader to bootstrap'.format('standby_' if self.is_standby_cluster() else '')
|
||||
@@ -295,17 +316,30 @@ class Ha(object):
|
||||
|
||||
return result
|
||||
|
||||
def _handle_crash_recovery(self):
|
||||
if not self._crash_recovery_executed and (self.cluster.is_unlocked() or self._rewind.can_rewind):
|
||||
self._crash_recovery_executed = True
|
||||
self._crash_recovery_started = time.time()
|
||||
msg = 'doing crash recovery in a single user mode'
|
||||
return self._async_executor.try_run_async(msg, self._rewind.ensure_clean_shutdown) or msg
|
||||
|
||||
def _handle_rewind_or_reinitialize(self):
|
||||
leader = self.get_remote_master() if self.is_standby_cluster() else self.cluster.leader
|
||||
if not self._rewind.rewind_or_reinitialize_needed_and_possible(leader):
|
||||
return None
|
||||
|
||||
if self._rewind.can_rewind:
|
||||
# rewind is required, but postgres wasn't shut down cleanly.
|
||||
if not self.state_handler.is_running() and \
|
||||
self.state_handler.controldata().get('Database cluster state') == 'in archive recovery':
|
||||
msg = self._handle_crash_recovery()
|
||||
if msg:
|
||||
return msg
|
||||
|
||||
msg = 'running pg_rewind from ' + leader.name
|
||||
return self._async_executor.try_run_async(msg, self._rewind.execute, args=(leader,)) or msg
|
||||
|
||||
# remove_data_directory_on_diverged_timelines is set
|
||||
if not self.is_standby_cluster():
|
||||
if self._rewind.should_remove_data_directory_on_diverged_timelines and not self.is_standby_cluster():
|
||||
msg = 'reinitializing due to diverged timelines'
|
||||
return self._async_executor.try_run_async(msg, self._do_reinitialize, args=(self.cluster,)) or msg
|
||||
|
||||
@@ -318,10 +352,7 @@ class Ha(object):
|
||||
if timeout == 0:
|
||||
# We are requested to prefer failing over to restarting master. But see first if there
|
||||
# is anyone to fail over to.
|
||||
members = self.cluster.members
|
||||
if self.is_synchronous_mode():
|
||||
members = [m for m in members if self.cluster.sync.matches(m.name)]
|
||||
if self.is_failover_possible(members):
|
||||
if self.is_failover_possible(self.cluster.members):
|
||||
logger.info("Master crashed. Failing over.")
|
||||
self.demote('immediate')
|
||||
return 'stopped PostgreSQL to fail over after a crash'
|
||||
@@ -330,13 +361,10 @@ class Ha(object):
|
||||
|
||||
data = self.state_handler.controldata()
|
||||
logger.info('pg_controldata:\n%s\n', '\n'.join(' {0}: {1}'.format(k, v) for k, v in data.items()))
|
||||
if data.get('Database cluster state') in ('in production', 'shutting down', 'in crash recovery') \
|
||||
and not self._crash_recovery_executed and \
|
||||
(self.cluster.is_unlocked() or self._rewind.can_rewind):
|
||||
self._crash_recovery_executed = True
|
||||
self._crash_recovery_started = time.time()
|
||||
msg = 'doing crash recovery in a single user mode'
|
||||
return self._async_executor.try_run_async(msg, self._rewind.ensure_clean_shutdown) or msg
|
||||
if data.get('Database cluster state') in ('in production', 'shutting down', 'in crash recovery'):
|
||||
msg = self._handle_crash_recovery()
|
||||
if msg:
|
||||
return msg
|
||||
|
||||
self.load_cluster_from_dcs()
|
||||
|
||||
@@ -409,7 +437,7 @@ class Ha(object):
|
||||
self.state_handler.set_role('replica')
|
||||
|
||||
if not node_to_follow:
|
||||
return 'no action'
|
||||
return 'no action. I am ({0})'.format(self.state_handler.name)
|
||||
elif is_leader:
|
||||
self.demote('immediate-nolock')
|
||||
return demote_reason
|
||||
@@ -418,9 +446,13 @@ class Ha(object):
|
||||
self.state_handler.get_history(self._leader_timeline + 1):
|
||||
self._rewind.trigger_check_diverged_lsn()
|
||||
|
||||
msg = self._handle_rewind_or_reinitialize()
|
||||
if msg:
|
||||
return msg
|
||||
if not self.state_handler.is_starting():
|
||||
msg = self._handle_rewind_or_reinitialize()
|
||||
if msg:
|
||||
return msg
|
||||
|
||||
if not self.is_paused():
|
||||
self.state_handler.handle_parameter_change()
|
||||
|
||||
role = 'standby_leader' if isinstance(node_to_follow, RemoteMember) and self.has_lock(False) else 'replica'
|
||||
# It might happen that leader key in the standby cluster references non-exiting member.
|
||||
@@ -553,7 +585,7 @@ class Ha(object):
|
||||
if master_timeline == 1:
|
||||
if cluster_history:
|
||||
self.dcs.set_history_value('[]')
|
||||
elif not cluster_history or cluster_history[-1][0] != master_timeline - 1 or len(cluster_history[-1]) != 4:
|
||||
elif not cluster_history or cluster_history[-1][0] != master_timeline - 1 or len(cluster_history[-1]) != 5:
|
||||
cluster_history = {line[0]: line for line in cluster_history or []}
|
||||
history = self.state_handler.get_history(master_timeline)
|
||||
if history and self.cluster.config:
|
||||
@@ -561,9 +593,11 @@ class Ha(object):
|
||||
for line in history:
|
||||
# enrich current history with promotion timestamps stored in DCS
|
||||
if len(line) == 3 and line[0] in cluster_history \
|
||||
and len(cluster_history[line[0]]) == 4 \
|
||||
and len(cluster_history[line[0]]) >= 4 \
|
||||
and cluster_history[line[0]][1] == line[1]:
|
||||
line.append(cluster_history[line[0]][3])
|
||||
if len(cluster_history[line[0]]) == 5:
|
||||
line.append(cluster_history[line[0]][4])
|
||||
self.dcs.set_history_value(json.dumps(history, separators=(',', ':')))
|
||||
|
||||
def enforce_follow_remote_master(self, message):
|
||||
@@ -613,8 +647,6 @@ class Ha(object):
|
||||
return 'Postponing promotion because synchronous replication state was updated by somebody else'
|
||||
self.state_handler.config.set_synchronous_standby(['*'] if self.is_synchronous_mode_strict() else [])
|
||||
if self.state_handler.role != 'master':
|
||||
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
|
||||
|
||||
def on_success():
|
||||
self._rewind.reset_state()
|
||||
logger.info("cleared rewind state after becoming the leader")
|
||||
@@ -622,8 +654,7 @@ class Ha(object):
|
||||
with self._async_response:
|
||||
self._async_response.reset()
|
||||
self._async_executor.try_run_async('promote', self.state_handler.promote,
|
||||
args=(self.dcs.loop_wait, self._async_response, on_success,
|
||||
self._leader_access_is_restricted))
|
||||
args=(self.dcs.loop_wait, self._async_response, on_success))
|
||||
return promote_message
|
||||
|
||||
def fetch_node_status(self, member):
|
||||
@@ -653,14 +684,13 @@ class Ha(object):
|
||||
:param wal_position: Current wal position.
|
||||
:returns True when node is lagging
|
||||
"""
|
||||
lag = (self.cluster.last_leader_operation or 0) - wal_position
|
||||
lag = (self.cluster.last_lsn or 0) - wal_position
|
||||
return lag > self.patroni.config.get('maximum_lag_on_failover', 0)
|
||||
|
||||
def _is_healthiest_node(self, members, check_replication_lag=True):
|
||||
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
|
||||
|
||||
# We don't call `last_operation()` here because it returns a string
|
||||
_, my_wal_position, _ = self.state_handler.timeline_wal_position()
|
||||
my_wal_position = self.state_handler.last_operation()
|
||||
if check_replication_lag and self.is_lagging(my_wal_position):
|
||||
logger.info('My wal position exceeds maximum replication lag')
|
||||
return False # Too far behind last reported wal position on master
|
||||
@@ -690,16 +720,21 @@ class Ha(object):
|
||||
logger.info('Ignoring the former leader being ahead of us')
|
||||
return True
|
||||
|
||||
def is_failover_possible(self, members):
|
||||
def is_failover_possible(self, members, check_synchronous=True, cluster_lsn=None):
|
||||
ret = False
|
||||
cluster_timeline = self.cluster.timeline
|
||||
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
|
||||
if check_synchronous and self.is_synchronous_mode():
|
||||
members = [m for m in members if self.cluster.sync.matches(m.name)]
|
||||
if members:
|
||||
for st in self.fetch_nodes_statuses(members):
|
||||
not_allowed_reason = st.failover_limitation()
|
||||
if not_allowed_reason:
|
||||
logger.info('Member %s is %s', st.member.name, not_allowed_reason)
|
||||
elif self.is_lagging(st.wal_position):
|
||||
elif not isinstance(st.wal_position, six.integer_types):
|
||||
logger.info('Member %s does not report wal_position', st.member.name)
|
||||
elif cluster_lsn and st.wal_position < cluster_lsn or\
|
||||
not cluster_lsn and self.is_lagging(st.wal_position):
|
||||
logger.info('Member %s exceeds maximum replication lag', st.member.name)
|
||||
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
|
||||
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
|
||||
@@ -725,6 +760,11 @@ class Ha(object):
|
||||
return None
|
||||
return False
|
||||
|
||||
# in synchronous mode when our name is not in the /sync key
|
||||
# we shouldn't take any action even if the candidate is unhealthy
|
||||
if self.is_synchronous_mode() and not self.cluster.sync.matches(self.state_handler.name):
|
||||
return False
|
||||
|
||||
# find specific node and check that it is healthy
|
||||
member = self.cluster.get_member(failover.candidate, fallback_to_leader=False)
|
||||
if member:
|
||||
@@ -783,6 +823,10 @@ class Ha(object):
|
||||
return False
|
||||
|
||||
if self.cluster.failover:
|
||||
# When doing a switchover in synchronous mode only synchronous nodes and former leader are allowed to race
|
||||
if self.is_synchronous_mode() and self.cluster.failover.leader and \
|
||||
not self.cluster.sync.matches(self.state_handler.name):
|
||||
return False
|
||||
return self.manual_failover_process_no_leader()
|
||||
|
||||
if not self.watchdog.is_healthy:
|
||||
@@ -791,7 +835,7 @@ class Ha(object):
|
||||
|
||||
# When in sync mode, only last known master and sync standby are allowed to promote automatically.
|
||||
all_known_members = self.cluster.members + self.old_cluster.members
|
||||
if self.is_synchronous_mode() and self.cluster.sync.leader:
|
||||
if self.is_synchronous_mode() and self.cluster.sync and self.cluster.sync.leader:
|
||||
if not self.cluster.sync.matches(self.state_handler.name):
|
||||
return False
|
||||
# pick between synchronous candidates so we minimize unnecessary failovers/demotions
|
||||
@@ -802,13 +846,13 @@ class Ha(object):
|
||||
|
||||
return self._is_healthiest_node(members.values())
|
||||
|
||||
def _delete_leader(self, last_operation=None):
|
||||
def _delete_leader(self, last_lsn=None):
|
||||
self.set_is_leader(False)
|
||||
self.dcs.delete_leader(last_operation)
|
||||
self.dcs.delete_leader(last_lsn)
|
||||
self.dcs.reset_cluster()
|
||||
|
||||
def release_leader_key_voluntarily(self, last_operation=None):
|
||||
self._delete_leader(last_operation)
|
||||
def release_leader_key_voluntarily(self, last_lsn=None):
|
||||
self._delete_leader(last_lsn)
|
||||
self.touch_member()
|
||||
logger.info("Leader key released")
|
||||
|
||||
@@ -830,23 +874,44 @@ class Ha(object):
|
||||
'immediate-nolock': dict(stop='immediate', checkpoint=False, release=False, offline=False, async_req=True),
|
||||
}[mode]
|
||||
|
||||
logger.info('Demoting self (%s)', mode)
|
||||
|
||||
self._rewind.trigger_check_diverged_lsn()
|
||||
|
||||
status = {'released': False}
|
||||
|
||||
def on_shutdown(checkpoint_location):
|
||||
# Postmaster is still running, but pg_control already reports clean "shut down".
|
||||
# It could happen if Postgres is still archiving the backlog of WAL files.
|
||||
# If we know that there are replicas that received the shutdown checkpoint
|
||||
# location, we can remove the leader key and allow them to start leader race.
|
||||
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
|
||||
self.state_handler.set_role('demoted')
|
||||
with self._async_executor:
|
||||
self.release_leader_key_voluntarily(checkpoint_location)
|
||||
status['released'] = True
|
||||
|
||||
self.state_handler.stop(mode_control['stop'], checkpoint=mode_control['checkpoint'],
|
||||
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
|
||||
on_shutdown=on_shutdown if mode_control['release'] else None,
|
||||
stop_timeout=self.master_stop_timeout())
|
||||
self.state_handler.set_role('demoted')
|
||||
self.set_is_leader(False)
|
||||
|
||||
if mode_control['release']:
|
||||
checkpoint_location = self.state_handler.latest_checkpoint_location() if mode == 'graceful' else None
|
||||
with self._async_executor:
|
||||
self.release_leader_key_voluntarily(checkpoint_location)
|
||||
if not status['released']:
|
||||
checkpoint_location = self.state_handler.latest_checkpoint_location() if mode == 'graceful' else None
|
||||
with self._async_executor:
|
||||
self.release_leader_key_voluntarily(checkpoint_location)
|
||||
time.sleep(2) # Give a time to somebody to take the leader lock
|
||||
if mode_control['offline']:
|
||||
node_to_follow, leader = None, None
|
||||
else:
|
||||
cluster = self.dcs.get_cluster()
|
||||
node_to_follow, leader = self._get_node_to_follow(cluster), cluster.leader
|
||||
try:
|
||||
cluster = self.dcs.get_cluster()
|
||||
node_to_follow, leader = self._get_node_to_follow(cluster), cluster.leader
|
||||
except Exception:
|
||||
node_to_follow, leader = None, None
|
||||
|
||||
# FIXME: with mode offline called from DCS exception handler and handle_long_action_in_progress
|
||||
# there could be an async action already running, calling follow from here will lead
|
||||
@@ -924,7 +989,7 @@ class Ha(object):
|
||||
else:
|
||||
members = [m for m in self.cluster.members
|
||||
if not failover.candidate or m.name == failover.candidate]
|
||||
if self.is_failover_possible(members): # check that there are healthy members
|
||||
if self.is_failover_possible(members, False): # check that there are healthy members
|
||||
ret = self._async_executor.try_run_async('manual failover: demote', self.demote, ('graceful',))
|
||||
return ret or 'manual failover: demoting myself'
|
||||
else:
|
||||
@@ -986,13 +1051,9 @@ class Ha(object):
|
||||
if self.cluster.failover and self.cluster.failover.candidate == self.state_handler.name:
|
||||
return 'waiting to become master after promote...'
|
||||
|
||||
self._delete_leader()
|
||||
return 'removed leader lock because postgres is not running as master'
|
||||
|
||||
if self.state_handler.is_leader() and self._leader_access_is_restricted:
|
||||
self.state_handler.slots_handler.sync_replication_slots(self.cluster)
|
||||
self.state_handler.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||
self.set_leader_access_is_restricted(False)
|
||||
if not self.is_standby_cluster():
|
||||
self._delete_leader()
|
||||
return 'removed leader lock because postgres is not running as master'
|
||||
|
||||
if self.update_lock(True):
|
||||
msg = self.process_manual_failover_from_leader()
|
||||
@@ -1006,14 +1067,14 @@ class Ha(object):
|
||||
# in case of standby cluster we don't really need to
|
||||
# enforce anything, since the leader is not a master.
|
||||
# So just remind the role.
|
||||
msg = 'no action. i am the standby leader with the lock' \
|
||||
msg = 'no action. I am ({0}), the standby leader with the lock'.format(self.state_handler.name) \
|
||||
if self.state_handler.role == 'standby_leader' else \
|
||||
'promoted self to a standby leader because i had the session lock'
|
||||
return self.enforce_follow_remote_master(msg)
|
||||
else:
|
||||
return self.enforce_master_role(
|
||||
'no action. i am the leader with the lock',
|
||||
'promoted self to leader because i had the session lock'
|
||||
'no action. I am ({0}), the leader with the lock'.format(self.state_handler.name),
|
||||
'promoted self to leader because I had the session lock'
|
||||
)
|
||||
else:
|
||||
# Either there is no connection to DCS or someone else acquired the lock
|
||||
@@ -1026,12 +1087,15 @@ class Ha(object):
|
||||
else:
|
||||
return 'not promoting because failed to update leader lock in DCS'
|
||||
else:
|
||||
logger.info('does not have lock')
|
||||
logger.debug('does not have lock')
|
||||
lock_owner = self.cluster.leader and self.cluster.leader.name
|
||||
if self.is_standby_cluster():
|
||||
return self.follow('cannot be a real master in standby cluster',
|
||||
'no action. i am a secondary and i am following a standby leader', refresh=False)
|
||||
return self.follow('demoting self because i do not have the lock and i was a leader',
|
||||
'no action. i am a secondary and i am following a leader', refresh=False)
|
||||
return self.follow('cannot be a real primary in a standby cluster',
|
||||
'no action. I am ({0}), a secondary, and following a standby leader ({1})'.format(
|
||||
self.state_handler.name, lock_owner), refresh=False)
|
||||
return self.follow('demoting self because I do not have the lock and I was a leader',
|
||||
'no action. I am ({0}), a secondary, and following a leader ({1})'.format(
|
||||
self.state_handler.name, lock_owner), refresh=False)
|
||||
|
||||
def evaluate_scheduled_restart(self):
|
||||
if self._async_executor.busy: # Restart already in progress
|
||||
@@ -1227,9 +1291,6 @@ class Ha(object):
|
||||
self._delete_leader()
|
||||
return 'removed leader key after trying and failing to start postgres'
|
||||
return 'failed to start postgres'
|
||||
self._crash_recovery_executed = False
|
||||
if self._rewind.executed and not self._rewind.failed:
|
||||
self._rewind.reset_state()
|
||||
return None
|
||||
|
||||
def cancel_initialization(self):
|
||||
@@ -1259,9 +1320,9 @@ class Ha(object):
|
||||
if not self.watchdog.activate():
|
||||
logger.error('Cancelling bootstrap because watchdog activation failed')
|
||||
self.cancel_initialization()
|
||||
self._rewind.ensure_checkpoint_after_promote(self.wakeup)
|
||||
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
||||
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
|
||||
self.state_handler.slots_handler.sync_replication_slots(self.cluster)
|
||||
self.dcs.take_leader()
|
||||
self.set_is_leader(True)
|
||||
self.state_handler.call_nowait(ACTION_ON_START)
|
||||
@@ -1317,8 +1378,12 @@ class Ha(object):
|
||||
def _run_cycle(self):
|
||||
dcs_failed = False
|
||||
try:
|
||||
self.state_handler.reset_cluster_info_state()
|
||||
self.load_cluster_from_dcs()
|
||||
try:
|
||||
self.load_cluster_from_dcs()
|
||||
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni.nofailover)
|
||||
except Exception:
|
||||
self.state_handler.reset_cluster_info_state(None, self.patroni.nofailover)
|
||||
raise
|
||||
|
||||
if self.is_paused():
|
||||
self.watchdog.disable()
|
||||
@@ -1350,15 +1415,34 @@ class Ha(object):
|
||||
if self.state_handler.bootstrapping:
|
||||
return self.post_bootstrap()
|
||||
|
||||
if self.recovering and not self._rewind.is_needed:
|
||||
if self.recovering:
|
||||
self.recovering = False
|
||||
# Check if we tried to recover and failed
|
||||
msg = self.post_recover()
|
||||
if msg is not None:
|
||||
return msg
|
||||
|
||||
if not self._rewind.is_needed:
|
||||
# Check if we tried to recover from postgres crash and failed
|
||||
msg = self.post_recover()
|
||||
if msg is not None:
|
||||
return msg
|
||||
|
||||
# Reset some states after postgres successfully started up
|
||||
self._crash_recovery_executed = False
|
||||
if self._rewind.executed and not self._rewind.failed:
|
||||
self._rewind.reset_state()
|
||||
|
||||
# The Raft cluster without a quorum takes a bit of time to stabilize.
|
||||
# Therefore we want to postpone the leader race if we just started up.
|
||||
if self.cluster.is_unlocked() and self.dcs.__class__.__name__ == 'Raft':
|
||||
return 'started as a secondary'
|
||||
|
||||
# is data directory empty?
|
||||
if self.state_handler.data_directory_empty():
|
||||
try:
|
||||
data_directory_is_empty = self.state_handler.data_directory_empty()
|
||||
data_directory_is_accessible = True
|
||||
except OSError as e:
|
||||
data_directory_is_accessible = False
|
||||
data_directory_error = e
|
||||
|
||||
if not data_directory_is_accessible or data_directory_is_empty:
|
||||
self.state_handler.set_role('uninitialized')
|
||||
self.state_handler.stop('immediate', stop_timeout=self.patroni.config['retry_timeout'])
|
||||
# In case datadir went away while we were master.
|
||||
@@ -1367,8 +1451,11 @@ class Ha(object):
|
||||
# is this instance the leader?
|
||||
if self.has_lock():
|
||||
self.release_leader_key_voluntarily()
|
||||
return 'released leader key voluntarily as data dir empty and currently leader'
|
||||
return 'released leader key voluntarily as data dir {0} and currently leader'.format(
|
||||
'empty' if data_directory_is_accessible else 'not accessible')
|
||||
|
||||
if not data_directory_is_accessible:
|
||||
return 'data directory is not accessible: {0}'.format(data_directory_error)
|
||||
if self.is_paused():
|
||||
return 'running with empty data directory'
|
||||
return self.bootstrap() # new node
|
||||
@@ -1424,28 +1511,42 @@ class Ha(object):
|
||||
|
||||
try:
|
||||
if self.cluster.is_unlocked():
|
||||
return self.process_unhealthy_cluster()
|
||||
ret = self.process_unhealthy_cluster()
|
||||
else:
|
||||
msg = self.process_healthy_cluster()
|
||||
return self.evaluate_scheduled_restart() or msg
|
||||
ret = self.evaluate_scheduled_restart() or msg
|
||||
finally:
|
||||
# we might not have a valid PostgreSQL connection here if another thread
|
||||
# stops PostgreSQL, therefore, we only reload replication slots if no
|
||||
# asynchronous processes are running (should be always the case for the master)
|
||||
if not self._async_executor.busy and not self.state_handler.is_starting():
|
||||
self.state_handler.slots_handler.sync_replication_slots(self.cluster)
|
||||
create_slots = self.state_handler.slots_handler.sync_replication_slots(self.cluster,
|
||||
self.patroni.nofailover,
|
||||
self.patroni.replicatefrom,
|
||||
self.is_paused())
|
||||
if not self.state_handler.cb_called:
|
||||
if not self.state_handler.is_leader():
|
||||
self._rewind.trigger_check_diverged_lsn()
|
||||
self.state_handler.call_nowait(ACTION_ON_START)
|
||||
if create_slots and self.cluster.leader:
|
||||
err = self._async_executor.try_run_async('copy_logical_slots',
|
||||
self.state_handler.slots_handler.copy_logical_slots,
|
||||
args=(self.cluster, create_slots))
|
||||
if not err:
|
||||
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
|
||||
return ret
|
||||
except DCSError:
|
||||
dcs_failed = True
|
||||
logger.error('Error communicating with DCS')
|
||||
if not self.is_paused() and self.state_handler.is_running() and self.state_handler.is_leader():
|
||||
msg = 'demoting self because DCS is not accessible and I was a leader'
|
||||
if not self._async_executor.try_run_async(msg, self.demote, ('offline',)):
|
||||
return msg
|
||||
logger.warning('AsyncExecutor is busy, demoting from the main thread')
|
||||
self.demote('offline')
|
||||
return 'demoted self because DCS is not accessible and i was a leader'
|
||||
return 'demoted self because DCS is not accessible and I was a leader'
|
||||
return 'DCS is not accessible'
|
||||
except (psycopg2.Error, PostgresConnectionException):
|
||||
except (psycopg.Error, PostgresConnectionException):
|
||||
return 'Error communicating with PostgreSQL. Will try again later'
|
||||
finally:
|
||||
if not dcs_failed:
|
||||
@@ -1468,14 +1569,31 @@ class Ha(object):
|
||||
self.watchdog.disable()
|
||||
elif not self._join_aborted:
|
||||
# FIXME: If stop doesn't reach safepoint quickly enough keepalive is triggered. If shutdown checkpoint
|
||||
# takes longer than ttl, then leader key is lost and replication might not have sent out all xlog.
|
||||
# takes longer than ttl, then leader key is lost and replication might not have sent out all WAL.
|
||||
# This might not be the desired behavior of users, as a graceful shutdown of the host can mean lost data.
|
||||
# We probably need to something smarter here.
|
||||
disable_wd = self.watchdog.disable if self.watchdog.is_running else None
|
||||
|
||||
status = {'deleted': False}
|
||||
|
||||
def _on_shutdown(checkpoint_location):
|
||||
if self.is_leader():
|
||||
# Postmaster is still running, but pg_control already reports clean "shut down".
|
||||
# It could happen if Postgres is still archiving the backlog of WAL files.
|
||||
# If we know that there are replicas that received the shutdown checkpoint
|
||||
# location, we can remove the leader key and allow them to start leader race.
|
||||
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
|
||||
self.dcs.delete_leader(checkpoint_location)
|
||||
status['deleted'] = True
|
||||
else:
|
||||
self.dcs.write_leader_optime(checkpoint_location)
|
||||
|
||||
on_shutdown = _on_shutdown if self.is_leader() else None
|
||||
self.while_not_sync_standby(lambda: self.state_handler.stop(checkpoint=False, on_safepoint=disable_wd,
|
||||
on_shutdown=on_shutdown,
|
||||
stop_timeout=self.master_stop_timeout()))
|
||||
if not self.state_handler.is_running():
|
||||
if self.is_leader():
|
||||
if self.is_leader() and not status['deleted']:
|
||||
checkpoint_location = self.state_handler.latest_checkpoint_location()
|
||||
self.dcs.delete_leader(checkpoint_location)
|
||||
self.touch_member()
|
||||
|
||||
+14
-1
@@ -166,6 +166,8 @@ class PatroniLogger(Thread):
|
||||
self._root_logger.addHandler(self._queue_handler)
|
||||
self._root_logger.removeHandler(self._proxy_handler)
|
||||
|
||||
prev_record = None
|
||||
|
||||
while True:
|
||||
self._close_old_handlers()
|
||||
|
||||
@@ -173,7 +175,18 @@ class PatroniLogger(Thread):
|
||||
if record is None:
|
||||
break
|
||||
|
||||
self.log_handler.handle(record)
|
||||
if self._root_logger.level == logging.INFO:
|
||||
if record.msg.startswith('Lock owner: '):
|
||||
prev_record, record = record, None
|
||||
else:
|
||||
if prev_record and prev_record.thread == record.thread:
|
||||
if not (record.msg.startswith('no action. ') or record.msg.startswith('PAUSE: no action')):
|
||||
self.log_handler.handle(prev_record)
|
||||
prev_record = None
|
||||
|
||||
if record:
|
||||
self.log_handler.handle(record)
|
||||
|
||||
self._queue_handler.queue.task_done()
|
||||
|
||||
def shutdown(self):
|
||||
|
||||
+176
-58
@@ -1,28 +1,31 @@
|
||||
import logging
|
||||
import os
|
||||
import psycopg2
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import six
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from dateutil import tz
|
||||
from datetime import datetime
|
||||
from patroni.postgresql.callback_executor import CallbackExecutor
|
||||
from patroni.postgresql.bootstrap import Bootstrap
|
||||
from patroni.postgresql.cancellable import CancellableSubprocess
|
||||
from patroni.postgresql.config import ConfigHandler, mtime
|
||||
from patroni.postgresql.connection import Connection, get_connection_cursor
|
||||
from patroni.postgresql.misc import parse_history, parse_lsn, postgres_major_version_to_int
|
||||
from patroni.postgresql.postmaster import PostmasterProcess
|
||||
from patroni.postgresql.slots import SlotsHandler
|
||||
from patroni.exceptions import PostgresConnectionException
|
||||
from patroni.utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
|
||||
from dateutil import tz
|
||||
from psutil import TimeoutExpired
|
||||
from threading import current_thread, Lock
|
||||
|
||||
from .bootstrap import Bootstrap
|
||||
from .callback_executor import CallbackExecutor
|
||||
from .cancellable import CancellableSubprocess
|
||||
from .config import ConfigHandler, mtime
|
||||
from .connection import Connection, get_connection_cursor
|
||||
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
|
||||
from .postmaster import PostmasterProcess
|
||||
from .slots import SlotsHandler
|
||||
from .. import psycopg
|
||||
from ..exceptions import PostgresConnectionException
|
||||
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,13 +51,13 @@ def null_context():
|
||||
|
||||
class Postgresql(object):
|
||||
|
||||
POSTMASTER_START_TIME = "pg_catalog.to_char(pg_catalog.pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ')"
|
||||
POSTMASTER_START_TIME = "pg_catalog.pg_postmaster_start_time()"
|
||||
TL_LSN = ("CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
|
||||
"ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
|
||||
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, " # master timeline
|
||||
"CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
|
||||
"ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END, " # write_lsn
|
||||
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint, "
|
||||
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint, "
|
||||
"pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint, "
|
||||
"pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()")
|
||||
|
||||
@@ -101,15 +104,19 @@ class Postgresql(object):
|
||||
self._state_entry_timestamp = None
|
||||
|
||||
self._cluster_info_state = {}
|
||||
self._has_permanent_logical_slots = True
|
||||
self._enforce_hot_standby_feedback = False
|
||||
self._cached_replica_timeline = None
|
||||
|
||||
# Last known running process
|
||||
self._postmaster_proc = None
|
||||
|
||||
if self.is_running():
|
||||
if self.is_running(): # we are "joining" already running postgres
|
||||
self.set_state('running')
|
||||
self.set_role('master' if self.is_leader() else 'replica')
|
||||
self.config.write_postgresql_conf() # we are "joining" already running postgres
|
||||
# postpone writing postgresql.conf for 12+ because recovery parameters are not yet known
|
||||
if self.major_version < 120000 or self.is_leader():
|
||||
self.config.write_postgresql_conf()
|
||||
hba_saved = self.config.replace_pg_hba()
|
||||
ident_saved = self.config.replace_pg_ident()
|
||||
if hba_saved or ident_saved:
|
||||
@@ -152,14 +159,18 @@ class Postgresql(object):
|
||||
@property
|
||||
def cluster_info_query(self):
|
||||
if self._major_version >= 90600:
|
||||
extra = "(SELECT pg_catalog.json_agg(s.*) FROM (SELECT slot_name, slot_type as type, datoid::bigint, " +\
|
||||
"plugin, catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" + \
|
||||
" AS confirmed_flush_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"\
|
||||
if self._has_permanent_logical_slots and self._major_version >= 110000 else "NULL"
|
||||
extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END,"
|
||||
" slot_name, conninfo FROM pg_catalog.pg_stat_get_wal_receiver()")
|
||||
" slot_name, conninfo, {0} FROM pg_catalog.pg_stat_get_wal_receiver()").format(extra)
|
||||
if self.role == 'standby_leader':
|
||||
extra = "timeline_id" + extra + ", pg_catalog.pg_control_checkpoint()"
|
||||
else:
|
||||
extra = "0" + extra
|
||||
else:
|
||||
extra = "0, NULL, NULL, NULL"
|
||||
extra = "0, NULL, NULL, NULL, NULL"
|
||||
|
||||
return ("SELECT " + self.TL_LSN + ", {2}").format(self.wal_name, self.lsn_name, extra)
|
||||
|
||||
@@ -253,15 +264,15 @@ class Postgresql(object):
|
||||
cursor = None
|
||||
try:
|
||||
cursor = self._connection.cursor()
|
||||
cursor.execute(sql, params)
|
||||
cursor.execute(sql, params or None)
|
||||
return cursor
|
||||
except psycopg2.Error as e:
|
||||
except psycopg.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):
|
||||
if isinstance(e, psycopg.OperationalError):
|
||||
self._connection.close()
|
||||
else:
|
||||
raise e
|
||||
@@ -299,16 +310,41 @@ class Postgresql(object):
|
||||
replica_methods = self.create_replica_methods
|
||||
return any(self.replica_method_can_work_without_replication_connection(m) for m in replica_methods)
|
||||
|
||||
def reset_cluster_info_state(self):
|
||||
@property
|
||||
def enforce_hot_standby_feedback(self):
|
||||
return self._enforce_hot_standby_feedback
|
||||
|
||||
def set_enforce_hot_standby_feedback(self, value):
|
||||
# If we enable or disable the hot_standby_feedback we need to update postgresql.conf and reload
|
||||
if self._enforce_hot_standby_feedback != value:
|
||||
self._enforce_hot_standby_feedback = value
|
||||
if self.is_running():
|
||||
self.config.write_postgresql_conf()
|
||||
self.reload()
|
||||
|
||||
def reset_cluster_info_state(self, cluster, nofailover=None):
|
||||
self._cluster_info_state = {}
|
||||
if cluster and cluster.config and cluster.config.modify_index:
|
||||
self._has_permanent_logical_slots =\
|
||||
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
|
||||
|
||||
# We want to enable hot_standby_feedback if the replica is supposed
|
||||
# to have a logical slot or in case if it is the cascading replica.
|
||||
self.set_enforce_hot_standby_feedback(
|
||||
self._has_permanent_logical_slots or
|
||||
cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
|
||||
|
||||
def _cluster_info_state_get(self, name):
|
||||
if not self._cluster_info_state:
|
||||
try:
|
||||
result = self._is_leader_retry(self._query, self.cluster_info_query).fetchone()
|
||||
self._cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location',
|
||||
'received_location', 'replay_paused', 'pg_control_timeline',
|
||||
'received_tli', 'slot_name', 'conninfo'], result))
|
||||
cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location',
|
||||
'received_location', 'replay_paused', 'pg_control_timeline',
|
||||
'received_tli', 'slot_name', 'conninfo', 'slots'], result))
|
||||
if self._has_permanent_logical_slots:
|
||||
cluster_info_state['slots'] =\
|
||||
self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
|
||||
self._cluster_info_state = cluster_info_state
|
||||
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:
|
||||
@@ -325,6 +361,9 @@ class Postgresql(object):
|
||||
def received_location(self):
|
||||
return self._cluster_info_state_get('received_location')
|
||||
|
||||
def slots(self):
|
||||
return self._cluster_info_state_get('slots')
|
||||
|
||||
def primary_slot_name(self):
|
||||
return self._cluster_info_state_get('slot_name')
|
||||
|
||||
@@ -335,24 +374,65 @@ class Postgresql(object):
|
||||
return self._cluster_info_state_get('received_tli')
|
||||
|
||||
def is_leader(self):
|
||||
return bool(self._cluster_info_state_get('timeline'))
|
||||
try:
|
||||
return bool(self._cluster_info_state_get('timeline'))
|
||||
except PostgresConnectionException:
|
||||
logger.warning('Failed to determine PostgreSQL state from the connection, falling back to cached role')
|
||||
return bool(self.is_running() and self.role == 'master')
|
||||
|
||||
def replay_paused(self):
|
||||
return self._cluster_info_state_get('replay_paused')
|
||||
|
||||
def resume_wal_replay(self):
|
||||
self._query('SELECT pg_catalog.pg_{0}_replay_resume()'.format(self.wal_name))
|
||||
|
||||
def handle_parameter_change(self):
|
||||
if self.major_version >= 140000 and not self.is_starting() and self.replay_paused():
|
||||
logger.info('Resuming paused WAL replay for PostgreSQL 14+')
|
||||
self.resume_wal_replay()
|
||||
|
||||
def pg_control_timeline(self):
|
||||
try:
|
||||
|
||||
return int(self.controldata().get("Latest checkpoint's TimeLineID"))
|
||||
except (TypeError, ValueError):
|
||||
logger.exception('Failed to parse timeline from pg_controldata output')
|
||||
|
||||
def parse_wal_record(self, timeline, lsn):
|
||||
out, err = self.waldump(timeline, lsn, 1)
|
||||
if out and not err:
|
||||
match = re.match(r'^rmgr:\s+(.+?)\s+len \(rec/tot\):\s+\d+/\s+\d+, tx:\s+\d+, '
|
||||
r'lsn: ([0-9A-Fa-f]+/[0-9A-Fa-f]+), prev ([0-9A-Fa-f]+/[0-9A-Fa-f]+), '
|
||||
r'.*?desc: (.+)', out.decode('utf-8'))
|
||||
if match:
|
||||
return match.groups()
|
||||
return None, None, None, None
|
||||
|
||||
def latest_checkpoint_location(self):
|
||||
"""Returns checkpoint location for the cleanly shut down primary"""
|
||||
"""Returns checkpoint location for the cleanly shut down primary.
|
||||
But, if we know that the checkpoint was written to the new WAL
|
||||
due to the archive_mode=on, we will return the LSN of prev wal record (SWITCH)."""
|
||||
|
||||
data = self.controldata()
|
||||
lsn = data.get('Latest checkpoint location')
|
||||
if data.get('Database cluster state') == 'shut down' and lsn:
|
||||
timeline = data.get("Latest checkpoint's TimeLineID")
|
||||
lsn = checkpoint_lsn = data.get('Latest checkpoint location')
|
||||
if data.get('Database cluster state') == 'shut down' and lsn and timeline:
|
||||
try:
|
||||
return str(parse_lsn(lsn))
|
||||
except (IndexError, ValueError) as e:
|
||||
logger.error('Exception when parsing lsn %s: %r', lsn, e)
|
||||
checkpoint_lsn = parse_lsn(checkpoint_lsn)
|
||||
rm_name, lsn, prev, desc = self.parse_wal_record(timeline, lsn)
|
||||
desc = desc.strip().lower()
|
||||
if rm_name == 'XLOG' and parse_lsn(lsn) == checkpoint_lsn and prev and\
|
||||
desc.startswith('checkpoint') and desc.endswith('shutdown'):
|
||||
_, lsn, _, desc = self.parse_wal_record(timeline, prev)
|
||||
prev = parse_lsn(prev)
|
||||
# If the cluster is shutdown with archive_mode=on, WAL is switched before writing the checkpoint.
|
||||
# In this case we want to take the LSN of previous record (switch) as the last known WAL location.
|
||||
if parse_lsn(lsn) == prev and desc.strip() in ('xlog switch', 'SWITCH'):
|
||||
return prev
|
||||
except Exception as e:
|
||||
logger.error('Exception when parsing WAL pg_%sdump output: %r', self.wal_name, e)
|
||||
if isinstance(checkpoint_lsn, six.integer_types):
|
||||
return checkpoint_lsn
|
||||
|
||||
def is_running(self):
|
||||
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
|
||||
@@ -362,7 +442,7 @@ class Postgresql(object):
|
||||
return self._postmaster_proc
|
||||
self._postmaster_proc = None
|
||||
|
||||
# we noticed that postgres was restarted, force syncing of replication
|
||||
# we noticed that postgres was restarted, force syncing of replication slots and check of logical slots
|
||||
self.slots_handler.schedule()
|
||||
|
||||
self._postmaster_proc = PostmasterProcess.from_pidfile(self._data_dir)
|
||||
@@ -523,12 +603,13 @@ class Postgresql(object):
|
||||
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
|
||||
if cur.fetchone()[0]:
|
||||
return 'is_in_recovery=true'
|
||||
return cur.execute('CHECKPOINT')
|
||||
except psycopg2.Error:
|
||||
cur.execute('CHECKPOINT')
|
||||
except psycopg.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_timeout=None):
|
||||
def stop(self, mode='fast', block_callbacks=False, checkpoint=None,
|
||||
on_safepoint=None, on_shutdown=None, stop_timeout=None):
|
||||
"""Stop PostgreSQL
|
||||
|
||||
Supports a callback when a safepoint is reached. A safepoint is when no user backend can return a successful
|
||||
@@ -536,11 +617,12 @@ class Postgresql(object):
|
||||
could be added.
|
||||
|
||||
:param on_safepoint: This callback is called when no user backends are running.
|
||||
:param on_shutdown: is called when pg_controldata starts reporting `Database cluster state: shut down`
|
||||
"""
|
||||
if checkpoint is None:
|
||||
checkpoint = False if mode == 'immediate' else True
|
||||
|
||||
success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint, stop_timeout)
|
||||
success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint, on_shutdown, stop_timeout)
|
||||
if success:
|
||||
# block_callbacks is used during restart to avoid
|
||||
# running start/stop callbacks in addition to restart ones
|
||||
@@ -553,7 +635,7 @@ class Postgresql(object):
|
||||
self.set_state('stop failed')
|
||||
return success
|
||||
|
||||
def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint, stop_timeout):
|
||||
def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint, on_shutdown, stop_timeout):
|
||||
postmaster = self.is_running()
|
||||
if not postmaster:
|
||||
if on_safepoint:
|
||||
@@ -577,9 +659,25 @@ class Postgresql(object):
|
||||
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()
|
||||
postmaster.wait_for_user_backends_to_close(stop_timeout)
|
||||
on_safepoint()
|
||||
|
||||
if on_shutdown and mode in ('fast', 'smart'):
|
||||
i = 0
|
||||
# Wait for pg_controldata `Database cluster state:` to change to "shut down"
|
||||
while postmaster.is_running():
|
||||
data = self.controldata()
|
||||
if data.get('Database cluster state', '') == 'shut down':
|
||||
on_shutdown(self.latest_checkpoint_location())
|
||||
break
|
||||
elif data.get('Database cluster state', '').startswith('shut down'): # shut down in recovery
|
||||
break
|
||||
elif stop_timeout and i >= stop_timeout:
|
||||
stop_timeout = 0
|
||||
break
|
||||
time.sleep(STOP_POLLING_INTERVAL)
|
||||
i += STOP_POLLING_INTERVAL
|
||||
|
||||
try:
|
||||
postmaster.wait(timeout=stop_timeout)
|
||||
except TimeoutExpired:
|
||||
@@ -614,7 +712,7 @@ class Postgresql(object):
|
||||
while postmaster.is_running(): # Need a timeout here?
|
||||
cur.execute("SELECT 1")
|
||||
time.sleep(STOP_POLLING_INTERVAL)
|
||||
except psycopg2.Error:
|
||||
except psycopg.Error:
|
||||
pass
|
||||
|
||||
def reload(self, block_callbacks=False):
|
||||
@@ -700,7 +798,8 @@ class Postgresql(object):
|
||||
return True
|
||||
|
||||
def get_guc_value(self, name):
|
||||
cmd = [self.pgcommand('postgres'), '-D', self._data_dir, '-C', name]
|
||||
cmd = [self.pgcommand('postgres'), '-D', self._data_dir, '-C', name,
|
||||
'--config-file={}'.format(self.config.postgresql_conf)]
|
||||
try:
|
||||
data = subprocess.check_output(cmd)
|
||||
if data:
|
||||
@@ -724,8 +823,22 @@ class Postgresql(object):
|
||||
logger.exception("Error when calling pg_controldata")
|
||||
return {}
|
||||
|
||||
def waldump(self, timeline, lsn, limit):
|
||||
cmd = self.pgcommand('pg_{0}dump'.format(self.wal_name))
|
||||
env = os.environ.copy()
|
||||
env.update(LANG='C', LC_ALL='C', PGDATA=self._data_dir)
|
||||
try:
|
||||
waldump = subprocess.Popen([cmd, '-t', str(timeline), '-s', str(lsn), '-n', str(limit)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
out, err = waldump.communicate()
|
||||
waldump.wait()
|
||||
return out, err
|
||||
except Exception as e:
|
||||
logger.error('Failed to execute `%s -t %s -s %s -n %s`: %r', cmd, timeline, lsn, limit, e)
|
||||
return None, None
|
||||
|
||||
@contextmanager
|
||||
def get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs):
|
||||
def get_replication_connection_cursor(self, host=None, port=5432, **kwargs):
|
||||
conn_kwargs = self.config.replication.copy()
|
||||
conn_kwargs.update(host=host, port=int(port) if port else None, user=conn_kwargs.pop('username'),
|
||||
connect_timeout=3, replication=1, options='-c statement_timeout=2000')
|
||||
@@ -759,6 +872,7 @@ class Postgresql(object):
|
||||
if history[-1][0] == timeline - 1:
|
||||
history_mtime = datetime.fromtimestamp(history_mtime).replace(tzinfo=tz.tzlocal())
|
||||
history[-1].append(history_mtime.isoformat())
|
||||
history[-1].append(self.name)
|
||||
return history
|
||||
except Exception:
|
||||
logger.exception('Failed to read and parse %s', (history_path,))
|
||||
@@ -776,20 +890,22 @@ class Postgresql(object):
|
||||
if change_role:
|
||||
self.__cb_pending = ACTION_NOOP
|
||||
|
||||
ret = True
|
||||
if self.is_running():
|
||||
if do_reload:
|
||||
self.config.write_postgresql_conf()
|
||||
if self.reload(block_callbacks=change_role) and change_role:
|
||||
ret = self.reload(block_callbacks=change_role)
|
||||
if ret and change_role:
|
||||
self.set_role(role)
|
||||
else:
|
||||
self.restart(block_callbacks=change_role, role=role)
|
||||
ret = self.restart(block_callbacks=change_role, role=role)
|
||||
else:
|
||||
self.start(timeout=timeout, block_callbacks=change_role, role=role)
|
||||
ret = self.start(timeout=timeout, block_callbacks=change_role, role=role) or None
|
||||
|
||||
if change_role:
|
||||
# TODO: postpone this until start completes, or maybe do even earlier
|
||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||
return True
|
||||
return ret
|
||||
|
||||
def _wait_promote(self, wait_seconds):
|
||||
for _ in polling_loop(wait_seconds):
|
||||
@@ -812,7 +928,7 @@ class Postgresql(object):
|
||||
logger.info('pre_promote script `%s` exited with %s', cmd, ret)
|
||||
return ret == 0
|
||||
|
||||
def promote(self, wait_seconds, task, on_success=None, access_is_restricted=False):
|
||||
def promote(self, wait_seconds, task, on_success=None):
|
||||
if self.role == 'master':
|
||||
return True
|
||||
|
||||
@@ -829,13 +945,14 @@ class Postgresql(object):
|
||||
logger.info("PostgreSQL promote cancelled.")
|
||||
return False
|
||||
|
||||
self.slots_handler.on_promote()
|
||||
|
||||
ret = self.pg_ctl('promote', '-W')
|
||||
if ret:
|
||||
self.set_role('master')
|
||||
if on_success is not None:
|
||||
on_success()
|
||||
if not access_is_restricted:
|
||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||
ret = self._wait_promote(wait_seconds)
|
||||
return ret
|
||||
|
||||
@@ -865,16 +982,16 @@ class Postgresql(object):
|
||||
try:
|
||||
query = "SELECT " + self.POSTMASTER_START_TIME
|
||||
if current_thread().ident == self.__thread_ident:
|
||||
return self.query(query).fetchone()[0]
|
||||
return self.query(query).fetchone()[0].isoformat(sep=' ')
|
||||
with self.connection().cursor() as cursor:
|
||||
cursor.execute(query)
|
||||
return cursor.fetchone()[0]
|
||||
except psycopg2.Error:
|
||||
return cursor.fetchone()[0].isoformat(sep=' ')
|
||||
except psycopg.Error:
|
||||
return None
|
||||
|
||||
def last_operation(self):
|
||||
return str(self._wal_position(self.is_leader(), self._cluster_info_state_get('wal_position'),
|
||||
self.received_location(), self.replayed_location()))
|
||||
return self._wal_position(self.is_leader(), self._cluster_info_state_get('wal_position'),
|
||||
self.received_location(), self.replayed_location())
|
||||
|
||||
def configure_server_parameters(self):
|
||||
self._major_version = self.get_major_version()
|
||||
@@ -967,7 +1084,7 @@ class Postgresql(object):
|
||||
|
||||
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
|
||||
synchronous standby any longer.
|
||||
Parameter sync_node_maxlag(maximum_lag_on_syncnode) would help swapping unhealthy sync replica incase
|
||||
Parameter sync_node_maxlag(maximum_lag_on_syncnode) would help swapping unhealthy sync replica in case
|
||||
if it stops responding (or hung). Please set the value high enough so it won't unncessarily swap sync
|
||||
standbys during high loads. Any less or equal of 0 value keep the behavior backward compatible and
|
||||
will not swap. Please note that it will not also swap sync standbys in case where all replicas are hung.
|
||||
@@ -992,15 +1109,16 @@ class Postgresql(object):
|
||||
for app_name, sync_state, replica_lsn in self.query(
|
||||
"SELECT pg_catalog.lower(application_name), sync_state, pg_{2}_{1}_diff({0}_{1}, '0/0')::bigint"
|
||||
" FROM pg_catalog.pg_stat_replication"
|
||||
" WHERE state = 'streaming'"
|
||||
" WHERE state = 'streaming' AND {0}_{1} IS NOT NULL"
|
||||
" ORDER BY sync_state DESC, {0}_{1} DESC".format(sort_col, self.lsn_name, self.wal_name)):
|
||||
member = members.get(app_name)
|
||||
if member and not member.tags.get('nosync', False):
|
||||
replica_list.append((member.name, sync_state, replica_lsn))
|
||||
replica_list.append((member.name, sync_state, replica_lsn, bool(member.nofailover)))
|
||||
|
||||
max_lsn = max(replica_list, key=lambda x: x[2])[2] if len(replica_list) > 1 else int(str(self.last_operation()))
|
||||
|
||||
for app_name, sync_state, replica_lsn in replica_list:
|
||||
# Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
|
||||
for app_name, sync_state, replica_lsn, _ in sorted(replica_list, key=lambda x: x[3]):
|
||||
if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag:
|
||||
candidates.append(app_name)
|
||||
if sync_state == 'sync':
|
||||
|
||||
@@ -4,10 +4,12 @@ import shlex
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from patroni.dcs import RemoteMember
|
||||
from patroni.utils import deep_compare
|
||||
from six import string_types
|
||||
|
||||
from ..dcs import RemoteMember
|
||||
from ..psycopg import quote_ident, quote_literal
|
||||
from ..utils import deep_compare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -53,7 +55,7 @@ class Bootstrap(object):
|
||||
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))
|
||||
error_handler('{0} options must be list or dict'.format(tool))
|
||||
return user_options
|
||||
|
||||
def _initdb(self, config):
|
||||
@@ -90,8 +92,8 @@ class Bootstrap(object):
|
||||
self._postgresql.configure_server_parameters()
|
||||
|
||||
# make sure there is no trigger file or postgres will be automatically promoted
|
||||
trigger_file = 'promote_trigger_file' if self._postgresql.major_version >= 120000 else 'trigger_file'
|
||||
trigger_file = self._postgresql.config.get('recovery_conf', {}).get(trigger_file) or 'promote'
|
||||
trigger_file = self._postgresql.config.triggerfile_good_name
|
||||
trigger_file = (self._postgresql.config.get('recovery_conf') or {}).get(trigger_file) or 'promote'
|
||||
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
|
||||
if os.path.exists(trigger_file):
|
||||
os.unlink(trigger_file)
|
||||
@@ -297,30 +299,30 @@ class Bootstrap(object):
|
||||
if 'NOLOGIN' not in options and 'LOGIN' not in options:
|
||||
options.append('LOGIN')
|
||||
|
||||
params = [name]
|
||||
if password:
|
||||
options.extend(['PASSWORD', '%s'])
|
||||
params.extend([password, password])
|
||||
options.extend(['PASSWORD', quote_literal(password)])
|
||||
|
||||
sql = """DO $$
|
||||
BEGIN
|
||||
SET local synchronous_commit = 'local';
|
||||
PERFORM * FROM pg_authid WHERE rolname = %s;
|
||||
PERFORM * FROM pg_catalog.pg_authid WHERE rolname = {0};
|
||||
IF FOUND THEN
|
||||
ALTER ROLE "{0}" WITH {1};
|
||||
ALTER ROLE {1} WITH {2};
|
||||
ELSE
|
||||
CREATE ROLE "{0}" WITH {1};
|
||||
CREATE ROLE {1} WITH {2};
|
||||
END IF;
|
||||
END;$$""".format(name, ' '.join(options))
|
||||
END;$$""".format(quote_literal(name), quote_ident(name, self._postgresql.connection()), ' '.join(options))
|
||||
self._postgresql.query('SET log_statement TO none')
|
||||
self._postgresql.query('SET log_min_duration_statement TO -1')
|
||||
self._postgresql.query("SET log_min_error_statement TO 'log'")
|
||||
self._postgresql.query("SET pg_stat_statements.track_utility to 'off'")
|
||||
try:
|
||||
self._postgresql.query(sql, *params)
|
||||
self._postgresql.query(sql)
|
||||
finally:
|
||||
self._postgresql.query('RESET log_min_error_statement')
|
||||
self._postgresql.query('RESET log_min_duration_statement')
|
||||
self._postgresql.query('RESET log_statement')
|
||||
self._postgresql.query('RESET pg_stat_statements.track_utility')
|
||||
|
||||
def post_bootstrap(self, config, task):
|
||||
try:
|
||||
@@ -342,8 +344,8 @@ END;$$""".format(name, ' '.join(options))
|
||||
sql = """DO $$
|
||||
BEGIN
|
||||
SET local synchronous_commit = 'local';
|
||||
GRANT EXECUTE ON function pg_catalog.{0} TO "{1}";
|
||||
END;$$""".format(f, rewind['username'])
|
||||
GRANT EXECUTE ON function pg_catalog.{0} TO {1};
|
||||
END;$$""".format(f, quote_ident(rewind['username'], self._postgresql.connection()))
|
||||
postgresql.query(sql)
|
||||
|
||||
for name, value in (config.get('users') or {}).items():
|
||||
|
||||
@@ -36,7 +36,7 @@ class CancellableExecutor(object):
|
||||
with self._lock:
|
||||
if self._process is not None and self._process.is_running() and not self._process_children:
|
||||
try:
|
||||
self._process.suspend() # Suspend the process before getting list of childrens
|
||||
self._process.suspend() # Suspend the process before getting list of children
|
||||
except psutil.Error as e:
|
||||
logger.info('Failed to suspend the process: %s', e.msg)
|
||||
|
||||
|
||||
+108
-66
@@ -12,6 +12,7 @@ from .validator import CaseInsensitiveDict, recovery_parameters,\
|
||||
transform_postgresql_parameter_value, transform_recovery_parameter_value
|
||||
from ..dcs import slot_name_from_member_name, RemoteMember
|
||||
from ..exceptions import PatroniFatalException
|
||||
from ..psycopg import quote_ident as _quote_ident
|
||||
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, \
|
||||
validate_directory, is_subpath
|
||||
|
||||
@@ -23,7 +24,7 @@ PARAMETER_RE = re.compile(r'([a-z_]+)\s*=\s*')
|
||||
|
||||
def quote_ident(value):
|
||||
"""Very simplified version of quote_ident"""
|
||||
return value if SYNC_STANDBY_NAME_RE.match(value) else '"' + value + '"'
|
||||
return value if SYNC_STANDBY_NAME_RE.match(value) else _quote_ident(value)
|
||||
|
||||
|
||||
def conninfo_uri_parse(dsn):
|
||||
@@ -336,8 +337,9 @@ class ConfigHandler(object):
|
||||
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, {}")
|
||||
self.try_to_create_dir(os.path.dirname(self._pgpass),
|
||||
"'{}' is defined in `postgresql.pgpass`, {}")
|
||||
if not self._krbsrvname:
|
||||
self.try_to_create_dir(os.path.dirname(self._pgpass),
|
||||
"'{}' is defined in `postgresql.pgpass`, {}")
|
||||
|
||||
@property
|
||||
def _configuration_to_save(self):
|
||||
@@ -352,7 +354,7 @@ class ConfigHandler(object):
|
||||
|
||||
def save_configuration_files(self, check_custom_bootstrap=False):
|
||||
"""
|
||||
copy postgresql.conf to postgresql.conf.backup to be able to retrive configuration files
|
||||
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)
|
||||
"""
|
||||
@@ -387,22 +389,22 @@ class ConfigHandler(object):
|
||||
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)
|
||||
|
||||
# In case we are using custom bootstrap from spilo image with PITR it fails if it contains increasing
|
||||
# values like Max_connections. We disable hot_standby so it will accept increasing values.
|
||||
if self._postgresql.bootstrap.running_custom_bootstrap:
|
||||
configuration['hot_standby'] = 'off'
|
||||
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 ConfigWriter(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 or self._server_parameters).items()):
|
||||
for name, value in sorted((configuration).items()):
|
||||
value = transform_postgresql_parameter_value(self._postgresql.major_version, name, value)
|
||||
if (not self._postgresql.bootstrap.running_custom_bootstrap or name != 'hba_file') \
|
||||
and name not in self._RECOVERY_PARAMETERS and value is not None:
|
||||
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 overriden
|
||||
# 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)
|
||||
@@ -476,18 +478,20 @@ class ConfigHandler(object):
|
||||
ret.setdefault('channel_binding', 'prefer')
|
||||
if self._krbsrvname:
|
||||
ret['krbsrvname'] = self._krbsrvname
|
||||
if 'database' in ret:
|
||||
del ret['database']
|
||||
if 'dbname' in ret:
|
||||
del ret['dbname']
|
||||
return ret
|
||||
|
||||
def format_dsn(self, params, include_dbname=False):
|
||||
# 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',
|
||||
'application_name', 'krbsrvname', 'gssencmode', 'channel_binding')
|
||||
'sslcrldir', 'application_name', 'krbsrvname', 'gssencmode', 'channel_binding',
|
||||
'target_session_attrs')
|
||||
if include_dbname:
|
||||
params = params.copy()
|
||||
params['dbname'] = params.get('database') or self._postgresql.database
|
||||
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'}
|
||||
@@ -539,6 +543,12 @@ class ConfigHandler(object):
|
||||
if use_slots and not (is_remote_master and member.no_replication_slot):
|
||||
primary_slot_name = member.primary_slot_name if is_remote_master 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_master 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
|
||||
@@ -553,7 +563,7 @@ class ConfigHandler(object):
|
||||
return os.path.exists(self._recovery_conf)
|
||||
|
||||
@property
|
||||
def _triggerfile_good_name(self):
|
||||
def triggerfile_good_name(self):
|
||||
return 'trigger_file' if self._postgresql.major_version < 120000 else 'promote_trigger_file'
|
||||
|
||||
@property
|
||||
@@ -567,6 +577,9 @@ class ConfigHandler(object):
|
||||
return self._RECOVERY_PARAMETERS - skip_params
|
||||
|
||||
def _read_recovery_params(self):
|
||||
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
|
||||
@@ -615,19 +628,19 @@ class ConfigHandler(object):
|
||||
|
||||
def _check_passfile(self, passfile, wanted_primary_conninfo):
|
||||
# If there is a passfile in the primary_conninfo try to figure out that
|
||||
# the passfile contains the line allowing connection to the given node.
|
||||
# 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_line = self._pgpass_line(wanted_primary_conninfo).strip()
|
||||
for raw_line in f:
|
||||
if raw_line.strip() == wanted_line:
|
||||
self._passfile = passfile
|
||||
self._passfile_mtime = passfile_mtime
|
||||
return True
|
||||
wanted_lines = self._pgpass_line(wanted_primary_conninfo).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
|
||||
@@ -640,16 +653,17 @@ class ConfigHandler(object):
|
||||
elif not primary_conninfo:
|
||||
return False
|
||||
|
||||
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 not self._postgresql.is_starting():
|
||||
wal_receiver_primary_conninfo = self._postgresql.primary_conninfo()
|
||||
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']
|
||||
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:
|
||||
@@ -658,7 +672,7 @@ class ConfigHandler(object):
|
||||
else:
|
||||
return False
|
||||
|
||||
return all(primary_conninfo.get(p) == str(v) for p, v in wanted_primary_conninfo.items() if v is not None)
|
||||
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):
|
||||
"""Returns a tuple. The first boolean element indicates that recovery params don't match
|
||||
@@ -694,14 +708,19 @@ class ConfigHandler(object):
|
||||
else: # empty string, primary_conninfo is not in the config
|
||||
primary_conninfo[0] = {}
|
||||
|
||||
# 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
|
||||
if not self._postgresql.is_starting():
|
||||
# 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
|
||||
|
||||
required = {'restart': 0, 'reload': 0}
|
||||
# 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):
|
||||
required['restart' if mtype else 'reload'] += 1
|
||||
@@ -740,7 +759,12 @@ class ConfigHandler(object):
|
||||
return re.sub(r'([:\\])', r'\\\1', str(value))
|
||||
|
||||
record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')}
|
||||
return '{host}:{port}:*:{user}:{password}'.format(**record)
|
||||
# 'host' could be several comma-separated hostnames, in this case
|
||||
# we need to write on pgpass line per host
|
||||
line = ''
|
||||
for hostname in record.get('host').split(','):
|
||||
line += hostname + ':{port}:*:{user}:{password}'.format(**record) + '\n'
|
||||
return line.rstrip()
|
||||
|
||||
def write_pgpass(self, record):
|
||||
line = self._pgpass_line(record)
|
||||
@@ -756,13 +780,22 @@ class ConfigHandler(object):
|
||||
return env
|
||||
|
||||
def write_recovery_conf(self, recovery_params):
|
||||
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()
|
||||
else:
|
||||
self._remove_file_if_exists(self._standby_signal)
|
||||
open(self._recovery_signal, 'w').close()
|
||||
self._recovery_params = recovery_params
|
||||
|
||||
def restart_required(name):
|
||||
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 = {n: [v, restart_required(n), self._postgresql_conf]
|
||||
for n, v in recovery_params.items()}
|
||||
else:
|
||||
with ConfigWriter(self._recovery_conf) as f:
|
||||
os.chmod(self._recovery_conf, stat.S_IWRITE | stat.S_IREAD)
|
||||
@@ -772,6 +805,7 @@ class ConfigHandler(object):
|
||||
for name in (self._recovery_conf, self._standby_signal, self._recovery_signal):
|
||||
self._remove_file_if_exists(name)
|
||||
self._recovery_params = {}
|
||||
self._current_recovery_params = None
|
||||
|
||||
def _sanitize_auto_conf(self):
|
||||
overwrite = False
|
||||
@@ -806,8 +840,8 @@ class ConfigHandler(object):
|
||||
|
||||
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
|
||||
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):
|
||||
parameters = config['parameters'].copy()
|
||||
@@ -831,7 +865,7 @@ class ConfigHandler(object):
|
||||
# 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(wal_keep_segments * 16) + 'MB')
|
||||
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 + 8) / 16))
|
||||
@@ -867,7 +901,7 @@ class ConfigHandler(object):
|
||||
ret['user'] = self._superuser['username']
|
||||
del ret['username']
|
||||
# ensure certain Patroni configurations are available
|
||||
ret.update({'database': self._postgresql.database,
|
||||
ret.update({'dbname': self._postgresql.database,
|
||||
'fallback_application_name': 'Patroni',
|
||||
'connect_timeout': 3,
|
||||
'options': '-c statement_timeout=2000'})
|
||||
@@ -876,25 +910,21 @@ class ConfigHandler(object):
|
||||
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}
|
||||
|
||||
netloc = self._config.get('connect_address') or tcp_local_address + ':' + port
|
||||
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database)
|
||||
|
||||
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_address = unix_local_address if self._config.get('use_unix_socket') else tcp_local_address
|
||||
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)
|
||||
self._postgresql.set_connection_kwargs(self.local_connect_kwargs)
|
||||
|
||||
def _get_pg_settings(self, names):
|
||||
@@ -987,6 +1017,9 @@ class ConfigHandler(object):
|
||||
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()
|
||||
|
||||
@@ -1002,8 +1035,9 @@ class ConfigHandler(object):
|
||||
if self._postgresql.major_version >= 90500:
|
||||
time.sleep(1)
|
||||
try:
|
||||
pending_restart = self._postgresql.query('SELECT COUNT(*) FROM pg_catalog.pg_settings'
|
||||
' WHERE pending_restart').fetchone()[0] > 0
|
||||
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 self._RECOVERY_PARAMETERS]).fetchone()[0] > 0
|
||||
self._postgresql.set_pending_restart(pending_restart)
|
||||
except Exception as e:
|
||||
logger.warning('Exception %r when running query', e)
|
||||
@@ -1067,6 +1101,14 @@ class ConfigHandler(object):
|
||||
if 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 self._postgresql.bootstrap.running_custom_bootstrap and \
|
||||
(self._postgresql.bootstrap.keep_existing_recovery_conf or self._recovery_conf):
|
||||
effective_configuration['hot_standby'] = 'off'
|
||||
self._postgresql.set_pending_restart(True)
|
||||
|
||||
return effective_configuration
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
import psycopg2
|
||||
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
|
||||
from .. import psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -20,8 +21,7 @@ class Connection(object):
|
||||
def get(self):
|
||||
with self._lock:
|
||||
if not self._connection or self._connection.closed != 0:
|
||||
self._connection = psycopg2.connect(**self._conn_kwargs)
|
||||
self._connection.autocommit = True
|
||||
self._connection = psycopg.connect(**self._conn_kwargs)
|
||||
self.server_version = self._connection.server_version
|
||||
return self._connection
|
||||
|
||||
@@ -40,7 +40,7 @@ class Connection(object):
|
||||
|
||||
@contextmanager
|
||||
def get_connection_cursor(**kwargs):
|
||||
with psycopg2.connect(**kwargs) as conn:
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
conn = psycopg.connect(**kwargs)
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
conn.close()
|
||||
|
||||
@@ -37,7 +37,7 @@ def postgres_version_to_int(pg_version):
|
||||
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
|
||||
# new style version numbers, i.e. 10.1 becomes 100001
|
||||
components.insert(1, 0)
|
||||
|
||||
return int(''.join('{0:02d}'.format(c) for c in components))
|
||||
@@ -68,3 +68,8 @@ def parse_history(data):
|
||||
yield values
|
||||
except (IndexError, ValueError):
|
||||
logger.exception('Exception when parsing timeline history line "%s"', values)
|
||||
|
||||
|
||||
def format_lsn(lsn, full=False):
|
||||
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
|
||||
return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
|
||||
|
||||
@@ -171,8 +171,8 @@ class PostmasterProcess(psutil.Process):
|
||||
else:
|
||||
return not self.is_running()
|
||||
|
||||
def wait_for_user_backends_to_close(self):
|
||||
# These regexps are cross checked against versions PostgreSQL 9.1 .. 11
|
||||
def wait_for_user_backends_to_close(self, stop_timeout):
|
||||
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
|
||||
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
|
||||
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
|
||||
"walreceiver|wal sender process|walsender|walwriter|background writer|"
|
||||
@@ -184,19 +184,23 @@ class PostmasterProcess(psutil.Process):
|
||||
return logger.debug('Failed to get list of postmaster children')
|
||||
|
||||
user_backends = []
|
||||
user_backends_cmdlines = []
|
||||
user_backends_cmdlines = {}
|
||||
for child in children:
|
||||
try:
|
||||
cmdline = child.cmdline()
|
||||
if cmdline and not aux_proc_re.match(cmdline[0]):
|
||||
user_backends.append(child)
|
||||
user_backends_cmdlines.append(cmdline[0])
|
||||
user_backends_cmdlines[child.pid] = cmdline[0]
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
if user_backends:
|
||||
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines))
|
||||
psutil.wait_procs(user_backends)
|
||||
logger.debug("Backends closed")
|
||||
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines.values()))
|
||||
gone, live = psutil.wait_procs(user_backends, stop_timeout)
|
||||
if stop_timeout and live:
|
||||
live = [user_backends_cmdlines[b.pid] for b in live]
|
||||
logger.warning('Backends still alive after %s: %s', stop_timeout, ', '.join(live))
|
||||
else:
|
||||
logger.debug("Backends closed")
|
||||
|
||||
@staticmethod
|
||||
def start(pgcommand, data_dir, conf, options):
|
||||
|
||||
+149
-61
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import six
|
||||
import subprocess
|
||||
|
||||
from threading import Lock, Thread
|
||||
|
||||
from .connection import get_connection_cursor
|
||||
from .misc import parse_history, parse_lsn
|
||||
from .misc import format_lsn, parse_history, parse_lsn
|
||||
from ..async_executor import CriticalTask
|
||||
from ..dcs import Leader
|
||||
|
||||
@@ -17,11 +19,6 @@ REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECKPOINT': 1, 'CHECK': 2, 'NE
|
||||
'NOT_NEED': 4, 'SUCCESS': 5, 'FAILED': 6})
|
||||
|
||||
|
||||
def format_lsn(lsn, full=False):
|
||||
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
|
||||
return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
|
||||
|
||||
|
||||
class Rewind(object):
|
||||
|
||||
def __init__(self, postgresql):
|
||||
@@ -33,13 +30,17 @@ class Rewind(object):
|
||||
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 enabled(self):
|
||||
return self._postgresql.config.get('use_pg_rewind')
|
||||
|
||||
@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._postgresql.config.get('use_pg_rewind'):
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
cmd = [self._postgresql.pgcommand('pg_rewind'), '--help']
|
||||
@@ -51,9 +52,13 @@ class Rewind(object):
|
||||
return False
|
||||
return self.configuration_allows_rewind(self._postgresql.controldata())
|
||||
|
||||
@property
|
||||
def should_remove_data_directory_on_diverged_timelines(self):
|
||||
return self._postgresql.config.get('remove_data_directory_on_diverged_timelines')
|
||||
|
||||
@property
|
||||
def can_rewind_or_reinitialize_allowed(self):
|
||||
return self._postgresql.config.get('remove_data_directory_on_diverged_timelines') or self.can_rewind
|
||||
return self.should_remove_data_directory_on_diverged_timelines or self.can_rewind
|
||||
|
||||
def trigger_check_diverged_lsn(self):
|
||||
if self.can_rewind_or_reinitialize_allowed and self._state != REWIND_STATUS.NEED:
|
||||
@@ -70,26 +75,31 @@ class Rewind(object):
|
||||
except Exception:
|
||||
return logger.exception('Exception when working with leader')
|
||||
|
||||
@staticmethod
|
||||
def check_leader_has_run_checkpoint(conn_kwargs):
|
||||
try:
|
||||
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
|
||||
cur.execute("SELECT NOT pg_catalog.pg_is_in_recovery()" +
|
||||
" AND ('x' || pg_catalog.substr(pg_catalog.pg_walfile_name(" +
|
||||
" pg_catalog.pg_current_wal_lsn()), 1, 8))::bit(32)::int = timeline_id" +
|
||||
" FROM pg_catalog.pg_control_checkpoint()")
|
||||
if not cur.fetchone()[0]:
|
||||
return 'leader has not run a checkpoint yet'
|
||||
except Exception:
|
||||
logger.exception('Exception when working with leader')
|
||||
return 'not accessible or not healty'
|
||||
|
||||
def _get_checkpoint_end(self, timeline, lsn):
|
||||
"""The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
|
||||
Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump
|
||||
and parse the output. We are trying to read two records, and expect that it wil fail to read the second one:
|
||||
and parse the output. We are trying to read two records, and expect that it will fail to read the second one:
|
||||
`pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0`
|
||||
The error message contains information about LSN of the next record, which is exactly where checkpoint ends."""
|
||||
|
||||
cmd = self._postgresql.pgcommand('pg_{0}dump'.format(self._postgresql.wal_name))
|
||||
lsn8 = format_lsn(lsn, True)
|
||||
lsn = format_lsn(lsn)
|
||||
env = os.environ.copy()
|
||||
env.update(LANG='C', LC_ALL='C', PGDATA=self._postgresql.data_dir)
|
||||
try:
|
||||
waldump = subprocess.Popen([cmd, '-t', str(timeline), '-s', lsn, '-n', '2'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
out, err = waldump.communicate()
|
||||
waldump.wait()
|
||||
except Exception as e:
|
||||
logger.error('Failed to execute `%s -t %s -s %s -n 2`: %r', cmd, timeline, lsn, e)
|
||||
else:
|
||||
out, err = self._postgresql.waldump(timeline, lsn, 2)
|
||||
if out is not None and err is not None:
|
||||
out = out.decode('utf-8').rstrip().split('\n')
|
||||
err = err.decode('utf-8').rstrip().split('\n')
|
||||
pattern = 'error in WAL record at {0}: invalid record length at '.format(lsn)
|
||||
@@ -102,7 +112,7 @@ class Rewind(object):
|
||||
return parse_lsn(err[0][i:j])
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
|
||||
logger.error('Failed to parse `%s -t %s -s %s -n 2` output', cmd, timeline, lsn)
|
||||
logger.error('Failed to parse pg_%sdump output', self._postgresql.wal_name)
|
||||
logger.error(' stdout=%s', '\n'.join(out))
|
||||
logger.error(' stderr=%s', '\n'.join(err))
|
||||
|
||||
@@ -112,7 +122,7 @@ class Rewind(object):
|
||||
in_recovery = timeline = lsn = None
|
||||
data = self._postgresql.controldata()
|
||||
try:
|
||||
if data.get('Database cluster state') == 'shut down in recovery':
|
||||
if data.get('Database cluster state') in ('shut down in recovery', 'in archive recovery'):
|
||||
in_recovery = True
|
||||
lsn = data.get('Minimum recovery ending location')
|
||||
timeline = int(data.get("Min recovery ending loc's timeline"))
|
||||
@@ -166,8 +176,12 @@ class Rewind(object):
|
||||
|
||||
def _conn_kwargs(self, member, auth):
|
||||
ret = member.conn_kwargs(auth)
|
||||
if not ret.get('database'):
|
||||
ret['database'] = self._postgresql.database
|
||||
if not ret.get('dbname'):
|
||||
ret['dbname'] = self._postgresql.database
|
||||
# Add target_session_attrs in case more than one hostname is specified
|
||||
# (libpq client-side failover) making sure we hit the primary
|
||||
if 'target_session_attrs' not in ret and self._postgresql.major_version >= 100000:
|
||||
ret['target_session_attrs'] = 'read-write'
|
||||
return ret
|
||||
|
||||
def _check_timeline_and_lsn(self, leader):
|
||||
@@ -175,11 +189,17 @@ class Rewind(object):
|
||||
if local_timeline is None or local_lsn is None:
|
||||
return
|
||||
|
||||
if isinstance(leader, Leader):
|
||||
if leader.member.data.get('role') != 'master':
|
||||
return
|
||||
# standby cluster
|
||||
elif not self.check_leader_is_not_in_recovery(self._conn_kwargs(leader, self._postgresql.config.replication)):
|
||||
if isinstance(leader, Leader) and leader.member.data.get('role') != 'master':
|
||||
return
|
||||
|
||||
# We want to use replication credentials when connecting to the "postgres" database in case if
|
||||
# `use_pg_rewind` isn't enabled and only `remove_data_directory_on_diverged_timelines` is set
|
||||
# for Postgresql older than v11 (where Patroni can't use a dedicated user for rewind).
|
||||
# In all other cases we will use rewind or superuser credentials.
|
||||
check_credentials = self._postgresql.config.replication if not self.enabled and\
|
||||
self.should_remove_data_directory_on_diverged_timelines and\
|
||||
self._postgresql.major_version < 110000 else self._postgresql.config.rewind_credentials
|
||||
if not self.check_leader_is_not_in_recovery(self._conn_kwargs(leader, check_credentials)):
|
||||
return
|
||||
|
||||
history = need_rewind = None
|
||||
@@ -193,8 +213,10 @@ class Rewind(object):
|
||||
elif local_timeline == master_timeline:
|
||||
need_rewind = False
|
||||
elif master_timeline > 1:
|
||||
cur.execute('TIMELINE_HISTORY %s', (master_timeline,))
|
||||
history = bytes(cur.fetchone()[1]).decode('utf-8')
|
||||
cur.execute('TIMELINE_HISTORY {0}'.format(master_timeline))
|
||||
history = cur.fetchone()[1]
|
||||
if not isinstance(history, six.string_types):
|
||||
history = bytes(history).decode('utf-8')
|
||||
logger.debug('master: history=%s', history)
|
||||
except Exception:
|
||||
return logger.exception('Exception when working with master via replication connection')
|
||||
@@ -214,7 +236,10 @@ class Rewind(object):
|
||||
need_rewind = switchpoint != self._get_checkpoint_end(local_timeline, local_lsn)
|
||||
break
|
||||
elif parent_timeline > local_timeline:
|
||||
need_rewind = True
|
||||
break
|
||||
else:
|
||||
need_rewind = True
|
||||
self._log_master_history(history, i)
|
||||
|
||||
self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED
|
||||
@@ -242,42 +267,49 @@ class Rewind(object):
|
||||
with self._checkpoint_task_lock:
|
||||
if self._checkpoint_task:
|
||||
with self._checkpoint_task:
|
||||
if self._checkpoint_task.result:
|
||||
if self._checkpoint_task.result is not None:
|
||||
self._state = REWIND_STATUS.CHECKPOINT
|
||||
if self._checkpoint_task.result is not False:
|
||||
return
|
||||
self._checkpoint_task = None
|
||||
elif self._postgresql.get_master_timeline() == self._postgresql.pg_control_timeline():
|
||||
self._state = REWIND_STATUS.CHECKPOINT
|
||||
else:
|
||||
self._checkpoint_task = CriticalTask()
|
||||
return Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start()
|
||||
|
||||
if self._postgresql.get_master_timeline() == self._postgresql.pg_control_timeline():
|
||||
self._state = REWIND_STATUS.CHECKPOINT
|
||||
Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start()
|
||||
|
||||
def checkpoint_after_promote(self):
|
||||
return self._state == REWIND_STATUS.CHECKPOINT
|
||||
|
||||
def _fetch_missing_wal(self, restore_command, wal_filename):
|
||||
def _buid_archiver_command(self, command, wal_filename):
|
||||
"""Replace placeholders in the given archiver command's template.
|
||||
Applicable for archive_command and restore_command.
|
||||
Can also be used for archive_cleanup_command and recovery_end_command,
|
||||
however %r value is always set to 000000010000000000000001."""
|
||||
cmd = ''
|
||||
length = len(restore_command)
|
||||
length = len(command)
|
||||
i = 0
|
||||
while i < length:
|
||||
if restore_command[i] == '%' and i + 1 < length:
|
||||
if command[i] == '%' and i + 1 < length:
|
||||
i += 1
|
||||
if restore_command[i] == 'p':
|
||||
if command[i] == 'p':
|
||||
cmd += os.path.join(self._postgresql.wal_dir, wal_filename)
|
||||
elif restore_command[i] == 'f':
|
||||
elif command[i] == 'f':
|
||||
cmd += wal_filename
|
||||
elif restore_command[i] == 'r':
|
||||
elif command[i] == 'r':
|
||||
cmd += '000000010000000000000001'
|
||||
elif restore_command[i] == '%':
|
||||
elif command[i] == '%':
|
||||
cmd += '%'
|
||||
else:
|
||||
cmd += '%'
|
||||
i -= 1
|
||||
else:
|
||||
cmd += restore_command[i]
|
||||
cmd += command[i]
|
||||
i += 1
|
||||
|
||||
return cmd
|
||||
|
||||
def _fetch_missing_wal(self, restore_command, wal_filename):
|
||||
cmd = self._buid_archiver_command(restore_command, wal_filename)
|
||||
|
||||
logger.info('Trying to fetch the missing wal: %s', cmd)
|
||||
return self._postgresql.cancellable.call(shlex.split(cmd)) == 0
|
||||
|
||||
@@ -294,6 +326,42 @@ class Rewind(object):
|
||||
if waldir.endswith('/pg_' + self._postgresql.wal_name) and len(wal_filename) == 24:
|
||||
return wal_filename
|
||||
|
||||
def _archive_ready_wals(self):
|
||||
"""Try to archive WALs that have .ready files just in case
|
||||
archive_mode was not set to 'always' before promote, while
|
||||
after it the WALs were recycled on the promoted replica.
|
||||
With this we prevent the entire loss of such WALs and the
|
||||
consequent old leader's start failure."""
|
||||
archive_mode = self._postgresql.get_guc_value('archive_mode')
|
||||
archive_cmd = self._postgresql.get_guc_value('archive_command')
|
||||
if archive_mode not in ('on', 'always') or not archive_cmd:
|
||||
return
|
||||
|
||||
walseg_regex = re.compile(r'^[0-9A-F]{24}(\.partial){0,1}\.ready$')
|
||||
status_dir = os.path.join(self._postgresql.wal_dir, 'archive_status')
|
||||
try:
|
||||
wals_to_archive = [f[:-6] for f in os.listdir(status_dir) if walseg_regex.match(f)]
|
||||
except OSError as e:
|
||||
return logger.error('Unable to list %s: %r', status_dir, e)
|
||||
|
||||
# skip fsync, as postgres --single or pg_rewind will anyway run it
|
||||
for wal in sorted(wals_to_archive):
|
||||
old_name = os.path.join(status_dir, wal + '.ready')
|
||||
# wal file might have alredy been archived
|
||||
if os.path.isfile(old_name) and os.path.isfile(os.path.join(self._postgresql.wal_dir, wal)):
|
||||
cmd = self._buid_archiver_command(archive_cmd, wal)
|
||||
# it is the author of archive_command, who is responsible
|
||||
# for not overriding the WALs already present in archive
|
||||
logger.info('Trying to archive %s: %s', wal, cmd)
|
||||
if self._postgresql.cancellable.call(shlex.split(cmd)) == 0:
|
||||
new_name = os.path.join(status_dir, wal + '.done')
|
||||
try:
|
||||
shutil.move(old_name, new_name)
|
||||
except Exception as e:
|
||||
logger.error('Unable to rename %s to %s: %r', old_name, new_name, e)
|
||||
else:
|
||||
logger.info('Failed to archive WAL segment %s', wal)
|
||||
|
||||
def pg_rewind(self, r):
|
||||
# prepare pg_rewind connection
|
||||
env = self._postgresql.config.write_pgpass(r)
|
||||
@@ -304,9 +372,18 @@ class Rewind(object):
|
||||
restore_command = self._postgresql.config.get('recovery_conf', {}).get('restore_command') \
|
||||
if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command')
|
||||
|
||||
# Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian
|
||||
pg_rewind_can_restore = restore_command and (self._postgresql.major_version >= 150000 or
|
||||
(self._postgresql.major_version >= 130000 and
|
||||
self._postgresql.config._config_dir == self._postgresql.data_dir))
|
||||
|
||||
cmd = [self._postgresql.pgcommand('pg_rewind')]
|
||||
if self._postgresql.major_version >= 130000 and restore_command:
|
||||
if pg_rewind_can_restore:
|
||||
cmd.append('--restore-target-wal')
|
||||
if self._postgresql.major_version >= 150000 and\
|
||||
self._postgresql.config._config_dir != self._postgresql.data_dir:
|
||||
cmd.append('--config-file={0}'.format(self._postgresql.config.postgresql_conf))
|
||||
|
||||
cmd.extend(['-D', self._postgresql.data_dir, '--source-server', dsn])
|
||||
|
||||
while True:
|
||||
@@ -322,7 +399,7 @@ class Rewind(object):
|
||||
if ret == 0:
|
||||
return True
|
||||
|
||||
if not restore_command or self._postgresql.major_version >= 130000:
|
||||
if not restore_command or pg_rewind_can_restore:
|
||||
return False
|
||||
|
||||
missing_wal = self._find_missing_wal(results['stderr']) or self._find_missing_wal(results['stdout'])
|
||||
@@ -337,6 +414,8 @@ class Rewind(object):
|
||||
if self._postgresql.is_running() and not self._postgresql.stop(checkpoint=False):
|
||||
return logger.warning('Can not run pg_rewind because postgres is still running')
|
||||
|
||||
self._archive_ready_wals()
|
||||
|
||||
# prepare pg_rewind connection
|
||||
r = self._conn_kwargs(leader, self._postgresql.config.rewind_credentials)
|
||||
|
||||
@@ -345,9 +424,14 @@ class Rewind(object):
|
||||
# running a checkpoint or
|
||||
# waiting until Patroni on the master will expose checkpoint_after_promote=True
|
||||
checkpoint_status = leader.checkpoint_after_promote if isinstance(leader, Leader) else None
|
||||
if checkpoint_status is None: # master still runs the old Patroni
|
||||
leader_status = self._postgresql.checkpoint(self._conn_kwargs(leader, self._postgresql.config.superuser))
|
||||
if leader_status:
|
||||
if checkpoint_status is None: # we are the standby-cluster leader or master still runs the old Patroni
|
||||
# superuser credentials match rewind_credentials if the latter are not provided or we run 10 or older
|
||||
if self._postgresql.config.superuser == self._postgresql.config.rewind_credentials:
|
||||
leader_status = self._postgresql.checkpoint(
|
||||
self._conn_kwargs(leader, self._postgresql.config.superuser))
|
||||
else: # we run 11+ and have a dedicated pg_rewind user
|
||||
leader_status = self.check_leader_has_run_checkpoint(r)
|
||||
if leader_status: # we tried to run/check for a checkpoint on the remote leader, but it failed
|
||||
return logger.warning('Can not use %s for rewind: %s', leader.name, leader_status)
|
||||
elif not checkpoint_status:
|
||||
return logger.info('Waiting for checkpoint on %s before rewind', leader.name)
|
||||
@@ -356,19 +440,22 @@ class Rewind(object):
|
||||
|
||||
if self.pg_rewind(r):
|
||||
self._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)
|
||||
|
||||
for name in ('remove_data_directory_on_rewind_failure', 'remove_data_directory_on_diverged_timelines'):
|
||||
if self._postgresql.config.get(name):
|
||||
logger.warning('%s is set. removing...', name)
|
||||
self._postgresql.remove_data_directory()
|
||||
self._state = REWIND_STATUS.INITIAL
|
||||
break
|
||||
if not self.check_leader_is_not_in_recovery(r):
|
||||
logger.warning('Failed to rewind because master %s become unreachable', leader.name)
|
||||
if not self.can_rewind: # It is possible that the previous attempt damaged pg_control file!
|
||||
self._state = REWIND_STATUS.FAILED
|
||||
else:
|
||||
logger.error('Failed to rewind from healty master: %s', leader.name)
|
||||
self._state = REWIND_STATUS.FAILED
|
||||
|
||||
if self.failed:
|
||||
for name in ('remove_data_directory_on_rewind_failure', 'remove_data_directory_on_diverged_timelines'):
|
||||
if self._postgresql.config.get(name):
|
||||
logger.warning('%s is set. removing...', name)
|
||||
self._postgresql.remove_data_directory()
|
||||
self._state = REWIND_STATUS.INITIAL
|
||||
break
|
||||
return False
|
||||
|
||||
def reset_state(self):
|
||||
@@ -427,6 +514,7 @@ class Rewind(object):
|
||||
logger.exception('Unable to list %s', status_dir)
|
||||
|
||||
def ensure_clean_shutdown(self):
|
||||
self._archive_ready_wals()
|
||||
self.cleanup_archive_status()
|
||||
|
||||
# Start in a single user mode and stop to produce a clean shutdown
|
||||
|
||||
+361
-51
@@ -1,37 +1,187 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from patroni.postgresql.connection import get_connection_cursor
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from threading import Condition, Thread
|
||||
|
||||
from .connection import get_connection_cursor
|
||||
from .misc import format_lsn
|
||||
from ..psycopg import OperationalError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compare_slots(s1, s2):
|
||||
def compare_slots(s1, s2, dbid='database'):
|
||||
return s1['type'] == s2['type'] and (s1['type'] == 'physical' or
|
||||
s1['database'] == s2['database'] and s1['plugin'] == s2['plugin'])
|
||||
s1.get(dbid) == s2.get(dbid) and s1['plugin'] == s2['plugin'])
|
||||
|
||||
|
||||
def fsync_dir(path):
|
||||
if os.name != 'nt':
|
||||
fd = os.open(path, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
except OSError as e:
|
||||
# Some filesystems don't like fsyncing directories and raise EINVAL. Ignoring it is usually safe.
|
||||
if e.errno != errno.EINVAL:
|
||||
raise
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class SlotsAdvanceThread(Thread):
|
||||
|
||||
def __init__(self, slots_handler):
|
||||
super(SlotsAdvanceThread, self).__init__()
|
||||
self.daemon = True
|
||||
self._slots_handler = slots_handler
|
||||
|
||||
# _copy_slots and _failed are used to asynchronously give some feedback to the main thread
|
||||
self._copy_slots = []
|
||||
self._failed = False
|
||||
|
||||
self._scheduled = defaultdict(dict) # {'dbname1': {'slot1': 100, 'slot2': 100}, 'dbname2': {'slot3': 100}}
|
||||
self._condition = Condition() # protect self._scheduled from concurrent access and to wakeup the run() method
|
||||
|
||||
self.start()
|
||||
|
||||
def sync_slot(self, cur, database, slot, lsn):
|
||||
failed = copy = False
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", (slot, format_lsn(lsn)))
|
||||
except Exception as e:
|
||||
logger.error("Failed to advance logical replication slot '%s': %r", slot, e)
|
||||
failed = True
|
||||
copy = isinstance(e, OperationalError) and e.diag.sqlstate == '58P01' # WAL file is gone
|
||||
with self._condition:
|
||||
if self._scheduled and failed:
|
||||
if copy and slot not in self._copy_slots:
|
||||
self._copy_slots.append(slot)
|
||||
self._failed = True
|
||||
|
||||
new_lsn = self._scheduled.get(database, {}).get(slot, 0)
|
||||
# remove slot from the self._scheduled structure only if it wasn't changed
|
||||
if new_lsn == lsn and database in self._scheduled:
|
||||
self._scheduled[database].pop(slot)
|
||||
if not self._scheduled[database]:
|
||||
self._scheduled.pop(database)
|
||||
|
||||
def sync_slots_in_database(self, database, slots):
|
||||
with self._slots_handler.get_local_connection_cursor(dbname=database, options='-c statement_timeout=0') as cur:
|
||||
for slot in slots:
|
||||
with self._condition:
|
||||
lsn = self._scheduled.get(database, {}).get(slot, 0)
|
||||
if lsn:
|
||||
self.sync_slot(cur, database, slot, lsn)
|
||||
|
||||
def sync_slots(self):
|
||||
with self._condition:
|
||||
databases = list(self._scheduled.keys())
|
||||
for database in databases:
|
||||
with self._condition:
|
||||
slots = list(self._scheduled.get(database, {}).keys())
|
||||
if slots:
|
||||
try:
|
||||
self.sync_slots_in_database(database, slots)
|
||||
except Exception as e:
|
||||
logger.error('Failed to advance replication slots in database %s: %r', database, e)
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
with self._condition:
|
||||
if not self._scheduled:
|
||||
self._condition.wait()
|
||||
|
||||
self.sync_slots()
|
||||
|
||||
def schedule(self, advance_slots):
|
||||
with self._condition:
|
||||
for database, values in advance_slots.items():
|
||||
self._scheduled[database].update(values)
|
||||
ret = (self._failed, self._copy_slots)
|
||||
self._copy_slots = []
|
||||
self._failed = False
|
||||
self._condition.notify()
|
||||
|
||||
return ret
|
||||
|
||||
def on_promote(self):
|
||||
with self._condition:
|
||||
self._scheduled.clear()
|
||||
self._failed = False
|
||||
self._copy_slots = []
|
||||
|
||||
|
||||
class SlotsHandler(object):
|
||||
|
||||
def __init__(self, postgresql):
|
||||
self._postgresql = postgresql
|
||||
self._advance = None
|
||||
self._replication_slots = {} # already existing replication slots
|
||||
self._unready_logical_slots = {}
|
||||
self.schedule()
|
||||
|
||||
def _query(self, sql, *params):
|
||||
return self._postgresql.query(sql, *params, retry=False)
|
||||
|
||||
@staticmethod
|
||||
def _copy_items(src, dst, keys=None):
|
||||
dst.update({key: src[key] for key in keys or ('datoid', 'catalog_xmin', 'confirmed_flush_lsn')})
|
||||
|
||||
def process_permanent_slots(self, slots):
|
||||
"""This methods solves three problems at once (I know, it is weird).
|
||||
|
||||
The cluster_info_query from `Postgresql` is executed every HA loop and returns
|
||||
information about all replication slots that exists on the current host.
|
||||
Based on this information we perform the following actions:
|
||||
1. For the primary we want to expose to DCS permanent logical slots, therefore the method
|
||||
builds (and returns) a dict, that maps permanent logical slot names and confirmed_flush_lsns.
|
||||
2. This method also detects if one of the previously known permanent slots got missing and schedules resync.
|
||||
3. Updates the local cache with the fresh catalog_xmin and confirmed_flush_lsn for every known slot.
|
||||
This info is used when performing the check of logical slot readiness on standbys.
|
||||
"""
|
||||
ret = {}
|
||||
|
||||
slots = {slot['slot_name']: slot for slot in slots or []}
|
||||
if slots:
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots:
|
||||
if compare_slots(value, self._replication_slots[name], 'datoid'):
|
||||
if value['type'] == 'logical':
|
||||
ret[name] = value['confirmed_flush_lsn']
|
||||
self._copy_items(value, self._replication_slots[name])
|
||||
else:
|
||||
self._schedule_load_slots = True
|
||||
|
||||
# It could happen that the slots was deleted in the background, we want to detect this case
|
||||
if any(name not in slots for name in self._replication_slots.keys()):
|
||||
self._schedule_load_slots = True
|
||||
|
||||
return ret
|
||||
|
||||
def load_replication_slots(self):
|
||||
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
|
||||
replication_slots = {}
|
||||
cursor = self._query('SELECT slot_name, slot_type, plugin, database FROM pg_catalog.pg_replication_slots')
|
||||
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
|
||||
if self._postgresql.major_version >= 100000 else ""
|
||||
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
|
||||
cursor = self._query('SELECT slot_name, slot_type, plugin, database, datoid'
|
||||
'{0} FROM pg_catalog.pg_replication_slots{1}'.format(extra, skip_temp_slots))
|
||||
for r in cursor:
|
||||
value = {'type': r[1]}
|
||||
if r[1] == 'logical':
|
||||
value.update({'plugin': r[2], 'database': r[3]})
|
||||
value.update(plugin=r[2], database=r[3], datoid=r[4])
|
||||
if self._postgresql.major_version >= 100000:
|
||||
value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6])
|
||||
replication_slots[r[0]] = value
|
||||
self._replication_slots = replication_slots
|
||||
self._schedule_load_slots = False
|
||||
if self._force_readiness_check:
|
||||
self._unready_logical_slots = {n: None for n, v in replication_slots.items() if v['type'] == 'logical'}
|
||||
self._force_readiness_check = False
|
||||
|
||||
def ignore_replication_slot(self, cluster, name):
|
||||
slot = self._replication_slots[name]
|
||||
@@ -47,65 +197,225 @@ class SlotsHandler(object):
|
||||
# In normal situation rowcount should be 1, otherwise either slot doesn't exists or it is still active
|
||||
return cursor.rowcount == 1
|
||||
|
||||
def sync_replication_slots(self, cluster):
|
||||
def _drop_incorrect_slots(self, cluster, slots, paused):
|
||||
# drop old replication slots which are not presented in desired slots
|
||||
for name in set(self._replication_slots) - set(slots):
|
||||
if not paused and not self.ignore_replication_slot(cluster, name) and not self.drop_replication_slot(name):
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]):
|
||||
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
|
||||
name, self._replication_slots[name], value)
|
||||
if self.drop_replication_slot(name):
|
||||
self._replication_slots.pop(name)
|
||||
else:
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def _ensure_physical_slots(self, slots):
|
||||
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
|
||||
for name, value in slots.items():
|
||||
if name not in self._replication_slots and value['type'] == 'physical':
|
||||
try:
|
||||
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
|
||||
immediately_reserve), name, name)
|
||||
except Exception:
|
||||
logger.exception("Failed to create physical replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
@contextmanager
|
||||
def get_local_connection_cursor(self, **kwargs):
|
||||
conn_kwargs = self._postgresql.config.local_connect_kwargs
|
||||
conn_kwargs.update(kwargs)
|
||||
with get_connection_cursor(**conn_kwargs) as cur:
|
||||
yield cur
|
||||
|
||||
def _ensure_logical_slots_primary(self, slots):
|
||||
# Group logical slots to be created by database name
|
||||
logical_slots = defaultdict(dict)
|
||||
for name, value in slots.items():
|
||||
if value['type'] == 'logical':
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if self._replication_slots.get(name, {}).get('datoid'):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
else:
|
||||
logical_slots[value['database']][name] = value
|
||||
|
||||
# Create new logical slots
|
||||
for database, values in logical_slots.items():
|
||||
with self.get_local_connection_cursor(dbname=database) as cur:
|
||||
for name, value in values.items():
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'logical' AND slot_name = %s)",
|
||||
(name, value['plugin'], name))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create logical replication slot '%s' plugin='%s': %r",
|
||||
name, value['plugin'], e)
|
||||
slots.pop(name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def schedule_advance_slots(self, slots):
|
||||
if not self._advance:
|
||||
self._advance = SlotsAdvanceThread(self)
|
||||
return self._advance.schedule(slots)
|
||||
|
||||
def _ensure_logical_slots_replica(self, cluster, slots):
|
||||
advance_slots = defaultdict(dict) # Group logical slots to be advanced by database name
|
||||
create_slots = [] # And collect logical slots to be created on the replica
|
||||
for name, value in slots.items():
|
||||
if value['type'] == 'logical':
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if self._replication_slots.get(name, {}).get('datoid'):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
if name in cluster.slots:
|
||||
try: # Skip slots that doesn't need to be advanced
|
||||
if value['confirmed_flush_lsn'] < int(cluster.slots[name]):
|
||||
advance_slots[value['database']][name] = int(cluster.slots[name])
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse "%s": %r', cluster.slots[name], e)
|
||||
elif name in cluster.slots: # We want to copy only slots with feedback in a DCS
|
||||
create_slots.append(name)
|
||||
|
||||
error, copy_slots = self.schedule_advance_slots(advance_slots)
|
||||
if error:
|
||||
self._schedule_load_slots = True
|
||||
return create_slots + copy_slots
|
||||
|
||||
def sync_replication_slots(self, cluster, nofailover, replicatefrom=None, paused=False):
|
||||
ret = None
|
||||
if self._postgresql.major_version >= 90400 and cluster.config:
|
||||
try:
|
||||
self.load_replication_slots()
|
||||
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role)
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role,
|
||||
nofailover, self._postgresql.major_version, True)
|
||||
|
||||
# drop old replication slots which are not presented in desired slots
|
||||
for name in set(self._replication_slots) - set(slots):
|
||||
if not self.ignore_replication_slot(cluster, name) and not self.drop_replication_slot(name):
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
self._drop_incorrect_slots(cluster, slots, paused)
|
||||
|
||||
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
|
||||
self._ensure_physical_slots(slots)
|
||||
|
||||
logical_slots = defaultdict(dict)
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]):
|
||||
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
|
||||
name, self._replication_slots[name], value)
|
||||
if not self.drop_replication_slot(name):
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
continue
|
||||
self._replication_slots.pop(name)
|
||||
if name not in self._replication_slots:
|
||||
if value['type'] == 'physical':
|
||||
try:
|
||||
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
|
||||
immediately_reserve), name, name)
|
||||
except Exception:
|
||||
logger.exception("Failed to create physical replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
elif value['type'] == 'logical' and name not in self._replication_slots:
|
||||
logical_slots[value['database']][name] = value
|
||||
if self._postgresql.is_leader():
|
||||
self._unready_logical_slots.clear()
|
||||
self._ensure_logical_slots_primary(slots)
|
||||
elif cluster.slots and slots:
|
||||
self.check_logical_slots_readiness(cluster, nofailover, replicatefrom)
|
||||
|
||||
ret = self._ensure_logical_slots_replica(cluster, slots)
|
||||
|
||||
# create new logical slots
|
||||
for database, values in logical_slots.items():
|
||||
conn_kwargs = self._postgresql.config.local_connect_kwargs
|
||||
conn_kwargs['database'] = database
|
||||
with get_connection_cursor(**conn_kwargs) as cur:
|
||||
for name, value in values.items():
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'logical' AND slot_name = %s)",
|
||||
(name, value['plugin'], name))
|
||||
except Exception:
|
||||
logger.exception("Failed to create logical replication slot '%s' plugin='%s'",
|
||||
name, value['plugin'])
|
||||
self._schedule_load_slots = True
|
||||
self._replication_slots = slots
|
||||
except Exception:
|
||||
logger.exception('Exception when changing replication slots')
|
||||
self._schedule_load_slots = True
|
||||
return ret
|
||||
|
||||
@contextmanager
|
||||
def _get_leader_connection_cursor(self, leader):
|
||||
conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
|
||||
conn_kwargs['dbname'] = self._postgresql.database
|
||||
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
|
||||
yield cur
|
||||
|
||||
def check_logical_slots_readiness(self, cluster, nofailover, replicatefrom):
|
||||
if self._unready_logical_slots:
|
||||
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
|
||||
try:
|
||||
with self._get_leader_connection_cursor(cluster.leader) as cur:
|
||||
cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
|
||||
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)",
|
||||
([n for n, v in self._unready_logical_slots.items() if v is None] + [slot_name],))
|
||||
slots = {row[0]: row[1] for row in cur}
|
||||
if slot_name not in slots:
|
||||
return logger.warning('Physical slot %s does not exist on the primary', slot_name)
|
||||
catalog_xmin = slots.pop(slot_name)
|
||||
except Exception as e:
|
||||
return logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e)
|
||||
# Remember catalog_xmin of logical slots on the primary when catalog_xmin of
|
||||
# the physical slot became valid. Logical slots on replica will be safe to use after
|
||||
# promote when catalog_xmin of the physical slot overtakes these values.
|
||||
if catalog_xmin:
|
||||
for name, value in slots.items():
|
||||
self._unready_logical_slots[name] = value
|
||||
else: # Replica isn't streaming or the hot_standby_feedback isn't enabled
|
||||
try:
|
||||
cur = self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")
|
||||
if not cur.fetchone()[0]:
|
||||
logger.error('Logical slot failover requires "hot_standby_feedback".'
|
||||
' Please check postgresql.auto.conf')
|
||||
except Exception as e:
|
||||
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
|
||||
return # since `catalog_xmin` isn't valid further checks don't make any sense
|
||||
|
||||
for name in list(self._unready_logical_slots):
|
||||
value = self._replication_slots.get(name)
|
||||
# The logical slot on a replica is safe to use when the physical replica slot on the primary:
|
||||
# 1. has a nonzero/non-null catalog_xmin
|
||||
# 2. has a catalog_xmin that is not newer (greater) than the catalog_xmin of any slot on the standby
|
||||
# 3. overtook the catalog_xmin of remembered values of logical slots on the primary.
|
||||
if not value or self._unready_logical_slots[name] <= catalog_xmin <= value['catalog_xmin']:
|
||||
del self._unready_logical_slots[name]
|
||||
if value:
|
||||
logger.info('Logical slot %s is safe to be used after a failover', name)
|
||||
|
||||
def copy_logical_slots(self, cluster, create_slots):
|
||||
leader = cluster.leader
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version)
|
||||
with self._get_leader_connection_cursor(leader) as cur:
|
||||
try:
|
||||
cur.execute("SELECT slot_name, slot_type, datname, plugin, catalog_xmin, "
|
||||
"pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint, "
|
||||
"pg_catalog.pg_read_binary_file('pg_replslot/' || slot_name || '/state')"
|
||||
" FROM pg_catalog.pg_get_replication_slots() JOIN pg_catalog.pg_database ON datoid = oid"
|
||||
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)", (create_slots,))
|
||||
|
||||
create_slots = {}
|
||||
for r in cur:
|
||||
if r[0] in slots: # slot_name is defined in the global configuration
|
||||
slot = {'type': r[1], 'database': r[2], 'plugin': r[3],
|
||||
'catalog_xmin': r[4], 'confirmed_flush_lsn': r[5], 'data': r[6]}
|
||||
if compare_slots(slot, slots[r[0]]):
|
||||
create_slots[r[0]] = slot
|
||||
else:
|
||||
logger.warning('Will not copy the logical slot "%s" due to the configuration mismatch: ' +
|
||||
'configuration=%s, slot on the primary=%s', r[0], slots[r[0]], slot)
|
||||
except Exception as e:
|
||||
logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e)
|
||||
|
||||
if isinstance(create_slots, dict) and create_slots and self._postgresql.stop():
|
||||
pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
|
||||
for name, value in create_slots.items():
|
||||
slot_dir = os.path.join(pg_replslot_dir, name)
|
||||
slot_tmp_dir = slot_dir + '.tmp'
|
||||
if os.path.exists(slot_tmp_dir):
|
||||
shutil.rmtree(slot_tmp_dir)
|
||||
os.makedirs(slot_tmp_dir)
|
||||
fsync_dir(slot_tmp_dir)
|
||||
with open(os.path.join(slot_tmp_dir, 'state'), 'wb') as f:
|
||||
f.write(value['data'])
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
if os.path.exists(slot_dir):
|
||||
shutil.rmtree(slot_dir)
|
||||
os.rename(slot_tmp_dir, slot_dir)
|
||||
fsync_dir(slot_dir)
|
||||
self._unready_logical_slots[name] = None
|
||||
fsync_dir(pg_replslot_dir)
|
||||
self._postgresql.start()
|
||||
|
||||
def schedule(self, value=None):
|
||||
if value is None:
|
||||
value = self._postgresql.major_version >= 90400
|
||||
self._schedule_load_slots = value
|
||||
self._schedule_load_slots = self._force_readiness_check = value
|
||||
|
||||
def on_promote(self):
|
||||
if self._advance:
|
||||
self._advance.on_promote()
|
||||
|
||||
if self._unready_logical_slots:
|
||||
logger.warning('Logical replication slots that might be unsafe to use after promote: %s',
|
||||
set(self._unready_logical_slots))
|
||||
|
||||
@@ -99,16 +99,18 @@ class String(namedtuple('String', 'version_from,version_till')):
|
||||
# key - parameter name
|
||||
# value - tuple or multiple tuples if something was changing in GUC across postgres versions
|
||||
parameters = CaseInsensitiveDict({
|
||||
'allow_in_place_tablespaces': Bool(150000, None),
|
||||
'allow_system_table_mods': Bool(90300, None),
|
||||
'application_name': String(90300, None),
|
||||
'archive_command': String(90300, None),
|
||||
'archive_library': String(150000, None),
|
||||
'archive_mode': (
|
||||
Bool(90300, 90500),
|
||||
EnumBool(90500, None, ('always',))
|
||||
),
|
||||
'archive_timeout': Integer(90300, None, 0, 1073741823, 's'),
|
||||
'array_nulls': Bool(90300, None),
|
||||
'authentication_timeout': Integer(90300, None, 1, 600, 's'),
|
||||
'authentication_timeout': Integer(90300, None, 1, 600, 's'),
|
||||
'autovacuum': Bool(90300, None),
|
||||
'autovacuum_analyze_scale_factor': Real(90300, None, 0, 100, None),
|
||||
'autovacuum_analyze_threshold': Integer(90300, None, 0, 2147483647, None),
|
||||
@@ -151,12 +153,17 @@ parameters = CaseInsensitiveDict({
|
||||
Integer(90600, None, 30, 86400, 's')
|
||||
),
|
||||
'checkpoint_warning': Integer(90300, None, 0, 2147483647, 's'),
|
||||
'client_connection_check_interval': Integer(140000, None, 0, 2147483647, 'ms'),
|
||||
'client_encoding': String(90300, None),
|
||||
'client_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
|
||||
'debug1', 'log', 'notice', 'warning', 'error')),
|
||||
'cluster_name': String(90500, None),
|
||||
'commit_delay': Integer(90300, None, 0, 100000, None),
|
||||
'commit_siblings': Integer(90300, None, 0, 1000, None),
|
||||
'compute_query_id': (
|
||||
EnumBool(140000, 150000, ('auto',)),
|
||||
EnumBool(150000, None, ('auto', 'regress'))
|
||||
),
|
||||
'config_file': String(90300, None),
|
||||
'constraint_exclusion': EnumBool(90300, None, ('partition',)),
|
||||
'cpu_index_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
@@ -168,6 +175,7 @@ parameters = CaseInsensitiveDict({
|
||||
'DateStyle': String(90300, None),
|
||||
'db_user_namespace': Bool(90300, None),
|
||||
'deadlock_timeout': Integer(90300, None, 1, 2147483647, 'ms'),
|
||||
'debug_discard_caches': Integer(150000, None, 0, 0, None),
|
||||
'debug_pretty_print': Bool(90300, None),
|
||||
'debug_print_parse': Bool(90300, None),
|
||||
'debug_print_plan': Bool(90300, None),
|
||||
@@ -176,6 +184,7 @@ parameters = CaseInsensitiveDict({
|
||||
'default_table_access_method': String(120000, None),
|
||||
'default_tablespace': String(90300, None),
|
||||
'default_text_search_config': String(90300, None),
|
||||
'default_toast_compression': Enum(140000, None, ('pglz', 'lz4')),
|
||||
'default_transaction_deferrable': Bool(90300, None),
|
||||
'default_transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
|
||||
'read committed', 'read uncommitted')),
|
||||
@@ -188,6 +197,7 @@ parameters = CaseInsensitiveDict({
|
||||
),
|
||||
'effective_cache_size': Integer(90300, None, 1, 2147483647, '8kB'),
|
||||
'effective_io_concurrency': Integer(90300, None, 0, 1000, None),
|
||||
'enable_async_append': Bool(140000, None),
|
||||
'enable_bitmapscan': Bool(90300, None),
|
||||
'enable_gathermerge': Bool(100000, None),
|
||||
'enable_hashagg': Bool(90300, None),
|
||||
@@ -196,6 +206,7 @@ parameters = CaseInsensitiveDict({
|
||||
'enable_indexonlyscan': Bool(90300, None),
|
||||
'enable_indexscan': Bool(90300, None),
|
||||
'enable_material': Bool(90300, None),
|
||||
'enable_memoize': Bool(150000, None),
|
||||
'enable_mergejoin': Bool(90300, None),
|
||||
'enable_nestloop': Bool(90300, None),
|
||||
'enable_parallel_append': Bool(110000, None),
|
||||
@@ -209,6 +220,7 @@ parameters = CaseInsensitiveDict({
|
||||
'escape_string_warning': Bool(90300, None),
|
||||
'event_source': String(90300, None),
|
||||
'exit_on_error': Bool(90300, None),
|
||||
'extension_destdir': String(140000, None),
|
||||
'external_pid_file': String(90300, None),
|
||||
'extra_float_digits': Integer(90300, None, -15, 3, None),
|
||||
'force_parallel_mode': EnumBool(90600, None, ('regress',)),
|
||||
@@ -229,8 +241,10 @@ parameters = CaseInsensitiveDict({
|
||||
'hot_standby': Bool(90300, None),
|
||||
'hot_standby_feedback': Bool(90300, None),
|
||||
'huge_pages': EnumBool(90400, None, ('try',)),
|
||||
'huge_page_size': Integer(140000, None, 0, 2147483647, 'kB'),
|
||||
'ident_file': String(90300, None),
|
||||
'idle_in_transaction_session_timeout': Integer(90600, None, 0, 2147483647, 'ms'),
|
||||
'idle_session_timeout': Integer(140000, None, 0, 2147483647, 'ms'),
|
||||
'ignore_checksum_failure': Bool(90300, None),
|
||||
'ignore_invalid_pages': Bool(130000, None),
|
||||
'ignore_system_indexes': Bool(90300, None),
|
||||
@@ -283,9 +297,11 @@ parameters = CaseInsensitiveDict({
|
||||
'log_parameter_max_length_on_error': Integer(130000, None, -1, 1073741823, 'B'),
|
||||
'log_parser_stats': Bool(90300, None),
|
||||
'log_planner_stats': Bool(90300, None),
|
||||
'log_recovery_conflict_waits': Bool(140000, None),
|
||||
'log_replication_commands': Bool(90500, None),
|
||||
'log_rotation_age': Integer(90300, None, 0, 35791394, 'min'),
|
||||
'log_rotation_size': Integer(90300, None, 0, 2097151, 'kB'),
|
||||
'log_startup_progress_interval': Integer(150000, None, 0, 2147483647, 'ms'),
|
||||
'log_statement': Enum(90300, None, ('none', 'ddl', 'mod', 'all')),
|
||||
'log_statement_sample_rate': Real(130000, None, 0, 1, None),
|
||||
'log_statement_stats': Bool(90300, None),
|
||||
@@ -336,6 +352,7 @@ parameters = CaseInsensitiveDict({
|
||||
Integer(90400, 90600, 1, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'min_dynamic_shared_memory': Integer(140000, None, 0, 2147483647, 'MB'),
|
||||
'min_parallel_index_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
|
||||
'min_parallel_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'),
|
||||
'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
|
||||
@@ -344,7 +361,7 @@ parameters = CaseInsensitiveDict({
|
||||
Integer(100000, None, 2, 2147483647, 'MB')
|
||||
),
|
||||
'old_snapshot_threshold': Integer(90600, None, -1, 86400, 'min'),
|
||||
'operator_precedence_warning': Bool(90500, None),
|
||||
'operator_precedence_warning': Bool(90500, 140000),
|
||||
'parallel_leader_participation': Bool(110000, None),
|
||||
'parallel_setup_cost': Real(90600, None, 0, 1.79769e+308, None),
|
||||
'parallel_tuple_cost': Real(90600, None, 0, 1.79769e+308, None),
|
||||
@@ -358,6 +375,10 @@ parameters = CaseInsensitiveDict({
|
||||
'pre_auth_delay': Integer(90300, None, 0, 60, 's'),
|
||||
'quote_all_identifiers': Bool(90300, None),
|
||||
'random_page_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
'recovery_init_sync_method': Enum(140000, None, ('fsync', 'syncfs')),
|
||||
'recovery_prefetch': EnumBool(150000, None, ('try',)),
|
||||
'recursive_worktable_factor': Real(150000, None, 0.001, 1e+06, None),
|
||||
'remove_temp_files_after_crash': Bool(140000, None),
|
||||
'replacement_sort_tuples': Integer(90600, 110000, 0, 2147483647, None),
|
||||
'restart_after_crash': Bool(90300, None),
|
||||
'row_security': Bool(90500, None),
|
||||
@@ -373,6 +394,7 @@ parameters = CaseInsensitiveDict({
|
||||
'ssl_ca_file': String(90300, None),
|
||||
'ssl_cert_file': String(90300, None),
|
||||
'ssl_ciphers': String(90300, None),
|
||||
'ssl_crl_dir': String(140000, None),
|
||||
'ssl_crl_file': String(90300, None),
|
||||
'ssl_dh_params_file': String(100000, None),
|
||||
'ssl_ecdh_curve': String(90400, None),
|
||||
@@ -385,10 +407,11 @@ parameters = CaseInsensitiveDict({
|
||||
'ssl_renegotiation_limit': Integer(90300, 90500, 0, 2147483647, 'kB'),
|
||||
'standard_conforming_strings': Bool(90300, None),
|
||||
'statement_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
|
||||
'stats_temp_directory': String(90300, None),
|
||||
'stats_fetch_consistency': Enum(150000, None, ('none', 'cache', 'snapshot')),
|
||||
'stats_temp_directory': String(90300, 150000),
|
||||
'superuser_reserved_connections': (
|
||||
Integer(90300, 90600, 0, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'synchronize_seqscans': Bool(90300, None),
|
||||
'synchronous_commit': (
|
||||
@@ -424,6 +447,7 @@ parameters = CaseInsensitiveDict({
|
||||
'track_counts': Bool(90300, None),
|
||||
'track_functions': Enum(90300, None, ('none', 'pl', 'all')),
|
||||
'track_io_timing': Bool(90300, None),
|
||||
'track_wal_io_timing': Bool(140000, None),
|
||||
'transaction_deferrable': Bool(90300, None),
|
||||
'transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
|
||||
'read committed', 'read uncommitted')),
|
||||
@@ -433,7 +457,7 @@ parameters = CaseInsensitiveDict({
|
||||
'unix_socket_group': String(90300, None),
|
||||
'unix_socket_permissions': Integer(90300, None, 0, 511, None),
|
||||
'update_process_title': Bool(90300, None),
|
||||
'vacuum_cleanup_index_scale_factor': Real(110000, None, 0, 1e+10, None),
|
||||
'vacuum_cleanup_index_scale_factor': Real(110000, 140000, 0, 1e+10, None),
|
||||
'vacuum_cost_delay': (
|
||||
Integer(90300, 120000, 0, 100, 'ms'),
|
||||
Real(120000, None, 0, 100, 'ms')
|
||||
@@ -443,13 +467,19 @@ parameters = CaseInsensitiveDict({
|
||||
'vacuum_cost_page_hit': Integer(90300, None, 0, 10000, None),
|
||||
'vacuum_cost_page_miss': Integer(90300, None, 0, 10000, None),
|
||||
'vacuum_defer_cleanup_age': Integer(90300, None, 0, 1000000, None),
|
||||
'vacuum_failsafe_age': Integer(140000, None, 0, 2100000000, None),
|
||||
'vacuum_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
|
||||
'vacuum_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
|
||||
'vacuum_multixact_failsafe_age': Integer(140000, None, 0, 2100000000, None),
|
||||
'vacuum_multixact_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
|
||||
'vacuum_multixact_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
|
||||
'wal_buffers': Integer(90300, None, -1, 262143, '8kB'),
|
||||
'wal_compression': Bool(90500, None),
|
||||
'wal_compression': (
|
||||
Bool(90500, 150000),
|
||||
EnumBool(150000, None, ('pglz', 'lz4', 'zstd'))
|
||||
),
|
||||
'wal_consistency_checking': String(100000, None),
|
||||
'wal_decode_buffer_size': Integer(150000, None, 65536, 1073741823, 'B'),
|
||||
'wal_init_zero': Bool(120000, None),
|
||||
'wal_keep_segments': Integer(90300, 130000, 0, 2147483647, None),
|
||||
'wal_keep_size': Integer(130000, None, 0, 2147483647, 'MB'),
|
||||
@@ -511,6 +541,8 @@ def _transform_parameter_value(validators, version, name, value):
|
||||
def transform_postgresql_parameter_value(version, name, value):
|
||||
if '.' in name:
|
||||
return value
|
||||
if name in recovery_parameters:
|
||||
return None
|
||||
return _transform_parameter_value(parameters, version, name, value)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError']
|
||||
|
||||
_legacy = False
|
||||
try:
|
||||
from psycopg2 import __version__
|
||||
from . import MIN_PSYCOPG2, parse_version
|
||||
if parse_version(__version__) < MIN_PSYCOPG2:
|
||||
raise ImportError
|
||||
from psycopg2 import connect as _connect, Error, DatabaseError, OperationalError, ProgrammingError
|
||||
from psycopg2.extensions import adapt
|
||||
|
||||
try:
|
||||
from psycopg2.extensions import quote_ident as _quote_ident
|
||||
except ImportError:
|
||||
_legacy = True
|
||||
|
||||
def quote_literal(value, conn=None):
|
||||
value = adapt(value)
|
||||
if conn:
|
||||
value.prepare(conn)
|
||||
return value.getquoted().decode('utf-8')
|
||||
except ImportError:
|
||||
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
|
||||
|
||||
def _connect(*args, **kwargs):
|
||||
ret = __connect(*args, **kwargs)
|
||||
ret.server_version = ret.pgconn.server_version # compatibility with psycopg2
|
||||
return ret
|
||||
|
||||
def _quote_ident(value, conn):
|
||||
return sql.Identifier(value).as_string(conn)
|
||||
|
||||
def quote_literal(value, conn=None):
|
||||
return sql.Literal(value).as_string(conn)
|
||||
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
if kwargs and 'replication' not in kwargs and kwargs.get('fallback_application_name') != 'Patroni ctl':
|
||||
options = [kwargs['options']] if 'options' in kwargs else []
|
||||
options.append('-c search_path=pg_catalog')
|
||||
kwargs['options'] = ' '.join(options)
|
||||
ret = _connect(*args, **kwargs)
|
||||
ret.autocommit = True
|
||||
return ret
|
||||
|
||||
|
||||
def quote_ident(value, conn=None):
|
||||
if _legacy or conn is None:
|
||||
return '"{0}"'.format(value.replace('"', '""'))
|
||||
return _quote_ident(value, conn)
|
||||
@@ -1,9 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from patroni.daemon import AbstractPatroniDaemon, abstract_main
|
||||
from patroni.dcs.raft import KVStoreTTL
|
||||
from pysyncobj import SyncObjConf
|
||||
from .daemon import AbstractPatroniDaemon, abstract_main
|
||||
from .dcs.raft import KVStoreTTL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,16 +11,13 @@ class RaftController(AbstractPatroniDaemon):
|
||||
def __init__(self, config):
|
||||
super(RaftController, self).__init__(config)
|
||||
|
||||
raft_config = self.config.get('raft')
|
||||
self_addr = raft_config['self_addr']
|
||||
template = os.path.join(raft_config.get('data_dir', ''), self_addr)
|
||||
self._syncobj_config = SyncObjConf(autoTick=False, appendEntriesUseBatch=False, dynamicMembershipChange=True,
|
||||
journalFile=template + '.journal', fullDumpFile=template + '.dump')
|
||||
self._raft = KVStoreTTL(self_addr, raft_config.get('partner_addrs', []), self._syncobj_config)
|
||||
config = self.config.get('raft')
|
||||
assert 'self_addr' in config
|
||||
self._raft = KVStoreTTL(None, None, None, **config)
|
||||
|
||||
def _run_cycle(self):
|
||||
try:
|
||||
self._raft.doTick(self._syncobj_config.autoTickPeriod)
|
||||
self._raft.doTick(self._raft.conf.autoTickPeriod)
|
||||
except Exception:
|
||||
logger.exception('doTick')
|
||||
|
||||
|
||||
+13
-3
@@ -9,9 +9,9 @@ from .utils import USER_AGENT
|
||||
|
||||
class PatroniRequest(object):
|
||||
|
||||
def __init__(self, config, insecure=False):
|
||||
cert_reqs = 'CERT_NONE' if insecure or config.get('ctl', {}).get('insecure', False) else 'CERT_REQUIRED'
|
||||
self._pool = urllib3.PoolManager(num_pools=10, maxsize=10, cert_reqs=cert_reqs)
|
||||
def __init__(self, config, insecure=None):
|
||||
self._insecure = insecure
|
||||
self._pool = urllib3.PoolManager(num_pools=10, maxsize=10)
|
||||
self.reload_config(config)
|
||||
|
||||
@staticmethod
|
||||
@@ -32,9 +32,19 @@ class PatroniRequest(object):
|
||||
def reload_config(self, config):
|
||||
self._pool.headers = urllib3.make_headers(basic_auth=self._get_cfg_value(config, 'auth'), user_agent=USER_AGENT)
|
||||
|
||||
insecure = self._insecure if isinstance(self._insecure, bool) else config.get('ctl', {}).get('insecure', False)
|
||||
if self._apply_ssl_file_param(config, 'cert'):
|
||||
# With client certificate the cert_reqs must be set to CERT_REQUIRED even if insecure option is used
|
||||
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_REQUIRED'
|
||||
# The assert_hostname = False helps to silence warnings
|
||||
self._pool.connection_pool_kw['assert_hostname'] = False if insecure else None
|
||||
|
||||
self._apply_ssl_file_param(config, 'key')
|
||||
|
||||
password = self._get_cfg_value(config, 'keyfile_password')
|
||||
self._apply_pool_param('key_password', password)
|
||||
else:
|
||||
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_NONE' if insecure else 'CERT_REQUIRED'
|
||||
self._pool.connection_pool_kw.pop('key_file', None)
|
||||
|
||||
cacert = config.get('ctl', {}).get('cacert') or config.get('restapi', {}).get('cafile')
|
||||
|
||||
+14
-10
@@ -3,10 +3,12 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import boto.ec2
|
||||
import boto3
|
||||
|
||||
from patroni.utils import Retry, RetryFailedError
|
||||
from patroni.request import get as requests_get
|
||||
from ..utils import Retry, RetryFailedError
|
||||
from ..request import get as requests_get
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,7 +18,7 @@ class AWSConnection(object):
|
||||
def __init__(self, cluster_name):
|
||||
self.available = False
|
||||
self.cluster_name = cluster_name if cluster_name is not None else 'unknown'
|
||||
self._retry = Retry(deadline=300, max_delay=30, max_tries=-1, retry_exceptions=(boto.exception.StandardError,))
|
||||
self._retry = Retry(deadline=300, max_delay=30, max_tries=-1, retry_exceptions=(ClientError,))
|
||||
try:
|
||||
# get the instance id
|
||||
r = requests_get('http://169.254.169.254/latest/dynamic/instance-identity/document', timeout=2.1)
|
||||
@@ -42,20 +44,22 @@ class AWSConnection(object):
|
||||
|
||||
def _tag_ebs(self, conn, role):
|
||||
""" set tags, carrying the cluster name, instance role and instance id for the EBS storage """
|
||||
tags = {'Name': 'spilo_' + self.cluster_name, 'Role': role, 'Instance': self.instance_id}
|
||||
volumes = conn.get_all_volumes(filters={'attachment.instance-id': self.instance_id})
|
||||
conn.create_tags([v.id for v in volumes], tags)
|
||||
tags = [{'Key': 'Name', 'Value': 'spilo_' + self.cluster_name},
|
||||
{'Key': 'Role', 'Value': role},
|
||||
{'Key': 'Instance', 'Value': self.instance_id}]
|
||||
volumes = conn.volumes.filter(Filters=[{'Name': 'attachment.instance-id', 'Values': [self.instance_id]}])
|
||||
conn.create_tags(Resources=[v.id for v in volumes], Tags=tags)
|
||||
|
||||
def _tag_ec2(self, conn, role):
|
||||
""" tag the current EC2 instance with a cluster role """
|
||||
tags = {'Role': role}
|
||||
conn.create_tags([self.instance_id], tags)
|
||||
tags = [{'Key': 'Role', 'Value': role}]
|
||||
conn.create_tags(Resources=[self.instance_id], Tags=tags)
|
||||
|
||||
def on_role_change(self, new_role):
|
||||
if not self.available:
|
||||
return False
|
||||
try:
|
||||
conn = self.retry(boto.ec2.connect_to_region, self.region)
|
||||
conn = boto3.resource('ec2', region_name=self.region)
|
||||
self.retry(self._tag_ec2, conn, new_role)
|
||||
self.retry(self._tag_ebs, conn, new_role)
|
||||
except RetryFailedError:
|
||||
|
||||
@@ -27,13 +27,14 @@ import argparse
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import psycopg2
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
from .. import psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RETRY_SLEEP_INTERVAL = 1
|
||||
@@ -213,27 +214,27 @@ class WALERestore(object):
|
||||
attempts_no = 0
|
||||
while True:
|
||||
if self.master_connection:
|
||||
con = None
|
||||
try:
|
||||
# get the difference in bytes between the current WAL location and the backup start offset
|
||||
with psycopg2.connect(self.master_connection) as con:
|
||||
if con.server_version >= 100000:
|
||||
wal_name = 'wal'
|
||||
lsn_name = 'lsn'
|
||||
else:
|
||||
wal_name = 'xlog'
|
||||
lsn_name = 'location'
|
||||
con.autocommit = True
|
||||
with con.cursor() as cur:
|
||||
cur.execute(("SELECT CASE WHEN pg_catalog.pg_is_in_recovery()"
|
||||
" THEN GREATEST(pg_catalog.pg_{0}_{1}_diff(COALESCE("
|
||||
"pg_last_{0}_receive_{1}(), '0/0'), %s)::bigint, "
|
||||
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), %s)::bigint)"
|
||||
" ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), %s)::bigint"
|
||||
" END").format(wal_name, lsn_name),
|
||||
(backup_start_lsn, backup_start_lsn, backup_start_lsn))
|
||||
con = psycopg.connect(self.master_connection)
|
||||
if con.server_version >= 100000:
|
||||
wal_name = 'wal'
|
||||
lsn_name = 'lsn'
|
||||
else:
|
||||
wal_name = 'xlog'
|
||||
lsn_name = 'location'
|
||||
with con.cursor() as cur:
|
||||
cur.execute(("SELECT CASE WHEN pg_catalog.pg_is_in_recovery()"
|
||||
" THEN GREATEST(pg_catalog.pg_{0}_{1}_diff(COALESCE("
|
||||
"pg_last_{0}_receive_{1}(), '0/0'), %s)::bigint, "
|
||||
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), %s)::bigint)"
|
||||
" ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), %s)::bigint"
|
||||
" END").format(wal_name, lsn_name),
|
||||
(backup_start_lsn, backup_start_lsn, backup_start_lsn))
|
||||
|
||||
diff_in_bytes = int(cur.fetchone()[0])
|
||||
except psycopg2.Error:
|
||||
diff_in_bytes = int(cur.fetchone()[0])
|
||||
except psycopg.Error:
|
||||
logger.exception('could not determine difference with the master location')
|
||||
if attempts_no < self.retries: # retry in case of a temporarily connection issue
|
||||
attempts_no = attempts_no + 1
|
||||
@@ -245,12 +246,15 @@ class WALERestore(object):
|
||||
logger.info("continue with base backup from S3 since master is not available")
|
||||
diff_in_bytes = 0
|
||||
break
|
||||
finally:
|
||||
if con:
|
||||
con.close()
|
||||
else:
|
||||
# always try to use WAL-E if master connection string is not available
|
||||
diff_in_bytes = 0
|
||||
break
|
||||
|
||||
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
|
||||
# if the size of the accumulated WAL segments is more than a certain percentage of the backup size
|
||||
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
|
||||
is_size_thresh_ok = diff_in_bytes < int(threshold_megabytes) * 1048576
|
||||
threshold_pct_bytes = backup_size * threshold_percent / 100.0
|
||||
@@ -308,7 +312,7 @@ class WALERestore(object):
|
||||
try:
|
||||
os.mkdir(path)
|
||||
except OSError:
|
||||
logger.exception("coud not create missing %s directory path", dirname)
|
||||
logger.exception("could not create missing %s directory path", dirname)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
+8
-8
@@ -362,7 +362,7 @@ def polling_loop(timeout, interval=1):
|
||||
def split_host_port(value, default_port):
|
||||
t = value.rsplit(':', 1)
|
||||
if ':' in t[0]:
|
||||
t[0] = t[0].strip('[]')
|
||||
t[0] = ','.join([h.strip().strip('[]') for h in t[0].split(',')])
|
||||
t.append(default_port)
|
||||
return t[0], int(t[1])
|
||||
|
||||
@@ -405,7 +405,7 @@ def is_standby_cluster(config):
|
||||
|
||||
def cluster_as_json(cluster):
|
||||
leader_name = cluster.leader.name if cluster.leader else None
|
||||
xlog_location_cluster = cluster.last_leader_operation or 0
|
||||
cluster_lsn = cluster.last_lsn or 0
|
||||
|
||||
ret = {'members': []}
|
||||
for m in cluster.members:
|
||||
@@ -427,11 +427,11 @@ def cluster_as_json(cluster):
|
||||
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
|
||||
|
||||
if m.name != leader_name:
|
||||
xlog_location = m.data.get('xlog_location')
|
||||
if xlog_location is None:
|
||||
lsn = m.data.get('xlog_location')
|
||||
if lsn is None:
|
||||
member['lag'] = 'unknown'
|
||||
elif xlog_location_cluster >= xlog_location:
|
||||
member['lag'] = xlog_location_cluster - xlog_location
|
||||
elif cluster_lsn >= lsn:
|
||||
member['lag'] = cluster_lsn - lsn
|
||||
else:
|
||||
member['lag'] = 0
|
||||
|
||||
@@ -519,8 +519,8 @@ def enable_keepalive(sock, timeout, idle, cnt=3):
|
||||
def find_executable(executable, path=None):
|
||||
_, ext = os.path.splitext(executable)
|
||||
|
||||
if (sys.platform == 'win32') and (ext != '.exe'):
|
||||
executable = executable + '.exe'
|
||||
if (sys.platform == 'win32') and (ext == ''):
|
||||
executable = executable + '.exe' # Set default WIN extension
|
||||
|
||||
if os.path.isfile(executable):
|
||||
return executable
|
||||
|
||||
+10
-2
@@ -37,6 +37,10 @@ def validate_host_port(host_port, listen=False, multiple_hosts=False):
|
||||
hosts = hosts.split(",")
|
||||
else:
|
||||
hosts = [hosts]
|
||||
if "*" in hosts:
|
||||
if len(hosts) != 1:
|
||||
raise ConfigParseError("expecting '*' alone")
|
||||
hosts = [p[-1][0] for p in socket.getaddrinfo(None, port, 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)]
|
||||
for host in hosts:
|
||||
proto = socket.getaddrinfo(host, "", 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
|
||||
s = socket.socket(proto[0][0], socket.SOCK_STREAM)
|
||||
@@ -178,9 +182,11 @@ class Schema(object):
|
||||
self.validator = validator
|
||||
|
||||
def __call__(self, data):
|
||||
errors = []
|
||||
for i in self.validate(data):
|
||||
if not i.status:
|
||||
print(i)
|
||||
errors.append(str(i))
|
||||
return errors
|
||||
|
||||
def validate(self, data):
|
||||
self.data = data
|
||||
@@ -302,10 +308,11 @@ validate_host_port_listen.expected_type = string_types
|
||||
validate_host_port_listen_multiple_hosts.expected_type = string_types
|
||||
validate_data_dir.expected_type = string_types
|
||||
validate_etcd = {
|
||||
Or("host", "hosts", "srv", "url", "proxy"): Case({
|
||||
Or("host", "hosts", "srv", "srv_suffix", "url", "proxy"): Case({
|
||||
"host": validate_host_port,
|
||||
"hosts": Or(comma_separated_host_port, [validate_host_port]),
|
||||
"srv": str,
|
||||
"srv_suffix": str,
|
||||
"url": str,
|
||||
"proxy": str})
|
||||
}
|
||||
@@ -363,6 +370,7 @@ schema = Schema({
|
||||
"postgresql": {
|
||||
"listen": validate_host_port_listen_multiple_hosts,
|
||||
"connect_address": validate_connect_address,
|
||||
Optional("proxy_address"): validate_connect_address,
|
||||
"authentication": {
|
||||
"replication": userattributes,
|
||||
"superuser": userattributes,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = '2.0.2'
|
||||
__version__ = '2.1.7'
|
||||
|
||||
@@ -215,6 +215,10 @@ class Watchdog(object):
|
||||
self._activate()
|
||||
if self.config.timeout != self.active_config.timeout:
|
||||
self.impl.set_timeout(self.config.timeout)
|
||||
if self.is_running:
|
||||
logger.info("{0} updated with {1} second timeout, timing slack {2} seconds"
|
||||
.format(self.impl.describe(), self.impl.get_timeout(), self.config.timing_slack))
|
||||
self.active_config = self.config
|
||||
except WatchdogError as e:
|
||||
logger.error("Error while sending keepalive: %s", e)
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ IOC_DIRBITS = 2
|
||||
|
||||
# Non-generic platform special cases
|
||||
machine = platform.machine()
|
||||
if machine in ['mips', 'sparc', 'powerpc', 'ppc64']: # pragma: no cover
|
||||
if machine in ['mips', 'sparc', 'powerpc', 'ppc64', 'ppc64le']: # pragma: no cover
|
||||
IOC_SIZEBITS = 13
|
||||
IOC_DIRBITS = 3
|
||||
IOC_NONE, IOC_WRITE, IOC_READ = 1, 2, 4
|
||||
IOC_NONE, IOC_WRITE, IOC_READ = 1, 4, 2
|
||||
elif machine == 'parisc': # pragma: no cover
|
||||
IOC_WRITE, IOC_READ = 2, 1
|
||||
|
||||
|
||||
+15
-6
@@ -5,16 +5,18 @@ name: postgresql0
|
||||
restapi:
|
||||
listen: 127.0.0.1:8008
|
||||
connect_address: 127.0.0.1:8008
|
||||
# cafile: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
# authentication:
|
||||
# username: username
|
||||
# password: password
|
||||
|
||||
# ctl:
|
||||
# insecure: false # Allow connections to SSL sites without certs
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
#ctl:
|
||||
# insecure: false # Allow connections to Patroni REST API without verifying certificates
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
|
||||
etcd:
|
||||
#Provide host to do the initial discovery of the cluster topology:
|
||||
@@ -57,10 +59,15 @@ bootstrap:
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# max_connections: 100
|
||||
# max_worker_processes: 8
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 10
|
||||
# max_replication_slots: 10
|
||||
# max_prepared_transactions: 0
|
||||
# max_locks_per_transaction: 64
|
||||
# wal_log_hints: "on"
|
||||
# track_commit_timestamp: "off"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
@@ -86,7 +93,7 @@ bootstrap:
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin
|
||||
password: admin%
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
@@ -94,6 +101,8 @@ bootstrap:
|
||||
postgresql:
|
||||
listen: 127.0.0.1:5432
|
||||
connect_address: 127.0.0.1:5432
|
||||
|
||||
# proxy_address: 127.0.0.1:5433 # The address of connection pool (e.g., pgbouncer) running next to Patroni/Postgres. Only for service discovery.
|
||||
data_dir: data/postgresql0
|
||||
# bin_dir:
|
||||
# config_dir:
|
||||
@@ -114,7 +123,7 @@ postgresql:
|
||||
# Fully qualified kerberos ticket file for the running user
|
||||
# same as KRB5CCNAME used by the GSS
|
||||
# krb_server_keyfile: /var/spool/keytabs/postgres
|
||||
unix_socket_directories: '.'
|
||||
unix_socket_directories: '..' # parent directory of data_dir
|
||||
# Additional fencing script executed after acquiring the leader lock but before promoting the replica
|
||||
#pre_promote: /path/to/pre_promote.sh
|
||||
|
||||
|
||||
+14
-6
@@ -5,16 +5,18 @@ name: postgresql1
|
||||
restapi:
|
||||
listen: 127.0.0.1:8009
|
||||
connect_address: 127.0.0.1:8009
|
||||
# cafile: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
# authentication:
|
||||
# username: username
|
||||
# password: password
|
||||
|
||||
# ctl:
|
||||
# insecure: false # Allow connections to SSL sites without certs
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
#ctl:
|
||||
# insecure: false # Allow connections to Patroni REST API without verifying certificates
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
|
||||
etcd:
|
||||
#Provide host to do the initial discovery of the cluster topology:
|
||||
@@ -51,10 +53,15 @@ bootstrap:
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# max_connections: 100
|
||||
# max_worker_processes: 8
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 10
|
||||
# max_replication_slots: 10
|
||||
# max_prepared_transactions: 0
|
||||
# max_locks_per_transaction: 64
|
||||
# wal_log_hints: "on"
|
||||
# track_commit_timestamp: "off"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
@@ -80,7 +87,7 @@ bootstrap:
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin
|
||||
password: admin%
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
@@ -88,6 +95,7 @@ bootstrap:
|
||||
postgresql:
|
||||
listen: 127.0.0.1:5433
|
||||
connect_address: 127.0.0.1:5433
|
||||
# proxy_address: 127.0.0.1:5434 # The address of connection pool (e.g., pgbouncer) running next to Patroni/Postgres. Only for service discovery.
|
||||
data_dir: data/postgresql1
|
||||
# bin_dir:
|
||||
# config_dir:
|
||||
@@ -108,7 +116,7 @@ postgresql:
|
||||
# Fully qualified kerberos ticket file for the running user
|
||||
# same as KRB5CCNAME used by the GSS
|
||||
# krb_server_keyfile: /var/spool/keytabs/postgres
|
||||
unix_socket_directories: '.'
|
||||
unix_socket_directories: '..' # parent directory of data_dir
|
||||
basebackup:
|
||||
- verbose
|
||||
- max-rate: 100M
|
||||
|
||||
+14
-6
@@ -5,16 +5,18 @@ name: postgresql2
|
||||
restapi:
|
||||
listen: 127.0.0.1:8010
|
||||
connect_address: 127.0.0.1:8010
|
||||
# cafile: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
authentication:
|
||||
username: username
|
||||
password: password
|
||||
|
||||
# ctl:
|
||||
# insecure: false # Allow connections to SSL sites without certs
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
#ctl:
|
||||
# insecure: false # Allow connections to Patroni REST API without verifying certificates
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||
|
||||
etcd:
|
||||
#Provide host to do the initial discovery of the cluster topology:
|
||||
@@ -51,10 +53,15 @@ bootstrap:
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# max_connections: 100
|
||||
# max_worker_processes: 8
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 10
|
||||
# max_replication_slots: 10
|
||||
# max_prepared_transactions: 0
|
||||
# max_locks_per_transaction: 64
|
||||
# wal_log_hints: "on"
|
||||
# track_commit_timestamp: "off"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
@@ -77,7 +84,7 @@ bootstrap:
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin
|
||||
password: admin%
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
@@ -85,6 +92,7 @@ bootstrap:
|
||||
postgresql:
|
||||
listen: 127.0.0.1:5434
|
||||
connect_address: 127.0.0.1:5434
|
||||
# proxy_address: 127.0.0.1:5435 # The address of connection pool (e.g., pgbouncer) running next to Patroni/Postgres. Only for service discovery.
|
||||
data_dir: data/postgresql2
|
||||
# bin_dir:
|
||||
# config_dir:
|
||||
@@ -105,7 +113,7 @@ postgresql:
|
||||
# Fully qualified kerberos ticket file for the running user
|
||||
# same as KRB5CCNAME used by the GSS
|
||||
# krb_server_keyfile: /var/spool/keytabs/postgres
|
||||
unix_socket_directories: '.'
|
||||
unix_socket_directories: '..' # parent directory of data_dir
|
||||
tags:
|
||||
nofailover: false
|
||||
noloadbalance: false
|
||||
|
||||
+18
-21
@@ -1,31 +1,28 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
>&2 echo "usage: $0 <version>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
readonly VERSIONFILE="patroni/version.py"
|
||||
# Release process:
|
||||
# 1. Open a PR that updates release notes and Patroni version
|
||||
# 2. Merge it
|
||||
# 3. Run release.sh
|
||||
# 4. After the new tag is pushed, the .github/workflows/release.yaml will run tests and upload the new package to test.pypi.org
|
||||
# 5. Once the release is created, the .github/workflows/release.yaml will run tests and upload the new package to pypi.org
|
||||
|
||||
## Bail out on any non-zero exitcode from the called processes
|
||||
set -xe
|
||||
|
||||
python3 --version
|
||||
if python3 --version &> /dev/null; then
|
||||
alias python=python3
|
||||
shopt -s expand_aliases
|
||||
fi
|
||||
|
||||
python --version
|
||||
git --version
|
||||
|
||||
version=$1
|
||||
version=$(python -c 'from patroni.version import __version__; print(__version__)')
|
||||
|
||||
sed -i "s/__version__ = .*/__version__ = '${version}'/" "${VERSIONFILE}"
|
||||
python3 setup.py clean
|
||||
python3 setup.py test
|
||||
python3 setup.py flake8
|
||||
python setup.py clean
|
||||
python setup.py test
|
||||
python setup.py flake8
|
||||
|
||||
git add "${VERSIONFILE}"
|
||||
|
||||
git commit -m "Bumped version to $version"
|
||||
git push
|
||||
|
||||
python3 setup.py sdist bdist_wheel upload
|
||||
|
||||
git tag v${version}
|
||||
git tag "v$version"
|
||||
git push --tags
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
psycopg2-binary
|
||||
behave
|
||||
coverage
|
||||
flake8
|
||||
flake8>=3.0.0
|
||||
mock
|
||||
pytest-cov
|
||||
pytest
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
urllib3>=1.19.1,!=1.21
|
||||
ipaddress; python_version=="2.7"
|
||||
boto
|
||||
boto3
|
||||
PyYAML
|
||||
six >= 1.7
|
||||
kazoo>=1.3.1
|
||||
@@ -9,6 +9,7 @@ python-consul>=0.7.1
|
||||
click>=4.1
|
||||
prettytable>=0.7
|
||||
python-dateutil
|
||||
pysyncobj>=0.3.7
|
||||
pysyncobj>=0.3.8
|
||||
cryptography>=1.4
|
||||
psutil>=2.0.0
|
||||
ydiff>=1.2.0
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -17,15 +18,15 @@ MAIN_PACKAGE = NAME
|
||||
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
|
||||
LICENSE = 'The MIT License'
|
||||
URL = 'https://github.com/zalando/patroni'
|
||||
AUTHOR = 'Alexander Kukushkin, Dmitrii Dolgov, Oleksii Kliukin'
|
||||
AUTHOR_EMAIL = 'alexander.kukushkin@zalando.de, [email protected], [email protected]'
|
||||
AUTHOR = 'Alexander Kukushkin, Polina Bungina'
|
||||
AUTHOR_EMAIL = 'akukushkin@microsoft.com, [email protected]'
|
||||
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
|
||||
' zookeeper exhibitor consul streaming replication kubernetes k8s'
|
||||
|
||||
EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'], 'consul': ['python-consul'],
|
||||
'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'], 'kubernetes': ['ipaddress'], 'raft': ['pysyncobj']}
|
||||
EXTRAS_REQUIRE = {'aws': ['boto3'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
|
||||
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
|
||||
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
|
||||
COVERAGE_XML = True
|
||||
COVERAGE_HTML = False
|
||||
|
||||
# Add here all kinds of additional classifiers as defined under
|
||||
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||
@@ -48,29 +49,29 @@ CLASSIFIERS = [
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: Implementation :: CPython',
|
||||
]
|
||||
|
||||
CONSOLE_SCRIPTS = ['patroni = patroni:main',
|
||||
CONSOLE_SCRIPTS = ['patroni = patroni.__main__:main',
|
||||
'patronictl = patroni.ctl:ctl',
|
||||
'patroni_raft_controller = patroni.raft_controller:main',
|
||||
"patroni_wale_restore = patroni.scripts.wale_restore:main",
|
||||
"patroni_aws = patroni.scripts.aws:main"]
|
||||
|
||||
|
||||
class Flake8(Command):
|
||||
|
||||
class _Command(Command):
|
||||
user_options = []
|
||||
|
||||
def initialize_options(self):
|
||||
from flake8.main import application
|
||||
|
||||
self.flake8 = application.Application()
|
||||
self.flake8.initialize([])
|
||||
pass
|
||||
|
||||
def finalize_options(self):
|
||||
pass
|
||||
|
||||
|
||||
class Flake8(_Command):
|
||||
|
||||
def package_files(self):
|
||||
seen_package_directories = ()
|
||||
directories = self.distribution.package_dir or {}
|
||||
@@ -92,68 +93,29 @@ class Flake8(Command):
|
||||
return [package for package in self.package_files()] + ['tests', 'setup.py']
|
||||
|
||||
def run(self):
|
||||
self.flake8.run_checks(self.targets())
|
||||
self.flake8.formatter.start()
|
||||
self.flake8.report_errors()
|
||||
self.flake8.report_statistics()
|
||||
self.flake8.report_benchmarks()
|
||||
self.flake8.formatter.stop()
|
||||
try:
|
||||
self.flake8.exit()
|
||||
except SystemExit as e:
|
||||
# Cause system exit only if exit code is not zero (terminates
|
||||
# other possibly remaining/pending setuptools commands).
|
||||
if e.code:
|
||||
raise
|
||||
from flake8.main.cli import main
|
||||
|
||||
logging.getLogger().setLevel(logging.ERROR)
|
||||
main(self.targets())
|
||||
|
||||
|
||||
class PyTest(Command):
|
||||
class PyTest(_Command):
|
||||
|
||||
user_options = [('cov=', None, 'Run coverage'), ('cov-xml=', None, 'Generate junit xml report'),
|
||||
('cov-html=', None, 'Generate junit html report')]
|
||||
|
||||
def initialize_options(self):
|
||||
self.cov = []
|
||||
self.cov_xml = False
|
||||
self.cov_html = False
|
||||
|
||||
def finalize_options(self):
|
||||
if self.cov_xml or self.cov_html:
|
||||
self.cov = ['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing']
|
||||
if self.cov_xml:
|
||||
self.cov.extend(['--cov-report', 'xml'])
|
||||
if self.cov_html:
|
||||
self.cov.extend(['--cov-report', 'html'])
|
||||
|
||||
def run_tests(self):
|
||||
def run(self):
|
||||
try:
|
||||
import pytest
|
||||
except Exception:
|
||||
raise RuntimeError('py.test is not installed, run: pip install pytest')
|
||||
|
||||
import logging
|
||||
silence = logging.WARNING
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
|
||||
logging.getLogger().setLevel(logging.WARNING)
|
||||
|
||||
args = ['--verbose', 'tests', '--doctest-modules', MAIN_PACKAGE] +\
|
||||
['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd']
|
||||
if self.cov:
|
||||
args += self.cov
|
||||
['-s' if logging.getLogger().getEffectiveLevel() < logging.WARNING else '--capture=fd'] +\
|
||||
['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing', '--cov-report', 'xml']
|
||||
|
||||
errno = pytest.main(args=args)
|
||||
sys.exit(errno)
|
||||
|
||||
def run(self):
|
||||
from pkg_resources import evaluate_marker
|
||||
|
||||
requirements = set(self.distribution.install_requires + ['mock>=2.0.0', 'pytest-cov', 'pytest'])
|
||||
for k, v in self.distribution.extras_require.items():
|
||||
if not k.startswith(':') or evaluate_marker(k[1:]):
|
||||
requirements.update(v)
|
||||
|
||||
self.distribution.fetch_build_eggs(list(requirements))
|
||||
self.run_tests()
|
||||
|
||||
|
||||
def read(fname):
|
||||
with open(os.path.join(__location__, fname)) as fd:
|
||||
@@ -161,6 +123,8 @@ def read(fname):
|
||||
|
||||
|
||||
def setup_package(version):
|
||||
logging.basicConfig(format='%(message)s', level=os.getenv('LOGLEVEL', logging.WARNING))
|
||||
|
||||
# Assemble additional setup commands
|
||||
cmdclass = {'test': PyTest, 'flake8': Flake8}
|
||||
|
||||
@@ -171,19 +135,18 @@ def setup_package(version):
|
||||
if r == '':
|
||||
continue
|
||||
extra = False
|
||||
for e, v in EXTRAS_REQUIRE.items():
|
||||
if v and r.startswith(v[0]):
|
||||
EXTRAS_REQUIRE[e] = [r] if e != 'kubernetes' or sys.version_info < (3, 0, 0) else []
|
||||
extra = True
|
||||
for e, deps in EXTRAS_REQUIRE.items():
|
||||
for i, v in enumerate(deps):
|
||||
if r.startswith(v):
|
||||
deps[i] = r
|
||||
EXTRAS_REQUIRE[e] = deps
|
||||
extra = True
|
||||
break
|
||||
if extra:
|
||||
break
|
||||
if not extra:
|
||||
install_requires.append(r)
|
||||
|
||||
command_options = {'test': {}}
|
||||
if COVERAGE_XML:
|
||||
command_options['test']['cov_xml'] = 'setup.py', True
|
||||
if COVERAGE_HTML:
|
||||
command_options['test']['cov_html'] = 'setup.py', True
|
||||
|
||||
setup(
|
||||
name=NAME,
|
||||
version=version,
|
||||
@@ -200,9 +163,7 @@ def setup_package(version):
|
||||
python_requires='>=2.7',
|
||||
install_requires=install_requires,
|
||||
extras_require=EXTRAS_REQUIRE,
|
||||
setup_requires='flake8',
|
||||
cmdclass=cmdclass,
|
||||
command_options=command_options,
|
||||
entry_points={'console_scripts': CONSOLE_SCRIPTS},
|
||||
)
|
||||
|
||||
@@ -210,13 +171,14 @@ def setup_package(version):
|
||||
if __name__ == '__main__':
|
||||
old_modules = sys.modules.copy()
|
||||
try:
|
||||
from patroni import check_psycopg2, fatal, __version__
|
||||
from patroni import check_psycopg, fatal
|
||||
from patroni.version import __version__
|
||||
finally:
|
||||
sys.modules.clear()
|
||||
sys.modules.update(old_modules)
|
||||
|
||||
if sys.version_info < (2, 7, 0):
|
||||
fatal('Patroni needs to be run with Python 2.7+')
|
||||
check_psycopg2()
|
||||
check_psycopg()
|
||||
|
||||
setup_package(__version__)
|
||||
|
||||
+22
-13
@@ -1,16 +1,18 @@
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
|
||||
import psycopg2
|
||||
import urllib3
|
||||
|
||||
import patroni.psycopg as psycopg
|
||||
|
||||
from patroni.dcs import Leader, Member
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.config import ConfigHandler
|
||||
from patroni.utils import RetryFailedError
|
||||
from patroni.utils import RetryFailedError, tzutc
|
||||
|
||||
|
||||
class SleepException(Exception):
|
||||
@@ -48,7 +50,7 @@ def requests_get(url, **kwargs):
|
||||
if url.startswith('http://local'):
|
||||
raise urllib3.exceptions.HTTPError()
|
||||
elif ':8011/patroni' in url:
|
||||
response.content = '{"role": "replica", "xlog": {"received_location": 0}, "tags": {}}'
|
||||
response.content = '{"role": "replica", "wal": {"received_location": 0}, "tags": {}}'
|
||||
elif url.endswith('/members'):
|
||||
response.content = '[{}]' if url.startswith('http://error') else members
|
||||
elif url.startswith('http://exhibitor'):
|
||||
@@ -84,21 +86,26 @@ class MockCursor(object):
|
||||
|
||||
def execute(self, sql, *params):
|
||||
if sql.startswith('blabla'):
|
||||
raise psycopg2.ProgrammingError()
|
||||
raise psycopg.ProgrammingError()
|
||||
elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'):
|
||||
raise psycopg2.OperationalError()
|
||||
raise psycopg.OperationalError()
|
||||
elif sql.startswith('RetryFailedError'):
|
||||
raise RetryFailedError('retry')
|
||||
elif sql.startswith('SELECT slot_name, catalog_xmin'):
|
||||
self.results = [('postgresql0', 100), ('ls', 100)]
|
||||
elif sql.startswith('SELECT slot_name, slot_type, datname, plugin, catalog_xmin'):
|
||||
self.results = [('ls', 'logical', 'a', 'b', 100, 500, b'123456')]
|
||||
elif sql.startswith('SELECT slot_name'):
|
||||
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b')]
|
||||
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b', 5, 100, 500)]
|
||||
elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'):
|
||||
self.results = [(1, 2, 1, 0, False, 1, 1, None, None)]
|
||||
self.results = [(1, 2, 1, 0, False, 1, 1, None, None, [{"slot_name": "ls", "confirmed_flush_lsn": 12345}])]
|
||||
elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'):
|
||||
self.results = [(False, 2)]
|
||||
elif sql.startswith('SELECT pg_catalog.to_char'):
|
||||
elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
|
||||
replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
|
||||
'"state":"streaming","sync_state":"async","sync_priority":0}]'
|
||||
self.results = [('', 0, '', 0, '', '', False, replication_info)]
|
||||
now = datetime.datetime.now(tzutc)
|
||||
self.results = [(now, 0, '', 0, '', False, now, replication_info)]
|
||||
elif sql.startswith('SELECT name, setting'):
|
||||
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
|
||||
('wal_block_size', '8192', None, 'integer', 'internal'),
|
||||
@@ -156,7 +163,7 @@ class MockConnect(object):
|
||||
pass
|
||||
|
||||
|
||||
def psycopg2_connect(*args, **kwargs):
|
||||
def psycopg_connect(*args, **kwargs):
|
||||
return MockConnect()
|
||||
|
||||
|
||||
@@ -170,7 +177,7 @@ class PostgresInit(unittest.TestCase):
|
||||
'force_parallel_mode': '1', 'constraint_exclusion': '',
|
||||
'max_stack_depth': 'Z', 'vacuum_cost_limit': -1, 'vacuum_cost_delay': 200}
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg._connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.CallbackExecutor', Mock())
|
||||
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
|
||||
@patch.object(ConfigHandler, 'replace_pg_hba', Mock())
|
||||
@@ -181,9 +188,11 @@ class PostgresInit(unittest.TestCase):
|
||||
self.p = Postgresql({'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
|
||||
'config_dir': data_dir, 'retry_timeout': 10,
|
||||
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
|
||||
'listen': '127.0.0.2, 127.0.0.3:5432', 'connect_address': '127.0.0.2:5432',
|
||||
'listen': '127.0.0.2, 127.0.0.3:5432',
|
||||
'connect_address': '127.0.0.2:5432', 'proxy_address': '127.0.0.2:5433',
|
||||
'authentication': {'superuser': {'username': 'foo', 'password': 'test'},
|
||||
'replication': {'username': '', 'password': 'rep-pass'}},
|
||||
'replication': {'username': '', 'password': 'rep-pass'},
|
||||
'rewind': {'username': 'rewind', 'password': 'test'}},
|
||||
'remove_data_directory_on_rewind_failure': True,
|
||||
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla',
|
||||
'parameters': self._PARAMETERS,
|
||||
|
||||
+147
-39
@@ -1,9 +1,10 @@
|
||||
import datetime
|
||||
import json
|
||||
import psycopg2
|
||||
import unittest
|
||||
import socket
|
||||
|
||||
import patroni.psycopg as psycopg
|
||||
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.api import RestApiHandler, RestApiServer
|
||||
from patroni.dcs import ClusterConfig, Member
|
||||
@@ -11,7 +12,8 @@ from patroni.ha import _MemberStatus
|
||||
from patroni.utils import tzutc
|
||||
from six import BytesIO as IO
|
||||
from six.moves import BaseHTTPServer
|
||||
from . import psycopg2_connect, MockCursor
|
||||
from six.moves.socketserver import ThreadingMixIn
|
||||
from . import psycopg_connect, MockCursor
|
||||
from .test_ha import get_cluster_initialized_without_leader
|
||||
|
||||
|
||||
@@ -30,21 +32,25 @@ class MockPostgresql(object):
|
||||
pending_restart = True
|
||||
wal_name = 'wal'
|
||||
lsn_name = 'lsn'
|
||||
POSTMASTER_START_TIME = 'pg_catalog.to_char(pg_catalog.pg_postmaster_start_time'
|
||||
POSTMASTER_START_TIME = 'pg_catalog.pg_postmaster_start_time()'
|
||||
TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()'
|
||||
|
||||
@staticmethod
|
||||
def connection():
|
||||
return psycopg2_connect()
|
||||
return psycopg_connect()
|
||||
|
||||
@staticmethod
|
||||
def postmaster_start_time():
|
||||
return str(postmaster_start_time)
|
||||
return postmaster_start_time
|
||||
|
||||
@staticmethod
|
||||
def replica_cached_timeline(_):
|
||||
return 2
|
||||
|
||||
@staticmethod
|
||||
def is_running():
|
||||
return True
|
||||
|
||||
|
||||
class MockWatchdog(object):
|
||||
is_healthy = False
|
||||
@@ -77,7 +83,7 @@ class MockHa(object):
|
||||
|
||||
@staticmethod
|
||||
def fetch_nodes_statuses(members):
|
||||
return [_MemberStatus(None, True, None, 0, None, {}, False)]
|
||||
return [_MemberStatus(None, True, None, 0, 0, None, {}, False)]
|
||||
|
||||
@staticmethod
|
||||
def schedule_future_restart(data):
|
||||
@@ -118,7 +124,7 @@ class MockPatroni(object):
|
||||
postgresql = ha.state_handler
|
||||
dcs = Mock()
|
||||
logger = MockLogger()
|
||||
tags = {}
|
||||
tags = {"key1": True, "key2": False, "key3": 1, "key4": 1.4, "key5": "RandomTag"}
|
||||
version = '0.00'
|
||||
noloadbalance = PropertyMock(return_value=False)
|
||||
scheduled_restart = {'schedule': future_restart_time,
|
||||
@@ -128,6 +134,10 @@ class MockPatroni(object):
|
||||
def sighup_handler():
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def api_sigterm():
|
||||
pass
|
||||
|
||||
|
||||
class MockRequest(object):
|
||||
|
||||
@@ -162,7 +172,7 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||
|
||||
def test_do_GET(self):
|
||||
MockPatroni.dcs.cluster.last_leader_operation = 20
|
||||
MockPatroni.dcs.cluster.last_lsn = 20
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
|
||||
@@ -174,16 +184,18 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'state': 'running'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /health')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master')
|
||||
MockRestApiServer(RestApiHandler, 'GET /leader')
|
||||
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
|
||||
MockPatroni.dcs.cluster.is_synchronous_mode = Mock(return_value=True)
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /synchronous')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
||||
MockPatroni.dcs.cluster.sync.members = []
|
||||
MockRestApiServer(RestApiHandler, 'GET /asynchronous')
|
||||
with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
|
||||
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
||||
MockPatroni.dcs.cluster = None
|
||||
@@ -197,10 +209,98 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
||||
|
||||
# test tags
|
||||
#
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'standby_leader'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
|
||||
def test_do_OPTIONS(self):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
|
||||
|
||||
def test_do_GET_liveness(self):
|
||||
def test_do_HEAD(self):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'HEAD / HTTP/1.0'))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_GET_liveness(self, mock_dcs):
|
||||
mock_dcs.ttl.return_value = PropertyMock(30)
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /liveness HTTP/1.0'))
|
||||
|
||||
def test_do_GET_readiness(self):
|
||||
@@ -236,6 +336,10 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
mock_dcs.cluster.config = None
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config'))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_GET_metrics(self, mock_dcs):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /metrics'))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_PATCH_config(self, mock_dcs):
|
||||
config = {'postgresql': {'use_slots': False, 'use_pg_rewind': True, 'parameters': {'wal_level': 'logical'}}}
|
||||
@@ -271,6 +375,11 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
def test_do_POST_reload(self):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /reload HTTP/1.0' + self._authorization))
|
||||
|
||||
@patch('os.environ', {'BEHAVE_DEBUG': 'true'})
|
||||
@patch('os.name', 'nt')
|
||||
def test_do_POST_sigterm(self):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /sigterm HTTP/1.0' + self._authorization))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_POST_restart(self, mock_dcs):
|
||||
mock_dcs.get_cluster.return_value.is_paused.return_value = False
|
||||
@@ -348,9 +457,9 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_RestApiServer_query(self):
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)):
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)):
|
||||
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg.OperationalError)):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
@@ -451,7 +560,9 @@ class TestRestApiServer(unittest.TestCase):
|
||||
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
|
||||
def setUp(self):
|
||||
self.srv = MockRestApiServer(Mock(), '', {'listen': '*:8008', 'certfile': 'a', 'verify_client': 'required',
|
||||
'ciphers': '!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1'})
|
||||
'ciphers': '!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1',
|
||||
'allowlist': ['127.0.0.1', '::1/128', '::1/zxc'],
|
||||
'allowlist_include_members': True})
|
||||
|
||||
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
|
||||
def test_reload_config(self):
|
||||
@@ -459,13 +570,21 @@ class TestRestApiServer(unittest.TestCase):
|
||||
self.assertRaises(ValueError, MockRestApiServer, None, '', bad_config)
|
||||
self.assertRaises(ValueError, self.srv.reload_config, bad_config)
|
||||
self.assertRaises(ValueError, self.srv.reload_config, {})
|
||||
with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)):
|
||||
with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)), \
|
||||
patch.object(MockRestApiServer, 'server_close', Mock()):
|
||||
self.srv.reload_config({'listen': ':8008'})
|
||||
|
||||
def test_check_auth(self):
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_check_access(self, mock_dcs):
|
||||
mock_dcs.cluster = get_cluster_initialized_without_leader()
|
||||
mock_dcs.cluster.members[1].data['api_url'] = 'http://127.0.0.1z:8011/patroni'
|
||||
mock_dcs.cluster.members.append(Member(0, 'bad-api-url', 30, {'api_url': 123}))
|
||||
mock_rh = Mock()
|
||||
mock_rh.client_address = ('127.0.0.2',)
|
||||
self.assertIsNot(self.srv.check_access(mock_rh), True)
|
||||
mock_rh.client_address = ('127.0.0.1',)
|
||||
mock_rh.request.getpeercert.return_value = None
|
||||
self.assertIsNot(self.srv.check_auth(mock_rh), True)
|
||||
self.assertIsNot(self.srv.check_access(mock_rh), True)
|
||||
|
||||
def test_handle_error(self):
|
||||
try:
|
||||
@@ -477,29 +596,18 @@ class TestRestApiServer(unittest.TestCase):
|
||||
def test_socket_error(self):
|
||||
self.assertRaises(socket.error, MockRestApiServer, Mock(), '', {'listen': '*:8008'})
|
||||
|
||||
@patch.object(MockRestApiServer, 'finish_request', Mock())
|
||||
@patch.object(ThreadingMixIn, 'process_request_thread', Mock())
|
||||
def test_process_request_thread(self):
|
||||
mock_socket = Mock()
|
||||
self.srv.process_request_thread((mock_socket, 1), '2')
|
||||
mock_socket.context.wrap_socket.side_effect = socket.error
|
||||
self.srv.process_request_thread((mock_socket, 1), '2')
|
||||
|
||||
@patch.object(socket.socket, 'accept')
|
||||
def test_get_request(self, mock_accept):
|
||||
newsock = Mock()
|
||||
mock_accept.return_value = (newsock, '2')
|
||||
self.srv.socket = Mock()
|
||||
self.assertEqual(self.srv.get_request(), ((self.srv.socket, newsock), '2'))
|
||||
self.srv.process_request_thread(Mock(), '2')
|
||||
|
||||
@patch.object(MockRestApiServer, 'process_request', Mock(side_effect=RuntimeError))
|
||||
def test_process_request_error(self):
|
||||
mock_address = ('127.0.0.1', 55555)
|
||||
mock_socket = Mock()
|
||||
mock_ssl_socket = (Mock(), Mock())
|
||||
for mock_request in (mock_socket, mock_ssl_socket):
|
||||
with patch.object(
|
||||
MockRestApiServer,
|
||||
'get_request',
|
||||
Mock(return_value=(mock_request, mock_address))
|
||||
):
|
||||
self.srv._handle_request_noblock()
|
||||
@patch.object(MockRestApiServer, 'get_request')
|
||||
def test_process_request_error(self, mock_get_request):
|
||||
mock_request = Mock()
|
||||
mock_request.unwrap.side_effect = Exception
|
||||
mock_get_request.return_value = (mock_request, ('127.0.0.1', 55555))
|
||||
self.srv._handle_request_noblock()
|
||||
|
||||
@patch('ssl._ssl._test_decode_cert', Mock())
|
||||
def test_reload_local_certificate(self):
|
||||
self.assertTrue(self.srv.reload_local_certificate())
|
||||
|
||||
+15
-10
@@ -1,4 +1,4 @@
|
||||
import boto.ec2
|
||||
import botocore
|
||||
import sys
|
||||
import unittest
|
||||
import urllib3
|
||||
@@ -8,21 +8,27 @@ from collections import namedtuple
|
||||
from patroni.scripts.aws import AWSConnection, main as _main
|
||||
|
||||
|
||||
class MockEc2Connection(object):
|
||||
class MockVolumes(object):
|
||||
|
||||
@staticmethod
|
||||
def get_all_volumes(*args, **kwargs):
|
||||
def filter(*args, **kwargs):
|
||||
oid = namedtuple('Volume', 'id')
|
||||
return [oid(id='a'), oid(id='b')]
|
||||
|
||||
|
||||
class MockEc2Connection(object):
|
||||
|
||||
volumes = MockVolumes()
|
||||
|
||||
@staticmethod
|
||||
def create_tags(objects, *args, **kwargs):
|
||||
if len(objects) == 0:
|
||||
raise boto.exception.BotoServerError(503, 'Service Unavailable', 'Request limit exceeded')
|
||||
def create_tags(Resources, **kwargs):
|
||||
if len(Resources) == 0:
|
||||
raise botocore.exceptions.ClientError({'Error': {'Code': 503, 'Message': 'Request limit exceeded'}},
|
||||
'create_tags')
|
||||
return True
|
||||
|
||||
|
||||
@patch('boto.ec2.connect_to_region', Mock(return_value=MockEc2Connection()))
|
||||
@patch('boto3.resource', Mock(return_value=MockEc2Connection()))
|
||||
class TestAWSConnection(unittest.TestCase):
|
||||
|
||||
@patch('patroni.scripts.aws.requests_get', Mock(return_value=urllib3.HTTPResponse(
|
||||
@@ -32,7 +38,7 @@ class TestAWSConnection(unittest.TestCase):
|
||||
|
||||
def test_on_role_change(self):
|
||||
self.assertTrue(self.conn.on_role_change('master'))
|
||||
with patch.object(MockEc2Connection, 'get_all_volumes', Mock(return_value=[])):
|
||||
with patch.object(MockVolumes, 'filter', Mock(return_value=[])):
|
||||
self.conn._retry.max_tries = 1
|
||||
self.assertFalse(self.conn.on_role_change('master'))
|
||||
|
||||
@@ -46,8 +52,7 @@ class TestAWSConnection(unittest.TestCase):
|
||||
conn = AWSConnection('test')
|
||||
self.assertFalse(conn.aws_available())
|
||||
|
||||
@patch('patroni.scripts.aws.requests_get', Mock(return_value=urllib3.HTTPResponse(
|
||||
status=200, body=b'{"instanceId": "012345", "region": "eu-west-1"}')))
|
||||
@patch('patroni.scripts.aws.requests_get', Mock(return_value=urllib3.HTTPResponse(status=503, body=b'Error')))
|
||||
@patch('sys.exit', Mock())
|
||||
def test_main(self):
|
||||
self.assertIsNone(_main())
|
||||
|
||||
@@ -8,11 +8,11 @@ from patroni.postgresql.bootstrap import Bootstrap
|
||||
from patroni.postgresql.cancellable import CancellableSubprocess
|
||||
from patroni.postgresql.config import ConfigHandler
|
||||
|
||||
from . import psycopg2_connect, BaseTestPostgresql
|
||||
from . import psycopg_connect, BaseTestPostgresql
|
||||
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('os.rename', Mock())
|
||||
class TestBootstrap(BaseTestPostgresql):
|
||||
|
||||
@@ -164,6 +164,7 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
@patch('os.unlink', Mock())
|
||||
@patch('shutil.copy', Mock())
|
||||
@patch('os.path.isfile', Mock(return_value=True))
|
||||
@patch('patroni.postgresql.bootstrap.quote_ident', Mock())
|
||||
@patch.object(Bootstrap, 'call_post_bootstrap', Mock(return_value=True))
|
||||
@patch.object(Bootstrap, '_custom_bootstrap', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
||||
|
||||
@@ -27,8 +27,8 @@ class TestCancellableSubprocess(unittest.TestCase):
|
||||
def test_cancel(self):
|
||||
self.c._process = Mock()
|
||||
self.c._process.is_running.return_value = True
|
||||
self.c._process.children.side_effect = psutil.Error()
|
||||
self.c._process.suspend.side_effect = psutil.Error()
|
||||
self.c._process.children.side_effect = psutil.NoSuchProcess(123)
|
||||
self.c._process.suspend.side_effect = psutil.AccessDenied()
|
||||
self.c.cancel()
|
||||
self.c._process.is_running.side_effect = [True, False]
|
||||
self.c.cancel()
|
||||
|
||||
@@ -30,14 +30,17 @@ class TestConfig(unittest.TestCase):
|
||||
'PATRONI_SCOPE': 'batman2',
|
||||
'PATRONI_LOGLEVEL': 'ERROR',
|
||||
'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG',
|
||||
'PATRONI_LOG_FILE_NUM': '5',
|
||||
'PATRONI_RESTAPI_USERNAME': 'username',
|
||||
'PATRONI_RESTAPI_PASSWORD': 'password',
|
||||
'PATRONI_RESTAPI_LISTEN': '0.0.0.0:8008',
|
||||
'PATRONI_RESTAPI_CONNECT_ADDRESS': '127.0.0.1:8008',
|
||||
'PATRONI_RESTAPI_CERTFILE': '/certfile',
|
||||
'PATRONI_RESTAPI_KEYFILE': '/keyfile',
|
||||
'PATRONI_RESTAPI_ALLOWLIST_INCLUDE_MEMBERS': 'on',
|
||||
'PATRONI_POSTGRESQL_LISTEN': '0.0.0.0:5432',
|
||||
'PATRONI_POSTGRESQL_CONNECT_ADDRESS': '127.0.0.1:5432',
|
||||
'PATRONI_POSTGRESQL_PROXY_ADDRESS': '127.0.0.1:5433',
|
||||
'PATRONI_POSTGRESQL_DATA_DIR': 'data/postgres0',
|
||||
'PATRONI_POSTGRESQL_CONFIG_DIR': 'data/postgres0',
|
||||
'PATRONI_POSTGRESQL_PGPASS': '/tmp/pgpass0',
|
||||
|
||||
+100
-17
@@ -2,9 +2,9 @@ import consul
|
||||
import unittest
|
||||
|
||||
from consul import ConsulException, NotFound
|
||||
from mock import Mock, patch
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
|
||||
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession
|
||||
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession, RetryFailedError
|
||||
from . import SleepException
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ def kv_get(self, key, **kwargs):
|
||||
return None, None
|
||||
if key == 'service/good/leader':
|
||||
return '1', None
|
||||
if key == 'service/good/':
|
||||
return ('6429',
|
||||
good_cls = ('6429',
|
||||
[{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'failover', 'LockIndex': 0,
|
||||
'ModifyIndex': 1334, 'Value': b''},
|
||||
{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'initialize', 'LockIndex': 0,
|
||||
@@ -34,7 +33,19 @@ def kv_get(self, key, **kwargs):
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'optime/leader', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'4496294792'},
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'sync', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'{"leader": "leader", "sync_standby": null}'}])
|
||||
'ModifyIndex': 6429, 'Value': b'{"leader": "leader", "sync_standby": null}'},
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'failsafe', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'{'},
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'status', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'{"optime":4496294792, "slots":{"ls":12345}}'}])
|
||||
if key == 'service/good/':
|
||||
return good_cls
|
||||
if key == 'service/broken/':
|
||||
good_cls[1][-1]['Value'] = b'{'
|
||||
return good_cls
|
||||
if key == 'service/legacy/':
|
||||
good_cls[1].pop()
|
||||
return good_cls
|
||||
raise ConsulException
|
||||
|
||||
|
||||
@@ -82,7 +93,7 @@ class TestConsul(unittest.TestCase):
|
||||
Consul({'ttl': 30, 'scope': 't_', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
||||
'verify': 'on', 'cert': 'bar', 'cacert': 'buz', 'register_service': True})
|
||||
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10,
|
||||
'register_service': True})
|
||||
'register_service': True, 'service_check_tls_server_name': True})
|
||||
self.c._base_path = '/service/good'
|
||||
self.c.get_cluster()
|
||||
|
||||
@@ -109,16 +120,18 @@ class TestConsul(unittest.TestCase):
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
self.c._base_path = '/service/fail'
|
||||
self.assertRaises(ConsulError, self.c.get_cluster)
|
||||
self.c._base_path = '/service/good'
|
||||
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
|
||||
self.c._base_path = '/service/broken'
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
self.c._base_path = '/service/legacy'
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(side_effect=[ConsulException, True, True, True]))
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException, InvalidSession]))
|
||||
def test_touch_member(self):
|
||||
self.c.refresh_session = Mock(return_value=False)
|
||||
self.c.touch_member({'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
|
||||
'api_url': 'http://127.0.0.1:8009/patroni'})
|
||||
with patch.object(Consul, 'update_service', Mock(side_effect=Exception)):
|
||||
self.c.touch_member({'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
|
||||
'api_url': 'http://127.0.0.1:8009/patroni'})
|
||||
self.c._register_service = True
|
||||
self.c.refresh_session = Mock(return_value=True)
|
||||
for _ in range(0, 4):
|
||||
@@ -126,11 +139,15 @@ class TestConsul(unittest.TestCase):
|
||||
self.c.refresh_session = Mock(side_effect=ConsulError('foo'))
|
||||
self.assertFalse(self.c.touch_member({'balbla': 'blabla'}))
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=InvalidSession))
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[InvalidSession, False, InvalidSession]))
|
||||
def test_take_leader(self):
|
||||
self.c.set_ttl(20)
|
||||
self.c.refresh_session = Mock()
|
||||
self.c.take_leader()
|
||||
self.c._do_refresh_session = Mock()
|
||||
self.assertFalse(self.c.take_leader())
|
||||
with patch('time.time', Mock(side_effect=[0, 100])):
|
||||
self.assertRaises(ConsulError, self.c.take_leader)
|
||||
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 0, 100])):
|
||||
self.assertRaises(ConsulError, self.c.take_leader)
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||
def test_set_failover_value(self):
|
||||
@@ -140,13 +157,32 @@ class TestConsul(unittest.TestCase):
|
||||
def test_set_config_value(self):
|
||||
self.c.set_config_value('')
|
||||
|
||||
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
|
||||
def test_write_leader_optime(self):
|
||||
self.c.get_cluster()
|
||||
self.c.write_leader_optime('1')
|
||||
|
||||
@patch.object(consul.Consul.Session, 'renew', Mock())
|
||||
def test_update_leader(self):
|
||||
self.c.update_leader(None)
|
||||
@patch.object(consul.Consul.Session, 'renew')
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
|
||||
def test_update_leader(self, mock_renew):
|
||||
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
|
||||
with patch.object(consul.Consul.KV, 'delete', Mock(return_value=True)):
|
||||
with patch.object(consul.Consul.KV, 'put', Mock(return_value=True)):
|
||||
self.assertTrue(self.c.update_leader(12345, failsafe={'foo': 'bar'}))
|
||||
with patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException)):
|
||||
self.assertFalse(self.c.update_leader(12345))
|
||||
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 100, 200, 300])):
|
||||
self.assertRaises(ConsulError, self.c.update_leader, 12345)
|
||||
with patch('time.time', Mock(side_effect=[0, 100, 200, 300])):
|
||||
self.assertRaises(ConsulError, self.c.update_leader, 12345)
|
||||
with patch.object(consul.Consul.KV, 'delete', Mock(side_effect=ConsulException)):
|
||||
self.assertFalse(self.c.update_leader(12347))
|
||||
mock_renew.side_effect = RetryFailedError('')
|
||||
self.c._last_session_refresh = 0
|
||||
self.assertRaises(ConsulError, self.c.update_leader, 12346)
|
||||
mock_renew.side_effect = ConsulException
|
||||
self.assertFalse(self.c.update_leader(12347))
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
|
||||
def test_delete_leader(self):
|
||||
@@ -201,5 +237,52 @@ class TestConsul(unittest.TestCase):
|
||||
d['role'] = 'bla'
|
||||
self.assertIsNone(self.c.update_service({}, d))
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
|
||||
def test_reload_config(self):
|
||||
self.c.reload_config({'consul': {'token': 'foo'}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
self.assertEqual([], self.c._service_tags)
|
||||
self.c.reload_config({'consul': {'token': 'foo', 'register_service': True, 'service_tags': ['foo']},
|
||||
'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
self.assertEqual(["foo"], self.c._service_tags)
|
||||
|
||||
self.c.refresh_session = Mock(return_value=False)
|
||||
|
||||
d = {'role': 'replica', 'api_url': 'http://a/t', 'conn_url': 'pg://c:1', 'state': 'running'}
|
||||
|
||||
# Changing register_service from True to False calls deregister()
|
||||
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
|
||||
self.c.touch_member(d)
|
||||
mock_deregister.assert_called_once()
|
||||
|
||||
self.assertEqual([], self.c._service_tags)
|
||||
|
||||
# register_service staying False between reloads does not call deregister()
|
||||
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
|
||||
self.c.touch_member(d)
|
||||
self.assertFalse(mock_deregister.called)
|
||||
|
||||
# Changing register_service from False to True calls register()
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
# register_service staying True between reloads does not call register()
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
self.assertFalse(mock_deregister.called)
|
||||
|
||||
# register_service staying True between reloads does calls register() if other service data has changed
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
# register_service staying True between reloads does calls register() if service_tags have changed
|
||||
self.c.reload_config({'consul': {'register_service': True, 'service_tags': ['foo']}, 'loop_wait': 10,
|
||||
'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
+53
-28
@@ -5,31 +5,21 @@ import unittest
|
||||
from click.testing import CliRunner
|
||||
from datetime import datetime, timedelta
|
||||
from mock import patch, Mock
|
||||
from patroni.ctl import ctl, store_config, load_config, output_members, get_dcs, parse_dcs, \
|
||||
get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, \
|
||||
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH
|
||||
from patroni.ctl import ctl, load_config, output_members, get_dcs, parse_dcs, \
|
||||
get_all_members, get_any_member, get_cursor, query_member, PatroniCtlException, apply_config_changes, \
|
||||
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH, PatronictlPrettyTable
|
||||
from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Failover
|
||||
from patroni.psycopg import OperationalError
|
||||
from patroni.utils import tzutc
|
||||
from psycopg2 import OperationalError
|
||||
from prettytable import PrettyTable, ALL
|
||||
from urllib3 import PoolManager
|
||||
|
||||
from . import MockConnect, MockCursor, MockResponse, psycopg2_connect
|
||||
from . import MockConnect, MockCursor, MockResponse, psycopg_connect
|
||||
from .test_etcd import etcd_read, socket_getaddrinfo
|
||||
from .test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
|
||||
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member
|
||||
|
||||
|
||||
def test_rw_config():
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
load_config(CONFIG_FILE_PATH, None)
|
||||
CONFIG_PATH = './test-ctl.yaml'
|
||||
store_config({'etcd': {'host': 'localhost:2379'}}, CONFIG_PATH + '/dummy')
|
||||
load_config(CONFIG_PATH + '/dummy', '0.0.0.0')
|
||||
os.remove(CONFIG_PATH + '/dummy')
|
||||
os.rmdir(CONFIG_PATH)
|
||||
|
||||
|
||||
@patch('patroni.ctl.load_config', Mock(return_value={
|
||||
'scope': 'alpha', 'restapi': {'listen': '::', 'certfile': 'a'}, 'etcd': {'host': 'localhost:2379'},
|
||||
'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}}))
|
||||
@@ -42,13 +32,29 @@ class TestCtl(unittest.TestCase):
|
||||
self.runner = CliRunner()
|
||||
self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'retry_timeout': 10}}, 'foo')
|
||||
|
||||
def test_load_config(self):
|
||||
@patch('patroni.ctl.logging.debug')
|
||||
def test_load_config(self, mock_logger_debug):
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None)
|
||||
self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None)
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
with patch('os.path.exists', Mock(return_value=True)), \
|
||||
patch('patroni.config.Config._load_config_path', Mock(return_value={})):
|
||||
load_config(CONFIG_FILE_PATH, None)
|
||||
mock_logger_debug.assert_called_once()
|
||||
self.assertEqual(('Ignoring configuration file "%s". It does not exists or is not readable.',
|
||||
CONFIG_FILE_PATH),
|
||||
mock_logger_debug.call_args[0])
|
||||
mock_logger_debug.reset_mock()
|
||||
|
||||
with patch('os.access', Mock(return_value=True)):
|
||||
load_config(CONFIG_FILE_PATH, '')
|
||||
mock_logger_debug.assert_called_once()
|
||||
self.assertEqual(('Loading configuration from file %s', CONFIG_FILE_PATH),
|
||||
mock_logger_debug.call_args[0])
|
||||
mock_logger_debug.reset_mock()
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
def test_get_cursor(self):
|
||||
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), {}, role='master'))
|
||||
|
||||
@@ -57,7 +63,7 @@ class TestCtl(unittest.TestCase):
|
||||
# MockCursor returns pg_is_in_recovery as false
|
||||
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='replica'))
|
||||
|
||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'database': 'foo'}, role='any'))
|
||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'dbname': 'foo'}, role='any'))
|
||||
|
||||
def test_parse_dcs(self):
|
||||
assert parse_dcs(None) is None
|
||||
@@ -165,7 +171,7 @@ class TestCtl(unittest.TestCase):
|
||||
def test_get_dcs(self):
|
||||
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
@@ -379,10 +385,6 @@ class TestCtl(unittest.TestCase):
|
||||
with patch('patroni.ctl.load_config', Mock(return_value={})):
|
||||
self.runner.invoke(ctl, ['list'])
|
||||
|
||||
def test_configure(self):
|
||||
result = self.runner.invoke(configure, ['--dcs', 'abc', '-c', 'dummy', '-n', 'bla'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_scaffold(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
@@ -562,7 +564,8 @@ class TestCtl(unittest.TestCase):
|
||||
|
||||
@patch('sys.stdout.isatty', return_value=False)
|
||||
@patch('patroni.ctl.markup_to_pager')
|
||||
def test_show_diff(self, mock_markup_to_pager, mock_isatty):
|
||||
@patch('patroni.ctl.find_executable', return_value=None)
|
||||
def test_show_diff(self, mock_find_executable, mock_markup_to_pager, mock_isatty):
|
||||
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
|
||||
mock_markup_to_pager.assert_not_called()
|
||||
|
||||
@@ -570,10 +573,10 @@ class TestCtl(unittest.TestCase):
|
||||
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
|
||||
mock_markup_to_pager.assert_called_once()
|
||||
|
||||
with patch('patroni.ctl.find_executable', Mock(return_value=None)):
|
||||
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
|
||||
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
|
||||
|
||||
# Test that unicode handling doesn't fail with an exception
|
||||
mock_find_executable.return_value = '/usr/bin/less'
|
||||
show_diff(b"foo:\n bar: \xc3\xb6\xc3\xb6\n".decode('utf-8'),
|
||||
b"foo:\n bar: \xc3\xbc\xc3\xbc\n".decode('utf-8'))
|
||||
|
||||
@@ -591,6 +594,7 @@ class TestCtl(unittest.TestCase):
|
||||
self.runner.invoke(ctl, ['show-config', 'dummy'])
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
def test_edit_config(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
@@ -646,3 +650,24 @@ class TestCtl(unittest.TestCase):
|
||||
result = self.runner.invoke(ctl, ['reinit', 'alpha', 'other', '--wait'], input='y\ny')
|
||||
self.assertIn("Waiting for reinitialize to complete on: other", result.output)
|
||||
self.assertIn("Reinitialize is completed on: other", result.output)
|
||||
|
||||
|
||||
class TestPatronictlPrettyTable(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.pt = PatronictlPrettyTable(' header', ['foo', 'bar'], hrules=ALL)
|
||||
|
||||
def test__get_hline(self):
|
||||
expected = '+-----+-----+'
|
||||
self.pt._hrule = expected
|
||||
self.assertEqual(self.pt._hrule, '+ header----+')
|
||||
self.assertFalse(self.pt._is_first_hline())
|
||||
self.assertEqual(self.pt._hrule, expected)
|
||||
|
||||
@patch.object(PrettyTable, '_stringify_hrule', Mock(return_value='+-----+-----+'))
|
||||
def test__stringify_hrule(self):
|
||||
self.assertEqual(self.pt._stringify_hrule((), 'top_'), '+ header----+')
|
||||
self.assertFalse(self.pt._is_first_hline())
|
||||
|
||||
def test_output(self):
|
||||
self.assertEqual(str(self.pt), '+ header----+\n| foo | bar |\n+-----+-----+')
|
||||
|
||||
+32
-4
@@ -4,7 +4,7 @@ import socket
|
||||
import unittest
|
||||
|
||||
from dns.exception import DNSException
|
||||
from mock import Mock, patch
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.utils import Retry
|
||||
@@ -66,7 +66,14 @@ def etcd_read(self, key, **kwargs):
|
||||
"?application_name=http://127.0.0.1:8008/patroni",
|
||||
"expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30,
|
||||
"modifiedIndex": 20730, "createdIndex": 20730}],
|
||||
"modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}}
|
||||
"modifiedIndex": 1581, "createdIndex": 1581},
|
||||
{"key": "/service/batman5/failsafe", "value": '{', "modifiedIndex": 1582, "createdIndex": 1582},
|
||||
{"key": "/service/batman5/status", "value": '{"optime":2164261704,"slots":{"ls":12345}}',
|
||||
"modifiedIndex": 1582, "createdIndex": 1582}], "modifiedIndex": 1581, "createdIndex": 1581}}
|
||||
if key == '/service/legacy/':
|
||||
response['node']['nodes'].pop()
|
||||
if key == '/service/broken/':
|
||||
response['node']['nodes'][-1]['value'] = '{'
|
||||
result = etcd.EtcdResult(**response)
|
||||
result.etcd_index = 0
|
||||
return result
|
||||
@@ -81,7 +88,8 @@ def dns_query(name, _):
|
||||
raise DNSException()
|
||||
srv = Mock()
|
||||
srv.port = 2380
|
||||
srv.target.to_text.return_value = 'localhost' if name == '_etcd-server._tcp.foobar' else '127.0.0.1'
|
||||
srv.target.to_text.return_value = \
|
||||
'localhost' if name in ['_etcd-server._tcp.foobar', '_etcd-server-baz._tcp.foobar'] else '127.0.0.1'
|
||||
return [srv]
|
||||
|
||||
|
||||
@@ -177,6 +185,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
def test__get_machines_cache_from_srv(self):
|
||||
self.client._get_machines_cache_from_srv('foobar')
|
||||
self.client._get_machines_cache_from_srv('foobar', 'baz')
|
||||
self.client.get_srv_record = Mock(return_value=[('localhost', 2380)])
|
||||
self.client._get_machines_cache_from_srv('blabla')
|
||||
|
||||
@@ -246,6 +255,10 @@ class TestEtcd(unittest.TestCase):
|
||||
cluster = self.etcd.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
self.assertFalse(cluster.is_synchronous_mode())
|
||||
self.etcd._base_path = '/service/legacy'
|
||||
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
||||
self.etcd._base_path = '/service/broken'
|
||||
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
||||
self.etcd._base_path = '/service/nocluster'
|
||||
cluster = self.etcd.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
@@ -264,12 +277,24 @@ class TestEtcd(unittest.TestCase):
|
||||
self.assertFalse(self.etcd.attempt_to_acquire_leader())
|
||||
self.etcd._base_path = '/service/failed'
|
||||
self.assertFalse(self.etcd.attempt_to_acquire_leader())
|
||||
with patch.object(EtcdClient, 'write', Mock(side_effect=[etcd.EtcdConnectionFailed, Exception])):
|
||||
self.assertRaises(EtcdError, self.etcd.attempt_to_acquire_leader)
|
||||
self.assertRaises(EtcdError, self.etcd.attempt_to_acquire_leader)
|
||||
|
||||
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
|
||||
def test_write_leader_optime(self):
|
||||
self.etcd.get_cluster()
|
||||
self.etcd.write_leader_optime('0')
|
||||
|
||||
def test_update_leader(self):
|
||||
self.assertTrue(self.etcd.update_leader(None))
|
||||
self.assertTrue(self.etcd.update_leader(None, failsafe={'foo': 'bar'}))
|
||||
with patch.object(etcd.Client, 'write',
|
||||
Mock(side_effect=[etcd.EtcdConnectionFailed, etcd.EtcdClusterIdChanged, Exception])):
|
||||
self.assertRaises(EtcdError, self.etcd.update_leader, None)
|
||||
self.assertFalse(self.etcd.update_leader(None))
|
||||
self.assertRaises(EtcdError, self.etcd.update_leader, None)
|
||||
with patch.object(etcd.Client, 'write', Mock(side_effect=etcd.EtcdKeyNotFound)):
|
||||
self.assertFalse(self.etcd.update_leader(None))
|
||||
|
||||
def test_initialize(self):
|
||||
self.assertFalse(self.etcd.initialize())
|
||||
@@ -309,3 +334,6 @@ class TestEtcd(unittest.TestCase):
|
||||
|
||||
def test_set_history_value(self):
|
||||
self.assertFalse(self.etcd.set_history_value('{}'))
|
||||
|
||||
def test_last_seen(self):
|
||||
self.assertIsNotNone(self.etcd.last_seen)
|
||||
|
||||
+50
-5
@@ -4,8 +4,9 @@ import unittest
|
||||
import urllib3
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Error, Etcd3ClientError, RetryFailedError,\
|
||||
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode
|
||||
from patroni.dcs.etcd import DnsCachingResolver
|
||||
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3Client, Etcd3Error, Etcd3ClientError, RetryFailedError,\
|
||||
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode, Etcd3
|
||||
from threading import Thread
|
||||
|
||||
from . import SleepException, MockResponse
|
||||
@@ -33,7 +34,10 @@ def mock_urlopen(self, method, url, **kwargs):
|
||||
"value": base64_encode('foo'), "lease": "bla", "mod_revision": '1'},
|
||||
{"key": base64_encode('/patroni/test/members/foo'),
|
||||
"value": base64_encode('{}'), "lease": "123", "mod_revision": '1'},
|
||||
{"key": base64_encode('/patroni/test/failover'), "value": base64_encode('{}'), "mod_revision": '1'}
|
||||
{"key": base64_encode('/patroni/test/members/bar'),
|
||||
"value": base64_encode('{"version":"1.6.5"}'), "lease": "123", "mod_revision": '1'},
|
||||
{"key": base64_encode('/patroni/test/failover'), "value": base64_encode('{}'), "mod_revision": '1'},
|
||||
{"key": base64_encode('/patroni/test/failsafe'), "value": base64_encode('{'), "mod_revision": '1'}
|
||||
]
|
||||
})
|
||||
elif url.endswith('/watch'):
|
||||
@@ -55,6 +59,16 @@ def mock_urlopen(self, method, url, **kwargs):
|
||||
return ret
|
||||
|
||||
|
||||
class TestEtcd3Client(unittest.TestCase):
|
||||
|
||||
@patch.object(Thread, 'start', Mock())
|
||||
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
|
||||
def test_authenticate(self):
|
||||
etcd3 = Etcd3Client({'host': '127.0.0.1', 'port': 2379, 'use_proxies': True, 'retry_timeout': 10},
|
||||
DnsCachingResolver())
|
||||
self.assertIsNotNone(etcd3._cluster_version)
|
||||
|
||||
|
||||
class BaseTestEtcd3(unittest.TestCase):
|
||||
|
||||
@patch.object(Thread, 'start', Mock())
|
||||
@@ -172,6 +186,22 @@ class TestEtcd3(BaseTestEtcd3):
|
||||
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
|
||||
self.client._kv_cache = None
|
||||
with patch.object(urllib3.PoolManager, 'urlopen') as mock_urlopen:
|
||||
mock_urlopen.return_value = MockResponse()
|
||||
mock_urlopen.return_value.content = json.dumps({
|
||||
"header": {"revision": "1"},
|
||||
"kvs": [
|
||||
{"key": base64_encode('/patroni/test/status'),
|
||||
"value": base64_encode('{"optime":1234567,"slots":{"ls":12345}}'), "mod_revision": '1'}
|
||||
]
|
||||
})
|
||||
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
|
||||
mock_urlopen.return_value.content = json.dumps({
|
||||
"header": {"revision": "1"},
|
||||
"kvs": [
|
||||
{"key": base64_encode('/patroni/test/status'), "value": base64_encode('{'), "mod_revision": '1'}
|
||||
]
|
||||
})
|
||||
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
|
||||
mock_urlopen.side_effect = UnsupportedEtcdVersion('')
|
||||
self.assertRaises(UnsupportedEtcdVersion, self.etcd3.get_cluster)
|
||||
mock_urlopen.side_effect = SleepException()
|
||||
@@ -186,12 +216,27 @@ class TestEtcd3(BaseTestEtcd3):
|
||||
|
||||
def test__update_leader(self):
|
||||
self.etcd3._lease = None
|
||||
self.etcd3.update_leader('123')
|
||||
self.etcd3.update_leader('123', failsafe={'foo': 'bar'})
|
||||
self.etcd3._last_lease_refresh = 0
|
||||
self.etcd3.update_leader('124')
|
||||
with patch.object(PatroniEtcd3Client, 'lease_keepalive', Mock(return_value=True)),\
|
||||
patch('time.time', Mock(side_effect=[0, 100, 200, 300])):
|
||||
self.assertRaises(Etcd3Error, self.etcd3.update_leader, '126')
|
||||
self.etcd3._last_lease_refresh = 0
|
||||
with patch.object(PatroniEtcd3Client, 'lease_keepalive', Mock(side_effect=Unknown)):
|
||||
self.assertFalse(self.etcd3.update_leader('125'))
|
||||
|
||||
def test_take_leader(self):
|
||||
self.assertFalse(self.etcd3.take_leader())
|
||||
|
||||
def test_attempt_to_acquire_leader(self):
|
||||
self.etcd3._lease = None
|
||||
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
||||
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 100, 200])):
|
||||
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
|
||||
with patch('time.time', Mock(side_effect=[0, 100, 200, 300, 400])):
|
||||
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
|
||||
with patch.object(PatroniEtcd3Client, 'put', Mock(return_value=False)):
|
||||
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
||||
|
||||
def test_set_ttl(self):
|
||||
self.etcd3.set_ttl(20)
|
||||
|
||||
@@ -24,11 +24,12 @@ class TestExhibitor(unittest.TestCase):
|
||||
|
||||
@patch('urllib3.PoolManager.request', Mock(return_value=urllib3.HTTPResponse(
|
||||
status=200, body=b'{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}')))
|
||||
@patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient)
|
||||
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
||||
def setUp(self):
|
||||
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
|
||||
|
||||
@patch.object(ExhibitorEnsembleProvider, 'poll', Mock(return_value=True))
|
||||
@patch.object(MockKazooClient, 'get_children', Mock(side_effect=Exception))
|
||||
def test_get_cluster(self):
|
||||
self.assertRaises(ZooKeeperError, self.e.get_cluster)
|
||||
|
||||
+211
-55
@@ -19,7 +19,7 @@ from patroni.utils import tzutc
|
||||
from patroni.watchdog import Watchdog
|
||||
from six.moves import builtins
|
||||
|
||||
from . import PostgresInit, MockPostmaster, psycopg2_connect, requests_get
|
||||
from . import PostgresInit, MockPostmaster, psycopg_connect, requests_get
|
||||
from .test_etcd import socket_getaddrinfo, etcd_read, etcd_write
|
||||
|
||||
SYSID = '12345678901'
|
||||
@@ -35,16 +35,20 @@ def false(*args, **kwargs):
|
||||
|
||||
def get_cluster(initialize, leader, members, failover, sync, cluster_config=None):
|
||||
t = datetime.datetime.now().isoformat()
|
||||
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '"]]',
|
||||
[(1, 67197376, 'no recovery target specified', t)])
|
||||
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '","foo"]]',
|
||||
[(1, 67197376, 'no recovery target specified', t, 'foo')])
|
||||
cluster_config = cluster_config or ClusterConfig(1, {'check_timeline': True}, 1)
|
||||
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history)
|
||||
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history, None, None)
|
||||
|
||||
|
||||
def get_cluster_not_initialized_without_leader(cluster_config=None):
|
||||
return get_cluster(None, None, [], None, SyncState(None, None, None), cluster_config)
|
||||
|
||||
|
||||
def get_cluster_bootstrapping_without_leader(cluster_config=None):
|
||||
return get_cluster("", None, [], None, SyncState(None, None, None), cluster_config)
|
||||
|
||||
|
||||
def get_cluster_initialized_without_leader(leader=False, failover=None, sync=None, cluster_config=None):
|
||||
m1 = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres',
|
||||
'api_url': 'http://127.0.0.1:8008/patroni', 'xlog_location': 4})
|
||||
@@ -66,7 +70,7 @@ def get_cluster_initialized_with_leader(failover=None, sync=None):
|
||||
|
||||
def get_cluster_initialized_with_only_leader(failover=None, cluster_config=None):
|
||||
leader = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
|
||||
return get_cluster(True, leader, [leader], failover, None, cluster_config)
|
||||
return get_cluster(True, leader, [leader.member], failover, None, cluster_config)
|
||||
|
||||
|
||||
def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None):
|
||||
@@ -80,13 +84,14 @@ def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None):
|
||||
)
|
||||
|
||||
|
||||
def get_node_status(reachable=True, in_recovery=True, timeline=2,
|
||||
wal_position=10, nofailover=False, watchdog_failed=False):
|
||||
def get_node_status(reachable=True, in_recovery=True, dcs_last_seen=0,
|
||||
timeline=2, wal_position=10, nofailover=False,
|
||||
watchdog_failed=False):
|
||||
def fetch_node_status(e):
|
||||
tags = {}
|
||||
if nofailover:
|
||||
tags['nofailover'] = True
|
||||
return _MemberStatus(e, reachable, in_recovery, timeline, wal_position, tags, watchdog_failed)
|
||||
return _MemberStatus(e, reachable, in_recovery, dcs_last_seen, timeline, wal_position, tags, watchdog_failed)
|
||||
return fetch_node_status
|
||||
|
||||
|
||||
@@ -152,13 +157,14 @@ def run_async(self, func, args=()):
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster()))
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'timeline_wal_position', Mock(return_value=(1, 10, 1)))
|
||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=3))
|
||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=10))
|
||||
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={
|
||||
'Database system identifier': SYSID,
|
||||
'Database cluster state': 'shut down',
|
||||
'Latest checkpoint location': '0/12345678'}))
|
||||
@patch.object(SlotsHandler, 'sync_replication_slots', Mock())
|
||||
'Latest checkpoint location': '0/12345678',
|
||||
"Latest checkpoint's TimeLineID": '2'}))
|
||||
@patch.object(SlotsHandler, 'load_replication_slots', Mock(side_effect=Exception))
|
||||
@patch.object(ConfigHandler, 'append_pg_hba', Mock())
|
||||
@patch.object(ConfigHandler, 'write_pgpass', Mock(return_value={}))
|
||||
@patch.object(ConfigHandler, 'write_recovery_conf', Mock())
|
||||
@@ -200,7 +206,8 @@ class TestHa(PostgresInit):
|
||||
|
||||
def test_update_lock(self):
|
||||
self.p.last_operation = Mock(side_effect=PostgresConnectionException(''))
|
||||
self.ha.dcs.update_leader = Mock(side_effect=Exception)
|
||||
self.ha.dcs.update_leader = Mock(side_effect=[DCSError(''), Exception])
|
||||
self.assertRaises(DCSError, self.ha.update_lock)
|
||||
self.assertFalse(self.ha.update_lock(True))
|
||||
|
||||
@patch.object(Postgresql, 'received_timeline', Mock(return_value=None))
|
||||
@@ -250,6 +257,15 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.assertEqual(self.ha.run_cycle(), 'failed to start postgres')
|
||||
|
||||
def test_recover_raft(self):
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in recovery', 'Database system identifier': SYSID}
|
||||
self.p.is_running = false
|
||||
self.p.follow = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.p.is_running = true
|
||||
self.ha.dcs.__class__.__name__ = 'Raft'
|
||||
self.assertEqual(self.ha.run_cycle(), 'started as a secondary')
|
||||
|
||||
def test_recover_former_master(self):
|
||||
self.p.follow = false
|
||||
self.p.is_running = false
|
||||
@@ -273,14 +289,37 @@ class TestHa(PostgresInit):
|
||||
self.ha.patroni.config.set_dynamic_configuration({'maximum_lag_on_failover': 10})
|
||||
self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout')
|
||||
|
||||
@patch.object(Rewind, 'ensure_clean_shutdown', Mock())
|
||||
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
|
||||
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
|
||||
def test_crash_recovery_before_rewind(self):
|
||||
self.p.is_leader = false
|
||||
self.p.is_running = false
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in archive recovery',
|
||||
'Database system identifier': SYSID}
|
||||
self.ha._rewind.trigger_check_diverged_lsn()
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.assertEqual(self.ha.run_cycle(), 'doing crash recovery in a single user mode')
|
||||
|
||||
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
|
||||
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
|
||||
def test_recover_with_rewind(self):
|
||||
self.p.is_running = false
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
|
||||
self.ha.cluster.leader.member.data.update(version='2.0.2', role='master')
|
||||
self.ha._rewind.pg_rewind = true
|
||||
self.ha._rewind.check_leader_is_not_in_recovery = true
|
||||
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True)):
|
||||
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
|
||||
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=False)):
|
||||
self.p.follow = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.p.is_running = true
|
||||
self.ha.follow = Mock(return_value='fake')
|
||||
self.assertEqual(self.ha.run_cycle(), 'fake')
|
||||
|
||||
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
|
||||
@patch.object(Rewind, 'should_remove_data_directory_on_diverged_timelines', PropertyMock(return_value=True))
|
||||
@patch.object(Bootstrap, 'create_replica', Mock(return_value=1))
|
||||
def test_recover_with_reinitialize(self):
|
||||
self.p.is_running = false
|
||||
@@ -300,9 +339,9 @@ class TestHa(PostgresInit):
|
||||
self.p.is_healthy = true
|
||||
self.ha.has_lock = true
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because I had the session lock')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
def test_acquire_lock_as_master(self):
|
||||
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
|
||||
|
||||
@@ -329,7 +368,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.has_lock = true
|
||||
self.p.is_leader = false
|
||||
self.p.set_role('master')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
|
||||
def test_demote_after_failing_to_obtain_lock(self):
|
||||
self.ha.acquire_lock = false
|
||||
@@ -354,7 +393,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.has_lock = true
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because I had the session lock')
|
||||
|
||||
def test_promote_without_watchdog(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
@@ -369,35 +408,46 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.has_lock = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
|
||||
@patch.object(Postgresql, '_wait_for_connection_close', Mock())
|
||||
def test_demote_because_not_having_lock(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
with patch.object(Watchdog, 'is_running', PropertyMock(return_value=True)):
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoting self because I do not have the lock and I was a leader')
|
||||
|
||||
def test_demote_because_update_lock_failed(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.has_lock = true
|
||||
self.ha.update_lock = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
|
||||
with patch.object(Ha, '_get_node_to_follow', Mock(side_effect=DCSError('foo'))):
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
|
||||
|
||||
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000))
|
||||
def test_follow(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
|
||||
self.ha.patroni.replicatefrom = "foo"
|
||||
self.p.config.check_recovery_conf = Mock(return_value=(True, False))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
self.ha.cluster.config.data.update({'slots': {'l': {'database': 'a', 'plugin': 'b'}}})
|
||||
self.ha.cluster.members[1].data['tags']['replicatefrom'] = 'postgresql0'
|
||||
self.ha.patroni.nofailover = True
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
|
||||
del self.ha.cluster.config.data['slots']
|
||||
self.ha.cluster.config.data.update({'postgresql': {'use_slots': False}})
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
|
||||
del self.ha.cluster.config.data['postgresql']['use_slots']
|
||||
|
||||
def test_follow_in_pause(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.is_paused = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action')
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0)')
|
||||
|
||||
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
|
||||
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
|
||||
@@ -409,7 +459,9 @@ class TestHa(PostgresInit):
|
||||
|
||||
def test_no_etcd_connection_master_demote(self):
|
||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader')
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoting self because DCS is not accessible and I was a leader')
|
||||
self.ha._async_executor.schedule('dummy')
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoted self because DCS is not accessible and I was a leader')
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_bootstrap_from_another_member(self):
|
||||
@@ -425,10 +477,17 @@ class TestHa(PostgresInit):
|
||||
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
|
||||
self.assertEqual(self.ha.bootstrap(), 'trying to bootstrap (without leader)')
|
||||
|
||||
def test_bootstrap_not_running_concurrently(self):
|
||||
self.ha.cluster = get_cluster_bootstrapping_without_leader()
|
||||
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
|
||||
self.assertEqual(self.ha.bootstrap(), 'waiting for leader to bootstrap')
|
||||
|
||||
def test_bootstrap_initialize_lock_failed(self):
|
||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
||||
def test_bootstrap_initialized_new_cluster(self):
|
||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||
self.e.initialize = true
|
||||
@@ -446,6 +505,8 @@ class TestHa(PostgresInit):
|
||||
self.p.is_running = false
|
||||
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
||||
def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
|
||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||
self.e.initialize = true
|
||||
@@ -456,7 +517,7 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap')
|
||||
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
def test_reinitialize(self):
|
||||
self.assertIsNotNone(self.ha.reinitialize())
|
||||
|
||||
@@ -508,27 +569,27 @@ class TestHa(PostgresInit):
|
||||
self.ha.fetch_node_status = get_node_status()
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
f = Failover(0, self.p.name, '', None)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
||||
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||
self.ha._rewind.rewind_or_reinitialize_needed_and_possible = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||
self.ha.fetch_node_status = get_node_status(nofailover=True)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
self.ha.fetch_node_status = get_node_status(watchdog_failed=True)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
self.ha.fetch_node_status = get_node_status(timeline=1)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
self.ha.fetch_node_status = get_node_status(wal_position=1)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
# manual failover from the previous leader to us won't happen if we hold the nofailover flag
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
|
||||
# Failover scheduled time must include timezone
|
||||
scheduled = datetime.datetime.now()
|
||||
@@ -537,28 +598,28 @@ class TestHa(PostgresInit):
|
||||
|
||||
scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = scheduled + datetime.timedelta(seconds=30)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = scheduled + datetime.timedelta(seconds=-600)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = None
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
def test_manual_failover_from_leader_in_pause(self):
|
||||
self.ha.has_lock = true
|
||||
self.ha.is_paused = true
|
||||
scheduled = datetime.datetime.now()
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
|
||||
self.assertEqual('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
def test_manual_failover_from_leader_in_synchronous_mode(self):
|
||||
self.p.is_leader = true
|
||||
@@ -567,7 +628,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.is_failover_possible = false
|
||||
self.ha.process_sync_replication = Mock()
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a'))
|
||||
self.ha.is_failover_possible = true
|
||||
self.assertEqual('manual failover: demoting myself', self.ha.run_cycle())
|
||||
@@ -596,6 +657,59 @@ class TestHa(PostgresInit):
|
||||
self.ha.patroni.nofailover = True
|
||||
self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
|
||||
|
||||
def test_manual_failover_process_no_leader_in_synchronous_mode(self):
|
||||
self.ha.is_synchronous_mode = true
|
||||
self.p.is_leader = false
|
||||
|
||||
# switchover to a specific node, which name doesn't match our name (postgresql0)
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'other', None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||
|
||||
# switchover to our node (postgresql0), which name is not in sync nodes list
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'postgresql0', None),
|
||||
sync=('leader1', 'blabla'))
|
||||
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||
|
||||
# switchover from a specific leader, but our name (postgresql0) is not in the sync nodes list
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None),
|
||||
sync=('leader', 'blabla'))
|
||||
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||
|
||||
# switchover from a specific leader, but the only sync node (us, postgresql0) has nofailover tag
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None),
|
||||
sync=('postgresql0'))
|
||||
self.ha.patroni.nofailover = True
|
||||
self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
|
||||
self.ha.patroni.nofailover = False
|
||||
|
||||
# manual failover when our name (postgresql0) isn't in the /sync key and the `other` node is not available
|
||||
self.ha.fetch_node_status = get_node_status(nofailover=True) # accessible, in_recovery
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None),
|
||||
sync=('leader1', 'blabla'))
|
||||
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||
|
||||
# manual failover when the `other` node isn't available but our name is in the /sync key
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None),
|
||||
sync=('leader1', 'postgresql0'))
|
||||
self.p.pick_synchronous_standby = Mock(return_value=([], []))
|
||||
self.ha.dcs.write_sync_state = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
|
||||
# manual failover to our node (postgresql0),
|
||||
# which name is not in sync nodes list (the leader and all sync nodes are not available)
|
||||
self.p.set_role('replica')
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None),
|
||||
sync=('leader1', 'other'))
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
|
||||
# manual failover to our node (postgresql0),
|
||||
# which name is not in sync nodes list (some sync nodes are available)
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None),
|
||||
sync=('leader1', 'other'))
|
||||
self.p.set_role('replica')
|
||||
self.p.pick_synchronous_standby = Mock(return_value=(['leader1'], ['leader1']))
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
|
||||
def test_manual_failover_process_no_leader_in_pause(self):
|
||||
self.ha.is_paused = true
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
|
||||
@@ -634,7 +748,7 @@ class TestHa(PostgresInit):
|
||||
# in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us
|
||||
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
|
||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
with patch('patroni.postgresql.Postgresql.timeline_wal_position', return_value=(1, 1, 1)):
|
||||
with patch('patroni.postgresql.Postgresql.last_operation', return_value=1):
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=1):
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
@@ -673,7 +787,7 @@ class TestHa(PostgresInit):
|
||||
|
||||
def test_evaluate_scheduled_restart(self):
|
||||
self.p.postmaster_start_time = Mock(return_value=str(postmaster_start_time))
|
||||
# restart already in progres
|
||||
# restart already in progress
|
||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
|
||||
self.assertIsNone(self.ha.evaluate_scheduled_restart())
|
||||
# restart while the postmaster has been already restarted, fails
|
||||
@@ -728,7 +842,7 @@ class TestHa(PostgresInit):
|
||||
self.p.config.check_recovery_conf = Mock(return_value=(False, False))
|
||||
self.ha._leader_timeline = 1
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the standby leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (leader), the standby leader with the lock')
|
||||
self.p.set_role('replica')
|
||||
self.p.config.check_recovery_conf = Mock(return_value=(True, False))
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
|
||||
@@ -737,7 +851,8 @@ class TestHa(PostgresInit):
|
||||
self.p.is_leader = false
|
||||
self.p.name = 'replica'
|
||||
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a standby leader')
|
||||
self.assertEqual(self.ha.run_cycle(),
|
||||
'no action. I am (replica), a secondary, and following a standby leader (leader)')
|
||||
with patch.object(Leader, 'conn_url', PropertyMock(return_value='')):
|
||||
self.assertEqual(self.ha.run_cycle(), 'continue following the old known standby leader')
|
||||
|
||||
@@ -830,7 +945,8 @@ class TestHa(PostgresInit):
|
||||
|
||||
self.ha.has_lock = false
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
self.assertEqual(self.ha.run_cycle(),
|
||||
'no action. I am (postgresql0), a secondary, and following a leader (leader)')
|
||||
check_calls([(update_lock, False), (demote, False)])
|
||||
|
||||
def test_manual_failover_while_starting(self):
|
||||
@@ -1055,7 +1171,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster.config.data.clear()
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
|
||||
def test_watch(self):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
@@ -1067,20 +1183,30 @@ class TestHa(PostgresInit):
|
||||
def test_shutdown(self):
|
||||
self.p.is_running = false
|
||||
self.ha.is_leader = true
|
||||
|
||||
def stop(*args, **kwargs):
|
||||
kwargs['on_shutdown'](123)
|
||||
|
||||
self.p.stop = stop
|
||||
self.ha.shutdown()
|
||||
|
||||
self.ha.is_failover_possible = true
|
||||
self.ha.shutdown()
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_leader_with_empty_directory(self):
|
||||
def test_leader_with_not_accessible_data_directory(self):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.ha.has_lock = true
|
||||
self.p.data_directory_empty = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'released leader key voluntarily as data dir empty and currently leader')
|
||||
self.p.data_directory_empty = Mock(side_effect=OSError(5, "Input/output error: '{}'".format(self.p.data_dir)))
|
||||
self.assertEqual(self.ha.run_cycle(),
|
||||
'released leader key voluntarily as data dir not accessible and currently leader')
|
||||
self.assertEqual(self.p.role, 'uninitialized')
|
||||
|
||||
# as has_lock is mocked out, we need to fake the leader key release
|
||||
self.ha.has_lock = false
|
||||
# will not say bootstrap from leader as replica can't self elect
|
||||
self.assertEqual(self.ha.run_cycle(), "trying to bootstrap from replica 'other'")
|
||||
# will not say bootstrap because data directory is not accessible
|
||||
self.assertEqual(self.ha.run_cycle(),
|
||||
"data directory is not accessible: [Errno 5] Input/output error: '{}'".format(self.p.data_dir))
|
||||
|
||||
@patch('patroni.postgresql.mtime', Mock(return_value=1588316884))
|
||||
@patch.object(builtins, 'open', mock_open(read_data=('1\t0/40159C0\tno recovery target specified\n\n'
|
||||
@@ -1090,7 +1216,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
for tl in (1, 3):
|
||||
self.p.get_master_timeline = Mock(return_value=tl)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
|
||||
@patch('sys.exit', return_value=1)
|
||||
def test_abort_join(self, exit_mock):
|
||||
@@ -1103,18 +1229,19 @@ class TestHa(PostgresInit):
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.is_paused = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0), the leader with the lock')
|
||||
self.ha.is_paused = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
def test_permanent_logical_slots_after_promote(self):
|
||||
config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1)
|
||||
self.p.name = 'other'
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(cluster_config=config)
|
||||
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(leader=True, cluster_config=config)
|
||||
self.ha.has_lock = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (other), the leader with the lock')
|
||||
|
||||
@patch.object(Cluster, 'has_member', true)
|
||||
def test_run_cycle(self):
|
||||
@@ -1137,3 +1264,32 @@ class TestHa(PostgresInit):
|
||||
|
||||
self.ha.has_lock = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: released leader key voluntarily due to the system ID mismatch')
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
@patch('shutil.rmtree', Mock())
|
||||
@patch('os.makedirs', Mock())
|
||||
@patch('os.open', Mock())
|
||||
@patch('os.fsync', Mock())
|
||||
@patch('os.close', Mock())
|
||||
@patch('os.rename', Mock())
|
||||
@patch('patroni.postgresql.Postgresql.is_starting', Mock(return_value=False))
|
||||
@patch.object(builtins, 'open', mock_open())
|
||||
@patch.object(ConfigHandler, 'check_recovery_conf', Mock(return_value=(False, False)))
|
||||
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000))
|
||||
@patch.object(SlotsHandler, 'sync_replication_slots', Mock(return_value=['ls']))
|
||||
def test_follow_copy(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.cluster.config.data['slots'] = {'ls': {'database': 'a', 'plugin': 'b'}}
|
||||
self.p.is_leader = false
|
||||
self.assertTrue(self.ha.run_cycle().startswith('Copying logical slots'))
|
||||
|
||||
def test_is_failover_possible(self):
|
||||
self.ha.fetch_node_status = Mock(return_value=_MemberStatus(self.ha.cluster.members[0],
|
||||
True, True, 0, 2, None, {}, False))
|
||||
self.assertFalse(self.ha.is_failover_possible(self.ha.cluster.members))
|
||||
|
||||
def test_acquire_lock(self):
|
||||
self.ha.dcs.attempt_to_acquire_leader = Mock(side_effect=[DCSError('foo'), Exception])
|
||||
self.assertRaises(DCSError, self.ha.acquire_lock)
|
||||
self.assertFalse(self.ha.acquire_lock())
|
||||
|
||||
+94
-11
@@ -1,9 +1,12 @@
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import mock
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from mock import Mock, mock_open, patch
|
||||
from mock import Mock, PropertyMock, mock_open, patch
|
||||
from patroni.dcs.kubernetes import k8s_client, k8s_config, K8sConfig, K8sConnectionFailed,\
|
||||
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException,\
|
||||
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
|
||||
@@ -16,7 +19,8 @@ def mock_list_namespaced_config_map(*args, **kwargs):
|
||||
metadata = {'resource_version': '1', 'labels': {'f': 'b'}, 'name': 'test-config',
|
||||
'annotations': {'initialize': '123', 'config': '{}'}}
|
||||
items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))]
|
||||
metadata.update({'name': 'test-leader', 'annotations': {'optime': '1234', 'leader': 'p-0', 'ttl': '30s'}})
|
||||
metadata.update({'name': 'test-leader',
|
||||
'annotations': {'optime': '1234x', 'leader': 'p-0', 'ttl': '30s', 'slots': '{', 'failsafe': '{'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
metadata.update({'name': 'test-failover', 'annotations': {'leader': 'p-0'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
@@ -78,6 +82,28 @@ class TestK8sConfig(unittest.TestCase):
|
||||
self.assertRaises(k8s_config.ConfigException, k8s_config.load_incluster_config)
|
||||
k8s_config.load_incluster_config()
|
||||
self.assertEqual(k8s_config.server, 'https://a:1')
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
|
||||
|
||||
def test_refresh_token(self):
|
||||
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}),\
|
||||
patch('os.path.isfile', Mock(side_effect=[True, True, False, True, True, True])),\
|
||||
patch.object(builtins, 'open', Mock(side_effect=[
|
||||
mock_open(read_data='cert')(), mock_open(read_data='a')(),
|
||||
mock_open()(), mock_open(read_data='b')(), mock_open(read_data='c')()])):
|
||||
k8s_config.load_incluster_config(token_refresh_interval=datetime.timedelta(milliseconds=100))
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
|
||||
time.sleep(0.1)
|
||||
# token file doesn't exist
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
|
||||
# token file is empty
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
|
||||
# token refreshed
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer b')
|
||||
time.sleep(0.1)
|
||||
# token refreshed
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer c')
|
||||
# no need to refresh token
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer c')
|
||||
|
||||
def test_load_kube_config(self):
|
||||
config = {
|
||||
@@ -97,6 +123,20 @@ class TestK8sConfig(unittest.TestCase):
|
||||
k8s_config.load_kube_config()
|
||||
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer token')
|
||||
|
||||
config["users"][0]["user"]["client-key-data"] = base64.b64encode(b'foobar').decode('utf-8')
|
||||
config["clusters"][0]["cluster"]["certificate-authority-data"] = base64.b64encode(b'foobar').decode('utf-8')
|
||||
with patch.object(builtins, 'open', mock_open(read_data=json.dumps(config))),\
|
||||
patch('os.write', Mock()), patch('os.close', Mock()),\
|
||||
patch('os.remove') as mock_remove,\
|
||||
patch('atexit.register') as mock_atexit,\
|
||||
patch('tempfile.mkstemp') as mock_mkstemp:
|
||||
mock_mkstemp.side_effect = [(3, '1.tmp'), (4, '2.tmp')]
|
||||
k8s_config.load_kube_config()
|
||||
mock_atexit.assert_called_once()
|
||||
mock_remove.side_effect = OSError
|
||||
mock_atexit.call_args[0][0]() # call _cleanup_temp_files
|
||||
mock_remove.assert_has_calls([mock.call('1.tmp'), mock.call('2.tmp')])
|
||||
|
||||
|
||||
@patch('urllib3.PoolManager.request')
|
||||
class TestApiClient(unittest.TestCase):
|
||||
@@ -199,6 +239,13 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
|
||||
with patch.object(Kubernetes, '_wait_caches', Mock(side_effect=Exception)):
|
||||
self.assertRaises(KubernetesError, self.k.get_cluster)
|
||||
|
||||
def test_attempt_to_acquire_leader(self):
|
||||
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', create=True) as mock_patch:
|
||||
mock_patch.side_effect = K8sException
|
||||
self.assertRaises(KubernetesError, self.k.attempt_to_acquire_leader)
|
||||
mock_patch.side_effect = k8s_client.rest.ApiException(409, '')
|
||||
self.assertFalse(self.k.attempt_to_acquire_leader())
|
||||
|
||||
def test_take_leader(self):
|
||||
self.k.take_leader()
|
||||
self.k._leader_observed_record['leader'] = 'test'
|
||||
@@ -211,7 +258,9 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
|
||||
self.k.manual_failover('foo', 'bar')
|
||||
|
||||
def test_set_config_value(self):
|
||||
self.k.set_config_value('{}')
|
||||
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map',
|
||||
Mock(side_effect=k8s_client.rest.ApiException(409, '')), create=True):
|
||||
self.k.set_config_value('{}', 1)
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', create=True)
|
||||
def test_touch_member(self, mock_patch_namespaced_pod):
|
||||
@@ -252,7 +301,7 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True)
|
||||
def test_update_leader(self, mock_patch_namespaced_endpoints):
|
||||
self.assertIsNotNone(self.k.update_leader('123'))
|
||||
self.assertIsNotNone(self.k.update_leader('123', failsafe={'foo': 'bar'}))
|
||||
args = mock_patch_namespaced_endpoints.call_args[0]
|
||||
self.assertEqual(args[2].subsets[0].addresses[0].target_ref.resource_version, '10')
|
||||
self.k._kinds._object_cache['test'].subsets[:] = []
|
||||
@@ -260,10 +309,6 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
self.k._kinds._object_cache['test'].metadata.annotations['leader'] = 'p-1'
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind, create=True)
|
||||
def test_update_leader_with_restricted_access(self):
|
||||
self.assertIsNotNone(self.k.update_leader('123', True))
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'read_namespaced_endpoints', create=True)
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True)
|
||||
def test__update_leader_with_retry(self, mock_patch, mock_read):
|
||||
@@ -271,7 +316,7 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
mock_patch.side_effect = k8s_client.rest.ApiException(502, '')
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
mock_patch.side_effect = RetryFailedError('')
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
self.assertRaises(KubernetesError, self.k.update_leader, '123')
|
||||
mock_patch.side_effect = k8s_client.rest.ApiException(409, '')
|
||||
with patch('time.time', Mock(side_effect=[0, 100, 200, 0, 0, 0, 0, 100, 200])):
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
@@ -281,6 +326,8 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
mock_read.return_value.metadata.resource_version = '2'
|
||||
self.assertIsNotNone(self.k._update_leader_with_retry({}, '1', []))
|
||||
mock_patch.side_effect = k8s_client.rest.ApiException(409, '')
|
||||
mock_read.side_effect = RetryFailedError('')
|
||||
self.assertRaises(KubernetesError, self.k.update_leader, '123')
|
||||
mock_read.side_effect = Exception
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
|
||||
@@ -293,11 +340,32 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', mock_namespaced_kind, create=True)
|
||||
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints', mock_namespaced_kind, create=True)
|
||||
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_service',
|
||||
Mock(side_effect=[True, False, k8s_client.rest.ApiException(500, '')]), create=True)
|
||||
def test__create_config_service(self):
|
||||
Mock(side_effect=[True,
|
||||
False,
|
||||
k8s_client.rest.ApiException(409, ''),
|
||||
k8s_client.rest.ApiException(403, ''),
|
||||
k8s_client.rest.ApiException(500, ''),
|
||||
Exception("Unexpected")
|
||||
]), create=True)
|
||||
@patch('patroni.dcs.kubernetes.logger.exception')
|
||||
def test__create_config_service(self, mock_logger_exception):
|
||||
self.assertIsNotNone(self.k.patch_or_create_config({'foo': 'bar'}))
|
||||
self.assertIsNotNone(self.k.patch_or_create_config({'foo': 'bar'}))
|
||||
|
||||
self.k.patch_or_create_config({'foo': 'bar'})
|
||||
mock_logger_exception.assert_not_called()
|
||||
|
||||
self.k.patch_or_create_config({'foo': 'bar'})
|
||||
mock_logger_exception.assert_not_called()
|
||||
|
||||
self.k.patch_or_create_config({'foo': 'bar'})
|
||||
mock_logger_exception.assert_called_once()
|
||||
self.assertEqual(('create_config_service failed',), mock_logger_exception.call_args[0])
|
||||
mock_logger_exception.reset_mock()
|
||||
|
||||
self.k.touch_member({'state': 'running', 'role': 'replica'})
|
||||
mock_logger_exception.assert_called_once()
|
||||
self.assertEqual(('create_config_service failed',), mock_logger_exception.call_args[0])
|
||||
|
||||
|
||||
class TestCacheBuilder(BaseTestKubernetes):
|
||||
@@ -325,3 +393,18 @@ class TestCacheBuilder(BaseTestKubernetes):
|
||||
def test__list(self):
|
||||
self.k._pods._func = Mock(side_effect=Exception)
|
||||
self.assertRaises(Exception, self.k._pods._list)
|
||||
|
||||
@patch('patroni.dcs.kubernetes.ObjectCache._watch', Mock(return_value=None))
|
||||
def test__do_watch(self):
|
||||
self.assertRaises(AttributeError, self.k._kinds._do_watch, '1')
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
|
||||
@patch('patroni.dcs.kubernetes.ObjectCache._watch')
|
||||
def test_kill_stream(self, mock_watch):
|
||||
self.k._kinds.kill_stream()
|
||||
mock_watch.return_value.read_chunked.return_value = []
|
||||
mock_watch.return_value.connection.sock.close.side_effect = Exception
|
||||
self.k._kinds._do_watch('1')
|
||||
self.k._kinds.kill_stream()
|
||||
type(mock_watch.return_value).connection = PropertyMock(side_effect=Exception)
|
||||
self.k._kinds.kill_stream()
|
||||
|
||||
@@ -63,3 +63,12 @@ class TestPatroniLogger(unittest.TestCase):
|
||||
self.assertRaises(Exception, logger.shutdown)
|
||||
self.assertLessEqual(logger.queue_size, 2) # "Failed to close the old log handler" could be still in the queue
|
||||
self.assertEqual(logger.records_lost, 0)
|
||||
|
||||
def test_interceptor(self):
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config({'level': 'INFO'})
|
||||
logger.start()
|
||||
_LOG.info('Lock owner: ')
|
||||
_LOG.info('blabla')
|
||||
logger.shutdown()
|
||||
self.assertEqual(logger.records_lost, 0)
|
||||
|
||||
+21
-8
@@ -13,15 +13,24 @@ from patroni.dcs.etcd import AbstractEtcdClientWithFailover
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.config import ConfigHandler
|
||||
from patroni import Patroni, main as _main, patroni_main, check_psycopg2
|
||||
from patroni import check_psycopg
|
||||
from patroni.__main__ import Patroni, main as _main, patroni_main
|
||||
from six.moves import BaseHTTPServer, builtins
|
||||
from threading import Thread
|
||||
|
||||
from . import psycopg2_connect, SleepException
|
||||
from . import psycopg_connect, SleepException
|
||||
from .test_etcd import etcd_read, etcd_write
|
||||
from .test_postgresql import MockPostmaster
|
||||
|
||||
|
||||
def mock_import(*args, **kwargs):
|
||||
if args[0] == 'psycopg':
|
||||
raise ImportError
|
||||
ret = Mock()
|
||||
ret.__version__ = '2.5.3.dev1 a b c'
|
||||
return ret
|
||||
|
||||
|
||||
class MockFrozenImporter(object):
|
||||
|
||||
toc = set(['patroni.dcs.etcd'])
|
||||
@@ -29,7 +38,7 @@ class MockFrozenImporter(object):
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch.object(ConfigHandler, 'append_pg_hba', Mock())
|
||||
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
|
||||
@patch.object(ConfigHandler, 'write_recovery_conf', Mock())
|
||||
@@ -41,12 +50,16 @@ class MockFrozenImporter(object):
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
class TestPatroni(unittest.TestCase):
|
||||
|
||||
@patch('sys.argv', ['patroni.py'])
|
||||
def test_no_config(self):
|
||||
self.assertRaises(SystemExit, patroni_main)
|
||||
|
||||
@patch('sys.argv', ['patroni.py', '--validate-config', 'postgres0.yml'])
|
||||
@patch('socket.socket.connect_ex', Mock(return_value=1))
|
||||
def test_validate_config(self):
|
||||
self.assertRaises(SystemExit, patroni_main)
|
||||
with patch.object(config.Config, '__init__', Mock(return_value=None)):
|
||||
self.assertRaises(SystemExit, patroni_main)
|
||||
|
||||
@patch('pkgutil.iter_importers', Mock(return_value=[MockFrozenImporter()]))
|
||||
@patch('sys.frozen', Mock(return_value=True), create=True)
|
||||
@@ -89,7 +102,7 @@ class TestPatroni(unittest.TestCase):
|
||||
|
||||
@patch('os.getpid')
|
||||
@patch('multiprocessing.Process')
|
||||
@patch('patroni.patroni_main', Mock())
|
||||
@patch('patroni.__main__.patroni_main', Mock())
|
||||
def test_patroni_main(self, mock_process, mock_getpid):
|
||||
mock_getpid.return_value = 2
|
||||
_main()
|
||||
@@ -181,8 +194,8 @@ class TestPatroni(unittest.TestCase):
|
||||
self.p.ha.shutdown = Mock(side_effect=Exception)
|
||||
self.p.shutdown()
|
||||
|
||||
def test_check_psycopg2(self):
|
||||
def test_check_psycopg(self):
|
||||
with patch.object(builtins, '__import__', Mock(side_effect=ImportError)):
|
||||
self.assertRaises(SystemExit, check_psycopg2)
|
||||
with patch('psycopg2.__version__', '2.5.3.dev1 a b c'):
|
||||
self.assertRaises(SystemExit, check_psycopg2)
|
||||
self.assertRaises(SystemExit, check_psycopg)
|
||||
with patch.object(builtins, '__import__', mock_import):
|
||||
self.assertRaises(SystemExit, check_psycopg)
|
||||
|
||||
+106
-64
@@ -1,23 +1,25 @@
|
||||
import mock # for the mock.call method, importing it without a namespace breaks python3
|
||||
import datetime
|
||||
import os
|
||||
import psutil
|
||||
import psycopg2
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||
|
||||
import patroni.psycopg as psycopg
|
||||
|
||||
from patroni.async_executor import CriticalTask
|
||||
from patroni.dcs import Cluster, ClusterConfig, Member, RemoteMember, SyncState
|
||||
from patroni.dcs import Cluster, RemoteMember, SyncState
|
||||
from patroni.exceptions import PostgresConnectionException, PatroniException
|
||||
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
||||
from patroni.postgresql.bootstrap import Bootstrap
|
||||
from patroni.postgresql.postmaster import PostmasterProcess
|
||||
from patroni.postgresql.slots import SlotsHandler
|
||||
from patroni.utils import RetryFailedError
|
||||
from six.moves import builtins
|
||||
from threading import Thread, current_thread
|
||||
|
||||
from . import BaseTestPostgresql, MockCursor, MockPostmaster, psycopg2_connect
|
||||
from . import BaseTestPostgresql, MockCursor, MockPostmaster, psycopg_connect
|
||||
|
||||
|
||||
mtime_ret = {}
|
||||
@@ -87,13 +89,13 @@ Data page checksum version: 0
|
||||
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
class TestPostgresql(BaseTestPostgresql):
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('os.rename', Mock())
|
||||
@patch('patroni.postgresql.CallbackExecutor', Mock())
|
||||
@patch.object(Postgresql, 'get_major_version', Mock(return_value=130000))
|
||||
@patch.object(Postgresql, 'get_major_version', Mock(return_value=140000))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def setUp(self):
|
||||
super(TestPostgresql, self).setUp()
|
||||
@@ -203,6 +205,21 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
mock_postmaster.signal_stop.side_effect = [None, True]
|
||||
self.assertTrue(self.p.stop(on_safepoint=mock_callback, stop_timeout=30))
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
@patch.object(Postgresql, 'is_running', MockPostmaster)
|
||||
@patch.object(Postgresql, '_wait_for_connection_close', Mock())
|
||||
@patch.object(Postgresql, 'latest_checkpoint_location', Mock(return_value='7'))
|
||||
def test__do_stop(self):
|
||||
mock_callback = Mock()
|
||||
with patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down'})):
|
||||
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
|
||||
mock_callback.assert_called()
|
||||
with patch.object(Postgresql, 'controldata',
|
||||
Mock(return_value={'Database cluster state': 'shut down in recovery'})):
|
||||
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
|
||||
with patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shutting down'})):
|
||||
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
|
||||
|
||||
def test_restart(self):
|
||||
self.p.start = Mock(return_value=False)
|
||||
self.assertFalse(self.p.restart())
|
||||
@@ -224,6 +241,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch('patroni.postgresql.config.mtime', mock_mtime)
|
||||
@patch('patroni.postgresql.config.ConfigHandler._get_pg_settings')
|
||||
def test_check_recovery_conf(self, mock_get_pg_settings):
|
||||
self.p.call_nowait('on_start')
|
||||
mock_get_pg_settings.return_value = {
|
||||
'primary_conninfo': ['primary_conninfo', 'foo=', None, 'string', 'postmaster', self.p.config._auto_conf],
|
||||
'recovery_min_apply_delay': ['recovery_min_apply_delay', '0', 'ms', 'integer', 'sighup', 'foo']
|
||||
@@ -242,8 +260,8 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
with patch('patroni.postgresql.config.ConfigHandler.primary_conninfo_params', Mock(return_value=conninfo)):
|
||||
mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '1'
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
|
||||
mock_get_pg_settings.return_value['primary_conninfo'][1] = 'host=1 passfile='\
|
||||
+ re.sub(r'([\'\\ ])', r'\\\1', self.p.config._pgpass)
|
||||
mock_get_pg_settings.return_value['primary_conninfo'][1] = 'host=1 target_session_attrs=read-write'\
|
||||
+ ' passfile=' + re.sub(r'([\'\\ ])', r'\\\1', self.p.config._pgpass)
|
||||
mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '0'
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
|
||||
self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': conninfo.copy()})
|
||||
@@ -259,6 +277,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(MockPostmaster, 'create_time', Mock(return_value=1234567), create=True)
|
||||
@patch('patroni.postgresql.config.ConfigHandler._get_pg_settings')
|
||||
def test__read_recovery_params(self, mock_get_pg_settings):
|
||||
self.p.call_nowait('on_start')
|
||||
mock_get_pg_settings.return_value = {'primary_conninfo': ['primary_conninfo', '', None, 'string',
|
||||
'postmaster', self.p.config._postgresql_conf]}
|
||||
self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': {'password': 'foo'}})
|
||||
@@ -268,6 +287,8 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
mock_get_pg_settings.side_effect = Exception
|
||||
with patch('patroni.postgresql.config.mtime', mock_mtime):
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
|
||||
with patch.object(Postgresql, 'is_starting', Mock(return_value=True)):
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (False, False))
|
||||
|
||||
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=100000))
|
||||
@patch.object(Postgresql, 'primary_conninfo', Mock(return_value='host=1'))
|
||||
@@ -302,30 +323,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'bar'}})
|
||||
self.p.follow(m)
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_sync_replication_slots(self):
|
||||
self.p.start()
|
||||
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
|
||||
'A': 0, 'ls': 0, 'b': {'type': 'logical', 'plugin': '1'}},
|
||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None)
|
||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=True)),\
|
||||
patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
|
||||
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
cluster.members.extend([alias1, alias2])
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
self.assertEqual(errorlog_mock.call_count, 5)
|
||||
ca = errorlog_mock.call_args_list[0][0][1]
|
||||
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
|
||||
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
|
||||
|
||||
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
|
||||
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError))
|
||||
def test__query(self):
|
||||
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
|
||||
self.p._state = 'restarting'
|
||||
@@ -334,19 +332,46 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
def test_query(self):
|
||||
self.p.query('select 1')
|
||||
self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError')
|
||||
self.assertRaises(psycopg2.ProgrammingError, self.p.query, 'blabla')
|
||||
self.assertRaises(psycopg.ProgrammingError, self.p.query, 'blabla')
|
||||
|
||||
@patch.object(Postgresql, 'pg_isready', Mock(return_value=STATE_REJECT))
|
||||
def test_is_leader(self):
|
||||
self.assertTrue(self.p.is_leader())
|
||||
self.p.reset_cluster_info_state()
|
||||
self.p.reset_cluster_info_state(None)
|
||||
with patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError(''))):
|
||||
self.assertRaises(PostgresConnectionException, self.p.is_leader)
|
||||
self.assertFalse(self.p.is_leader())
|
||||
|
||||
@patch.object(Postgresql, 'controldata',
|
||||
Mock(return_value={'Database cluster state': 'shut down', 'Latest checkpoint location': 'X/678'}))
|
||||
def test_latest_checkpoint_location(self):
|
||||
self.assertIsNone(self.p.latest_checkpoint_location())
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down',
|
||||
'Latest checkpoint location': '0/1ADBC18',
|
||||
"Latest checkpoint's TimeLineID": '1'}))
|
||||
@patch('subprocess.Popen')
|
||||
def test_latest_checkpoint_location(self, mock_popen):
|
||||
mock_popen.return_value.communicate.return_value = (None, None)
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), 28163096)
|
||||
# 9.3 and 9.4 format
|
||||
mock_popen.return_value.communicate.side_effect = [
|
||||
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
|
||||
b'bkp: 0000, desc: checkpoint: redo 0/1ADBC18; tli 1; prev tli 1; fpw true; xid 0/727; oid 16386; multi' +
|
||||
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
|
||||
(b'rmgr: Transaction len (rec/tot): 64/ 96, tx: 726, lsn: 0/01ADBBB8, prev 0/01ADBB70, ' +
|
||||
b'bkp: 0000, desc: commit: 2021-02-26 11:19:37.900918 CET; inval msgs: catcache 11 catcache 10', None)]
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), 28163096)
|
||||
mock_popen.return_value.communicate.side_effect = [
|
||||
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
|
||||
b'bkp: 0000, desc: checkpoint: redo 0/1ADBC18; tli 1; prev tli 1; fpw true; xid 0/727; oid 16386; multi' +
|
||||
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
|
||||
(b'rmgr: XLOG len (rec/tot): 0/ 32, tx: 0, lsn: 0/01ADBBB8, prev 0/01ADBBA0, ' +
|
||||
b'bkp: 0000, desc: xlog switch ', None)]
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), 28163000)
|
||||
# 9.5+ format
|
||||
mock_popen.return_value.communicate.side_effect = [
|
||||
(b'rmgr: XLOG len (rec/tot): 114/ 114, tx: 0, lsn: 0/01ADBC18, prev 0/018260F8, ' +
|
||||
b'desc: CHECKPOINT_SHUTDOWN redo 0/1825ED8; tli 1; prev tli 1; fpw true; xid 0:494; oid 16387; multi 1' +
|
||||
b'; offset 0; oldest xid 479 in DB 1; oldest multi 1 in DB 1; oldest/newest commit timestamp xid: 0/0;' +
|
||||
b' oldest running xid 0; shutdown', None),
|
||||
(b'rmgr: XLOG len (rec/tot): 24/ 24, tx: 0, lsn: 0/018260F8, prev 0/01826080, ' +
|
||||
b'desc: SWITCH ', None)]
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), 25321720)
|
||||
|
||||
def test_reload(self):
|
||||
self.assertTrue(self.p.reload())
|
||||
@@ -409,7 +434,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster()))
|
||||
def test_is_leader_exception(self):
|
||||
self.p.start()
|
||||
self.p.query = Mock(side_effect=psycopg2.OperationalError("not supported"))
|
||||
self.p.query = Mock(side_effect=psycopg.OperationalError("not supported"))
|
||||
self.assertTrue(self.p.stop())
|
||||
|
||||
@patch('os.rename', Mock())
|
||||
@@ -429,24 +454,20 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
def test_get_postgres_role_from_data_directory(self):
|
||||
self.assertEqual(self.p.get_postgres_role_from_data_directory(), 'replica')
|
||||
|
||||
@patch('os.remove', Mock())
|
||||
@patch('shutil.rmtree', Mock())
|
||||
@patch('os.unlink', Mock(side_effect=OSError))
|
||||
@patch('os.path.isdir', Mock(return_value=True))
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
def test_remove_data_directory(self):
|
||||
def _symlink(src, dst):
|
||||
if os.name != 'nt': # os.symlink under Windows needs admin rights skip it
|
||||
os.symlink(src, dst)
|
||||
|
||||
os.makedirs(os.path.join(self.p.data_dir, 'foo'))
|
||||
_symlink('foo', os.path.join(self.p.data_dir, 'pg_wal'))
|
||||
os.makedirs(os.path.join(self.p.data_dir, 'foo_tsp'))
|
||||
pg_tblspc = os.path.join(self.p.data_dir, 'pg_tblspc')
|
||||
os.makedirs(pg_tblspc)
|
||||
_symlink('../foo_tsp', os.path.join(pg_tblspc, '12345'))
|
||||
self.p.remove_data_directory()
|
||||
open(self.p.data_dir, 'w').close()
|
||||
self.p.remove_data_directory()
|
||||
_symlink('unexisting', self.p.data_dir)
|
||||
with patch('os.unlink', Mock(side_effect=OSError)):
|
||||
with patch('os.path.islink', Mock(return_value=True)):
|
||||
self.p.remove_data_directory()
|
||||
with patch('os.path.isfile', Mock(return_value=True)):
|
||||
self.p.remove_data_directory()
|
||||
with patch('os.path.islink', Mock(side_effect=[False, False, True, True])),\
|
||||
patch('os.listdir', Mock(return_value=['12345'])),\
|
||||
patch('os.path.realpath', Mock(side_effect=['../foo', '../foo_tsp'])):
|
||||
self.p.remove_data_directory()
|
||||
self.p.remove_data_directory()
|
||||
|
||||
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
|
||||
def test_controldata(self):
|
||||
@@ -498,7 +519,8 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
parameters = self._PARAMETERS.copy()
|
||||
parameters.pop('f.oo')
|
||||
parameters['wal_buffers'] = '512'
|
||||
config = {'pg_hba': [''], 'pg_ident': [''], 'use_unix_socket': True, 'authentication': {},
|
||||
config = {'pg_hba': [''], 'pg_ident': [''], 'use_unix_socket': True, 'use_unix_socket_repl': True,
|
||||
'authentication': {},
|
||||
'retry_timeout': 10, 'listen': '*', 'krbsrvname': 'postgres', 'parameters': parameters}
|
||||
self.p.reload_config(config)
|
||||
mock_fetchone.side_effect = Exception
|
||||
@@ -514,6 +536,14 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.p.reload_config(config)
|
||||
self.p.config.resolve_connection_addresses()
|
||||
|
||||
def test_resolve_connection_addresses(self):
|
||||
self.p.config._config['use_unix_socket'] = self.p.config._config['use_unix_socket_repl'] = True
|
||||
self.p.config.resolve_connection_addresses()
|
||||
self.assertEqual(self.p.config.local_replication_address, {'host': '/tmp', 'port': '5432'})
|
||||
self.p.config._server_parameters.pop('unix_socket_directories')
|
||||
self.p.config.resolve_connection_addresses()
|
||||
self.assertEqual(self.p.config._local_address, {'port': '5432'})
|
||||
|
||||
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
|
||||
def test_get_major_version(self):
|
||||
with patch.object(builtins, 'open', mock_open(read_data='9.4')):
|
||||
@@ -522,13 +552,14 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertEqual(self.p.get_major_version(), 0)
|
||||
|
||||
def test_postmaster_start_time(self):
|
||||
with patch.object(MockCursor, "fetchone", Mock(return_value=('foo', True, '', '', '', '', False))):
|
||||
self.assertEqual(self.p.postmaster_start_time(), 'foo')
|
||||
now = datetime.datetime.now()
|
||||
with patch.object(MockCursor, "fetchone", Mock(return_value=(now, True, '', '', '', '', False))):
|
||||
self.assertEqual(self.p.postmaster_start_time(), now.isoformat(sep=' '))
|
||||
t = Thread(target=self.p.postmaster_start_time)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
with patch.object(MockCursor, "execute", side_effect=psycopg2.Error):
|
||||
with patch.object(MockCursor, "execute", side_effect=psycopg.Error):
|
||||
self.assertIsNone(self.p.postmaster_start_time())
|
||||
|
||||
def test_check_for_startup(self):
|
||||
@@ -609,7 +640,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
|
||||
def test_pick_sync_standby(self):
|
||||
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None,
|
||||
SyncState(0, self.me.name, self.leadermem.name), None)
|
||||
SyncState(0, self.me.name, self.leadermem.name), None, None, None)
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.fetchone.return_value = ('remote_apply',)
|
||||
|
||||
@@ -691,7 +722,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.p.stop(on_safepoint=mock_callback)
|
||||
|
||||
mock_postmaster.is_running.side_effect = [True, False, False]
|
||||
with patch.object(MockCursor, "execute", Mock(side_effect=psycopg2.Error)):
|
||||
with patch.object(MockCursor, "execute", Mock(side_effect=psycopg.Error)):
|
||||
self.p.stop(on_safepoint=mock_callback)
|
||||
|
||||
def test_terminate_starting_postmaster(self):
|
||||
@@ -707,6 +738,8 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertEqual(self.p.get_master_timeline(), 1)
|
||||
|
||||
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica'))
|
||||
@patch.object(Bootstrap, 'running_custom_bootstrap', PropertyMock(return_value=True))
|
||||
@patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True))
|
||||
def test__build_effective_configuration(self):
|
||||
with patch.object(Postgresql, 'controldata',
|
||||
Mock(return_value={'max_connections setting': '200',
|
||||
@@ -725,10 +758,19 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError('')))
|
||||
def test_received_timeline(self):
|
||||
self.p.set_role('standby_leader')
|
||||
self.p.reset_cluster_info_state()
|
||||
self.p.reset_cluster_info_state(None)
|
||||
self.assertRaises(PostgresConnectionException, self.p.received_timeline)
|
||||
|
||||
def test__write_recovery_params(self):
|
||||
self.p.config._write_recovery_params(Mock(), {'pause_at_recovery_target': 'false'})
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90400)):
|
||||
self.p.config._write_recovery_params(Mock(), {'recovery_target_action': 'PROMOTE'})
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_set_enforce_hot_standby_feedback(self):
|
||||
self.p.set_enforce_hot_standby_feedback(True)
|
||||
|
||||
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=140000))
|
||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=True))
|
||||
def test_handle_parameter_change(self):
|
||||
self.p.handle_parameter_change()
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestPostmasterProcess(unittest.TestCase):
|
||||
|
||||
# all processes successfully stopped
|
||||
mock_children.return_value = [Mock()]
|
||||
mock_children.return_value[0].kill.side_effect = psutil.Error
|
||||
mock_children.return_value[0].kill.side_effect = psutil.NoSuchProcess(123)
|
||||
self.assertTrue(proc.signal_kill())
|
||||
|
||||
# postmaster has gone before suspend
|
||||
@@ -81,17 +81,17 @@ class TestPostmasterProcess(unittest.TestCase):
|
||||
self.assertTrue(proc.signal_kill())
|
||||
|
||||
# postmaster has gone before we got a list of children
|
||||
mock_suspend.side_effect = psutil.Error()
|
||||
mock_suspend.side_effect = psutil.AccessDenied()
|
||||
mock_children.side_effect = psutil.NoSuchProcess(123)
|
||||
self.assertTrue(proc.signal_kill())
|
||||
|
||||
# postmaster has gone after we got a list of children
|
||||
mock_children.side_effect = psutil.Error()
|
||||
mock_children.side_effect = psutil.AccessDenied()
|
||||
mock_kill.side_effect = psutil.NoSuchProcess(123)
|
||||
self.assertTrue(proc.signal_kill())
|
||||
|
||||
# failed to kill postmaster
|
||||
mock_kill.side_effect = psutil.AccessDenied(123)
|
||||
mock_kill.side_effect = psutil.AccessDenied()
|
||||
self.assertFalse(proc.signal_kill())
|
||||
|
||||
@patch('psutil.Process.__init__', Mock())
|
||||
@@ -133,14 +133,20 @@ class TestPostmasterProcess(unittest.TestCase):
|
||||
c2.cmdline = Mock(return_value=["postgres: postgres postgres [local] idle"])
|
||||
c3 = Mock()
|
||||
c3.cmdline = Mock(side_effect=psutil.NoSuchProcess(123))
|
||||
mock_wait.return_value = ([], [c2])
|
||||
with patch('psutil.Process.children', Mock(return_value=[c1, c2, c3])):
|
||||
proc = PostmasterProcess(123)
|
||||
self.assertIsNone(proc.wait_for_user_backends_to_close())
|
||||
mock_wait.assert_called_with([c2])
|
||||
self.assertIsNone(proc.wait_for_user_backends_to_close(1))
|
||||
mock_wait.assert_called_with([c2], 1)
|
||||
|
||||
mock_wait.return_value = ([c2], [])
|
||||
with patch('psutil.Process.children', Mock(return_value=[c1, c2, c3])):
|
||||
proc = PostmasterProcess(123)
|
||||
proc.wait_for_user_backends_to_close(1)
|
||||
|
||||
with patch('psutil.Process.children', Mock(side_effect=psutil.NoSuchProcess(123))):
|
||||
proc = PostmasterProcess(123)
|
||||
self.assertIsNone(proc.wait_for_user_backends_to_close())
|
||||
self.assertIsNone(proc.wait_for_user_backends_to_close(None))
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@patch('os.setsid', Mock(), create=True)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user