mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 07:30:14 +00:00
Compare commits
@@ -45,8 +45,8 @@ def install_packages(what):
|
||||
packages['exhibitor'] = packages['zookeeper']
|
||||
packages = packages.get(what, [])
|
||||
ver = versions.get(what)
|
||||
if float(ver) >= 15:
|
||||
packages += ['postgresql-{0}-citus-11.2'.format(ver)]
|
||||
if float(ver) == 15:
|
||||
packages += ['postgresql-{0}-citus-12.0'.format(ver)]
|
||||
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
|
||||
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- 'REL_[0-9]+_[0-9]+'
|
||||
|
||||
env:
|
||||
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
@@ -173,4 +174,28 @@ jobs:
|
||||
|
||||
- uses: jakebailey/pyright-action@v1
|
||||
with:
|
||||
version: 1.1.317
|
||||
version: 1.1.326
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.11
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install tox
|
||||
|
||||
- name: Install package dependencies
|
||||
run: |
|
||||
sudo apt update \
|
||||
&& sudo apt install -y \
|
||||
latexmk texlive-latex-extra tex-gyre \
|
||||
--no-install-recommends
|
||||
|
||||
- name: Generate documentation
|
||||
run: tox -m docs
|
||||
|
||||
@@ -51,6 +51,7 @@ scm-source.json
|
||||
docs/build/
|
||||
docs/source/_static/
|
||||
docs/source/_templates/
|
||||
docs/modules/
|
||||
|
||||
# Pycharm IDE
|
||||
.idea/
|
||||
|
||||
@@ -19,3 +19,8 @@ formats:
|
||||
- epub
|
||||
- pdf
|
||||
- htmlzip
|
||||
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements.docs.txt
|
||||
- requirements: requirements.txt
|
||||
|
||||
+2
-3
@@ -25,8 +25,7 @@ RUN set -ex \
|
||||
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
|
||||
| xargs apt-get install -y vim curl less jq locales haproxy sudo \
|
||||
python3-etcd python3-kazoo python3-pip busybox \
|
||||
net-tools iputils-ping --fix-missing \
|
||||
&& pip3 install dumb-init \
|
||||
net-tools iputils-ping dumb-init --fix-missing \
|
||||
\
|
||||
# Cleanup all locales but en_US.UTF-8
|
||||
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
|
||||
@@ -71,7 +70,7 @@ RUN set -ex \
|
||||
# Clean up all useless packages and some files
|
||||
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
|
||||
libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \
|
||||
exim4-config gnupg-agent dirmngr libpython2.7-stdlib libpython2.7-minimal \
|
||||
exim4-config gnupg-agent dirmngr \
|
||||
git make \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
|
||||
+2
-3
@@ -25,7 +25,7 @@ RUN set -ex \
|
||||
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
|
||||
| xargs apt-get install -y vim curl less jq locales haproxy sudo \
|
||||
python3-etcd python3-kazoo python3-pip busybox \
|
||||
net-tools iputils-ping lsb-release --fix-missing \
|
||||
net-tools iputils-ping lsb-release dumb-init --fix-missing \
|
||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||
apt-get install -y postgresql-server-dev-$PG_MAJOR \
|
||||
git gcc make autoconf \
|
||||
@@ -42,7 +42,6 @@ RUN set -ex \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
|
||||
fi \
|
||||
&& pip3 install dumb-init \
|
||||
\
|
||||
# Cleanup all locales but en_US.UTF-8
|
||||
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
|
||||
@@ -88,7 +87,7 @@ RUN set -ex \
|
||||
# Clean up all useless packages and some files
|
||||
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
|
||||
libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \
|
||||
exim4-config gnupg-agent dirmngr libpython2.7-stdlib libpython2.7-minimal \
|
||||
exim4-config gnupg-agent dirmngr \
|
||||
postgresql-server-dev-$PG_MAJOR git gcc make autoconf \
|
||||
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
|
||||
|
||||
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 15.
|
||||
Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
|
||||
|
||||
|
||||
+7
-177
@@ -1,182 +1,12 @@
|
||||
.. _contributing:
|
||||
|
||||
Contributing guidelines
|
||||
=======================
|
||||
Contributing
|
||||
============
|
||||
|
||||
Wanna contribute to Patroni? Yay - here is how!
|
||||
Resources and information for developers can be found in the pages below.
|
||||
|
||||
Chatting
|
||||
--------
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
Requirements for running behave tests:
|
||||
|
||||
1. PostgreSQL packages need to be installed.
|
||||
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
|
||||
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify pip3.
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.dev.txt
|
||||
|
||||
After you have all dependencies installed, you can run the various test suites:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify python3.
|
||||
|
||||
# Run flake8 to check syntax and formatting:
|
||||
python setup.py flake8
|
||||
|
||||
# Run the pytest suite in tests/:
|
||||
python setup.py test
|
||||
|
||||
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
|
||||
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
|
||||
DCS=raft python -m behave
|
||||
|
||||
Testing with tox
|
||||
----------------
|
||||
|
||||
To run tox tests you only need to install one dependency (other than Python)
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install tox>=4
|
||||
|
||||
If you wish to run `behave` tests then you also need docker installed.
|
||||
|
||||
Tox configuration in `tox.ini` has "environments" to run the following tasks:
|
||||
|
||||
* lint: Python code lint with `flake8`
|
||||
* test: unit tests for all available python interpreters with `pytest`,
|
||||
generates XML reports or HTML reports if a TTY is detected
|
||||
* dep: detect package dependency conflicts using `pipdeptree`
|
||||
* type: static type checking with `pyright`
|
||||
* black: code formatting with `black`
|
||||
* docker-build: build docker image used for the `behave` env
|
||||
* docker-cmd: run arbitrary command with the above image
|
||||
* docker-behave-etcd: run tox for behave tests with above image
|
||||
* py*behave: run behave with available python interpreters (without docker, although
|
||||
this is what is called inside docker containers)
|
||||
* docs: build docs with `sphinx`
|
||||
|
||||
Running tox
|
||||
^^^^^^^^^^^
|
||||
|
||||
To run the default env list; dep, lint, test, and docs, just run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox
|
||||
|
||||
The `test` envs can be run with the label `test`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m test
|
||||
|
||||
The `behave` docker tests can be run with the label `behave`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m behave
|
||||
|
||||
Similarly, docs has the label `docs`.
|
||||
|
||||
All other envs can be run with their respective env names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -e lint
|
||||
tox -e py39-test-lin
|
||||
|
||||
It is also possible to select partial env lists using `factors`. For example, if you want to run
|
||||
all envs for python 3.10:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -f py310
|
||||
|
||||
This is equivalent to running all the envs listed below:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ tox -l -f py310
|
||||
py310-test-lin
|
||||
py310-test-mac
|
||||
py310-test-win
|
||||
py310-type-lin
|
||||
py310-type-mac
|
||||
py310-type-win
|
||||
py310-behave-etcd-lin
|
||||
py310-behave-etcd-win
|
||||
py310-behave-etcd-mac
|
||||
|
||||
|
||||
You can list all configured combinations of environments with tox (>=v4) like so
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox l
|
||||
|
||||
The envs `test` and `docs` will attempt to open the HTML output files
|
||||
when the job completes, if tox is run with an active terminal. This
|
||||
is intended to be for benefit of the developer running this env locally.
|
||||
It will attempt to run `open` on a mac and `xdg-open` on Linux.
|
||||
To use a different command set the env var `OPEN_CMD` to the name or path of
|
||||
the command. If this step fails it will not fail the run overall.
|
||||
If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op command.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
OPEN_CMD=: tox -m docs
|
||||
|
||||
Behave tests
|
||||
^^^^^^^^^^^^
|
||||
|
||||
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 15 and then run all
|
||||
behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of
|
||||
Postgres or to a specific feature set or steps.
|
||||
|
||||
To specify the version of postgres include the full name of the dependent image build env that you want and then the
|
||||
behave env name. For instance if you want Postgres 15 use:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -e pg14-docker-build,pg14-docker-behave-etcd-lin
|
||||
|
||||
If on the other hand you want to test a specific feature you can pass positional arguments to behave. This will run
|
||||
the watchdog behave feature test scenario with all versions of Postgres.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m behave -- features/watchdog.feature
|
||||
|
||||
Of course you can combine the two.
|
||||
|
||||
Reporting issues
|
||||
----------------
|
||||
|
||||
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
|
||||
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
|
||||
|
||||
Contributing a pull request
|
||||
---------------------------
|
||||
|
||||
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
|
||||
2) Do a fork, develop and test your code changes.
|
||||
3) Include documentation
|
||||
4) Submit a pull request.
|
||||
|
||||
You'll get feedback about your pull request as soon as possible.
|
||||
|
||||
Happy Patroni hacking ;-)
|
||||
contributing_guidelines
|
||||
Patroni API docs<modules/modules>
|
||||
|
||||
+19
-6
@@ -112,7 +112,11 @@ Kubernetes
|
||||
- **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`.
|
||||
- **PATRONI\_KUBERNETES\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
|
||||
- **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
||||
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing Postgres role (`master` or `replica`). Patroni will set this label on the pod it is running in. Default value is `role`.
|
||||
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
|
||||
- **PATRONI\_KUBERNETES\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `master`. Default value is `master`.
|
||||
- **PATRONI\_KUBERNETES\_FOLLOWER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `replica`. Default value is `replica`.
|
||||
- **PATRONI\_KUBERNETES\_STANDBY\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is ``standby_leader``. Default value is ``master``.
|
||||
- **PATRONI\_KUBERNETES\_TMP\_ROLE\_LABEL**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
|
||||
- **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.
|
||||
@@ -196,10 +200,19 @@ REST API
|
||||
- **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``.
|
||||
- **PATRONI\_RESTAPI\_REQUEST\_QUEUE\_SIZE**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
|
||||
|
||||
.. warning::
|
||||
|
||||
- The ``PATRONI_RESTAPI_CONNECT_ADDRESS`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
|
||||
- If you enabled client certificates validation (``PATRONI_RESTAPI_VERIFY_CLIENT`` is set to ``required``), you also **must** provide **valid client certificates** in the ``PATRONI_CTL_CERTFILE``, ``PATRONI_CTL_KEYFILE``, ``PATRONI_CTL_KEYFILE_PASSWORD``. If not provided, Patroni will not work correctly.
|
||||
|
||||
|
||||
CTL
|
||||
---
|
||||
- **PATRONICTL\_CONFIG\_FILE**: location of the configuration file.
|
||||
- **PATRONI\_CTL\_INSECURE**: Allow connections to REST API without verifying SSL certs.
|
||||
- **PATRONI\_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.
|
||||
- **PATRONI\_CTL\_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.
|
||||
- **PATRONI\_CTL\_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.
|
||||
- **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file.
|
||||
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
|
||||
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
|
||||
- **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
|
||||
- **PATRONI\_CTL\_CACERT**: (optional) 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.
|
||||
- **PATRONI\_CTL\_CERTFILE**: (optional) Specifies the file with the client certificate in the PEM format.
|
||||
- **PATRONI\_CTL\_KEYFILE**: (optional) Specifies the file with the client secret key in the PEM format.
|
||||
- **PATRONI\_CTL\_KEYFILE\_PASSWORD**: (optional) Specifies a password for decrypting the client keyfile.
|
||||
|
||||
+99
-5
@@ -20,10 +20,15 @@
|
||||
import os
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
from patroni.version import __version__
|
||||
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
module_dir = os.path.abspath(os.path.join(project_root, 'patroni'))
|
||||
excludes = ['tests', 'setup.py', 'conf']
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
@@ -33,11 +38,21 @@ from patroni.version import __version__
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = ['sphinx.ext.intersphinx',
|
||||
extensions = [
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.todo',
|
||||
'sphinx.ext.mathjax',
|
||||
'sphinx.ext.ifconfig',
|
||||
'sphinx.ext.viewcode']
|
||||
# 'sphinx.ext.viewcode',
|
||||
'sphinx_github_style', # Generate "View on GitHub" for source code
|
||||
'sphinxcontrib.apidoc', # For generating module docs from code
|
||||
'sphinx.ext.autodoc', # For generating module docs from docstrings
|
||||
'sphinx.ext.napoleon', # For Google and Numpy formatted docstrings
|
||||
]
|
||||
apidoc_module_dir = module_dir
|
||||
apidoc_output_dir = 'modules'
|
||||
apidoc_excluded_paths = excludes
|
||||
apidoc_separate_modules = True
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
@@ -90,10 +105,10 @@ todo_include_todos = True
|
||||
# a list of builtin themes.
|
||||
#
|
||||
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
@@ -107,6 +122,34 @@ if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Replace "source" links with "edit on GitHub" when using rtd theme
|
||||
html_context = {
|
||||
'display_github': True,
|
||||
'github_user': 'zalando',
|
||||
'github_repo': 'patroni',
|
||||
'github_version': 'master',
|
||||
'conf_py_path': '/docs/',
|
||||
}
|
||||
|
||||
# sphinx-github-style options, https://sphinx-github-style.readthedocs.io/en/latest/index.html
|
||||
|
||||
# The name of the top-level package.
|
||||
top_level = "patroni"
|
||||
|
||||
# The blob to link to on GitHub - any of "head", "last_tag", or "{blob}"
|
||||
# linkcode_blob = 'head'
|
||||
|
||||
# The link to your GitHub repository formatted as https://github.com/user/repo
|
||||
# If not provided, will attempt to create the link from the html_context dict
|
||||
# linkcode_url = f"https://github.com/{html_context['github_user']}/" \
|
||||
# f"{html_context['github_repo']}/{html_context['github_version']}"
|
||||
|
||||
# The text to use for the linkcode link
|
||||
# linkcode_link_text: str = "View on GitHub"
|
||||
|
||||
# A linkcode_resolve() function to use for resolving the link target
|
||||
# linkcode_resolve: types.FunctionType
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
@@ -165,7 +208,6 @@ texinfo_documents = [
|
||||
]
|
||||
|
||||
|
||||
|
||||
# -- Options for Epub output ----------------------------------------------
|
||||
|
||||
# Bibliographic Dublin Core info.
|
||||
@@ -187,10 +229,57 @@ epub_copyright = copyright
|
||||
epub_exclude_files = ['search.html']
|
||||
|
||||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'python': ('https://docs.python.org/', None)}
|
||||
|
||||
|
||||
# Remove these pages from index, references, toc trees, etc.
|
||||
# If the builder is not 'html' then add the API docs modules index to pages to be removed.
|
||||
exclude_from_builder = {
|
||||
'latex': ['modules/modules'],
|
||||
'epub': ['modules/modules'],
|
||||
}
|
||||
# Internal holding list, anything added here will always be excluded
|
||||
_docs_to_remove = []
|
||||
|
||||
|
||||
def builder_inited(app):
|
||||
"""Run during Sphinx `builder-inited` phase.
|
||||
|
||||
Set a config value to builder name and add module docs to `docs_to_remove`.
|
||||
"""
|
||||
print(f'The builder is: {app.builder.name}')
|
||||
app.add_config_value('builder', app.builder.name, 'env')
|
||||
|
||||
# Remove pages when builder matches any referenced in exclude_from_builder
|
||||
if exclude_from_builder.get(app.builder.name):
|
||||
_docs_to_remove.extend(exclude_from_builder[app.builder.name])
|
||||
|
||||
|
||||
def env_get_outdated(app, env, added, changed, removed):
|
||||
"""Run during Sphinx `env-get-outdated` phase.
|
||||
|
||||
Remove the items listed in `docs_to_remove` from known pages.
|
||||
"""
|
||||
added.difference_update(_docs_to_remove)
|
||||
changed.difference_update(_docs_to_remove)
|
||||
removed.update(_docs_to_remove)
|
||||
return []
|
||||
|
||||
|
||||
def doctree_read(app, doctree):
|
||||
"""Run during Sphinx `doctree-read` phase.
|
||||
|
||||
Remove the items listed in `docs_to_remove` from the table of contents.
|
||||
"""
|
||||
from sphinx import addnodes
|
||||
for toc_tree_node in doctree.traverse(addnodes.toctree):
|
||||
for e in toc_tree_node['entries']:
|
||||
ref = str(e[1])
|
||||
if ref in _docs_to_remove:
|
||||
toc_tree_node['entries'].remove(e)
|
||||
|
||||
|
||||
# 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):
|
||||
@@ -198,3 +287,8 @@ def setup(app):
|
||||
app.add_css_file('custom.css')
|
||||
else:
|
||||
app.add_stylesheet('custom.css')
|
||||
|
||||
# Run extra steps to remove module docs when running with a non-html builder
|
||||
app.connect('builder-inited', builder_inited)
|
||||
app.connect('env-get-outdated', env_get_outdated)
|
||||
app.connect('doctree-read', doctree_read)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
.. _contributing_guidelines:
|
||||
|
||||
Contributing guidelines
|
||||
=======================
|
||||
|
||||
Wanna contribute to Patroni? Yay - here is how!
|
||||
|
||||
Chatting
|
||||
--------
|
||||
|
||||
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
Requirements for running behave tests:
|
||||
|
||||
1. PostgreSQL packages need to be installed.
|
||||
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
|
||||
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify pip3.
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.dev.txt
|
||||
|
||||
After you have all dependencies installed, you can run the various test suites:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify python3.
|
||||
|
||||
# Run flake8 to check syntax and formatting:
|
||||
python setup.py flake8
|
||||
|
||||
# Run the pytest suite in tests/:
|
||||
python setup.py test
|
||||
|
||||
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
|
||||
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
|
||||
DCS=raft python -m behave
|
||||
|
||||
Testing with tox
|
||||
----------------
|
||||
|
||||
To run tox tests you only need to install one dependency (other than Python)
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install tox>=4
|
||||
|
||||
If you wish to run `behave` tests then you also need docker installed.
|
||||
|
||||
Tox configuration in `tox.ini` has "environments" to run the following tasks:
|
||||
|
||||
* lint: Python code lint with `flake8`
|
||||
* test: unit tests for all available python interpreters with `pytest`,
|
||||
generates XML reports or HTML reports if a TTY is detected
|
||||
* dep: detect package dependency conflicts using `pipdeptree`
|
||||
* type: static type checking with `pyright`
|
||||
* black: code formatting with `black`
|
||||
* docker-build: build docker image used for the `behave` env
|
||||
* docker-cmd: run arbitrary command with the above image
|
||||
* docker-behave-etcd: run tox for behave tests with above image
|
||||
* py*behave: run behave with available python interpreters (without docker, although
|
||||
this is what is called inside docker containers)
|
||||
* docs: build docs with `sphinx`
|
||||
|
||||
Running tox
|
||||
^^^^^^^^^^^
|
||||
|
||||
To run the default env list; dep, lint, test, and docs, just run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox
|
||||
|
||||
The `test` envs can be run with the label `test`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m test
|
||||
|
||||
The `behave` docker tests can be run with the label `behave`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m behave
|
||||
|
||||
Similarly, docs has the label `docs`.
|
||||
|
||||
All other envs can be run with their respective env names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -e lint
|
||||
tox -e py39-test-lin
|
||||
|
||||
It is also possible to select partial env lists using `factors`. For example, if you want to run
|
||||
all envs for python 3.10:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -f py310
|
||||
|
||||
This is equivalent to running all the envs listed below:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ tox -l -f py310
|
||||
py310-test-lin
|
||||
py310-test-mac
|
||||
py310-test-win
|
||||
py310-type-lin
|
||||
py310-type-mac
|
||||
py310-type-win
|
||||
py310-behave-etcd-lin
|
||||
py310-behave-etcd-win
|
||||
py310-behave-etcd-mac
|
||||
|
||||
|
||||
You can list all configured combinations of environments with tox (>=v4) like so
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox l
|
||||
|
||||
The envs `test` and `docs` will attempt to open the HTML output files
|
||||
when the job completes, if tox is run with an active terminal. This
|
||||
is intended to be for benefit of the developer running this env locally.
|
||||
It will attempt to run `open` on a mac and `xdg-open` on Linux.
|
||||
To use a different command set the env var `OPEN_CMD` to the name or path of
|
||||
the command. If this step fails it will not fail the run overall.
|
||||
If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op command.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
OPEN_CMD=: tox -m docs
|
||||
|
||||
Behave tests
|
||||
^^^^^^^^^^^^
|
||||
|
||||
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 15 and then run all
|
||||
behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of
|
||||
Postgres or to a specific feature set or steps.
|
||||
|
||||
To specify the version of postgres include the full name of the dependent image build env that you want and then the
|
||||
behave env name. For instance if you want Postgres 15 use:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -e pg14-docker-build,pg14-docker-behave-etcd-lin
|
||||
|
||||
If on the other hand you want to test a specific feature you can pass positional arguments to behave. This will run
|
||||
the watchdog behave feature test scenario with all versions of Postgres.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m behave -- features/watchdog.feature
|
||||
|
||||
Of course you can combine the two.
|
||||
|
||||
Reporting issues
|
||||
----------------
|
||||
|
||||
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
|
||||
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
|
||||
|
||||
Contributing a pull request
|
||||
---------------------------
|
||||
|
||||
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
|
||||
2) Do a fork, develop and test your code changes.
|
||||
3) Include documentation
|
||||
4) Submit a pull request.
|
||||
|
||||
You'll get feedback about your pull request as soon as possible.
|
||||
|
||||
Happy Patroni hacking ;-)
|
||||
@@ -46,9 +46,9 @@ In order to change the dynamic configuration you can use either ``patronictl edi
|
||||
- **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. 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+.
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. The physical slots are maintained only in the current primary. 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.
|
||||
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current leader it will not be created. Please note that Patroni does not make checks for permanent slot names added to this configuration matching those that Patroni creates automatically for members. If those names are added, Patroni will ensure that any slots that were created are not removed even if the member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS due to its effect on normal functioning of Patroni.
|
||||
|
||||
- **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.
|
||||
|
||||
+56
-16
@@ -10,18 +10,58 @@ To deploy a Patroni cluster without using a pre-existing PostgreSQL instance, se
|
||||
Procedure
|
||||
---------
|
||||
|
||||
A Patroni cluster can be started with a data directory from a single-node PostgreSQL database. This is achieved by following closely these steps:
|
||||
You can find below an overview of steps for converting an existing Postgres cluster to a Patroni managed cluster. In the steps we assume all nodes that are part of the existing cluster are currently up and running, and that you *do not* intend to change Postgres configuration while the migration is ongoing. The steps:
|
||||
|
||||
1. Manually start PostgreSQL daemon
|
||||
2. Create Patroni superuser and replication users as defined in the :ref:`authentication <postgresql_settings>` section of the Patroni configuration. If this user is created in SQL, the following queries achieve this:
|
||||
#. Create the Postgres users as explained for :ref:`authentication <postgresql_settings>` section of the Patroni configuration. You can find sample SQL commands to create the users in the code block below, in which you need to replace the usernames and passwords as per your environment. If you already have the relevant users, then you can skip this step.
|
||||
|
||||
.. code-block:: sql
|
||||
.. code-block:: sql
|
||||
|
||||
CREATE USER $PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD '$PATRONI_SUPERUSER_PASSWORD';
|
||||
CREATE USER $PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD '$PATRONI_REPLICATION_PASSWORD';
|
||||
-- Patroni superuser
|
||||
-- Replace PATRONI_SUPERUSER_USERNAME and PATRONI_SUPERUSER_PASSWORD accordingly
|
||||
CREATE USER PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD 'PATRONI_SUPERUSER_PASSWORD';
|
||||
|
||||
3. Start Patroni (e.g. ``patroni /etc/patroni/patroni.yml``). It automatically detects that PostgreSQL daemon is already running but its configuration might be out-of-date.
|
||||
4. Ask Patroni to restart the node with ``patronictl restart cluster-name node-name``. This step is only required if PostgreSQL configuration is out-of-date.
|
||||
-- Patroni replication user
|
||||
-- Replace PATRONI_REPLICATION_USERNAME and PATRONI_REPLICATION_PASSWORD accordingly
|
||||
CREATE USER PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD 'PATRONI_REPLICATION_PASSWORD';
|
||||
|
||||
-- Patroni rewind user, if you intend to enable use_pg_rewind in your Patroni configuration
|
||||
-- Replace PATRONI_REWIND_USERNAME and PATRONI_REWIND_PASSWORD accordingly
|
||||
CREATE USER PATRONI_REWIND_USERNAME WITH ENCRYPTED PASSWORD 'PATRONI_REWIND_PASSWORD';
|
||||
GRANT EXECUTE ON function pg_catalog.pg_ls_dir(text, boolean, boolean) TO PATRONI_REWIND_USERNAME;
|
||||
GRANT EXECUTE ON function pg_catalog.pg_stat_file(text, boolean) TO PATRONI_REWIND_USERNAME;
|
||||
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text) TO PATRONI_REWIND_USERNAME;
|
||||
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text, bigint, bigint, boolean) TO PATRONI_REWIND_USERNAME;
|
||||
|
||||
#. Perform the following steps on all Postgres nodes. Perform all steps on one node before proceeding with the next node. Start with the primary node, then proceed with each standby node:
|
||||
|
||||
#. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon.
|
||||
|
||||
#. Create a YAML configuration file for Patroni.
|
||||
|
||||
* **Note (specific for the primary node):** If you have replication slots being used for replication between cluster members, then it is recommended that you enable ``use_slots`` and configure the existing replication slots as permanent via the ``slots`` configuration item. Be aware that Patroni automatically creates replication slots for replication between members, and drops replication slots that it does not recognize, when ``use_slots`` is enabled. The idea of using permanent slots here is to allow your existing slots to persist while the migration to Patroni is in progress. See :ref:`YAML Configuration Settings <yaml_configuration>` for details.
|
||||
|
||||
#. Start Patroni using the ``patroni`` systemd service unit. It automatically detects that Postgres is already running and starts monitoring the instance.
|
||||
|
||||
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through ``patronictl restart cluster-name member-name`` command. For minimal downtime you might want to split this step into:
|
||||
|
||||
#. Immediate restart of the standby nodes.
|
||||
#. Scheduled restart of the primary node within a maintenance window.
|
||||
|
||||
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through ``patronictl edit-config cluster-name member-name`` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
-- Assume original_slot_for_member_x is the name of the slot in your original
|
||||
-- cluster for replicating changes to member X, and slot_for_member_x is the
|
||||
-- slot created by Patroni for that purpose. You need restart_lsn of
|
||||
-- slot_for_member_x to be >= restart_lsn of original_slot_for_member_x
|
||||
SELECT slot_name,
|
||||
restart_lsn
|
||||
FROM pg_replication_slots
|
||||
WHERE slot_name IN (
|
||||
'original_slot_for_member_x',
|
||||
'slot_for_member_x'
|
||||
)
|
||||
|
||||
.. _major_upgrade:
|
||||
|
||||
@@ -30,14 +70,14 @@ 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 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 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.
|
||||
#. Stop Patroni
|
||||
#. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
|
||||
#. Update patroni.yml
|
||||
#. 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.
|
||||
#. 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.
|
||||
#. Start Patroni on the primary node.
|
||||
#. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
|
||||
#. Start Patroni on the standby nodes and wait for the replication to complete.
|
||||
|
||||
Running pg_upgrade on standby nodes is not supported by PostgreSQL. If you know what you are doing, you can try the rsync procedure described in https://www.postgresql.org/docs/current/pgupgrade.html instead of wiping data_dir on standby nodes. The safest way is however to let Patroni replicate the data for you.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ In both cases, it is important to be clear about the following concepts:
|
||||
- You should run the odd number of etcd, ZooKeeper or Consul nodes: 3 or 5!
|
||||
|
||||
Synchronous Replication
|
||||
----------------------------
|
||||
-----------------------
|
||||
|
||||
To have a multi DC cluster that can automatically tolerate a zone drop, a minimum of 3 is required.
|
||||
|
||||
@@ -27,7 +27,7 @@ Regarding postgres, we must deploy at least 2 nodes, in different DC. Then you h
|
||||
This enables sync replication and the primary node will choose one of the nodes as synchronous.
|
||||
|
||||
Asynchronous Replication
|
||||
----------------------------------
|
||||
------------------------
|
||||
|
||||
With only two data centers it would be better to have two independent etcd clusters and run Patroni :ref:`standby cluster <standby_cluster>` in the second data center. If the first site is down, you can MANUALLY promote the ``standby_cluster``.
|
||||
|
||||
|
||||
+11
-4
@@ -10,7 +10,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
|
||||
|
||||
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 15.
|
||||
Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the :ref:`Citus support page <citus>` in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
|
||||
|
||||
@@ -40,6 +40,13 @@ Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
.. ifconfig:: builder == 'html'
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
.. ifconfig:: builder != 'html'
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`search`
|
||||
|
||||
@@ -32,6 +32,58 @@ Configuration
|
||||
|
||||
Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation.
|
||||
|
||||
.. _kubernetes_role_values:
|
||||
|
||||
Customize role label
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
By default, Patroni will set corresponding labels on the pod it runs in based on node's role, such as ``role=master``.
|
||||
The key and value of label can be customized by `kubernetes.role_label`, `kubernetes.leader_label_value`, `kubernetes.follower_label_value` and `kubernetes.standby_leader_label_value`.
|
||||
|
||||
Note that if you migrate from default role labels to custom ones, you can reduce downtime by following migration steps:
|
||||
|
||||
1. Add a temporary label using original role value for the pod with `kubernetes.tmp_role_label` (like ``tmp_role``). Once pods are restarted they will get following labels set by Patroni:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: master
|
||||
tmp_role: master
|
||||
|
||||
2. After all pods have been updated, modify the service selector to select the temporary label.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
selector:
|
||||
cluster-name: foo
|
||||
tmp_role: master
|
||||
|
||||
3. Add your custom role label (e.g., set `kubernetes.leader_label_value=primary`). Once pods are restarted they will get following new labels set by Patroni:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: primary
|
||||
tmp_role: master
|
||||
|
||||
4. After all pods have been updated again, modify the service selector to use new role value.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
selector:
|
||||
cluster-name: foo
|
||||
role: primary
|
||||
|
||||
5. Finally, remove the temporary label from your configuration and update all pods.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: primary
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
|
||||
@@ -3,6 +3,164 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 3.1.2
|
||||
-------------
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed bug with ``wal_keep_size`` checks (Alexander Kukushkin)
|
||||
|
||||
The ``wal_keep_size`` is a GUC that normally has a unit and Patroni was failing to cast its value to ``int``. As a result the value of ``bootstrap.dcs`` was not written to the ``/config`` key afterwards.
|
||||
|
||||
- Detect and resolve inconsistencies between ``/sync`` key and ``synchronous_standby_names`` (Alexander Kukushkin)
|
||||
|
||||
Normally, Patroni updates ``/sync`` and ``synchronous_standby_names`` in a very specific order, but in case of a bug or when someone manually reset ``synchronous_standby_names``, Patroni was getting into an inconsistent state. As a result it was possible that the failover happens to an asynchronous node.
|
||||
|
||||
- Read GUC's values when joining running Postgres (Alexander Kukushkin)
|
||||
|
||||
When restarted in ``pause``, Patroni was discarding the ``synchronous_standby_names`` GUC from the ``postgresql.conf``. To solve it and avoid similar issues, Patroni will read GUC's value if it is joining an already running Postgres.
|
||||
|
||||
- Silenced annoying warnings when checking for node uniqueness (Alexander Kukushkin)
|
||||
|
||||
``WARNING`` messages are produced by ``urllib3`` if Patroni is quickly restarted.
|
||||
|
||||
|
||||
Version 3.1.1
|
||||
-------------
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Reset failsafe state on promote (ChenChangAo)
|
||||
|
||||
If switchover/failover happened shortly after failsafe mode had been activated, the newly promoted primary was demoting itself after failsafe becomes inactive.
|
||||
|
||||
- Silence useless warnings in ``patronictl`` (Alexander Kukushkin)
|
||||
|
||||
If ``patronictl`` uses the same patroni.yaml file as Patroni and can access ``PGDATA`` directory it might have been showing annoying warnings about incorrect values in the global configuration.
|
||||
|
||||
- Explicitly enable synchronous mode for a corner case (Alexander Kukushkin)
|
||||
|
||||
Synchronous mode effectively was never activated if there are no replicas streaming from the primary.
|
||||
|
||||
- Fixed bug with ``0`` integer values validation (Israel Barth Rubio)
|
||||
|
||||
In most cases, it didn't cause any issues, just warnings.
|
||||
|
||||
- Don't return logical slots for standby cluster (Alexander Kukushkin)
|
||||
|
||||
Patroni can't create logical replication slots in the standby cluster, thus they should be ignored if they are defined in the global configuration.
|
||||
|
||||
- Avoid showing docstring in ``patronictl --help`` output (Israel Barth Rubio)
|
||||
|
||||
The ``click`` module needs to get a special hint for that.
|
||||
|
||||
- Fixed bug with ``kubernetes.standby_leader_label_value`` (Alexander Kukushkin)
|
||||
|
||||
This feature effectively never worked.
|
||||
|
||||
- Returned cluster system identifier to the ``patronictl list`` output (Polina Bungina)
|
||||
|
||||
The problem was introduced while implementing the support for Citus, where we need to hide the identifier because it is different for coordinator and all workers.
|
||||
|
||||
- Override ``write_leader_optime`` method in Kubernetes implementation (Alexander Kukushkin)
|
||||
|
||||
The method is supposed to write shutdown LSN to the leader Endpoint/ConfigMap when there are no healthy replicas available to become the new primary.
|
||||
|
||||
- Don't start stopped postgres in pause (Alexander Kukushkin)
|
||||
|
||||
Due to a race condition, Patroni was falsely assuming that the standby should be restarted because some recovery parameters (``primary_conninfo`` or similar) were changed.
|
||||
|
||||
- Fixed bug in ``patronictl query`` command (Israel Barth Rubio)
|
||||
|
||||
It didn't work when only ``-m`` argument was provided or when none of ``-r`` or ``-m`` were provided.
|
||||
|
||||
- Properly treat integer parameters that are used in the command line to start postgres (Polina Bungina)
|
||||
|
||||
If values are supplied as strings and not casted to integer it was resulting in an incorrect calculation of ``max_prepared_transactions`` based on ``max_connections`` for Citus clusters.
|
||||
|
||||
- Don't rely on ``pg_stat_wal_receiver`` when deciding on ``pg_rewind`` (Alexander Kukushkin)
|
||||
|
||||
It could happen that ``received_tli`` reported by ``pg_stat_wal_recevier`` is ahead of the actual replayed timeline, while the timeline reported by ``DENTIFY_SYSTEM`` via replication connection is always correct.
|
||||
|
||||
|
||||
Version 3.1.0
|
||||
-------------
|
||||
|
||||
**Breaking changes**
|
||||
|
||||
- Changed semantic of ``restapi.keyfile`` and ``restapi.certfile`` (Alexander Kukushkin)
|
||||
|
||||
Previously Patroni was using ``restapi.keyfile`` and ``restapi.certfile`` as client certificates as a fallback if there were no respective configuration parameters in the ``ctl`` section.
|
||||
|
||||
.. warning::
|
||||
If you enabled client certificates validation (``restapi.verify_client`` is set to ``required``), you also **must** provide **valid client certificates** in the ``ctl.certfile``, ``ctl.keyfile``, ``ctl.keyfile_password``. If not provided, Patroni will not work correctly.
|
||||
|
||||
|
||||
**New features**
|
||||
|
||||
- Make Pod role label configurable (Waynerv)
|
||||
|
||||
Values could be customized using ``kubernetes.leader_label_value``, ``kubernetes.follower_label_value`` and ``kubernetes.standby_leader_label_value`` parameters. This feature will be very useful when we change the ``master`` role to the ``primary``. You can read more about the feature and migration steps :ref:`here <kubernetes_role_values>`.
|
||||
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Various improvements of ``patroni --validate-config`` (Alexander Kukushkin)
|
||||
|
||||
Improved parameter validation for different DCS, ``bootstrap.dcs`` , ``ctl``, ``restapi``, and ``watchdog`` sections.
|
||||
|
||||
- Start Postgres not in recovery if it crashed during recovery while Patroni is running (Alexander Kukushkin)
|
||||
|
||||
It may reduce recovery time and will help to prevent unnecessary timeline increments.
|
||||
|
||||
- Avoid unnecessary updates of ``/status`` key (Alexander Kukushkin)
|
||||
|
||||
When there are no permanent logical slots Patroni was updating the ``/status`` on every heartbeat loop even when LSN on the primary didn't move forward.
|
||||
|
||||
- Don't allow stale primary to win the leader race (Alexander Kukushkin)
|
||||
|
||||
If Patroni was hanging during a significant time due to lack of resources it will additionally check that no other nodes promoted Postgres before acquiring the leader lock.
|
||||
|
||||
- Implemented visibility of certain PostgreSQL parameters validation (Alexander Kukushkin, Feike Steenbergen)
|
||||
|
||||
If validation of ``max_connections``, ``max_wal_senders``, ``max_prepared_transactions``, ``max_locks_per_transaction``, ``max_replication_slots``, or ``max_worker_processes`` failed Patroni was using some sane default value. Now in addition to that it will also show a warning.
|
||||
|
||||
- Set permissions for files and directories created in ``PGDATA`` (Alexander Kukushkin)
|
||||
|
||||
All files created by Patroni had only owner read/write permissions. This behaviour was breaking backup tools that run under a different user and relying on group read permissions. Now Patroni honors permissions on ``PGDATA`` and correctly sets permissions on all directories and files it creates inside ``PGDATA``.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Run ``archive_command`` through shell (Waynerv)
|
||||
|
||||
Patroni might archive some WAL segments before doing crash recovery in a single-user mode or before ``pg_rewind``. If the archive_command contains some shell operators, like ``&&`` it didn't work with Patroni.
|
||||
|
||||
- Fixed "on switchover" shutdown checks (Polina Bungina)
|
||||
|
||||
It was possible that specified candidate is still streaming and didn't received shut down checking but the leader key was removed because some other nodes were healthy.
|
||||
|
||||
- Fixed "is primary" check (Alexander Kukushkin)
|
||||
|
||||
During the leader race replicas were not able to recognize that Postgres on the old leader is still running as a primary.
|
||||
|
||||
- Fixed ``patronictl list`` (Alexander Kukushkin)
|
||||
|
||||
The Cluster name field was missing in ``tsv``, ``json``, and ``yaml`` output formats.
|
||||
|
||||
- Fixed ``pg_rewind`` behaviour after pause (Alexander Kukushkin)
|
||||
|
||||
Under certain conditions, Patroni wasn't able to join the false primary back to the cluster with ``pg_rewind`` after coming out of maintenance mode.
|
||||
|
||||
- Fixed bug in Etcd v3 implementation (Alexander Kukushkin)
|
||||
|
||||
Invalidate internal KV cache if key update performed using ``create_revision``/``mod_revision`` field due to revision mismatch.
|
||||
|
||||
- Fixed behaviour of replicas in standby cluster in pause (Alexander Kukushkin)
|
||||
|
||||
When the leader key expires replicas in standby cluster will not follow the remote node but keep ``primary_conninfo`` as it is.
|
||||
|
||||
|
||||
Version 3.0.4
|
||||
-------------
|
||||
|
||||
|
||||
+34
-13
@@ -43,17 +43,24 @@ Bootstrap configuration
|
||||
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
|
||||
- **- encoding: UTF8**: default encoding for new databases.
|
||||
- **- locale: UTF8**: default locale for new databases.
|
||||
- **users**: Some additional users which need to be created after initializing new cluster
|
||||
|
||||
- **admin**: the name of user
|
||||
|
||||
- **password**: (optional) password for the user
|
||||
- **options**: list of options for CREATE USER statement
|
||||
|
||||
- **- createrole**
|
||||
- **- createdb**
|
||||
- **users**: Some additional users which need to be created after initializing new cluster, see :ref:`Bootstrap users configuration <bootstrap_users_configuration>` below.
|
||||
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
|
||||
|
||||
.. _bootstrap_users_configuration:
|
||||
|
||||
Bootstrap users configuration
|
||||
=============================
|
||||
|
||||
Users which need to be created after initializing the cluster:
|
||||
|
||||
- **admin**: the name of user
|
||||
|
||||
- **password**: (optional) password for the user
|
||||
- **options**: list of options for CREATE USER statement
|
||||
|
||||
- **- createrole**
|
||||
- **- createdb**
|
||||
|
||||
.. _citus_settings:
|
||||
|
||||
Citus
|
||||
@@ -155,7 +162,11 @@ Kubernetes
|
||||
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
|
||||
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
|
||||
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
||||
- **role\_label**: (optional) name of the label containing role (master or replica). Patroni will set this label on the pod it runs in. Default value is ``role``.
|
||||
- **role\_label**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
|
||||
- **leader\_label\_value**: (optional) value of the pod label when Postgres role is ``master``. Default value is ``master``.
|
||||
- **follower\_label\_value**: (optional) value of the pod label when Postgres role is ``replica``. Default value is ``replica``.
|
||||
- **standby\_leader\_label\_value**: (optional) value of the pod label when Postgres role is ``standby_leader``. Default value is ``master``.
|
||||
- **tmp_\role\_label**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
|
||||
- **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.
|
||||
@@ -335,17 +346,27 @@ Here is an example of both **http_extra_headers** and **https_extra_headers**:
|
||||
https_extra_headers:
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
|
||||
|
||||
.. warning::
|
||||
|
||||
- The ``restapi.connect_address`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
|
||||
- If you enabled client certificates validation (``restapi.verify_client`` is set to ``required``), you also **must** provide **valid client certificates** in the ``ctl.certfile``, ``ctl.keyfile``, ``ctl.keyfile_password``. If not provided, Patroni will not work correctly.
|
||||
|
||||
|
||||
.. _patronictl_settings:
|
||||
|
||||
CTL
|
||||
---
|
||||
- **ctl**: (optional)
|
||||
|
||||
- **authentication**:
|
||||
|
||||
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
|
||||
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
|
||||
- **insecure**: Allow connections to REST API without verifying SSL certs.
|
||||
- **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.
|
||||
- **certfile**: Specifies the file with the client certificate in the PEM format.
|
||||
- **keyfile**: Specifies the file with the client secret key in the PEM format.
|
||||
- **keyfile\_password**: Specifies a password for decrypting the client keyfile.
|
||||
|
||||
Watchdog
|
||||
--------
|
||||
|
||||
@@ -79,6 +79,7 @@ Feature: basic replication
|
||||
When I add the table buz to postgres2
|
||||
Then table buz is present on postgres0 after 20 seconds
|
||||
|
||||
@reject-duplicate-name
|
||||
Scenario: check graceful rejection when two nodes have the same name
|
||||
Given I start duplicate postgres0 on port 8011
|
||||
Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log
|
||||
|
||||
@@ -59,7 +59,7 @@ class AbstractController(abc.ABC):
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
assert False, \
|
||||
"{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit)
|
||||
|
||||
def stop(self, kill=False, timeout=15, _=False):
|
||||
@@ -1082,7 +1082,9 @@ def before_all(context):
|
||||
'PATRONI_RESTAPI_CERTFILE': context.certfile,
|
||||
'PATRONI_RESTAPI_KEYFILE': context.keyfile,
|
||||
'PATRONI_RESTAPI_VERIFY_CLIENT': 'required',
|
||||
'PATRONI_CTL_INSECURE': 'on'})
|
||||
'PATRONI_CTL_INSECURE': 'on',
|
||||
'PATRONI_CTL_CERTFILE': context.certfile,
|
||||
'PATRONI_CTL_KEYFILE': context.keyfile})
|
||||
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)
|
||||
@@ -1144,3 +1146,5 @@ def before_scenario(context, scenario):
|
||||
break
|
||||
if 'dcs-failsafe' in scenario.effective_tags and not context.dcs_ctl._handle:
|
||||
scenario.skip('it is not possible to control state of {0} from tests'.format(context.dcs_ctl.name()))
|
||||
if 'reject-duplicate-name' in scenario.effective_tags and context.dcs_ctl.name() == 'raft':
|
||||
scenario.skip('Flaky test with Raft')
|
||||
|
||||
@@ -68,6 +68,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
When I kill postmaster on postgres1
|
||||
And I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 503
|
||||
And "members/postgres1" key in DCS has state=stopped after 10 seconds
|
||||
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
|
||||
@@ -76,7 +77,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
Then I receive a response code 200
|
||||
And I receive a response state running
|
||||
And I receive a response role replica
|
||||
When I run patronictl.py reinit batman postgres1 --force
|
||||
When I run patronictl.py reinit batman postgres1 --force --wait
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Success: reinitialize for member postgres1"
|
||||
And postgres1 role is the secondary after 30 seconds
|
||||
|
||||
@@ -21,7 +21,7 @@ def start_duplicate_patroni(context, name, port):
|
||||
context.pctl.start('dup-' + name, custom_config=config)
|
||||
assert False, "Process was expected to fail"
|
||||
except AssertionError as e:
|
||||
assert 'is not running after being started' in str(e),\
|
||||
assert 'is not running after being started' in str(e), \
|
||||
"No error was raised by duplicate start of {0} ".format(name)
|
||||
|
||||
|
||||
@@ -88,14 +88,14 @@ def table_is_present_on(context, table_name, pg_name, max_replication_delay):
|
||||
break
|
||||
sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
assert False, \
|
||||
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
|
||||
|
||||
|
||||
@then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds')
|
||||
def check_role(context, pg_name, pg_role, max_promotion_timeout):
|
||||
max_promotion_timeout *= context.timeout_multiplier
|
||||
assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)),\
|
||||
assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)), \
|
||||
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
|
||||
|
||||
|
||||
@@ -111,5 +111,5 @@ def replication_works(context, primary, replica, time_limit):
|
||||
@then('there is a "{message}" {level:w} in the {node} patroni log')
|
||||
def check_patroni_log(context, message, level, node):
|
||||
messsages_of_level = context.pctl.read_patroni_log(node, level)
|
||||
assert any(message in line for line in messsages_of_level),\
|
||||
assert any(message in line for line in messsages_of_level), \
|
||||
"There was no {0} {1} in the {2} patroni log".format(message, level, node)
|
||||
|
||||
@@ -125,5 +125,5 @@ def check_transaction(context, name):
|
||||
|
||||
@step("a transaction finishes in {timeout:d} seconds")
|
||||
def check_transaction_timeout(context, timeout):
|
||||
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout,\
|
||||
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout, \
|
||||
"a transaction finished earlier than in {0} seconds".format(timeout)
|
||||
|
||||
@@ -98,7 +98,7 @@ def do_run(context, cmd):
|
||||
@then('I receive a response {component:w} {data}')
|
||||
def check_response(context, component, data):
|
||||
if component == 'code':
|
||||
assert context.status_code == int(data),\
|
||||
assert context.status_code == int(data), \
|
||||
"status code {0} != {1}, response: {2}".format(context.status_code, data, context.response)
|
||||
elif component == 'returncode':
|
||||
assert context.status_code == int(data), "return code {0} != {1}, {2}".format(context.status_code,
|
||||
@@ -158,7 +158,7 @@ def check_http_response(context, url, value, timeout, negate=False):
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
assert False, \
|
||||
"Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout)
|
||||
|
||||
|
||||
|
||||
+8
-3
@@ -65,6 +65,8 @@ class Patroni(AbstractPatroniDaemon):
|
||||
|
||||
def ensure_unique_name(self) -> None:
|
||||
"""A helper method to prevent splitbrain from operator naming error."""
|
||||
from urllib.parse import urlparse
|
||||
from urllib3.connection import HTTPConnection
|
||||
from patroni.dcs import Member
|
||||
|
||||
cluster = self.dcs.get_cluster()
|
||||
@@ -74,9 +76,12 @@ class Patroni(AbstractPatroniDaemon):
|
||||
if not isinstance(member, Member):
|
||||
return
|
||||
try:
|
||||
_ = self.request(member, endpoint="/liveness")
|
||||
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
|
||||
sys.exit(1)
|
||||
parts = urlparse(member.api_url)
|
||||
if isinstance(parts.hostname, str):
|
||||
connection = HTTPConnection(parts.hostname, port=parts.port or 80, timeout=3)
|
||||
connection.connect()
|
||||
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
+162
-63
@@ -49,9 +49,23 @@ def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None
|
||||
|
||||
:Example:
|
||||
|
||||
@check_access
|
||||
def do_PUT_foo():
|
||||
pass
|
||||
>>> class FooServer:
|
||||
... def check_access(self, *args, **kwargs):
|
||||
... print(f'In FooServer: {args[0].__class__.__name__}')
|
||||
... return True
|
||||
...
|
||||
|
||||
>>> class Foo:
|
||||
... server = FooServer()
|
||||
... @check_access
|
||||
... def do_PUT_foo(self):
|
||||
... print('In do_PUT_foo')
|
||||
|
||||
>>> f = Foo()
|
||||
>>> f.do_PUT_foo()
|
||||
In FooServer: Foo
|
||||
In do_PUT_foo
|
||||
|
||||
"""
|
||||
|
||||
def wrapper(self: 'RestApiHandler', *args: Any, **kwargs: Any) -> None:
|
||||
@@ -97,6 +111,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""Write a response that is composed only of the HTTP status.
|
||||
|
||||
The response is written with these values separated by space:
|
||||
|
||||
* HTTP protocol version;
|
||||
* *status_code*;
|
||||
* description of *status_code*.
|
||||
@@ -157,19 +172,19 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Modifies *response* before sending it to the client. Defines the ``patroni`` key, which is a
|
||||
dictionary that contains the mandatory keys:
|
||||
|
||||
* ``version``: Patroni version, e.g. ``3.0.2``;
|
||||
* ``scope``: value of ``scope`` setting from Patroni configuration.
|
||||
* ``version``: Patroni version, e.g. ``3.0.2``;
|
||||
* ``scope``: value of ``scope`` setting from Patroni configuration.
|
||||
|
||||
May also add the following optional keys, depending on the status of this Patroni/PostgreSQL node:
|
||||
|
||||
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
|
||||
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
|
||||
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
|
||||
* ``scheduled_restart``: a dictionary with a single key ``schedule``, which is the timestamp for the scheduled
|
||||
restart;
|
||||
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
|
||||
* ``logger_queue_size``: log queue length if it is longer than expected;
|
||||
* ``logger_records_lost``: number of log records that have been lost while the log queue was full.
|
||||
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
|
||||
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
|
||||
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
|
||||
* ``scheduled_restart``: a dictionary with a single key ``schedule``, which is the timestamp for the
|
||||
scheduled restart;
|
||||
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
|
||||
* ``logger_queue_size``: log queue length if it is longer than expected;
|
||||
* ``logger_records_lost``: number of log records that have been lost while the log queue was full.
|
||||
|
||||
:param status_code: response HTTP status code.
|
||||
:param response: represents the status of the PostgreSQL node, and is used as a basis for the HTTP response.
|
||||
@@ -204,32 +219,54 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Is used for handling all health-checks requests. E.g. "GET /(primary|replica|sync|async|etc...)".
|
||||
|
||||
The (optional) query parameters and the HTTP response status depend on the requested path:
|
||||
|
||||
* ``/``, ``primary``, or ``read-write``:
|
||||
|
||||
* HTTP status ``200``: if a primary with the leader lock.
|
||||
|
||||
* ``/standby-leader``:
|
||||
|
||||
* HTTP status ``200``: if holds the leader lock in a standby cluster.
|
||||
|
||||
* ``/leader``:
|
||||
|
||||
* HTTP status ``200``: if holds the leader lock.
|
||||
|
||||
* ``/replica``:
|
||||
|
||||
* Query parameters:
|
||||
|
||||
* ``lag``: only accept replication lag up to ``lag``. Accepts either an :class:`int`, which
|
||||
represents lag in bytes, or a :class:`str` representing lag in human-readable format (e.g.
|
||||
``10MB``).
|
||||
* Any custom parameter: will attempt to match them against node tags.
|
||||
|
||||
* HTTP status ``200``: if up and running as a standby and without ``noloadbalance`` tag.
|
||||
|
||||
* ``/read-only``:
|
||||
|
||||
* HTTP status ``200``: if up and running and without ``noloadbalance`` tag.
|
||||
|
||||
* ``/synchronous`` or ``/sync``:
|
||||
|
||||
* HTTP status ``200``: if up and running as a synchronous standby.
|
||||
|
||||
* ``/read-only-sync``:
|
||||
|
||||
* HTTP status ``200``: if up and running as a synchronous standby or primary.
|
||||
|
||||
* ``/asynchronous``:
|
||||
|
||||
* Query parameters:
|
||||
|
||||
* ``lag``: only accept replication lag up to ``lag``. Accepts either an :class:`int`, which
|
||||
represents lag in bytes, or a :class:`str` representing lag in human-readable format (e.g.
|
||||
``10MB``).
|
||||
|
||||
* HTTP status ``200``: if up and running as an asynchronous standby.
|
||||
|
||||
* ``/health``:
|
||||
|
||||
* HTTP status ``200``: if up and running.
|
||||
|
||||
.. note::
|
||||
@@ -333,16 +370,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_OPTIONS(self) -> None:
|
||||
"""Handle an ``OPTIONS`` request.
|
||||
|
||||
Write a simple HTTP response that represents the current PostgreSQL status. Send only `200 OK` or
|
||||
`503 Service Unavailable` as a response and nothing more, particularly no headers.
|
||||
Write a simple HTTP response that represents the current PostgreSQL status. Send only ``200 OK`` or
|
||||
``503 Service Unavailable`` as a response and nothing more, particularly no headers.
|
||||
"""
|
||||
self.do_GET(write_status_code_only=True)
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
"""Handle a ``HEAD`` request.
|
||||
|
||||
Write a simple HTTP response that represents the current PostgreSQL status. Send only `200 OK` or
|
||||
`503 Service Unavailable` as a response and nothing more, particularly no headers.
|
||||
Write a simple HTTP response that represents the current PostgreSQL status. Send only ``200 OK`` or
|
||||
``503 Service Unavailable`` as a response and nothing more, particularly no headers.
|
||||
"""
|
||||
self.do_GET(write_status_code_only=True)
|
||||
|
||||
@@ -350,11 +387,17 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""Handle a ``GET`` request to ``/liveness`` path.
|
||||
|
||||
Write a simple HTTP response with HTTP status:
|
||||
|
||||
* ``200``:
|
||||
|
||||
* If the cluster is in maintenance mode; or
|
||||
* If Patroni heartbeat loop is properly running;
|
||||
* ``503`` if Patroni heartbeat loop last run was more than ``ttl`` setting ago on the primary (or twice the
|
||||
value of ``ttl`` on a replica).
|
||||
|
||||
* ``503``:
|
||||
|
||||
* if Patroni heartbeat loop last run was more than ``ttl`` setting ago on the primary (or twice the
|
||||
value of ``ttl`` on a replica).
|
||||
|
||||
"""
|
||||
patroni: Patroni = self.server.patroni
|
||||
is_primary = patroni.postgresql.role in ('master', 'primary') and patroni.postgresql.is_running()
|
||||
@@ -371,10 +414,14 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""Handle a ``GET`` request to ``/readiness`` path.
|
||||
|
||||
Write a simple HTTP response which HTTP status can be:
|
||||
|
||||
* ``200``:
|
||||
|
||||
* If this Patroni node holds the DCS leader lock; or
|
||||
* If this PostgreSQL instance is up and running;
|
||||
|
||||
* ``503``: if none of the previous conditions apply.
|
||||
|
||||
"""
|
||||
patroni = self.server.patroni
|
||||
if patroni.ha.is_leader():
|
||||
@@ -397,8 +444,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_GET_cluster(self) -> None:
|
||||
"""Handle a ``GET`` request to ``/cluster`` path.
|
||||
|
||||
Write an HTTP response with JSON content based on the output of :func:`cluster_as_json`, with HTTP status
|
||||
``200`` and the JSON representation of the cluster topology.
|
||||
Write an HTTP response with JSON content based on the output of :func:`~patroni.utils.cluster_as_json`, with
|
||||
HTTP status ``200`` and the JSON representation of the cluster topology.
|
||||
"""
|
||||
cluster = self.server.patroni.dcs.get_cluster(True)
|
||||
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||
@@ -412,11 +459,13 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
The response contains a :class:`list` of failover/switchover events. Each item is a :class:`list` with the
|
||||
following items:
|
||||
|
||||
* Timeline when the event occurred (class:`int`);
|
||||
* LSN at which the event occurred (class:`int`);
|
||||
* The reason for the event (class:`str`);
|
||||
* Timestamp when the new timeline was created (class:`str`);
|
||||
* Name of the involved Patroni node (class:`str`).
|
||||
|
||||
"""
|
||||
cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
|
||||
self._write_json_response(200, cluster.history and cluster.history.lines or [])
|
||||
@@ -443,26 +492,33 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
The response contains the following items:
|
||||
|
||||
* ``patroni_version``: Patroni version without periods, e.g. ``030002`` for Patroni ``3.0.2``;
|
||||
* ``patroni_postgres_running``: ``1`` if PostgreSQL is running, else ``0``;
|
||||
* ``patroni_postmaster_start_time``: epoch timestamp since Postmaster was started;
|
||||
* ``patroni_master``: ``1`` if this node holds the leader lock, else ``0``;
|
||||
* ``patroni_primary``: same as ``patroni_master``;
|
||||
* ``patroni_xlog_location``: ``pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')`` if leader, else ``0``;
|
||||
* ``patroni_standby_leader``: ``1`` if standby leader node, else ``0``;
|
||||
* ``patroni_replica``: ``1`` if a replica, else ``0``;
|
||||
* ``patroni_sync_standby``: ``1`` if a sync replica, else ``0``;
|
||||
* ``patroni_xlog_received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
|
||||
* ``patroni_xlog_replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
|
||||
* ``patroni_xlog_replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
|
||||
* ``patroni_xlog_paused``: ``pg_is_wal_replay_paused()``;
|
||||
* ``patroni_postgres_server_version``: Postgres version without periods, e.g. ``150002`` for Postgres ``15.2``;
|
||||
* ``patroni_cluster_unlocked``: ``1`` if no one holds the leader lock, else ``0``;
|
||||
* ``patroni_failsafe_mode_is_active``: ``1`` if ``failsafe_mode`` is currently active, else ``0``;
|
||||
* ``patroni_postgres_timeline``: PostgreSQL timeline based on current WAL file name;
|
||||
* ``patroni_dcs_last_seen``: epoch timestamp when DCS was last contacted successfully;
|
||||
* ``patroni_pending_restart``: ``1`` if this PostgreSQL node is pending a restart, else ``0``;
|
||||
* ``patroni_is_paused``: ``1`` if Patroni is in maintenance node, else ``0``.
|
||||
* ``patroni_version``: Patroni version without periods, e.g. ``030002`` for Patroni ``3.0.2``;
|
||||
* ``patroni_postgres_running``: ``1`` if PostgreSQL is running, else ``0``;
|
||||
* ``patroni_postmaster_start_time``: epoch timestamp since Postmaster was started;
|
||||
* ``patroni_master``: ``1`` if this node holds the leader lock, else ``0``;
|
||||
* ``patroni_primary``: same as ``patroni_master``;
|
||||
* ``patroni_xlog_location``: ``pg_wal_lsn_diff(pg_current_wal_flush_lsn(), '0/0')`` if leader, else ``0``;
|
||||
* ``patroni_standby_leader``: ``1`` if standby leader node, else ``0``;
|
||||
* ``patroni_replica``: ``1`` if a replica, else ``0``;
|
||||
* ``patroni_sync_standby``: ``1`` if a sync replica, else ``0``;
|
||||
* ``patroni_xlog_received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
|
||||
* ``patroni_xlog_replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
|
||||
* ``patroni_xlog_replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
|
||||
* ``patroni_xlog_paused``: ``pg_is_wal_replay_paused()``;
|
||||
* ``patroni_postgres_server_version``: Postgres version without periods, e.g. ``150002`` for Postgres
|
||||
``15.2``;
|
||||
* ``patroni_cluster_unlocked``: ``1`` if no one holds the leader lock, else ``0``;
|
||||
* ``patroni_failsafe_mode_is_active``: ``1`` if ``failsafe_mode`` is currently active, else ``0``;
|
||||
* ``patroni_postgres_timeline``: PostgreSQL timeline based on current WAL file name;
|
||||
* ``patroni_dcs_last_seen``: epoch timestamp when DCS was last contacted successfully;
|
||||
* ``patroni_pending_restart``: ``1`` if this PostgreSQL node is pending a restart, else ``0``;
|
||||
* ``patroni_is_paused``: ``1`` if Patroni is in maintenance node, else ``0``.
|
||||
|
||||
For PostgreSQL v9.6+ the response will also have the following:
|
||||
|
||||
* ``patroni_postgres_streaming``: 1 if Postgres is streaming from another node, else ``0``;
|
||||
* ``patroni_postgres_in_archive_recovery``: ``1`` if Postgres isn't streaming and
|
||||
there is ``restore_command`` available, else ``0``.
|
||||
"""
|
||||
postgres = self.get_postgresql_status(True)
|
||||
patroni = self.server.patroni
|
||||
@@ -661,7 +717,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_POST_reload(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/reload`` path.
|
||||
|
||||
Schedules a reload to Patroni and writes a response with HTTP status `202`.
|
||||
Schedules a reload to Patroni and writes a response with HTTP status ``202``.
|
||||
"""
|
||||
self.server.patroni.sighup_handler()
|
||||
self.write_response(202, 'reload scheduled')
|
||||
@@ -722,13 +778,17 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
:param schedule: a string representing a timestamp, e.g. ``2023-04-14T20:27:00+00:00``.
|
||||
:param action: the action to be scheduled (``restart``, ``switchover``, or ``failover``).
|
||||
|
||||
:returns: a tuple composed of 3 items
|
||||
:returns: a tuple composed of 3 items:
|
||||
|
||||
* Suggested HTTP status code for a response:
|
||||
|
||||
* ``None``: if no issue was faced while parsing, leaving it up to the caller to decide the status; or
|
||||
* ``400``: if no timezone information could be found in *schedule*; or
|
||||
* ``422``: if *schedule* is invalid -- in the past or not parsable.
|
||||
|
||||
* An error message, if any error is faced, otherwise ``None``;
|
||||
* Parsed *schedule*, if able to parse, otherwise ``None``.
|
||||
|
||||
"""
|
||||
error = None
|
||||
scheduled_at = None
|
||||
@@ -755,25 +815,31 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Used to restart postgres (or schedule a restart), mainly by ``patronictl restart``.
|
||||
|
||||
The request body should be a JSON dictionary, and it can contain the following keys:
|
||||
|
||||
* ``schedule``: timestamp at which the restart should occur;
|
||||
* ``role``: restart only nodes which role is ``role``. Can be either:
|
||||
|
||||
* ``primary`` (or ``master``); or
|
||||
* ``replica``.
|
||||
|
||||
* ``postgres_version``: restart only nodes which PostgreSQL version is less than ``postgres_version``, e.g.
|
||||
``15.2``;
|
||||
* ``timeout``: if restart takes longer than ``timeout`` return an error and fail over to a replica;
|
||||
* ``restart_pending``: if we should restart only when have ``pending restart`` flag;
|
||||
|
||||
Response HTTP status codes:
|
||||
|
||||
* ``200``: if successfully performed an immediate restart; or
|
||||
* ``202``: if successfully scheduled a restart for later; or
|
||||
* ``500``: if the cluster is in maintenance mode; or
|
||||
* ``400``: if
|
||||
|
||||
* ``role`` value is invalid; or
|
||||
* ``postgres_version`` value is invalid; or
|
||||
* ``timeout`` is not a number, or lesser than ``0``; or
|
||||
* request contains an unknown key; or
|
||||
* exception is faced while performing an immediate restart.
|
||||
|
||||
* ``409``: if another restart was already previously scheduled; or
|
||||
* ``503``: if any issue was found while performing an immediate restart; or
|
||||
* HTTP status returned by :func:`parse_schedule`, if any error was observed while parsing the schedule.
|
||||
@@ -851,6 +917,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Used to remove a scheduled restart of PostgreSQL.
|
||||
|
||||
Response HTTP status codes:
|
||||
|
||||
* ``200``: if a scheduled restart was removed; or
|
||||
* ``404``: if no scheduled restart could be found.
|
||||
"""
|
||||
@@ -869,6 +936,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Used to remove a scheduled switchover in the cluster.
|
||||
|
||||
It writes a response, and the HTTP status code can be:
|
||||
|
||||
* ``200``: if a scheduled switchover was removed; or
|
||||
* ``404``: if no scheduled switchover could be found; or
|
||||
* ``409``: if not able to update the switchover info in the DCS.
|
||||
@@ -890,11 +958,13 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""Handle a ``POST`` request to ``/reinitialize`` path.
|
||||
|
||||
The request body may contain a JSON dictionary with the following key:
|
||||
|
||||
* ``force``: ``True`` if we want to cancel an already running task in order to reinit a replica.
|
||||
|
||||
Response HTTP status codes:
|
||||
|
||||
* ``200``: if the reinit operation has started; or
|
||||
* ``503``: if any error is returned by :func:`Ha.reinitialize`.
|
||||
* ``503``: if any error is returned by :func:`~patroni.ha.Ha.reinitialize`.
|
||||
"""
|
||||
request = self._read_json_content(body_is_optional=True)
|
||||
|
||||
@@ -918,11 +988,15 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
:param candidate: name of the Patroni node to be promoted.
|
||||
:param action: the action that is ongoing (``switchover`` or ``failover``).
|
||||
|
||||
:returns: a tuple composed of 2 items
|
||||
:returns: a tuple composed of 2 items:
|
||||
|
||||
* Response HTTP status codes:
|
||||
|
||||
* ``200``: if the operation succeeded; or
|
||||
* ``503``: if the operation failed or timed out.
|
||||
|
||||
* A status message about the operation.
|
||||
|
||||
"""
|
||||
timeout = max(10, self.server.patroni.dcs.loop_wait)
|
||||
for _ in range(0, timeout * 2):
|
||||
@@ -981,12 +1055,14 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Handles manual failovers/switchovers, mainly from ``patronictl``.
|
||||
|
||||
The request body should be a JSON dictionary, and it can contain the following keys:
|
||||
|
||||
* ``leader``: name of the current leader in the cluster;
|
||||
* ``candidate``: name of the Patroni node to be promoted;
|
||||
* ``scheduled_at``: a string representing the timestamp when to execute the switchover/failover, e.g.
|
||||
``2023-04-14T20:27:00+00:00``.
|
||||
|
||||
Response HTTP status codes:
|
||||
|
||||
* ``202``: if operation has been scheduled;
|
||||
* ``412``: if operation is not possible;
|
||||
* ``503``: if unable to register the operation to the DCS;
|
||||
@@ -1063,8 +1139,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_POST_citus(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/citus`` path.
|
||||
|
||||
Call :func:`CitusHandler.handle_event` to handle the request, then write a response with HTTP status code
|
||||
``200``.
|
||||
Call :func:`~patroni.postgresql.CitusHandler.handle_event` to handle the request, then write a response with
|
||||
HTTP status code ``200``.
|
||||
|
||||
.. note::
|
||||
If unable to parse the request body, then the request is silently discarded.
|
||||
@@ -1080,18 +1156,21 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.write_response(200, 'OK')
|
||||
|
||||
def parse_request(self) -> bool:
|
||||
"""Override :func:`parse_request` method to enrich basic functionality of :class:`BaseHTTPRequestHandler`.
|
||||
"""Override :func:`parse_request` to enrich basic functionality of :class:`~http.server.BaseHTTPRequestHandler`.
|
||||
|
||||
Original class can only invoke :func:`do_GET`, :func:`do_POST`, :func:`do_PUT`, etc method implementations if
|
||||
they are defined.
|
||||
|
||||
But we would like to have at least some simple routing mechanism, i.e.:
|
||||
|
||||
* ``GET /uri1/part2`` request should invoke :func:`do_GET_uri1()`
|
||||
* ``POST /other`` should invoke :func:`do_POST_other()`
|
||||
|
||||
If the :func:`do_<REQUEST_METHOD>_<first_part_url>` method does not exist we'll fall back to original behavior.
|
||||
|
||||
:returns: ``True`` for success, ``False`` for failure; on failure, any relevant error response has already been
|
||||
sent back.
|
||||
sent back.
|
||||
|
||||
"""
|
||||
ret = BaseHTTPRequestHandler.parse_request(self)
|
||||
if ret:
|
||||
@@ -1125,36 +1204,46 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Some of the values are collected by executing a query and other are taken from the state stored in memory.
|
||||
|
||||
:param retry: whether the query should be retried if failed or give up immediately
|
||||
|
||||
:returns: a dict with the status of Postgres/Patroni. The keys are:
|
||||
|
||||
* ``state``: Postgres state among ``stopping``, ``stopped``, ``stop failed``, ``crashed``, ``running``,
|
||||
``starting``, ``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``,
|
||||
``initdb failed``, ``running custom bootstrap script``, ``custom bootstrap failed``,
|
||||
``creating replica``, or ``unknown``;
|
||||
``starting``, ``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``,
|
||||
``initdb failed``, ``running custom bootstrap script``, ``custom bootstrap failed``,
|
||||
``creating replica``, or ``unknown``;
|
||||
* ``postmaster_start_time``: ``pg_postmaster_start_time()``;
|
||||
* ``role``: ``replica`` or ``master`` based on ``pg_is_in_recovery()`` output;
|
||||
* ``server_version``: Postgres version without periods, e.g. ``150002`` for Postgres ``15.2``;
|
||||
* ``xlog``: dictionary. Its structure depends on ``role``:
|
||||
|
||||
* If ``master``:
|
||||
* ``location``: ``pg_current_wal_lsn()``
|
||||
|
||||
* ``location``: ``pg_current_wal_flush_lsn()``
|
||||
|
||||
* If ``replica``:
|
||||
|
||||
* ``received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
|
||||
* ``replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
|
||||
* ``replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
|
||||
* ``paused``: ``pg_is_wal_replay_paused()``;
|
||||
|
||||
* ``sync_standby``: ``True`` if replication mode is synchronous and this is a sync standby;
|
||||
* ``timeline``: PostgreSQL primary node timeline;
|
||||
* ``replication``: :class:`list` of :class:`dict` entries, one for each replication connection. Each entry
|
||||
contains the following keys:
|
||||
|
||||
* ``application_name``: ``pg_stat_activity.application_name``;
|
||||
* ``client_addr``: ``pg_stat_activity.client_addr``;
|
||||
* ``state``: ``pg_stat_replication.state``;
|
||||
* ``sync_priority``: ``pg_stat_replication.sync_priority``;
|
||||
* ``sync_state``: ``pg_stat_replication.sync_state``;
|
||||
* ``usename``: ``pg_stat_activity.usename``.
|
||||
|
||||
* ``pause``: ``True`` if cluster is in maintenance mode;
|
||||
* ``cluster_unlocked``: ``True`` if cluster has no node holding the leader lock;
|
||||
* ``failsafe_mode_is_active``: ``True`` if DCS failsafe mode is currently active;
|
||||
* ``dcs_last_seen``: epoch timestamp DCS was last reached by Patroni.
|
||||
|
||||
"""
|
||||
postgresql = self.server.patroni.postgresql
|
||||
cluster = self.server.patroni.dcs.cluster
|
||||
@@ -1285,8 +1374,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
:param params: positional arguments to be used as parameters for *sql*.
|
||||
|
||||
:returns: a list of rows that were fetched from the database.
|
||||
:raises psycopg.Error: if had issues while executing *sql*.
|
||||
:raises PostgresConnectionException: if had issues while connecting to the database.
|
||||
|
||||
:raises:
|
||||
:class:`psycopg.Error`: if had issues while executing *sql*.
|
||||
:class:`~patroni.exceptions.PostgresConnectionException`: if had issues while connecting to the database.
|
||||
"""
|
||||
cursor = None
|
||||
try:
|
||||
@@ -1346,7 +1437,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
:param host: hostname to be checked.
|
||||
:param port: port to be checked.
|
||||
|
||||
:rtype: Iterator[Union[IPv4Network, IPv6Network]] of *host* + *port* resolved to IP networks.
|
||||
:yields: *host* + *port* resolved to IP networks.
|
||||
"""
|
||||
try:
|
||||
for _, _, _, _, sa in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP):
|
||||
@@ -1360,8 +1451,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
.. note::
|
||||
Only yields object if ``restapi.allowlist_include_members`` setting is enabled.
|
||||
|
||||
:rtype: Iterator[Union[IPv4Network, IPv6Network]] of each node ``restapi.connect_address`` resolved to an IP
|
||||
network.
|
||||
:yields: each node ``restapi.connect_address`` resolved to an IP network.
|
||||
"""
|
||||
cluster = self.patroni.dcs.cluster
|
||||
if self.__allowlist_include_members and cluster:
|
||||
@@ -1381,8 +1471,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
"""Ensure client has enough privileges to perform a given request.
|
||||
|
||||
Write a response back to the client if any issue is observed, and the HTTP status may be:
|
||||
|
||||
* ``401``: if ``Authorization`` header is missing or contain an invalid password;
|
||||
* ``403``: if:
|
||||
|
||||
* ``restapi.allowlist`` was configured, but client IP is not in the allowed list; or
|
||||
* ``restapi.allowlist_include_members`` is enabled, but client IP is not in the members list; or
|
||||
* a client certificate is expected by the server, but is missing in the request.
|
||||
@@ -1462,17 +1554,20 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
``host`` can be a hostname or IP address. It is the value of ``restapi.listen`` setting.
|
||||
:param ssl_options: dictionary that may contain the following keys, depending on what has been configured in
|
||||
``restapi` section:
|
||||
|
||||
* ``certfile``: path to PEM certificate. If given, will start in HTTPS mode;
|
||||
* ``keyfile``: path to key of ``certfile``;
|
||||
* ``keyfile_password``: password for decrypting ``keyfile``;
|
||||
* ``cafile``: path to CA file to validate client certificates;
|
||||
* ``ciphers``: permitted cipher suites;
|
||||
* ``verify_client``: value can be one among:
|
||||
|
||||
* ``none``: do not check client certificates;
|
||||
* ``optional``: check client certificate only for unsafe REST API endpoints;
|
||||
* ``required``: check client certificate for all REST API endpoints.
|
||||
|
||||
:raises ValueError: if any issue is faced while parsing *listen*.
|
||||
:raises:
|
||||
:class:`ValueError`: if any issue is faced while parsing *listen*.
|
||||
"""
|
||||
try:
|
||||
host, port = split_host_port(listen, None)
|
||||
@@ -1520,7 +1615,8 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
client_address: Tuple[str, int]) -> None:
|
||||
"""Process a request to the REST API.
|
||||
|
||||
Wrapper for :func:`ThreadingMixIn.process_request_thread` that additionally:
|
||||
Wrapper for :func:`~socketserver.ThreadingMixIn.process_request_thread` that additionally:
|
||||
|
||||
* Enable TCP keepalive
|
||||
* Perform SSL handshake (if an SSL socket).
|
||||
|
||||
@@ -1538,7 +1634,8 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
def shutdown_request(self, request: Union[socket.socket, Tuple[bytes, socket.socket]]) -> None:
|
||||
"""Shut down a request to the REST API.
|
||||
|
||||
Wrapper for :func:`HTTPServer.shutdown_request` that additionally:
|
||||
Wrapper for :func:`http.server.HTTPServer.shutdown_request` that additionally:
|
||||
|
||||
* Perform SSL shutdown handshake (if a SSL socket).
|
||||
|
||||
:param request: socket to handle the client request.
|
||||
@@ -1586,7 +1683,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
:param value: list of IPs and/or networks contained in ``restapi.allowlist`` setting. Each item can be a host,
|
||||
an IP, or a network in CIDR format.
|
||||
|
||||
:rtype: Iterator[Union[IPv4Network, IPv6Network]] of *host* + *port* resolved to IP networks.
|
||||
:yields: *host* + *port* resolved to IP networks.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
for v in value:
|
||||
@@ -1603,7 +1700,9 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
"""Reload REST API configuration.
|
||||
|
||||
:param config: dictionary representing values under the ``restapi`` configuration section.
|
||||
:raises ValueError: if ``listen`` key is not present in *config*.
|
||||
|
||||
:raises:
|
||||
:class:`ValueError`: if ``listen`` key is not present in *config*.
|
||||
"""
|
||||
if 'listen' not in config: # changing config in runtime
|
||||
raise ValueError('Can not find "restapi.listen" config')
|
||||
|
||||
+31
-11
@@ -13,7 +13,9 @@ from . import PATRONI_ENV_PREFIX
|
||||
from .collections import CaseInsensitiveDict
|
||||
from .dcs import ClusterConfig, Cluster
|
||||
from .exceptions import ConfigParseError
|
||||
from .file_perm import pg_perm
|
||||
from .postgresql.config import ConfigHandler
|
||||
from .validator import IntValidator
|
||||
from .utils import deep_compare, parse_bool, parse_int, patch_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -223,7 +225,8 @@ class Config(object):
|
||||
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
|
||||
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
|
||||
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
|
||||
self._load_cache()
|
||||
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
|
||||
self._load_cache()
|
||||
self._cache_needs_saving = False
|
||||
|
||||
@property
|
||||
@@ -275,11 +278,13 @@ class Config(object):
|
||||
if self._cache_needs_saving:
|
||||
tmpfile = fd = None
|
||||
try:
|
||||
pg_perm.set_permissions_from_data_directory(self._data_dir)
|
||||
(fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir)
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
fd = None
|
||||
json.dump(self.dynamic_configuration, f)
|
||||
tmpfile = shutil.move(tmpfile, self._cache_file)
|
||||
os.chmod(self._cache_file, pg_perm.file_create_mode)
|
||||
self._cache_needs_saving = False
|
||||
except Exception:
|
||||
logger.exception('Exception when saving file: %s', self._cache_file)
|
||||
@@ -329,9 +334,21 @@ class Config(object):
|
||||
|
||||
@staticmethod
|
||||
def _process_postgresql_parameters(parameters: Dict[str, Any], is_local: bool = False) -> Dict[str, Any]:
|
||||
return {name: value for name, value in (parameters or {}).items()
|
||||
if name not in ConfigHandler.CMDLINE_OPTIONS
|
||||
or not is_local and ConfigHandler.CMDLINE_OPTIONS[name][1](value)}
|
||||
pg_params: Dict[str, Any] = {}
|
||||
|
||||
for name, value in (parameters or {}).items():
|
||||
if name not in ConfigHandler.CMDLINE_OPTIONS:
|
||||
pg_params[name] = value
|
||||
elif not is_local:
|
||||
validator = ConfigHandler.CMDLINE_OPTIONS[name][1]
|
||||
if validator(value):
|
||||
int_val = parse_int(value) if isinstance(validator, IntValidator) else None
|
||||
pg_params[name] = int_val if isinstance(int_val, int) else value
|
||||
else:
|
||||
logger.warning("postgresql parameter %s=%s failed validation, defaulting to %s",
|
||||
name, value, ConfigHandler.CMDLINE_OPTIONS[name][0])
|
||||
|
||||
return pg_params
|
||||
|
||||
def _safe_copy_dynamic_configuration(self, dynamic_configuration: Dict[str, Any]) -> Dict[str, Any]:
|
||||
config = deepcopy(self.__DEFAULT_CONFIG)
|
||||
@@ -452,9 +469,10 @@ class Config(object):
|
||||
ret[param] = value
|
||||
return ret
|
||||
|
||||
restapi_auth = _get_auth('restapi')
|
||||
if restapi_auth:
|
||||
ret['restapi']['authentication'] = restapi_auth
|
||||
for section in ('ctl', 'restapi'):
|
||||
auth = _get_auth(section)
|
||||
if auth:
|
||||
ret[section]['authentication'] = auth
|
||||
|
||||
authentication = {}
|
||||
for user_type in ('replication', 'superuser', 'rewind'):
|
||||
@@ -474,7 +492,8 @@ class Config(object):
|
||||
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME',
|
||||
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
|
||||
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
|
||||
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE') and name:
|
||||
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
|
||||
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name:
|
||||
value = os.environ.pop(param)
|
||||
if name == 'CITUS':
|
||||
if suffix == 'GROUP':
|
||||
@@ -531,9 +550,10 @@ class Config(object):
|
||||
elif name not in config or name in ['watchdog']:
|
||||
config[name] = deepcopy(value) if value else {}
|
||||
|
||||
# restapi server expects to get restapi.auth = 'username:password'
|
||||
if 'restapi' in config and 'authentication' in config['restapi']:
|
||||
config['restapi']['auth'] = '{username}:{password}'.format(**config['restapi']['authentication'])
|
||||
# restapi server expects to get restapi.auth = 'username:password' and similarly for `ctl`
|
||||
for section in ('ctl', 'restapi'):
|
||||
if section in config and 'authentication' in config[section]:
|
||||
config[section]['auth'] = '{username}:{password}'.format(**config[section]['authentication'])
|
||||
|
||||
# special treatment for old config
|
||||
|
||||
|
||||
+19
-13
@@ -36,7 +36,7 @@ from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from prettytable import ALL, FRAME, PrettyTable
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, Generator, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Cursor
|
||||
from psycopg2 import cursor
|
||||
@@ -254,7 +254,6 @@ arg_cluster_name = click.argument('cluster_name', required=False,
|
||||
option_default_citus_group = click.option('--group', required=False, type=int, help='Citus group',
|
||||
default=lambda: click.get_current_context().obj.get('citus', {}).get('group'))
|
||||
option_citus_group = click.option('--group', required=False, type=int, help='Citus group')
|
||||
option_insecure = click.option('-k', '--insecure', is_flag=True, help='Allow connections to SSL sites without certs')
|
||||
role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 'standby', 'any', 'master'])
|
||||
|
||||
|
||||
@@ -262,10 +261,12 @@ role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 's
|
||||
@click.option('--config-file', '-c', help='Configuration file',
|
||||
envvar='PATRONICTL_CONFIG_FILE', default=CONFIG_FILE_PATH)
|
||||
@click.option('--dcs-url', '--dcs', '-d', 'dcs_url', help='The DCS connect url', envvar='DCS_URL')
|
||||
@option_insecure
|
||||
@click.option('-k', '--insecure', is_flag=True, help='Allow connections to SSL sites without certs')
|
||||
@click.pass_context
|
||||
def ctl(ctx: click.Context, config_file: str, dcs_url: Optional[str], insecure: bool) -> None:
|
||||
"""Entry point of ``patronictl`` utility.
|
||||
"""Command-line interface for interacting with Patroni.
|
||||
\f
|
||||
Entry point of ``patronictl`` utility.
|
||||
|
||||
Load the configuration file.
|
||||
|
||||
@@ -561,9 +562,10 @@ def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], conn
|
||||
from . import psycopg
|
||||
conn = psycopg.connect(**params)
|
||||
cursor = conn.cursor()
|
||||
# If we want ``any`` node we are fine to return the cursor
|
||||
# If we want ``any`` node we are fine to return the cursor. ``None`` is similar to ``any`` at this point, as it's
|
||||
# been dealt with through :func:`get_any_member`.
|
||||
# If we want the Patroni leader node, :func:`get_any_member` already checks that for us
|
||||
if role in ('any', 'leader'):
|
||||
if role in (None, 'any', 'leader'):
|
||||
return cursor
|
||||
|
||||
# If we want something other than ``any`` or ``leader``, then we do not rely only on the DCS information about
|
||||
@@ -857,9 +859,11 @@ def query_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int],
|
||||
|
||||
if cursor is None:
|
||||
if member is not None:
|
||||
message = 'No connection to member {0} is available'.format(member)
|
||||
message = f'No connection to member {member} is available'
|
||||
elif role is not None:
|
||||
message = f'No connection to role {role} is available'
|
||||
else:
|
||||
message = 'No connection to role={0} is available'.format(role)
|
||||
message = 'No connection is available'
|
||||
logging.debug(message)
|
||||
return [[timestamp(0), message]], None
|
||||
|
||||
@@ -1544,7 +1548,7 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str,
|
||||
logging.debug(member)
|
||||
|
||||
lag = member.get('lag', '')
|
||||
member.update(c=name, member=member['name'], group=g,
|
||||
member.update(cluster=name, member=member['name'], group=g,
|
||||
host=member.get('host', ''), tl=member.get('timeline', ''),
|
||||
role=member['role'].replace('_', ' ').title(),
|
||||
lag_in_mb=round(lag / 1024 / 1024) if isinstance(lag, int) else lag,
|
||||
@@ -1562,9 +1566,11 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str,
|
||||
rows.append([member.get(n.lower().replace(' ', '_'), '') for n in columns])
|
||||
|
||||
title = 'Citus cluster' if is_citus_cluster else 'Cluster'
|
||||
group_title = '' if group is None else 'group: {0}, '.format(group)
|
||||
title_details = group_title and ' ({0}{1})'.format(group_title, initialize)
|
||||
title = ' {0}: {1}{2} '.format(title, name, title_details)
|
||||
title_details = f' ({initialize})'
|
||||
if is_citus_cluster:
|
||||
title_details = '' if group is None else f' (group: {group}, {initialize})'
|
||||
|
||||
title = f' {title}: {name}{title_details} '
|
||||
print_output(columns, rows, {'Group': 'r', 'Lag in MB': 'r', 'TL': 'r'}, fmt, title)
|
||||
|
||||
if fmt not in ('pretty', 'topology'): # Omit service info when using machine-readable formats
|
||||
@@ -1817,7 +1823,7 @@ def resume(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait: b
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_file(contents: bytes, suffix: str = '', prefix: str = 'tmp') -> Generator[str, None, None]:
|
||||
def temporary_file(contents: bytes, suffix: str = '', prefix: str = 'tmp') -> Iterator[str]:
|
||||
"""Create a temporary file with specified contents that persists for the context.
|
||||
|
||||
:param contents: binary string that will be written to the file.
|
||||
|
||||
+1044
-315
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ from urllib3.exceptions import HTTPError
|
||||
from urllib.parse import urlencode, urlparse, quote
|
||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ from urllib.parse import urlparse
|
||||
from urllib3 import Timeout
|
||||
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..request import get as requests_get
|
||||
|
||||
+13
-3
@@ -15,7 +15,7 @@ from urllib3.exceptions import ReadTimeoutError, ProtocolError
|
||||
from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState,\
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, \
|
||||
TimelineHistory, catch_return_false_exception, citus_group_re
|
||||
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
|
||||
from ..exceptions import DCSError, PatroniException
|
||||
@@ -205,7 +205,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
|
||||
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
||||
self._token = None
|
||||
self._cluster_version: Tuple[int] = tuple()
|
||||
self._cluster_version: Tuple[int, ...] = tuple()
|
||||
super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
|
||||
|
||||
try:
|
||||
@@ -228,7 +228,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
return self.http.urlopen
|
||||
|
||||
def _handle_server_response(self, response: urllib3.response.HTTPResponse) -> Dict[str, Any]:
|
||||
data: Union[bytes, str] = response.data
|
||||
data = response.data
|
||||
try:
|
||||
data = data.decode('utf-8')
|
||||
ret: Dict[str, Any] = json.loads(data)
|
||||
@@ -630,6 +630,16 @@ class PatroniEtcd3Client(Etcd3Client):
|
||||
|
||||
return ret
|
||||
|
||||
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
|
||||
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry)
|
||||
# Here we abuse the fact that the `failure` is only set in the call from update_leader().
|
||||
# In all other cases the txn() call failure may be an indicator of a stale cache,
|
||||
# and therefore we want to restart watcher.
|
||||
if not failure and not ret:
|
||||
self._restart_watcher()
|
||||
return ret
|
||||
|
||||
|
||||
class Etcd3(AbstractEtcd):
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ from urllib3.exceptions import HTTPError
|
||||
from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
|
||||
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options,\
|
||||
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
|
||||
Retry, RetryFailedError, tzutc, uri, USER_AGENT
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
@@ -134,6 +134,8 @@ class K8sConfig(object):
|
||||
config: Dict[str, Any] = yaml.safe_load(f)
|
||||
|
||||
context = context or config['current-context']
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(context, str)
|
||||
context_value = self._get_by_name(config, 'context', context)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(context_value, dict)
|
||||
@@ -752,6 +754,10 @@ 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._leader_label_value = config.get('leader_label_value', 'master')
|
||||
self._follower_label_value = config.get('follower_label_value', 'replica')
|
||||
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
|
||||
self._tmp_role_label = config.get('tmp_role_label')
|
||||
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
|
||||
super(Kubernetes, self).__init__({**config, 'namespace': ''})
|
||||
if self._citus_group:
|
||||
@@ -830,7 +836,7 @@ class Kubernetes(AbstractDCS):
|
||||
self._api.configure_timeouts(self.loop_wait, self._retry.deadline, self.ttl)
|
||||
|
||||
# retriable_http_codes supposed to be either int, list of integers or comma-separated string with integers.
|
||||
retriable_http_codes = config.get('retriable_http_codes', [])
|
||||
retriable_http_codes: Union[str, List[Union[str, int]]] = config.get('retriable_http_codes', [])
|
||||
if not isinstance(retriable_http_codes, list):
|
||||
retriable_http_codes = [c.strip() for c in str(retriable_http_codes).split(',')]
|
||||
|
||||
@@ -1134,6 +1140,13 @@ class Kubernetes(AbstractDCS):
|
||||
"""Unused"""
|
||||
raise NotImplementedError # pragma: no cover
|
||||
|
||||
def write_leader_optime(self, last_lsn: int) -> None:
|
||||
"""Write value for WAL LSN to ``optime`` annotation of the leader object.
|
||||
|
||||
:param last_lsn: absolute WAL LSN in bytes.
|
||||
"""
|
||||
self.patch_or_create(self.leader_path, {self._OPTIME: str(last_lsn)}, patch=True, retry=False)
|
||||
|
||||
def _update_leader_with_retry(self, annotations: Dict[str, Any],
|
||||
resource_version: Optional[str], ips: List[str]) -> bool:
|
||||
retry = self._retry.copy()
|
||||
@@ -1263,19 +1276,27 @@ class Kubernetes(AbstractDCS):
|
||||
def touch_member(self, data: Dict[str, Any]) -> bool:
|
||||
cluster = self.cluster
|
||||
if cluster and cluster.leader and cluster.leader.name == self._name:
|
||||
role = 'master'
|
||||
role = self._standby_leader_label_value if data['role'] == 'standby_leader' else self._leader_label_value
|
||||
tmp_role = 'master'
|
||||
elif data['state'] == 'running' and data['role'] not in ('master', 'primary'):
|
||||
role = data['role']
|
||||
role = {'replica': self._follower_label_value}.get(data['role'], data['role'])
|
||||
tmp_role = data['role']
|
||||
else:
|
||||
role = None
|
||||
tmp_role = None
|
||||
|
||||
role_labels = {self._role_label: role}
|
||||
if self._tmp_role_label:
|
||||
role_labels[self._tmp_role_label] = tmp_role
|
||||
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
pod_labels = member and member.data.pop('pod_labels', None)
|
||||
ret = member and pod_labels is not None\
|
||||
and pod_labels.get(self._role_label) == role and deep_compare(data, member.data)
|
||||
and all(pod_labels.get(k) == v for k, v in role_labels.items())\
|
||||
and deep_compare(data, member.data)
|
||||
|
||||
if not ret:
|
||||
metadata = {'namespace': self._namespace, 'name': self._name, 'labels': {self._role_label: role},
|
||||
metadata = {'namespace': self._namespace, 'name': self._name, 'labels': role_labels,
|
||||
'annotations': {'status': json.dumps(data, separators=(',', ':'))}}
|
||||
body = k8s_client.V1Pod(metadata=k8s_client.V1ObjectMeta(**metadata))
|
||||
ret = self._api.patch_namespaced_pod(self._name, self._namespace, body)
|
||||
|
||||
+23
-16
@@ -89,7 +89,7 @@ class ZooKeeper(AbstractDCS):
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(ZooKeeper, self).__init__(config)
|
||||
|
||||
hosts = config.get('hosts', [])
|
||||
hosts: Union[str, List[str]] = config.get('hosts', [])
|
||||
if isinstance(hosts, list):
|
||||
hosts = ','.join(hosts)
|
||||
|
||||
@@ -393,21 +393,28 @@ class ZooKeeper(AbstractDCS):
|
||||
cluster = self.cluster
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
member_data = self.__last_member_data or member and member.data
|
||||
# We want to notify leader if some important fields in the member key changed by removing ZNode
|
||||
if member and (self._client.client_id is not None and member.session != self._client.client_id[0]
|
||||
or not (member_data and deep_compare(member_data.get('tags', {}), data.get('tags', {}))
|
||||
and (member_data.get('state') == data.get('state')
|
||||
or 'running' not in (member_data.get('state'), data.get('state')))
|
||||
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:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
member = None
|
||||
if member and member_data:
|
||||
is_leader = data.get('role') in ('master', 'primary', 'standby_leader')
|
||||
checkpoint_after_promote_changed = member_data.get('checkpoint_after_promote') \
|
||||
!= data.get('checkpoint_after_promote')
|
||||
state_running_changed = member_data.get('state') != data.get('state') \
|
||||
and 'running' in (member_data.get('state'), data.get('state'))
|
||||
tags_changed = not deep_compare(member_data.get('tags', {}), data.get('tags', {}))
|
||||
|
||||
# We want delete the member ZNode if:
|
||||
# - our session doesn't match with session id on our member key; or
|
||||
# - we want to notify leader if some important fields in the member key changed; or
|
||||
# - if we are the leader and want to notify replicas about checkpoint_after_promote;
|
||||
if self._client.client_id is not None and member.session != self._client.client_id[0] \
|
||||
or is_leader and checkpoint_after_promote_changed \
|
||||
or not is_leader and (state_running_changed or tags_changed):
|
||||
try:
|
||||
self._client.delete_async(self.member_path).get(timeout=1)
|
||||
except NoNodeError:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
member = None
|
||||
|
||||
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
|
||||
if member and member_data:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Helper object that helps with figuring out file and directory permissions based on permissions of PGDATA.
|
||||
|
||||
:var logger: logger of this module.
|
||||
:var pg_perm: instance of the :class:`__FilePermissions` object.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class __FilePermissions:
|
||||
"""Helper class for managing permissions of directories and files under PGDATA.
|
||||
|
||||
Execute :meth:`set_permissions_from_data_directory` to figure out which permissions should be used for files and
|
||||
directories under PGDATA based on permissions of PGDATA root directory.
|
||||
"""
|
||||
|
||||
# Mode mask for data directory permissions that only allows the owner to
|
||||
# read/write directories and files -- mask 077.
|
||||
__PG_MODE_MASK_OWNER = stat.S_IRWXG | stat.S_IRWXO
|
||||
|
||||
# Mode mask for data directory permissions that also allows group read/execute -- mask 027.
|
||||
__PG_MODE_MASK_GROUP = stat.S_IWGRP | stat.S_IRWXO
|
||||
|
||||
# Default mode for creating directories -- mode 700.
|
||||
__PG_DIR_MODE_OWNER = stat.S_IRWXU
|
||||
|
||||
# Mode for creating directories that allows group read/execute -- mode 750.
|
||||
__PG_DIR_MODE_GROUP = stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP
|
||||
|
||||
# Default mode for creating files -- mode 600.
|
||||
__PG_FILE_MODE_OWNER = stat.S_IRUSR | stat.S_IWUSR
|
||||
|
||||
# Mode for creating files that allows group read -- mode 640.
|
||||
__PG_FILE_MODE_GROUP = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create a :class:`__FilePermissions` object and set default permissions."""
|
||||
self.__set_owner_permissions()
|
||||
self.__set_umask()
|
||||
|
||||
def __set_umask(self) -> None:
|
||||
"""Set umask value based on calculations.
|
||||
|
||||
.. note::
|
||||
Should only be called once either :meth:`__set_owner_permissions`
|
||||
or :meth:`__set_group_permissions` has been executed.
|
||||
"""
|
||||
try:
|
||||
os.umask(self.__pg_mode_mask)
|
||||
except Exception as e:
|
||||
logger.error('Can not set umask to %03o: %r', self.__pg_mode_mask, e)
|
||||
|
||||
def __set_owner_permissions(self) -> None:
|
||||
"""Make directories/files accessible only by the owner."""
|
||||
self.__pg_dir_create_mode = self.__PG_DIR_MODE_OWNER
|
||||
self.__pg_file_create_mode = self.__PG_FILE_MODE_OWNER
|
||||
self.__pg_mode_mask = self.__PG_MODE_MASK_OWNER
|
||||
|
||||
def __set_group_permissions(self) -> None:
|
||||
"""Make directories/files accessible by the owner and readable by group."""
|
||||
self.__pg_dir_create_mode = self.__PG_DIR_MODE_GROUP
|
||||
self.__pg_file_create_mode = self.__PG_FILE_MODE_GROUP
|
||||
self.__pg_mode_mask = self.__PG_MODE_MASK_GROUP
|
||||
|
||||
def set_permissions_from_data_directory(self, data_dir: str) -> None:
|
||||
"""Set new permissions based on provided *data_dir*.
|
||||
|
||||
:param data_dir: reference to PGDATA to calculate permissions from.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(data_dir)
|
||||
if (st.st_mode & self.__PG_DIR_MODE_GROUP) == self.__PG_DIR_MODE_GROUP:
|
||||
self.__set_group_permissions()
|
||||
else:
|
||||
self.__set_owner_permissions()
|
||||
except Exception as e:
|
||||
logger.error('Can not check permissions on %s: %r', data_dir, e)
|
||||
else:
|
||||
self.__set_umask()
|
||||
|
||||
@property
|
||||
def dir_create_mode(self) -> int:
|
||||
"""Directory permissions."""
|
||||
return self.__pg_dir_create_mode
|
||||
|
||||
@property
|
||||
def file_create_mode(self) -> int:
|
||||
"""File permissions."""
|
||||
return self.__pg_file_create_mode
|
||||
|
||||
|
||||
pg_perm = __FilePermissions()
|
||||
+146
-67
@@ -56,7 +56,7 @@ class _MemberStatus(NamedTuple):
|
||||
# If one of those is not in a response we want to count the node as not healthy/reachable
|
||||
wal: Dict[str, Any] = json.get('wal') or json['xlog']
|
||||
# abuse difference in primary/replica response format
|
||||
in_recovery = not bool(wal.get('location')) or json.get('role') in ('master', 'primary')
|
||||
in_recovery = not (bool(wal.get('location')) or json.get('role') in ('master', 'primary'))
|
||||
timeline = json.get('timeline', 0)
|
||||
dcs_last_seen = json.get('dcs_last_seen', 0)
|
||||
lsn = int(in_recovery and max(wal.get('received_location', 0), wal.get('replayed_location', 0)))
|
||||
@@ -83,11 +83,7 @@ class Failsafe(object):
|
||||
def __init__(self, dcs: AbstractDCS) -> None:
|
||||
self._lock = RLock()
|
||||
self._dcs = dcs
|
||||
self._last_update = 0
|
||||
self._name = None
|
||||
self._conn_url = None
|
||||
self._api_url = None
|
||||
self._slots = None
|
||||
self._reset_state()
|
||||
|
||||
def update(self, data: Dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
@@ -97,13 +93,20 @@ class Failsafe(object):
|
||||
self._api_url = data['api_url']
|
||||
self._slots = data.get('slots')
|
||||
|
||||
def _reset_state(self) -> None:
|
||||
self._last_update = 0
|
||||
self._name = None
|
||||
self._conn_url = None
|
||||
self._api_url = None
|
||||
self._slots = None
|
||||
|
||||
@property
|
||||
def leader(self) -> Optional[Leader]:
|
||||
with self._lock:
|
||||
if self._last_update + self._dcs.ttl > time.time() and self._name:
|
||||
return Leader('', '', RemoteMember.from_name_and_data(self._name, {'api_url': self._api_url,
|
||||
'conn_url': self._conn_url,
|
||||
'slots': self._slots}))
|
||||
return Leader('', '', RemoteMember(self._name, {'api_url': self._api_url,
|
||||
'conn_url': self._conn_url,
|
||||
'slots': self._slots}))
|
||||
|
||||
def update_cluster(self, cluster: Cluster) -> Cluster:
|
||||
# Enreach cluster with the real leader if there was a ping from it
|
||||
@@ -130,6 +133,8 @@ class Failsafe(object):
|
||||
def set_is_active(self, value: float) -> None:
|
||||
with self._lock:
|
||||
self._last_update = value
|
||||
if not value:
|
||||
self._reset_state()
|
||||
|
||||
|
||||
class Ha(object):
|
||||
@@ -442,16 +447,23 @@ class Ha(object):
|
||||
"""Handle the case when postgres isn't running.
|
||||
|
||||
Depending on the state of Patroni, DCS cluster view, and pg_controldata the following could happen:
|
||||
- if ``primary_start_timeout`` is 0 and this node owns the leader lock, the lock
|
||||
will be voluntarily released if there are healthy replicas to take it over.
|
||||
- if postgres was running as a ``primary`` and this node owns the leader lock, postgres is started as primary.
|
||||
- crash recover in a single-user mode is executed in the following cases:
|
||||
- postgres was running as ``primary`` wasn't ``shut down`` cleanly and there is no leader in DCS
|
||||
- postgres was running as ``replica`` wasn't ``shut down in recovery`` (cleanly)
|
||||
and we need to run ``pg_rewind`` to join back to the cluster.
|
||||
- ``pg_rewind`` is executed if it is necessary, or optinally, the data directory could
|
||||
be removed if it is allowed by configuration.
|
||||
- after ``crash recovery`` and/or ``pg_rewind`` are executed, postgres is started in recovery.
|
||||
|
||||
- if ``primary_start_timeout`` is 0 and this node owns the leader lock, the lock
|
||||
will be voluntarily released if there are healthy replicas to take it over.
|
||||
|
||||
- if postgres was running as a ``primary`` and this node owns the leader lock, postgres is started as primary.
|
||||
|
||||
- crash recover in a single-user mode is executed in the following cases:
|
||||
|
||||
- postgres was running as ``primary`` wasn't ``shut down`` cleanly and there is no leader in DCS
|
||||
|
||||
- postgres was running as ``replica`` wasn't ``shut down in recovery`` (cleanly)
|
||||
and we need to run ``pg_rewind`` to join back to the cluster.
|
||||
|
||||
- ``pg_rewind`` is executed if it is necessary, or optinally, the data directory could
|
||||
be removed if it is allowed by configuration.
|
||||
|
||||
- after ``crash recovery`` and/or ``pg_rewind`` are executed, postgres is started in recovery.
|
||||
|
||||
:returns: action message, describing what was performed.
|
||||
"""
|
||||
@@ -473,7 +485,8 @@ class Ha(object):
|
||||
|
||||
# timeout > 0 indicates that we still have the leader lock, and it was just updated
|
||||
if timeout\
|
||||
and data.get('Database cluster state') in ('in production', 'shutting down', 'shut down')\
|
||||
and data.get('Database cluster state') in ('in production', 'in crash recovery',
|
||||
'shutting down', 'shut down')\
|
||||
and self.state_handler.state == 'crashed'\
|
||||
and self.state_handler.role in ('primary', 'master')\
|
||||
and not self.state_handler.config.recovery_conf_exists():
|
||||
@@ -496,6 +509,7 @@ class Ha(object):
|
||||
|
||||
role = 'replica'
|
||||
if self.has_lock() and not self.is_standby_cluster():
|
||||
self._rewind.reset_state() # we want to later trigger CHECKPOINT after promote
|
||||
msg = "starting as readonly because i had the session lock"
|
||||
node_to_follow = None
|
||||
else:
|
||||
@@ -525,10 +539,17 @@ class Ha(object):
|
||||
return msg
|
||||
|
||||
def _get_node_to_follow(self, cluster: Cluster) -> Union[Leader, Member, None]:
|
||||
# determine the node to follow. If replicatefrom tag is set,
|
||||
# try to follow the node mentioned there, otherwise, follow the leader.
|
||||
if self.is_standby_cluster() and (self.cluster.is_unlocked() or self.has_lock(False)):
|
||||
"""Determine the node to follow.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
|
||||
:returns: the node which we should be replicating from.
|
||||
"""
|
||||
# The standby leader or when there is no standby leader we want to follow
|
||||
# the remote member, except when there is no standby leader in pause.
|
||||
if self.is_standby_cluster() and (self.has_lock(False) or self.cluster.is_unlocked() and not self.is_paused()):
|
||||
node_to_follow = self.get_remote_member()
|
||||
# If replicatefrom tag is set, try to follow the node mentioned there, otherwise, follow the leader.
|
||||
elif self.patroni.replicatefrom and self.patroni.replicatefrom != self.state_handler.name:
|
||||
node_to_follow = cluster.get_member(self.patroni.replicatefrom)
|
||||
else:
|
||||
@@ -620,11 +641,28 @@ class Ha(object):
|
||||
promoting standbys that were guaranteed to be replicating synchronously.
|
||||
"""
|
||||
if self.is_synchronous_mode():
|
||||
current = CaseInsensitiveSet(self.cluster.sync.members)
|
||||
sync = self.cluster.sync
|
||||
if sync.is_empty:
|
||||
# corner case: we need to explicitly enable synchronous mode by updating the
|
||||
# ``/sync`` key with the current leader name and empty members. In opposite case
|
||||
# it will never be automatically enabled if there are not eligible candidates.
|
||||
sync = self.dcs.write_sync_state(self.state_handler.name, None, version=sync.version)
|
||||
if not sync:
|
||||
return logger.warning("Updating sync state failed")
|
||||
logger.info("Enabled synchronous replication")
|
||||
|
||||
current = CaseInsensitiveSet(sync.members)
|
||||
picked, allow_promote = self.state_handler.sync_handler.current_state(self.cluster)
|
||||
|
||||
if picked == current and current != allow_promote:
|
||||
logger.warning('Inconsistent state between synchronous_standby_names = %s and /sync = %s key '
|
||||
'detected, updating synchronous replication key...', list(allow_promote), list(current))
|
||||
sync = self.dcs.write_sync_state(self.state_handler.name, allow_promote, version=sync.version)
|
||||
if not sync:
|
||||
return logger.warning("Updating sync state failed")
|
||||
current = CaseInsensitiveSet(sync.members)
|
||||
|
||||
if picked != current:
|
||||
sync = self.cluster.sync
|
||||
# update synchronous standby list in dcs temporarily to point to common nodes in current and picked
|
||||
sync_common = current & allow_promote
|
||||
if sync_common != current:
|
||||
@@ -705,13 +743,13 @@ class Ha(object):
|
||||
if cluster_history:
|
||||
self.dcs.set_history_value('[]')
|
||||
elif not cluster_history or cluster_history[-1][0] != primary_timeline - 1 or len(cluster_history[-1]) != 5:
|
||||
cluster_history = {line[0]: line for line in cluster_history}
|
||||
cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history}
|
||||
history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline)))
|
||||
if self.cluster.config:
|
||||
history = history[-self.cluster.config.max_timelines_history:]
|
||||
for line in history:
|
||||
# enrich current history with promotion timestamps stored in DCS
|
||||
cluster_history_line = list(cluster_history.get(line[0], []))
|
||||
cluster_history_line = cluster_history_dict.get(line[0], [])
|
||||
if len(line) == 3 and len(cluster_history_line) >= 4 and cluster_history_line[1] == line[1]:
|
||||
line.append(cluster_history_line[3])
|
||||
if len(cluster_history_line) == 5:
|
||||
@@ -768,18 +806,17 @@ class Ha(object):
|
||||
self.state_handler.sync_handler.set_synchronous_standby_names(
|
||||
CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
|
||||
if self.state_handler.role not in ('master', 'promoted', 'primary'):
|
||||
def on_success():
|
||||
self._rewind.reset_state()
|
||||
logger.info("cleared rewind state after becoming the leader")
|
||||
# reset failsafe state when promote
|
||||
self._failsafe.set_is_active(0)
|
||||
|
||||
def before_promote():
|
||||
self.notify_citus_coordinator('before_promote')
|
||||
|
||||
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,
|
||||
before_promote, on_success))
|
||||
args=(self.dcs.loop_wait, self._async_response, before_promote))
|
||||
return promote_message
|
||||
|
||||
def fetch_node_status(self, member: Member) -> _MemberStatus:
|
||||
@@ -834,7 +871,7 @@ class Ha(object):
|
||||
data['slots'] = self.state_handler.slots()
|
||||
except Exception:
|
||||
logger.exception('Exception when called state_handler.slots()')
|
||||
members = [RemoteMember.from_name_and_data(name, {'api_url': url})
|
||||
members = [RemoteMember(name, {'api_url': url})
|
||||
for name, url in failsafe.items() if name != self.state_handler.name]
|
||||
if not members: # A sinlge node cluster
|
||||
return True
|
||||
@@ -849,6 +886,7 @@ class Ha(object):
|
||||
"""Returns if instance with an wal should consider itself unhealthy to be promoted due to replication lag.
|
||||
|
||||
:param wal_position: Current wal position.
|
||||
|
||||
:returns True when node is lagging
|
||||
"""
|
||||
lag = (self.cluster.last_lsn or 0) - wal_position
|
||||
@@ -927,7 +965,7 @@ class Ha(object):
|
||||
|
||||
:returns: - `True` if the current node is the best candidate to become the new leader
|
||||
- `None` if the current node is running as a primary and requested candidate doesn't exist
|
||||
"""
|
||||
"""
|
||||
failover = self.cluster.failover
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert failover is not None
|
||||
@@ -985,6 +1023,7 @@ class Ha(object):
|
||||
"""Performs a series of checks to determine that the current node is the best candidate.
|
||||
|
||||
In case if manual failover/switchover is requested it calls :func:`manual_failover_process_no_leader` method.
|
||||
|
||||
:returns: `True` if the current node is among the best candidates to become the new leader.
|
||||
"""
|
||||
if time.time() - self._released_leader_key_timestamp < self.dcs.ttl:
|
||||
@@ -998,9 +1037,22 @@ class Ha(object):
|
||||
return ret
|
||||
|
||||
if self.state_handler.is_leader():
|
||||
# in pause leader is the healthiest only when no initialize or sysid matches with initialize!
|
||||
return not self.is_paused() or not self.cluster.initialize\
|
||||
or self.state_handler.sysid == self.cluster.initialize
|
||||
if self.is_paused():
|
||||
# in pause leader is the healthiest only when no initialize or sysid matches with initialize!
|
||||
return not self.cluster.initialize or self.state_handler.sysid == self.cluster.initialize
|
||||
|
||||
# We want to protect from the following scenario:
|
||||
# 1. node1 is stressed so much that heart-beat isn't running regularly and the leader lock expires.
|
||||
# 2. node2 promotes, gets heavy load and the situation described in 1 repeats.
|
||||
# 3. Patroni on node1 comes back, notices that Postgres is running as primary but there is
|
||||
# no leader key and "happily" acquires the leader lock.
|
||||
# That is, node1 discarded promotion of node2. To avoid it we want to detect timeline change.
|
||||
my_timeline = self.state_handler.get_primary_timeline()
|
||||
if my_timeline < self.cluster.timeline:
|
||||
logger.warning('My timeline %s is behind last known cluster timeline %s',
|
||||
my_timeline, self.cluster.timeline)
|
||||
return False
|
||||
return True
|
||||
|
||||
if self.is_paused():
|
||||
return False
|
||||
@@ -1028,8 +1080,7 @@ class Ha(object):
|
||||
if failsafe_members and self.state_handler.name not in failsafe_members:
|
||||
return False
|
||||
# Race among not only existing cluster members, but also all known members from the failsafe config
|
||||
all_known_members += [RemoteMember.from_name_and_data(name, {'api_url': url})
|
||||
for name, url in failsafe_members.items()]
|
||||
all_known_members += [RemoteMember(name, {'api_url': url}) for name, url in failsafe_members.items()]
|
||||
all_known_members += self.cluster.members
|
||||
|
||||
# When in sync mode, only last known primary and sync standby are allowed to promote automatically.
|
||||
@@ -1057,13 +1108,15 @@ class Ha(object):
|
||||
def demote(self, mode: str) -> Optional[bool]:
|
||||
"""Demote PostgreSQL running as primary.
|
||||
|
||||
:param mode: One of offline, graceful or immediate.
|
||||
offline is used when connection to DCS is not available.
|
||||
graceful is used when failing over to another node due to user request. May only be called running async.
|
||||
immediate is used when we determine that we are not suitable for primary and want to failover quickly
|
||||
without regard for data durability. May only be called synchronously.
|
||||
immediate-nolock is used when find out that we have lost the lock to be primary. Need to bring down
|
||||
PostgreSQL as quickly as possible without regard for data durability. May only be called synchronously.
|
||||
:param mode: One of offline, graceful, immediate or immediate-nolock.
|
||||
``offline`` is used when connection to DCS is not available.
|
||||
``graceful`` is used when failing over to another node due to user request. May only be called
|
||||
running async.
|
||||
``immediate`` is used when we determine that we are not suitable for primary and want to failover
|
||||
quickly without regard for data durability. May only be called synchronously.
|
||||
``immediate-nolock`` is used when find out that we have lost the lock to be primary. Need to bring
|
||||
down PostgreSQL as quickly as possible without regard for data durability. May only be called
|
||||
synchronously.
|
||||
"""
|
||||
mode_control = {
|
||||
'offline': dict(stop='fast', checkpoint=False, release=False, offline=True, async_req=False), # noqa: E241,E501
|
||||
@@ -1084,7 +1137,9 @@ class Ha(object):
|
||||
# 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):
|
||||
|
||||
# for a manual failover/switchover with a candidate, we should check the requested candidate only
|
||||
if self.is_failover_possible(self.get_failover_candidates(), cluster_lsn=checkpoint_location):
|
||||
self.state_handler.set_role('demoted')
|
||||
with self._async_executor:
|
||||
self.release_leader_key_voluntarily(checkpoint_location)
|
||||
@@ -1188,15 +1243,12 @@ class Ha(object):
|
||||
logger.warning('Failover is possible only to a specific candidate in a paused state')
|
||||
else:
|
||||
if self.is_synchronous_mode():
|
||||
if failover.candidate and not self.cluster.sync.matches(failover.candidate):
|
||||
members = self.get_failover_candidates(check_sync=True)
|
||||
if failover.candidate and not members:
|
||||
logger.warning('Failover candidate=%s does not match with sync_standbys=%s',
|
||||
failover.candidate, self.cluster.sync.sync_standby)
|
||||
members = []
|
||||
else:
|
||||
members = [m for m in self.cluster.members if self.cluster.sync.matches(m.name)]
|
||||
else:
|
||||
members = [m for m in self.cluster.members
|
||||
if not failover.candidate or m.name == failover.candidate]
|
||||
members = self.get_failover_candidates()
|
||||
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'
|
||||
@@ -1456,9 +1508,7 @@ class Ha(object):
|
||||
self._async_executor.run_async(self._do_reinitialize, args=(cluster, ))
|
||||
|
||||
def handle_long_action_in_progress(self) -> str:
|
||||
"""
|
||||
Figure out what to do with the task AsyncExecutor is performing.
|
||||
"""
|
||||
"""Figure out what to do with the task AsyncExecutor is performing."""
|
||||
if self.has_lock() and self.update_lock():
|
||||
if self._async_executor.scheduled_action == 'doing crash recovery in a single user mode':
|
||||
time_left = self.global_config.primary_start_timeout - (time.time() - self._crash_recovery_started)
|
||||
@@ -1553,8 +1603,7 @@ class Ha(object):
|
||||
return 'initialized a new cluster'
|
||||
|
||||
def handle_starting_instance(self) -> Optional[str]:
|
||||
"""Starting up PostgreSQL may take a long time. In case we are the leader we may want to
|
||||
fail over to."""
|
||||
"""Starting up PostgreSQL may take a long time. In case we are the leader we may want to fail over to."""
|
||||
|
||||
# Check if we are in startup, when paused defer to main loop for manual failovers.
|
||||
if not self.state_handler.check_for_startup() or self.is_paused():
|
||||
@@ -1594,7 +1643,8 @@ class Ha(object):
|
||||
def set_start_timeout(self, value: Optional[int]) -> None:
|
||||
"""Sets timeout for starting as primary before eligible for failover.
|
||||
|
||||
Must be called when async_executor is busy or in the main thread."""
|
||||
Must be called when async_executor is busy or in the main thread.
|
||||
"""
|
||||
self._start_timeout = value
|
||||
|
||||
def _run_cycle(self) -> str:
|
||||
@@ -1614,6 +1664,9 @@ class Ha(object):
|
||||
else:
|
||||
if self._was_paused:
|
||||
self.state_handler.schedule_sanity_checks_after_pause()
|
||||
# during pause people could manually do something with Postgres, therefore we want
|
||||
# to double check rewind conditions on replicas and maybe run CHECKPOINT on the primary
|
||||
self._rewind.reset_state()
|
||||
self._was_paused = False
|
||||
|
||||
if not self.cluster.has_member(self.state_handler.name):
|
||||
@@ -1796,7 +1849,9 @@ class Ha(object):
|
||||
"""Handles replication slots.
|
||||
|
||||
:param dcs_failed: bool, indicates that communication with DCS failed (get_cluster() or update_leader())
|
||||
:returns: list[str], replication slots names that should be copied from the primary"""
|
||||
|
||||
:returns: list[str], replication slots names that should be copied from the primary
|
||||
"""
|
||||
|
||||
slots: List[str] = []
|
||||
|
||||
@@ -1848,7 +1903,9 @@ class Ha(object):
|
||||
# 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):
|
||||
|
||||
# for a manual failover/switchover with a candidate, we should check the requested candidate only
|
||||
if self.is_failover_possible(self.get_failover_candidates(), cluster_lsn=checkpoint_location):
|
||||
self.dcs.delete_leader(checkpoint_location)
|
||||
status['deleted'] = True
|
||||
else:
|
||||
@@ -1885,15 +1942,16 @@ class Ha(object):
|
||||
return self.dcs.watch(leader_version, timeout)
|
||||
|
||||
def wakeup(self) -> None:
|
||||
"""Call of this method will trigger the next run of HA loop if there is
|
||||
no "active" leader watch request in progress.
|
||||
"""Trigger the next run of HA loop if there is no "active" leader watch request in progress.
|
||||
|
||||
This usually happens on the leader or if the node is running async action"""
|
||||
self.dcs.event.set()
|
||||
|
||||
def get_remote_member(self, member: Union[Leader, Member, None] = None) -> RemoteMember:
|
||||
""" In case of standby cluster this will tel us from which remote
|
||||
member to stream. Config can be both patroni config or
|
||||
cluster.config.data
|
||||
"""Get remote member node to stream from.
|
||||
|
||||
In case of standby cluster this will tell us from which remote member to stream. Config can be both patroni
|
||||
config or cluster.config.data.
|
||||
"""
|
||||
data: Dict[str, Any] = {}
|
||||
cluster_params = self.global_config.get_standby_cluster_config()
|
||||
@@ -1907,4 +1965,25 @@ class Ha(object):
|
||||
data['conn_kwargs'] = conn_kwargs
|
||||
|
||||
name = member.name if member else 'remote_member:{}'.format(uuid.uuid1())
|
||||
return RemoteMember.from_name_and_data(name, data)
|
||||
return RemoteMember(name, data)
|
||||
|
||||
def get_failover_candidates(self, check_sync: bool = False) -> List[Member]:
|
||||
"""Return list of candidates for either manual or automatic failover.
|
||||
|
||||
Mainly used to later be passed to ``Ha.is_failover_possible()``.
|
||||
|
||||
:param check_sync: if ``True``, also check against the sync key members
|
||||
|
||||
:returns: a list of ``Member`` ojects or an empty list if there is no candidate available
|
||||
"""
|
||||
failover = self.cluster.failover
|
||||
if check_sync:
|
||||
# TODO: allow manual failover (=no leader specified) to async node
|
||||
# every sync_standby or the candidate specified if is in sync_standbys
|
||||
return [m for m in self.cluster.members
|
||||
if self.cluster.sync.matches(m.name)
|
||||
and (not failover or not failover.candidate or m.name == failover.candidate)]
|
||||
else:
|
||||
# every member or the candidate specified
|
||||
return [m for m in self.cluster.members
|
||||
if not failover or not failover.candidate or m.name == failover.candidate]
|
||||
|
||||
+13
-12
@@ -21,17 +21,17 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def debug_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
|
||||
"""Add full stack trace info to debug log messages and partial to others.
|
||||
|
||||
Handle :func:`exception` calls for *self*.
|
||||
Handle :func:`~self.exception` calls for *self*.
|
||||
|
||||
.. note::
|
||||
* If *self* log level is set to ``DEBUG``, then issue a ``DEBUG`` message with the complete stack trace;
|
||||
* If *self* log level is ``INFO`` or higher, then issue an ``ERROR`` message with only the last line of
|
||||
the stack trace.
|
||||
|
||||
:param self: logger for which :func:`exception` will be processed.
|
||||
:param self: logger for which :func:`~self.exception` will be processed.
|
||||
:param msg: the message related to the exception to be logged.
|
||||
:param args: positional arguments to be passed to :func:`self.debug` or :func:`loger_obj.error`.
|
||||
:param kwargs: keyword arguments to be passed to :func:`self.debug` or :func:`loger_obj.error`.
|
||||
:param args: positional arguments to be passed to :func:`~self.debug` or :func:`~self.error`.
|
||||
:param kwargs: keyword arguments to be passed to :func:`~self.debug` or :func:`~self.error`.
|
||||
"""
|
||||
kwargs.pop("exc_info", False)
|
||||
if self.isEnabledFor(logging.DEBUG):
|
||||
@@ -44,16 +44,16 @@ def debug_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any
|
||||
def error_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
|
||||
"""Add full stack trace info to error messages.
|
||||
|
||||
Handle :func:`exception` calls for *self*.
|
||||
Handle :func:`~self.exception` calls for *self*.
|
||||
|
||||
.. note::
|
||||
* By default issue an ``ERROR`` message with the complete stack trace. If you do not want to show the complete
|
||||
stack trace, call with ``exc_info=False``.
|
||||
stack trace, call with ``exc_info=False``.
|
||||
|
||||
:param self: logger for which :func:`exception` will be processed.
|
||||
:param self: logger for which :func:`~self.exception` will be processed.
|
||||
:param msg: the message related to the exception to be logged.
|
||||
:param args: positional arguments to be passed to :func:`loger_obj.error`.
|
||||
:param kwargs: keyword arguments to be passed to :func:`loger_obj.error`.
|
||||
:param args: positional arguments to be passed to :func:`~self.error`.
|
||||
:param kwargs: keyword arguments to be passed to :func:`~self.error`.
|
||||
"""
|
||||
exc_info = kwargs.pop("exc_info", True)
|
||||
self.error(msg, *args, exc_info=exc_info, **kwargs)
|
||||
@@ -140,7 +140,7 @@ class ProxyHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Emit each log record that is handled.
|
||||
|
||||
Will push the log record down to :func:`handle` method of the currently configured log handler.
|
||||
Will push the log record down to :func:`~logging.Handler.handle` method of the currently configured log handler.
|
||||
|
||||
:param record: the record that was emitted.
|
||||
"""
|
||||
@@ -203,7 +203,7 @@ class PatroniLogger(Thread):
|
||||
self._root_logger.addHandler(self._proxy_handler)
|
||||
|
||||
def update_loggers(self) -> None:
|
||||
"""Configure loggers' log level as defined in ``log.loggers` section of Patroni configuration.
|
||||
"""Configure loggers' log level as defined in ``log.loggers`` section of Patroni configuration.
|
||||
|
||||
.. note::
|
||||
It creates logger objects that are not defined yet in the log manager.
|
||||
@@ -281,7 +281,8 @@ class PatroniLogger(Thread):
|
||||
|
||||
.. note::
|
||||
It is used to remove different handlers that were configured previous to a reload in the configuration,
|
||||
e.g. if we are switching from :class:`RotatingFileHandler` to class:`StreamHandler` and vice-versa.
|
||||
e.g. if we are switching from :class:`~logging.handlers.RotatingFileHandler` to
|
||||
class:`~logging.StreamHandler` and vice-versa.
|
||||
"""
|
||||
while True:
|
||||
with self.log_handler_lock:
|
||||
|
||||
@@ -12,7 +12,7 @@ from datetime import datetime
|
||||
from dateutil import tz
|
||||
from psutil import TimeoutExpired
|
||||
from threading import current_thread, Lock
|
||||
from typing import Any, Callable, Dict, Generator, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from .bootstrap import Bootstrap
|
||||
from .callback_executor import CallbackAction, CallbackExecutor
|
||||
@@ -118,17 +118,28 @@ class Postgresql(object):
|
||||
# Last known running process
|
||||
self._postmaster_proc = None
|
||||
|
||||
if self.is_running(): # we are "joining" already running postgres
|
||||
self.set_state('running')
|
||||
if self.is_running():
|
||||
# If we found postmaster process we need to figure out whether postgres is accepting connections
|
||||
self.set_state('starting')
|
||||
self.check_startup_state_changed()
|
||||
|
||||
if self.state == 'running': # we are "joining" already running postgres
|
||||
# we know that PostgreSQL is accepting connections and can read some GUC's from pg_settings
|
||||
self.config.load_current_server_parameters()
|
||||
|
||||
self.set_role('master' if self.is_leader() else 'replica')
|
||||
# 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:
|
||||
|
||||
if self.major_version < 120000 or self.role in ('master', 'primary'):
|
||||
# If PostgreSQL is running as a primary or we run PostgreSQL that is older than 12 we can
|
||||
# call reload_config() once again (the first call happened in the ConfigHandler constructor),
|
||||
# so that it can figure out if config files should be updated and pg_ctl reload executed.
|
||||
self.config.reload_config(config, sighup=bool(hba_saved or ident_saved))
|
||||
elif hba_saved or ident_saved:
|
||||
self.reload()
|
||||
elif self.role in ('master', 'primary'):
|
||||
elif not self.is_running() and self.role in ('master', 'primary'):
|
||||
self.set_role('demoted')
|
||||
|
||||
@property
|
||||
@@ -173,6 +184,7 @@ class Postgresql(object):
|
||||
"""Returns the monitoring query with a fixed number of fields.
|
||||
|
||||
The query text is constructed based on current state in DCS and PostgreSQL version:
|
||||
|
||||
1. function names depend on version. wal/lsn for v10+ and xlog/location for pre v10.
|
||||
2. for primary we query timeline_id (extracted from pg_walfile_name()) and pg_current_wal_lsn()
|
||||
3. for replicas we query pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), and pg_is_wal_replay_paused()
|
||||
@@ -182,7 +194,8 @@ class Postgresql(object):
|
||||
7. if sync replication is enabled we query pg_stat_replication and aggregate the result.
|
||||
In addition to that we get current values of synchronous_commit and synchronous_standby_names GUCs.
|
||||
|
||||
If some conditions are not satisfied we simply put static values instead. E.g., NULL, 0, '', and so on."""
|
||||
If some conditions are not satisfied we simply put static values instead. E.g., NULL, 0, '', and so on.
|
||||
"""
|
||||
|
||||
extra = ", " + (("pg_catalog.current_setting('synchronous_commit'), "
|
||||
"pg_catalog.current_setting('synchronous_standby_names'), "
|
||||
@@ -408,7 +421,18 @@ class Postgresql(object):
|
||||
:param global_config: last known :class:`GlobalConfig` object
|
||||
"""
|
||||
self._cluster_info_state = {}
|
||||
if cluster and cluster.config and cluster.config.modify_version:
|
||||
|
||||
if global_config:
|
||||
self._global_config = global_config
|
||||
|
||||
if not self._global_config:
|
||||
return
|
||||
|
||||
if self._global_config.is_standby_cluster:
|
||||
# Standby cluster can't have logical replication slots, and we don't need to enforce hot_standby_feedback
|
||||
self._has_permanent_logical_slots = False
|
||||
self.set_enforce_hot_standby_feedback(False)
|
||||
elif cluster and cluster.config and cluster.config.modify_version:
|
||||
self._has_permanent_logical_slots =\
|
||||
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
|
||||
|
||||
@@ -418,9 +442,6 @@ class Postgresql(object):
|
||||
self._has_permanent_logical_slots
|
||||
or cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
|
||||
|
||||
if global_config:
|
||||
self._global_config = global_config
|
||||
|
||||
def _cluster_info_state_get(self, name: str) -> Optional[Any]:
|
||||
if not self._cluster_info_state:
|
||||
try:
|
||||
@@ -542,7 +563,7 @@ class Postgresql(object):
|
||||
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 match.group(1), match.group(2), match.group(3), match.group(4)
|
||||
return None, None, None, None
|
||||
|
||||
def latest_checkpoint_location(self) -> Optional[int]:
|
||||
@@ -998,8 +1019,8 @@ class Postgresql(object):
|
||||
return None, None
|
||||
|
||||
@contextmanager
|
||||
def get_replication_connection_cursor(self, host: Optional[str] = None, port: int = 5432,
|
||||
**kwargs: Any) -> Generator[Union['cursor', 'Cursor[Any]'], None, None]:
|
||||
def get_replication_connection_cursor(self, host: Optional[str] = None, port: Union[int, str] = 5432,
|
||||
**kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
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')
|
||||
@@ -1125,8 +1146,8 @@ class Postgresql(object):
|
||||
except Exception as e:
|
||||
logger.error('Exception when calling `%s`: %r', cmd, e)
|
||||
|
||||
def promote(self, wait_seconds: int, task: CriticalTask, before_promote: Optional[Callable[..., Any]] = None,
|
||||
on_success: Optional[Callable[..., Any]] = None) -> Optional[bool]:
|
||||
def promote(self, wait_seconds: int, task: CriticalTask,
|
||||
before_promote: Optional[Callable[..., Any]] = None) -> Optional[bool]:
|
||||
if self.role in ('promoted', 'master', 'primary'):
|
||||
return True
|
||||
|
||||
@@ -1152,8 +1173,6 @@ class Postgresql(object):
|
||||
ret = self.pg_ctl('promote', '-W')
|
||||
if ret:
|
||||
self.set_role('promoted')
|
||||
if on_success is not None:
|
||||
on_success()
|
||||
self.call_nowait(CallbackAction.ON_ROLE_CHANGE)
|
||||
ret = self._wait_promote(wait_seconds)
|
||||
return ret
|
||||
|
||||
@@ -174,11 +174,13 @@ class CitusHandler(Thread):
|
||||
"""Returns the tuple(i, task), where `i` - is the task index in the self._tasks list
|
||||
|
||||
Tasks are picked by following priorities:
|
||||
|
||||
1. If there is already a transaction in progress, pick a task
|
||||
that that will change already affected worker primary.
|
||||
2. If the coordinator address should be changed - pick a task
|
||||
with group=0 (coordinators are always in group 0).
|
||||
3. Pick a task that is the oldest (first from the self._tasks)"""
|
||||
3. Pick a task that is the oldest (first from the self._tasks)
|
||||
"""
|
||||
|
||||
with self._condition:
|
||||
if self._in_flight:
|
||||
@@ -405,7 +407,7 @@ class CitusHandler(Thread):
|
||||
parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries)
|
||||
|
||||
# if not explicitly set Citus overrides max_prepared_transactions to max_connections*2
|
||||
if parameters.get('max_prepared_transactions') == 0:
|
||||
if parameters['max_prepared_transactions'] == 0:
|
||||
parameters['max_prepared_transactions'] = parameters['max_connections'] * 2
|
||||
|
||||
# Resharding in Citus implemented using logical replication
|
||||
|
||||
@@ -6,16 +6,18 @@ import socket
|
||||
import stat
|
||||
import time
|
||||
|
||||
from contextlib import contextmanager
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
from types import TracebackType
|
||||
from typing import Any, Collection, Dict, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
|
||||
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
|
||||
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
|
||||
from ..exceptions import PatroniFatalException
|
||||
from ..exceptions import PatroniFatalException, PostgresConnectionException
|
||||
from ..file_perm import pg_perm
|
||||
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
|
||||
from ..validator import IntValidator
|
||||
from ..validator import IntValidator, EnumValidator
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from . import Postgresql
|
||||
@@ -258,14 +260,14 @@ def _false_validator(value: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _wal_level_validator(value: Any) -> bool:
|
||||
return str(value).lower() in ('hot_standby', 'replica', 'logical')
|
||||
|
||||
|
||||
def _bool_validator(value: Any) -> bool:
|
||||
return parse_bool(value) is not None
|
||||
|
||||
|
||||
def _bool_is_true_validator(value: Any) -> bool:
|
||||
return parse_bool(value) is True
|
||||
|
||||
|
||||
class ConfigHandler(object):
|
||||
|
||||
# List of parameters which must be always passed to postmaster as command line options
|
||||
@@ -286,8 +288,8 @@ class ConfigHandler(object):
|
||||
'listen_addresses': (None, _false_validator, 90100),
|
||||
'port': (None, _false_validator, 90100),
|
||||
'cluster_name': (None, _false_validator, 90500),
|
||||
'wal_level': ('hot_standby', _wal_level_validator, 90100),
|
||||
'hot_standby': ('on', _false_validator, 90100),
|
||||
'wal_level': ('hot_standby', EnumValidator(('hot_standby', 'replica', 'logical')), 90100),
|
||||
'hot_standby': ('on', _bool_is_true_validator, 90100),
|
||||
'max_connections': (100, IntValidator(min=25), 90100),
|
||||
'max_wal_senders': (10, IntValidator(min=3), 90100),
|
||||
'wal_keep_segments': (8, IntValidator(min=1), 90100),
|
||||
@@ -297,7 +299,7 @@ class ConfigHandler(object):
|
||||
'track_commit_timestamp': ('off', _bool_validator, 90500),
|
||||
'max_replication_slots': (10, IntValidator(min=4), 90400),
|
||||
'max_worker_processes': (8, IntValidator(min=2), 90400),
|
||||
'wal_log_hints': ('on', _false_validator, 90400)
|
||||
'wal_log_hints': ('on', _bool_is_true_validator, 90400)
|
||||
})
|
||||
|
||||
_RECOVERY_PARAMETERS = CaseInsensitiveSet(recovery_parameters.keys())
|
||||
@@ -324,14 +326,22 @@ class ConfigHandler(object):
|
||||
.format(self._pgpass))
|
||||
self._passfile = None
|
||||
self._passfile_mtime = None
|
||||
self._synchronous_standby_names = None
|
||||
self._postmaster_ctime = None
|
||||
self._current_recovery_params: Optional[CaseInsensitiveDict] = None
|
||||
self._config = {}
|
||||
self._recovery_params = CaseInsensitiveDict()
|
||||
self._server_parameters: CaseInsensitiveDict
|
||||
self._server_parameters: CaseInsensitiveDict = CaseInsensitiveDict()
|
||||
self.reload_config(config)
|
||||
|
||||
def load_current_server_parameters(self) -> None:
|
||||
"""Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running."""
|
||||
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator] \
|
||||
+ [name.lower() for name in self._RECOVERY_PARAMETERS]
|
||||
self._server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
|
||||
"SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings"
|
||||
" WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)"
|
||||
" AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)})
|
||||
|
||||
def setup_server_parameters(self) -> None:
|
||||
self._server_parameters = self.get_server_parameters(self._config)
|
||||
self._adjust_recovery_parameters()
|
||||
@@ -367,6 +377,30 @@ class ConfigHandler(object):
|
||||
configuration.append('pg_ident.conf')
|
||||
return configuration
|
||||
|
||||
def set_file_permissions(self, filename: str) -> None:
|
||||
"""Set permissions of file *filename* according to the expected permissions if it resides under PGDATA.
|
||||
|
||||
.. note::
|
||||
Do nothing if the file is not under PGDATA.
|
||||
|
||||
:param filename: path to a file which permissions might need to be adjusted.
|
||||
"""
|
||||
if is_subpath(self._postgresql.data_dir, filename):
|
||||
pg_perm.set_permissions_from_data_directory(self._postgresql.data_dir)
|
||||
os.chmod(filename, pg_perm.file_create_mode)
|
||||
|
||||
@contextmanager
|
||||
def config_writer(self, filename: str) -> Iterator[ConfigWriter]:
|
||||
"""Create :class:`ConfigWriter` object and set permissions on a *filename*.
|
||||
|
||||
:param filename: path to a config file.
|
||||
|
||||
:yields: :class:`ConfigWriter` object.
|
||||
"""
|
||||
with ConfigWriter(filename) as writer:
|
||||
yield writer
|
||||
self.set_file_permissions(filename)
|
||||
|
||||
def save_configuration_files(self, check_custom_bootstrap: bool = False) -> bool:
|
||||
"""
|
||||
copy postgresql.conf to postgresql.conf.backup to be able to retrieve configuration files
|
||||
@@ -380,6 +414,7 @@ class ConfigHandler(object):
|
||||
backup_file = os.path.join(self._postgresql.data_dir, f + '.backup')
|
||||
if os.path.isfile(config_file):
|
||||
shutil.copy(config_file, backup_file)
|
||||
self.set_file_permissions(backup_file)
|
||||
except IOError:
|
||||
logger.exception('unable to create backup copies of configuration files')
|
||||
return True
|
||||
@@ -393,9 +428,11 @@ class ConfigHandler(object):
|
||||
if not os.path.isfile(config_file):
|
||||
if os.path.isfile(backup_file):
|
||||
shutil.copy(backup_file, config_file)
|
||||
self.set_file_permissions(config_file)
|
||||
# Previously we didn't backup pg_ident.conf, if file is missing just create empty
|
||||
elif f == 'pg_ident.conf':
|
||||
open(config_file, 'w').close()
|
||||
self.set_file_permissions(config_file)
|
||||
except IOError:
|
||||
logger.exception('unable to restore configuration files from backup')
|
||||
|
||||
@@ -409,7 +446,7 @@ class ConfigHandler(object):
|
||||
if self._postgresql.enforce_hot_standby_feedback:
|
||||
configuration['hot_standby_feedback'] = 'on'
|
||||
|
||||
with ConfigWriter(self._postgresql_conf) as f:
|
||||
with self.config_writer(self._postgresql_conf) as f:
|
||||
include = self._config.get('custom_conf') or self._postgresql_base_conf_name
|
||||
f.writeline("include '{0}'\n".format(ConfigWriter.escape(include)))
|
||||
for name, value in sorted((configuration).items()):
|
||||
@@ -439,6 +476,7 @@ class ConfigHandler(object):
|
||||
if not self.hba_file and not self._config.get('pg_hba'):
|
||||
with open(self._pg_hba_conf, 'a') as f:
|
||||
f.write('\n{}\n'.format('\n'.join(config)))
|
||||
self.set_file_permissions(self._pg_hba_conf)
|
||||
return True
|
||||
|
||||
def replace_pg_hba(self) -> Optional[bool]:
|
||||
@@ -458,14 +496,14 @@ class ConfigHandler(object):
|
||||
self.local_replication_address['host'], self.local_replication_address['port'],
|
||||
0, socket.SOCK_STREAM, socket.IPPROTO_TCP)})
|
||||
|
||||
with ConfigWriter(self._pg_hba_conf) as f:
|
||||
with self.config_writer(self._pg_hba_conf) as f:
|
||||
for address, t in addresses.items():
|
||||
f.writeline((
|
||||
'{0}\treplication\t{1}\t{3}\ttrust\n'
|
||||
'{0}\tall\t{2}\t{3}\ttrust'
|
||||
).format(t, self.replication['username'], self._superuser.get('username') or 'all', address))
|
||||
elif not self.hba_file and self._config.get('pg_hba'):
|
||||
with ConfigWriter(self._pg_hba_conf) as f:
|
||||
with self.config_writer(self._pg_hba_conf) as f:
|
||||
f.writelines(self._config['pg_hba'])
|
||||
return True
|
||||
|
||||
@@ -478,7 +516,7 @@ class ConfigHandler(object):
|
||||
"""
|
||||
|
||||
if not self.ident_file and self._config.get('pg_ident'):
|
||||
with ConfigWriter(self._pg_ident_conf) as f:
|
||||
with self.config_writer(self._pg_ident_conf) as f:
|
||||
f.writelines(self._config['pg_ident'])
|
||||
return True
|
||||
|
||||
@@ -593,7 +631,24 @@ class ConfigHandler(object):
|
||||
'recovery_target_action', 'standby_mode', self._triggerfile_wrong_name})
|
||||
return CaseInsensitiveSet(self._RECOVERY_PARAMETERS - skip_params)
|
||||
|
||||
def _read_recovery_params(self) -> Tuple[Optional[CaseInsensitiveDict], Optional[bool]]:
|
||||
def _read_recovery_params(self) -> Tuple[Optional[CaseInsensitiveDict], bool]:
|
||||
"""Read current recovery parameters values.
|
||||
|
||||
.. note::
|
||||
We query Postgres only if we detected that Postgresql was restarted
|
||||
or when at least one of the following files was updated:
|
||||
|
||||
* ``postgresql.conf``;
|
||||
* ``postgresql.auto.conf``;
|
||||
* ``passfile`` that is used in the ``primary_conninfo``.
|
||||
|
||||
:returns: a tuple with two elements:
|
||||
|
||||
* :class:`CaseInsensitiveDict` object with current values of recovery parameters,
|
||||
or ``None`` if no configuration files were updated;
|
||||
|
||||
* ``True`` if new values of recovery parameters were queried, ``False`` otherwise.
|
||||
"""
|
||||
if self._postgresql.is_starting():
|
||||
return None, False
|
||||
|
||||
@@ -614,11 +669,20 @@ class ConfigHandler(object):
|
||||
self._postgresql_conf_mtime = pg_conf_mtime
|
||||
self._auto_conf_mtime = auto_conf_mtime
|
||||
self._postmaster_ctime = postmaster_ctime
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
if all((isinstance(exc, PostgresConnectionException),
|
||||
self._postgresql_conf_mtime == pg_conf_mtime,
|
||||
self._auto_conf_mtime == auto_conf_mtime,
|
||||
self._passfile_mtime == passfile_mtime,
|
||||
self._postmaster_ctime != postmaster_ctime)):
|
||||
# We detected that the connection to postgres fails, but the process creation time of the postmaster
|
||||
# doesn't match the old value. It is an indicator that Postgres crashed and either doing crash
|
||||
# recovery or down. In this case we return values like nothing changed in the config.
|
||||
return None, False
|
||||
values = None
|
||||
return values, True
|
||||
|
||||
def _read_recovery_params_pre_v12(self) -> Tuple[Optional[CaseInsensitiveDict], Optional[bool]]:
|
||||
def _read_recovery_params_pre_v12(self) -> Tuple[Optional[CaseInsensitiveDict], bool]:
|
||||
recovery_conf_mtime = mtime(self._recovery_conf)
|
||||
passfile_mtime = mtime(self._passfile) if self._passfile else False
|
||||
if recovery_conf_mtime == self._recovery_conf_mtime and passfile_mtime == self._passfile_mtime:
|
||||
@@ -800,9 +864,11 @@ class ConfigHandler(object):
|
||||
if self._postgresql.major_version >= 120000:
|
||||
if parse_bool(recovery_params.pop('standby_mode', None)):
|
||||
open(self._standby_signal, 'w').close()
|
||||
self.set_file_permissions(self._standby_signal)
|
||||
else:
|
||||
self._remove_file_if_exists(self._standby_signal)
|
||||
open(self._recovery_signal, 'w').close()
|
||||
self.set_file_permissions(self._recovery_signal)
|
||||
|
||||
def restart_required(name: str) -> bool:
|
||||
if self._postgresql.major_version >= 140000:
|
||||
@@ -813,8 +879,7 @@ class ConfigHandler(object):
|
||||
self._current_recovery_params = CaseInsensitiveDict({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)
|
||||
with self.config_writer(self._recovery_conf) as f:
|
||||
self._write_recovery_params(f, recovery_params)
|
||||
|
||||
def remove_recovery_conf(self) -> None:
|
||||
@@ -843,6 +908,7 @@ class ConfigHandler(object):
|
||||
if overwrite:
|
||||
try:
|
||||
with open(self._auto_conf, 'w') as f:
|
||||
self.set_file_permissions(self._auto_conf)
|
||||
for raw_line in lines:
|
||||
f.write(raw_line)
|
||||
except Exception:
|
||||
@@ -864,14 +930,15 @@ class ConfigHandler(object):
|
||||
listen_addresses, port = split_host_port(config['listen'], 5432)
|
||||
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
|
||||
if not self._postgresql.global_config or self._postgresql.global_config.is_synchronous_mode:
|
||||
if self._synchronous_standby_names is None:
|
||||
synchronous_standby_names = self._server_parameters.get('synchronous_standby_names')
|
||||
if synchronous_standby_names is None:
|
||||
if self._postgresql.global_config and self._postgresql.global_config.is_synchronous_mode_strict\
|
||||
and self._postgresql.role in ('master', 'primary', 'promoted'):
|
||||
parameters['synchronous_standby_names'] = '*'
|
||||
else:
|
||||
parameters.pop('synchronous_standby_names', None)
|
||||
else:
|
||||
parameters['synchronous_standby_names'] = self._synchronous_standby_names
|
||||
parameters['synchronous_standby_names'] = synchronous_standby_names
|
||||
|
||||
# Handle hot_standby <-> replica rename
|
||||
if parameters.get('wal_level') == ('hot_standby' if self._postgresql.major_version >= 90600 else 'replica'):
|
||||
@@ -947,17 +1014,14 @@ class ConfigHandler(object):
|
||||
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: Collection[str]
|
||||
) -> Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]]:
|
||||
def _get_pg_settings(self, names: Collection[str]) -> Dict[Any, Tuple[Any, ...]]:
|
||||
return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile'
|
||||
+ ' FROM pg_catalog.pg_settings '
|
||||
+ ' WHERE pg_catalog.lower(name) = ANY(%s)'),
|
||||
[n.lower() for n in names])}
|
||||
|
||||
@staticmethod
|
||||
def _handle_wal_buffers(old_values: Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]],
|
||||
changes: CaseInsensitiveDict) -> None:
|
||||
def _handle_wal_buffers(old_values: Dict[Any, Tuple[Any, ...]], changes: CaseInsensitiveDict) -> None:
|
||||
wal_block_size = parse_int(old_values['wal_block_size'][1]) or 8192
|
||||
wal_segment_size = old_values['wal_segment_size']
|
||||
wal_segment_unit = parse_int(wal_segment_size[2], 'B') or 8192 \
|
||||
@@ -1074,12 +1138,11 @@ class ConfigHandler(object):
|
||||
def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
|
||||
"""Updates synchronous_standby_names and reloads if necessary.
|
||||
:returns: True if value was updated."""
|
||||
if value != self._synchronous_standby_names:
|
||||
if value != self._server_parameters.get('synchronous_standby_names'):
|
||||
if value is None:
|
||||
self._server_parameters.pop('synchronous_standby_names', None)
|
||||
else:
|
||||
self._server_parameters['synchronous_standby_names'] = value
|
||||
self._synchronous_standby_names = value
|
||||
if self._postgresql.state == 'running':
|
||||
self.write_postgresql_conf()
|
||||
self._postgresql.reload()
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Generator, Union, TYPE_CHECKING
|
||||
from typing import Any, Dict, Iterator, Union, TYPE_CHECKING
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Connection as Connection3, Cursor
|
||||
from psycopg2 import connection, cursor
|
||||
@@ -44,7 +44,7 @@ class Connection(object):
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection_cursor(**kwargs: Any) -> Generator[Union['cursor', 'Cursor[Any]'], None, None]:
|
||||
def get_connection_cursor(**kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
conn = psycopg.connect(**kwargs)
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
|
||||
@@ -158,7 +158,7 @@ class Rewind(object):
|
||||
def _get_local_timeline_lsn(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]:
|
||||
if self._postgresql.is_running(): # if postgres is running - get timeline from replication connection
|
||||
in_recovery = True
|
||||
timeline = self._postgresql.received_timeline() or self._postgresql.get_replica_timeline()
|
||||
timeline = self._postgresql.get_replica_timeline()
|
||||
lsn = self._postgresql.replayed_location()
|
||||
else: # otherwise analyze pg_controldata output
|
||||
in_recovery, timeline, lsn = self._get_local_timeline_lsn_from_controldata()
|
||||
@@ -280,7 +280,7 @@ class Rewind(object):
|
||||
"""After promote issue a CHECKPOINT from a new thread and asynchronously check the result.
|
||||
In case if CHECKPOINT failed, just check that timeline in pg_control was updated."""
|
||||
|
||||
if self._state == REWIND_STATUS.INITIAL and self._postgresql.is_leader():
|
||||
if self._state != REWIND_STATUS.CHECKPOINT and self._postgresql.is_leader():
|
||||
with self._checkpoint_task_lock:
|
||||
if self._checkpoint_task:
|
||||
with self._checkpoint_task:
|
||||
@@ -370,7 +370,7 @@ class Rewind(object):
|
||||
# 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:
|
||||
if self._postgresql.cancellable.call([cmd], shell=True) == 0:
|
||||
new_name = os.path.join(status_dir, wal + '.done')
|
||||
try:
|
||||
shutil.move(old_name, new_name)
|
||||
|
||||
+357
-85
@@ -1,15 +1,20 @@
|
||||
"""Replication slot handling.
|
||||
|
||||
Provides classes for the creation, monitoring, management and synchronisation of PostgreSQL replication slots.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from threading import Condition, Thread
|
||||
from typing import Any, Dict, Generator, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING, Collection
|
||||
|
||||
from .connection import get_connection_cursor
|
||||
from .misc import format_lsn, fsync_dir
|
||||
from ..dcs import Cluster, Leader
|
||||
from ..file_perm import pg_perm
|
||||
from ..psycopg import OperationalError
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -42,9 +47,17 @@ def compare_slots(s1: Dict[str, Any], s2: Dict[str, Any], dbid: str = 'database'
|
||||
|
||||
|
||||
class SlotsAdvanceThread(Thread):
|
||||
"""Daemon process :class:``Thread`` object for advancing logical replication slots on replicas.
|
||||
|
||||
This ensures that slot advancing queries sent to postgres do not block the main loop.
|
||||
"""
|
||||
|
||||
def __init__(self, slots_handler: 'SlotsHandler') -> None:
|
||||
super(SlotsAdvanceThread, self).__init__()
|
||||
"""Create and start a new thread for handling slot advance queries.
|
||||
|
||||
:param slots_handler: The calling class instance for reference to slot information attributes.
|
||||
"""
|
||||
super().__init__()
|
||||
self.daemon = True
|
||||
self._slots_handler = slots_handler
|
||||
|
||||
@@ -58,6 +71,13 @@ class SlotsAdvanceThread(Thread):
|
||||
self.start()
|
||||
|
||||
def sync_slot(self, cur: Union['cursor', 'Cursor[Any]'], database: str, slot: str, lsn: int) -> None:
|
||||
"""Execute a ``pg_replication_slot_advance`` query and store success for scheduled synchronisation task.
|
||||
|
||||
:param cur: database connection cursor.
|
||||
:param database: name of the database associated with the slot.
|
||||
:param slot: name of the slot to be synchronised.
|
||||
:param lsn: last known LSN position
|
||||
"""
|
||||
failed = copy = False
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", (slot, format_lsn(lsn)))
|
||||
@@ -79,6 +99,11 @@ class SlotsAdvanceThread(Thread):
|
||||
self._scheduled.pop(database)
|
||||
|
||||
def sync_slots_in_database(self, database: str, slots: List[str]) -> None:
|
||||
"""Synchronise slots for a single database.
|
||||
|
||||
:param database: name of the database.
|
||||
:param slots: list of slot names to synchronise.
|
||||
"""
|
||||
with self._slots_handler.get_local_connection_cursor(dbname=database, options='-c statement_timeout=0') as cur:
|
||||
for slot in slots:
|
||||
with self._condition:
|
||||
@@ -87,6 +112,7 @@ class SlotsAdvanceThread(Thread):
|
||||
self.sync_slot(cur, database, slot, lsn)
|
||||
|
||||
def sync_slots(self) -> None:
|
||||
"""Synchronise slots for all scheduled databases."""
|
||||
with self._condition:
|
||||
databases = list(self._scheduled.keys())
|
||||
for database in databases:
|
||||
@@ -99,6 +125,12 @@ class SlotsAdvanceThread(Thread):
|
||||
logger.error('Failed to advance replication slots in database %s: %r', database, e)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Thread main loop entrypoint.
|
||||
|
||||
.. note::
|
||||
Thread will wait until a sync is scheduled from outside, normally triggered during the HA loop or a wakeup
|
||||
call.
|
||||
"""
|
||||
while True:
|
||||
with self._condition:
|
||||
if not self._scheduled:
|
||||
@@ -107,6 +139,14 @@ class SlotsAdvanceThread(Thread):
|
||||
self.sync_slots()
|
||||
|
||||
def schedule(self, advance_slots: Dict[str, Dict[str, int]]) -> Tuple[bool, List[str]]:
|
||||
"""Trigger a synchronisation of slots.
|
||||
|
||||
This is the main entrypoint for Patroni HA loop wakeup call.
|
||||
|
||||
:param advance_slots: dictionary containing slots that need to be advanced
|
||||
|
||||
:return: tuple of failure status and a list of slots to be copied
|
||||
"""
|
||||
with self._condition:
|
||||
for database, values in advance_slots.items():
|
||||
self._scheduled[database].update(values)
|
||||
@@ -118,40 +158,75 @@ class SlotsAdvanceThread(Thread):
|
||||
return ret
|
||||
|
||||
def on_promote(self) -> None:
|
||||
"""Reset state of the daemon."""
|
||||
with self._condition:
|
||||
self._scheduled.clear()
|
||||
self._failed = False
|
||||
self._copy_slots = []
|
||||
|
||||
|
||||
class SlotsHandler(object):
|
||||
class SlotsHandler:
|
||||
"""Handler for managing and storing information on replication slots in PostgreSQL.
|
||||
|
||||
:ivar pg_replslot_dir: system location path of the PostgreSQL replication slots.
|
||||
:ivar _logical_slots_processing_queue: yet to be processed logical replication slots on the primary
|
||||
"""
|
||||
|
||||
def __init__(self, postgresql: 'Postgresql') -> None:
|
||||
"""Create an instance with storage attributes for replication slots and schedule the first synchronisation.
|
||||
|
||||
:param postgresql: Calling class instance providing interface to PostgreSQL.
|
||||
"""
|
||||
self._force_readiness_check = False
|
||||
self._schedule_load_slots = False
|
||||
self._postgresql = postgresql
|
||||
self._advance = None
|
||||
self._replication_slots: Dict[str, Dict[str, Any]] = {} # already existing replication slots
|
||||
self._unready_logical_slots: Dict[str, Optional[int]] = {}
|
||||
self._logical_slots_processing_queue: Dict[str, Optional[int]] = {}
|
||||
self.pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
|
||||
self.schedule()
|
||||
|
||||
def _query(self, sql: str, *params: Any) -> Union['cursor', 'Cursor[Any]']:
|
||||
"""Helper method for :meth:`Postgresql.query`.
|
||||
|
||||
:param sql: SQL statement to execute.
|
||||
:param params: parameters to pass through to :meth:`Postgresql.query`.
|
||||
|
||||
:returns: query response.
|
||||
"""
|
||||
return self._postgresql.query(sql, *params, retry=False)
|
||||
|
||||
@staticmethod
|
||||
def _copy_items(src: Dict[str, Any], dst: Dict[str, Any], keys: Optional[List[str]] = None) -> None:
|
||||
def _copy_items(src: Dict[str, Any], dst: Dict[str, Any], keys: Optional[Collection[str]] = None) -> None:
|
||||
"""Select values from *src* dictionary to update in *dst* dictionary for optional supplied *keys*.
|
||||
|
||||
:param src: source dictionary that *keys* will be looked up from.
|
||||
:param dst: destination dictionary to be updated.
|
||||
:param keys: optional list of keys to be looked up in the source dictionary.
|
||||
"""
|
||||
dst.update({key: src[key] for key in keys or ('datoid', 'catalog_xmin', 'confirmed_flush_lsn')})
|
||||
|
||||
def process_permanent_slots(self, slots: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
"""This methods solves three problems at once (I know, it is weird).
|
||||
"""Process replication slot information from the host and prepare information used in subsequent cluster tasks.
|
||||
|
||||
.. note::
|
||||
This methods solves three problems.
|
||||
|
||||
The ``cluster_info_query`` from :class:``Postgresql`` is executed every HA loop and returns information
|
||||
about all replication slots that exists on the current host.
|
||||
|
||||
Based on this information perform the following actions:
|
||||
|
||||
1. For the primary we want to expose to DCS permanent logical slots, therefore build (and return) a dict
|
||||
that maps permanent logical slot names to ``confirmed_flush_lsn``.
|
||||
2. detect if one of the previously known permanent slots is missing and schedule resync.
|
||||
3. Update the local cache with the fresh ``catalog_xmin`` and ``confirmed_flush_lsn`` for every known slot.
|
||||
|
||||
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.
|
||||
|
||||
:param slots: replication slot information that exists on the current host.
|
||||
|
||||
:return: dictionary of logical slot names to ``confirmed_flush_lsn``.
|
||||
"""
|
||||
ret: Dict[str, int] = {}
|
||||
|
||||
@@ -173,13 +248,23 @@ class SlotsHandler(object):
|
||||
return ret
|
||||
|
||||
def load_replication_slots(self) -> None:
|
||||
"""Query replication slot information from the database and store it for processing by other tasks.
|
||||
|
||||
.. note::
|
||||
Only supported from PostgreSQL version 9.4 onwards.
|
||||
|
||||
Store replication slot ``name``, ``type``, ``plugin``, ``database`` and ``datoid``.
|
||||
If PostgreSQL version is 10 or newer also store ``catalog_xmin`` and ``confirmed_flush_lsn``.
|
||||
|
||||
When using logical slots, store information separately for slot synchronisation on replica nodes.
|
||||
"""
|
||||
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
|
||||
replication_slots: Dict[str, Dict[str, Any]] = {}
|
||||
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
|
||||
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))
|
||||
cursor = self._query(f'SELECT slot_name, slot_type, plugin, database, datoid'
|
||||
f'{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}')
|
||||
for r in cursor:
|
||||
value = {'type': r[1]}
|
||||
if r[1] == 'logical':
|
||||
@@ -190,20 +275,39 @@ class SlotsHandler(object):
|
||||
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._logical_slots_processing_queue = {n: None for n, v in replication_slots.items()
|
||||
if v['type'] == 'logical'}
|
||||
self._force_readiness_check = False
|
||||
|
||||
def ignore_replication_slot(self, cluster: Cluster, name: str) -> bool:
|
||||
"""Check if slot *name* should not be managed by Patroni.
|
||||
|
||||
:param cluster: cluster state information object.
|
||||
:param name: name of the slot to ignore
|
||||
|
||||
:returns: ``True`` if slot *name* matches any slot specified in ``ignore_slots`` configuration,
|
||||
otherwise will pass through and return result of :meth:`CitusHandler.ignore_replication_slot`.
|
||||
"""
|
||||
slot = self._replication_slots[name]
|
||||
if cluster.config:
|
||||
for matcher in cluster.config.ignore_slots_matchers:
|
||||
if ((matcher.get("name") is None or matcher["name"] == name)
|
||||
and all(not matcher.get(a) or matcher[a] == slot.get(a) for a in ('database', 'plugin', 'type'))):
|
||||
if (
|
||||
(matcher.get("name") is None or matcher["name"] == name)
|
||||
and all(not matcher.get(a) or matcher[a] == slot.get(a)
|
||||
for a in ('database', 'plugin', 'type'))
|
||||
):
|
||||
return True
|
||||
return self._postgresql.citus_handler.ignore_replication_slot(slot)
|
||||
|
||||
def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
|
||||
"""Returns a tuple(active, dropped)"""
|
||||
"""Drop a named slot from Postgres.
|
||||
|
||||
:param name: name of the slot to be dropped.
|
||||
|
||||
:returns: a tuple of ``active`` and ``dropped``. ``active`` is ``True`` if the slot is active,
|
||||
``dropped`` is ``True`` if the slot was successfully dropped. If the slot was not found return
|
||||
``False`` for both.
|
||||
"""
|
||||
cursor = self._query(('WITH slots AS (SELECT slot_name, active'
|
||||
' FROM pg_catalog.pg_replication_slots WHERE slot_name = %s),'
|
||||
' dropped AS (SELECT pg_catalog.pg_drop_replication_slot(slot_name),'
|
||||
@@ -213,10 +317,22 @@ class SlotsHandler(object):
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
row = (False, False)
|
||||
return row
|
||||
return row[0], row[1]
|
||||
|
||||
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None:
|
||||
# drop old replication slots which are not presented in desired slots
|
||||
"""Compare required slots and configured as permanent slots with those found, dropping extraneous ones.
|
||||
|
||||
.. note::
|
||||
Slots that are not contained in *slots* will be dropped.
|
||||
Slots can be filtered out with ``ignore_slots`` configuration.
|
||||
|
||||
Slots that have matching names but do not match attributes in *slots* will also be dropped.
|
||||
|
||||
:param cluster: cluster state information object.
|
||||
:param slots: dictionary of desired slot names as keys with slot attributes as a dictionary value, if known.
|
||||
:param paused: ``True`` if the patroni cluster is currently in a paused state.
|
||||
"""
|
||||
# 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):
|
||||
active, dropped = self.drop_replication_slot(name)
|
||||
@@ -228,6 +344,8 @@ class SlotsHandler(object):
|
||||
logger.debug("Unable to drop unknown replication slot '%s', slot is still active", name)
|
||||
else:
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
|
||||
# drop slots with matching names but attributes that do not match, e.g. `plugin` or `database`.
|
||||
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",
|
||||
@@ -239,31 +357,57 @@ class SlotsHandler(object):
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def _ensure_physical_slots(self, slots: Dict[str, Any]) -> None:
|
||||
"""Create any missing physical replication *slots*.
|
||||
|
||||
Any failures are logged and do not interrupt creation of all *slots*.
|
||||
|
||||
:param slots: A dictionary mapping slot name to slot attributes. This method only considers a slot
|
||||
if the value is a dictionary with the key ``type`` and a value of ``physical``.
|
||||
"""
|
||||
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)
|
||||
self._query(f"SELECT pg_catalog.pg_create_physical_replication_slot(%s{immediately_reserve})"
|
||||
f" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
|
||||
f" WHERE slot_type = 'physical' AND slot_name = %s)",
|
||||
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: Any) -> Generator[Union['cursor', 'Cursor[Any]'], None, None]:
|
||||
def get_local_connection_cursor(self, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
"""Create a new database connection to local server.
|
||||
|
||||
Create a non-blocking connection cursor to avoid the situation where an execution of the query of
|
||||
``pg_replication_slot_advance`` takes longer than the timeout on a HA loop, which could cause a false
|
||||
failure state.
|
||||
|
||||
:param kwargs: Any keyword arguments to pass to :func:`psycopg.connect`.
|
||||
|
||||
:yields: connection cursor object, note implementation varies depending on version of :mod:`psycopg`.
|
||||
"""
|
||||
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: Dict[str, Any]) -> None:
|
||||
"""Create any missing logical replication *slots* on the primary.
|
||||
|
||||
If the logical slot already exists, copy state information into the replication slots structure stored in the
|
||||
class instance.
|
||||
|
||||
:param slots: Slots that should exist are supplied in a dictionary, mapping slot name to any attributes.
|
||||
The method will only consider slots that have a value that is a dictionary with a key ``type``
|
||||
with a value that is ``logical``.
|
||||
|
||||
"""
|
||||
# Group logical slots to be created by database name
|
||||
logical_slots: Dict[str, Dict[str, Dict[str, Any]]] = 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:
|
||||
@@ -285,27 +429,56 @@ class SlotsHandler(object):
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def schedule_advance_slots(self, slots: Dict[str, Dict[str, int]]) -> Tuple[bool, List[str]]:
|
||||
"""Wrapper to ensure slots advance daemon thread is started if not already.
|
||||
|
||||
:param slots: dictionary containing slot information.
|
||||
|
||||
:return: tuple with the result of the scheduling of slot advancement: ``failed`` and list of slots to copy.
|
||||
"""
|
||||
if not self._advance:
|
||||
self._advance = SlotsAdvanceThread(self)
|
||||
return self._advance.schedule(slots)
|
||||
|
||||
def _ensure_logical_slots_replica(self, cluster: Cluster, slots: Dict[str, Any]) -> List[str]:
|
||||
"""Update logical *slots* on replicas.
|
||||
|
||||
If the logical slot already exists, copy state information into the replication slots structure stored in the
|
||||
class instance. Slots that exist are also advanced if their ``confirmed_flush_lsn`` is greater than the stored
|
||||
state of the slot.
|
||||
|
||||
As logical slots can only be created when the primary is available, pass the list of slots that need to be
|
||||
copied back to the caller. They will be created on replicas with :meth:`SlotsHandler.copy_logical_slots`.
|
||||
|
||||
:param cluster: object containing stateful information for the cluster.
|
||||
:param slots: A dictionary mapping slot name to slot attributes. This method only considers a slot
|
||||
if the value is a dictionary with the key ``type`` and a value of ``logical``.
|
||||
|
||||
:returns: list of slots to be copied from the primary.
|
||||
"""
|
||||
# Group logical slots to be advanced by database name
|
||||
advance_slots: Dict[str, Dict[str, int]] = defaultdict(dict)
|
||||
create_slots: List[str] = [] # And collect logical slots to be created on the replica
|
||||
create_slots: List[str] = [] # 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 cluster.slots and 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 cluster.slots and name in cluster.slots: # We want to copy only slots with feedback in a DCS
|
||||
create_slots.append(name)
|
||||
if value['type'] != 'logical':
|
||||
continue
|
||||
|
||||
# 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 cluster.slots and name in cluster.slots:
|
||||
try: # Skip slots that don'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 cluster.slots and name in cluster.slots: # We want to copy only slots with feedback in a DCS
|
||||
create_slots.append(name)
|
||||
|
||||
# Slots to be copied from the primary should be removed from the *slots* structure,
|
||||
# otherwise Patroni falsely assumes that they already exist.
|
||||
for name in create_slots:
|
||||
slots.pop(name)
|
||||
|
||||
error, copy_slots = self.schedule_advance_slots(advance_slots)
|
||||
if error:
|
||||
@@ -314,23 +487,38 @@ class SlotsHandler(object):
|
||||
|
||||
def sync_replication_slots(self, cluster: Cluster, nofailover: bool,
|
||||
replicatefrom: Optional[str] = None, paused: bool = False) -> List[str]:
|
||||
"""During the HA loop read, check and alter replication slots found in the cluster.
|
||||
|
||||
Read physical and logical slots found on the primary, then compare to those configured in the DCS.
|
||||
Drop any slots that do not match those required by configuration and are not configured as permanent.
|
||||
Create any missing physical slots. If we are the leader then logical slots too, otherwise if logical slots
|
||||
are known and active create them on replica nodes.
|
||||
|
||||
:param cluster: object containing stateful information for the cluster.
|
||||
:param nofailover: ``True`` if this node has been tagged to not be a failover candidate.
|
||||
:param replicatefrom: the tag containing the node to replicate from.
|
||||
:param paused: ``True`` if the cluster is in maintenance mode.
|
||||
|
||||
:returns: list of logical replication slots names that should be copied from the primary.
|
||||
"""
|
||||
ret = []
|
||||
if self._postgresql.major_version >= 90400 and cluster.config:
|
||||
if self._postgresql.major_version >= 90400 and self._postgresql.global_config and cluster.config:
|
||||
try:
|
||||
self.load_replication_slots()
|
||||
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role,
|
||||
nofailover, self._postgresql.major_version, True)
|
||||
slots = cluster.get_replication_slots(
|
||||
self._postgresql.name, self._postgresql.role, nofailover, self._postgresql.major_version,
|
||||
is_standby_cluster=self._postgresql.global_config.is_standby_cluster, show_error=True)
|
||||
|
||||
self._drop_incorrect_slots(cluster, slots, paused)
|
||||
|
||||
self._ensure_physical_slots(slots)
|
||||
|
||||
if self._postgresql.is_leader():
|
||||
self._unready_logical_slots.clear()
|
||||
self._logical_slots_processing_queue.clear()
|
||||
self._ensure_logical_slots_primary(slots)
|
||||
elif cluster.slots and slots:
|
||||
self.check_logical_slots_readiness(cluster, nofailover, replicatefrom)
|
||||
self.check_logical_slots_readiness(cluster, replicatefrom)
|
||||
|
||||
ret = self._ensure_logical_slots_replica(cluster, slots)
|
||||
|
||||
@@ -341,57 +529,124 @@ class SlotsHandler(object):
|
||||
return ret
|
||||
|
||||
@contextmanager
|
||||
def _get_leader_connection_cursor(self, leader: Leader) -> Generator[Union['cursor', 'Cursor[Any]'], None, None]:
|
||||
def _get_leader_connection_cursor(self, leader: Leader) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
"""Create a new database connection to the leader.
|
||||
|
||||
.. note::
|
||||
Uses rewind user credentials because it has enough permissions to read files from PGDATA.
|
||||
Sets the options ``connect_timeout`` to ``3`` and ``statement_timeout`` to ``2000``.
|
||||
|
||||
:param leader: object with information on the leader
|
||||
|
||||
:yields: connection cursor object, note implementation varies depending on version of ``psycopg``.
|
||||
"""
|
||||
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: Cluster, nofailover: bool, replicatefrom: Optional[str]) -> None:
|
||||
def check_logical_slots_readiness(self, cluster: Cluster, replicatefrom: Optional[str]) -> bool:
|
||||
"""Determine whether all known logical slots are synchronised from the leader.
|
||||
|
||||
1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and
|
||||
2) using previously stored list of "unready" logical slots, those which have yet to be checked hence have no
|
||||
stored slot attributes,
|
||||
3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid.
|
||||
|
||||
:param cluster: object containing stateful information for the cluster.
|
||||
:param replicatefrom: name of the member that should be used to replicate from.
|
||||
|
||||
:returns: ``False`` if any issue while checking logical slots readiness, ``True`` otherwise.
|
||||
"""
|
||||
catalog_xmin = None
|
||||
if self._unready_logical_slots and cluster.leader:
|
||||
if self._logical_slots_processing_queue and cluster.leader:
|
||||
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],))
|
||||
([n for n, v in self._logical_slots_processing_queue.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)
|
||||
logger.warning('Physical slot %s does not exist on the primary', slot_name)
|
||||
return False
|
||||
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 is not None:
|
||||
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")
|
||||
row = cur.fetchone()
|
||||
if row and not row[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
|
||||
logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e)
|
||||
return False
|
||||
|
||||
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 catalog_xmin is not None and\
|
||||
self._unready_logical_slots[name] <= catalog_xmin <= value['catalog_xmin']:
|
||||
del self._unready_logical_slots[name]
|
||||
if value:
|
||||
if not self._update_pending_logical_slot_primary(slots, catalog_xmin):
|
||||
return False # since `catalog_xmin` isn't valid further checks don't make any sense
|
||||
|
||||
self._ready_logical_slots(catalog_xmin)
|
||||
return True
|
||||
|
||||
def _update_pending_logical_slot_primary(self, slots: Dict[str, Any], catalog_xmin: Optional[int] = None) -> bool:
|
||||
"""Store pending logical slot information for ``catalog_xmin`` on the primary.
|
||||
|
||||
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.
|
||||
|
||||
:param slots: dictionary of slot information from the primary
|
||||
:param catalog_xmin: ``catalog_xmin`` of the physical slot used by this replica to stream changes from primary.
|
||||
|
||||
:returns: ``False`` if any issue was faced while processing, ``True`` otherwise.
|
||||
"""
|
||||
if catalog_xmin is not None:
|
||||
for name, value in slots.items():
|
||||
self._logical_slots_processing_queue[name] = value
|
||||
return True
|
||||
|
||||
# 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")
|
||||
row = cur.fetchone()
|
||||
if row and not row[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 False
|
||||
|
||||
def _ready_logical_slots(self, primary_physical_catalog_xmin: Optional[int] = None) -> None:
|
||||
"""Ready logical slots by comparing primary physical slot ``catalog_xmin`` to logical ``catalog_xmin``.
|
||||
|
||||
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`` represented by ``primary_physical_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.
|
||||
|
||||
:param primary_physical_catalog_xmin: is the value retrieved from ``pg_catalog.pg_get_replication_slots()`` for
|
||||
the physical replication slot on the primary.
|
||||
"""
|
||||
# Make a copy of processing queue keys as a list as the queue dictionary is modified inside the loop.
|
||||
for name in list(self._logical_slots_processing_queue):
|
||||
primary_logical_catalog_xmin = self._logical_slots_processing_queue[name]
|
||||
standby_logical_slot = self._replication_slots.get(name, {})
|
||||
standby_logical_catalog_xmin = standby_logical_slot.get('catalog_xmin', 0)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert primary_logical_catalog_xmin is not None
|
||||
|
||||
if (
|
||||
not standby_logical_slot
|
||||
or primary_physical_catalog_xmin is not None
|
||||
and primary_logical_catalog_xmin <= primary_physical_catalog_xmin <= standby_logical_catalog_xmin
|
||||
):
|
||||
|
||||
del self._logical_slots_processing_queue[name]
|
||||
|
||||
if standby_logical_slot:
|
||||
logger.info('Logical slot %s is safe to be used after a failover', name)
|
||||
|
||||
def copy_logical_slots(self, cluster: Cluster, create_slots: List[str]) -> None:
|
||||
"""Create logical replication slots on standby nodes.
|
||||
|
||||
:param cluster: object containing stateful information for the cluster.
|
||||
:param create_slots: list of slot names to copy from the primary.
|
||||
"""
|
||||
leader = cluster.leader
|
||||
if not leader:
|
||||
return
|
||||
@@ -418,34 +673,51 @@ class SlotsHandler(object):
|
||||
logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e)
|
||||
|
||||
if copy_slots and self._postgresql.stop():
|
||||
pg_perm.set_permissions_from_data_directory(self._postgresql.data_dir)
|
||||
for name, value in copy_slots.items():
|
||||
slot_dir = os.path.join(self._postgresql.slots_handler.pg_replslot_dir, name)
|
||||
slot_dir = os.path.join(self.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)
|
||||
os.chmod(slot_tmp_dir, pg_perm.dir_create_mode)
|
||||
fsync_dir(slot_tmp_dir)
|
||||
with open(os.path.join(slot_tmp_dir, 'state'), 'wb') as f:
|
||||
slot_filename = os.path.join(slot_tmp_dir, 'state')
|
||||
with open(slot_filename, 'wb') as f:
|
||||
os.chmod(slot_filename, pg_perm.file_create_mode)
|
||||
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)
|
||||
os.chmod(slot_dir, pg_perm.dir_create_mode)
|
||||
fsync_dir(slot_dir)
|
||||
self._unready_logical_slots[name] = None
|
||||
fsync_dir(self._postgresql.slots_handler.pg_replslot_dir)
|
||||
self._logical_slots_processing_queue[name] = None
|
||||
fsync_dir(self.pg_replslot_dir)
|
||||
self._postgresql.start()
|
||||
|
||||
def schedule(self, value: Optional[bool] = None) -> None:
|
||||
"""Schedule the loading of slot information from the database.
|
||||
|
||||
:param value: the optional value can be used to unschedule if set to ``False`` or force it to be ``True``.
|
||||
If it is omitted the value will be ``True`` if this PostgreSQL node supports slot replication.
|
||||
"""
|
||||
if value is None:
|
||||
value = self._postgresql.major_version >= 90400
|
||||
self._schedule_load_slots = self._force_readiness_check = value
|
||||
|
||||
def on_promote(self) -> None:
|
||||
"""Entry point from HA cycle used when a standby node is to be promoted to primary.
|
||||
|
||||
.. note::
|
||||
If logical replication slot synchronisation is enabled then slot advancement will be triggered.
|
||||
If any logical slots that were copied are yet to be confirmed as ready a warning message will be logged.
|
||||
|
||||
"""
|
||||
if self._advance:
|
||||
self._advance.on_promote()
|
||||
|
||||
if self._unready_logical_slots:
|
||||
if self._logical_slots_processing_queue:
|
||||
logger.warning('Logical replication slots that might be unsafe to use after promote: %s',
|
||||
set(self._unready_logical_slots))
|
||||
set(self._logical_slots_processing_queue))
|
||||
|
||||
+90
-37
@@ -153,6 +153,72 @@ def parse_sync_standby_names(value: str) -> _SSN:
|
||||
return _SSN(sync_type, has_star, num, members)
|
||||
|
||||
|
||||
class _Replica(NamedTuple):
|
||||
"""Class representing a single replica that is eligible to be synchronous.
|
||||
|
||||
Attributes are taken from ``pg_stat_replication`` view and respective ``Cluster.members``.
|
||||
|
||||
:ivar pid: PID of walsender process.
|
||||
:ivar application_name: matches with the ``Member.name``.
|
||||
:ivar sync_state: possible values are: ``async``, ``potential``, ``quorum``, and ``sync``.
|
||||
:ivar lsn: ``write_lsn``, ``flush_lsn``, or ``replay_lsn``, depending on the value of ``synchronous_commit`` GUC.
|
||||
:ivar nofailover: whether the corresponding member has ``nofailover`` tag set to ``True``.
|
||||
"""
|
||||
pid: int
|
||||
application_name: str
|
||||
sync_state: str
|
||||
lsn: int
|
||||
nofailover: bool
|
||||
|
||||
|
||||
class _ReplicaList(List[_Replica]):
|
||||
"""A collection of :class:``_Replica`` objects.
|
||||
|
||||
Values are reverse ordered by ``_Replica.sync_state`` and ``_Replica.lsn``.
|
||||
That is, first there will be replicas that have ``sync_state`` == ``sync``, even if they are not
|
||||
the most up-to-date in term of write/flush/replay LSN. It helps to keep the result of chosing new
|
||||
synchronous nodes consistent in case if a synchronous standby member is slowed down OR async node
|
||||
is receiving changes faster than the sync member. Such cases would trigger sync standby member
|
||||
swapping, but only if lag on this member is exceeding a threshold (``maximum_lag_on_syncnode``).
|
||||
|
||||
:ivar max_lsn: maximum value of ``_Replica.lsn`` among all values. In case if there is just one
|
||||
element in the list we take value of ``pg_current_wal_lsn()``.
|
||||
"""
|
||||
|
||||
def __init__(self, postgresql: 'Postgresql', cluster: Cluster) -> None:
|
||||
"""Create :class:``_ReplicaList`` object.
|
||||
|
||||
:param postgresql: reference to :class:``Postgresql`` object.
|
||||
:param cluster: currently known cluster state from DCS.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# We want to prioritize candidates based on `write_lsn``, ``flush_lsn``, or ``replay_lsn``.
|
||||
# Which column exactly to pick depends on the values of ``synchronous_commit`` GUC.
|
||||
sort_col = {
|
||||
'remote_apply': 'replay',
|
||||
'remote_write': 'write'
|
||||
}.get(postgresql.synchronous_commit(), 'flush') + '_lsn'
|
||||
|
||||
members = CaseInsensitiveDict({m.name: m for m in cluster.members})
|
||||
for row in postgresql.pg_stat_replication():
|
||||
member = members.get(row['application_name'])
|
||||
|
||||
# We want to consider only rows from ``pg_stat_replication` that:
|
||||
# 1. are known to be streaming (write/flush/replay LSN are not NULL).
|
||||
# 2. can be mapped to a ``Member`` of the ``Cluster``:
|
||||
# a. ``Member`` doesn't have ``nosync`` tag set;
|
||||
# b. PostgreSQL on the member is known to be running and accepting client connections.
|
||||
if member and row[sort_col] is not None and member.is_running and not member.tags.get('nosync', False):
|
||||
self.append(_Replica(row['pid'], row['application_name'],
|
||||
row['sync_state'], row[sort_col], bool(member.nofailover)))
|
||||
|
||||
# Prefer replicas that are in state ``sync`` and with higher values of ``write``/``flush``/``replay`` LSN.
|
||||
self.sort(key=lambda r: (r.sync_state, r.lsn), reverse=True)
|
||||
|
||||
self.max_lsn = max(self, key=lambda x: x.lsn).lsn if len(self) > 1 else postgresql.last_operation()
|
||||
|
||||
|
||||
class SyncHandler(object):
|
||||
"""Class responsible for working with the `synchronous_standby_names`.
|
||||
|
||||
@@ -201,13 +267,29 @@ BEGIN
|
||||
END;$$""")
|
||||
self._postgresql.reset_cluster_info_state(None) # Reset internal cache to query fresh values
|
||||
|
||||
def _process_replica_readiness(self, cluster: Cluster, replica_list: _ReplicaList) -> None:
|
||||
"""Flags replicas as truly "synchronous" when they have caught up with ``_primary_flush_lsn``.
|
||||
|
||||
:param cluster: current cluster topology from DCS
|
||||
:param replica_list: collection of replicas that we want to evaluate.
|
||||
"""
|
||||
for replica in replica_list:
|
||||
# if standby name is listed in the /sync key we can count it as synchronous, otherwise
|
||||
# it becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up
|
||||
if replica.application_name not in self._ready_replicas\
|
||||
and replica.application_name in self._ssn_data.members\
|
||||
and (cluster.sync.matches(replica.application_name)
|
||||
or replica.sync_state == 'sync' and replica.lsn >= self._primary_flush_lsn):
|
||||
self._ready_replicas[replica.application_name] = replica.pid
|
||||
|
||||
def current_state(self, cluster: Cluster) -> Tuple[CaseInsensitiveSet, CaseInsensitiveSet]:
|
||||
"""Finds best candidates to be the synchronous standbys.
|
||||
"""Find the best candidates to be the synchronous standbys.
|
||||
|
||||
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
|
||||
synchronous standby any longer.
|
||||
|
||||
Standbys are selected based on values from the global configuration:
|
||||
|
||||
- `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 value less or equal of 0 keeps the behavior backward compatible.
|
||||
@@ -218,31 +300,8 @@ END;$$""")
|
||||
"""
|
||||
self._handle_synchronous_standby_names_change()
|
||||
|
||||
# Pick candidates based on who has higher replay/remote_write/flush lsn.
|
||||
sort_col = {
|
||||
'remote_apply': 'replay',
|
||||
'remote_write': 'write'
|
||||
}.get(self._postgresql.synchronous_commit(), 'flush') + '_lsn'
|
||||
|
||||
pg_stat_replication = [(r['pid'], r['application_name'], r['sync_state'], r[sort_col])
|
||||
for r in self._postgresql.pg_stat_replication()
|
||||
if r[sort_col] is not None]
|
||||
|
||||
members = CaseInsensitiveDict({m.name: m for m in cluster.members})
|
||||
replica_list: List[Tuple[int, str, str, int, bool]] = []
|
||||
# pg_stat_replication.sync_state has 4 possible states - async, potential, quorum, sync.
|
||||
# That is, alphabetically they are in the reversed order of priority.
|
||||
# Since we are doing reversed sort on (sync_state, lsn) tuples, it helps to keep the result
|
||||
# consistent in case if a synchronous standby member is slowed down OR async node receiving
|
||||
# changes faster than the sync member (very rare but possible).
|
||||
# Such cases would trigger sync standby member swapping, but only if lag on a sync node exceeding a threshold.
|
||||
for pid, app_name, sync_state, replica_lsn in sorted(pg_stat_replication, key=lambda r: r[2:4], reverse=True):
|
||||
member = members.get(app_name)
|
||||
if member and member.is_running and not member.tags.get('nosync', False):
|
||||
replica_list.append((pid, member.name, sync_state, replica_lsn, bool(member.nofailover)))
|
||||
|
||||
max_lsn = max(replica_list, key=lambda x: x[3])[3]\
|
||||
if len(replica_list) > 1 else self._postgresql.last_operation()
|
||||
replica_list = _ReplicaList(self._postgresql, cluster)
|
||||
self._process_replica_readiness(cluster, replica_list)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert self._postgresql.global_config is not None
|
||||
@@ -253,17 +312,11 @@ END;$$""")
|
||||
candidates = CaseInsensitiveSet()
|
||||
sync_nodes = CaseInsensitiveSet()
|
||||
# Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
|
||||
for pid, app_name, sync_state, replica_lsn, _ in sorted(replica_list, key=lambda x: x[4]):
|
||||
# if standby name is listed in the /sync key we can count it as synchronous, otherwice
|
||||
# it becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up
|
||||
if app_name not in self._ready_replicas and app_name in self._ssn_data.members and\
|
||||
(cluster.sync.matches(app_name) or sync_state == 'sync' and replica_lsn >= self._primary_flush_lsn):
|
||||
self._ready_replicas[app_name] = pid
|
||||
|
||||
if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag:
|
||||
candidates.add(app_name)
|
||||
if sync_state == 'sync' and app_name in self._ready_replicas:
|
||||
sync_nodes.add(app_name)
|
||||
for replica in sorted(replica_list, key=lambda x: x.nofailover):
|
||||
if sync_node_maxlag <= 0 or replica_list.max_lsn - replica.lsn <= sync_node_maxlag:
|
||||
candidates.add(replica.application_name)
|
||||
if replica.sync_state == 'sync' and replica.application_name in self._ready_replicas:
|
||||
sync_nodes.add(replica.application_name)
|
||||
if len(candidates) >= sync_node_count:
|
||||
break
|
||||
|
||||
|
||||
@@ -178,10 +178,11 @@ class ValidatorFactory:
|
||||
|
||||
:returns: the Patroni validator object that corresponds to the specification found in *validator*.
|
||||
|
||||
:raises :class:`ValidatorFactoryNoType`: if *validator* contains no ``type`` key.
|
||||
:raises :class:`ValidatorFactoryInvalidType`: if ``type`` key from *validator* contains an invalid value.
|
||||
:raises :class:`ValidatorFactoryInvalidSpec`: if *validator* contains an invalid set of attributes for the
|
||||
given ``type``.
|
||||
:raises:
|
||||
:class:`ValidatorFactoryNoType`: if *validator* contains no ``type`` key.
|
||||
:class:`ValidatorFactoryInvalidType`: if ``type`` key from *validator* contains an invalid value.
|
||||
:class:`ValidatorFactoryInvalidSpec`: if *validator* contains an invalid set of attributes for the given
|
||||
``type``.
|
||||
|
||||
:Example:
|
||||
|
||||
@@ -265,7 +266,8 @@ def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
|
||||
:returns: the YAML content parsed into a Python object. If any issue is faced while reading/parsing the file, then
|
||||
return ``None``.
|
||||
|
||||
:raises :class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
|
||||
:raises:
|
||||
:class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
|
||||
"""
|
||||
try:
|
||||
with open(file, encoding='UTF-8') as stream:
|
||||
@@ -462,11 +464,13 @@ def transform_postgresql_parameter_value(version: int, name: str, value: Any,
|
||||
:param value: value of the Postgres GUC.
|
||||
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
|
||||
GUC. Used for a couple purposes:
|
||||
* Disallow writing GUCs to ``postgresql.conf`` that does not exist in Postgres *version*;
|
||||
* Avoid ignoring GUC *name* if it does not have a validator in ``parameters``, but is a valid GUC in Postgres
|
||||
*version*.
|
||||
|
||||
:returns: The return value may be one among
|
||||
* Disallow writing GUCs to ``postgresql.conf`` that does not exist in Postgres *version*;
|
||||
* Avoid ignoring GUC *name* if it does not have a validator in ``parameters``, but is a valid GUC in
|
||||
Postgres *version*.
|
||||
|
||||
:returns: The return value may be one among:
|
||||
|
||||
* The original *value* if *name* seems to be an extension GUC (contains a period '.'); or
|
||||
* ``None`` if **name** is a recovery GUC; or
|
||||
* *value* transformed to the expected format for GUC *name* in Postgres *version* using validators defined in
|
||||
@@ -490,10 +494,11 @@ def transform_recovery_parameter_value(version: int, name: str, value: Any,
|
||||
:param value: value of the Postgres recovery GUC.
|
||||
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
|
||||
GUC. Used for a couple purposes:
|
||||
* Disallow writing GUCs to ``recovery.conf`` (or ``postgresql.conf`` depending on *version*), that does not
|
||||
exist in Postgres *version*;
|
||||
* Avoid ignoring recovery GUC *name* if it does not have a validator in ``recovery_parameters``, but is a valid
|
||||
GUC in Postgres *version*.
|
||||
|
||||
* Disallow writing GUCs to ``recovery.conf`` (or ``postgresql.conf`` depending on *version*), that does not
|
||||
exist in Postgres *version*;
|
||||
* Avoid ignoring recovery GUC *name* if it does not have a validator in ``recovery_parameters``, but is a
|
||||
valid GUC in Postgres *version*.
|
||||
|
||||
:returns: *value* transformed to the expected format for recovery GUC *name* in Postgres *version* using validators
|
||||
defined in ``recovery_parameters``. It can also return ``None``. See :func:`_transform_parameter_value`.
|
||||
|
||||
+14
-13
@@ -1,7 +1,8 @@
|
||||
"""Abstraction layer for ``psycopg`` module.
|
||||
"""Abstraction layer for :mod:`psycopg` module.
|
||||
|
||||
This module is able to handle both ``pyscopg2`` and ``psycopg3``, and it exposes a common interface for both.
|
||||
``psycopg2`` takes precedence. ``psycopg3`` will only be used if ``psycopg2`` is either absent or older than ``2.5.4``.
|
||||
This module is able to handle both :mod:`pyscopg2` and :mod:`psycopg`, and it exposes a common interface for both.
|
||||
:mod:`psycopg2` takes precedence. :mod:`psycopg` will only be used if :mod:`psycopg2` is either absent or older than
|
||||
``2.5.4``.
|
||||
"""
|
||||
from typing import Any, Optional, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -28,7 +29,7 @@ try:
|
||||
"""Quote *value* as a SQL literal.
|
||||
|
||||
.. note::
|
||||
*value* is quoted through ``psycopg`` adapters.
|
||||
*value* is quoted through :mod:`psycopg2` adapters.
|
||||
|
||||
:param value: value to be quoted.
|
||||
:param conn: if a connection is given then :func:`quote_literal` checks if any special handling based on server
|
||||
@@ -44,14 +45,14 @@ except ImportError:
|
||||
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
|
||||
|
||||
def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
|
||||
"""Call ``psycopg.connect`` with ``dsn`` and ``**kwargs``.
|
||||
"""Call :func:`psycopg.connect` with *dsn* and ``**kwargs``.
|
||||
|
||||
.. note::
|
||||
Will create ``server_version`` attribute in the returning connection, so it keeps compatibility with the
|
||||
object that would be returned by ``psycopg2.connect``.
|
||||
object that would be returned by :func:`psycopg2.connect`.
|
||||
|
||||
:param dsn: DSN to call ``psycopg.connect`` with.
|
||||
:param kwargs: keyword arguments to call ``psycopg.connect`` with.
|
||||
:param dsn: DSN to call :func:`psycopg.connect` with.
|
||||
:param kwargs: keyword arguments to call :func:`psycopg.connect` with.
|
||||
|
||||
:returns: a connection to the database.
|
||||
"""
|
||||
@@ -89,11 +90,11 @@ def connect(*args: Any, **kwargs: Any) -> Union['connection', 'Connection[Any]']
|
||||
It also enforces ``search_path=pg_catalog`` for non-replication connections to mitigate security issues as
|
||||
Patroni relies on superuser connections.
|
||||
|
||||
:param args: positional arguments to call ``connect`` function from ``psycopg`` module.
|
||||
:param kwargs: keyword arguments to call ``connect`` function from ``psycopg`` module.
|
||||
:param args: positional arguments to call :func:`~psycopg.connect` function from :mod:`psycopg` module.
|
||||
:param kwargs: keyword arguments to call :func:`~psycopg.connect` function from :mod:`psycopg` module.
|
||||
|
||||
:returns: a connection to the database. Can be either a :class:`psycopg.Connection` if using ``psycopg3``, or a
|
||||
:class:`psycopg2.extensions.connection` if using ``psycopg2``.
|
||||
:returns: a connection to the database. Can be either a :class:`psycopg.Connection` if using :mod:`psycopg`, or a
|
||||
:class:`psycopg2.extensions.connection` if using :mod:`psycopg2`.
|
||||
"""
|
||||
if kwargs and 'replication' not in kwargs and kwargs.get('fallback_application_name') != 'Patroni ctl':
|
||||
options = [kwargs['options']] if 'options' in kwargs else []
|
||||
@@ -109,7 +110,7 @@ def quote_ident(value: Any, conn: Optional[Union['cursor', 'connection', 'Connec
|
||||
|
||||
:param value: value to be quoted.
|
||||
:param conn: connection to evaluate the returning string into. Can be either a :class:`psycopg.Connection` if
|
||||
using ``psycopg3``, or a :class:`psycopg2.extensions.connection` if using ``psycopg2``.
|
||||
using :mod:`psycopg`, or a :class:`psycopg2.extensions.connection` if using :mod:`psycopg2`.
|
||||
|
||||
:returns: *value* quoted as a SQL identifier.
|
||||
"""
|
||||
|
||||
+55
-31
@@ -11,6 +11,19 @@ from .dcs import Member
|
||||
from .utils import USER_AGENT
|
||||
|
||||
|
||||
class HTTPSConnectionPool(urllib3.HTTPSConnectionPool):
|
||||
|
||||
def _validate_conn(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Override parent method to silence warnings about requests without certificate verification enabled."""
|
||||
|
||||
|
||||
class PatroniPoolManager(urllib3.PoolManager):
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super(PatroniPoolManager, self).__init__(*args, **kwargs)
|
||||
self.pool_classes_by_scheme = {'http': urllib3.HTTPConnectionPool, 'https': HTTPSConnectionPool}
|
||||
|
||||
|
||||
class PatroniRequest(object):
|
||||
"""Wrapper for performing requests to Patroni's REST API.
|
||||
|
||||
@@ -21,29 +34,38 @@ class PatroniRequest(object):
|
||||
"""Create a new :class:`PatroniRequest` instance with given *config*.
|
||||
|
||||
:param config: Patroni YAML configuration.
|
||||
:param insecure: how to deal with SSL certs verification
|
||||
:param insecure: how to deal with SSL certs verification:
|
||||
|
||||
* If ``True`` it will perform REST API requests without verifying SSL certs; or
|
||||
* If ``False`` it will perform REST API requests and verify SSL certs; or
|
||||
* If ``None`` it will behave according to the value of ``ctl -> insecure`` configuration; or
|
||||
* If ``None`` it will behave according to the value of ``ctl.insecure`` configuration; or
|
||||
* If none of the above applies, then it falls back to ``False``.
|
||||
"""
|
||||
self._insecure = insecure
|
||||
self._pool = urllib3.PoolManager(num_pools=10, maxsize=10)
|
||||
self._pool = PatroniPoolManager(num_pools=10, maxsize=10)
|
||||
self.reload_config(config)
|
||||
|
||||
@staticmethod
|
||||
def _get_cfg_value(config: Union[Config, Dict[str, Any]], name: str) -> Union[Any, None]:
|
||||
"""Get value of *name* setting in *config*.
|
||||
|
||||
.. note::
|
||||
*name* key will be searched only under ``ctl`` and ``restapi`` sections, in that order.
|
||||
def _get_ctl_value(config: Union[Config, Dict[str, Any]], name: str, default: Any = None) -> Optional[Any]:
|
||||
"""Get value of *name* setting from the ``ctl`` section of the *config*.
|
||||
|
||||
:param config: Patroni YAML configuration.
|
||||
:param name: name of the setting value to be retrieved.
|
||||
|
||||
:returns: value of ``ctl -> *name*`` or ``restapi -> *name*``, if either is present, ``None`` otherwise.
|
||||
:returns: value of ``ctl.*name*`` if present, ``None`` otherwise.
|
||||
"""
|
||||
return config.get('ctl', {}).get(name) or config.get('restapi', {}).get(name)
|
||||
return config.get('ctl', {}).get(name, default)
|
||||
|
||||
@staticmethod
|
||||
def _get_restapi_value(config: Union[Config, Dict[str, Any]], name: str) -> Optional[Any]:
|
||||
"""Get value of *name* setting from the ``restapi`` section of the *config*.
|
||||
|
||||
:param config: Patroni YAML configuration.
|
||||
:param name: name of the setting value to be retrieved.
|
||||
|
||||
:returns: value of ``restapi -> *name*`` if present, ``None`` otherwise.
|
||||
"""
|
||||
return config.get('restapi', {}).get(name)
|
||||
|
||||
def _apply_pool_param(self, param: str, value: Any) -> None:
|
||||
"""Configure *param* as *value* in the request manager.
|
||||
@@ -62,15 +84,15 @@ class PatroniRequest(object):
|
||||
|
||||
:param config: Patroni YAML configuration.
|
||||
:param name: prefix of the Patroni SSL related setting name. Currently, supports these:
|
||||
|
||||
* ``cert``: gets translated to ``certfile``
|
||||
* ``key``: gets translated to ``keyfile``
|
||||
|
||||
Will attempt to fetch the requested key first from ``ctl`` section, and fall back to ``restapi`` section
|
||||
if the former is missing.
|
||||
Will attempt to fetch the requested key first from ``ctl`` section.
|
||||
|
||||
:returns: value of ``ctl -> *name*file`` or ``restapi -> *name*file`` if either is present, ``None`` otherwise.
|
||||
:returns: value of ``ctl.*name*file`` if present, ``None`` otherwise.
|
||||
"""
|
||||
value = self._get_cfg_value(config, name + 'file')
|
||||
value = self._get_ctl_value(config, name + 'file')
|
||||
self._apply_pool_param(name + '_file', value)
|
||||
return value
|
||||
|
||||
@@ -79,37 +101,39 @@ class PatroniRequest(object):
|
||||
|
||||
Configure these HTTP headers for requests:
|
||||
|
||||
* ``authorization``: based on Patroni' REST API authentication config;
|
||||
* ``user-agent``: based on `patroni.utils.USER_AGENT`.
|
||||
* ``authorization``: based on Patroni' CTL or REST API authentication config;
|
||||
* ``user-agent``: based on ``patroni.utils.USER_AGENT``.
|
||||
|
||||
Also configure SSL related settings for requests:
|
||||
|
||||
* ``ca_certs`` is configured if ``ctl -> cacert`` or ``restapi -> cafile`` is available;
|
||||
* ``cert``, ``key`` and ``key_password`` are configured if ``ctl -> certile`` or ``restapi -> certfile`` is
|
||||
available.
|
||||
* ``ca_certs`` is configured if ``ctl.cacert`` or ``restapi.cafile`` is available;
|
||||
* ``cert``, ``key`` and ``key_password`` are configured if ``ctl.certfile`` is available.
|
||||
|
||||
:param config: Patroni YAML configuration.
|
||||
"""
|
||||
# ``restapi -> auth`` is equivalent to ``restapi -> authentication -> username`` + ``:`` +
|
||||
# ``restapi -> authentication -> password``
|
||||
self._pool.headers = urllib3.make_headers(basic_auth=self._get_cfg_value(config, 'auth'), user_agent=USER_AGENT)
|
||||
# ``ctl -> auth`` is equivalent to ``ctl -> authentication -> username`` + ``:`` +
|
||||
# ``ctl -> authentication -> password``. And the same for ``restapi -> auth``
|
||||
basic_auth = self._get_ctl_value(config, 'auth') or self._get_restapi_value(config, 'auth')
|
||||
self._pool.headers = urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
|
||||
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_REQUIRED'
|
||||
|
||||
insecure = self._insecure if isinstance(self._insecure, bool)\
|
||||
else self._get_ctl_value(config, 'insecure', False)
|
||||
|
||||
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
|
||||
if insecure: # The assert_hostname = False helps to silence warnings
|
||||
self._pool.connection_pool_kw['assert_hostname'] = False
|
||||
|
||||
self._apply_ssl_file_param(config, 'key')
|
||||
|
||||
password = self._get_cfg_value(config, 'keyfile_password')
|
||||
password = self._get_ctl_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'
|
||||
if insecure: # Disable server certificate validation if requested
|
||||
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_NONE'
|
||||
self._pool.connection_pool_kw.pop('assert_hostname', None)
|
||||
self._pool.connection_pool_kw.pop('key_file', None)
|
||||
|
||||
cacert = config.get('ctl', {}).get('cacert') or config.get('restapi', {}).get('cafile')
|
||||
cacert = self._get_ctl_value(config, 'cacert') or self._get_restapi_value(config, 'cafile')
|
||||
self._apply_pool_param('ca_certs', cacert)
|
||||
|
||||
def request(self, method: str, url: str, body: Optional[Any] = None,
|
||||
|
||||
+111
-73
@@ -129,8 +129,8 @@ def parse_bool(value: Any) -> Union[bool, None]:
|
||||
.. note::
|
||||
|
||||
The parsing is case-insensitive, and takes into consideration these values:
|
||||
* ``on``, ``true``, ``yes``, and ``1`` as ``True``.
|
||||
* ``off``, ``false``, ``no``, and ``0`` as ``False``.
|
||||
* ``on``, ``true``, ``yes``, and ``1`` as ``True``.
|
||||
* ``off``, ``false``, ``no``, and ``0`` as ``False``.
|
||||
|
||||
:param value: value to be parsed to :class:`bool`.
|
||||
|
||||
@@ -245,14 +245,16 @@ def convert_to_base_unit(value: Union[int, float], unit: str, base_unit: Optiona
|
||||
"""Convert *value* as a *unit* of compute information or time to *base_unit*.
|
||||
|
||||
:param value: value to be converted to the base unit.
|
||||
:param unit: unit of *value*. Accepts these units (case sensitive)
|
||||
* For space: ``B``, ``kB``, ``MB``, ``GB``, or ``TB``;
|
||||
* For time: ``d``, ``h``, ``min``, ``s``, ``ms``, or ``us``.
|
||||
:param unit: unit of *value*. Accepts these units (case sensitive):
|
||||
|
||||
* For space: ``B``, ``kB``, ``MB``, ``GB``, or ``TB``;
|
||||
* For time: ``d``, ``h``, ``min``, ``s``, ``ms``, or ``us``.
|
||||
|
||||
:param base_unit: target unit in the conversion. May contain the target unit with an associated value, e.g
|
||||
``512MB``. Accepts these units (case sensitive)
|
||||
* For space: ``B``, ``kB``, or ``MB``;
|
||||
* For time: ``ms``, ``s``, or ``min``.
|
||||
``512MB``. Accepts these units (case sensitive):
|
||||
|
||||
* For space: ``B``, ``kB``, or ``MB``;
|
||||
* For time: ``ms``, ``s``, or ``min``.
|
||||
|
||||
:returns: *value* in *unit* converted to *base_unit*. Returns ``None`` if *unit* or *base_unit* is invalid.
|
||||
|
||||
@@ -402,7 +404,8 @@ def compare_values(vartype: str, unit: Optional[str], old_value: Any, new_value:
|
||||
"""Check if *old_value* and *new_value* are equivalent after parsing them as *vartype*.
|
||||
|
||||
:param vartpe: the target type to parse *old_value* and *new_value* before comparing them. Accepts any among of the
|
||||
following (case sensitive)
|
||||
following (case sensitive):
|
||||
|
||||
* ``bool``: parse values using :func:`parse_bool`; or
|
||||
* ``integer``: parse values using :func:`parse_int`; or
|
||||
* ``real``: parse values using :func:`parse_real`; or
|
||||
@@ -459,7 +462,7 @@ def compare_values(vartype: str, unit: Optional[str], old_value: Any, new_value:
|
||||
|
||||
|
||||
def _sleep(interval: Union[int, float]) -> None:
|
||||
"""Wrap :func:`time.sleep`.
|
||||
"""Wrap :func:`~time.sleep`.
|
||||
|
||||
:param interval: Delay execution for a given number of seconds. The argument may be a floating point number for
|
||||
subsecond precision.
|
||||
@@ -536,6 +539,7 @@ class Retry(object):
|
||||
"""Set next cycle delay.
|
||||
|
||||
It will be the minimum value between:
|
||||
|
||||
* current delay with ``backoff``; or
|
||||
* ``max_delay``.
|
||||
"""
|
||||
@@ -549,10 +553,14 @@ class Retry(object):
|
||||
def ensure_deadline(self, timeout: float, raise_ex: Optional[Exception] = None) -> bool:
|
||||
"""Calculates, sets, and checks the remaining deadline time.
|
||||
|
||||
:param timeout: if the *deadline* is smaller than the provided *timeout* value raise *raise_ex* exception
|
||||
:param raise_ex: the exception object that will be raised if the *deadline* is smaller than provided *timeout*
|
||||
:returns: `False` if *deadline* is smaller than a provided *timeout* and *raise_ex* isn't set. Otherwise `True`
|
||||
:raises Exception: if calculated deadline is smaller than provided *timeout*
|
||||
:param timeout: if the *deadline* is smaller than the provided *timeout* value raise *raise_ex* exception.
|
||||
:param raise_ex: the exception object that will be raised if the *deadline* is smaller than provided *timeout*.
|
||||
|
||||
:returns: ``False`` if *deadline* is smaller than a provided *timeout* and *raise_ex* isn't set. Otherwise
|
||||
``True``.
|
||||
|
||||
:raises:
|
||||
:class:`Exception`: *raise_ex* if calculated deadline is smaller than provided *timeout*.
|
||||
"""
|
||||
self.deadline = self.stoptime - time.time()
|
||||
if self.deadline < timeout:
|
||||
@@ -565,9 +573,10 @@ class Retry(object):
|
||||
"""Call a function *func* with arguments ``*args`` and ``*kwargs`` in a loop.
|
||||
|
||||
*func* will be called until one of the following conditions is met:
|
||||
* It completes without throwing one of the configured ``retry_exceptions``; or
|
||||
* ``max_retries`` is exceeded.; or
|
||||
* ``deadline`` is exceeded.
|
||||
|
||||
* It completes without throwing one of the configured ``retry_exceptions``; or
|
||||
* ``max_retries`` is exceeded.; or
|
||||
* ``deadline`` is exceeded.
|
||||
|
||||
.. note::
|
||||
* It will set loop stop time based on ``deadline`` attribute.
|
||||
@@ -576,9 +585,10 @@ class Retry(object):
|
||||
:param func: function to call.
|
||||
:param args: positional arguments to call *func* with.
|
||||
:params kwargs: keyword arguments to call *func* with.
|
||||
:raises :class:`RetryFailedError`
|
||||
* If ``max_tries`` is exceeded; or
|
||||
* If ``deadline`` is exceeded.
|
||||
:raises:
|
||||
:class:`RetryFailedError`:
|
||||
* If ``max_tries`` is exceeded; or
|
||||
* If ``deadline`` is exceeded.
|
||||
"""
|
||||
self.reset()
|
||||
|
||||
@@ -613,7 +623,8 @@ def polling_loop(timeout: Union[int, float], interval: Union[int, float] = 1) ->
|
||||
|
||||
:param timeout: for how long (in seconds) from now it should keep returning values.
|
||||
:param interval: for how long to sleep before returning a new value.
|
||||
:rtype: Iterator[:class:`int`] with current iteration counter, starting from ``0``.
|
||||
|
||||
:yields: current iteration counter, starting from ``0``.
|
||||
"""
|
||||
start_time = time.time()
|
||||
iteration = 0
|
||||
@@ -627,14 +638,16 @@ def polling_loop(timeout: Union[int, float], interval: Union[int, float] = 1) ->
|
||||
def split_host_port(value: str, default_port: Optional[int]) -> Tuple[str, int]:
|
||||
"""Extract host(s) and port from *value*.
|
||||
|
||||
:param value: string from where host(s) and port will be extracted. Accepts either of these formats
|
||||
* ``host:port``; or
|
||||
* ``host1,host2,...,hostn:port``.
|
||||
:param value: string from where host(s) and port will be extracted. Accepts either of these formats:
|
||||
|
||||
* ``host:port``; or
|
||||
* ``host1,host2,...,hostn:port``.
|
||||
|
||||
Each ``host`` portion of *value* can be either:
|
||||
* A FQDN; or
|
||||
* An IPv4 address; or
|
||||
* An IPv6 address, with or without square brackets.
|
||||
|
||||
* A FQDN; or
|
||||
* An IPv4 address; or
|
||||
* An IPv6 address, with or without square brackets.
|
||||
|
||||
:param default_port: if no port can be found in *param*, use *default_port* instead.
|
||||
|
||||
@@ -669,18 +682,23 @@ def uri(proto: str, netloc: Union[List[str], Tuple[str, Union[int, str]], str],
|
||||
|
||||
:param proto: the URI protocol.
|
||||
:param netloc: the URI host(s) and port. Can be specified in either way among
|
||||
|
||||
* A :class:`list` or :class:`tuple`. The second item should be a port, and the first item should be composed of
|
||||
hosts in either of these formats:
|
||||
|
||||
* ``host``; or.
|
||||
* ``host1,host2,...,hostn``.
|
||||
|
||||
* A :class:`str` in either of these formats:
|
||||
|
||||
* ``host:port``; or
|
||||
* ``host1,host2,...,hostn:port``.
|
||||
|
||||
In all cases, each ``host`` portion of *netloc* can be either:
|
||||
* An FQDN; or
|
||||
* An IPv4 address; or
|
||||
* An IPv6 address, with or without square brackets.
|
||||
|
||||
* An FQDN; or
|
||||
* An IPv4 address; or
|
||||
* An IPv6 address, with or without square brackets.
|
||||
|
||||
:param path: the URI path.
|
||||
:param user: the authenticating user, if any.
|
||||
@@ -698,10 +716,11 @@ def uri(proto: str, netloc: Union[List[str], Tuple[str, Union[int, str]], str],
|
||||
|
||||
|
||||
def iter_response_objects(response: HTTPResponse) -> Iterator[Dict[str, Any]]:
|
||||
"""Iterate over the chunks of a :class:`HTTPResponse` and yield each JSON document that is found along the way.
|
||||
"""Iterate over the chunks of a :class:`~urllib3.response.HTTPResponse` and yield each JSON document that is found.
|
||||
|
||||
:param response: the HTTP response from which JSON documents will be retrieved.
|
||||
:rtype: Iterator[:class:`dict`] with current JSON document.
|
||||
|
||||
:yields: current JSON document.
|
||||
"""
|
||||
prev = ''
|
||||
decoder = JSONDecoder()
|
||||
@@ -730,33 +749,36 @@ def iter_response_objects(response: HTTPResponse) -> Iterator[Dict[str, Any]]:
|
||||
def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig'] = None) -> Dict[str, Any]:
|
||||
"""Get a JSON representation of *cluster*.
|
||||
|
||||
:param cluster: the :class:`Cluster` object to be parsed as JSON.
|
||||
:param global_config: optional :class:`GlobalConfig` object to check the cluster state.
|
||||
:param cluster: the :class:`~patroni.dcs.Cluster` object to be parsed as JSON.
|
||||
:param global_config: optional :class:`~patroni.config.GlobalConfig` object to check the cluster state.
|
||||
if not provided will be instantiated from the `Cluster.config`.
|
||||
|
||||
:returns: JSON representation of *cluster*.
|
||||
|
||||
These are the possible keys in the returning object depending on the available information in *cluster*:
|
||||
|
||||
* ``members``: list of members in the cluster. Each value is a :class:`dict` that may have the following keys:
|
||||
* ``name``: the name of the host (unique in the cluster). The ``members`` list is sorted by this key;
|
||||
* ``role``: ``leader``, ``standby_leader``, ``sync_standby``, or ``replica``;
|
||||
* ``state``: ``stopping``, ``stopped``, ``stop failed``, ``crashed``, ``running``, ``starting``,
|
||||
``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``, ``initdb failed``,
|
||||
``running custom bootstrap script``, ``custom bootstrap failed``, or ``creating replica``;
|
||||
* ``api_url``: REST API URL based on ``restapi->connect_address`` configuration;
|
||||
* ``host``: PostgreSQL host based on ``postgresql->connect_address``;
|
||||
* ``port``: PostgreSQL port based on ``postgresql->connect_address``;
|
||||
* ``timeline``: PostgreSQL current timeline;
|
||||
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
|
||||
* ``scheduled_restart``: scheduled restart timestamp, if any;
|
||||
* ``tags``: any tags that were set for this member;
|
||||
* ``lag``: replication lag, if applicable;
|
||||
* ``pause``: ``True`` if cluster is in maintenance mode;
|
||||
* ``scheduled_switchover``: if a switchover has been scheduled, then it contains this entry with these keys:
|
||||
* ``at``: timestamp when switchover was scheduled to occur;
|
||||
* ``from``: name of the member to be demoted;
|
||||
* ``to``: name of the member to be promoted.
|
||||
* ``members``: list of members in the cluster. Each value is a :class:`dict` that may have the following keys:
|
||||
|
||||
* ``name``: the name of the host (unique in the cluster). The ``members`` list is sorted by this key;
|
||||
* ``role``: ``leader``, ``standby_leader``, ``sync_standby``, or ``replica``;
|
||||
* ``state``: ``stopping``, ``stopped``, ``stop failed``, ``crashed``, ``running``, ``starting``,
|
||||
``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``, ``initdb failed``,
|
||||
``running custom bootstrap script``, ``custom bootstrap failed``, or ``creating replica``;
|
||||
* ``api_url``: REST API URL based on ``restapi->connect_address`` configuration;
|
||||
* ``host``: PostgreSQL host based on ``postgresql->connect_address``;
|
||||
* ``port``: PostgreSQL port based on ``postgresql->connect_address``;
|
||||
* ``timeline``: PostgreSQL current timeline;
|
||||
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
|
||||
* ``scheduled_restart``: scheduled restart timestamp, if any;
|
||||
* ``tags``: any tags that were set for this member;
|
||||
* ``lag``: replication lag, if applicable;
|
||||
|
||||
* ``pause``: ``True`` if cluster is in maintenance mode;
|
||||
* ``scheduled_switchover``: if a switchover has been scheduled, then it contains this entry with these keys:
|
||||
|
||||
* ``at``: timestamp when switchover was scheduled to occur;
|
||||
* ``from``: name of the member to be demoted;
|
||||
* ``to``: name of the member to be promoted.
|
||||
"""
|
||||
if not global_config:
|
||||
from patroni.config import get_global_config
|
||||
@@ -832,15 +854,18 @@ def validate_directory(d: str, msg: str = "{} {}") -> None:
|
||||
If the directory does not exist, :func:`validate_directory` will attempt to create it.
|
||||
|
||||
:param d: the directory to be checked.
|
||||
:param msg: a message to be thrown when raising :class:`PatroniException`, if any issue is faced. It must contain
|
||||
2 placeholders to be used by :func:`format`:
|
||||
* The first placeholder will be replaced with path *d*;
|
||||
* The second placeholder will be replaced with the error condition.
|
||||
:param msg: a message to be thrown when raising :class:`~patroni.exceptions.PatroniException`, if any issue is
|
||||
faced. It must contain 2 placeholders to be used by :func:`format`:
|
||||
|
||||
:raises :class:`PatroniException`: if any issue is observed while validating *d*. Can be thrown in these situations
|
||||
* *d* did not exist, and :func:`validate_directory` was not able to create it; or
|
||||
* *d* is an existing directory, but Patroni is not able to write to that directory; or
|
||||
* *d* is an existing file, not a directory.
|
||||
* The first placeholder will be replaced with path *d*;
|
||||
* The second placeholder will be replaced with the error condition.
|
||||
|
||||
:raises:
|
||||
:class:`~patroni.exceptions.PatroniException`: if any issue is observed while validating *d*. Can be thrown if:
|
||||
|
||||
* *d* did not exist, and :func:`validate_directory` was not able to create it; or
|
||||
* *d* is an existing directory, but Patroni is not able to write to that directory; or
|
||||
* *d* is an existing file, not a directory.
|
||||
"""
|
||||
if not os.path.exists(d):
|
||||
try:
|
||||
@@ -895,13 +920,22 @@ def keepalive_socket_options(timeout: int, idle: int, cnt: int = 3) -> Iterator[
|
||||
:param idle: value for ``TCP_KEEPIDLE``.
|
||||
:param cnt: value for ``TCP_KEEPCNT``.
|
||||
|
||||
:rtype: Iterator[Tuple[:class:`int`, :class:`int`, :class:`int`]] of all keepalive related socket options to be
|
||||
set. The first item in the tuple is the protocol, the second item is the option, and the third item is the
|
||||
value to be used. The return values depend on the platform:
|
||||
* ``Windows``: yield ``SO_KEEPALIVE``;
|
||||
* ``Linux``: yield ``SO_KEEPALIVE``, ``TCP_USER_TIMEOUT``, ``TCP_KEEPIDLE`, ``TCP_KEEPINTVL``, and
|
||||
``TCP_KEEPCNT``;
|
||||
* ``MacOS``: yield ``SO_KEEPALIVE``, ``TCP_KEEPIDLE`, ``TCP_KEEPINTVL``, and ``TCP_KEEPCNT``
|
||||
:yields: all keepalive related socket options to be set. The first item in the tuple is the protocol, the second
|
||||
item is the option, and the third item is the value to be used. The return values depend on the platform:
|
||||
|
||||
* ``Windows``:
|
||||
* ``SO_KEEPALIVE``.
|
||||
* ``Linux``:
|
||||
* ``SO_KEEPALIVE``;
|
||||
* ``TCP_USER_TIMEOUT``;
|
||||
* ``TCP_KEEPIDLE``;
|
||||
* ``TCP_KEEPINTVL``;
|
||||
* ``TCP_KEEPCNT``.
|
||||
* ``MacOS``:
|
||||
* ``SO_KEEPALIVE``;
|
||||
* ``TCP_KEEPIDLE``;
|
||||
* ``TCP_KEEPINTVL``;
|
||||
* ``TCP_KEEPCNT``.
|
||||
"""
|
||||
yield (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
|
||||
@@ -939,7 +973,7 @@ def enable_keepalive(sock: socket.socket, timeout: int, idle: int, cnt: int = 3)
|
||||
:param idle: value for ``TCP_KEEPIDLE``.
|
||||
:param cnt: value for ``TCP_KEEPCNT``.
|
||||
|
||||
:returns: output of :func:`socket.ioctl` if we are on Windows, nothing otherwise.
|
||||
:returns: output of :func:`~socket.ioctl` if we are on Windows, nothing otherwise.
|
||||
"""
|
||||
SIO_KEEPALIVE_VALS = getattr(socket, 'SIO_KEEPALIVE_VALS', None)
|
||||
if SIO_KEEPALIVE_VALS is not None: # Windows
|
||||
@@ -953,23 +987,27 @@ def enable_keepalive(sock: socket.socket, timeout: int, idle: int, cnt: int = 3)
|
||||
def unquote(string: str) -> str:
|
||||
"""Unquote a fully quoted *string*.
|
||||
|
||||
:param string: The string to be checked for quoting.
|
||||
|
||||
:returns: The string with quotes removed, if it is a fully quoted single string, or the original string if quoting
|
||||
is not detected, or unquoting was not possible.
|
||||
|
||||
:Examples:
|
||||
|
||||
A *string* with quotes will have those quotes removed
|
||||
|
||||
>>> unquote('"a quoted string"')
|
||||
'a quoted string'
|
||||
|
||||
A *string* with multiple quotes will be returned as is
|
||||
|
||||
>>> unquote('"a multi" "quoted string"')
|
||||
'"a multi" "quoted string"'
|
||||
|
||||
So will a *string* with unbalanced quotes
|
||||
|
||||
>>> unquote('unbalanced "quoted string')
|
||||
'unbalanced "quoted string'
|
||||
|
||||
:param string: The string to be checked for quoting.
|
||||
:returns: The string with quotes removed, if it is a fully quoted single string,
|
||||
or the original string if quoting is not detected, or unquoting was not possible.
|
||||
"""
|
||||
try:
|
||||
ret = split(string)
|
||||
|
||||
+247
-89
@@ -3,7 +3,7 @@
|
||||
|
||||
This module contains facilities for validating configuration of Patroni processes.
|
||||
|
||||
:var schema: configuration schema of the daemon launched by `patroni` command.
|
||||
:var schema: configuration schema of the daemon launched by ``patroni`` command.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
@@ -11,17 +11,19 @@ import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, TYPE_CHECKING
|
||||
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING
|
||||
|
||||
from .utils import parse_int, split_host_port, data_directory_is_empty
|
||||
from .collections import CaseInsensitiveSet
|
||||
from .dcs import dcs_modules
|
||||
from .exceptions import ConfigParseError
|
||||
from .utils import parse_int, split_host_port, data_directory_is_empty
|
||||
|
||||
|
||||
def data_directory_empty(data_dir: str) -> bool:
|
||||
"""Check if PostgreSQL data directory is empty.
|
||||
|
||||
:param data_dir: path to the PostgreSQL data directory to be checked.
|
||||
|
||||
:returns: ``True`` if the data directory is empty.
|
||||
"""
|
||||
if os.path.isfile(os.path.join(data_dir, "global", "pg_control")):
|
||||
@@ -32,12 +34,14 @@ def data_directory_empty(data_dir: str) -> bool:
|
||||
def validate_connect_address(address: str) -> bool:
|
||||
"""Check if options related to connection address were properly configured.
|
||||
|
||||
:param address: address to be validated in the format
|
||||
``host:ip``.
|
||||
:param address: address to be validated in the format ``host:ip``.
|
||||
|
||||
:returns: ``True`` if the address is valid.
|
||||
:raises :class:`patroni.exceptions.ConfigParseError`:
|
||||
* If the address is not in the expected format; or
|
||||
* If the host is set to not allowed values (``127.0.0.1``, ``0.0.0.0``, ``*``, ``::1``, or ``localhost``).
|
||||
|
||||
:raises:
|
||||
:class:`~patroni.exceptions.ConfigParseError`:
|
||||
* If the address is not in the expected format; or
|
||||
* If the host is set to not allowed values (``127.0.0.1``, ``0.0.0.0``, ``*``, ``::1``, or ``localhost``).
|
||||
"""
|
||||
try:
|
||||
host, _ = split_host_port(address, 1)
|
||||
@@ -51,20 +55,25 @@ def validate_connect_address(address: str) -> bool:
|
||||
def validate_host_port(host_port: str, listen: bool = False, multiple_hosts: bool = False) -> bool:
|
||||
"""Check if host(s) and port are valid and available for usage.
|
||||
|
||||
:param host_port: the host(s) and port to be validated. It can be in either of these formats
|
||||
:param host_port: the host(s) and port to be validated. It can be in either of these formats:
|
||||
|
||||
* ``host:ip``, if *multiple_hosts* is ``False``; or
|
||||
* ``host_1,host_2,...,host_n:port``, if *multiple_hosts* is ``True``.
|
||||
|
||||
:param listen: if the address is expected to be available for binding. ``False`` means it expects to connect to that
|
||||
address, and ``True`` that it expects to bind to that address.
|
||||
:param multiple_hosts: if *host_port* can contain multiple hosts.
|
||||
|
||||
:returns: ``True`` if the host(s) and port are valid.
|
||||
:raises: :class:`patroni.exceptions.ConfigParserError`:
|
||||
* If the *host_port* is not in the expected format; or
|
||||
* If ``*`` was specified along with more hosts in *host_port*; or
|
||||
* If we are expecting to bind to an address that is already in use; or
|
||||
* If we are not able to connect to an address that we are expecting to do so; or
|
||||
* If :class:`socket.gaierror` is thrown by socket module when attempting to connect to the given address(es).
|
||||
|
||||
:raises:
|
||||
:class:`~patroni.exceptions.ConfigParseError`:
|
||||
* If the *host_port* is not in the expected format; or
|
||||
* If ``*`` was specified along with more hosts in *host_port*; or
|
||||
* If we are expecting to bind to an address that is already in use; or
|
||||
* If we are not able to connect to an address that we are expecting to do so; or
|
||||
* If :class:`~socket.gaierror` is thrown by socket module when attempting to connect to the given
|
||||
address(es).
|
||||
"""
|
||||
try:
|
||||
hosts, port = split_host_port(host_port, 1)
|
||||
@@ -104,6 +113,7 @@ def validate_host_port_list(value: List[str]) -> bool:
|
||||
Call :func:`validate_host_port` with each item in *value*.
|
||||
|
||||
:param value: list of host(s) and port items to be validated.
|
||||
|
||||
:returns: ``True`` if all items are valid.
|
||||
"""
|
||||
assert all([validate_host_port(v) for v in value]), "didn't pass the validation"
|
||||
@@ -116,6 +126,7 @@ def comma_separated_host_port(string: str) -> bool:
|
||||
Call :func:`validate_host_port_list` with a list represented by the CSV *string*.
|
||||
|
||||
:param string: comma-separated list of host and port items.
|
||||
|
||||
:returns: ``True`` if all items in the CSV string are valid.
|
||||
"""
|
||||
return validate_host_port_list([s.strip() for s in string.split(",")])
|
||||
@@ -127,7 +138,7 @@ def validate_host_port_listen(host_port: str) -> bool:
|
||||
Call :func:`validate_host_port` with *listen* set to ``True``.
|
||||
|
||||
:param host_port: the host and port to be validated. Must be in the format
|
||||
`host:ip`.
|
||||
``host:ip``.
|
||||
|
||||
:returns: ``True`` if the host and port are valid and available for binding.
|
||||
"""
|
||||
@@ -140,8 +151,9 @@ def validate_host_port_listen_multiple_hosts(host_port: str) -> bool:
|
||||
Call :func:`validate_host_port` with both *listen* and *multiple_hosts* set to ``True``.
|
||||
|
||||
:param host_port: the host(s) and port to be validated. It can be in either of these formats
|
||||
* `host:ip`; or
|
||||
* `host_1,host_2,...,host_n:port`
|
||||
|
||||
* ``host:ip``; or
|
||||
* ``host_1,host_2,...,host_n:port``
|
||||
|
||||
:returns: ``True`` if the host(s) and port are valid and available for binding.
|
||||
"""
|
||||
@@ -152,8 +164,11 @@ def is_ipv4_address(ip: str) -> bool:
|
||||
"""Check if *ip* is a valid IPv4 address.
|
||||
|
||||
:param ip: the IP to be checked.
|
||||
|
||||
:returns: ``True`` if the IP is an IPv4 address.
|
||||
:raises :class:`patroni.exceptions.ConfigParserError`: if *ip* is not a valid IPv4 address.
|
||||
|
||||
:raises:
|
||||
:class:`~patroni.exceptions.ConfigParseError`: if *ip* is not a valid IPv4 address.
|
||||
"""
|
||||
try:
|
||||
socket.inet_aton(ip)
|
||||
@@ -166,8 +181,11 @@ def is_ipv6_address(ip: str) -> bool:
|
||||
"""Check if *ip* is a valid IPv6 address.
|
||||
|
||||
:param ip: the IP to be checked.
|
||||
|
||||
:returns: ``True`` if the IP is an IPv6 address.
|
||||
:raises :class:`patroni.exceptions.ConfigParserError`: if *ip* is not a valid IPv6 address.
|
||||
|
||||
:raises:
|
||||
:class:`~patroni.exceptions.ConfigParseError`: if *ip* is not a valid IPv6 address.
|
||||
"""
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, ip)
|
||||
@@ -221,14 +239,17 @@ def validate_data_dir(data_dir: str) -> bool:
|
||||
* Point to a non-empty directory that seems to contain a valid PostgreSQL data directory.
|
||||
|
||||
:param data_dir: the value of ``postgresql.data_dir`` configuration option.
|
||||
|
||||
:returns: ``True`` if the PostgreSQL data directory is valid.
|
||||
:raises :class:`patroni.exceptions.ConfigParserError`:
|
||||
* If no *data_dir* was given; or
|
||||
* If *data_dir* is a file and not a directory; or
|
||||
* If *data_dir* is a non-empty directory and:
|
||||
* ``PG_VERSION`` file is not available in the directory
|
||||
* ``pg_wal``/``pg_xlog`` is not available in the directory
|
||||
* ``PG_VERSION`` content does not match the major version reported by ``postgres --version``
|
||||
|
||||
:raises:
|
||||
:class:`~patroni.exceptions.ConfigParseError`:
|
||||
* If no *data_dir* was given; or
|
||||
* If *data_dir* is a file and not a directory; or
|
||||
* If *data_dir* is a non-empty directory and:
|
||||
* ``PG_VERSION`` file is not available in the directory
|
||||
* ``pg_wal``/``pg_xlog`` is not available in the directory
|
||||
* ``PG_VERSION`` content does not match the major version reported by ``postgres --version``
|
||||
"""
|
||||
if not data_dir:
|
||||
raise ConfigParseError("is an empty string")
|
||||
@@ -269,11 +290,12 @@ def validate_binary_name(bin_name: str) -> bool:
|
||||
|
||||
:returns: ``True`` if the conditions are true
|
||||
|
||||
:raises :class:`patroni.exceptions.ConfigParserError`: if:
|
||||
* *bin_name* is not set; or
|
||||
* the path join of the ``postgresql.bin_dir`` plus *bin_name* does not exist; or
|
||||
* the path join as above is not executable; or
|
||||
* the *bin_name* cannot be found in the system PATH
|
||||
:raises:
|
||||
:class:`~patroni.exceptions.ConfigParseError` if:
|
||||
* *bin_name* is not set; or
|
||||
* the path join of the ``postgresql.bin_dir`` plus *bin_name* does not exist; or
|
||||
* the path join as above is not executable; or
|
||||
* the *bin_name* cannot be found in the system PATH
|
||||
|
||||
"""
|
||||
if not bin_name:
|
||||
@@ -300,7 +322,7 @@ class Result(object):
|
||||
|
||||
.. note::
|
||||
|
||||
``error`` attribute is only set if ``status`` is failed.
|
||||
``error`` attribute is only set if *status* is failed.
|
||||
|
||||
:param status: if the validation succeeded.
|
||||
:param error: error message related to the validation that was performed, if the validation failed.
|
||||
@@ -347,8 +369,8 @@ class Case(object):
|
||||
"url": str,
|
||||
})
|
||||
|
||||
That will check that ``host`` configuration, if given, is valid based on ``validate_host_port`` function, and
|
||||
will also check that ``url`` configuration, if given, is a ``str`` instance.
|
||||
That will check that ``host`` configuration, if given, is valid based on :func:`validate_host_port`, and will
|
||||
also check that ``url`` configuration, if given, is a ``str`` instance.
|
||||
"""
|
||||
self._schema = schema
|
||||
|
||||
@@ -374,7 +396,7 @@ class Or(object):
|
||||
|
||||
The outer :class:`Or` is used to define that ``host`` and ``hosts`` are possible options in this scope.
|
||||
The inner :class`Or` in the ``hosts`` key value is used to define that ``hosts`` option is valid if either of
|
||||
the functions ``comma_separated_host_port`` or ``validate_host_port`` succeed to validate it.
|
||||
:func:`comma_separated_host_port` or :func:`validate_host_port` succeed to validate it.
|
||||
"""
|
||||
self.args = args
|
||||
|
||||
@@ -416,12 +438,12 @@ class Directory(object):
|
||||
self.contains_executable = contains_executable
|
||||
|
||||
def _check_executables(self, path: OptionalType[str] = None) -> Iterator[Result]:
|
||||
"""Check that all executables from contains_executable list exist within the given directory or within PATH.
|
||||
"""Check that all executables from contains_executable list exist within the given directory or within ``PATH``.
|
||||
|
||||
:param path: optional path to the base directory against which executables will be validated.
|
||||
If not provided, check within PATH.
|
||||
:rtype: Iterator[:class:`Result`] objects with the error message containing the name of the executable,
|
||||
if any check fails.
|
||||
If not provided, check within ``PATH``.
|
||||
|
||||
:yields: objects with the error message containing the name of the executable, if any check fails.
|
||||
"""
|
||||
for program in self.contains_executable or []:
|
||||
if not shutil.which(program, path=path):
|
||||
@@ -431,8 +453,9 @@ class Directory(object):
|
||||
"""Check if the expected paths and executables can be found under *name* directory.
|
||||
|
||||
:param name: path to the base directory against which paths and executables will be validated.
|
||||
Check against PATH if name is not provided.
|
||||
:rtype: Iterator[:class:`Result`] objects with the error message related to the failure, if any check fails.
|
||||
Check against ``PATH`` if name is not provided.
|
||||
|
||||
:yields: objects with the error message related to the failure, if any check fails.
|
||||
"""
|
||||
if not name:
|
||||
yield from self._check_executables()
|
||||
@@ -480,12 +503,13 @@ class Schema(object):
|
||||
be performed against each one of them. The validations will be performed whenever the :class:`Schema` object is
|
||||
called, or its :func:`validate` method is called.
|
||||
|
||||
:ivar validator: validator of the configuration schema. Can be any of these
|
||||
:ivar validator: validator of the configuration schema. Can be any of these:
|
||||
|
||||
* :class:`str`: defines that a string value is required; or
|
||||
* :class:`type`: any subclass of `type`, defines that a value of the given type is required; or
|
||||
* `callable`: any callable object, defines that validation will follow the code defined in the callable
|
||||
object. If the callable object contains an ``expected_type`` attribute, then it will check if the
|
||||
configuration value is of the expected type before calling the code of the callable object; or
|
||||
* :class:`type`: any subclass of :class:`type`, defines that a value of the given type is required; or
|
||||
* ``callable``: any callable object, defines that validation will follow the code defined in the callable
|
||||
object. If the callable object contains an ``expected_type`` attribute, then it will check if the
|
||||
configuration value is of the expected type before calling the code of the callable object; or
|
||||
* :class:`list`: list representing one or more values in the configuration; or
|
||||
* :class:`dict`: dictionary representing the YAML configuration tree.
|
||||
"""
|
||||
@@ -502,11 +526,12 @@ class Schema(object):
|
||||
nodes, when it performs checks of the actual setting values.
|
||||
|
||||
:param validator: validator of the configuration schema. Can be any of these:
|
||||
|
||||
* :class:`str`: defines that a string value is required; or
|
||||
* :class:`type`: any subclass of :class:`type`, defines that a value of the given type is required; or
|
||||
* `callable`: Any callable object, defines that validation will follow the code defined in the callable
|
||||
object. If the callable object contains an ``expected_type`` attribute, then it will check if the
|
||||
configuration value is of the expected type before calling the code of the callable object; or
|
||||
* ``callable``: Any callable object, defines that validation will follow the code defined in the callable
|
||||
object. If the callable object contains an ``expected_type`` attribute, then it will check if the
|
||||
configuration value is of the expected type before calling the code of the callable object; or
|
||||
* :class:`list`: list representing it expects to contain one or more values in the configuration; or
|
||||
* :class:`dict`: dictionary representing the YAML configuration tree.
|
||||
|
||||
@@ -514,18 +539,22 @@ class Schema(object):
|
||||
to stop.
|
||||
|
||||
If *validator* is a :class:`dict`, then you should follow these rules:
|
||||
|
||||
* For the keys it can be either:
|
||||
|
||||
* A :class:`str` instance. It will be the name of the configuration option; or
|
||||
* An :class:`Optional` instance. The ``name`` attribute of that object will be the name of the
|
||||
configuration option, and that class makes this configuration option as optional to the
|
||||
user, allowing it to not be specified in the YAML; or
|
||||
configuration option, and that class makes this configuration option as optional to the
|
||||
user, allowing it to not be specified in the YAML; or
|
||||
* An :class:`Or` instance. The ``args`` attribute of that object will contain a tuple of
|
||||
configuration option names. At least one of them should be specified by the user in the YAML;
|
||||
|
||||
* For the values it can be either:
|
||||
|
||||
* A new :class:`dict` instance. It will represent a new level in the YAML configuration tree; or
|
||||
* A :class:`Case` instance. This is required if the key of this value is an :class:`Or` instance,
|
||||
and the :class:`Case` instance is used to map each of the ``args`` in :class:`Or` to their
|
||||
corresponding base validator in :class:`Case`; or
|
||||
and the :class:`Case` instance is used to map each of the ``args`` in :class:`Or` to their
|
||||
corresponding base validator in :class:`Case`; or
|
||||
* An :class:`Or` instance with one or more base validators; or
|
||||
* A :class:`list` instance with a single item which is the base validator; or
|
||||
* A base validator.
|
||||
@@ -548,15 +577,16 @@ class Schema(object):
|
||||
})
|
||||
|
||||
This sample schema defines that your YAML configuration follows these rules:
|
||||
* It must contain an ``application_name`` entry which value should be a :class:`str` instance;
|
||||
* It must contain a ``bind.host`` entry which value should be valid as per function ``validate_host``;
|
||||
* It must contain a ``bind.port`` entry which value should be an :class:`int` instance;
|
||||
* It must contain a ``aliases`` entry which value should be a :class:`list` of :class:`str` instances;
|
||||
* It may optionally contain a ``data_directory`` entry, with a value which should be a string;
|
||||
* It must contain at least one of ``log_to_file`` or ``log_to_db``, with a value which should be a
|
||||
:class:`bool` instance;
|
||||
* It must contain a ``version`` entry which value should be either an :class:`int` or a :class:`float`
|
||||
instance.
|
||||
|
||||
* It must contain an ``application_name`` entry which value should be a :class:`str` instance;
|
||||
* It must contain a ``bind.host`` entry which value should be valid as per function ``validate_host``;
|
||||
* It must contain a ``bind.port`` entry which value should be an :class:`int` instance;
|
||||
* It must contain a ``aliases`` entry which value should be a :class:`list` of :class:`str` instances;
|
||||
* It may optionally contain a ``data_directory`` entry, with a value which should be a string;
|
||||
* It must contain at least one of ``log_to_file`` or ``log_to_db``, with a value which should be a
|
||||
:class:`bool` instance;
|
||||
* It must contain a ``version`` entry which value should be either an :class:`int` or a :class:`float`
|
||||
instance.
|
||||
"""
|
||||
self.validator = validator
|
||||
|
||||
@@ -564,6 +594,7 @@ class Schema(object):
|
||||
"""Perform validation of data using the rules defined in this schema.
|
||||
|
||||
:param data: configuration to be validated against ``validator``.
|
||||
|
||||
:returns: list of errors identified while validating the *data*, if any.
|
||||
"""
|
||||
errors: List[str] = []
|
||||
@@ -578,14 +609,15 @@ class Schema(object):
|
||||
It first checks that *data* argument type is compliant with the type of ``validator`` attribute.
|
||||
|
||||
Additionally:
|
||||
* If ``validator`` attribute is a callable object, calls it to validate *data* argument. Before doing so, if
|
||||
`validator` contains an ``expected_type`` attribute, check if *data* argument is compliant with that
|
||||
expected type.
|
||||
* If ``validator`` attribute is an iterable object (:class:`dict`, :class:`list`, :class:`Directory` or
|
||||
:class:`Or`), then it iterates over it to validate each of the corresponding entries in *data* argument.
|
||||
* If ``validator`` attribute is a callable object, calls it to validate *data* argument. Before doing so, if
|
||||
`validator` contains an ``expected_type`` attribute, check if *data* argument is compliant with that
|
||||
expected type.
|
||||
* If ``validator`` attribute is an iterable object (:class:`dict`, :class:`list`, :class:`Directory` or
|
||||
:class:`Or`), then it iterates over it to validate each of the corresponding entries in *data* argument.
|
||||
|
||||
:param data: configuration to be validated against ``validator``.
|
||||
:rtype: Iterator[:class:`Result`] objects with the error message related to the failure, if any check fails.
|
||||
|
||||
:yields: objects with the error message related to the failure, if any check fails.
|
||||
"""
|
||||
self.data = data
|
||||
|
||||
@@ -626,7 +658,7 @@ class Schema(object):
|
||||
|
||||
Only :class:`dict`, :class:`list`, :class:`Directory` and :class:`Or` objects are considered iterable objects.
|
||||
|
||||
:rtype: Iterator[:class:`Result`] objects with the error message related to the failure, if any check fails.
|
||||
:yields: objects with the error message related to the failure, if any check fails.
|
||||
"""
|
||||
if isinstance(self.validator, dict):
|
||||
if not isinstance(self.data, dict):
|
||||
@@ -654,7 +686,7 @@ class Schema(object):
|
||||
def iter_dict(self) -> Iterator[Result]:
|
||||
"""Iterate over a :class:`dict` based ``validator`` to validate the corresponding entries in ``data``.
|
||||
|
||||
:rtype: Iterator[:class:`Result`] objects with the error message related to the failure, if any check fails.
|
||||
:yields: objects with the error message related to the failure, if any check fails.
|
||||
"""
|
||||
# One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d`
|
||||
# variable), depending on the `key` type.
|
||||
@@ -677,12 +709,12 @@ class Schema(object):
|
||||
path=(d + ("." + v.path if v.path else "")), level=v.level, data=v.data)
|
||||
|
||||
def iter_or(self) -> Iterator[Result]:
|
||||
"""Perform all validations defined in an `Or` object for a given configuration option.
|
||||
"""Perform all validations defined in an :class:`Or` object for a given configuration option.
|
||||
|
||||
This method can be only called against leaf nodes in the configuration tree. :class:`Or` objects defined in the
|
||||
``validator`` keys will be handled by :func:`iter_dict` method.
|
||||
|
||||
:rtype: Iterator[:class:`Result`] objects with the error message related to the failure, if any check fails.
|
||||
:yields: objects with the error message related to the failure, if any check fails.
|
||||
"""
|
||||
results: List[Result] = []
|
||||
for a in self.validator.args:
|
||||
@@ -708,7 +740,7 @@ class Schema(object):
|
||||
|
||||
:param key: key from the ``validator`` attribute.
|
||||
|
||||
:rtype: Iterator[str], keys that should be used to access corresponding value in the ``data`` attribute.
|
||||
:yields: keys that should be used to access corresponding value in the ``data`` attribute.
|
||||
"""
|
||||
# If the key was defined as a `str` object in `validator` attribute, then it is already the final key to access
|
||||
# the `data` dictionary.
|
||||
@@ -735,12 +767,11 @@ class Schema(object):
|
||||
|
||||
|
||||
def _get_type_name(python_type: Any) -> str:
|
||||
"""Get a user friendly name for a given Python type.
|
||||
"""Get a user-friendly name for a given Python type.
|
||||
|
||||
:param python_type: Python type which user friendly name should be taken.
|
||||
|
||||
Returns:
|
||||
User friendly name of the given Python type.
|
||||
:returns: User friendly name of the given Python type.
|
||||
"""
|
||||
types: Dict[Any, str] = {str: 'a string', int: 'an integer', float: 'a number',
|
||||
bool: 'a boolean', list: 'an array', dict: 'a dictionary'}
|
||||
@@ -761,11 +792,11 @@ def assert_(condition: bool, message: str = "Wrong value") -> None:
|
||||
class IntValidator(object):
|
||||
"""Validate an integer setting.
|
||||
|
||||
:cvar expected_type: the expect Python type for an integer setting (:class:`int`).
|
||||
:cvar expected_type: the expected Python type for an integer setting (:class:`int`).
|
||||
:ivar min: minimum allowed value for the setting, if any.
|
||||
:ivar max: maximum allowed value for the setting, if any.
|
||||
:ivar base_unit: the base unit to convert the value to before checking if it's within `min` and `max` range.
|
||||
:ivar raise_assert: if an ``assert`` call should be performed regarding expected type and valid range.
|
||||
:ivar base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
|
||||
:ivar raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
|
||||
"""
|
||||
|
||||
expected_type = int
|
||||
@@ -777,22 +808,24 @@ class IntValidator(object):
|
||||
:param min: minimum allowed value for the setting, if any.
|
||||
:param max: maximum allowed value for the setting, if any.
|
||||
:param base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
|
||||
:param raise_assert: if an ``assert`` call should be performed regarding expected type and valid range.
|
||||
:param raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
|
||||
"""
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.base_unit = base_unit
|
||||
self.raise_assert = raise_assert
|
||||
|
||||
def __call__(self, value: Union[int, str]) -> bool:
|
||||
def __call__(self, value: Any) -> bool:
|
||||
"""Check if *value* is a valid integer and within the expected range.
|
||||
|
||||
.. note::
|
||||
If ``raise_assert`` is ``True`` and *value* is not valid, then an ``AssertionError`` will be triggered.
|
||||
If ``raise_assert`` is ``True`` and *value* is not valid, then an :class:`AssertionError` will be triggered.
|
||||
|
||||
:param value: value to be checked against the rules defined for this :class:`IntValidator` instance.
|
||||
|
||||
:returns: ``True`` if *value* is valid and within the expected range.
|
||||
"""
|
||||
value = parse_int(value, self.base_unit) or ""
|
||||
value = parse_int(value, self.base_unit)
|
||||
ret = isinstance(value, int)\
|
||||
and (self.min is None or value >= self.min)\
|
||||
and (self.max is None or value <= self.max)
|
||||
@@ -802,6 +835,39 @@ class IntValidator(object):
|
||||
return ret
|
||||
|
||||
|
||||
class EnumValidator(object):
|
||||
"""Validate enum setting
|
||||
|
||||
:ivar allowed_values: a ``set`` or ``CaseInsensitiveSet`` object with allowed enum values.
|
||||
:ivar raise_assert: if an ``assert`` call should be performed regarding expected type and valid range.
|
||||
"""
|
||||
|
||||
def __init__(self, allowed_values: Tuple[str, ...],
|
||||
case_sensitive: bool = False, raise_assert: bool = False) -> None:
|
||||
"""Create an :class:`EnumValidator` object with given allowed values.
|
||||
|
||||
:param allowed_values: a tuple with allowed enum values
|
||||
:param case_sensitive: set to ``True`` to do case sensitive comparisons
|
||||
:param raise_assert: if an ``assert`` call should be performed regarding expected values.
|
||||
"""
|
||||
self.allowed_values = set(allowed_values) if case_sensitive else CaseInsensitiveSet(allowed_values)
|
||||
self.raise_assert = raise_assert
|
||||
|
||||
def __call__(self, value: Any) -> bool:
|
||||
"""Check if provided *value* could be found within *allowed_values*.
|
||||
|
||||
.. note::
|
||||
If ``raise_assert`` is ``True`` and *value* is not valid, then an ``AssertionError`` will be triggered.
|
||||
:param value: value to be checked.
|
||||
:returns: ``True`` if *value* could be found within *allowed_values*.
|
||||
"""
|
||||
ret = isinstance(value, str) and value in self.allowed_values
|
||||
|
||||
if self.raise_assert:
|
||||
assert_(ret)
|
||||
return ret
|
||||
|
||||
|
||||
def validate_watchdog_mode(value: Any) -> None:
|
||||
"""Validate ``watchdog.mode`` configuration option.
|
||||
|
||||
@@ -827,15 +893,44 @@ validate_etcd = {
|
||||
"srv": str,
|
||||
"srv_suffix": str,
|
||||
"url": str,
|
||||
"proxy": str})
|
||||
"proxy": str
|
||||
}),
|
||||
Optional("protocol"): str,
|
||||
Optional("username"): str,
|
||||
Optional("password"): str,
|
||||
Optional("cacert"): str,
|
||||
Optional("cert"): str,
|
||||
Optional("key"): str
|
||||
}
|
||||
|
||||
schema = Schema({
|
||||
"name": str,
|
||||
"scope": str,
|
||||
Optional("ctl"): {
|
||||
Optional("insecure"): bool,
|
||||
Optional("cacert"): str,
|
||||
Optional("certfile"): str,
|
||||
Optional("keyfile"): str,
|
||||
Optional("keyfile_password"): str
|
||||
},
|
||||
"restapi": {
|
||||
"listen": validate_host_port_listen,
|
||||
"connect_address": validate_connect_address,
|
||||
Optional("authentication"): {
|
||||
"username": str,
|
||||
"password": str
|
||||
},
|
||||
Optional("certfile"): str,
|
||||
Optional("keyfile"): str,
|
||||
Optional("keyfile_password"): str,
|
||||
Optional("cafile"): str,
|
||||
Optional("ciphers"): str,
|
||||
Optional("verify_client"): EnumValidator(("none", "optional", "required"),
|
||||
case_sensitive=True, raise_assert=True),
|
||||
Optional("allowlist"): [str],
|
||||
Optional("allowlist_include_members"): bool,
|
||||
Optional("http_extra_headers"): dict,
|
||||
Optional("https_extra_headers"): dict,
|
||||
Optional("request_queue_size"): IntValidator(min=0, max=4096, raise_assert=True)
|
||||
},
|
||||
Optional("bootstrap"): {
|
||||
@@ -843,15 +938,64 @@ schema = Schema({
|
||||
Optional("ttl"): int,
|
||||
Optional("loop_wait"): int,
|
||||
Optional("retry_timeout"): int,
|
||||
Optional("maximum_lag_on_failover"): int
|
||||
Optional("maximum_lag_on_failover"): int,
|
||||
Optional("maximum_lag_on_syncnode"): int,
|
||||
Optional("postgresql"): {
|
||||
Optional("parameters"): {
|
||||
Optional("max_connections"): int,
|
||||
Optional("max_locks_per_transaction"): int,
|
||||
Optional("max_prepared_transactions"): int,
|
||||
Optional("max_replication_slots"): int,
|
||||
Optional("max_wal_senders"): int,
|
||||
Optional("max_worker_processes"): int
|
||||
},
|
||||
Optional("use_pg_rewind"): bool,
|
||||
Optional("pg_hba"): [str],
|
||||
Optional("pg_ident"): [str],
|
||||
Optional("pg_ctl_timeout"): int,
|
||||
Optional("use_slots"): bool,
|
||||
},
|
||||
Optional("primary_start_timeout"): int,
|
||||
Optional("primary_stop_timeout"): int,
|
||||
Optional("standby_cluster"): {
|
||||
Or("host", "port", "restore_command"): Case({
|
||||
"host": str,
|
||||
"port": int,
|
||||
"restore_command": str
|
||||
}),
|
||||
Optional("primary_slot_name"): str,
|
||||
Optional("create_replica_methods"): [str],
|
||||
Optional("archive_cleanup_command"): str,
|
||||
Optional("recovery_min_apply_delay"): str
|
||||
},
|
||||
Optional("synchronous_mode"): bool,
|
||||
Optional("synchronous_mode_strict"): bool,
|
||||
Optional("synchronous_node_count"): int
|
||||
},
|
||||
Optional("initdb"): [Or(str, dict)]
|
||||
Optional("initdb"): [Or(str, dict)],
|
||||
Optional("method"): str
|
||||
},
|
||||
Or(*available_dcs): Case({
|
||||
"consul": {
|
||||
Or("host", "url"): Case({
|
||||
"host": validate_host_port,
|
||||
"url": str})
|
||||
"url": str
|
||||
}),
|
||||
Optional("port"): int,
|
||||
Optional("scheme"): str,
|
||||
Optional("token"): str,
|
||||
Optional("verify"): bool,
|
||||
Optional("cacert"): str,
|
||||
Optional("cert"): str,
|
||||
Optional("key"): str,
|
||||
Optional("dc"): str,
|
||||
Optional("checks"): [str],
|
||||
Optional("register_service"): bool,
|
||||
Optional("service_tags"): [str],
|
||||
Optional("service_check_interval"): str,
|
||||
Optional("service_check_tls_server_name"): str,
|
||||
Optional("consistency"): EnumValidator(('default', 'consistent', 'stale'),
|
||||
case_sensitive=True, raise_assert=True)
|
||||
},
|
||||
"etcd": validate_etcd,
|
||||
"etcd3": validate_etcd,
|
||||
@@ -869,15 +1013,28 @@ schema = Schema({
|
||||
},
|
||||
"zookeeper": {
|
||||
"hosts": Or(comma_separated_host_port, [validate_host_port]),
|
||||
Optional("use_ssl"): bool,
|
||||
Optional("cacert"): str,
|
||||
Optional("cert"): str,
|
||||
Optional("key"): str,
|
||||
Optional("key_password"): str,
|
||||
Optional("verify"): bool,
|
||||
Optional("set_acls"): dict
|
||||
},
|
||||
"kubernetes": {
|
||||
"labels": {},
|
||||
Optional("bypass_api_service"): bool,
|
||||
Optional("namespace"): str,
|
||||
Optional("scope_label"): str,
|
||||
Optional("role_label"): str,
|
||||
Optional("leader_label_value"): str,
|
||||
Optional("follower_label_value"): str,
|
||||
Optional("standby_leader_label_value"): str,
|
||||
Optional("tmp_role_label"): str,
|
||||
Optional("use_endpoints"): bool,
|
||||
Optional("pod_ip"): Or(is_ipv4_address, is_ipv6_address),
|
||||
Optional("ports"): [{"name": str, "port": int}],
|
||||
Optional("cacert"): str,
|
||||
Optional("retriable_http_codes"): Or(int, [int]),
|
||||
},
|
||||
}),
|
||||
@@ -915,7 +1072,8 @@ schema = Schema({
|
||||
},
|
||||
Optional("watchdog"): {
|
||||
Optional("mode"): validate_watchdog_mode,
|
||||
Optional("device"): str
|
||||
Optional("device"): str,
|
||||
Optional("safety_margin"): int
|
||||
},
|
||||
Optional("tags"): {
|
||||
Optional("nofailover"): bool,
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
:var __version__: the current Patroni version.
|
||||
"""
|
||||
__version__ = '3.0.4'
|
||||
__version__ = '3.1.2'
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ bootstrap:
|
||||
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
|
||||
# post_init: /usr/local/bin/setup_cluster.sh
|
||||
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
# Some additional users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin%
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ bootstrap:
|
||||
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
|
||||
# post_init: /usr/local/bin/setup_cluster.sh
|
||||
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
# Some additional users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin%
|
||||
|
||||
+2
-2
@@ -84,7 +84,7 @@ bootstrap:
|
||||
- encoding: UTF8
|
||||
- data-checksums
|
||||
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
# Some additional users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin%
|
||||
@@ -121,4 +121,4 @@ tags:
|
||||
nofailover: false
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
replicatefrom: postgres1
|
||||
# replicatefrom: postgresql1
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
sphinx>=4
|
||||
sphinx_rtd_theme>1
|
||||
sphinxcontrib-apidoc
|
||||
sphinx-github-style
|
||||
pyyaml
|
||||
@@ -118,11 +118,31 @@ class MockCursor(object):
|
||||
'"state":"streaming","sync_state":"async","sync_priority":0}]'
|
||||
now = datetime.datetime.now(tzutc)
|
||||
self.results = [(now, 0, '', 0, '', False, now, 'streaming', None, replication_info)]
|
||||
elif sql.startswith('SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings'):
|
||||
self.results = [('data_directory', 'data'),
|
||||
('hba_file', os.path.join('data', 'pg_hba.conf')),
|
||||
('ident_file', os.path.join('data', 'pg_ident.conf')),
|
||||
('max_connections', 42),
|
||||
('max_locks_per_transaction', 73),
|
||||
('max_prepared_transactions', 0),
|
||||
('max_replication_slots', 21),
|
||||
('max_wal_senders', 37),
|
||||
('track_commit_timestamp', 'off'),
|
||||
('wal_level', 'replica'),
|
||||
('listen_addresses', '6.6.6.6'),
|
||||
('port', 1984),
|
||||
('archive_command', 'my archive command'),
|
||||
('cluster_name', 'my_cluster')]
|
||||
elif sql.startswith('SELECT name, setting'):
|
||||
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
|
||||
('wal_block_size', '8192', None, 'integer', 'internal'),
|
||||
('shared_buffers', '16384', '8kB', 'integer', 'postmaster'),
|
||||
('wal_buffers', '-1', '8kB', 'integer', 'postmaster'),
|
||||
('max_connections', '100', None, 'integer', 'postmaster'),
|
||||
('max_prepared_transactions', '0', None, 'integer', 'postmaster'),
|
||||
('max_worker_processes', '8', None, 'integer', 'postmaster'),
|
||||
('max_locks_per_transaction', '64', None, 'integer', 'postmaster'),
|
||||
('max_wal_senders', '5', None, 'integer', 'postmaster'),
|
||||
('search_path', 'public', None, 'string', 'user'),
|
||||
('port', '5433', None, 'integer', 'postmaster'),
|
||||
('listen_addresses', '*', None, 'string', 'postmaster'),
|
||||
@@ -222,6 +242,7 @@ class PostgresInit(unittest.TestCase):
|
||||
|
||||
class BaseTestPostgresql(PostgresInit):
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def setUp(self):
|
||||
super(BaseTestPostgresql, self).setUp()
|
||||
|
||||
|
||||
+2
-2
@@ -226,7 +226,7 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary'))
|
||||
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, None, None, '')])):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)),\
|
||||
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)), \
|
||||
patch.object(GlobalConfig, 'is_paused', Mock(return_value=True)):
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
||||
|
||||
@@ -559,7 +559,7 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\
|
||||
' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)),\
|
||||
with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)), \
|
||||
patch.object(MockPatroni, 'dcs') as d:
|
||||
d.manual_failover.return_value = False
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
+11
-10
@@ -155,9 +155,9 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
|
||||
config = {'users': {'replicator': {'password': 'rep-pass', 'options': ['replication']}}}
|
||||
|
||||
with patch.object(Postgresql, 'is_running', Mock(return_value=False)),\
|
||||
patch.object(Postgresql, 'get_major_version', Mock(return_value=140000)),\
|
||||
patch('multiprocessing.Process', Mock(side_effect=Exception)),\
|
||||
with patch.object(Postgresql, 'is_running', Mock(return_value=False)), \
|
||||
patch.object(Postgresql, 'get_major_version', Mock(return_value=140000)), \
|
||||
patch('multiprocessing.Process', Mock(side_effect=Exception)), \
|
||||
patch('multiprocessing.get_context', Mock(side_effect=Exception), create=True):
|
||||
self.assertRaises(Exception, self.b.bootstrap, config)
|
||||
with open(os.path.join(self.p.data_dir, 'pg_hba.conf')) as f:
|
||||
@@ -185,12 +185,12 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
self.assertFalse(self.b.bootstrap(config))
|
||||
|
||||
mock_cancellable_subprocess_call.return_value = 0
|
||||
with patch('multiprocessing.Process', Mock(side_effect=Exception("42"))),\
|
||||
patch('multiprocessing.get_context', Mock(side_effect=Exception("42")), create=True),\
|
||||
patch('os.path.isfile', Mock(return_value=True)),\
|
||||
patch('os.unlink', Mock()),\
|
||||
patch.object(ConfigHandler, 'save_configuration_files', Mock()),\
|
||||
patch.object(ConfigHandler, 'restore_configuration_files', Mock()),\
|
||||
with patch('multiprocessing.Process', Mock(side_effect=Exception("42"))), \
|
||||
patch('multiprocessing.get_context', Mock(side_effect=Exception("42")), create=True), \
|
||||
patch('os.path.isfile', Mock(return_value=True)), \
|
||||
patch('os.unlink', Mock()), \
|
||||
patch.object(ConfigHandler, 'save_configuration_files', Mock()), \
|
||||
patch.object(ConfigHandler, 'restore_configuration_files', Mock()), \
|
||||
patch.object(ConfigHandler, 'write_recovery_conf', Mock()):
|
||||
with self.assertRaises(Exception) as e:
|
||||
self.b.bootstrap(config)
|
||||
@@ -238,7 +238,8 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
self.p.reload_config({'authentication': {'superuser': {'username': 'p', 'password': 'p'},
|
||||
'replication': {'username': 'r', 'password': 'r'},
|
||||
'rewind': {'username': 'rw', 'password': 'rw'}},
|
||||
'listen': '*', 'retry_timeout': 10, 'parameters': {'wal_level': '', 'hba_file': 'foo'}})
|
||||
'listen': '*', 'retry_timeout': 10,
|
||||
'parameters': {'wal_level': '', 'hba_file': 'foo', 'max_prepared_transactions': 10}})
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=110000)), \
|
||||
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
|
||||
self.b.post_bootstrap({}, task)
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ class TestCitus(BaseTestPostgresql):
|
||||
'leader': 'leader', 'timeout': 30, 'cooldown': 10})
|
||||
|
||||
def test_add_task(self):
|
||||
with patch('patroni.postgresql.citus.logger.error') as mock_logger,\
|
||||
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
|
||||
patch('patroni.postgresql.citus.urlparse', Mock(side_effect=Exception)):
|
||||
self.c.add_task('', 1, None)
|
||||
mock_logger.assert_called_once()
|
||||
@@ -107,7 +107,7 @@ class TestCitus(BaseTestPostgresql):
|
||||
self.c.process_tasks()
|
||||
|
||||
self.c.add_task('after_promote', 0, 'postgres://host3:5432/postgres')
|
||||
with patch('patroni.postgresql.citus.logger.error') as mock_logger,\
|
||||
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
|
||||
patch.object(CitusHandler, 'query', Mock(side_effect=Exception)):
|
||||
self.c.process_tasks()
|
||||
mock_logger.assert_called_once()
|
||||
|
||||
+27
-1
@@ -3,6 +3,7 @@ import sys
|
||||
import unittest
|
||||
import io
|
||||
|
||||
from copy import deepcopy
|
||||
from mock import MagicMock, Mock, patch
|
||||
from patroni.config import Config, ConfigParseError
|
||||
|
||||
@@ -21,7 +22,8 @@ class TestConfig(unittest.TestCase):
|
||||
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
|
||||
self.assertFalse(self.config.set_dynamic_configuration({'foo': 'bar'}))
|
||||
self.assertTrue(self.config.set_dynamic_configuration({'standby_cluster': {}, 'postgresql': {
|
||||
'parameters': {'cluster_name': 1, 'wal_keep_size': 1, 'track_commit_timestamp': 1, 'wal_level': 1}}}))
|
||||
'parameters': {'cluster_name': 1, 'hot_standby': 1, 'wal_keep_size': 1,
|
||||
'track_commit_timestamp': 1, 'wal_level': 1, 'max_connections': '100'}}}))
|
||||
|
||||
def test_reload_local_configuration(self):
|
||||
os.environ.update({
|
||||
@@ -84,6 +86,7 @@ class TestConfig(unittest.TestCase):
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
@patch('os.remove', Mock(side_effect=IOError))
|
||||
@patch('os.close', Mock(side_effect=IOError))
|
||||
@patch('os.chmod', Mock())
|
||||
@patch('shutil.move', Mock(return_value=None))
|
||||
@patch('json.dump', Mock())
|
||||
def test_save_cache(self):
|
||||
@@ -147,3 +150,26 @@ class TestConfig(unittest.TestCase):
|
||||
@patch('os.path.isdir', Mock(return_value=False))
|
||||
def test_invalid_path(self):
|
||||
self.assertRaises(ConfigParseError, Config, 'postgres0')
|
||||
|
||||
def test__process_postgresql_parameters(self):
|
||||
expected_params = {
|
||||
'f.oo': 'bar', # not in ConfigHandler.CMDLINE_OPTIONS
|
||||
'max_connections': 100, # IntValidator
|
||||
'wal_keep_size': '128MB', # IntValidator
|
||||
'wal_level': 'hot_standby', # EnumValidator
|
||||
}
|
||||
input_params = deepcopy(expected_params)
|
||||
|
||||
input_params['max_connections'] = '100'
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
expected_params['f.oo'] = input_params['f.oo'] = '100'
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
input_params['wal_level'] = 'cold_standby'
|
||||
expected_params.pop('wal_level')
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
input_params['max_connections'] = 10
|
||||
expected_params.pop('max_connections')
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
+41
-6
@@ -22,7 +22,7 @@ from .test_ha import get_cluster_initialized_without_leader, get_cluster_initial
|
||||
|
||||
|
||||
@patch('patroni.ctl.load_config', Mock(return_value={
|
||||
'scope': 'alpha', 'restapi': {'listen': '::', 'certfile': 'a'},
|
||||
'scope': 'alpha', 'restapi': {'listen': '::', 'certfile': 'a'}, 'ctl': {'certfile': 'a'},
|
||||
'etcd': {'host': 'localhost:2379'}, 'citus': {'database': 'citus', 'group': 0},
|
||||
'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}}))
|
||||
class TestCtl(unittest.TestCase):
|
||||
@@ -68,6 +68,21 @@ class TestCtl(unittest.TestCase):
|
||||
|
||||
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, role='any'))
|
||||
|
||||
# Mutually exclusive options
|
||||
with self.assertRaises(PatroniCtlException) as e:
|
||||
get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, member_name='other',
|
||||
role='replica')
|
||||
|
||||
self.assertEqual(str(e.exception), '--role and --member are mutually exclusive options')
|
||||
|
||||
# Invalid member provided
|
||||
self.assertIsNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
|
||||
member_name='invalid'))
|
||||
|
||||
# Valid member provided
|
||||
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
|
||||
member_name='other'))
|
||||
|
||||
def test_parse_dcs(self):
|
||||
assert parse_dcs(None) is None
|
||||
assert parse_dcs('localhost') == {'etcd': {'host': 'localhost:2379'}}
|
||||
@@ -83,9 +98,13 @@ class TestCtl(unittest.TestCase):
|
||||
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
|
||||
cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at))
|
||||
del cluster.members[1].data['conn_url']
|
||||
for fmt in ('pretty', 'json', 'yaml', 'tsv', 'topology'):
|
||||
for fmt in ('pretty', 'json', 'yaml', 'topology'):
|
||||
self.assertIsNone(output_members({}, cluster, name='abc', fmt=fmt))
|
||||
|
||||
with patch('click.echo') as mock_echo:
|
||||
self.assertIsNone(output_members({}, cluster, name='abc', fmt='tsv'))
|
||||
self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown')
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
|
||||
def test_switchover(self, mock_get_dcs):
|
||||
@@ -227,11 +246,17 @@ class TestCtl(unittest.TestCase):
|
||||
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||
|
||||
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
|
||||
# No role nor member given -- generic message
|
||||
rows = query_member({}, None, None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||
self.assertTrue('No connection to' in str(rows))
|
||||
self.assertTrue('No connection is available' in str(rows))
|
||||
|
||||
rows = query_member({}, None, None, None, 'foo', 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||
self.assertTrue('No connection to' in str(rows))
|
||||
# Member given -- message pointing to member
|
||||
rows = query_member({}, None, None, None, 'foo', None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||
self.assertTrue('No connection to member foo' in str(rows))
|
||||
|
||||
# Role given -- message pointing to role
|
||||
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||
self.assertTrue('No connection to role replica' in str(rows))
|
||||
|
||||
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
|
||||
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||
@@ -398,9 +423,19 @@ class TestCtl(unittest.TestCase):
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_members(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
|
||||
result = self.runner.invoke(ctl, ['list'])
|
||||
assert '127.0.0.1' in result.output
|
||||
assert result.exit_code == 0
|
||||
assert 'Citus cluster: alpha -' in result.output
|
||||
|
||||
result = self.runner.invoke(ctl, ['list', '--group', '0'])
|
||||
assert 'Citus cluster: alpha (group: 0, 12345678901) -' in result.output
|
||||
|
||||
with patch('patroni.ctl.load_config', Mock(return_value={'scope': 'alpha'})):
|
||||
result = self.runner.invoke(ctl, ['list'])
|
||||
assert 'Cluster: alpha (12345678901) -' in result.output
|
||||
|
||||
with patch('patroni.ctl.load_config', Mock(return_value={})):
|
||||
self.runner.invoke(ctl, ['list'])
|
||||
|
||||
@@ -451,7 +486,7 @@ class TestCtl(unittest.TestCase):
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
|
||||
for role in self.TEST_ROLES:
|
||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '-r', role], input='y')
|
||||
result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'restart', '-r', role], input='y')
|
||||
assert 'No scheduled restart' in result.output
|
||||
|
||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
|
||||
|
||||
+2
-2
@@ -172,12 +172,12 @@ class TestClient(unittest.TestCase):
|
||||
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
|
||||
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
|
||||
|
||||
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
|
||||
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])), \
|
||||
patch.object(EtcdClient, '_load_machines_cache', Mock(side_effect=Exception)):
|
||||
self.client.http.request = Mock(side_effect=socket.error)
|
||||
self.assertRaises(etcd.EtcdException, rtry, self.client.api_execute, '/', 'GET', params={'retry': rtry})
|
||||
|
||||
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
|
||||
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])), \
|
||||
patch.object(EtcdClient, '_load_machines_cache', Mock(return_value=True)):
|
||||
self.assertRaises(etcd.EtcdException, rtry, self.client.api_execute, '/', 'GET', params={'retry': rtry})
|
||||
|
||||
|
||||
+11
-4
@@ -5,8 +5,9 @@ import urllib3
|
||||
|
||||
from mock import Mock, PropertyMock, patch
|
||||
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 patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \
|
||||
Etcd3Error, Etcd3ClientError, RetryFailedError, InvalidAuthToken, Unavailable, \
|
||||
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode
|
||||
from threading import Thread
|
||||
|
||||
from . import SleepException, MockResponse
|
||||
@@ -126,10 +127,16 @@ class TestPatroniEtcd3Client(BaseTestEtcd3):
|
||||
request = {'key': base64_encode('/patroni/test/leader')}
|
||||
mock_urlopen.return_value = MockResponse()
|
||||
mock_urlopen.return_value.content = '{"succeeded":true,"header":{"revision":"1"}}'
|
||||
self.client.call_rpc('/kv/txn', {'success': [{'request_delete_range': request}]})
|
||||
self.client.call_rpc('/kv/put', request)
|
||||
self.client.call_rpc('/kv/deleterange', request)
|
||||
|
||||
@patch.object(urllib3.PoolManager, 'urlopen')
|
||||
def test_txn(self, mock_urlopen):
|
||||
mock_urlopen.return_value = MockResponse()
|
||||
mock_urlopen.return_value.content = '{"header":{"revision":"1"}}'
|
||||
self.client.txn({'target': 'MOD', 'mod_revision': '1'},
|
||||
{'request_delete_range': {'key': base64_encode('/patroni/test/leader')}})
|
||||
|
||||
@patch('time.time', Mock(side_effect=[1, 10.9, 100]))
|
||||
def test__wait_cache(self):
|
||||
with self.kv_cache.condition:
|
||||
@@ -241,7 +248,7 @@ class TestEtcd3(BaseTestEtcd3):
|
||||
self.etcd3.update_leader(leader, '123', failsafe={'foo': 'bar'})
|
||||
self.etcd3._last_lease_refresh = 0
|
||||
self.etcd3.update_leader(leader, '124')
|
||||
with patch.object(PatroniEtcd3Client, 'lease_keepalive', Mock(return_value=True)),\
|
||||
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, leader, '126')
|
||||
self.etcd3._lease = leader.session
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import unittest
|
||||
import stat
|
||||
|
||||
from mock import Mock, patch
|
||||
|
||||
from patroni.file_perm import pg_perm
|
||||
|
||||
|
||||
class TestFilePermissions(unittest.TestCase):
|
||||
|
||||
@patch('os.stat')
|
||||
@patch('os.umask')
|
||||
@patch('patroni.file_perm.logger.error')
|
||||
def test_set_umask(self, mock_logger, mock_umask, mock_stat):
|
||||
mock_umask.side_effect = Exception
|
||||
mock_stat.return_value.st_mode = stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP
|
||||
pg_perm.set_permissions_from_data_directory('test')
|
||||
|
||||
# umask is called with PG_MODE_MASK_GROUP
|
||||
self.assertEqual(mock_umask.call_args[0][0], stat.S_IWGRP | stat.S_IRWXO)
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Can not set umask to %03o: %r')
|
||||
|
||||
mock_umask.reset_mock()
|
||||
mock_stat.return_value.st_mode = stat.S_IRWXU
|
||||
pg_perm.set_permissions_from_data_directory('test')
|
||||
# umask is called with PG_MODE_MASK_OWNER (permissions changed from group to owner)
|
||||
self.assertEqual(mock_umask.call_args[0][0], stat.S_IRWXG | stat.S_IRWXO)
|
||||
|
||||
@patch('os.stat', Mock(side_effect=FileNotFoundError))
|
||||
@patch('patroni.file_perm.logger.error')
|
||||
def test_set_permissions_from_data_directory(self, mock_logger):
|
||||
pg_perm.set_permissions_from_data_directory('test')
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Can not check permissions on %s: %r')
|
||||
+51
-6
@@ -278,8 +278,10 @@ class TestHa(PostgresInit):
|
||||
self.p.follow = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.p.is_running = true
|
||||
ha_dcs_orig_name = self.ha.dcs.__class__.__name__
|
||||
self.ha.dcs.__class__.__name__ = 'Raft'
|
||||
self.assertEqual(self.ha.run_cycle(), 'started as a secondary')
|
||||
self.ha.dcs.__class__.__name__ = ha_dcs_orig_name
|
||||
|
||||
def test_recover_former_primary(self):
|
||||
self.p.follow = false
|
||||
@@ -305,7 +307,7 @@ class TestHa(PostgresInit):
|
||||
self.p.is_running = false
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
|
||||
self.assertEqual(self.ha.run_cycle(), 'doing crash recovery in a single user mode')
|
||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)),\
|
||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)), \
|
||||
patch.object(Ha, 'check_timeline', Mock(return_value=False)):
|
||||
self.ha._async_executor.schedule('doing crash recovery in a single user mode')
|
||||
self.ha.state_handler.cancellable._process = Mock()
|
||||
@@ -338,7 +340,7 @@ class TestHa(PostgresInit):
|
||||
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)),\
|
||||
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=False)), \
|
||||
patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
|
||||
self.p.follow = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
@@ -373,6 +375,12 @@ class TestHa(PostgresInit):
|
||||
def test_acquire_lock_as_primary(self):
|
||||
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
|
||||
|
||||
def test_leader_race_stale_primary(self):
|
||||
with patch.object(Postgresql, 'get_primary_timeline', Mock(return_value=1)), \
|
||||
patch('patroni.ha.logger.warning') as mock_logger:
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'My timeline %s is behind last known cluster timeline %s')
|
||||
|
||||
def test_promoted_by_acquiring_lock(self):
|
||||
self.ha.is_healthiest_node = true
|
||||
self.p.is_leader = false
|
||||
@@ -606,7 +614,7 @@ class TestHa(PostgresInit):
|
||||
self.e.initialize = true
|
||||
self.ha.bootstrap()
|
||||
self.p.is_leader = true
|
||||
with patch.object(Watchdog, 'activate', Mock(return_value=False)),\
|
||||
with patch.object(Watchdog, 'activate', Mock(return_value=False)), \
|
||||
patch('patroni.ha.logger.error') as mock_logger:
|
||||
self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap')
|
||||
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
|
||||
@@ -667,9 +675,9 @@ class TestHa(PostgresInit):
|
||||
|
||||
self.ha.update_lock = false
|
||||
self.p.set_role('primary')
|
||||
with patch('patroni.async_executor.CriticalTask.cancel', Mock(return_value=False)),\
|
||||
with patch('patroni.async_executor.CriticalTask.cancel', Mock(return_value=False)), \
|
||||
patch('patroni.async_executor.CriticalTask.result',
|
||||
PropertyMock(return_value=PostmasterProcess(os.getpid())), create=True),\
|
||||
PropertyMock(return_value=PostmasterProcess(os.getpid())), create=True), \
|
||||
patch('patroni.postgresql.Postgresql.terminate_starting_postmaster') as mock_terminate:
|
||||
self.assertEqual(self.ha.run_cycle(), 'lost leader lock during restart')
|
||||
mock_terminate.assert_called()
|
||||
@@ -883,7 +891,10 @@ class TestHa(PostgresInit):
|
||||
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
|
||||
self.ha.fetch_node_status(member)
|
||||
member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'})
|
||||
self.ha.fetch_node_status(member)
|
||||
self.ha.patroni.request = Mock()
|
||||
self.ha.patroni.request.return_value.data = b'{"wal":{"location":1},"role":"primary"}'
|
||||
ret = self.ha.fetch_node_status(member)
|
||||
self.assertFalse(ret.in_recovery)
|
||||
|
||||
@patch.object(Rewind, 'pg_rewind', true)
|
||||
@patch.object(Rewind, 'check_leader_is_not_in_recovery', true)
|
||||
@@ -1287,6 +1298,39 @@ class TestHa(PostgresInit):
|
||||
mock_restart.assert_called_once()
|
||||
self.ha.dcs.get_cluster.assert_not_called()
|
||||
|
||||
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||
def test_enable_synchronous_mode(self):
|
||||
self.ha.is_synchronous_mode = true
|
||||
self.ha.has_lock = true
|
||||
self.p.name = 'leader'
|
||||
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet()))
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||
with patch('patroni.ha.logger.info') as mock_logger:
|
||||
self.ha.run_cycle()
|
||||
self.assertEqual(mock_logger.call_args_list[0][0][0], 'Enabled synchronous replication')
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=None)
|
||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||
self.ha.run_cycle()
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Updating sync state failed')
|
||||
|
||||
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||
def test_inconsistent_synchronous_state(self):
|
||||
self.ha.is_synchronous_mode = true
|
||||
self.ha.has_lock = true
|
||||
self.p.name = 'leader'
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'a'))
|
||||
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet('a'), CaseInsensitiveSet()))
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||
mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock()
|
||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||
self.ha.run_cycle()
|
||||
mock_set_sync.assert_called_once()
|
||||
self.assertTrue(mock_logger.call_args_list[0][0][0].startswith('Inconsistent state between '))
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=None)
|
||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||
self.ha.run_cycle()
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Updating sync state failed')
|
||||
|
||||
def test_effective_tags(self):
|
||||
self.ha._disable_sync = True
|
||||
self.assertEqual(self.ha.get_effective_tags(), {'foo': 'bar', 'nosync': True})
|
||||
@@ -1410,6 +1454,7 @@ class TestHa(PostgresInit):
|
||||
@patch('os.open', Mock())
|
||||
@patch('os.fsync', Mock())
|
||||
@patch('os.close', Mock())
|
||||
@patch('os.chmod', Mock())
|
||||
@patch('os.rename', Mock())
|
||||
@patch('patroni.postgresql.Postgresql.is_starting', Mock(return_value=False))
|
||||
@patch('builtins.open', mock_open())
|
||||
|
||||
+38
-10
@@ -8,8 +8,8 @@ import unittest
|
||||
import urllib3
|
||||
|
||||
from mock import Mock, PropertyMock, mock_open, patch
|
||||
from patroni.dcs.kubernetes import Cluster, k8s_client, k8s_config, K8sConfig, K8sConnectionFailed,\
|
||||
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException,\
|
||||
from patroni.dcs.kubernetes import Cluster, k8s_client, k8s_config, K8sConfig, K8sConnectionFailed, \
|
||||
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException, \
|
||||
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
|
||||
from threading import Thread
|
||||
from . import MockResponse, SleepException
|
||||
@@ -86,8 +86,8 @@ class TestK8sConfig(unittest.TestCase):
|
||||
with patch('os.environ', env):
|
||||
self.assertRaises(k8s_config.ConfigException, k8s_config.load_incluster_config)
|
||||
|
||||
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}),\
|
||||
patch('os.path.isfile', Mock(side_effect=[False, True, True, False, True, True, True, True])),\
|
||||
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}), \
|
||||
patch('os.path.isfile', Mock(side_effect=[False, True, True, False, True, True, True, True])), \
|
||||
patch('builtins.open', Mock(side_effect=[
|
||||
mock_open()(), mock_open(read_data='a')(), mock_open(read_data='a')(),
|
||||
mock_open()(), mock_open(read_data='a')(), mock_open(read_data='a')()])):
|
||||
@@ -98,8 +98,8 @@ class TestK8sConfig(unittest.TestCase):
|
||||
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])),\
|
||||
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('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')()])):
|
||||
@@ -138,10 +138,10 @@ class TestK8sConfig(unittest.TestCase):
|
||||
|
||||
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('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,\
|
||||
with patch('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()
|
||||
@@ -298,6 +298,30 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
|
||||
self.k.touch_member({'state': 'running', 'role': 'replica'})
|
||||
self.k.touch_member({'state': 'stopped', 'role': 'primary'})
|
||||
|
||||
self.k._role_label = 'isMaster'
|
||||
self.k._leader_label_value = 'true'
|
||||
self.k._follower_label_value = 'false'
|
||||
self.k._standby_leader_label_value = 'false'
|
||||
self.k._tmp_role_label = 'tmp_role'
|
||||
|
||||
self.k.touch_member({'state': 'running', 'role': 'replica'})
|
||||
mock_patch_namespaced_pod.assert_called()
|
||||
self.assertEqual(mock_patch_namespaced_pod.call_args[0][2].metadata.labels['isMaster'], 'false')
|
||||
self.assertEqual(mock_patch_namespaced_pod.call_args[0][2].metadata.labels['tmp_role'], 'replica')
|
||||
mock_patch_namespaced_pod.rest_mock()
|
||||
|
||||
self.k._name = 'p-0'
|
||||
self.k.touch_member({'role': 'standby_leader'})
|
||||
mock_patch_namespaced_pod.assert_called()
|
||||
self.assertEqual(mock_patch_namespaced_pod.call_args[0][2].metadata.labels['isMaster'], 'false')
|
||||
self.assertEqual(mock_patch_namespaced_pod.call_args[0][2].metadata.labels['tmp_role'], 'master')
|
||||
mock_patch_namespaced_pod.rest_mock()
|
||||
|
||||
self.k.touch_member({'role': 'primary'})
|
||||
mock_patch_namespaced_pod.assert_called()
|
||||
self.assertEqual(mock_patch_namespaced_pod.call_args[0][2].metadata.labels['isMaster'], 'true')
|
||||
self.assertEqual(mock_patch_namespaced_pod.call_args[0][2].metadata.labels['tmp_role'], 'master')
|
||||
|
||||
def test_initialize(self):
|
||||
self.k.initialize()
|
||||
|
||||
@@ -412,6 +436,10 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
mock_logger_exception.assert_called_once()
|
||||
self.assertEqual(('create_config_service failed',), mock_logger_exception.call_args[0])
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind, create=True)
|
||||
def test_write_leader_optime(self):
|
||||
self.k.write_leader_optime(12345)
|
||||
|
||||
|
||||
def mock_watch(*args):
|
||||
return urllib3.HTTPResponse()
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ class TestPatroniLogger(unittest.TestCase):
|
||||
_LOG.exception('test')
|
||||
logger.start()
|
||||
|
||||
with patch.object(logging.Handler, 'format', Mock(side_effect=Exception)),\
|
||||
with patch.object(logging.Handler, 'format', Mock(side_effect=Exception)), \
|
||||
patch('_pytest.logging.LogCaptureHandler.emit', Mock()):
|
||||
logging.error('test')
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class MockFrozenImporter(object):
|
||||
@patch('time.sleep', Mock())
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('urllib3.connection.HTTPConnection.connect', Mock(side_effect=Exception))
|
||||
@patch.object(ConfigHandler, 'append_pg_hba', Mock())
|
||||
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
|
||||
@patch.object(ConfigHandler, 'write_recovery_conf', Mock())
|
||||
@@ -63,6 +64,7 @@ class TestPatroni(unittest.TestCase):
|
||||
self.assertRaises(SystemExit, _main)
|
||||
|
||||
@patch('pkgutil.iter_importers', Mock(return_value=[MockFrozenImporter()]))
|
||||
@patch('urllib3.connection.HTTPConnection.connect', Mock(side_effect=Exception))
|
||||
@patch('sys.frozen', Mock(return_value=True), create=True)
|
||||
@patch.object(HTTPServer, '__init__', Mock())
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
@@ -106,6 +108,7 @@ class TestPatroni(unittest.TestCase):
|
||||
@patch('os.getpid')
|
||||
@patch('multiprocessing.Process')
|
||||
@patch('patroni.__main__.patroni_main', Mock())
|
||||
@patch('sys.argv', ['patroni.py', 'postgres0.yml'])
|
||||
def test_patroni_main(self, mock_process, mock_getpid):
|
||||
mock_getpid.return_value = 2
|
||||
_main()
|
||||
@@ -231,8 +234,8 @@ class TestPatroni(unittest.TestCase):
|
||||
)
|
||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=bad_cluster)):
|
||||
# If the api of the running node cannot be reached, this implies unique name
|
||||
with patch.object(self.p, 'request', Mock(side_effect=ConnectionError)):
|
||||
with patch('urllib3.connection.HTTPConnection.connect', Mock(side_effect=ConnectionError)):
|
||||
self.assertIsNone(self.p.ensure_unique_name())
|
||||
# Only if the api of the running node is reachable do we throw an error
|
||||
with patch.object(self.p, 'request', Mock()):
|
||||
with patch('urllib3.connection.HTTPConnection.connect', Mock()):
|
||||
self.assertRaises(SystemExit, self.p.ensure_unique_name)
|
||||
|
||||
@@ -310,6 +310,17 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.p.config.write_postgresql_conf()
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (False, False))
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (False, False))
|
||||
|
||||
# Config files changed, but can't connect to postgres
|
||||
mock_get_pg_settings.side_effect = PostgresConnectionException('')
|
||||
with patch('patroni.postgresql.config.mtime', mock_mtime):
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
|
||||
|
||||
# Config files didn't change, but postgres crashed or in crash recovery
|
||||
with patch.object(MockPostmaster, 'create_time', Mock(return_value=1234568), create=True):
|
||||
self.assertEqual(self.p.config.check_recovery_conf(None), (False, False))
|
||||
|
||||
# Any other exception raised when executing the query
|
||||
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))
|
||||
@@ -333,7 +344,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
|
||||
mock_read_auto = mock_open(read_data=read_data)
|
||||
mock_read_auto.return_value.__iter__ = lambda o: iter(o.readline, '')
|
||||
with patch('builtins.open', Mock(side_effect=[mock_open()(), mock_read_auto(), IOError])),\
|
||||
with patch('builtins.open', Mock(side_effect=[mock_open()(), mock_read_auto(), IOError])), \
|
||||
patch('os.chmod', Mock()):
|
||||
self.p.config.write_postgresql_conf()
|
||||
|
||||
@@ -346,8 +357,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, 'start', Mock())
|
||||
def test_follow(self):
|
||||
self.p.call_nowait(CallbackAction.ON_START)
|
||||
m = RemoteMember.from_name_and_data('1', {'restore_command': '2', 'primary_slot_name': 'foo',
|
||||
'conn_kwargs': {'host': 'bar'}})
|
||||
m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'bar'}})
|
||||
self.p.follow(m)
|
||||
with patch.object(Postgresql, 'ensure_major_version_is_known', Mock(return_value=False)):
|
||||
self.assertIsNone(self.p.follow(m))
|
||||
@@ -496,8 +506,8 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
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'])),\
|
||||
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()
|
||||
|
||||
@@ -523,8 +533,9 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
def test_save_configuration_files(self):
|
||||
self.p.config.save_configuration_files()
|
||||
|
||||
@patch('os.path.isfile', Mock(side_effect=[False, True]))
|
||||
@patch('shutil.copy', Mock(side_effect=IOError))
|
||||
@patch('os.path.isfile', Mock(side_effect=[False, True, False, True]))
|
||||
@patch('shutil.copy', Mock(side_effect=[None, IOError]))
|
||||
@patch('os.chmod', Mock())
|
||||
def test_restore_configuration_files(self):
|
||||
self.p.config.restore_configuration_files()
|
||||
|
||||
@@ -671,7 +682,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertIsNone(self.p.wait_for_startup())
|
||||
|
||||
def test_get_server_parameters(self):
|
||||
config = {'parameters': {'wal_level': 'hot_standby'}, 'listen': '0'}
|
||||
config = {'parameters': {'wal_level': 'hot_standby', 'max_prepared_transactions': 100}, 'listen': '0'}
|
||||
self.p._global_config = GlobalConfig({'synchronous_mode': True})
|
||||
self.p.config.get_server_parameters(config)
|
||||
self.p._global_config = GlobalConfig({'synchronous_mode': True, 'synchronous_mode_strict': True})
|
||||
@@ -704,6 +715,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertEqual(self.p.get_primary_timeline(), 1)
|
||||
|
||||
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica'))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=False))
|
||||
@patch.object(Bootstrap, 'running_custom_bootstrap', PropertyMock(return_value=True))
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={'max_connections setting': '200',
|
||||
'max_worker_processes setting': '20',
|
||||
@@ -947,6 +959,7 @@ class TestPostgresql2(BaseTestPostgresql):
|
||||
@patch('patroni.postgresql.CallbackExecutor', Mock())
|
||||
@patch.object(Postgresql, 'get_major_version', Mock(return_value=140000))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
|
||||
def setUp(self):
|
||||
super(TestPostgresql2, self).setUp()
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import tempfile
|
||||
import time
|
||||
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL,\
|
||||
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL, \
|
||||
Raft, RaftError, SyncObjUtility, TCPTransport, _TCPTransport
|
||||
from pysyncobj import SyncObjConf, FAIL_REASON
|
||||
|
||||
|
||||
@@ -65,14 +65,14 @@ class TestRewind(BaseTestPostgresql):
|
||||
|
||||
def test_pg_rewind(self):
|
||||
r = {'user': '', 'host': '', 'port': '', 'database': '', 'password': ''}
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=150000)),\
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=150000)), \
|
||||
patch.object(CancellableSubprocess, 'call', Mock(return_value=None)):
|
||||
with patch('subprocess.check_output', Mock(return_value=b'boo')):
|
||||
self.assertFalse(self.r.pg_rewind(r))
|
||||
with patch('subprocess.check_output', Mock(side_effect=Exception)):
|
||||
self.assertFalse(self.r.pg_rewind(r))
|
||||
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=120000)),\
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=120000)), \
|
||||
patch('subprocess.check_output', Mock(return_value=b'foo %f %p %r %% % %')):
|
||||
with patch.object(CancellableSubprocess, 'call', mock_cancellable_call):
|
||||
self.assertFalse(self.r.pg_rewind(r))
|
||||
@@ -91,9 +91,9 @@ class TestRewind(BaseTestPostgresql):
|
||||
'Latest checkpoint location': '0/'})):
|
||||
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
|
||||
|
||||
with patch.object(Postgresql, 'is_running', Mock(return_value=True)),\
|
||||
with patch.object(Postgresql, 'is_running', Mock(return_value=True)), \
|
||||
patch.object(MockCursor, 'fetchone',
|
||||
Mock(side_effect=[(0, 0, 1, 1, 0, 0, 0, 0, 0, None, None, None), Exception])):
|
||||
Mock(side_effect=[Exception, (0, 0, 1, 1, 0, 0, 0, 0, 0, None, None, None)])):
|
||||
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
|
||||
|
||||
@patch.object(CancellableSubprocess, 'call', mock_cancellable_call)
|
||||
@@ -239,7 +239,7 @@ class TestRewind(BaseTestPostgresql):
|
||||
|
||||
with patch('os.listdir', Mock(return_value=['000000000000000000000000.ready'])):
|
||||
# successful archive_command call
|
||||
with patch.object(CancellableSubprocess, 'call', Mock(return_value=0)):
|
||||
with patch.object(CancellableSubprocess, 'call', Mock(return_value=0)) as mock_subprocess_call:
|
||||
get_guc_value_res = [
|
||||
'on', 'command %f',
|
||||
'always', 'command %f',
|
||||
@@ -252,6 +252,10 @@ class TestRewind(BaseTestPostgresql):
|
||||
'000000000000000000000000', 'command 000000000000000000000000'),
|
||||
mock_logger_info.call_args[0])
|
||||
mock_logger_info.reset_mock()
|
||||
mock_subprocess_call.assert_called_once()
|
||||
self.assertEqual(mock_subprocess_call.call_args[0][0], ['command 000000000000000000000000'])
|
||||
self.assertEqual(mock_subprocess_call.call_args[1]['shell'], True)
|
||||
mock_subprocess_call.reset_mock()
|
||||
|
||||
# failed archive_command call
|
||||
with patch.object(CancellableSubprocess, 'call', Mock(return_value=1)):
|
||||
|
||||
+31
-11
@@ -7,6 +7,7 @@ from mock import Mock, PropertyMock, patch
|
||||
from threading import Thread
|
||||
|
||||
from patroni import psycopg
|
||||
from patroni.config import GlobalConfig
|
||||
from patroni.dcs import Cluster, ClusterConfig, Member, SyncState
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.misc import fsync_dir
|
||||
@@ -28,6 +29,7 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def setUp(self):
|
||||
super(TestSlotsHandler, self).setUp()
|
||||
self.p._global_config = GlobalConfig({})
|
||||
self.s = self.p.slots_handler
|
||||
self.p.start()
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
|
||||
@@ -43,12 +45,13 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
self.p.set_role('standby_leader')
|
||||
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=(True, False))),\
|
||||
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=(True, False))), \
|
||||
patch.object(GlobalConfig, 'is_standby_cluster', PropertyMock(return_value=True)), \
|
||||
patch('patroni.postgresql.slots.logger.debug') as mock_debug:
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
mock_debug.assert_called_once()
|
||||
self.p.set_role('replica')
|
||||
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)),\
|
||||
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)), \
|
||||
patch.object(SlotsHandler, 'drop_replication_slot') as mock_drop:
|
||||
self.s.sync_replication_slots(cluster, False, paused=True)
|
||||
mock_drop.assert_not_called()
|
||||
@@ -67,6 +70,23 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90618)):
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
|
||||
def test_cascading_replica_sync_replication_slots(self):
|
||||
"""Test sync with a cascading replica so physical slots are present on a replica."""
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
|
||||
cascading_replica = Member(0, 'test-2', 28, {
|
||||
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
|
||||
'tags': {'replicatefrom': 'postgresql0'}
|
||||
})
|
||||
cluster = Cluster(True, config, self.leader, 0,
|
||||
[self.me, self.other, self.leadermem, cascading_replica],
|
||||
None, SyncState.empty(), None, {'ls': 10}, None)
|
||||
self.p.set_role('replica')
|
||||
with patch.object(Postgresql, '_query') as mock_query, \
|
||||
patch.object(Postgresql, 'is_leader', Mock(return_value=False)):
|
||||
mock_query.return_value = [('ls', 'logical', 'b', 'a', 5, 12345, 105)]
|
||||
ret = self.s.sync_replication_slots(cluster, False)
|
||||
self.assertEqual(ret, [])
|
||||
|
||||
def test_process_permanent_slots(self):
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}},
|
||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||
@@ -93,11 +113,11 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
def test__ensure_logical_slots_replica(self):
|
||||
self.p.set_role('replica')
|
||||
self.cluster.slots['ls'] = 12346
|
||||
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock()):
|
||||
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock(return_value=False)):
|
||||
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
|
||||
self.s._schedule_load_slots = False
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)),\
|
||||
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))),\
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
|
||||
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))), \
|
||||
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
|
||||
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
|
||||
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
|
||||
@@ -119,14 +139,14 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
|
||||
def test_check_logical_slots_readiness(self):
|
||||
self.s.copy_logical_slots(self.cluster, ['ls'])
|
||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))),\
|
||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
|
||||
patch.object(MockCursor, 'fetchone', Mock(side_effect=Exception)):
|
||||
self.assertIsNone(self.s.check_logical_slots_readiness(self.cluster, False, None))
|
||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))),\
|
||||
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
|
||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
|
||||
patch.object(MockCursor, 'fetchone', Mock(return_value=(False,))):
|
||||
self.assertIsNone(self.s.check_logical_slots_readiness(self.cluster, False, None))
|
||||
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
|
||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('ls', 100)]))):
|
||||
self.s.check_logical_slots_readiness(self.cluster, False, None)
|
||||
self.s.check_logical_slots_readiness(self.cluster, None)
|
||||
|
||||
@patch.object(Postgresql, 'stop', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
||||
@@ -144,7 +164,7 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
self.assertRaises(OSError, fsync_dir, 'foo')
|
||||
|
||||
def test_slots_advance_thread(self):
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)),\
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
|
||||
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
|
||||
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
|
||||
self.s.schedule_advance_slots({'foo': {'bar': 100}})
|
||||
|
||||
@@ -15,7 +15,8 @@ config = {
|
||||
"scope": "string",
|
||||
"restapi": {
|
||||
"listen": "127.0.0.2:800",
|
||||
"connect_address": "127.0.0.2:800"
|
||||
"connect_address": "127.0.0.2:800",
|
||||
"verify_client": 'none'
|
||||
},
|
||||
"bootstrap": {
|
||||
"dcs": {
|
||||
|
||||
@@ -7,7 +7,7 @@ from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from kazoo.protocol.states import KeeperState, ZnodeStat
|
||||
from kazoo.retry import RetryFailedError
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.zookeeper import Cluster, Leader, PatroniKazooClient,\
|
||||
from patroni.dcs.zookeeper import Cluster, Leader, PatroniKazooClient, \
|
||||
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
||||
|
||||
|
||||
@@ -276,6 +276,7 @@ class TestZooKeeper(unittest.TestCase):
|
||||
self.assertTrue(self.zk.delete_cluster())
|
||||
|
||||
def test_watch(self):
|
||||
self.zk.event.wait = Mock()
|
||||
self.zk.watch(None, 0)
|
||||
self.zk.event.is_set = Mock(return_value=True)
|
||||
self.zk._fetch_status = False
|
||||
|
||||
@@ -6,6 +6,7 @@ postgres_matrix =
|
||||
pg13: PG_MAJOR = 13
|
||||
pg14: PG_MAJOR = 14
|
||||
pg15: PG_MAJOR = 15
|
||||
pg16: PG_MAJOR = 16
|
||||
psycopg_deps =
|
||||
py{37,38,39,310,311}-{lin,win}: psycopg[binary]
|
||||
mac: psycopg2-binary
|
||||
@@ -77,6 +78,7 @@ platform =
|
||||
{[common]platforms}
|
||||
allowlist_externals =
|
||||
rm
|
||||
true
|
||||
{env:OPEN_CMD}
|
||||
|
||||
[testenv:dep]
|
||||
@@ -105,7 +107,7 @@ description = Reformat code with black
|
||||
deps = black
|
||||
commands = black {posargs:patroni tests}
|
||||
|
||||
[testenv:pg{12,13,14,15}-docker-build]
|
||||
[testenv:pg{12,13,14,15,16}-docker-build]
|
||||
description = Build docker containers needed for testing
|
||||
labels =
|
||||
behave
|
||||
@@ -123,7 +125,7 @@ commands =
|
||||
--file features/Dockerfile
|
||||
allowlist_externals = docker
|
||||
|
||||
[testenv:pg{12,13,14,15}-docker-behave-{etcd}-{lin,mac}]
|
||||
[testenv:pg{12,13,14,15,16}-docker-behave-{etcd}-{lin,mac}]
|
||||
description = Run behaviour tests in patroni-dev docker container
|
||||
setenv =
|
||||
etcd: DCS=etcd
|
||||
@@ -132,7 +134,7 @@ setenv =
|
||||
labels =
|
||||
behave
|
||||
depends =
|
||||
pg{11,12,13,14,15}-docker-build
|
||||
pg{11,12,13,14,15,16}-docker-build
|
||||
|
||||
# There's a bug which affects calling multiple envs on the command line
|
||||
# This should be a valid command: tox -e 'py{36,37,38,39,310,311}-behave-{env:DCS}-lin'
|
||||
@@ -174,24 +176,52 @@ platform =
|
||||
{[common]platforms}
|
||||
|
||||
[testenv:docs-{lin,mac,win}]
|
||||
description = Build Sphinx documentation
|
||||
description = Build Sphinx documentation in HTML format
|
||||
labels:
|
||||
docs
|
||||
deps =
|
||||
sphinx>=4
|
||||
sphinx_rtd_theme
|
||||
-r requirements.docs.txt
|
||||
-r requirements.txt
|
||||
psycopg[binary]
|
||||
psycopg2-binary
|
||||
commands =
|
||||
sphinx-build \
|
||||
-d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" \
|
||||
--color -b html \
|
||||
-T -E -W --keep-going \
|
||||
{posargs}
|
||||
commands_post =
|
||||
- {tty:{env:OPEN_CMD} "{toxworkdir}{/}docs_out{/}index.html":true:}
|
||||
allowlist_externals =
|
||||
true
|
||||
{env:OPEN_CMD}
|
||||
platform =
|
||||
{[common]platforms}
|
||||
|
||||
[testenv:pdf-{lin,mac,win}]
|
||||
description = Build Sphinx documentation in PDF format
|
||||
labels:
|
||||
docs
|
||||
deps =
|
||||
-r requirements.docs.txt
|
||||
-r requirements.txt
|
||||
psycopg[binary]
|
||||
psycopg2-binary
|
||||
commands =
|
||||
python -m sphinx -T -E -b latex -d _build/doctrees -D language=en . pdf
|
||||
- latexmk -r pdf/latexmkrc -cd -C pdf/Patroni.tex
|
||||
latexmk -r pdf/latexmkrc -cd -pdf -f -dvi- -ps- -jobname=Patroni -interaction=nonstopmode pdf/Patroni.tex
|
||||
|
||||
commands_post =
|
||||
- {tty:{env:OPEN_CMD} "pdf{/}Patroni.pdf":true:}
|
||||
allowlist_externals =
|
||||
true
|
||||
latexmk
|
||||
{env:OPEN_CMD}
|
||||
platform =
|
||||
{[common]platforms}
|
||||
change_dir = docs
|
||||
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
ignore = D401,W503
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool
|
||||
from .poolmanager import PoolManager
|
||||
from .response import HTTPResponse
|
||||
from .util.request import make_headers
|
||||
from .util.timeout import Timeout
|
||||
|
||||
__all__ = ['HTTPResponse', 'PoolManager', 'Timeout', 'make_headers']
|
||||
__all__ = ['HTTPResponse', 'HTTPConnectionPool', 'HTTPSConnectionPool', 'PoolManager', 'Timeout', 'make_headers']
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
class HTTPConnectionPool: ...
|
||||
class HTTPSConnectionPool(HTTPConnectionPool): ...
|
||||
Reference in New Issue
Block a user