Compare commits

...
Author SHA1 Message Date
48fbf64ea9 Release v3.3.0 (#3043)
* Make sure tests are not making external calls
and pass url with scheme to urllib3 to avoid warnings

* Make sure unit tests not rely on filesystem state

* Bump pyright and "solve" reported "issues"

Most of them are related to partially unknown types of values from empty
dict or list. To solve it for the empty dict we use `EMPTY_DICT` object of
newly introduced `_FrozenDict` class.

* Improve unit-tests code coverage

* Add release notes for 3.3.0

* Bump version

* Fix pyinstaller spec file

* python 3.6 compatibility

---------

Co-authored-by: Polina Bungina <[email protected]>
2024-04-04 17:51:26 +02:00
Alexander KukushkinandGitHub 3fd7c98d2b Compatibility with ydiff>=1.3 (#3042)
PatchStream class was removed as it was effectively doing nothing useful.
2024-04-02 14:48:58 +02:00
Alexander KukushkinandGitHub d7454f7bcd Use target_session_attrs only when multiple hosts in standby_cluster (#3040)
Actually comment in the code was already saying that, but on practice it didn't happen.

It should help #3039
2024-04-02 11:59:57 +02:00
WaynervandGitHub ceb2965ab8 Use importlib_resources to read validators file (#3018)
When packaged into pyz (zip file), resources are not directly available on filesystem and therefore we can't always rely on os.listdir() and open() to enumerate and read them.

We are going to use importlib.resources() to solve this problem, except python 3.8 and older, where there is no function (files()) available to enumerate resources. For legacy (3.8 actually becomes EOL in October 2024) python versions we are going to use os.listdir() as a fallback.

Close #3017
2024-04-02 09:00:37 +02:00
Polina BunginaandGitHub ae53260030 Extend behave tests with nostream feature (#3036)
Check state and permanent logical replication slots behaviour
2024-03-29 12:54:40 +01:00
Polina BunginaandGitHub 9b237b332e Set global_config from dynamic_config if DCS data is empty (#3038)
Fix the oversight of 193c73f
We need to set global config from the local cache if cluster.config is not initialized.
If there is nothing written into the DCS (yet), we need the setup info for the decision making (e.g., if it is a standby cluster)
2024-03-28 08:15:58 +01:00
Grigory SmolkinandGitHub b09af642e6 Disable WAL streaming on standby node via new boolean tag "nostream" (#2842)
Add support for ``nostream`` tag. If set to ``true`` the node will not use replication protocol to stream WAL. It will rely instead on archive recovery (if ``restore_command`` is configured) and ``pg_wal``/``pg_xlog`` polling. It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas. Setting this tag on primary node has no effect.
2024-03-20 10:10:53 +01:00
IsraelandGitHub 014777b20a Refactor Barman scripts and add a sub-command to switch Barman config (#3016)
We currently have a script named `patroni_barman_recover` in Patroni, which is intended to be used as a custom bootstrap method, or as a custom replica creation method.

Now there is need of one more Barman related script in Patroni to handle switching of config models in Barman upon `on_role_change` events.

However, instead of creating another Patroni script, let's say `patroni_barman_config_switch`, and duplicating a lot of logic in the code, we decided to refactor the code so:

* Instead of two separate scripts (`patroni_barman_recover` and `patroni_barman_config_switch`), we have a single script (`patroni_barman`) with 2 sub-commands (`recover` and `config-switch`)

This is the overview of changes that have been performed:

* File `patroni.scripts.barman_recover` has been removed, and its logic has been split into a few files:
  * `patroni.scripts.barman.cli`: handles the entrypoint of the new `patroni_barman` command, exposing the argument parser and calling the appropriate functions depending on the sub-command
  * `patroni.scripts.barman.utils`: implements utilitary enums, functions and classes wich can be used by `cli` and by sub-commands implementation:
    * retry mechanism
    * logging set up
    * communication with pg-backup-api
  * `patroni.scripts.barman.recover`: implements the `recover` sub-command only
* File `patroni.tests.test_barman_recover` has been renamed as `patroni.tests.test_barman`
* File `patroni.scripts.barman.config_switch` was created to implement the `config-switch` sub-command only
* `setup.py` has been changed so it generates a `patroni_barman` application instead of `patroni_barman_recover`
* Docs and unit tests were updated accordingly

References: PAT-154.
2024-03-20 09:04:55 +01:00
Alexander KukushkinandGitHub a8cfd46801 Retry one time on Etcd3 auth error (#3026)
But do it only in case if we didn't authenticate right before executing a request. Previously retries only happened when the caller was executed with `Retry.__call__()`, which is not the case for methods like `set_failover_value()` or `set_config_value()`. Also, it seems that existing watchers aren't affected, therefore we will not restart them after reauthentication.

In addition to that fix issues with `Retry.ensure_deadline(0)`:
1. the return value was ignored
2. we don't have to set `Retry.deadline` attr, it is not used anywhere

Close https://github.com/zalando/patroni/issues/3023
2024-03-07 12:01:35 +01:00
Junwang ZhaoandGitHub fd3e3ca472 add missing busybox install command (#3029)
before this patch, when execute `ps` inside container, we see
the following error:

postgres@a97c9e438eae:~$ ps
bash: ps: command not found

Signed-off-by: Zhao Junwang <[email protected]>
2024-03-06 08:06:47 +01:00
zhjwpkuandGitHub e131065d74 rename citus_handler to mpp_handler (#2991)
obey the following 5 meanings of terminology _cluster_ in Patroni.

1. PostgreSQL cluster: a cluster of postgresql instances which have the same system identifier.
2. MPP cluster: a cluster of PostgreSQL clusters that one of them acts as Coodinator and others act as workers.
3. Coordinator cluster: a PostgreSQL cluster which act the role of 'coordinator' within a MPP cluster.
4. Worker cluster: a PostgreSQL cluster which act the role 'worker' within a MPP cluster.
5. Patroni cluster: all cluster managed by Patroni can be called Patroni cluster, but we usually use this term to refering a single PostgreSQL cluster or an MPP cluster.
2024-02-28 06:16:20 +01:00
Polina BunginaandGitHub bdd02324b4 Add pending restart reason information (#2978)
Provide info about the PG parameters that caused "pending restart"
flag to be set. Both `patronictl list` and `/patroni` REST API endpoint
now show the parameters names and the diff as the "pending restart
reason".
2024-02-14 08:54:20 +01:00
IsraelandGitHub 7adfc0dbe7 Patroni doesn't filter out some not allowed options from pg_basebackup (#3015)
When running `pg_basebackup` to bootstrap a replica, Patroni sanitizes
the custom user options that come from `postgresql.basebackup` configuration
section using the `process_user_options` method.

However, there is a bug in that method: it filters out not allowed options
that are in the format `- setting`, but not the ones in the format
`- setting: value` from `postgresql.basebackup`.

An example of that issue is the `dbname` setting. If you specify something
like this in the configuration file:

```yaml
postgresql:
  basebackup:
    - dbname: "host=RANDOM"
```

You end up with `--dbname` being specified twice for `pg_basebackup`, with
`--dbname='host=RANDOM'` taking precedence as it comes up later in the
command.

This commit fixes that issue by adding a `continue` statement when
the setting in format `- setting: value` is not allowed, thus skipping
it.

---------

Signed-off-by: Israel Barth Rubio <[email protected]>
2024-02-06 08:36:11 +01:00
Polina BunginaandGitHub f6943a859d Improve logging for Pg param change (#3008)
* Convert old value to a human-readable format
* Add log line about pg_controldata/global config mismatch that causes
  pending restart flag to be set
2024-01-29 10:44:25 +01:00
Alexander KukushkinandGitHub e532f9dc38 Fix bugs introduced in the jsonlog implementation (#3006)
1. RotatingFileHandler is a child of StreamHandler, therefore we can't rely on `not isinstance(handler, logging.StreamHandler)`.
2. If the legacy version of `python-json-logger` is installed (that doesn't support rename_fields or static_fields), we want do what is possible rather than fail with the exception.

Besides that:
1. improve code coverage
2. make unit tests pass without python-json-logger installed or if only some old version is installed.
2024-01-29 10:37:15 +01:00
688c85389c Release v3.2.2 (#3007)
- update release notes
- bump Patroni version
- bump pyright version and fix reported issues
- improve compatibility with legacy psycopg2

Co-authored-by: Polina Bungina <[email protected]>
2024-01-17 08:31:08 +01:00
علی سالمیandGitHub 5c4ee30dae Add JSON log format to logging configuration (#2982)
Now patroni can be configured as bellow to log in json format.

```yaml
log:
  type: json
  format:
    - asctime: '@timestamp'
    - levelname: level
    - message
    - module
    - name: logger_name
  static_fields:
    app: patroni
```

This config produce this log:

```json
{
  "@timestamp": "2023-12-14 19:51:24,872",
  "level": "INFO",
  "message": "Lock owner: None; I am postgresql1",
  "module": "ha",
  "app": "patroni",
  "logger_name": "patroni.ha"
}
```
2024-01-16 10:42:48 +01:00
Polina BunginaandGitHub 266cdc4810 Fixes around pending_restart flag (#3003)
* Do not set pending_restart flag if hot_standby is set to 'off' during a custom bootstrap (even though we will have this flag actually set in PG, this configuration parameter is irrelevant on primary and there is no actual need for restart)
* Skip hot_standby and wal_log_hints when querying parameters pending restart on config reload. They actually can be changed manually (e.g. via ALTER SYSTEM) and it will cause the pending_restart state in PG but Patroni anyway always passes those params to postmaster as command line options. And there they only can have one value - 'on' (except on primary when performing custom bootstrap)
2024-01-16 10:32:28 +01:00
Alexander KukushkinandGitHub 2ac1efea54 Optimize priority failover behave tests (#3004)
1. get rid of useless sleep calls
2. call `POST /failover` on the node where we want to failover to
2024-01-15 12:03:14 +01:00
Alexander KukushkinandGitHub 5d8c2fb559 Restore recovery GUCs when joining running standby (#2998)
Close https://github.com/zalando/patroni/issues/2993
2024-01-08 08:35:53 +01:00
IsraelandGitHub 4e5b2ee249 Close the doors for a possible future bug in the config generator (#3000)
The `AbstractConfigGenerator._format_config` method was missing a comma in the declaration of a tuple. As a consequence it was concatenating the strings `ctl` and `citus` instead of creating two separate items in the tuple.

There is currently no observed bug from that issue in the code because the template configuration created by the method `AbstractConfigGenerator.get_template_config` doesn't include either of `ctl` or `citus` keys.

However, it is still important that we close the doors for possible future bugs that would come up if we ever attempt to use either of those keys in the template, for example.

References: PAT-231.
2024-01-04 12:30:28 +01:00
Sophia RuanandGitHub 3390ee9dea call freeze_support in main module to solve pyinstaller frozen issue (#2996)
Close #2995
2024-01-04 12:30:03 +01:00
Polina BunginaandGitHub 71ccf91e36 Don't filter out contradictory nofailover tag (#2992)
* Ensure that nofailover will always be used if both nofailover and
failover_priority tags are provided
* Call _validate_failover_tags from reload_local_configuration() as well
* Properly check values in the _validate_failover_tags(): nofailover value should be casted to boolean like it is done when accessed in other places
2024-01-02 09:30:18 +01:00
zhjwpkuandGitHub 8acefefc42 Fix Citus bootstrap - CREATE DATABASE cannot be executed from a function (#2994)
This was introduced by #2990: pod cannot be started and show the
following logs:

```
2023-12-26 03:29:25.569 UTC [47] CONTEXT:  SQL statement "CREATE DATABASE "citus""
        PL/pgSQL function inline_code_block line 5 at SQL statement
2023-12-26 03:29:25.569 UTC [47] STATEMENT:  DO $$
        BEGIN
            PERFORM * FROM pg_catalog.pg_database WHERE datname = 'citus';
            IF NOT FOUND THEN
                CREATE DATABASE "citus";
            END IF;
        END;$$
2023-12-26 03:29:25,570 ERROR: post_bootstrap
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/dist-packages/patroni/postgresql/bootstrap.py", line 474, in post_bootstrap
    self._postgresql.citus_handler.bootstrap()
  File "/usr/local/lib/python3.11/dist-packages/patroni/postgresql/mpp/citus.py", line 401, in bootstrap
    cur.execute(sql.encode('utf-8'))
psycopg2.errors.ActiveSqlTransaction: CREATE DATABASE cannot be executed from a function
CONTEXT:  SQL statement "CREATE DATABASE "citus""
PL/pgSQL function inline_code_block line 5 at SQL statement
```
---------

Signed-off-by: Zhao Junwang <[email protected]>
2023-12-29 09:01:46 +01:00
Alexander KukushkinandGitHub dd548c4964 Create citus database and extension idempotently (#2990)
Consider a task: we want to create an extension _before_ citus in a database. Currently `post_bootstrab` script is executed before `CitusHandler.bootstrap()` method, which seems to allow doing that, but in fact `CitusHandler.bootstrap()` will fail to create already existing database and as a result the whole bootstrap will fail.

Changing the order of execution of `post_bootstrab` hook and `CitusHandler.bootstrap()` seems to be useless, because it will not allow creating another extension _before_ citus. Therefore the only way of solving it is making CREATE DATABASE and CREATE EXTENSION idempotent. It will allow to create citus database and all dependencies from the `post_bootstrab` hook.
2023-12-21 09:25:51 +01:00
bcfd8438a5 Abstract CitusHandler and decouple it from configuration (#2950)
the main issue was that the configuration for Citus handler and for DCS existed in two places, while ideally AbstractDCS should not know many details about what kind of MPP is in use.

To solve the problem we first dynamically create an object implementing AbstractMPP interfaces, which is a configuration for DCS. Later this object is used to instantiate the class implementing AbstractMPPHandler interface.

This is just a starting point, which does some heavy lifting. As a next steps all kind of variables named after Citus in files different from patroni/postgres/mpp/citus.py should be renamed.

In other words this commit takes over the most complex part of #2940, which was never implemented.

Co-authored-by: zhjwpku <[email protected]>
2023-12-21 08:58:26 +01:00
Alexander KukushkinandGitHub 5c3e1a693e Implement validation of the log section (#2989)
Somehow it was always forgotten.
2023-12-20 10:49:33 +01:00
Polina BunginaandGitHub 206ee91b07 Exclude leader from failover candidates in ctl (#2983)
Exclude actual leader (not the passed leader argument) from the
candidates list in the `patronictl failover` prompt.
Abort `patronictl failover` execution if candidate specified is
the same as the current cluster leader
2023-12-20 09:54:04 +01:00
Polina BunginaandGitHub c1ee99d81d Update PG version in a couple of places (#2986)
* All dockerfiles to use PG16 by default
* PGVERSION env in the test pipelines to 16.1-1 by default
* 11->14 in the dcs-pg mapping for test pipelines
* Code comments fixes
2023-12-18 10:44:05 +01:00
93 changed files with 4256 additions and 1574 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ def install_etcd():
def install_postgres():
version = os.environ.get('PGVERSION', '15.1-1')
version = os.environ.get('PGVERSION', '16.1-1')
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
if platform == 'osx':
return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])])
+1 -1
View File
@@ -1 +1 @@
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '14', 'kubernetes': '15'}
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
unbuffer = ['timeout', '900', 'unbuffer']
else:
if sys.platform == 'darwin':
version = os.environ.get('PGVERSION', '15.1-1')
version = os.environ.get('PGVERSION', '16.1-1')
path = '/usr/local/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
unbuffer = ['unbuffer']
else:
+2 -2
View File
@@ -85,7 +85,7 @@ jobs:
env:
DCS: ${{ matrix.dcs }}
ETCDVERSION: 3.4.23
PGVERSION: 15.1-1 # for windows and macos
PGVERSION: 16.1-1 # for windows and macos
strategy:
fail-fast: false
matrix:
@@ -174,7 +174,7 @@ jobs:
- uses: jakebailey/pyright-action@v1
with:
version: 1.1.338
version: 1.1.356
docs:
runs-on: ubuntu-latest
+3 -1
View File
@@ -1,6 +1,6 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
## It has all the necessary components to play/debug with a single node appliance, running etcd
ARG PG_MAJOR=15
ARG PG_MAJOR=16
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
@@ -125,6 +125,8 @@ RUN if [ "$COMPRESS" = "true" ]; then \
&& /bin/busybox sh -c "(find $save_dirs -not -type d && cat /exclude /exclude && echo exclude) | sort | uniq -u | xargs /bin/busybox rm" \
&& /bin/busybox --install -s \
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
else \
/bin/busybox --install -s; \
fi
FROM scratch
+2 -2
View File
@@ -1,6 +1,6 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
## It has all the necessary components to play/debug with a single node appliance, running etcd
ARG PG_MAJOR=15
ARG PG_MAJOR=16
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
@@ -40,7 +40,7 @@ RUN set -ex \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
&& apt-get -y install postgresql-$PG_MAJOR-citus-12.1; \
fi \
\
# Cleanup all locales but en_US.UTF-8
+9 -1
View File
@@ -14,10 +14,18 @@ Global/Universal
Log
---
- **PATRONI\_LOG\_TYPE**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
- **PATRONI\_LOG\_LEVEL**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **PATRONI\_LOG\_TRACEBACK\_LEVEL**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **PATRONI\_LOG\_LEVEL=DEBUG**.
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. If the log type is **plain**, the log format should be a string.
Refer to `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
Default value is **%(asctime)s %(levelname)s: %(message)s**
- **PATRONI\_LOG\_DATEFORMAT**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **PATRONI\_LOG\_STATIC\_FIELDS**: add additional fields to the log. This option is only available when the log type is set to **json**. Example ``PATRONI_LOG_STATIC_FIELDS="{app: patroni}"``
- **PATRONI\_LOG\_MAX\_QUEUE\_SIZE**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **PATRONI\_LOG\_DIR**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this env variable, the application will retain 4 25MB logs by default. You can tune those retention values with `PATRONI_LOG_FILE_NUM` and `PATRONI_LOG_FILE_SIZE` (see below).
- **PATRONI\_LOG\_FILE\_NUM**: The number of application logs to retain.
+4
View File
@@ -108,3 +108,7 @@ Note: if cluster topology is static (fixed number of nodes that never change the
.. warning::
Permanent replication slots are synchronized only from the ``primary``/``standby_leader`` to replica nodes. That means, applications are supposed to be using them only from the leader node. Using them on replica nodes will cause indefinite growth of ``pg_wal`` on all other nodes in the cluster.
An exception to that rule are permanent physical slots that match the Patroni member names, if you happen to configure any. Those will be synchronized among all nodes as they are used for replication among them.
.. warning::
Setting ``nostream`` tag on standby disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas if any.
+2
View File
@@ -28,12 +28,14 @@ Currently supported PostgreSQL versions: 9.3 to 16.
patronictl
replica_bootstrap
replication_modes
standby_cluster
watchdog
pause
dcs_failsafe_mode
kubernetes
citus
existing_data
tools_integration
security
ha_multi_dc
faq
+2
View File
@@ -60,6 +60,8 @@ raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto3` in order to use AWS callbacks
jsonlogger
`python-json-logger` module in order to enable :ref:`logging <log_settings>` in json format
all
all of the above (except psycopg family)
psycopg
+120
View File
@@ -3,6 +3,126 @@
Release notes
=============
Version 3.3.0
-------------
.. warning::
All older Partoni versions are not compatible with ``ydiff>=1.3``.
There are the following options available to "fix" the problem:
1. upgrade Patroni to the latest version
2. install ``ydiff<1.3`` after installing Patroni
3. install ``cdiff`` module
**New features**
- Add ability to pass ``auth_data`` to Zookeeper client (Aras Mumcuyan)
It allows to specify the authentication credentials to use for the connection.
- Add a contrib script for ``Barman`` integration (Israel Barth Rubio)
Provide an application ``patroni_barman`` that allows to perform ``Barman`` operations remotely and can be used as a custom bootstrap/custom replica method or as an ``on_role_change`` callback. Please check :ref:`here <tools_integration>` for more information.
- Support ``JSON`` log format (alisalemmi)
Apart from ``plain`` (default), Patroni now also supports ``json`` log format. Requires ``python-json-logger>=2.0.2`` library to be installed.
- Show ``pending_restart_reason`` information (Polina Bungina)
Provide extended information about the PostgreSQL parameters that caused ``pending_restart`` flag to be set. Both ``patronictl list`` and ``/patroni`` REST API endpoint now show the parameters names and their "diff" as ``pending_restart_reason``.
- Implement ``nostream`` tag (Grigory Smolkin)
If ``nostream`` tag is set to ``true``, the node will not use replication protocol to stream WAL but instead rely on archive recovery (if ``restore_command`` is configured). It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas.
**Improvements**
- Implement validation of the ``log`` section (Alexander Kukushkin)
Until now validator was not checking the correctness of the logging configuration provided.
- Improve logging for PostgreSQL parameters change (Polina Bungina)
Convert old values to a human-readable format and log information about the ``pg_controldata`` vs Patroni global configuration mismatch.
**Bugfixes**
- Properly filter out not allowed ``pg_basebackup`` options (Israel Barth Rubio)
Due to a bug, Patroni was not properly filtering out the not allowed options configured for the ``basebackup`` replica bootstrap method, when provided in the ``- setting: value`` format.
- Fix ``etcd3`` authentication error handling (Alexander Kukushkin)
Always retry one time on ``etcd3`` authentication error if authentication was not done right before executing the request. Also, do not restart watchers on reauthentication.
- Improve logic of the validator files discovery (Waynerv)
Use ``importlib`` library to discover the files with available configuration parameters when possible (for Python 3.9+). This implementation is more stable and doesn't break the Patroni distributions based on ``zip`` archives.
- Use ``target_session_attrs`` only when multiple hosts are specified in the ``standby_cluster`` section (Alexander Kukushkin)
``target_session_attrs=read-write`` is now added to the ``primary_conninfo`` on the standby leader node only when ``standby_cluster.host`` section contains multiple hosts separated by commas.
- Add compatibility code for ``ydiff`` library version 1.3+ (Alexander Kukushkin)
Patroni is relying on some API from ``ydiff`` that is not public because it is supposed to be just a terminal tool rather than a python module. Unfortunately, the API change in 1.3 broke old Patroni versions.
Version 3.2.2
-------------
**Bugfixes**
- Don't let replica restore initialize key when DCS was wiped (Alexander Kukushkin)
It was happening in the method where Patroni was supposed to take over a standalone PG cluster.
- Use consistent read when fetching just updated sync key from Consul (Alexander Kukushkin)
Consul doesn't provide any interface to immediately get ``ModifyIndex`` for the key that we just updated, therefore we have to perform an explicit read operation. Since stale reads are allowed by default, we sometimes used to get an outdated version of the key.
- Reload Postgres config if a parameter that requires restart was reset to the original value (Polina Bungina)
Previously Patroni wasn't updating the config, but only resetting the ``pending_restart``.
- Fix erroneous inverted logic of the confirmation prompt message when doing a failover to an async candidate in synchronous mode (Polina Bungina)
The problem existed only in ``patronictl``.
- Exclude leader from failover candidates in ``patronictl`` (Polina Bungina)
If the cluster is healthy, failing over to an existing leader is no-op.
- Create Citus database and extension idempotently (Alexander Kukushkin, Zhao Junwang)
It will allow to create them in the ``post_bootstrap`` script in case if there is a need to add some more dependencies to the Citus database.
- Don't filter our contradictory ``nofailover`` tag (Polina Bungina)
The configuration ``{nofailover: false, failover_priority: 0}`` set on a node didn't allow it to participate in the race, while it should, because ``nofailover`` tag should take precedence.
- Fixed PyInstaller frozen issue (Sophia Ruan)
The ``freeze_support()`` was called after ``argparse`` and as a result, Patroni wasn't able to start Postgres.
- Fixed bug in the config generator for ``patronictl`` and ``Citus`` configuration (Israel Barth Rubio)
It prevented ``patronictl`` and ``Citus`` configuration parameters set via environment variables from being written into the generated config.
- Restore recovery GUCs and some Patroni-managed parameters when joining a running standby (Alexander Kukushkin)
Patroni was failing to restart Postgres v12 onwards with an error about missing ``port`` in one of the internal structures.
- Fixes around ``pending_restart`` flag (Polina Bungina)
Don't expose ``pending_restart`` when in custom bootstrap with ``recovery_target_action = promote`` or when someone changed ``hot_standby`` or ``wal_log_hints`` using for example ``ALTER SYSTEM``.
Version 3.2.1
-------------
+8 -64
View File
@@ -1,3 +1,5 @@
.. _replica_imaging_and_bootstrap:
Replica imaging and bootstrap
=============================
@@ -79,14 +81,13 @@ As an example, you are able to bootstrap a fresh Patroni cluster from a Barman b
method: barman
barman:
keep_existing_recovery_conf: true
command: patroni_barman_recover
api-url: https://barman-host:7480
command: patroni_barman --api-url https://barman-host:7480 recover
barman-server: my_server
ssh-command: ssh postgres@patroni-host
.. note::
``patroni_barman_recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman_recover --help``.
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
.. _custom_replica_creation:
@@ -150,16 +151,15 @@ example: Barman
- barman
- basebackup
barman:
command: patroni_barman_recover
api-url: https://barman-host:7480
command: patroni_barman --api-url https://barman-host:7480 recover
barman-server: my_server
ssh-command: ssh postgres@patroni-host
basebackup:
max-rate: '100M'
.. note::
``patroni_barman_recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman_recover --help``.
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
@@ -219,59 +219,3 @@ and
- waldir: /pg-wal-mount/external-waldir
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
.. _standby_cluster:
Standby cluster
---------------
Another available option is to run a "standby cluster", that contains only of
standby nodes replicating from some remote node. This type of clusters has:
* "standby leader", that behaves pretty much like a regular cluster leader,
except it replicates from a remote node.
* cascade replicas, that are replicating from standby leader.
Standby leader holds and updates a leader lock in DCS. If the leader lock
expires, cascade replicas will perform an election to choose another leader
from the standbys.
There is no further relationship between the standby cluster and the primary
cluster it replicates from, in particular, they must not share the same DCS
scope if they use the same DCS. They do not know anything else from each other
apart from replication information. Also, the standby cluster is not being
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
primary cluster.
For the sake of flexibility, you can specify methods of creating a replica and
recovery WAL records when a cluster is in the "standby mode" by providing
`create_replica_methods` key in `standby_cluster` section. It is distinct from
creating replicas, when cluster is detached and functions as a normal cluster,
which is controlled by `create_replica_methods` in `postgresql` section. Both
"standby" and "normal" `create_replica_methods` reference keys in `postgresql`
section.
To configure such cluster you need to specify the section ``standby_cluster``
in a patroni configuration:
.. code:: YAML
bootstrap:
dcs:
standby_cluster:
host: 1.2.3.4
port: 5432
primary_slot_name: patroni
create_replica_methods:
- basebackup
Note, that these options will be applied only once during cluster bootstrap,
and the only way to change them afterwards is through DCS.
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
of the remote primary and will not start if it does not find it after a
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
your responsibility to copy it to PGDATA.
If you use replication slots on the standby cluster, you must also create the corresponding replication slot on the primary cluster. It will not be done automatically by the standby cluster implementation. You can use Patroni's permanent replication slots feature on the primary cluster to maintain a replication slot with the same name as ``primary_slot_name``, or its default value if ``primary_slot_name`` is not provided.
+1 -1
View File
@@ -53,7 +53,7 @@ are available. As a downside, the primary is not be available for writes
blocking all client write requests until at least one synchronous replica comes
up.
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby. Setting tag ``nostream`` to true will also have the same effect.
Synchronous mode can be switched on and off via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
+78
View File
@@ -0,0 +1,78 @@
.. _standby_cluster:
Standby cluster
---------------
Patroni also support running cascading replication to a remote datacenter
(region) using a feature that is called "standby cluster". This type of
clusters has:
* "standby leader", that behaves pretty much like a regular cluster leader,
except it replicates from a remote node.
* cascade replicas, that are replicating from standby leader.
Standby leader holds and updates a leader lock in DCS. If the leader lock
expires, cascade replicas will perform an election to choose another leader
from the standbys.
There is no further relationship between the standby cluster and the primary
cluster it replicates from, in particular, they must not share the same DCS
scope if they use the same DCS. They do not know anything else from each other
apart from replication information. Also, the standby cluster is not being
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
primary cluster.
For the sake of flexibility, you can specify methods of creating a replica and
recovery WAL records when a cluster is in the "standby mode" by providing
:ref:`create_replica_methods <custom_replica_creation>` key in
`standby_cluster` section. It is distinct from creating replicas, when cluster
is detached and functions as a normal cluster, which is controlled by
`create_replica_methods` in `postgresql` section. Both "standby" and "normal"
`create_replica_methods` reference keys in `postgresql` section.
To configure such cluster you need to specify the section ``standby_cluster``
in a patroni configuration:
.. code:: YAML
bootstrap:
dcs:
standby_cluster:
host: 1.2.3.4
port: 5432
primary_slot_name: patroni
create_replica_methods:
- basebackup
Note, that these options will be applied only once during cluster bootstrap,
and the only way to change them afterwards is through DCS.
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
of the remote primary and will not start if it does not find it after a
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
your responsibility to copy it to PGDATA.
If you use replication slots on the standby cluster, you must also create the
corresponding replication slot on the primary cluster. It will not be done
automatically by the standby cluster implementation. You can use Patroni's
permanent replication slots feature on the primary cluster to maintain a
replication slot with the same name as ``primary_slot_name``, or its default
value if ``primary_slot_name`` is not provided.
In case the remote site doesn't provide a single endpoint that connects to a
primary, one could list all hosts of the source cluster in the
``standby_cluster.host`` section. When ``standby_cluster.host`` contains
multiple hosts separated by commas, Patroni will:
* add ``target_session_attrs=read-write`` to the ``primary_conninfo`` on the
standby leader node.
* use ``target_session_attrs=read-write`` when trying to determine whether we
need to run ``pg_rewind`` or when executing ``pg_rewind`` on all nodes of the
standby cluster.
There is also a possibility to replicate the standby cluster from another
standby cluster or from a standby member of the primary cluster: for that, you
need to define a single host in the ``standby_cluster.host`` section. However,
you need to beware that in this case ``pg_rewind`` will fail to execute on the
standby cluster.
+64
View File
@@ -0,0 +1,64 @@
.. _tools_integration:
Integration with other tools
============================
Patroni is able to integrate with other tools in your stack. In this section you
will find a list of examples, which although not an exhaustive list, might
provide you with ideas on how Patroni can integrate with other tools.
Barman
------
Patroni delivers an application named ``patroni_barman`` which has logic to
communicate with ``pg-backup-api``, so you are able to perform Barman operations
remotely.
This application currently has a couple of sub-commands: ``recover`` and
``config-switch``.
patroni_barman recover
^^^^^^^^^^^^^^^^^^^^^^
The ``recover`` sub-command can be used as a custom bootstrap or custom replica
creation method. You can find more information about that in
:ref:`replica_imaging_and_bootstrap`.
patroni_barman config-switch
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The ``config-switch`` sub-command is designed to be used as an ``on_role_change``
callback in Patroni. As an example, assume you are streaming WALs from your
current primary to your Barman host. In the event of a failover in the cluster
you might want to start streaming WALs from the new primary. You can accomplish
this by using ``patroni_barman config-switch`` as the ``on_role_change`` callback.
.. note::
That sub-command relies on the ``barman config-switch`` command, which is in
charge of overriding the configuration of a Barman server by applying a
pre-defined model on top of it. This command is available since Barman 3.10.
Please consult the Barman documentation for more details.
This is an example of how you can configure Patroni to apply a configuration
model in case this Patroni node is promoted to primary:
.. code:: YAML
postgresql:
callbacks:
on_role_change: >
patroni_barman
--api-url YOUR_API_URL
config-switch
--barman-server YOUR_BARMAN_SERVER_NAME
--barman-model YOUR_BARMAN_MODEL_NAME
--switch-when promoted
.. note::
``patroni_barman config-switch`` requires that you have both Barman and
``pg-backup-api`` configured in the Barman host, so it can execute a remote
``barman config-switch`` through the backup API. Also, it requires that you
have pre-configured Barman models to be applied. The above example uses a
subset of the available parameters. You can get more information running
``patroni_barman config-switch --help``, and by consulting the Barman
documentation.
+26 -1
View File
@@ -11,12 +11,22 @@ Global/Universal
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
.. _log_settings:
Log
---
- **type**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **format**: sets the log formatting string. If the log type is **plain**, the log format should be a string. Refer to
`the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
Default value is **%(asctime)s %(levelname)s: %(message)s**
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **static_fields**: add additional fields to the log. This option is only available when the log type is set to **json**.
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
- **file\_num**: The number of application logs to retain.
@@ -26,6 +36,20 @@ Log
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
Here is an example of how to config patroni to log in json format.
.. code:: YAML
log:
type: json
format:
- message
- module
- asctime: '@timestamp'
- levelname: level
static_fields:
app: patroni
.. _bootstrap_settings:
Bootstrap configuration
@@ -375,6 +399,7 @@ Tags
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races.
- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
- **nostream**: ``true`` or ``false``. If set to ``true`` the node will not use replication protocol to stream WAL. It will rely instead on archive recovery (if ``restore_command`` is configured) and ``pg_wal``/``pg_xlog`` polling. It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas. Setting this tag on primary node has no effect.
.. warning::
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
+12 -9
View File
@@ -256,10 +256,18 @@ class PatroniController(AbstractController):
'parameters': {
'wal_keep_segments': 100,
'archive_mode': 'on',
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode archive '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data', 'wal_archive'))
'archive_command':
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode archive '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data',
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/'),
'restore_command':
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode restore '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data',
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/')
}
}
}
@@ -928,11 +936,6 @@ class PatroniPoolController(object):
custom_config = {
'scope': cluster_name,
'postgresql': {
'recovery_conf': {
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
+ '--dirname {} --filename %f --pathname %p')
.format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
},
'create_replica_methods': ['no_leader_bootstrap'],
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
}
+18
View File
@@ -0,0 +1,18 @@
Feature: nostream node
Scenario: check nostream node is recovering from archive
When I start postgres0
And I configure and start postgres1 with a tag nostream true
Then "members/postgres1" key in DCS has replication_state=in archive recovery after 10 seconds
And replication works from postgres0 to postgres1 after 30 seconds
@slot-advance
Scenario: check permanent logical replication slots are not copied
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}, "slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
Then I receive a response code 200
When I run patronictl.py restart batman postgres0 --force
Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
When I configure and start postgres2 with a tag replicatefrom postgres1
Then "members/postgres2" key in DCS has replication_state=streaming after 10 seconds
And postgres1 does not have a replication slot named test_logical
And postgres2 does not have a replication slot named test_logical
+20 -4
View File
@@ -6,10 +6,9 @@ Feature: priority replication
And I configure and start postgres1 with a tag failover_priority 0
Then replication works from postgres0 to postgres1 after 20 seconds
When I shut down postgres0
And I sleep for 5 seconds
Then postgres1 role is the secondary after 10 seconds
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds
Given I start postgres0
Then postgres1 role is the secondary after 10 seconds
When I start postgres0
Then postgres0 role is the primary after 10 seconds
Scenario: check higher failover priority is respected
@@ -18,6 +17,23 @@ Feature: priority replication
Then replication works from postgres0 to postgres2 after 20 seconds
And replication works from postgres0 to postgres3 after 20 seconds
When I shut down postgres0
And I sleep for 5 seconds
Then postgres3 role is the primary after 10 seconds
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
Scenario: check conflicting configuration handling
When I set nofailover tag in postgres2 config
And I issue an empty POST request to http://127.0.0.1:8010/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: True and failover_priority: 1. Defaulting to nofailover: True"] WARNING in the postgres2 patroni log after 5 seconds
And "members/postgres2" key in DCS has tags={'failover_priority': '1', 'nofailover': True} after 10 seconds
When I issue a POST request to http://127.0.0.1:8010/failover with {"candidate": "postgres2"}
Then I receive a response code 412
And I receive a response text "failover is not possible: no good candidates have been found"
When I reset nofailover tag in postgres1 config
And I issue an empty POST request to http://127.0.0.1:8009/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: False and failover_priority: 0. Defaulting to nofailover: False"] WARNING in the postgres1 patroni log after 5 seconds
And "members/postgres1" key in DCS has tags={'failover_priority': '0', 'nofailover': False} after 10 seconds
And I issue a POST request to http://127.0.0.1:8009/failover with {"candidate": "postgres1"}
Then I receive a response code 200
And postgres1 role is the primary after 10 seconds
+2 -1
View File
@@ -26,7 +26,7 @@ Feature: standby cluster
Scenario: Detach exiting node from the cluster
When I shut down postgres1
Then postgres0 is a leader after 10 seconds
And "members/postgres0" key in DCS has role=master after 3 seconds
And "members/postgres0" key in DCS has role=master after 5 seconds
When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200
@@ -47,6 +47,7 @@ Feature: standby cluster
And there is a postgres1_cb.log with "on_role_change standby_leader batman1" in postgres1 data directory
When I start postgres2 in a cluster batman1
Then postgres2 role is the replica after 24 seconds
And postgres2 is replicating from postgres1 after 10 seconds
And table foo is present on postgres2 after 20 seconds
When I issue a GET request to http://127.0.0.1:8010/patroni
Then I receive a response code 200
+9 -5
View File
@@ -46,11 +46,17 @@ def kill_postgres(context, name):
return context.pctl.stop(name, kill=True, postgres=True)
def get_wal_name(context, pg_name):
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
return 'xlog' if int(version) / 10000 < 10 else 'wal'
@step('I add the table {table_name:w} to {pg_name:w}')
def add_table(context, table_name, pg_name):
# parse the configuration file and get the port
try:
context.pctl.query(pg_name, "CREATE TABLE public.{0}()".format(table_name))
context.pctl.query(pg_name, "SELECT pg_switch_{0}()".format(get_wal_name(context, pg_name)))
except pg.Error as e:
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
@@ -59,9 +65,7 @@ def add_table(context, table_name, pg_name):
def toggle_wal_replay(context, action, pg_name):
# pause or resume the wal replay process
try:
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
wal_name = 'xlog' if int(version) / 10000 < 10 else 'wal'
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(get_wal_name(context, pg_name), action))
except pg.Error as e:
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
@@ -114,7 +118,7 @@ def replication_works(context, primary, replica, time_limit):
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
@then('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
@step('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
def check_patroni_log(context, message_list, level, node, timeout):
timeout *= context.timeout_multiplier
message_list = json.loads(message_list)
@@ -123,6 +127,6 @@ def check_patroni_log(context, message_list, level, node, timeout):
messsages_of_level = context.pctl.read_patroni_log(node, level)
if any(any(message in line for line in messsages_of_level) for message in message_list):
break
time.sleep(1)
sleep(1)
else:
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
+6
View File
@@ -128,6 +128,12 @@ def scheduled_restart(context, url, in_seconds, data):
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
@step('I {action:w} {tag:w} tag in {pg_name:w} config')
def add_bool_tag_to_config(context, action, tag, pg_name):
value = action == 'set'
context.pctl.add_tag_to_config(pg_name, tag, value)
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
def add_tag_to_config(context, tag, value, pg_name):
context.pctl.add_tag_to_config(pg_name, tag, value)
+1 -1
View File
@@ -1,4 +1,4 @@
FROM postgres:15
FROM postgres:16
LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \
+4 -4
View File
@@ -1,4 +1,4 @@
FROM postgres:15
FROM postgres:16
LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \
@@ -11,7 +11,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
apt-get install -y postgresql-server-dev-15 \
apt-get install -y postgresql-server-dev-16 \
gcc make autoconf \
libc6-dev flex libcurl4-gnutls-dev \
libicu-dev libkrb5-dev liblz4-dev \
@@ -24,7 +24,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-15-citus-12.0; \
&& apt-get -y install postgresql-16-citus-12.1; \
fi \
&& pip3 install --break-system-packages setuptools \
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
@@ -38,7 +38,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
&& chmod 664 /etc/passwd \
# Clean up
&& apt-get remove -y git python3-pip python3-wheel \
postgresql-server-dev-15 gcc make autoconf \
postgresql-server-dev-16 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 \
&& apt-get autoremove -y \
+8 -4
View File
@@ -13,13 +13,17 @@ def hiddenimports():
sys.path.pop(0)
def resources():
import os
res_dir = 'patroni/postgresql/available_parameters/'
exts = set(f.split('.')[-1] for f in os.listdir(res_dir))
return [(res_dir + '*.' + e, res_dir) for e in exts if e.lower() in {'yml', 'yaml'}]
a = Analysis(['patroni/__main__.py'],
pathex=[],
binaries=None,
datas=[
('patroni/postgresql/available_parameters/*.yml', 'patroni/postgresql/available_parameters'),
('patroni/postgresql/available_parameters/*.yaml', 'patroni/postgresql/available_parameters'),
],
datas=resources(),
hiddenimports=hiddenimports(),
hookspath=[],
runtime_hooks=[],
+7 -6
View File
@@ -68,7 +68,7 @@ class Patroni(AbstractPatroniDaemon, Tags):
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql'])
self.postgresql = Postgresql(self.config['postgresql'], self.dcs.mpp)
self.api = RestApiServer(self, self.config['restapi'])
self.ha = Ha(self)
@@ -229,11 +229,6 @@ def patroni_main(configfile: str) -> None:
:param configfile: path to Patroni configuration file.
"""
from multiprocessing import freeze_support
# Windows executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support()
abstract_main(Patroni, configfile)
@@ -335,6 +330,12 @@ def main() -> None:
``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded
to ``patroni`` daemon process.
"""
from multiprocessing import freeze_support
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support()
check_psycopg()
args = process_arguments()
+17 -6
View File
@@ -180,6 +180,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
* ``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;
* ``pending_restart_reason``: dictionary where each key is the parameter that caused "pending restart" flag
to be set and the value is a dictionary with the old and the new value.
* ``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;
@@ -196,8 +198,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
response['tags'] = tags
if patroni.postgresql.sysid:
response['database_system_identifier'] = patroni.postgresql.sysid
if patroni.postgresql.pending_restart:
if patroni.postgresql.pending_restart_reason:
response['pending_restart'] = True
response['pending_restart_reason'] = dict(patroni.postgresql.pending_restart_reason)
response['patroni'] = {
'version': patroni.version,
'scope': patroni.postgresql.scope,
@@ -634,7 +637,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
metrics.append("# TYPE patroni_pending_restart gauge")
metrics.append("patroni_pending_restart{0} {1}"
.format(labels, int(patroni.postgresql.pending_restart)))
.format(labels, int(bool(patroni.postgresql.pending_restart_reason))))
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
metrics.append("# TYPE patroni_is_paused gauge")
@@ -1153,8 +1156,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
def do_POST_citus(self) -> None:
"""Handle a ``POST`` request to ``/citus`` path.
Call :func:`~patroni.postgresql.CitusHandler.handle_event` to handle the request, then write a response with
HTTP status code ``200``.
.. note::
We keep this entrypoint for backward compatibility and simply dispatch the request to :meth:`do_POST_mpp`.
"""
self.do_POST_mpp()
def do_POST_mpp(self) -> None:
"""Handle a ``POST`` request to ``/mpp`` path.
Call :func:`~patroni.postgresql.mpp.AbstractMPPHandler.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.
@@ -1164,9 +1175,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
return
patroni = self.server.patroni
if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
if patroni.postgresql.mpp_handler.is_coordinator() and patroni.ha.is_leader():
cluster = patroni.dcs.get_cluster()
patroni.postgresql.citus_handler.handle_event(cluster, request)
patroni.postgresql.mpp_handler.handle_event(cluster, request)
self.write_response(200, 'OK')
def parse_request(self) -> bool:
+49 -4
View File
@@ -1,9 +1,10 @@
"""Patroni custom object types somewhat like :mod:`collections` module.
Provides a case insensitive :class:`dict` and :class:`set` object types.
Provides a case insensitive :class:`dict` and :class:`set` object types, and `EMPTY_DICT` frozen dictionary object.
"""
from collections import OrderedDict
from typing import Any, Collection, Dict, Iterator, KeysView, MutableMapping, MutableSet, Optional
from copy import deepcopy
from typing import Any, Collection, Dict, Iterator, KeysView, Mapping, MutableMapping, MutableSet, Optional
class CaseInsensitiveSet(MutableSet[str]):
@@ -48,7 +49,7 @@ class CaseInsensitiveSet(MutableSet[str]):
"""
return str(set(self._values.values()))
def __contains__(self, value: str) -> bool:
def __contains__(self, value: object) -> bool:
"""Check if set contains *value*.
The check is performed case-insensitively.
@@ -57,7 +58,7 @@ class CaseInsensitiveSet(MutableSet[str]):
:returns: ``True`` if *value* is already in the set, ``False`` otherwise.
"""
return value.lower() in self._values
return isinstance(value, str) and value.lower() in self._values
def __iter__(self) -> Iterator[str]:
"""Iterate over the values in this set.
@@ -207,3 +208,47 @@ class CaseInsensitiveDict(MutableMapping[str, Any]):
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
"""
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
class _FrozenDict(Mapping[str, Any]):
"""Frozen dictionary object."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Create a new instance of :class:`_FrozenDict` with given data."""
self.__values: Dict[str, Any] = dict(*args, **kwargs)
def __iter__(self) -> Iterator[str]:
"""Iterate over keys of this dict.
:yields: each key present in the dict. Yields each key with its last case that has been stored.
"""
return iter(self.__values)
def __len__(self) -> int:
"""Get the length of this dict.
:returns: number of keys in the dict.
:Example:
>>> len(_FrozenDict())
0
"""
return len(self.__values)
def __getitem__(self, key: str) -> Any:
"""Get the value corresponding to *key*.
:returns: value corresponding to *key*.
"""
return self.__values[key]
def copy(self) -> Dict[str, Any]:
"""Create a copy of this dict.
:return: a new dict object with the same keys and values of this dict.
"""
return deepcopy(self.__values)
EMPTY_DICT = _FrozenDict()
+26 -11
View File
@@ -1,4 +1,5 @@
"""Facilities related to Patroni configuration."""
import re
import json
import logging
import os
@@ -11,7 +12,7 @@ from copy import deepcopy
from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
from . import PATRONI_ENV_PREFIX
from .collections import CaseInsensitiveDict
from .collections import CaseInsensitiveDict, EMPTY_DICT
from .dcs import ClusterConfig
from .exceptions import ConfigParseError
from .file_perm import pg_perm
@@ -142,10 +143,10 @@ 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)
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
self._load_cache()
if validator: # patronictl uses validator=None
self._load_cache() # we don't want to load anything from local cache for ctl
self._validate_failover_tags() # irrelevant for ctl
self._cache_needs_saving = False
self._validate_failover_tags()
@property
def config_file(self) -> Optional[str]:
@@ -356,6 +357,7 @@ class Config(object):
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
self._local_configuration = configuration
self.__effective_configuration = new_configuration
self._validate_failover_tags()
return True
else:
logger.info('No local configuration items changed.')
@@ -443,14 +445,14 @@ class Config(object):
for name, value in dynamic_configuration.items():
if name == 'postgresql':
for name, value in (value or {}).items():
for name, value in (value or EMPTY_DICT).items():
if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value))
elif name not in ('connect_address', 'proxy_address', 'listen',
'config_dir', 'data_dir', 'pgpass', 'authentication'):
config['postgresql'][name] = deepcopy(value)
elif name == 'standby_cluster':
for name, value in (value or {}).items():
for name, value in (value or EMPTY_DICT).items():
if name in self.__DEFAULT_CONFIG['standby_cluster']:
config['standby_cluster'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
@@ -533,8 +535,8 @@ class Config(object):
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
'dir', 'file_size', 'file_num', 'loggers'])
_set_section_values('log', ['type', 'level', 'traceback_level', 'format', 'dateformat', 'static_fields',
'max_queue_size', 'dir', 'file_size', 'file_num', 'loggers'])
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
@@ -581,6 +583,12 @@ class Config(object):
if value:
ret[first][second] = value
logformat = ret.get('log', {}).get('format')
if logformat and not re.search(r'%\(\w+\)', logformat):
logformat = _parse_list(logformat)
if logformat:
ret['log']['format'] = logformat
def _parse_dict(value: str) -> Optional[Dict[str, Any]]:
"""Parse an YAML dictionary *value* as a :class:`dict`.
@@ -596,7 +604,12 @@ class Config(object):
logger.exception('Exception when parsing dict %s', value)
return None
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
dict_configs = (
('restapi', ('http_extra_headers', 'https_extra_headers')),
('log', ('static_fields', 'loggers'))
)
for first, params in dict_configs:
for second in params:
value = ret.get(first, {}).pop(second, None)
if value:
@@ -814,10 +827,12 @@ class Config(object):
bedrock source of truth)
"""
tags = self.get('tags', {})
if 'nofailover' not in tags:
return
nofailover_tag = tags.get('nofailover')
failover_priority_tag = parse_int(tags.get('failover_priority'))
if failover_priority_tag is not None \
and (nofailover_tag is True and failover_priority_tag > 0
or nofailover_tag is False and failover_priority_tag <= 0):
and (bool(nofailover_tag) is True and failover_priority_tag > 0
or bool(nofailover_tag) is False and failover_priority_tag <= 0):
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
+10 -4
View File
@@ -15,6 +15,7 @@ if TYPE_CHECKING: # pragma: no cover
from psycopg2 import cursor
from . import psycopg
from .collections import EMPTY_DICT
from .config import Config
from .exceptions import PatroniException
from .log import PatroniLogger
@@ -99,6 +100,7 @@ class AbstractConfigGenerator(abc.ABC):
'listen': cls._IP + ':8008'
},
'log': {
'type': PatroniLogger.DEFAULT_TYPE,
'level': PatroniLogger.DEFAULT_LEVEL,
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
'format': PatroniLogger.DEFAULT_FORMAT,
@@ -125,6 +127,7 @@ class AbstractConfigGenerator(abc.ABC):
'noloadbalance': False,
'clonefrom': True,
'nosync': False,
'nostream': False,
}
}
@@ -178,7 +181,7 @@ class AbstractConfigGenerator(abc.ABC):
:yields: formatted lines or blocks that represent a text output of the YAML document.
"""
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl' 'citus',
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl', 'citus',
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
yield from self._format_config_section(name)
@@ -242,7 +245,8 @@ class SampleConfigGenerator(AbstractConfigGenerator):
See :func:`~patroni.postgresql.misc.postgres_major_version_to_int` and
:func:`~patroni.utils.get_major_version`.
"""
postgres_bin = ((self.config.get('postgresql') or {}).get('bin_name') or {}).get('postgres', 'postgres')
postgres_bin = ((self.config.get('postgresql')
or EMPTY_DICT).get('bin_name') or EMPTY_DICT).get('postgres', 'postgres')
return postgres_major_version_to_int(get_major_version(self.config['postgresql'].get('bin_dir'), postgres_bin))
def generate(self) -> None:
@@ -409,8 +413,10 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
val = self.parsed_dsn.get(conn_param, os.getenv(env_var))
if val:
su_params[conn_param] = val
patroni_env_su_username = ((self.config.get('authentication') or {}).get('superuser') or {}).get('username')
patroni_env_su_pwd = ((self.config.get('authentication') or {}).get('superuser') or {}).get('password')
patroni_env_su_username = ((self.config.get('authentication')
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('username')
patroni_env_su_pwd = ((self.config.get('authentication')
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('password')
# because we use "username" in the config for some reason
su_params['username'] = su_params.pop('user', patroni_env_su_username) or getuser()
su_params['password'] = su_params.get('password', patroni_env_su_pwd) or \
+43 -27
View File
@@ -41,8 +41,12 @@ if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
from psycopg2 import cursor
try:
from ydiff import markup_to_pager, PatchStream # pyright: ignore [reportMissingModuleSource]
try: # pragma: no cover
from ydiff import markup_to_pager # pyright: ignore [reportMissingModuleSource]
try:
from ydiff import PatchStream # pyright: ignore [reportMissingModuleSource]
except ImportError:
PatchStream = iter
except ImportError: # pragma: no cover
from cdiff import markup_to_pager, PatchStream # pyright: ignore [reportMissingModuleSource]
@@ -51,6 +55,7 @@ from .config import Config
from .dcs import get_dcs as _get_dcs, AbstractDCS, Cluster, Member
from .exceptions import PatroniException
from .postgresql.misc import postgres_version_to_int
from .postgresql.mpp import get_mpp
from .utils import cluster_as_json, patch_config, polling_loop
from .request import PatroniRequest
from .version import __version__
@@ -313,7 +318,7 @@ def ctl(ctx: click.Context, config_file: str, dcs_url: Optional[str], insecure:
config = load_config(config_file, dcs_url)
# backward compatibility for configuration file where ctl section is not defined
config.setdefault('ctl', {})['insecure'] = config.get('ctl', {}).get('insecure') or insecure
ctx.obj = {'__config': config}
ctx.obj = {'__config': config, '__mpp': get_mpp(config)}
def is_citus_cluster() -> bool:
@@ -321,7 +326,7 @@ def is_citus_cluster() -> bool:
:returns: ``True`` if configuration has ``citus`` section, otherwise ``False``.
"""
return bool(_get_configuration().get('citus'))
return click.get_current_context().obj['__mpp'].is_enabled()
def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
@@ -340,12 +345,13 @@ def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
config = _get_configuration()
config.update({'scope': scope, 'patronictl': True})
if group is not None:
config['citus'] = {'group': group}
config['citus'] = {'group': group, 'database': 'postgres'}
config.setdefault('name', scope)
try:
dcs = _get_dcs(config)
if is_citus_cluster() and group is None:
dcs.is_citus_coordinator = lambda: True
dcs.is_mpp_coordinator = lambda: True
click.get_current_context().obj['__mpp'] = dcs.mpp
return dcs
except PatroniException as e:
raise PatroniCtlException(str(e))
@@ -1185,7 +1191,7 @@ def reinit(cluster_name: str, group: Optional[int], member_names: List[str], for
def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[int],
leader: Optional[str], candidate: Optional[str],
switchover_leader: Optional[str], candidate: Optional[str],
force: bool, scheduled: Optional[str] = None) -> None:
"""Perform a failover or a switchover operation in the cluster.
@@ -1199,7 +1205,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
:param cluster_name: name of the Patroni cluster.
:param group: filter Citus group within we should perform a failover or switchover. If ``None``, user will be
prompted for filling it -- unless *force* is ``True``, in which case an exception is raised.
:param leader: name of the current leader member.
:param switchover_leader: name of the leader member passed as switchover option.
:param candidate: name of a standby member to be promoted. Nodes that are tagged with ``nofailover`` cannot be used.
:param force: perform the failover or switchover without asking for confirmations.
:param scheduled: timestamp when the switchover should be scheduled to occur. If ``now`` perform immediately.
@@ -1208,10 +1214,11 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
:class:`PatroniCtlException`: if:
* Patroni is running on a Citus cluster, but no *group* was specified; or
* a switchover was requested by the cluster has no leader; or
* *leader* does not match the current leader of the cluster; or
* *switchover_leader* does not match the current leader of the cluster; or
* cluster has no candidates available for the operation; or
* no *candidate* is given for a failover operation; or
* *leader* and *candidate* are the same; or
* current leader and *candidate* are the same; or
* *candidate* is tagged as nofailover; or
* *candidate* is not a member of the cluster; or
* trying to schedule a switchover in a cluster that is in maintenance mode; or
* user aborts the operation.
@@ -1231,23 +1238,24 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
config = global_config.from_cluster(cluster)
cluster_leader = cluster.leader and cluster.leader.name
# leader has to be be defined for switchover only
if action == 'switchover':
if cluster.leader is None or not cluster.leader.name:
if not cluster_leader:
raise PatroniCtlException('This cluster has no leader')
if leader is None:
if switchover_leader is None:
if force:
leader = cluster.leader.name
switchover_leader = cluster_leader
else:
prompt = 'Standby Leader' if config.is_standby_cluster else 'Primary'
leader = click.prompt(prompt, type=str, default=(cluster.leader and cluster.leader.name))
switchover_leader = click.prompt(prompt, type=str, default=cluster_leader)
if cluster.leader.name != leader:
raise PatroniCtlException(f'Member {leader} is not the leader of cluster {cluster_name}')
if cluster_leader != switchover_leader:
raise PatroniCtlException(f'Member {switchover_leader} is not the leader of cluster {cluster_name}')
# excluding members with nofailover tag
candidate_names = [str(m.name) for m in cluster.members if m.name != leader and not m.nofailover]
candidate_names = [str(m.name) for m in cluster.members if m.name != cluster_leader and not m.nofailover]
# We sort the names for consistent output to the client
candidate_names.sort()
@@ -1260,10 +1268,10 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
if action == 'failover' and not candidate:
raise PatroniCtlException('Failover could be performed only to a specific candidate')
if candidate == leader:
raise PatroniCtlException(action.title() + ' target and source are the same.')
if candidate and candidate not in candidate_names:
if candidate == cluster_leader:
raise PatroniCtlException(
f'Member {candidate} is already the leader of cluster {cluster_name}')
raise PatroniCtlException(
f'Member {candidate} does not exist in cluster {cluster_name} or is tagged as nofailover')
@@ -1292,7 +1300,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
failover_value = {'candidate': candidate}
if action == 'switchover':
failover_value['leader'] = leader
failover_value['leader'] = switchover_leader
if scheduled_at_str:
failover_value['scheduled_at'] = scheduled_at_str
@@ -1300,7 +1308,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
# By now we have established that the leader exists and the candidate exists
if not force:
demote_msg = f', demoting current leader {cluster.leader.name}' if cluster.leader else ''
demote_msg = f', demoting current leader {cluster_leader}' if cluster_leader else ''
if scheduled_at_str:
# only switchover can be scheduled
if not click.confirm(f'Are you sure you want to schedule switchover of cluster '
@@ -1334,7 +1342,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
logging.exception(r)
logging.warning('Failing over to DCS')
click.echo('{0} Could not {1} using Patroni api, falling back to DCS'.format(timestamp(), action))
dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at)
dcs.manual_failover(switchover_leader, candidate, scheduled_at=scheduled_at)
output_members(cluster, cluster_name, group=group)
@@ -1406,7 +1414,7 @@ def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
def generate_topology(level: int, member: Dict[str, Any],
topology: Dict[str, List[Dict[str, Any]]]) -> Iterator[Dict[str, Any]]:
topology: Dict[Optional[str], List[Dict[str, Any]]]) -> Iterator[Dict[str, Any]]:
"""Recursively yield members with their names adjusted according to their *level* in the cluster topology.
.. note::
@@ -1469,7 +1477,7 @@ def topology_sort(members: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]:
:yields: *members* sorted by level in the topology, and with a new ``name`` value according to their level
in the topology.
"""
topology: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
topology: Dict[Optional[str], List[Dict[str, Any]]] = defaultdict(list)
leader = next((m for m in members if m['role'].endswith('leader')), {'name': None})
replicas = set(member['name'] for member in members if not member['role'].endswith('leader'))
for member in members:
@@ -1554,7 +1562,7 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
all_members = [m for c in clusters.values() for m in c['members'] if 'host' in m]
for c in ('Pending restart', 'Scheduled restart', 'Tags'):
for c in ('Pending restart', 'Pending restart reason', 'Scheduled restart', 'Tags'):
if extended or any(m.get(c.lower().replace(' ', '_')) for m in all_members):
columns.append(c)
@@ -1568,11 +1576,19 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
logging.debug(member)
lag = member.get('lag', '')
def format_diff(param: str, values: Dict[str, str], hide_long: bool):
full_diff = param + ': ' + values['old_value'] + '->' + values['new_value']
return full_diff if not hide_long or len(full_diff) <= 50 else param + ': [hidden - too long]'
restart_reason = '\n'.join([format_diff(k, v, fmt in ('pretty', 'topology'))
for k, v in member.get('pending_restart_reason', {}).items()]) or ''
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,
pending_restart='*' if member.get('pending_restart') else '')
pending_restart='*' if member.get('pending_restart') else '',
pending_restart_reason=restart_reason)
if append_port and member['host'] and member.get('port'):
member['host'] = ':'.join([member['host'], str(member['port'])])
+66 -49
View File
@@ -25,10 +25,9 @@ from ..utils import parse_int
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
from ..postgresql import Postgresql
from ..postgresql.mpp import AbstractMPP
SLOT_ADVANCE_AVAILABLE_VERSION = 110000
CITUS_COORDINATOR_GROUP_ID = 0
citus_group_re = re.compile('^(0|[1-9][0-9]*)$')
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
logger = logging.getLogger(__name__)
@@ -86,6 +85,8 @@ def dcs_modules() -> List[str]:
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
return iter_modules(__package__)
@@ -102,6 +103,8 @@ def iter_dcs_classes(
:returns: an iterator of tuples, each containing the module ``name`` and the imported DCS class object.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
return iter_classes(__package__, AbstractDCS, config)
@@ -130,10 +133,9 @@ def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
'patronictl', 'ttl', 'retry_timeout')
if p in config})
# From citus section we only need "group" parameter, but will propagate everything just in case.
if isinstance(config.get('citus'), dict):
config[name].update(config['citus'])
return dcs_class(config[name])
from patroni.postgresql.mpp import get_mpp
return dcs_class(config[name], get_mpp(config))
available_implementations = ', '.join(sorted([n for n, _ in iter_dcs_classes()]))
raise PatroniFatalException("Can not find suitable configuration of distributed configuration store\n"
@@ -784,7 +786,7 @@ class Cluster(NamedTuple('Cluster',
('history', Optional[TimelineHistory]),
('failsafe', Optional[Dict[str, str]]),
('workers', Dict[int, 'Cluster'])])):
"""Immutable object (namedtuple) which represents PostgreSQL or Citus cluster.
"""Immutable object (namedtuple) which represents PostgreSQL or MPP cluster.
.. note::
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
@@ -801,8 +803,8 @@ class Cluster(NamedTuple('Cluster',
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
:ivar history: reference to `TimelineHistory` object.
:ivar failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list.
:ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing
the group, and the corresponding value is a :class:`Cluster` instance.
:ivar workers: dictionary of workers of the MPP cluster, optional. Each key representing the group and the
corresponding value is a :class:`Cluster` instance.
"""
def __new__(cls, *args: Any, **kwargs: Any):
@@ -1041,6 +1043,8 @@ class Cluster(NamedTuple('Cluster',
.. note::
Permanent replication slots are only considered if ``use_slots`` configuration is enabled.
A node that is not supposed to become a leader (*nofailover*) will not have permanent replication slots.
Also node with disabled streaming (*nostream*) and its cascading followers must not have permanent
logical slots due to lack of feedback from node to primary, which makes them unsafe to use.
In a standby cluster we only support physical replication slots.
@@ -1056,7 +1060,7 @@ class Cluster(NamedTuple('Cluster',
if not global_config.use_slots or tags.nofailover:
return {}
if global_config.is_standby_cluster:
if global_config.is_standby_cluster or self.get_slot_name_on_primary(postgresql.name, tags) is None:
return self.__permanent_physical_slots \
if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
@@ -1071,6 +1075,10 @@ class Cluster(NamedTuple('Cluster',
the ``replicatefrom`` destination member is currently not a member of the cluster (fallback to the
primary), or if ``replicatefrom`` destination member happens to be the current primary.
If the ``nostream`` tag is set on the member - we should not create the replication slot for it on
the current primary or any other member even if ``replicatefrom`` is set, because ``nostream`` disables
WAL streaming.
Will log an error if:
* Conflicting slot names between members are found
@@ -1085,8 +1093,9 @@ class Cluster(NamedTuple('Cluster',
if not global_config.use_slots:
return {}
# we always want to exclude the member with our name from the list
members = filter(lambda m: m.name != name, self.members)
# we always want to exclude the member with our name from the list,
# also exlude members with disabled WAL streaming
members = filter(lambda m: m.name != name and not m.nostream, self.members)
if role in ('master', 'primary', 'standby_leader'):
members = [m for m in members if m.replicatefrom is None
@@ -1174,7 +1183,7 @@ class Cluster(NamedTuple('Cluster',
return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members)
return False
def get_slot_name_on_primary(self, name: str, tags: Tags) -> str:
def get_slot_name_on_primary(self, name: str, tags: Tags) -> Optional[str]:
"""Get the name of physical replication slot for this node on the primary.
.. note::
@@ -1188,6 +1197,8 @@ class Cluster(NamedTuple('Cluster',
:returns: the slot name on the primary that is in use for physical replication on this node.
"""
if tags.nostream:
return None
replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None
return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \
if isinstance(replicatefrom, Member) else slot_name_from_member_name(name)
@@ -1265,11 +1276,11 @@ class AbstractDCS(abc.ABC):
Functional methods that are critical in their timing, required to complete within ``retry_timeout`` period in order
to prevent the DCS considered inaccessible, each perform construction of complex data objects:
* :meth:`~AbstractDCS._cluster_loader`:
* :meth:`~AbstractDCS._postgresql_cluster_loader`:
method which processes the structure of data stored in the DCS used to build the :class:`Cluster` object
with all relevant associated data.
* :meth:`~AbstractDCS._citus_cluster_loader`:
Similar to above but specifically representing Citus group and workers information.
* :meth:`~AbstractDCS._mpp_cluster_loader`:
Similar to above but specifically representing MPP group and workers information.
* :meth:`~AbstractDCS._load_cluster`:
main method for calling specific ``loader`` method to build the :class:`Cluster` object representing the
state and topology of the cluster.
@@ -1338,15 +1349,15 @@ class AbstractDCS(abc.ABC):
_SYNC = 'sync'
_FAILSAFE = 'failsafe'
def __init__(self, config: Dict[str, Any]) -> None:
"""Prepare DCS paths, Citus group ID, initial values for state information and processing dependencies.
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
"""Prepare DCS paths, MPP object, initial values for state information and processing dependencies.
:ivar config: :class:`dict`, reference to config section of selected DCS.
i.e.: ``zookeeper`` for zookeeper, ``etcd`` for etcd, etc...
"""
self._mpp = mpp
self._name = config['name']
self._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
self._citus_group = str(config['group']) if isinstance(config.get('group'), int) else None
self._set_loop_wait(config.get('loop_wait', 10))
self._ctl = bool(config.get('patronictl', False))
@@ -1359,6 +1370,11 @@ class AbstractDCS(abc.ABC):
self._last_failsafe: Optional[Dict[str, str]] = {}
self.event = Event()
@property
def mpp(self) -> 'AbstractMPP':
"""Get the effective underlying MPP, if any has been configured."""
return self._mpp
def client_path(self, path: str) -> str:
"""Construct the absolute key name from appropriate parts for the DCS type.
@@ -1367,8 +1383,8 @@ class AbstractDCS(abc.ABC):
:returns: absolute key name for the current Patroni cluster.
"""
components = [self._base_path]
if self._citus_group:
components.append(self._citus_group)
if self._mpp.is_enabled():
components.append(str(self._mpp.group))
components.append(path.lstrip('/'))
return '/'.join(components)
@@ -1469,22 +1485,21 @@ class AbstractDCS(abc.ABC):
return self._last_seen
@abc.abstractmethod
def _cluster_loader(self, path: Any) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single Patroni or Citus cluster.
def _postgresql_cluster_loader(self, path: Any) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load Cluster(s) from.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
@abc.abstractmethod
def _citus_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
"""Load and build all Patroni clusters from a single Citus cluster.
def _mpp_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all Citus groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values or a
:class:`Cluster` object representing the coordinator with filled `Cluster.workers` attribute.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
@abc.abstractmethod
@@ -1499,13 +1514,14 @@ class AbstractDCS(abc.ABC):
the :meth:`~AbstractDCS.get_cluster` method.
:param path: the path in DCS where to load Cluster(s) from.
:param loader: one of :meth:`~AbstractDCS._cluster_loader` or :meth:`~AbstractDCS._citus_cluster_loader`.
:param loader: one of :meth:`~AbstractDCS._postgresql_cluster_loader` or
:meth:`~AbstractDCS._mpp_cluster_loader`.
:raise: :exc:`~DCSError` in case of communication problems with DCS. If the current node was running as a
primary and exception raised, instance would be demoted.
"""
def __get_patroni_cluster(self, path: Optional[str] = None) -> Cluster:
def __get_postgresql_cluster(self, path: Optional[str] = None) -> Cluster:
"""Low level method to load a :class:`Cluster` object from DCS.
:param path: optional client path in DCS backend to load from.
@@ -1514,42 +1530,43 @@ class AbstractDCS(abc.ABC):
"""
if path is None:
path = self.client_path('')
cluster = self._load_cluster(path, self._cluster_loader)
cluster = self._load_cluster(path, self._postgresql_cluster_loader)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(cluster, Cluster)
return cluster
def is_citus_coordinator(self) -> bool:
""":class:`Cluster` instance has a Citus Coordinator group ID.
def is_mpp_coordinator(self) -> bool:
""":class:`Cluster` instance has a Coordinator group ID.
:returns: ``True`` if the given node is running as Citus Coordinator (``group=0``).
:returns: ``True`` if the given node is running as the MPP Coordinator.
"""
return self._citus_group == str(CITUS_COORDINATOR_GROUP_ID)
return self._mpp.is_coordinator()
def get_citus_coordinator(self) -> Optional[Cluster]:
"""Load the Patroni cluster for the Citus Coordinator.
def get_mpp_coordinator(self) -> Optional[Cluster]:
"""Load the PostgreSQL cluster for the MPP Coordinator.
.. note::
This method is only executed on the worker nodes (``group!=0``) to find the coordinator.
.. note::
This method is only executed on the worker nodes to find the coordinator.
:returns: Select :class:`Cluster` instance associated with the Citus Coordinator group ID.
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
"""
try:
return self.__get_patroni_cluster(f'{self._base_path}/{CITUS_COORDINATOR_GROUP_ID}/')
return self.__get_postgresql_cluster(f'{self._base_path}/{self._mpp.coordinator_group_id}/')
except Exception as e:
logger.error('Failed to load Citus coordinator cluster from %s: %r', self.__class__.__name__, e)
logger.error('Failed to load %s coordinator cluster from %s: %r',
self._mpp.type, self.__class__.__name__, e)
return None
def _get_citus_cluster(self) -> Cluster:
"""Load Citus cluster from DCS.
def _get_mpp_cluster(self) -> Cluster:
"""Load MPP cluster from DCS.
:returns: A Citus :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
:returns: A MPP :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
dict.
"""
groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader)
groups = self._load_cluster(self._base_path + '/', self._mpp_cluster_loader)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(groups, dict)
cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
cluster = groups.pop(self._mpp.coordinator_group_id, Cluster.empty())
cluster.workers.update(groups)
return cluster
@@ -1560,12 +1577,12 @@ class AbstractDCS(abc.ABC):
Stores copy of time, status and failsafe values for comparison in DCS update decisions.
Caching is required to avoid overhead placed upon the REST API.
Returns either a Citus or Patroni implementation of :class:`Cluster` depending on availability.
Returns either a PostgreSQL or MPP implementation of :class:`Cluster` depending on availability.
:returns:
"""
try:
cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster()
cluster = self._get_mpp_cluster() if self.is_mpp_coordinator() else self.__get_postgresql_cluster()
except Exception:
self.reset_cluster()
raise
+27 -10
View File
@@ -16,8 +16,9 @@ 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, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
TimelineHistory, ReturnFalseException, catch_return_false_exception
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -232,8 +233,8 @@ def service_name_from_scope_name(scope_name: str) -> str:
class Consul(AbstractDCS):
def __init__(self, config: Dict[str, Any]) -> None:
super(Consul, self).__init__(config)
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Consul, self).__init__(config, mpp)
self._base_path = self._base_path[1:]
self._scope = config['scope']
self._session = None
@@ -419,7 +420,13 @@ class Consul(AbstractDCS):
def _consistency(self) -> str:
return 'consistent' if self._ctl else self._client.consistency
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
if results is None:
return Cluster.empty()
@@ -430,12 +437,19 @@ class Consul(AbstractDCS):
return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
results: Optional[List[Dict[str, Any]]]
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
for node in results or []:
key = node['Key'][len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
node['Value'] = (node['Value'] or b'').decode('utf-8')
clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
@@ -564,14 +578,17 @@ class Consul(AbstractDCS):
try:
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
except InvalidSession:
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
self._session = None
retry.ensure_deadline(0)
if not retry.ensure_deadline(0):
logger.error('Our session disappeared from Consul. Deadline exceeded, giving up')
return False
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
retry(self._do_refresh_session)
retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
@catch_return_false_exception
+21 -8
View File
@@ -22,8 +22,9 @@ from urllib3 import Timeout
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
TimelineHistory, ReturnFalseException, catch_return_false_exception
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
@@ -470,9 +471,9 @@ class EtcdClient(AbstractEtcdClientWithFailover):
class AbstractEtcd(AbstractDCS):
def __init__(self, config: Dict[str, Any], client_cls: Type[AbstractEtcdClientWithFailover],
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP, client_cls: Type[AbstractEtcdClientWithFailover],
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
super(AbstractEtcd, self).__init__(config)
super(AbstractEtcd, self).__init__(config, mpp)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=retry_errors_cls)
self._ttl = int(config.get('ttl') or 30)
@@ -645,8 +646,8 @@ def catch_etcd_errors(func: Callable[..., Any]) -> Any:
class Etcd(AbstractEtcd):
def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Etcd, self).__init__(config, mpp, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
self.__do_not_watch = False
@property
@@ -709,7 +710,13 @@ class Etcd(AbstractEtcd):
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
try:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
except etcd.EtcdKeyNotFound:
@@ -717,7 +724,13 @@ class Etcd(AbstractEtcd):
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
return self._cluster_from_nodes(result.etcd_index, nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
try:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
except etcd.EtcdKeyNotFound:
@@ -726,7 +739,7 @@ class Etcd(AbstractEtcd):
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
for node in result.leaves:
key = node.key[len(result.key):].lstrip('/').split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
+42 -35
View File
@@ -16,9 +16,10 @@ 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, Status, SyncState, \
TimelineHistory, catch_return_false_exception, citus_group_re
TimelineHistory, catch_return_false_exception
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
from ..exceptions import DCSError, PatroniException
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
logger = logging.getLogger(__name__)
@@ -197,12 +198,6 @@ def build_range_request(key: str, range_end: Union[bytes, str, None] = None) ->
return fields
class ReAuthenticateMode(IntEnum):
NOT_REQUIRED = 0
REQUIRED = 1
WITHOUT_WATCHER_RESTART = 2
def _handle_auth_errors(func: Callable[..., Any]) -> Any:
def wrapper(self: 'Etcd3Client', *args: Any, **kwargs: Any) -> Any:
return self.handle_auth_errors(func, *args, **kwargs)
@@ -214,7 +209,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
ERROR_CLS = Etcd3Error
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
self._reauthenticate = False
self._token = None
self._cluster_version: Tuple[int, ...] = tuple()
super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
@@ -293,7 +288,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
fields['retry'] = retry
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
def authenticate(self, *, retry: Optional[Retry] = None) -> bool:
if self._use_proxies and not self._cluster_version:
kwargs = self._prepare_common_parameters(1)
self._ensure_version_prefix(self._base_uri, **kwargs)
@@ -315,20 +310,18 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any,
retry: Optional[Retry] = None, **kwargs: Any) -> Any:
reauthenticated = False
exc = None
while True:
if self._reauthenticate_reason:
if self._reauthenticate:
if self.username and self.password:
self.authenticate(
restart_watcher=self._reauthenticate_reason != ReAuthenticateMode.WITHOUT_WATCHER_RESTART,
retry=retry)
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
if retry:
retry.ensure_deadline(0)
self.authenticate(retry=retry)
self._reauthenticate = False
else:
msg = 'Username or password not set, authentication is not possible'
logger.fatal(msg)
raise exc or Etcd3Exception(msg)
reauthenticated = True
try:
return func(self, *args, retry=retry, **kwargs)
@@ -346,11 +339,12 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
except AuthOldRevision as e:
logger.error('Auth token is for old revision of auth store')
exc = e
self._reauthenticate_reason = ReAuthenticateMode.WITHOUT_WATCHER_RESTART \
if isinstance(exc, AuthOldRevision) else ReAuthenticateMode.REQUIRED
if not retry:
self._reauthenticate = True
if retry:
logger.error('retry = %s', retry)
retry.ensure_deadline(0.5, exc)
elif reauthenticated:
raise exc
retry.ensure_deadline(0.5, exc)
@_handle_auth_errors
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
@@ -602,12 +596,6 @@ class PatroniEtcd3Client(Etcd3Client):
super(PatroniEtcd3Client, self).set_base_uri(value)
self._restart_watcher()
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
ret = super(PatroniEtcd3Client, self).authenticate(restart_watcher=restart_watcher, retry=retry)
if ret and restart_watcher:
self._restart_watcher()
return ret
def _wait_cache(self, timeout: float) -> None:
stop_time = time.time() + timeout
while self._kv_cache and not self._kv_cache.is_ready():
@@ -671,8 +659,9 @@ class PatroniEtcd3Client(Etcd3Client):
class Etcd3(AbstractEtcd):
def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Etcd3, self).__init__(config, mpp, PatroniEtcd3Client,
(DeadlineExceeded, Unavailable, FailedPrecondition))
self.__do_not_watch = False
self._lease = None
self._last_lease_refresh = 0
@@ -731,7 +720,11 @@ class Etcd3(AbstractEtcd):
@property
def cluster_prefix(self) -> str:
return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('')
"""Construct the cluster prefix for the cluster.
:returns: path in the DCS under which we store information about this Patroni cluster.
"""
return self._base_path + '/' if self.is_mpp_coordinator() else self.client_path('')
@staticmethod
def member(node: Dict[str, str]) -> Member:
@@ -785,18 +778,30 @@ class Etcd3(AbstractEtcd):
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
nodes = {node['key'][len(path):]: node
for node in self._client.get_cluster(path)
if node['key'].startswith(path)}
return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
path = self._base_path + '/'
for node in self._client.get_cluster(path):
key = node['key'][len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
@@ -848,14 +853,16 @@ class Etcd3(AbstractEtcd):
try:
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
except LeaseNotFound:
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
self._lease = None
retry.ensure_deadline(0)
if not retry.ensure_deadline(0):
logger.error('Our lease disappeared from Etcd. Deadline exceeded, giving up')
return False
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
_retry(self._do_refresh_lease)
retry.ensure_deadline(1, Etcd3Error('_do_attempt_to_acquire_leader timeout'))
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
@catch_return_false_exception
+3 -2
View File
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, List, Union
from . import Cluster
from .zookeeper import ZooKeeper
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get
from ..utils import uri
@@ -66,10 +67,10 @@ class ExhibitorEnsembleProvider(object):
class Exhibitor(ZooKeeper):
def __init__(self, config: Dict[str, Any]) -> None:
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
interval = config.get('poll_interval', 300)
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts})
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}, mpp)
def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
+51 -31
View File
@@ -19,9 +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, Status, SyncState, \
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..collections import EMPTY_DICT
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
Retry, RetryFailedError, tzutc, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
@@ -470,7 +471,7 @@ class K8sClient(object):
if len(args) == 3: # name, namespace, body
body = args[2]
elif action == 'create': # namespace, body
body = args[1]
body = args[1] # pyright: ignore [reportGeneralTypeIssues]
elif action == 'delete': # name, namespace
body = kwargs.pop('body', None)
else:
@@ -509,7 +510,7 @@ class KubernetesRetriableException(k8s_client.rest.ApiException):
@property
def sleeptime(self) -> Optional[int]:
try:
return int((self.headers or {}).get('retry-after', ''))
return int((self.headers or EMPTY_DICT).get('retry-after', ''))
except Exception:
return None
@@ -654,7 +655,7 @@ class ObjectCache(Thread):
obj = K8sObject(obj)
success, old_value = self.set(name, obj)
if success:
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
new_value = (obj.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
elif ev_type == 'DELETED':
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
else:
@@ -662,7 +663,7 @@ class ObjectCache(Thread):
if success and obj.get('kind') != 'Pod':
if old_value:
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
old_value = (old_value.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
value_changed = old_value != new_value and \
(name != self._dcs.config_path or old_value is not None and new_value is not None)
@@ -746,9 +747,7 @@ class ObjectCache(Thread):
class Kubernetes(AbstractDCS):
_CITUS_LABEL = 'citus-group'
def __init__(self, config: Dict[str, Any]) -> None:
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
self._labels = deepcopy(config['labels'])
self._labels[config.get('scope_label', 'cluster-name')] = config['scope']
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
@@ -759,9 +758,9 @@ class Kubernetes(AbstractDCS):
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:
self._labels[self._CITUS_LABEL] = self._citus_group
super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
if self._mpp.is_enabled():
self._labels[self._mpp.k8s_group_label] = str(self._mpp.group)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=KubernetesRetriableException)
@@ -846,7 +845,7 @@ class Kubernetes(AbstractDCS):
@staticmethod
def member(pod: K8sObject) -> Member:
annotations = pod.metadata.annotations or {}
annotations = pod.metadata.annotations or EMPTY_DICT
member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', ''))
member.data['pod_labels'] = pod.metadata.labels
return member
@@ -927,7 +926,7 @@ class Kubernetes(AbstractDCS):
failover = nodes.get(path + self._FAILOVER)
metadata = failover and failover.metadata
failover = metadata and Failover.from_node(metadata.resource_version,
(metadata.annotations or {}).copy())
(metadata.annotations or EMPTY_DICT).copy())
# get synchronization state
sync = nodes.get(path + self._SYNC)
@@ -936,20 +935,32 @@ class Kubernetes(AbstractDCS):
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: Dict[str, Any]) -> Cluster:
def _postgresql_cluster_loader(self, path: Dict[str, Any]) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())
def _citus_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
clusters: Dict[str, Dict[str, Dict[str, K8sObject]]] = defaultdict(lambda: defaultdict(dict))
for name, pod in path['pods'].items():
group = pod.metadata.labels.get(self._CITUS_LABEL)
if group and citus_group_re.match(group):
group = pod.metadata.labels.get(self._mpp.k8s_group_label)
if group and self._mpp.group_re.match(group):
clusters[group]['pods'][name] = pod
for name, kind in path['nodes'].items():
group = kind.metadata.labels.get(self._CITUS_LABEL)
if group and citus_group_re.match(group):
group = kind.metadata.labels.get(self._mpp.k8s_group_label)
if group and self._mpp.group_re.match(group):
clusters[group]['nodes'][name] = kind
return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
for group, value in clusters.items()}
@@ -965,9 +976,9 @@ class Kubernetes(AbstractDCS):
with self._condition:
self._wait_caches(stop_time)
pods = {name: pod for name, pod in self._pods.copy().items()
if not group or pod.metadata.labels.get(self._CITUS_LABEL) == group}
if not group or pod.metadata.labels.get(self._mpp.k8s_group_label) == group}
nodes = {name: kind for name, kind in self._kinds.copy().items()
if not group or kind.metadata.labels.get(self._CITUS_LABEL) == group}
if not group or kind.metadata.labels.get(self._mpp.k8s_group_label) == group}
return loader({'group': group, 'pods': pods, 'nodes': nodes})
except Exception:
logger.exception('get_cluster')
@@ -976,17 +987,24 @@ class Kubernetes(AbstractDCS):
def _load_cluster(
self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
group = self._citus_group if path == self.client_path('') else None
group = str(self._mpp.group) if self._mpp.is_enabled() and path == self.client_path('') else None
return self.__load_cluster(group, loader)
def get_citus_coordinator(self) -> Optional[Cluster]:
def get_mpp_coordinator(self) -> Optional[Cluster]:
"""Load the PostgreSQL cluster for the MPP Coordinator.
.. note::
This method is only executed on the worker nodes to find the coordinator.
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
"""
try:
ret = self.__load_cluster(str(CITUS_COORDINATOR_GROUP_ID), self._cluster_loader)
ret = self.__load_cluster(str(self._mpp.coordinator_group_id), self._postgresql_cluster_loader)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(ret, Cluster)
return ret
except Exception as e:
logger.error('Failed to load Citus coordinator cluster from Kubernetes: %r', e)
logger.error('Failed to load %s coordinator cluster from Kubernetes: %r', self._mpp.type, e)
@staticmethod
def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
@@ -1030,8 +1048,9 @@ class Kubernetes(AbstractDCS):
def __target_ref(self, leader_ip: str, latest_subsets: List[K8sObject], pod: K8sObject) -> K8sObject:
# we want to re-use existing target_ref if possible
empty_addresses: List[K8sObject] = []
for subset in latest_subsets:
for address in subset.addresses or []:
for address in subset.addresses or empty_addresses:
if address.ip == leader_ip and address.target_ref and address.target_ref.name == self._name:
return address.target_ref
return k8s_client.V1ObjectReference(kind='Pod', uid=pod.metadata.uid, namespace=self._namespace,
@@ -1039,7 +1058,8 @@ class Kubernetes(AbstractDCS):
def _map_subsets(self, endpoints: Dict[str, Any], ips: List[str]) -> None:
leader = self._kinds.get(self.leader_path)
latest_subsets = leader and leader.subsets or []
empty_addresses: List[K8sObject] = []
latest_subsets = leader and leader.subsets or empty_addresses
if not ips:
# We want to have subsets empty
if latest_subsets:
@@ -1195,7 +1215,7 @@ class Kubernetes(AbstractDCS):
if not retry.ensure_deadline(0.5):
return False
kind_annotations = kind and kind.metadata.annotations or {}
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
kind_resource_version = kind and kind.metadata.resource_version
# There is different leader or resource_version in cache didn't change
@@ -1208,7 +1228,7 @@ class Kubernetes(AbstractDCS):
def update_leader(self, leader: Leader, last_lsn: Optional[int],
slots: Optional[Dict[str, int]] = None, failsafe: Optional[Dict[str, str]] = None) -> bool:
kind = self._kinds.get(self.leader_path)
kind_annotations = kind and kind.metadata.annotations or {}
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
if kind and kind_annotations.get(self._LEADER) != self._name:
return False
@@ -1329,7 +1349,7 @@ class Kubernetes(AbstractDCS):
def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
ret = False
kind = self._kinds.get(self.leader_path)
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
if kind and (kind.metadata.annotations or EMPTY_DICT).get(self._LEADER) == self._name:
annotations: Dict[str, Optional[str]] = {self._LEADER: None}
if last_lsn:
annotations[self._OPTIME] = str(last_lsn)
+19 -7
View File
@@ -12,9 +12,9 @@ from pysyncobj.transport import TCPTransport, CONNECTION_STATE
from pysyncobj.utility import TcpUtility
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, citus_group_re
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import validate_directory
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -285,8 +285,8 @@ class KVStoreTTL(DynMemberSyncObj):
class Raft(AbstractDCS):
def __init__(self, config: Dict[str, Any]) -> None:
super(Raft, self).__init__(config)
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Raft, self).__init__(config, mpp)
self._ttl = int(config.get('ttl') or 30)
ready_event = threading.Event()
@@ -375,19 +375,31 @@ class Raft(AbstractDCS):
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
response = self._sync_obj.get(path, recursive=True)
if not response:
return Cluster.empty()
nodes = {key[len(path):]: value for key, value in response.items()}
return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
response = self._sync_obj.get(path, recursive=True)
for key, value in (response or {}).items():
key = key[len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = value
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
+20 -8
View File
@@ -12,9 +12,9 @@ from kazoo.retry import RetryFailedError
from kazoo.security import ACL, make_acl
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, citus_group_re
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -87,8 +87,8 @@ class PatroniKazooClient(KazooClient):
class ZooKeeper(AbstractDCS):
def __init__(self, config: Dict[str, Any]) -> None:
super(ZooKeeper, self).__init__(config)
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(ZooKeeper, self).__init__(config, mpp)
hosts: Union[str, List[str]] = config.get('hosts', [])
if isinstance(hosts, list):
@@ -214,7 +214,13 @@ class ZooKeeper(AbstractDCS):
members.append(self.member(member, *data))
return members
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
nodes = set(self.get_children(path))
# get initialize flag
@@ -258,11 +264,17 @@ class ZooKeeper(AbstractDCS):
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
ret: Dict[int, Cluster] = {}
for node in self.get_children(path):
if citus_group_re.match(node):
ret[int(node)] = self._cluster_loader(path + node + '/')
if self._mpp.group_re.match(node):
ret[int(node)] = self._postgresql_cluster_loader(path + node + '/')
return ret
def _load_cluster(
+7 -5
View File
@@ -10,6 +10,7 @@ import types
from copy import deepcopy
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .collections import EMPTY_DICT
from .utils import parse_bool, parse_int
if TYPE_CHECKING: # pragma: no cover
@@ -44,19 +45,20 @@ class GlobalConfig(types.ModuleType):
"""
return bool(cluster and cluster.config and cluster.config.modify_version)
def update(self, cluster: Optional['Cluster']) -> None:
def update(self, cluster: Optional['Cluster'], default: Optional[Dict[str, Any]] = None) -> None:
"""Update with the new global configuration from the :class:`Cluster` object view.
.. note::
Global configuration is updated only when configuration in the *cluster* view is valid.
Update happens in-place and is executed only from the main heartbeat thread.
:param cluster: the currently known cluster state from DCS.
:param default: default configuration, which will be used if there is no valid *cluster.config*.
"""
# Try to protect from the case when DCS was wiped out
if self._cluster_has_valid_config(cluster):
self.__config = cluster.config.data # pyright: ignore [reportOptionalMemberAccess]
elif default:
self.__config = default
def from_cluster(self, cluster: Optional['Cluster']) -> 'GlobalConfig':
"""Return :class:`GlobalConfig` instance from the provided :class:`Cluster` object view.
@@ -213,7 +215,7 @@ class GlobalConfig(types.ModuleType):
@property
def use_slots(self) -> bool:
"""``True`` if cluster is configured to use replication slots."""
return bool(parse_bool((self.get('postgresql') or {}).get('use_slots', True)))
return bool(parse_bool((self.get('postgresql') or EMPTY_DICT).get('use_slots', True)))
@property
def permanent_slots(self) -> Dict[str, Any]:
@@ -221,7 +223,7 @@ class GlobalConfig(types.ModuleType):
return deepcopy(self.get('permanent_replication_slots')
or self.get('permanent_slots')
or self.get('slots')
or {})
or EMPTY_DICT.copy())
sys.modules[__name__] = GlobalConfig()
+32 -19
View File
@@ -175,7 +175,7 @@ class Ha(object):
# Count of concurrent sync disabling requests. Value above zero means that we don't want to be synchronous
# standby. Changes protected by _member_state_lock.
self._disable_sync = 0
# Remember the last known member role and state written to the DCS in order to notify Citus coordinator
# Remember the last known member role and state written to the DCS in order to notify MPP coordinator
self._last_state = None
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
@@ -185,6 +185,9 @@ class Ha(object):
# used only in backoff after failing a pre_promote script
self._released_leader_key_timestamp = 0
# Initialize global config
global_config.update(None, self.patroni.config.dynamic_configuration)
def primary_stop_timeout(self) -> Union[int, None]:
""":returns: "primary_stop_timeout" from the global configuration or `None` when not in synchronous mode."""
ret = global_config.primary_stop_timeout
@@ -326,20 +329,26 @@ class Ha(object):
tags['nosync'] = True
return tags
def notify_citus_coordinator(self, event: str) -> None:
if self.state_handler.citus_handler.is_worker():
coordinator = self.dcs.get_citus_coordinator()
def notify_mpp_coordinator(self, event: str) -> None:
"""Send an event to the MPP coordinator.
:param event: the type of event for coordinator to parse.
"""
mpp_handler = self.state_handler.mpp_handler
if mpp_handler.is_worker():
coordinator = self.dcs.get_mpp_coordinator()
if coordinator and coordinator.leader and coordinator.leader.conn_url:
try:
data = {'type': event,
'group': self.state_handler.citus_handler.group(),
'group': mpp_handler.group,
'leader': self.state_handler.name,
'timeout': self.dcs.ttl,
'cooldown': self.patroni.config['retry_timeout']}
timeout = self.dcs.ttl if event == 'before_demote' else 2
self.patroni.request(coordinator.leader.member, 'post', 'citus', data, timeout=timeout, retries=0)
endpoint = 'citus' if mpp_handler.type == 'Citus' else 'mpp'
self.patroni.request(coordinator.leader.member, 'post', endpoint, data, timeout=timeout, retries=0)
except Exception as e:
logger.warning('Request to Citus coordinator leader %s %s failed: %r',
logger.warning('Request to %s coordinator leader %s %s failed: %r', mpp_handler.type,
coordinator.leader.name, coordinator.leader.member.api_url, e)
def touch_member(self) -> bool:
@@ -361,8 +370,9 @@ class Ha(object):
tags = self.get_effective_tags()
if tags:
data['tags'] = tags
if self.state_handler.pending_restart:
if self.state_handler.pending_restart_reason:
data['pending_restart'] = True
data['pending_restart_reason'] = dict(self.state_handler.pending_restart_reason)
if self._async_executor.scheduled_action in (None, 'promote') \
and data['state'] in ['running', 'restarting', 'starting']:
try:
@@ -402,7 +412,7 @@ class Ha(object):
if ret:
new_state = (data['state'], {'master': 'primary'}.get(data['role'], data['role']))
if self._last_state != new_state and new_state == ('running', 'primary'):
self.notify_citus_coordinator('after_promote')
self.notify_mpp_coordinator('after_promote')
self._last_state = new_state
return ret
@@ -600,9 +610,12 @@ class Ha(object):
:returns: the node which we should be replicating from.
"""
# nostream is set, the node must not use WAL streaming
if self.patroni.nostream:
return None
# 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() \
elif self.is_standby_cluster() \
and (cluster.leader and cluster.leader.name and cluster.leader.name == self.state_handler.name
or cluster.is_unlocked() and not self.is_paused()):
node_to_follow = self.get_remote_member()
@@ -847,7 +860,7 @@ class Ha(object):
self.state_handler.set_role('master')
self.process_sync_replication()
self.update_cluster_history()
self.state_handler.citus_handler.sync_pg_dist_node(self.cluster)
self.state_handler.mpp_handler.sync_meta_data(self.cluster)
return message
elif self.state_handler.role in ('master', 'promoted', 'primary'):
self.process_sync_replication()
@@ -867,7 +880,7 @@ class Ha(object):
self._failsafe.set_is_active(0)
def before_promote():
self.notify_citus_coordinator('before_promote')
self.notify_mpp_coordinator('before_promote')
with self._async_response:
self._async_response.reset()
@@ -1238,10 +1251,10 @@ class Ha(object):
status['released'] = True
def before_shutdown() -> None:
if self.state_handler.citus_handler.is_coordinator():
self.state_handler.citus_handler.on_demote()
if self.state_handler.mpp_handler.is_coordinator():
self.state_handler.mpp_handler.on_demote()
else:
self.notify_citus_coordinator('before_demote')
self.notify_mpp_coordinator('before_demote')
self.state_handler.stop(str(mode_control['stop']), checkpoint=bool(mode_control['checkpoint']),
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
@@ -1485,7 +1498,7 @@ class Ha(object):
if postgres_version and postgres_version_to_int(postgres_version) <= int(self.state_handler.server_version):
reason_to_cancel = "postgres version mismatch"
if pending_restart and not self.state_handler.pending_restart:
if pending_restart and not self.state_handler.pending_restart_reason:
reason_to_cancel = "pending restart flag is not set"
if not reason_to_cancel:
@@ -1543,10 +1556,10 @@ class Ha(object):
self.set_start_timeout(timeout)
def before_shutdown() -> None:
self.notify_citus_coordinator('before_demote')
self.notify_mpp_coordinator('before_demote')
def after_start() -> None:
self.notify_citus_coordinator('after_promote')
self.notify_mpp_coordinator('after_promote')
# For non async cases we want to wait for restart to complete or timeout before returning.
do_restart = functools.partial(self.state_handler.restart, timeout, self._async_executor.critical_task,
@@ -1999,7 +2012,7 @@ class Ha(object):
self.dcs.write_leader_optime(prev_location)
def _before_shutdown() -> None:
self.notify_citus_coordinator('before_demote')
self.notify_mpp_coordinator('before_demote')
on_shutdown = _on_shutdown if self.is_leader() else None
before_shutdown = _before_shutdown if self.is_leader() else None
+168 -21
View File
@@ -9,12 +9,15 @@ import sys
from copy import deepcopy
from logging.handlers import RotatingFileHandler
from patroni.utils import deep_compare
from queue import Queue, Full
from threading import Lock, Thread
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .utils import deep_compare
type_logformat = Union[List[Union[str, Dict[str, Any], Any]], str, Any]
_LOGGER = logging.getLogger(__name__)
@@ -157,6 +160,7 @@ class PatroniLogger(Thread):
.. seealso::
:class:`QueueHandler`: object used for enqueueing messages in-memory.
:cvar DEFAULT_TYPE: default type of log format (``plain``).
:cvar DEFAULT_LEVEL: default logging level (``INFO``).
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
@@ -169,6 +173,7 @@ class PatroniLogger(Thread):
:ivar log_handler_lock: lock used to modify ``log_handler``.
"""
DEFAULT_TYPE = 'plain'
DEFAULT_LEVEL = 'INFO'
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
@@ -237,6 +242,151 @@ class PatroniLogger(Thread):
logger = self._root_logger.manager.getLogger(name)
logger.setLevel(level)
def _is_config_changed(self, config: Dict[str, Any]) -> bool:
"""Checks if the given config is different from the current one.
:param config: ``log`` section from Patroni configuration.
:returns: ``True`` if the config is changed, ``False`` otherwise.
"""
old_config = self._config or {}
oldlogtype = old_config.get('type', PatroniLogger.DEFAULT_TYPE)
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
oldlogformat: type_logformat = old_config.get('format', PatroniLogger.DEFAULT_FORMAT)
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
olddateformat = old_config.get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None`
old_static_fields = old_config.get('static_fields', {})
static_fields = config.get('static_fields', {})
old_log_config = {
'type': oldlogtype,
'format': oldlogformat,
'dateformat': olddateformat,
'static_fields': old_static_fields
}
log_config = {
'type': logtype,
'format': logformat,
'dateformat': dateformat,
'static_fields': static_fields
}
return not deep_compare(old_log_config, log_config)
def _get_plain_formatter(self, logformat: type_logformat, dateformat: Optional[str]) -> logging.Formatter:
"""Returns a logging formatter with the specified format and date format.
.. note::
If the log format isn't a string, prints a warning message and uses the default log format instead.
:param logformat: The format of the log messages.
:param dateformat: The format of the timestamp in the log messages.
:returns: A logging formatter object that can be used to format log records.
"""
if not isinstance(logformat, str):
_LOGGER.warning('Expected log format to be a string when log type is plain, but got "%s"', type(logformat))
logformat = PatroniLogger.DEFAULT_FORMAT
return logging.Formatter(logformat, dateformat)
def _get_json_formatter(self, logformat: type_logformat, dateformat: Optional[str],
static_fields: Dict[str, Any]) -> logging.Formatter:
"""Returns a logging formatter that outputs JSON formatted messages.
.. note::
If :mod:`pythonjsonlogger` library is not installed, prints an error message and returns
a plain log formatter instead.
:param logformat: Specifies the log fields and their key names in the JSON log message.
:param dateformat: The format of the timestamp in the log messages.
:param static_fields: A dictionary of static fields that are added to every log message.
:returns: A logging formatter object that can be used to format log records as JSON strings.
"""
if isinstance(logformat, str):
jsonformat = logformat
rename_fields = {}
elif isinstance(logformat, list):
log_fields: List[str] = []
rename_fields: Dict[str, str] = {}
for field in logformat:
if isinstance(field, str):
log_fields.append(field)
elif isinstance(field, dict):
for original_field, renamed_field in field.items():
if isinstance(renamed_field, str):
log_fields.append(original_field)
rename_fields[original_field] = renamed_field
else:
_LOGGER.warning(
'Expected renamed log field to be a string, but got "%s"',
type(renamed_field)
)
else:
_LOGGER.warning(
'Expected each item of log format to be a string or dictionary, but got "%s"',
type(field)
)
if len(log_fields) > 0:
jsonformat = ' '.join([f'%({field})s' for field in log_fields])
else:
jsonformat = PatroniLogger.DEFAULT_FORMAT
else:
jsonformat = PatroniLogger.DEFAULT_FORMAT
rename_fields = {}
_LOGGER.warning('Expected log format to be a string or a list, but got "%s"', type(logformat))
try:
from pythonjsonlogger import jsonlogger
return jsonlogger.JsonFormatter(
jsonformat,
dateformat,
rename_fields=rename_fields,
static_fields=static_fields
)
except ImportError as e:
_LOGGER.error('Failed to import "python-json-logger" library: %r. Falling back to the plain logger', e)
except Exception as e:
_LOGGER.error('Failed to initialize JsonFormatter: %r. Falling back to the plain logger', e)
return self._get_plain_formatter(jsonformat, dateformat)
def _get_formatter(self, config: Dict[str, Any]) -> logging.Formatter:
"""Returns a logging formatter based on the type of logger in the given configuration.
:param config: ``log`` section from Patroni configuration.
:returns: A :class:`logging.Formatter` object that can be used to format log records.
"""
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
dateformat = config.get('dateformat') or None # Convert empty string to `None`
static_fields = config.get('static_fields', {})
if dateformat is not None and not isinstance(dateformat, str):
_LOGGER.warning('Expected log dateformat to be a string, but got "%s"', type(dateformat))
dateformat = None
if logtype == 'json':
formatter = self._get_json_formatter(logformat, dateformat, static_fields)
else:
formatter = self._get_plain_formatter(logformat, dateformat)
return formatter
def reload_config(self, config: Dict[str, Any]) -> None:
"""Apply log related configuration.
@@ -257,34 +407,31 @@ class PatroniLogger(Thread):
# show stack traces as ``ERROR`` log messages
logging.Logger.exception = error_exception
new_handler = None
handler = self.log_handler
if 'dir' in config:
if not isinstance(self.log_handler, RotatingFileHandler):
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
handler = new_handler or self.log_handler
if TYPE_CHECKING: # pragma: no cover
assert isinstance(handler, RotatingFileHandler)
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
if not isinstance(handler, RotatingFileHandler):
handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
max_file_size = int(config.get('file_size', 25000000))
handler.maxBytes = max_file_size # pyright: ignore [reportAttributeAccessIssue]
handler.backupCount = int(config.get('file_num', 4))
else:
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
new_handler = logging.StreamHandler()
handler = new_handler or self.log_handler
# we can't use `if not isinstance(handler, logging.StreamHandler)` below,
# because RotatingFileHandler is a child of StreamHandler!!!
elif handler is None or isinstance(handler, RotatingFileHandler):
handler = logging.StreamHandler()
oldlogformat = (self._config or {}).get('format', PatroniLogger.DEFAULT_FORMAT)
logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
is_new_handler = handler != self.log_handler
olddateformat = (self._config or {}).get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None`
if (self._is_config_changed(config) or is_new_handler) and handler:
formatter = self._get_formatter(config)
handler.setFormatter(formatter)
if (oldlogformat != logformat or olddateformat != dateformat or new_handler) and handler:
handler.setFormatter(logging.Formatter(logformat, dateformat))
if new_handler:
if is_new_handler:
with self.log_handler_lock:
if self.log_handler:
self._old_handlers.append(self.log_handler)
self.log_handler = new_handler
self.log_handler = handler
self._config = config.copy()
self.update_loggers(config.get('loggers') or {})
+25 -14
View File
@@ -19,14 +19,14 @@ from .callback_executor import CallbackAction, CallbackExecutor
from .cancellable import CancellableSubprocess
from .config import ConfigHandler, mtime
from .connection import ConnectionPool, get_connection_cursor
from .citus import CitusHandler
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
from .mpp import AbstractMPP
from .postmaster import PostmasterProcess
from .slots import SlotsHandler
from .sync import SyncHandler
from .. import global_config, psycopg
from ..async_executor import CriticalTask
from ..collections import CaseInsensitiveSet
from ..collections import CaseInsensitiveSet, CaseInsensitiveDict, EMPTY_DICT
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
@@ -63,7 +63,7 @@ class Postgresql(object):
"pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint, "
"pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()")
def __init__(self, config: Dict[str, Any]) -> None:
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
self.name: str = config['name']
self.scope: str = config['scope']
self._data_dir: str = config['data_dir']
@@ -77,10 +77,10 @@ class Postgresql(object):
self._state_lock = Lock()
self.set_state('stopped')
self._pending_restart = False
self._pending_restart_reason = CaseInsensitiveDict()
self.connection_pool = ConnectionPool()
self._connection = self.connection_pool.get('heartbeat')
self.citus_handler = CitusHandler(self, config.get('citus'))
self.mpp_handler = mpp.get_handler_impl(self)
self.config = ConfigHandler(self, config)
self.config.check_directories()
@@ -272,7 +272,7 @@ class Postgresql(object):
:returns: path to Postgres binary named *cmd*.
"""
return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or {}).get(cmd, cmd))
return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or EMPTY_DICT).get(cmd, cmd))
def pg_ctl(self, cmd: str, *args: str, **kwargs: Any) -> bool:
"""Builds and executes pg_ctl command
@@ -321,11 +321,22 @@ class Postgresql(object):
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout'] / 2.0
@property
def pending_restart(self) -> bool:
return self._pending_restart
def pending_restart_reason(self) -> CaseInsensitiveDict:
"""Get :attr:`_pending_restart_reason` value.
def set_pending_restart(self, value: bool) -> None:
self._pending_restart = value
:attr:`_pending_restart_reason` is a :class:`CaseInsensitiveDict` object of the PG parameters that are
causing pending restart state. Every key is a parameter name, value - a dictionary containing the old
and the new value (see :func:`~patroni.postgresql.config.get_param_diff`).
"""
return self._pending_restart_reason
def set_pending_restart_reason(self, diff_dict: CaseInsensitiveDict) -> None:
"""Set new or update current :attr:`_pending_restart_reason`.
:param diff_dict: :class:``CaseInsensitiveDict`` object with the parameters that are causing pending restart
state with the diff of their values. Used to reset/update the :attr:`_pending_restart_reason`.
"""
self._pending_restart_reason = diff_dict
@property
def sysid(self) -> str:
@@ -403,7 +414,7 @@ class Postgresql(object):
return data_directory_is_empty(self._data_dir)
def replica_method_options(self, method: str) -> Dict[str, Any]:
return deepcopy(self.config.get(method, {}) or {})
return deepcopy(self.config.get(method, {}) or EMPTY_DICT.copy())
def replica_method_can_work_without_replication_connection(self, method: str) -> bool:
return method != 'basebackup' and bool(self.replica_method_options(method).get('no_master')
@@ -727,7 +738,7 @@ class Postgresql(object):
self.set_role(role or self.get_postgres_role_from_data_directory())
self.set_state('starting')
self._pending_restart = False
self.set_pending_restart_reason(CaseInsensitiveDict())
try:
if not self.ensure_major_version_is_known():
@@ -1197,7 +1208,7 @@ class Postgresql(object):
before_promote()
self.slots_handler.on_promote()
self.citus_handler.schedule_cache_rebuild()
self.mpp_handler.schedule_cache_rebuild()
ret = self.pg_ctl('promote', '-W')
if ret:
@@ -1344,7 +1355,7 @@ class Postgresql(object):
"""
self.ensure_major_version_is_known()
self.slots_handler.schedule()
self.citus_handler.schedule_cache_rebuild()
self.mpp_handler.schedule_cache_rebuild()
self._sysid = ''
def _get_gucs(self) -> CaseInsensitiveSet:
@@ -0,0 +1,59 @@
import logging
import sys
from typing import Iterator
logger = logging.getLogger(__name__)
if sys.version_info < (3, 9): # pragma: no cover
from pathlib import Path
PathLikeObj = Path
conf_dir = Path(__file__).parent
else:
from importlib.resources import files
if sys.version_info < (3, 11): # pragma: no cover
from importlib.abc import Traversable
else: # pragma: no cover
from importlib.resources.abc import Traversable
PathLikeObj = Traversable
conf_dir = files(__name__)
def get_validator_files() -> Iterator[PathLikeObj]:
"""Recursively find YAML files from the current package directory.
:returns: an iterator of :class:`PathLikeObj` objects representing validator files.
"""
return _traversable_walk(conf_dir.iterdir())
def _traversable_walk(tvbs: Iterator[PathLikeObj]) -> Iterator[PathLikeObj]:
"""Recursively walk through Path/Traversable objects, yielding all YAML files in deterministic order.
:param tvbs: An iterator over :class:`PathLikeObj` objects, where each object is a file or directory
that potentially contains YAML files.
:yields: :class:`PathLikeObj` objects representing YAML files found during the traversal.
"""
for tvb in _filter_and_sort_files(tvbs):
if tvb.is_file():
yield tvb
elif tvb.is_dir():
yield from _traversable_walk(tvb.iterdir())
def _filter_and_sort_files(files: Iterator[PathLikeObj]) -> Iterator[PathLikeObj]:
"""Sort files by name, and filter out non-YAML files and Python files.
:param files: A list of files and/or directories to be filtered and sorted.
:yields: filtered and sorted objects.
"""
for file in sorted(files, key=lambda x: x.name):
if file.name.lower().endswith((".yml", ".yaml")) or file.is_dir():
yield file
elif not file.name.lower().endswith((".py", ".pyc")):
logger.info("Ignored a non-YAML file found under `%s` directory: `%s`.", __name__.split('.')[-1], file)
+9 -7
View File
@@ -7,6 +7,7 @@ import time
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember
from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare, unquote
@@ -100,10 +101,11 @@ class Bootstrap(object):
user_options.append('--{0}'.format(opt))
elif isinstance(opt, dict):
keys = list(opt.keys())
if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not option_is_allowed(keys[0]):
if len(keys) == 1 and isinstance(opt[keys[0]], str) and option_is_allowed(keys[0]):
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
else:
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
' and value should be a string'.format(tool, opt[keys[0]]))
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
else:
error_handler('Error when parsing {0} option {1}: value should be string value'
' or a single key-value pair'.format(tool, opt))
@@ -145,7 +147,7 @@ class Bootstrap(object):
# make sure there is no trigger file or postgres will be automatically promoted
trigger_file = self._postgresql.config.triggerfile_good_name
trigger_file = (self._postgresql.config.get('recovery_conf') or {}).get(trigger_file) or 'promote'
trigger_file = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).get(trigger_file) or 'promote'
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
if os.path.exists(trigger_file):
os.unlink(trigger_file)
@@ -440,7 +442,7 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
if config.get('users'):
logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0')
for name, value in (config.get('users') or {}).items():
for name, value in (config.get('users') or EMPTY_DICT).items():
if all(name != a.get('username') for a in (superuser, replication, rewind)):
self.create_or_update_role(name, value.get('password'), value.get('options', []))
@@ -463,15 +465,15 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
postgresql.restart()
else:
postgresql.config.replace_pg_hba()
if postgresql.pending_restart:
if postgresql.pending_restart_reason:
postgresql.restart()
else:
postgresql.reload()
time.sleep(1) # give a time to postgres to "reload" configuration files
postgresql.connection().close() # close connection to reconnect with a new password
else: # initdb
# We may want create database and extension for citus
self._postgresql.citus_handler.bootstrap()
# We may want create database and extension for some MPP clusters
self._postgresql.mpp_handler.bootstrap()
except Exception:
logger.exception('post_bootstrap')
task.complete(False)
+2 -1
View File
@@ -100,7 +100,8 @@ class CancellableSubprocess(CancellableExecutor):
if started and self._process is not None:
if isinstance(communicate, dict):
communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data)
communicate['stdout'], communicate['stderr'] = \
self._process.communicate(input_data) # pyright: ignore [reportGeneralTypeIssues]
return self._process.wait()
finally:
with self._lock:
+84 -30
View File
@@ -9,15 +9,16 @@ import time
from contextlib import contextmanager
from urllib.parse import urlparse, parse_qsl, unquote
from types import TracebackType
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
from .. import global_config
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet, EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException, PostgresConnectionException
from ..file_perm import pg_perm
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
from ..utils import (compare_values, maybe_convert_from_base_unit, parse_bool, parse_int,
split_host_port, uri, validate_directory, is_subpath)
from ..validator import IntValidator, EnumValidator
if TYPE_CHECKING: # pragma: no cover
@@ -270,6 +271,29 @@ def _bool_is_true_validator(value: Any) -> bool:
return parse_bool(value) is True
def get_param_diff(old_value: Any, new_value: Any,
vartype: Optional[str] = None, unit: Optional[str] = None) -> Dict[str, str]:
"""Get a dictionary representing a single PG parameter's value diff.
:param old_value: current :class:`str` parameter value.
:param new_value: :class:`str` value of the paramater after a restart.
:param vartype: the target type to parse old/new_value. See ``vartype`` argument of
:func:`~patroni.utils.maybe_convert_from_base_unit`.
:param unit: unit of *old/new_value*. See ``base_unit`` argument of
:func:`~patroni.utils.maybe_convert_from_base_unit`.
:returns: a :class:`dict` object that contains two keys: ``old_value`` and ``new_value``
with their values casted to :class:`str` and converted from base units (if possible).
"""
str_value: Callable[[Any], str] = lambda x: '' if x is None else str(x)
return {
'old_value': (maybe_convert_from_base_unit(str_value(old_value), vartype, unit)
if vartype else str_value(old_value)),
'new_value': (maybe_convert_from_base_unit(str_value(new_value), vartype, unit)
if vartype else str_value(new_value))
}
class ConfigHandler(object):
# List of parameters which must be always passed to postmaster as command line options
@@ -337,12 +361,24 @@ class ConfigHandler(object):
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(
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator]
keep_values = {k: self._server_parameters[k] for k in exclude}
server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
"SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings"
" WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)"
" AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)})
recovery_params = CaseInsensitiveDict({k: server_parameters.pop(k) for k in self._RECOVERY_PARAMETERS
if k in server_parameters})
# We also want to load current settings of recovery parameters, including primary_conninfo
# and primary_slot_name, otherwise patronictl restart will update postgresql.conf
# and remove them, what in the worst case will cause another restart.
# We are doing it only for PostgresSQL v12 onwards, because older version still have recovery.conf
if not self._postgresql.is_primary() and self._postgresql.major_version >= 120000:
# primary_conninfo is expected to be a dict, therefore we need to parse it
recovery_params['primary_conninfo'] = parse_dsn(recovery_params.pop('primary_conninfo', '')) or {}
self._recovery_params = recovery_params
self._server_parameters = CaseInsensitiveDict({**server_parameters, **keep_values})
def setup_server_parameters(self) -> None:
self._server_parameters = self.get_server_parameters(self._config)
@@ -583,7 +619,8 @@ class ConfigHandler(object):
fd.write_param(name, value)
def build_recovery_params(self, member: Union[Leader, Member, None]) -> CaseInsensitiveDict:
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or {}).items()
default: Dict[str, Any] = {}
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or default).items()
if not p.lower().startswith('recovery_target')
and p.lower() not in ('primary_conninfo', 'primary_slot_name')})
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
@@ -603,8 +640,7 @@ class ConfigHandler(object):
# We are a standby leader and are using a replication slot. Make sure we connect to
# the leader of the main cluster (in case more than one host is specified in the
# connstr) by adding 'target_session_attrs=read-write' to primary_conninfo.
if is_remote_member and 'target_sesions_attrs' not in primary_conninfo and\
self._postgresql.major_version >= 100000:
if is_remote_member and ',' in primary_conninfo['host'] and self._postgresql.major_version >= 100000:
primary_conninfo['target_session_attrs'] = 'read-write'
recovery_params['primary_conninfo'] = primary_conninfo
@@ -810,7 +846,7 @@ class ConfigHandler(object):
required['restart' if mtype else 'reload'] += 1
wanted_recovery_params = self.build_recovery_params(member)
for param, value in (self._current_recovery_params or {}).items():
for param, value in (self._current_recovery_params or EMPTY_DICT).items():
# Skip certain parameters defined in the included postgres config files
# if we know that they are not specified in the patroni configuration.
if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \
@@ -956,7 +992,7 @@ class ConfigHandler(object):
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
parameters.setdefault('wal_keep_segments', int(((wal_keep_size or 0) + 8) / 16))
self._postgresql.citus_handler.adjust_postgres_gucs(parameters)
self._postgresql.mpp_handler.adjust_postgres_gucs(parameters)
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version
or self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
@@ -1065,13 +1101,15 @@ class ConfigHandler(object):
def reload_config(self, config: Dict[str, Any], sighup: bool = False) -> None:
self._superuser = config['authentication'].get('superuser', {})
server_parameters = self.get_server_parameters(config)
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
conf_changed = hba_changed = ident_changed = local_connection_address_changed = pending_restart = False
conf_changed = hba_changed = ident_changed = local_connection_address_changed = False
param_diff = CaseInsensitiveDict()
if self._postgresql.state == 'running':
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
if p.lower() not in self._RECOVERY_PARAMETERS})
if p not in params_skip_changes})
changes.update({p: None for p in self._server_parameters.keys()
if not (p in changes or p.lower() in self._RECOVERY_PARAMETERS)})
if not (p in changes or p in params_skip_changes)})
if changes:
undef = []
if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers
@@ -1090,26 +1128,28 @@ class ConfigHandler(object):
if new_value is None or not compare_values(r[3], r[2], r[1], new_value):
conf_changed = True
if r[4] == 'postmaster':
pending_restart = True
logger.info('Changed %s from %s to %s (restart might be required)',
r[0], r[1], new_value)
param_diff[r[0]] = get_param_diff(r[1], new_value, r[3], r[2])
logger.info("Changed %s from '%s' to '%s' (restart might be required)",
r[0], param_diff[r[0]]['old_value'], new_value)
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
or r[0] in ('listen_addresses', 'port'):
local_connection_address_changed = True
else:
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
logger.info("Changed %s from '%s' to '%s'",
r[0], maybe_convert_from_base_unit(r[1], r[3], r[2]), new_value)
elif r[0] in self._server_parameters \
and not compare_values(r[3], r[2], r[1], self._server_parameters[r[0]]):
# Check if any parameter was set back to the current pg_settings value
# We can use pg_settings value here, as it is proved to be equal to new_value
logger.info('Changed %s from %s to %s', r[0], self._server_parameters[r[0]], r[1])
logger.info("Changed %s from '%s' to '%s'", r[0], self._server_parameters[r[0]], new_value)
conf_changed = True
for param, value in changes.items():
if '.' in param:
# Check that user-defined-paramters have changed (parameters with period in name)
# Check that user-defined-parameters have changed (parameters with period in name)
if value is None or param not in self._server_parameters \
or str(value) != str(self._server_parameters[param]):
logger.info('Changed %s from %s to %s', param, self._server_parameters.get(param), value)
logger.info("Changed %s from '%s' to '%s'",
param, self._server_parameters.get(param), value)
conf_changed = True
elif param in server_parameters:
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
@@ -1124,7 +1164,6 @@ class ConfigHandler(object):
ident_changed = self._config.get('pg_ident', []) != config['pg_ident']
self._config = config
self._postgresql.set_pending_restart(pending_restart)
self._server_parameters = server_parameters
self._adjust_recovery_parameters()
self._krbsrvname = config.get('krbsrvname')
@@ -1154,16 +1193,28 @@ class ConfigHandler(object):
if self._postgresql.major_version >= 90500:
time.sleep(1)
try:
pending_restart = self._postgresql.query(
'SELECT COUNT(*) FROM pg_catalog.pg_settings'
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in self._RECOVERY_PARAMETERS])[0][0] > 0
self._postgresql.set_pending_restart(pending_restart)
settings_diff: CaseInsensitiveDict = CaseInsensitiveDict()
for param, value, unit, vartype in self._postgresql.query(
'SELECT name, pg_catalog.current_setting(name), unit, vartype FROM pg_catalog.pg_settings'
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in params_skip_changes]):
new_value = self._postgresql.get_guc_value(param)
new_value = '?' if new_value is None else new_value
settings_diff[param] = get_param_diff(value, new_value, vartype, unit)
external_change = {param: value for param, value in settings_diff.items()
if param not in param_diff or value != param_diff[param]}
if external_change:
logger.info("PostgreSQL configuration parameters requiring restart"
" (%s) seem to be changed bypassing Patroni config."
" Setting 'Pending restart' flag", ', '.join(external_change))
param_diff = settings_diff
except Exception as e:
logger.warning('Exception %r when running query', e)
else:
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
self._postgresql.set_pending_restart_reason(param_diff)
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."""
@@ -1206,6 +1257,7 @@ class ConfigHandler(object):
data = self._postgresql.controldata()
effective_configuration = self._server_parameters.copy()
param_diff = CaseInsensitiveDict()
for name, cname in options_mapping.items():
value = parse_int(effective_configuration[name])
if cname not in data:
@@ -1215,7 +1267,10 @@ class ConfigHandler(object):
cvalue = parse_int(data[cname])
if cvalue is not None and value is not None and cvalue > value:
effective_configuration[name] = cvalue
self._postgresql.set_pending_restart(True)
logger.info("%s value in pg_controldata: %d, in the global configuration: %d."
" pg_controldata value will be used. Setting 'Pending restart' flag", name, cvalue, value)
param_diff[name] = get_param_diff(cvalue, value)
self._postgresql.set_pending_restart_reason(param_diff)
# If we are using custom bootstrap with PITR it could fail when values like max_connections
# are increased, therefore we disable hot_standby if recovery_target_action == 'promote'.
@@ -1232,7 +1287,6 @@ class ConfigHandler(object):
if disable_hot_standby:
effective_configuration['hot_standby'] = 'off'
self._postgresql.set_pending_restart(True)
return effective_configuration
@@ -1271,4 +1325,4 @@ class ConfigHandler(object):
return self._config.get(key, default)
def restore_command(self) -> Optional[str]:
return (self.get('recovery_conf') or {}).get('restore_command')
return (self.get('recovery_conf') or EMPTY_DICT).get('restore_command')
+317
View File
@@ -0,0 +1,317 @@
"""Abstract classes for MPP handler.
MPP stands for Massively Parallel Processing, and Citus belongs to this architecture. Currently, Citus is the only
supported MPP cluster. However, we may consider adapting other databases such as TimescaleDB, GPDB, etc. into Patroni.
"""
import abc
from typing import Any, Dict, Iterator, Optional, Union, Tuple, Type, TYPE_CHECKING
from ...dcs import Cluster
from ...dynamic_loader import iter_classes
from ...exceptions import PatroniException
if TYPE_CHECKING: # pragma: no cover
from .. import Postgresql
from ...config import Config
class AbstractMPP(abc.ABC):
"""An abstract class which should be passed to :class:`AbstractDCS`.
.. note::
We create :class:`AbstractMPP` and :class:`AbstractMPPHandler` to solve the chicken-egg initialization problem.
When initializing DCS, we dynamically create an object implementing :class:`AbstractMPP`, later this object is
used to instantiate an object implementing :class:`AbstractMPPHandler`.
"""
group_re: Any # re.Pattern[str]
def __init__(self, config: Dict[str, Union[str, int]]) -> None:
"""Init method for :class:`AbstractMPP`.
:param config: configuration of MPP section.
"""
self._config = config
def is_enabled(self) -> bool:
"""Check if MPP is enabled for a given MPP.
.. note::
We just check that the :attr:`_config` object isn't empty and expect
it to be empty only in case of :class:`Null`.
:returns: ``True`` if MPP is enabled, otherwise ``False``.
"""
return bool(self._config)
@staticmethod
@abc.abstractmethod
def validate_config(config: Any) -> bool:
"""Check whether provided config is good for a given MPP.
:param config: configuration of MPP section.
:returns: ``True`` is config passes validation, otherwise ``False``.
"""
@property
@abc.abstractmethod
def group(self) -> Any:
"""The group for a given MPP implementation."""
@property
@abc.abstractmethod
def coordinator_group_id(self) -> Any:
"""The group id of the coordinator PostgreSQL cluster."""
@property
def type(self) -> str:
"""The type of the MPP cluster.
:returns: A string representation of the type of a given MPP implementation.
"""
for base in self.__class__.__bases__:
if not base.__name__.startswith('Abstract'):
return base.__name__
return self.__class__.__name__
@property
def k8s_group_label(self):
"""Group label used for kubernetes DCS of the MPP cluster.
:returns: A string representation of the k8s group label of a given MPP implementation.
"""
return self.type.lower() + '-group'
def is_coordinator(self) -> bool:
"""Check whether this node is running in the coordinator PostgreSQL cluster.
:returns: ``True`` if MPP is enabled and the group id of this node
matches with the :attr:`coordinator_group_id`, otherwise ``False``.
"""
return self.is_enabled() and self.group == self.coordinator_group_id
def is_worker(self) -> bool:
"""Check whether this node is running as a MPP worker PostgreSQL cluster.
:returns: ``True`` if MPP is enabled and this node is known to be not running
as the coordinator PostgreSQL cluster, otherwise ``False``.
"""
return self.is_enabled() and not self.is_coordinator()
def _get_handler_cls(self) -> Iterator[Type['AbstractMPPHandler']]:
"""Find Handler classes inherited from a class type of this object.
:yields: handler classes for this object.
"""
for cls in self.__class__.__subclasses__():
if issubclass(cls, AbstractMPPHandler) and cls.__name__.startswith(self.__class__.__name__):
yield cls
def get_handler_impl(self, postgresql: 'Postgresql') -> 'AbstractMPPHandler':
"""Find and instantiate Handler implementation of this object.
:param postgresql: a reference to :class:`Postgresql` object.
:raises:
:exc:`PatroniException`: if the Handler class haven't been found.
:returns: an instantiated class that implements Handler for this object.
"""
for cls in self._get_handler_cls():
return cls(postgresql, self._config)
raise PatroniException(f'Failed to initialize {self.__class__.__name__}Handler object')
class AbstractMPPHandler(AbstractMPP):
"""An abstract class which defines interfaces that should be implemented by real handlers."""
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
"""Init method for :class:`AbstractMPPHandler`.
:param postgresql: a reference to :class:`Postgresql` object.
:param config: configuration of MPP section.
"""
super().__init__(config)
self._postgresql = postgresql
@abc.abstractmethod
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
"""Handle an event sent from a worker node.
:param cluster: the currently known cluster state from DCS.
:param event: the event to be handled.
"""
@abc.abstractmethod
def sync_meta_data(self, cluster: Cluster) -> None:
"""Sync meta data on the coordinator.
:param cluster: the currently known cluster state from DCS.
"""
@abc.abstractmethod
def on_demote(self) -> None:
"""On demote handler.
Is called when the primary was demoted.
"""
@abc.abstractmethod
def schedule_cache_rebuild(self) -> None:
"""Cache rebuild handler.
Is called to notify handler that it has to refresh its metadata cache from the database.
"""
@abc.abstractmethod
def bootstrap(self) -> None:
"""Bootstrap handler.
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
"""
@abc.abstractmethod
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
"""Adjust GUCs in the current PostgreSQL configuration.
:param parameters: dictionary of GUCs, with key as GUC name and the corresponding value as current GUC value.
"""
@abc.abstractmethod
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
"""Check whether provided replication *slot* existing in the database should not be removed.
.. note::
MPP database may create replication slots for its own use, for example to migrate data between workers
using logical replication, and we don't want to suddenly drop them.
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
``plugin``.
:returns: ``True`` if the replication slots should not be removed, otherwise ``False``.
"""
class Null(AbstractMPP):
"""Dummy implementation of :class:`AbstractMPP`."""
def __init__(self) -> None:
"""Init method for :class:`Null`."""
super().__init__({})
@staticmethod
def validate_config(config: Any) -> bool:
"""Check whether provided config is good for :class:`Null`.
:returns: always ``True``.
"""
return True
@property
def group(self) -> None:
"""The group for :class:`Null`.
:returns: always ``None``.
"""
return None
@property
def coordinator_group_id(self) -> None:
"""The group id of the coordinator PostgreSQL cluster.
:returns: always ``None``.
"""
return None
class NullHandler(Null, AbstractMPPHandler):
"""Dummy implementation of :class:`AbstractMPPHandler`."""
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
"""Init method for :class:`NullHandler`.
:param postgresql: a reference to :class:`Postgresql` object.
:param config: configuration of MPP section.
"""
AbstractMPPHandler.__init__(self, postgresql, config)
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
"""Handle an event sent from a worker node.
:param cluster: the currently known cluster state from DCS.
:param event: the event to be handled.
"""
def sync_meta_data(self, cluster: Cluster) -> None:
"""Sync meta data on the coordinator.
:param cluster: the currently known cluster state from DCS.
"""
def on_demote(self) -> None:
"""On demote handler.
Is called when the primary was demoted.
"""
def schedule_cache_rebuild(self) -> None:
"""Cache rebuild handler.
Is called to notify handler that it has to refresh its metadata cache from the database.
"""
def bootstrap(self) -> None:
"""Bootstrap handler.
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
"""
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
"""Adjust GUCs in the current PostgreSQL configuration.
:param parameters: dictionary of GUCs, with key as GUC name and corresponding value as current GUC value.
"""
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
"""Check whether provided replication *slot* existing in the database should not be removed.
.. note::
MPP database may create replication slots for its own use, for example to migrate data between workers
using logical replication, and we don't want to suddenly drop them.
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
``plugin``.
:returns: always ``False``.
"""
return False
def iter_mpp_classes(
config: Optional[Union['Config', Dict[str, Any]]] = None
) -> Iterator[Tuple[str, Type[AbstractMPP]]]:
"""Attempt to import MPP modules that are present in the given configuration.
:param config: configuration information with possible MPP names as keys. If given, only attempt to import MPP
modules defined in the configuration. Else, if ``None``, attempt to import any supported MPP module.
:yields: tuples, each containing the module ``name`` and the imported MPP class object.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
yield from iter_classes(__package__, AbstractMPP, config)
def get_mpp(config: Union['Config', Dict[str, Any]]) -> AbstractMPP:
"""Attempt to load and instantiate a MPP module from known available implementations.
:param config: object or dictionary with Patroni configuration.
:returns: The successfully loaded MPP or fallback to :class:`Null`.
"""
for name, mpp_class in iter_mpp_classes(config):
if mpp_class.validate_config(config[name]):
return mpp_class(config[name])
return Null()
@@ -6,12 +6,15 @@ from threading import Condition, Event, Thread
from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
from ..psycopg import connect, quote_ident
from . import AbstractMPP, AbstractMPPHandler
from ...dcs import Cluster
from ...psycopg import connect, quote_ident, ProgrammingError
from ...utils import parse_int
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
from .. import Postgresql
CITUS_COORDINATOR_GROUP_ID = 0
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
logger = logging.getLogger(__name__)
@@ -63,13 +66,45 @@ class PgDistNode(object):
return str(self)
class CitusHandler(Thread):
class Citus(AbstractMPP):
def __init__(self, postgresql: 'Postgresql', config: Optional[Dict[str, Union[str, int]]]) -> None:
super(CitusHandler, self).__init__()
group_re = re.compile('^(0|[1-9][0-9]*)$')
@staticmethod
def validate_config(config: Union[Any, Dict[str, Union[str, int]]]) -> bool:
"""Check whether provided config is good for a given MPP.
:param config: configuration of ``citus`` MPP section.
:returns: ``True`` is config passes validation, otherwise ``False``.
"""
return isinstance(config, dict) \
and isinstance(config.get('database'), str) \
and parse_int(config.get('group')) is not None
@property
def group(self) -> int:
"""The group of this Citus node."""
return int(self._config['group'])
@property
def coordinator_group_id(self) -> int:
"""The group id of the Citus coordinator PostgreSQL cluster."""
return CITUS_COORDINATOR_GROUP_ID
class CitusHandler(Citus, AbstractMPPHandler, Thread):
"""Define the interfaces for handling an underlying Citus cluster."""
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
""""Initialize a new instance of :class:`CitusHandler`.
:param postgresql: the Postgres node.
:param config: the ``citus`` MPP config section.
"""
Thread.__init__(self)
AbstractMPPHandler.__init__(self, postgresql, config)
self.daemon = True
self._postgresql = postgresql
self._config = config
if config:
self._connection = postgresql.connection_pool.get(
'citus', {'dbname': config['database'],
@@ -81,19 +116,11 @@ class CitusHandler(Thread):
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
self.schedule_cache_rebuild()
def is_enabled(self) -> bool:
return isinstance(self._config, dict)
def group(self) -> Optional[int]:
return int(self._config['group']) if isinstance(self._config, dict) else None
def is_coordinator(self) -> bool:
return self.is_enabled() and self.group() == CITUS_COORDINATOR_GROUP_ID
def is_worker(self) -> bool:
return self.is_enabled() and not self.is_coordinator()
def schedule_cache_rebuild(self) -> None:
"""Cache rebuild handler.
Is called to notify handler that it has to refresh its metadata cache from the database.
"""
with self._condition:
self._schedule_load_pg_dist_node = True
@@ -134,8 +161,8 @@ class CitusHandler(Thread):
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in rows}
return True
def sync_pg_dist_node(self, cluster: Cluster) -> None:
"""Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop.
def sync_meta_data(self, cluster: Cluster) -> None:
"""Maintain the ``pg_dist_node`` from the coordinator leader every heartbeat loop.
We can't always rely on REST API calls from worker nodes in order
to maintain `pg_dist_node`, therefore at least once per heartbeat
@@ -296,16 +323,16 @@ class CitusHandler(Thread):
with self._condition:
i = self.find_task_by_group(task.group)
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_pg_dist_node().
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data().
if task.timeout is None:
# We don't want to override the already existing task created from REST API.
if i is not None and self._tasks[i].timeout is not None:
return False
# There is a little race condition with tasks created from REST API - the call made "before" the member
# key is updated in DCS. Therefore it is possible that :func:`sync_pg_dist_node` will try to create a
# task based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_pg_dist_node()
# key is updated in DCS. Therefore it is possible that :func:`sync_meta_data` will try to create a task
# based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_meta_data()
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\
and self._in_flight.deadline > time.time():
return False
@@ -353,9 +380,10 @@ class CitusHandler(Thread):
task.wait()
def bootstrap(self) -> None:
if not isinstance(self._config, dict): # self.is_enabled()
return
"""Bootstrap handler.
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
"""
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs,
'options': '-c synchronous_commit=local -c statement_timeout=0'}
if self._config['database'] != self._postgresql.database:
@@ -364,6 +392,11 @@ class CitusHandler(Thread):
with conn.cursor() as cur:
cur.execute('CREATE DATABASE {0}'.format(
quote_ident(self._config['database'], conn)).encode('utf-8'))
except ProgrammingError as exc:
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
logger.debug('Exception when creating database: %r', exc)
else:
raise exc
finally:
conn.close()
@@ -371,7 +404,7 @@ class CitusHandler(Thread):
conn = connect(**conn_kwargs)
try:
with conn.cursor() as cur:
cur.execute('CREATE EXTENSION citus')
cur.execute('CREATE EXTENSION IF NOT EXISTS citus')
superuser = self._postgresql.config.superuser
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
@@ -388,9 +421,10 @@ class CitusHandler(Thread):
conn.close()
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
if not self.is_enabled():
return
"""Adjust GUCs in the current PostgreSQL configuration.
:param parameters: dictionary of GUCs, with key as GUC name and the corresponding value as current GUC value.
"""
# citus extension must be on the first place in shared_preload_libraries
shared_preload_libraries = list(filter(
lambda el: el and el != 'citus',
@@ -408,8 +442,18 @@ class CitusHandler(Thread):
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
if isinstance(self._config, dict) and self._postgresql.is_primary() and\
slot['type'] == 'logical' and slot['database'] == self._config['database']:
"""Check whether provided replication *slot* existing in the database should not be removed.
.. note::
MPP database may create replication slots for its own use, for example to migrate data between workers
using logical replication, and we don't want to suddenly drop them.
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
``plugin``.
:returns: ``True`` if the replication slots should not be removed, otherwise ``False``.
"""
if self._postgresql.is_primary() and slot['type'] == 'logical' and slot['database'] == self._config['database']:
m = CITUS_SLOT_NAME_RE.match(slot['name'])
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
return False
+1 -1
View File
@@ -176,7 +176,7 @@ class PostmasterProcess(psutil.Process):
return not self.is_running()
def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
# These regexps are cross checked against versions PostgreSQL 9.1 .. 16
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
"walreceiver|wal sender process|walsender|walwriter|background writer|"
+6 -4
View File
@@ -13,6 +13,7 @@ from . import Postgresql
from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir, parse_history, parse_lsn
from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, RemoteMember
logger = logging.getLogger(__name__)
@@ -209,9 +210,10 @@ class Rewind(object):
ret = member.conn_kwargs(auth)
if not ret.get('dbname'):
ret['dbname'] = self._postgresql.database
# Add target_session_attrs in case more than one hostname is specified
# (libpq client-side failover) making sure we hit the primary
if 'target_session_attrs' not in ret and self._postgresql.major_version >= 100000:
# Add target_session_attrs to make sure we hit the primary.
# It is not strictly necessary for starting from PostgreSQL v14, which made it possible
# to rewind from standby, but doing it from the real primary is always safer.
if self._postgresql.major_version >= 100000:
ret['target_session_attrs'] = 'read-write'
return ret
@@ -417,7 +419,7 @@ class Rewind(object):
dsn = self._postgresql.config.format_dsn(r, True)
logger.info('running pg_rewind from %s', dsn)
restore_command = (self._postgresql.config.get('recovery_conf') or {}).get('restore_command') \
restore_command = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).get('restore_command') \
if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command')
# Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian
+2 -2
View File
@@ -291,7 +291,7 @@ class SlotsHandler:
: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`.
otherwise will pass through and return result of :meth:`AbstractMPPHandler.ignore_replication_slot`.
"""
slot = self._replication_slots[name]
if cluster.config:
@@ -302,7 +302,7 @@ class SlotsHandler:
for a in ('database', 'plugin', 'type'))
):
return True
return self._postgresql.citus_handler.ignore_replication_slot(slot)
return self._postgresql.mpp_handler.ignore_replication_slot(slot)
def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
"""Drop a named slot from Postgres.
+5 -19
View File
@@ -1,11 +1,11 @@
import abc
from copy import deepcopy
import logging
import os
import yaml
from typing import Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Type, Union
from .available_parameters import get_validator_files, PathLikeObj
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..exceptions import PatroniException
from ..utils import parse_bool, parse_int, parse_real
@@ -258,10 +258,10 @@ class InvalidGucValidatorsFile(PatroniException):
"""Raised when reading or parsing of a YAML file faces an issue."""
def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
def _read_postgres_gucs_validators_file(file: PathLikeObj) -> Dict[str, Any]:
"""Read an YAML file and return the corresponding Python object.
:param file: path to the file to be read. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
:param file: path-like object to read from. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
:returns: the YAML content parsed into a Python object. If any issue is faced while reading/parsing the file, then
return ``None``.
@@ -270,7 +270,7 @@ def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
:class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
"""
try:
with open(file, encoding='UTF-8') as stream:
with file.open(encoding='UTF-8') as stream:
return yaml.safe_load(stream)
except Exception as exc:
raise InvalidGucValidatorsFile(
@@ -385,21 +385,7 @@ def _load_postgres_gucs_validators() -> None:
version_till: null
"""
conf_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'available_parameters',
)
yaml_files: List[str] = []
for root, _, files in os.walk(conf_dir):
for file in sorted(files):
full_path = os.path.join(root, file)
if file.lower().endswith(('.yml', '.yaml')):
yaml_files.append(full_path)
else:
logger.info('Ignored a non-YAML file found under `available_parameters` directory: `%s`.', full_path)
for file in yaml_files:
for file in get_validator_files():
try:
config: Dict[str, Any] = _read_postgres_gucs_validators_file(file)
except InvalidGucValidatorsFile as exc:
+3 -2
View File
@@ -42,7 +42,8 @@ try:
value.prepare(conn)
return value.getquoted().decode('utf-8')
except ImportError:
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
from psycopg import connect as __connect # pyright: ignore [reportUnknownVariableType]
from psycopg import sql, Error, DatabaseError, OperationalError, ProgrammingError
def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
"""Call :func:`psycopg.connect` with *dsn* and ``**kwargs``.
@@ -56,7 +57,7 @@ except ImportError:
:returns: a connection to the database.
"""
ret = __connect(dsn or "", **kwargs)
ret: 'Connection[Any]' = __connect(dsn or "", **kwargs)
setattr(ret, 'server_version', ret.pgconn.server_version) # compatibility with psycopg2
return ret
+1
View File
@@ -0,0 +1 @@
"""Create :mod:`patroni.scripts.barman`."""
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python
"""Perform operations on Barman through ``pg-backup-api``.
The actual operations are implemented by separate modules. This module only
builds the CLI that makes an interface with the actual commands.
.. note::
See :class:ExitCode` for possible exit codes of this main script.
"""
from argparse import ArgumentParser
from enum import IntEnum
import logging
import sys
from .config_switch import run_barman_config_switch
from .recover import run_barman_recover
from .utils import ApiNotOk, PgBackupApi, set_up_logging
class ExitCode(IntEnum):
"""Possible exit codes of this script.
:cvar NO_COMMAND: if no sub-command of ``patroni_barman`` application has
been selected by the user.
:cvar API_NOT_OK: ``pg-backup-api`` status is not ``OK``.
"""
NO_COMMAND = -1
API_NOT_OK = -2
def main() -> None:
"""Entry point of ``patroni_barman`` application.
Implements the parser for the application and for its sub-commands.
The script exit code may be one of:
* :attr:`ExitCode.NO_COMMAND`: if no sub-command was specified in the
``patroni_barman`` call;
* :attr:`ExitCode.API_NOT_OK`: if ``pg-backup-api`` is not correctly up and
running;
* Value returned by :func:`~patroni.scripts.barman.config_switch.run_barman_config_switch`,
if running ``patroni_barman config-switch``;
* Value returned by :func:`~patroni.scripts.barman.recover.run_barman_recover`,
if running ``patroni_barman recover``.
The called sub-command is expected to exit execution once finished using
its own set of exit codes.
"""
parser = ArgumentParser(
description=(
"Wrapper application for pg-backup-api. Communicate with the API "
"running at the given URL to perform remote Barman operations."
),
)
parser.add_argument(
"--api-url",
type=str,
required=True,
help="URL to reach the pg-backup-api, e.g. 'http://localhost:7480'",
dest="api_url",
)
parser.add_argument(
"--cert-file",
type=str,
required=False,
help="Certificate to authenticate against the API, if required.",
dest="cert_file",
)
parser.add_argument(
"--key-file",
type=str,
required=False,
help="Certificate key to authenticate against the API, if required.",
dest="key_file",
)
parser.add_argument(
"--retry-wait",
type=int,
required=False,
default=2,
help="How long in seconds to wait before retrying a failed "
"pg-backup-api request (default: '%(default)s')",
dest="retry_wait",
)
parser.add_argument(
"--max-retries",
type=int,
required=False,
default=5,
help="Maximum number of retries when receiving malformed responses "
"from the pg-backup-api (default: '%(default)s')",
dest="max_retries",
)
parser.add_argument(
"--log-file",
type=str,
required=False,
help="File where to log messages produced by this application, if any.",
dest="log_file",
)
subparsers = parser.add_subparsers(title="Sub-commands")
recover_parser = subparsers.add_parser(
"recover",
help="Remote 'barman recover'",
description="Restore a Barman backup of a given Barman server"
)
recover_parser.add_argument(
"--barman-server",
type=str,
required=True,
help="Name of the Barman server from which to restore the backup.",
dest="barman_server",
)
recover_parser.add_argument(
"--backup-id",
type=str,
required=False,
default="latest",
help="ID of the Barman backup to be restored. You can use any value "
"supported by 'barman recover' command "
"(default: '%(default)s')",
dest="backup_id",
)
recover_parser.add_argument(
"--ssh-command",
type=str,
required=True,
help="Value to be passed as '--remote-ssh-command' to 'barman recover'.",
dest="ssh_command",
)
recover_parser.add_argument(
"--data-directory",
"--datadir",
type=str,
required=True,
help="Destination path where to restore the barman backup in the "
"local host.",
dest="data_directory",
)
recover_parser.add_argument(
"--loop-wait",
type=int,
required=False,
default=10,
help="How long to wait before checking again the status of the "
"recovery process, in seconds. Use higher values if your "
"recovery is expected to take long (default: '%(default)s')",
dest="loop_wait",
)
recover_parser.set_defaults(func=run_barman_recover)
config_switch_parser = subparsers.add_parser(
"config-switch",
help="Remote 'barman config-switch'",
description="Switch the configuration of a given Barman server. "
"Intended to be used as a 'on_role_change' callback."
)
config_switch_parser.add_argument(
"action",
type=str,
choices=["on_role_change"],
help="Name of the callback (automatically filled by Patroni)",
)
config_switch_parser.add_argument(
"role",
type=str,
choices=["master", "primary", "promoted", "standby_leader", "replica",
"demoted"],
help="Name of the new role of this node (automatically filled by "
"Patroni)",
)
config_switch_parser.add_argument(
"cluster",
type=str,
help="Name of the Patroni cluster involved in the callback "
"(automatically filled by Patroni)",
)
config_switch_parser.add_argument(
"--barman-server",
type=str,
required=True,
help="Name of the Barman server which config is to be switched.",
dest="barman_server",
)
group = config_switch_parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--barman-model",
type=str,
help="Name of the Barman config model to be applied to the server.",
dest="barman_model",
)
group.add_argument(
"--reset",
action="store_true",
help="Unapply the currently active model for the server, if any.",
dest="reset",
)
config_switch_parser.add_argument(
"--switch-when",
type=str,
required=True,
default="promoted",
choices=["promoted", "demoted", "always"],
help="Controls under which circumstances the 'on_role_change' callback "
"should actually switch config in Barman. 'promoted' means the "
"'role' is either 'master', 'primary' or 'promoted'. 'demoted' "
"means the 'role' is either 'replica' or 'demoted' "
"(default: '%(default)s')",
dest="switch_when",
)
config_switch_parser.set_defaults(func=run_barman_config_switch)
args, _ = parser.parse_known_args()
set_up_logging(args.log_file)
if not hasattr(args, "func"):
parser.print_help()
sys.exit(ExitCode.NO_COMMAND)
api = None
try:
api = PgBackupApi(args.api_url, args.cert_file, args.key_file,
args.retry_wait, args.max_retries)
except ApiNotOk as exc:
logging.error("pg-backup-api is not working: %r", exc)
sys.exit(ExitCode.API_NOT_OK)
sys.exit(args.func(api, args))
if __name__ == "__main__":
main()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python
"""Implements ``patroni_barman config-switch`` sub-command.
Apply a Barman configuration model through ``pg-backup-api``.
This sub-command is specially useful as a ``on_role_change`` callback to change
Barman configuration in response to failovers and switchovers. Check the output
of ``--help`` to understand the parameters supported by the sub-command.
It requires that you have previously configured a Barman server and Barman
config models, and that you have ``pg-backup-api`` configured and running in
the same host as Barman.
Refer to :class:`ExitCode` for possible exit codes of this sub-command.
"""
from argparse import Namespace
from enum import IntEnum
import logging
import time
from typing import Optional, TYPE_CHECKING
from .utils import OperationStatus, RetriesExceeded
if TYPE_CHECKING: # pragma: no cover
from .utils import PgBackupApi
class ExitCode(IntEnum):
"""Possible exit codes of this script.
:cvar CONFIG_SWITCH_DONE: config switch was successfully performed.
:cvar CONFIG_SWITCH_SKIPPED: if the execution was skipped because of not
matching user expectations.
:cvar CONFIG_SWITCH_FAILED: config switch faced an issue.
:cvar HTTP_ERROR: an error has occurred while communicating with
``pg-backup-api``
:cvar INVALID_ARGS: an invalid set of arguments has been given to the
operation.
"""
CONFIG_SWITCH_DONE = 0
CONFIG_SWITCH_SKIPPED = 1
CONFIG_SWITCH_FAILED = 2
HTTP_ERROR = 3
INVALID_ARGS = 4
def _should_skip_switch(args: Namespace) -> bool:
"""Check if we should skip the config switch operation.
:param args: arguments received from the command-line of
``patroni_barman config-switch`` command.
:returns: if the operation should be skipped.
"""
if args.switch_when == "promoted":
return args.role not in {"master", "primary", "promoted"}
if args.switch_when == "demoted":
return args.role not in {"replica", "demoted"}
return False
def _switch_config(api: "PgBackupApi", barman_server: str,
barman_model: Optional[str], reset: Optional[bool]) -> int:
"""Switch configuration of Barman server through ``pg-backup-api``.
.. note::
If requests to ``pg-backup-api`` fail recurrently or we face HTTP
errors, then exit with :attr:`ExitCode.HTTP_ERROR`.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param barman_server: name of the Barman server which config is to be
switched.
:param barman_model: name of the Barman model to be applied to the server,
if any.
:param reset: ``True`` if you would like to unapply the currently active
model for the server, if any.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
operation_id = None
try:
operation_id = api.create_config_switch_operation(
barman_server,
barman_model,
reset,
)
except RetriesExceeded as exc:
logging.error("An issue was faced while trying to create a config "
"switch operation: %r", exc)
return ExitCode.HTTP_ERROR
logging.info("Created the config switch operation with ID %s",
operation_id)
status = None
while True:
try:
status = api.get_operation_status(barman_server, operation_id)
except RetriesExceeded:
logging.error("Maximum number of retries exceeded, exiting.")
return ExitCode.HTTP_ERROR
if status != OperationStatus.IN_PROGRESS:
break
logging.info("Config switch operation %s is still in progress",
operation_id)
time.sleep(5)
if status == OperationStatus.DONE:
logging.info("Config switch operation finished successfully.")
return ExitCode.CONFIG_SWITCH_DONE
else:
logging.error("Config switch operation failed.")
return ExitCode.CONFIG_SWITCH_FAILED
def run_barman_config_switch(api: "PgBackupApi", args: Namespace) -> int:
"""Run a remote ``barman config-switch`` through the ``pg-backup-api``.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param args: arguments received from the command-line of
``patroni_barman config-switch`` command.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
if _should_skip_switch(args):
logging.info("Config switch operation was skipped (role=%s, "
"switch_when=%s).", args.role, args.switch_when)
return ExitCode.CONFIG_SWITCH_SKIPPED
if not bool(args.barman_model) ^ bool(args.reset):
logging.error("One, and only one among 'barman_model' ('%s') and "
"'reset' ('%s') should be given", args.barman_model, args.reset)
return ExitCode.INVALID_ARGS
return _switch_config(api, args.barman_server, args.barman_model, args.reset)
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python
"""Implements ``patroni_barman recover`` sub-command.
Restore a Barman backup to the local node through ``pg-backup-api``.
This sub-command can be used both as a custom bootstrap method, and as a custom
create replica method. Check the output of ``--help`` to understand the
parameters supported by the sub-command. ``--datadir`` is a special parameter
and it is automatically filled by Patroni in both cases.
It requires that you have previously configured a Barman server, and that you
have ``pg-backup-api`` configured and running in the same host as Barman.
Refer to :class:`ExitCode` for possible exit codes of this sub-command.
"""
from argparse import Namespace
from enum import IntEnum
import logging
import time
from typing import TYPE_CHECKING
from .utils import OperationStatus, RetriesExceeded
if TYPE_CHECKING: # pragma: no cover
from .utils import PgBackupApi
class ExitCode(IntEnum):
"""Possible exit codes of this script.
:cvar RECOVERY_DONE: backup was successfully restored.
:cvar RECOVERY_FAILED: recovery of the backup faced an issue.
:cvar HTTP_ERROR: an error has occurred while communicating with
``pg-backup-api``
"""
RECOVERY_DONE = 0
RECOVERY_FAILED = 1
HTTP_ERROR = 2
def _restore_backup(api: "PgBackupApi", barman_server: str, backup_id: str,
ssh_command: str, data_directory: str,
loop_wait: int) -> int:
"""Restore the configured Barman backup through ``pg-backup-api``.
.. note::
If requests to ``pg-backup-api`` fail recurrently or we face HTTP
errors, then exit with :attr:`ExitCode.HTTP_ERROR`.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param barman_server: name of the Barman server which backup is to be
restored.
:param backup_id: ID of the backup from the Barman server.
:param ssh_command: SSH command to connect from the Barman host to the
target host.
:param data_directory: path to the Postgres data directory where to restore
the backup in.
:param loop_wait: how long in seconds to wait before checking again the
status of the recovery process. Higher values are useful for backups
that are expected to take longer to restore.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
operation_id = None
try:
operation_id = api.create_recovery_operation(
barman_server,
backup_id,
ssh_command,
data_directory,
)
except RetriesExceeded as exc:
logging.error("An issue was faced while trying to create a recovery "
"operation: %r", exc)
return ExitCode.HTTP_ERROR
logging.info("Created the recovery operation with ID %s", operation_id)
status = None
while True:
try:
status = api.get_operation_status(barman_server, operation_id)
except RetriesExceeded:
logging.error("Maximum number of retries exceeded, exiting.")
return ExitCode.HTTP_ERROR
if status != OperationStatus.IN_PROGRESS:
break
logging.info("Recovery operation %s is still in progress",
operation_id)
time.sleep(loop_wait)
if status == OperationStatus.DONE:
logging.info("Recovery operation finished successfully.")
return ExitCode.RECOVERY_DONE
else:
logging.error("Recovery operation failed.")
return ExitCode.RECOVERY_FAILED
def run_barman_recover(api: "PgBackupApi", args: Namespace) -> int:
"""Run a remote ``barman recover`` through the ``pg-backup-api``.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param args: arguments received from the command-line of
``patroni_barman recover`` command.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
return _restore_backup(api, args.barman_server, args.backup_id,
args.ssh_command, args.data_directory,
args.loop_wait)
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python
"""Utilitary stuff to be used by Barman related scripts."""
from enum import IntEnum
import json
import logging
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union
import time
from urllib.parse import urljoin
from urllib3 import PoolManager
from urllib3.exceptions import MaxRetryError
from urllib3.response import HTTPResponse
class RetriesExceeded(Exception):
"""Maximum number of retries exceeded."""
def retry(exceptions: Union[Type[Exception], Tuple[Type[Exception], ...]]) \
-> Any:
"""Retry an operation n times if expected *exceptions* are faced.
.. note::
Should be used as a decorator of a class' method as it expects the
first argument to be a class instance.
The class which method is going to be decorated should contain a couple
attributes:
* ``max_retries``: maximum retry attempts before failing;
* ``retry_wait``: how long in seconds to wait before retrying.
:param exceptions: exceptions that could trigger a retry attempt.
:raises:
:exc:`RetriesExceeded`: if the maximum number of attempts has been
exhausted.
"""
def decorator(func: Callable[..., Any]) -> Any:
def inner_func(instance: object, *args: Any, **kwargs: Any) -> Any:
times: int = getattr(instance, "max_retries")
retry_wait: int = getattr(instance, "retry_wait")
method_name = f"{instance.__class__.__name__}.{func.__name__}"
attempt = 1
while attempt <= times:
try:
return func(instance, *args, **kwargs)
except exceptions as exc:
logging.warning("Attempt %d of %d on method %s failed "
"with %r.",
attempt, times, method_name, exc)
attempt += 1
time.sleep(retry_wait)
raise RetriesExceeded("Maximum number of retries exceeded for "
f"method {method_name}.")
return inner_func
return decorator
def set_up_logging(log_file: Optional[str] = None) -> None:
"""Set up logging to file, if *log_file* is given, otherwise to console.
:param log_file: file where to log messages, if any.
"""
logging.basicConfig(filename=log_file, level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")
class OperationStatus(IntEnum):
"""Possible status of ``pg-backup-api`` operations.
:cvar IN_PROGRESS: the operation is still ongoing.
:cvar FAILED: the operation failed.
:cvar DONE: the operation finished successfully.
"""
IN_PROGRESS = 0
FAILED = 1
DONE = 2
class ApiNotOk(Exception):
"""The ``pg-backup-api`` is not currently up and running."""
class PgBackupApi:
"""Facilities for communicating with the ``pg-backup-api``.
:ivar api_url: base URL to reach the ``pg-backup-api``.
:ivar cert_file: certificate to authenticate against the ``pg-backup-api``,
if required.
:ivar key_file: certificate key to authenticate against the
``pg-backup-api``, if required.
:ivar retry_wait: how long in seconds to wait before retrying a failed
request to the ``pg-backup-api``.
:ivar max_retries: maximum number of retries when ``pg-backup-api`` returns
malformed responses.
:ivar http: a HTTP pool manager for performing web requests.
"""
def __init__(self, api_url: str, cert_file: Optional[str],
key_file: Optional[str], retry_wait: int,
max_retries: int) -> None:
"""Create a new instance of :class:`BarmanRecover`.
Make sure the ``pg-backup-api`` is reachable and running fine.
.. note::
When using any method which send requests to the API, be aware that
they might raise :exc:`RetriesExceeded` upon HTTP request errors.
Similarly, when instantiating this class you may face an
:exc:`ApiNotOk`, if the API is down or returns a bogus status.
:param api_url: base URL to reach the ``pg-backup-api``.
:param cert_file: certificate to authenticate against the
``pg-backup-api``, if required.
:param key_file: certificate key to authenticate against the
``pg-backup-api``, if required.
:param retry_wait: how long in seconds to wait before retrying a failed
request to the ``pg-backup-api``.
:param max_retries: maximum number of retries when ``pg-backup-api``
returns malformed responses.
"""
self.api_url = api_url
self.cert_file = cert_file
self.key_file = key_file
self.retry_wait = retry_wait
self.max_retries = max_retries
self._http = PoolManager(cert_file=cert_file, key_file=key_file)
self._ensure_api_ok()
def _build_full_url(self, url_path: str) -> str:
"""Build the full URL by concatenating *url_path* with the base URL.
:param url_path: path to be accessed in the ``pg-backup-api``.
:returns: the full URL after concatenating.
"""
return urljoin(self.api_url, url_path)
@staticmethod
def _deserialize_response(response: HTTPResponse) -> Any:
"""Retrieve body from *response* as a deserialized JSON object.
:param response: response from which JSON body will be deserialized.
:returns: the deserialized JSON body.
"""
return json.loads(response.data.decode("utf-8"))
@staticmethod
def _serialize_request(body: Any) -> Any:
"""Serialize a request body.
:param body: content of the request body to be serialized.
:returns: the serialized request body.
"""
return json.dumps(body).encode("utf-8")
def _get_request(self, url_path: str) -> Any:
"""Perform a ``GET`` request to *url_path*.
:param url_path: URL to perform the ``GET`` request against.
:returns: the deserialized response body.
:raises:
:exc:`RetriesExceeded`: raised from the corresponding :mod:`urllib3`
exception.
"""
url = self._build_full_url(url_path)
response = None
try:
response = self._http.request("GET", url)
except MaxRetryError as exc:
msg = f"Failed to perform a GET request to {url}"
raise RetriesExceeded(msg) from exc
return self._deserialize_response(response)
def _post_request(self, url_path: str, body: Any) -> Any:
"""Perform a ``POST`` request to *url_path* serializing *body* as JSON.
:param url_path: URL to perform the ``POST`` request against.
:param body: the body to be serialized as JSON and sent in the request.
:returns: the deserialized response body.
:raises:
:exc:`RetriesExceeded`: raised from the corresponding :mod:`urllib3`
exception.
"""
body = self._serialize_request(body)
url = self._build_full_url(url_path)
response = None
try:
response = self._http.request("POST",
url,
body=body,
headers={
"Content-Type": "application/json"
})
except MaxRetryError as exc:
msg = f"Failed to perform a POST request to {url} with {body}"
raise RetriesExceeded(msg) from exc
return self._deserialize_response(response)
def _ensure_api_ok(self) -> None:
"""Ensure ``pg-backup-api`` is reachable and ``OK``.
:raises:
:exc:`ApiNotOk`: if ``pg-backup-api`` status is not ``OK``.
"""
response = self._get_request("status")
if response != "OK":
msg = (
"pg-backup-api is currently not up and running at "
f"{self.api_url}: {response}"
)
raise ApiNotOk(msg)
@retry(KeyError)
def get_operation_status(self, barman_server: str,
operation_id: str) -> OperationStatus:
"""Get status of the operation which ID is *operation_id*.
:param barman_server: name of the Barman server related with the
operation.
:param operation_id: ID of the operation to be checked.
:returns: the status of the operation.
"""
response = self._get_request(
f"servers/{barman_server}/operations/{operation_id}",
)
status = response["status"]
return OperationStatus[status]
@retry(KeyError)
def create_recovery_operation(self, barman_server: str, backup_id: str,
ssh_command: str, data_directory: str) -> str:
"""Create a recovery operation on the ``pg-backup-api``.
:param barman_server: name of the Barman server which backup is to be
restored.
:param backup_id: ID of the backup from the Barman server.
:param ssh_command: SSH command to connect from the Barman host to the
target host.
:param data_directory: path to the Postgres data directory where to
restore the backup at.
:returns: the ID of the recovery operation that has been created.
"""
response = self._post_request(
f"servers/{barman_server}/operations",
{
"type": "recovery",
"backup_id": backup_id,
"remote_ssh_command": ssh_command,
"destination_directory": data_directory,
},
)
return response["operation_id"]
@retry(KeyError)
def create_config_switch_operation(self, barman_server: str,
barman_model: Optional[str],
reset: Optional[bool]) -> str:
"""Create a config switch operation on the ``pg-backup-api``.
:param barman_server: name of the Barman server which config is to be
switched.
:param barman_model: name of the Barman model to be applied to the
server, if any.
:param reset: ``True`` if you would like to unapply the currently active
model for the server, if any.
:returns: the ID of the config switch operation that has been created.
"""
body: Dict[str, Any] = {"type": "config_switch"}
if barman_model:
body["model_name"] = barman_model
elif reset:
body["reset"] = reset
response = self._post_request(
f"servers/{barman_server}/operations",
body,
)
return response["operation_id"]
-468
View File
@@ -1,468 +0,0 @@
#!/usr/bin/env python
"""Restore a Barman backup to the local node through ``pg-backup-api``.
This script can be used both as a custom bootstrap method, and as a custom
create replica method. Check the output of ``--help`` to understand the
parameters supported by the script. ``--datadir`` is a special parameter and it
is automatically filled by Patroni in both cases.
It requires that you have previously configured a Barman server, and that you
have ``pg-backup-api`` configured and running in the same host as Barman.
Refer to :class:`ExitCode` for possible exit codes of this script.
"""
from argparse import ArgumentParser
from enum import IntEnum
import json
import logging
import sys
import time
from typing import Any, Callable, Optional, Tuple, Type, Union
from urllib.parse import urljoin
from urllib3 import PoolManager
from urllib3.exceptions import MaxRetryError
from urllib3.response import HTTPResponse
class ExitCode(IntEnum):
"""Possible exit codes of this script.
:cvar RECOVERY_DONE: backup was successfully restored.
:cvar RECOVERY_FAILED: recovery of the backup faced an issue.
:cvar API_NOT_OK: ``pg-backup-api`` status is not ``OK``.
:cvar HTTP_REQUEST_ERROR: an error has occurred during a request to the
``pg-backup-api``.
:cvar HTTP_RESPONSE_MALFORMED: ``pg-backup-api`` returned a bogus response.
"""
RECOVERY_DONE = 0
RECOVERY_FAILED = 1
API_NOT_OK = 2
HTTP_REQUEST_ERROR = 3
HTTP_RESPONSE_MALFORMED = 4
class RetriesExceeded(Exception):
"""Maximum number of retries exceeded."""
def retry(exceptions: Union[Type[Exception], Tuple[Type[Exception], ...]]) \
-> Any:
"""Retry an operation n times if expected *exceptions* are faced.
.. note::
Should be used as a decorator of a class' method as it expects the
first argument to be a class instance.
The class which method is going to be decorated should contain a couple
attributes:
* ``max_retries``: maximum retry attempts before failing;
* ``retry_wait``: how long to wait before retrying.
:param exceptions: exceptions that could trigger a retry attempt.
:raises:
:exc:`RetriesExceeded`: if the maximum number of attempts has been
exhausted.
"""
def decorator(func: Callable[..., Any]) -> Any:
def inner_func(instance: object, *args: Any, **kwargs: Any) -> Any:
times: int = getattr(instance, "max_retries")
retry_wait: int = getattr(instance, "retry_wait")
method_name = f"{instance.__class__.__name__}.{func.__name__}"
attempt = 1
while attempt <= times:
try:
return func(instance, *args, **kwargs)
except exceptions as exc:
logging.warning("Attempt %d of %d on method %s failed "
"with %r.",
attempt, times, method_name, exc)
attempt += 1
time.sleep(retry_wait)
raise RetriesExceeded("Maximum number of retries exceeded for "
f"method {method_name}.")
return inner_func
return decorator
class BarmanRecover:
"""Facilities for performing a remote ``barman recover`` operation.
You should instantiate this class, which will take care of configuring the
operation accordingly. When you want to start the operation, you should
call :meth:`restore_backup`. At any point of interaction with this class,
you may face a :func:`sys.exit` call. Refer to :class:`ExitCode` for a view
on the possible exit codes.
:ivar api_url: base URL to reach the ``pg-backup-api``.
:ivar cert_file: certificate to authenticate against the
``pg-backup-api``, if required.
:ivar key_file: certificate key to authenticate against the
``pg-backup-api``, if required.
:ivar barman_server: name of the Barman server which backup is to be
restored.
:ivar backup_id: ID of the backup from the Barman server.
:ivar ssh_command: SSH command to connect from the Barman host to the
local host.
:ivar data_directory: path to the Postgres data directory where to
restore the backup at.
:ivar loop_wait: how long to wait before checking again the status of the
recovery process. Higher values are useful for backups that are
expected to take long to restore.
:ivar retry_wait: how long to wait before retrying a failed request to the
``pg-backup-api``.
:ivar max_retries: maximum number of retries when ``pg-backup-api`` returns
malformed responses.
:ivar http: a HTTP pool manager for performing web requests.
"""
def __init__(self, api_url: str, barman_server: str, backup_id: str,
ssh_command: str, data_directory: str, loop_wait: int,
retry_wait: int, max_retries: int,
cert_file: Optional[str] = None,
key_file: Optional[str] = None) -> None:
"""Create a new instance of :class:`BarmanRecover`.
Make sure the ``pg-backup-api`` is reachable and running fine.
:param api_url: base URL to reach the ``pg-backup-api``.
:param barman_server: name of the Barman server which backup is to be
restored.
:param backup_id: ID of the backup from the Barman server.
:param ssh_command: SSH command to connect from the Barman host to the
local host.
:param data_directory: path to the Postgres data directory where to
restore the backup at.
:param loop_wait: how long to wait before checking again the status of
the recovery process. Higher values are useful for backups that are
expected to take long to restore.
:param retry_wait: how long to wait before retrying a failed request to
the ``pg-backup-api``.
:param max_retries: maximum number of retries when ``pg-backup-api``
returns malformed responses.
:param cert_file: certificate to authenticate against the
``pg-backup-api``, if required.
:param key_file: certificate key to authenticate against the
``pg-backup-api``, if required.
"""
self.api_url = api_url
self.cert_file = cert_file
self.key_file = key_file
self.barman_server = barman_server
self.backup_id = backup_id
self.ssh_command = ssh_command
self.data_directory = data_directory
self.loop_wait = loop_wait
self.retry_wait = retry_wait
self.max_retries = max_retries
self.http = PoolManager(cert_file=cert_file, key_file=key_file)
self._ensure_api_ok()
def _build_full_url(self, url_path: str) -> str:
"""Build the full URL by concatenating *url_path* with the base URL.
:param url_path: path to be accessed in the ``pg-backup-api``.
:returns: the full URL after concatenating.
"""
return urljoin(self.api_url, url_path)
@staticmethod
def _deserialize_response(response: HTTPResponse) -> Any:
"""Retrieve body from *response* as a deserialized JSON object.
:param response: response from which JSON body will be deserialized.
:returns: the deserialized JSON body.
"""
return json.loads(response.data.decode("utf-8"))
@staticmethod
def _serialize_request(body: Any) -> Any:
"""Serialize a request body.
:param body: content of the request body to be serialized.
:returns: the serialized request body.
"""
return json.dumps(body).encode("utf-8")
def _get_request(self, url_path: str) -> Any:
"""Perform a ``GET`` request to *url_path*.
.. note::
If a :exc:`MaxRetryError` is faced while performing the request,
then exit with :attr:`ExitCode.HTTP_REQUEST_ERROR`
:param url_path: URL to perform the ``GET`` request against.
:returns: the deserialized response body.
"""
response = None
try:
response = self.http.request("GET", self._build_full_url(url_path))
except MaxRetryError as exc:
logging.critical("An error occurred while performing an HTTP GET "
"request: %r", exc)
sys.exit(ExitCode.HTTP_REQUEST_ERROR)
return self._deserialize_response(response)
def _post_request(self, url_path: str, body: Any) -> Any:
"""Perform a ``POST`` request to *url_path* serializing *body* as JSON.
.. note::
If a :exc:`MaxRetryError` is faced while performing the request,
then exit with :attr:`ExitCode.HTTP_REQUEST_ERROR`
:param url_path: URL to perform the ``POST`` request against.
:param body: the body to be serialized as JSON and sent in the request.
:returns: the deserialized response body.
"""
body = self._serialize_request(body)
response = None
try:
response = self.http.request("POST",
self._build_full_url(url_path),
body=body,
headers={
"Content-Type": "application/json"
})
except MaxRetryError as exc:
logging.critical("An error occurred while performing an HTTP POST "
"request: %r", exc)
sys.exit(ExitCode.HTTP_REQUEST_ERROR)
return self._deserialize_response(response)
def _ensure_api_ok(self) -> None:
"""Ensure ``pg-backup-api`` is reachable and ``OK``.
.. note::
If ``pg-backup-api`` status is not ``OK``, then exit with
:attr:`ExitCode.API_NOT_OK`.
"""
response = self._get_request("status")
if response != "OK":
logging.critical("pg-backup-api is not working: %s", response)
sys.exit(ExitCode.API_NOT_OK)
@retry(KeyError)
def _create_recovery_operation(self) -> str:
"""Create a recovery operation on the ``pg-backup-api``.
:returns: the ID of the recovery operation that has been created.
"""
response = self._post_request(
f"servers/{self.barman_server}/operations",
{
"type": "recovery",
"backup_id": self.backup_id,
"remote_ssh_command": self.ssh_command,
"destination_directory": self.data_directory,
},
)
return response["operation_id"]
@retry(KeyError)
def _get_recovery_operation_status(self, operation_id: str) -> str:
"""Get status of the recovery operation *operation_id*.
:param operation_id: ID of the recovery operation to be checked.
:returns: the status of the recovery operation.
"""
response = self._get_request(
f"servers/{self.barman_server}/operations/{operation_id}",
)
return response["status"]
def restore_backup(self) -> bool:
"""Restore the configured Barman backup through ``pg-backup-api``.
.. note::
If recovery API request returns a malformed response, then exit with
:attr:`ExitCode.HTTP_RESPONSE_MALFORMED`.
:returns: ``True`` if it was successfully recovered, ``False``
otherwise.
"""
operation_id = None
try:
operation_id = self._create_recovery_operation()
except RetriesExceeded:
logging.critical("Maximum number of retries exceeded, exiting.")
sys.exit(ExitCode.HTTP_RESPONSE_MALFORMED)
logging.info("Created the recovery operation with ID %s", operation_id)
status = None
while True:
try:
status = self._get_recovery_operation_status(operation_id)
except RetriesExceeded:
logging.critical("Maximum number of retries exceeded, "
"exiting.")
sys.exit(ExitCode.HTTP_RESPONSE_MALFORMED)
if status != "IN_PROGRESS":
break
logging.info("Recovery operation %s is still in progress",
operation_id)
time.sleep(self.loop_wait)
return status == "DONE"
def set_up_logging(log_file: Optional[str] = None) -> None:
"""Set up logging to file, if *log_file* is given, otherwise to console.
:param log_file: file where to log messages, if any.
"""
logging.basicConfig(filename=log_file, level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")
def main() -> None:
"""Entry point of this script.
Parse the command-line arguments and recover a Barman backup through
``pg-backup-api`` to the local host.
"""
parser = ArgumentParser(
epilog=(
"Wrapper script for ``pg-backup-api``. Communicate with the API "
"running at ``--api-url`` to restore a ``--backup-id`` Barman "
"backup of the server ``--barman-server``."
),
)
parser.add_argument(
"--api-url",
type=str,
required=True,
help="URL to reach the ``pg-backup-api``, e.g. "
"``http://localhost:7480``",
dest="api_url",
)
parser.add_argument(
"--cert-file",
type=str,
required=False,
help="Certificate to authenticate against the API, if required.",
dest="cert_file",
)
parser.add_argument(
"--key-file",
type=str,
required=False,
help="Certificate key to authenticate against the API, if required.",
dest="key_file",
)
parser.add_argument(
"--barman-server",
type=str,
required=True,
help="Name of the Barman server from which to restore the backup.",
dest="barman_server",
)
parser.add_argument(
"--backup-id",
type=str,
required=False,
default="latest",
help="ID of the Barman backup to be restored. You can use any value "
"supported by ``barman recover`` command "
"(default: ``%(default)s``)",
dest="backup_id",
)
parser.add_argument(
"--ssh-command",
type=str,
required=True,
help="Value to be passed as ``--remote-ssh-command`` to "
"``barman recover``.",
dest="ssh_command",
)
parser.add_argument(
"--data-directory",
"--datadir",
type=str,
required=True,
help="Destination path where to restore the barman backup in the "
"local host.",
dest="data_directory",
)
parser.add_argument(
"--log-file",
type=str,
required=False,
help="File where to log messages produced by this script, if any.",
dest="log_file",
)
parser.add_argument(
"--loop-wait",
type=int,
required=False,
default=10,
help="How long to wait before checking again the status of the "
"recovery process, in seconds. Use higher values if your "
"recovery is expected to take long (default: ``%(default)s``)",
dest="loop_wait",
)
parser.add_argument(
"--retry-wait",
type=int,
required=False,
default=2,
help="How long to wait before retrying a failed ``pg-backup-api`` "
"request (default: ``%(default)s``)",
dest="retry_wait",
)
parser.add_argument(
"--max-retries",
type=int,
required=False,
default=5,
help="Maximum number of retries when receiving malformed responses "
"from the ``pg-backup-api`` (default: ``%(default)s``)",
dest="max_retries",
)
args, _ = parser.parse_known_args()
set_up_logging(args.log_file)
barman_recover = BarmanRecover(args.api_url, args.barman_server,
args.backup_id, args.ssh_command,
args.data_directory, args.loop_wait,
args.retry_wait, args.max_retries,
args.cert_file, args.key_file)
successful = barman_recover.restore_backup()
if successful:
logging.info("Recovery operation finished successfully.")
sys.exit(ExitCode.RECOVERY_DONE)
else:
logging.critical("Recovery operation failed.")
sys.exit(ExitCode.RECOVERY_FAILED)
if __name__ == "__main__":
main()
+17 -5
View File
@@ -3,13 +3,16 @@ import abc
from typing import Any, Dict, Optional
from patroni.utils import parse_int
from patroni.utils import parse_int, parse_bool
class Tags(abc.ABC):
"""An abstract class that encapsulates all the ``tags`` logic.
Child classes that want to use provided facilities must implement ``tags`` abstract property.
.. note::
Due to backward-compatibility reasons, old tags may have a less strict type conversion than new ones.
"""
@staticmethod
@@ -20,16 +23,20 @@ class Tags(abc.ABC):
.. note::
A custom tag is any tag added to the configuration ``tags`` section that is not one of ``clonefrom``,
``nofailover``, ``noloadbalance`` or ``nosync``.
``nofailover``, ``noloadbalance``,``nosync`` or ``nostream``.
For the Patroni predefined tags, the returning object will only contain them if they are enabled as they
all are boolean values that default to disabled.
For most of the Patroni predefined tags, the returning object will only contain them if they are enabled as
they all are boolean values that default to disabled.
However ``nofailover`` tag is always returned if ``failover_priority`` tag is defined. In this case, we need
both values to see if they are contradictory and the ``nofailover`` value should be used.
:returns: a dictionary of tags set for this node. The key is the tag name, and the value is the corresponding
tag value.
"""
return {tag: value for tag, value in tags.items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync', 'nostream'),
value,
tag == 'nofailover' and 'failover_priority' in tags))}
@property
@abc.abstractmethod
@@ -85,3 +92,8 @@ class Tags(abc.ABC):
def replicatefrom(self) -> Optional[str]:
"""Value of ``replicatefrom`` tag, if any."""
return self.tags.get('replicatefrom')
@property
def nostream(self) -> bool:
"""``True`` if ``nostream`` is ``True``, else ``False``."""
return parse_bool(self.tags.get('nostream')) or False
+178 -27
View File
@@ -10,6 +10,7 @@
:var WHITESPACE_RE: regular expression to match whitespace characters
"""
import errno
import itertools
import logging
import os
import platform
@@ -24,6 +25,7 @@ from shlex import split
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from collections import OrderedDict
from dateutil import tz
from json import JSONDecoder
from urllib3.response import HTTPResponse
@@ -46,6 +48,37 @@ DBL_RE = re.compile(r'^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?')
WHITESPACE_RE = re.compile(r'[ \t\n\r]*', re.VERBOSE | re.MULTILINE | re.DOTALL)
def get_conversion_table(base_unit: str) -> Dict[str, Dict[str, Union[int, float]]]:
"""Get conversion table for the specified base unit.
If no conversion table exists for the passed unit, return an empty :class:`OrderedDict`.
:param base_unit: unit to choose the conversion table for.
:returns: :class:`OrderedDict` object.
"""
memory_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
('TB', {'B': 1024**4, 'kB': 1024**3, 'MB': 1024**2}),
('GB', {'B': 1024**3, 'kB': 1024**2, 'MB': 1024}),
('MB', {'B': 1024**2, 'kB': 1024, 'MB': 1}),
('kB', {'B': 1024, 'kB': 1, 'MB': 1024**-1}),
('B', {'B': 1, 'kB': 1024**-1, 'MB': 1024**-2})
])
time_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
('d', {'ms': 1000 * 60**2 * 24, 's': 60**2 * 24, 'min': 60 * 24}),
('h', {'ms': 1000 * 60**2, 's': 60**2, 'min': 60}),
('min', {'ms': 1000 * 60, 's': 60, 'min': 1}),
('s', {'ms': 1000, 's': 1, 'min': 60**-1}),
('ms', {'ms': 1, 's': 1000**-1, 'min': 1 / (1000 * 60)}),
('us', {'ms': 1000**-1, 's': 1000**-2, 'min': 1 / (1000**2 * 60)})
])
if base_unit in ('B', 'kB', 'MB'):
return memory_unit_conversion_table
elif base_unit in ('ms', 's', 'min'):
return time_unit_conversion_table
return OrderedDict()
def deep_compare(obj1: Dict[Any, Union[Any, Dict[Any, Any]]], obj2: Dict[Any, Union[Any, Dict[Any, Any]]]) -> bool:
"""Recursively compare two dictionaries to check if they are equal in terms of keys and values.
@@ -272,35 +305,154 @@ def convert_to_base_unit(value: Union[int, float], unit: str, base_unit: Optiona
>>> convert_to_base_unit(1, 'GB', '512 MB') is None
True
"""
convert: Dict[str, Dict[str, Union[int, float]]] = {
'B': {'B': 1, 'kB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024},
'kB': {'B': 1.0 / 1024, 'kB': 1, 'MB': 1024, 'GB': 1024 * 1024, 'TB': 1024 * 1024 * 1024},
'MB': {'B': 1.0 / (1024 * 1024), 'kB': 1.0 / 1024, 'MB': 1, 'GB': 1024, 'TB': 1024 * 1024},
'ms': {'us': 1.0 / 1000, 'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
's': {'us': 1.0 / (1000 * 1000), 'ms': 1.0 / 1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
'min': {'us': 1.0 / (1000 * 1000 * 60), 'ms': 1.0 / (1000 * 60), 's': 1.0 / 60, 'min': 1, 'h': 60, 'd': 60 * 24}
}
round_order = {
'TB': 'GB', 'GB': 'MB', 'MB': 'kB', 'kB': 'B',
'd': 'h', 'h': 'min', 'min': 's', 's': 'ms', 'ms': 'us'
}
if base_unit and base_unit not in convert:
base_value, base_unit = strtol(base_unit, False)
else:
base_value = 1
if base_value is not None and base_unit in convert and unit in convert[base_unit]:
value *= convert[base_unit][unit] / float(base_value)
base_value, base_unit = strtol(base_unit, False)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(base_value, int)
convert_tbl = get_conversion_table(base_unit)
# {'TB': 'GB', 'GB': 'MB', ...}
round_order = dict(zip(convert_tbl, itertools.islice(convert_tbl, 1, None)))
if unit in convert_tbl and base_unit in convert_tbl[unit]:
value *= convert_tbl[unit][base_unit] / float(base_value)
if unit in round_order:
multiplier = convert[base_unit][round_order[unit]]
multiplier = convert_tbl[round_order[unit]][base_unit]
value = round(value / float(multiplier)) * multiplier
return value
def convert_int_from_base_unit(base_value: int, base_unit: Optional[str]) -> Optional[str]:
"""Convert an integer value in some base unit to a human-friendly unit.
The output unit is chosen so that it's the greatest unit that can represent
the value without loss.
:param base_value: value to be converted from a base unit
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
* For space: ``B``, ``kB``, ``MB``;
* For time: ``ms``, ``s``, ``min``.
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
possible human-friendly unit, or ``None`` if conversion failed.
:Example:
>>> convert_int_from_base_unit(1024, 'kB')
'1MB'
>>> convert_int_from_base_unit(1025, 'kB')
'1025kB'
>>> convert_int_from_base_unit(4, '256MB')
'1GB'
>>> convert_int_from_base_unit(4, '256 MB') is None
True
>>> convert_int_from_base_unit(1024, 'KB') is None
True
"""
base_value_mult, base_unit = strtol(base_unit, False)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(base_value_mult, int)
base_value *= base_value_mult
convert_tbl = get_conversion_table(base_unit)
for unit in convert_tbl:
multiplier = convert_tbl[unit][base_unit]
if multiplier <= 1.0 or base_value % multiplier == 0:
return str(round(base_value / multiplier)) + unit
def convert_real_from_base_unit(base_value: float, base_unit: Optional[str]) -> Optional[str]:
"""Convert an floating-point value in some base unit to a human-friendly unit.
Same as :func:`convert_int_from_base_unit`, except we have to do the math a bit differently,
and there's a possibility that we don't find any exact divisor.
:param base_value: value to be converted from a base unit
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
* For space: ``B``, ``kB``, ``MB``;
* For time: ``ms``, ``s``, ``min``.
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
possible human-friendly unit, or ``None`` if conversion failed.
:Example:
>>> convert_real_from_base_unit(5, 'ms')
'5ms'
>>> convert_real_from_base_unit(2.5, 'ms')
'2500us'
>>> convert_real_from_base_unit(4.0, '256MB')
'1GB'
>>> convert_real_from_base_unit(4.0, '256 MB') is None
True
"""
base_value_mult, base_unit = strtol(base_unit, False)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(base_value_mult, int)
base_value *= base_value_mult
result = None
convert_tbl = get_conversion_table(base_unit)
for unit in convert_tbl:
value = base_value / convert_tbl[unit][base_unit]
result = f'{value:g}{unit}'
if value > 0 and abs((round(value) / value) - 1.0) <= 1e-8:
break
return result
def maybe_convert_from_base_unit(base_value: str, vartype: str, base_unit: Optional[str]) -> str:
"""Try to convert integer or real value in a base unit to a human-readable unit.
Value is passed as a string. If parsing or subsequent conversion fails, the original
value is returned.
:param base_value: value to be converted from a base unit.
:param vartype: the target type to parse *base_value* before converting (``integer``
or ``real`` is expected, any other type results in return value being equal to the
*base_value* string).
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
* For space: ``B``, ``kB``, ``MB``;
* For time: ``ms``, ``s``, ``min``.
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
possible human-friendly unit, or *base_value* string if conversion failed.
:Example:
>>> maybe_convert_from_base_unit('5', 'integer', 'ms')
'5ms'
>>> maybe_convert_from_base_unit('4.2', 'real', 'ms')
'4200us'
>>> maybe_convert_from_base_unit('on', 'bool', None)
'on'
>>> maybe_convert_from_base_unit('', 'integer', '256MB')
''
"""
converters: Dict[str, Tuple[Callable[[str, Optional[str]], Union[int, float, str, None]],
Callable[[Any, Optional[str]], Optional[str]]]] = {
'integer': (parse_int, convert_int_from_base_unit),
'real': (parse_real, convert_real_from_base_unit),
'default': (lambda v, _: v, lambda v, _: v)
}
parser, converter = converters.get(vartype, converters['default'])
parsed_value = parser(base_value, None)
if parsed_value:
return converter(parsed_value, base_unit) or base_value
return base_value
def parse_int(value: Any, base_unit: Optional[str] = None) -> Optional[int]:
"""Parse *value* as an :class:`int`.
@@ -564,7 +716,7 @@ class Retry(object):
return self._cur_stoptime or 0
def ensure_deadline(self, timeout: float, raise_ex: Optional[Exception] = None) -> bool:
"""Calculates, sets, and checks the remaining deadline time.
"""Calculates 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*.
@@ -575,8 +727,7 @@ class Retry(object):
:raises:
:class:`Exception`: *raise_ex* if calculated deadline is smaller than provided *timeout*.
"""
self.deadline = self.stoptime - time.time()
if self.deadline < timeout:
if self.stoptime - time.time() < timeout:
if raise_ex:
raise raise_ex
return False
@@ -813,7 +964,7 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
member['host'] = conn_kwargs['host']
if conn_kwargs.get('port'):
member['port'] = int(conn_kwargs['port'])
optional_attributes = ('timeline', 'pending_restart', 'scheduled_restart', 'tags')
optional_attributes = ('timeline', 'pending_restart', 'pending_restart_reason', 'scheduled_restart', 'tags')
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
if m.name != leader_name:
+61 -4
View File
@@ -11,11 +11,53 @@ import socket
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING
from .collections import CaseInsensitiveSet
from .collections import CaseInsensitiveSet, EMPTY_DICT
from .dcs import dcs_modules
from .exceptions import ConfigParseError
from .utils import parse_int, split_host_port, data_directory_is_empty, get_major_version
from .log import type_logformat
def validate_log_field(field: Union[str, Dict[str, Any], Any]) -> bool:
"""Checks if log field is valid.
:param field: A log field to be validated.
:returns: ``True`` if the field is either a string or a dictionary with exactly one key
that has string value, ``False`` otherwise.
"""
if isinstance(field, str):
return True
elif isinstance(field, dict):
return len(field) == 1 and isinstance(next(iter(field.values())), str)
return False
def validate_log_format(logformat: type_logformat) -> bool:
"""Checks if log format is valid.
:param logformat: A log format to be validated.
:returns: ``True`` if the log format is either a string or a list of valid log fields.
:raises:
:exc:`~patroni.exceptions.ConfigParseError`:
* If the logformat is not a string or a list; or
* If the logformat is an empty list; or
* If the log format is a list and it with values that don't pass validation using
:func:`validate_log_field`.
"""
if isinstance(logformat, str):
return True
elif isinstance(logformat, list):
if len(logformat) == 0:
raise ConfigParseError('should contain at least one item')
if not all(map(validate_log_field, logformat)):
raise ConfigParseError('each item should be a string or a dictionary with string values')
return True
else:
raise ConfigParseError('Should be a string or a list')
def data_directory_empty(data_dir: str) -> bool:
@@ -202,7 +244,7 @@ def get_bin_name(bin_name: str) -> str:
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(schema.data, dict)
return (schema.data.get('postgresql', {}).get('bin_name', {}) or {}).get(bin_name, bin_name)
return (schema.data.get('postgresql', {}).get('bin_name', {}) or EMPTY_DICT).get(bin_name, bin_name)
def validate_data_dir(data_dir: str) -> bool:
@@ -937,6 +979,20 @@ validate_etcd = {
schema = Schema({
"name": str,
"scope": str,
Optional("log"): {
Optional("type"): EnumValidator(('plain', 'json'), case_sensitive=True, raise_assert=True),
Optional("level"): EnumValidator(('DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR', 'FATAL', 'CRITICAL'),
case_sensitive=True, raise_assert=True),
Optional("traceback_level"): EnumValidator(('DEBUG', 'ERROR'), raise_assert=True),
Optional("format"): validate_log_format,
Optional("dateformat"): str,
Optional("static_fields"): dict,
Optional("max_queue_size"): int,
Optional("dir"): str,
Optional("file_num"): int,
Optional("file_size"): int,
Optional("loggers"): dict
},
Optional("ctl"): {
Optional("insecure"): bool,
Optional("cacert"): str,
@@ -1115,6 +1171,7 @@ schema = Schema({
Optional("clonefrom"): bool,
Optional("noloadbalance"): bool,
Optional("replicatefrom"): str,
Optional("nosync"): bool
Optional("nosync"): bool,
Optional("nostream"): bool
}
})
+1 -1
View File
@@ -2,4 +2,4 @@
:var __version__: the current Patroni version.
"""
__version__ = '3.2.1'
__version__ = '3.3.0'
+2 -1
View File
@@ -132,7 +132,8 @@ postgresql:
# safety_margin: 5
tags:
nofailover: false
# failover_priority: 1
noloadbalance: false
clonefrom: false
nosync: false
nostream: false
+1 -1
View File
@@ -124,6 +124,6 @@ postgresql:
#pre_promote: /path/to/pre_promote.sh
tags:
nofailover: false
# failover_priority: 1
noloadbalance: false
clonefrom: false
+1 -1
View File
@@ -114,7 +114,7 @@ postgresql:
# krb_server_keyfile: /var/spool/keytabs/postgres
unix_socket_directories: '..' # parent directory of data_dir
tags:
nofailover: false
# failover_priority: 1
noloadbalance: false
clonefrom: false
# replicatefrom: postgresql1
+1
View File
@@ -11,3 +11,4 @@ pysyncobj>=0.3.8
cryptography>=1.4
psutil>=2.0.0
ydiff>=1.2.0
python-json-logger>=2.0.2
+2 -2
View File
@@ -25,7 +25,7 @@ KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
EXTRAS_REQUIRE = {'aws': ['boto3'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography'], 'jsonlogger': ['python-json-logger']}
# Add here all kinds of additional classifiers as defined under
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
@@ -55,7 +55,7 @@ CONSOLE_SCRIPTS = ['patroni = patroni.__main__:main',
'patroni_raft_controller = patroni.raft_controller:main',
"patroni_wale_restore = patroni.scripts.wale_restore:main",
"patroni_aws = patroni.scripts.aws:main",
"patroni_barman_recover = patroni.scripts.barman_recover:main"]
"patroni_barman = patroni.scripts.barman.cli:main"]
class _Command(Command):
+25 -23
View File
@@ -12,6 +12,7 @@ import patroni.psycopg as psycopg
from patroni.dcs import Leader, Member
from patroni.postgresql import Postgresql
from patroni.postgresql.config import ConfigHandler
from patroni.postgresql.mpp import get_mpp
from patroni.utils import RetryFailedError, tzutc
@@ -54,10 +55,10 @@ GET_PG_SETTINGS_RESULT = [
('zero_damaged_pages', 'off', None, 'bool', 'superuser'),
('stats_temp_directory', '/tmp', None, 'string', 'sighup'),
('track_commit_timestamp', 'off', None, 'bool', 'postmaster'),
('wal_log_hints', 'on', None, 'bool', 'superuser'),
('hot_standby', 'on', None, 'bool', 'superuser'),
('max_replication_slots', '5', None, 'integer', 'superuser'),
('wal_level', 'logical', None, 'enum', 'superuser'),
('wal_log_hints', 'on', None, 'bool', 'postmaster'),
('hot_standby', 'on', None, 'bool', 'postmaster'),
('max_replication_slots', '5', None, 'integer', 'postmaster'),
('wal_level', 'logical', None, 'enum', 'postmaster'),
]
@@ -150,6 +151,8 @@ class MockCursor(object):
self.results = [(False, 2)]
elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
self.results = [(datetime.datetime.now(tzutc),)]
elif sql.endswith('AND pending_restart'):
self.results = []
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')),
@@ -167,8 +170,6 @@ class MockCursor(object):
('cluster_name', 'my_cluster')]
elif sql.startswith('SELECT name, setting'):
self.results = GET_PG_SETTINGS_RESULT
elif sql.startswith('SELECT COUNT(*) FROM pg_catalog.pg_settings'):
self.results = [(0,)]
elif sql.startswith('IDENTIFY_SYSTEM'):
self.results = [('1', 3, '0/402EEC0', '')]
elif sql.startswith('TIMELINE_HISTORY '):
@@ -252,23 +253,24 @@ class PostgresInit(unittest.TestCase):
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='primary'))
def setUp(self):
data_dir = os.path.join('data', 'test0')
self.p = Postgresql({'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
'config_dir': data_dir, 'retry_timeout': 10,
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
'listen': '127.0.0.2, 127.0.0.3:5432',
'connect_address': '127.0.0.2:5432', 'proxy_address': '127.0.0.2:5433',
'authentication': {'superuser': {'username': 'foo', 'password': 'test'},
'replication': {'username': '', 'password': 'rep-pass'},
'rewind': {'username': 'rewind', 'password': 'test'}},
'remove_data_directory_on_rewind_failure': True,
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', 'use_unix_socket': True,
'parameters': self._PARAMETERS,
'recovery_conf': {'foo': 'bar'},
'pg_hba': ['host all all 0.0.0.0/0 md5'],
'pg_ident': ['krb realm postgres'],
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'on_reload': 'true',
'on_restart': 'true', 'on_role_change': 'true'},
'citus': {'group': 0, 'database': 'citus'}})
config = {'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
'config_dir': data_dir, 'retry_timeout': 10,
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
'listen': '127.0.0.2, 127.0.0.3:5432',
'connect_address': '127.0.0.2:5432', 'proxy_address': '127.0.0.2:5433',
'authentication': {'superuser': {'username': 'foo', 'password': 'test'},
'replication': {'username': '', 'password': 'rep-pass'},
'rewind': {'username': 'rewind', 'password': 'test'}},
'remove_data_directory_on_rewind_failure': True,
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', 'use_unix_socket': True,
'parameters': self._PARAMETERS,
'recovery_conf': {'foo': 'bar'},
'pg_hba': ['host all all 0.0.0.0/0 md5'],
'pg_ident': ['krb realm postgres'],
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'on_reload': 'true',
'on_restart': 'true', 'on_role_change': 'true'},
'citus': {'group': 0, 'database': 'citus'}}
self.p = Postgresql(config, get_mpp(config))
class BaseTestPostgresql(PostgresInit):
+10 -2
View File
@@ -13,6 +13,7 @@ from patroni.api import RestApiHandler, RestApiServer
from patroni.dcs import ClusterConfig, Member
from patroni.exceptions import PostgresConnectionException
from patroni.ha import _MemberStatus
from patroni.postgresql.config import get_param_diff
from patroni.psycopg import OperationalError
from patroni.utils import RetryFailedError, tzutc
@@ -54,13 +55,13 @@ class MockPostgresql:
major_version = 90600
sysid = 'dummysysid'
scope = 'dummy'
pending_restart = True
pending_restart_reason = {}
wal_name = 'wal'
lsn_name = 'lsn'
wal_flush = '_flush'
POSTMASTER_START_TIME = 'pg_catalog.pg_postmaster_start_time()'
TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()'
citus_handler = Mock()
mpp_handler = Mock()
@staticmethod
def postmaster_start_time():
@@ -202,6 +203,7 @@ class TestRestApiHandler(unittest.TestCase):
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
def test_do_GET(self):
MockPostgresql.pending_restart_reason = {'max_connections': get_param_diff('200', '100')}
MockPatroni.dcs.cluster.last_lsn = 20
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
with patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True)):
@@ -673,6 +675,12 @@ class TestRestApiHandler(unittest.TestCase):
MockRestApiServer(RestApiHandler, post + '0\n\n')
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
@patch.object(MockHa, 'is_leader', Mock(return_value=True))
def test_do_POST_mpp(self):
post = 'POST /mpp HTTP/1.0' + self._authorization + '\nContent-Length: '
MockRestApiServer(RestApiHandler, post + '0\n\n')
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
class TestRestApiServer(unittest.TestCase):
+765
View File
@@ -0,0 +1,765 @@
import logging
import mock
from mock import MagicMock, Mock, patch
import unittest
from urllib3.exceptions import MaxRetryError
from patroni.scripts.barman.cli import main
from patroni.scripts.barman.config_switch import (ExitCode as BarmanConfigSwitchExitCode, _should_skip_switch,
_switch_config, run_barman_config_switch)
from patroni.scripts.barman.recover import ExitCode as BarmanRecoverExitCode, _restore_backup, run_barman_recover
from patroni.scripts.barman.utils import ApiNotOk, OperationStatus, PgBackupApi, RetriesExceeded, set_up_logging
API_URL = "http://localhost:7480"
BARMAN_SERVER = "my_server"
BARMAN_MODEL = "my_model"
BACKUP_ID = "backup_id"
SSH_COMMAND = "ssh postgres@localhost"
DATA_DIRECTORY = "/path/to/pgdata"
LOOP_WAIT = 10
RETRY_WAIT = 2
MAX_RETRIES = 5
# stuff from patroni.scripts.barman.utils
@patch("logging.basicConfig")
def test_set_up_logging(mock_log_config):
log_file = "/path/to/some/file.log"
set_up_logging(log_file)
mock_log_config.assert_called_once_with(filename=log_file, level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")
class TestPgBackupApi(unittest.TestCase):
@patch.object(PgBackupApi, "_ensure_api_ok", Mock())
@patch("patroni.scripts.barman.utils.PoolManager", MagicMock())
def setUp(self):
self.api = PgBackupApi(API_URL, None, None, RETRY_WAIT, MAX_RETRIES)
# Reset the mock as the same instance is used across tests
self.api._http.request.reset_mock()
self.api._http.request.side_effect = None
def test__build_full_url(self):
self.assertEqual(self.api._build_full_url("/some/path"), f"{API_URL}/some/path")
@patch("json.loads")
def test__deserialize_response(self, mock_json_loads):
mock_response = MagicMock()
self.assertIsNotNone(self.api._deserialize_response(mock_response))
mock_json_loads.assert_called_once_with(mock_response.data.decode("utf-8"))
@patch("json.dumps")
def test__serialize_request(self, mock_json_dumps):
body = "some_body"
ret = self.api._serialize_request(body)
self.assertIsNotNone(ret)
mock_json_dumps.assert_called_once_with(body)
mock_json_dumps.return_value.encode.assert_called_once_with("utf-8")
@patch.object(PgBackupApi, "_deserialize_response", Mock(return_value="test"))
def test__get_request(self):
mock_request = self.api._http.request
# with no error
self.assertEqual(self.api._get_request("/some/path"), "test")
mock_request.assert_called_once_with("GET", f"{API_URL}/some/path")
# with MaxRetryError
http_error = MaxRetryError(self.api._http, f"{API_URL}/some/path")
mock_request.side_effect = http_error
with self.assertRaises(RetriesExceeded) as exc:
self.assertIsNone(self.api._get_request("/some/path"))
self.assertEqual(
str(exc.exception),
"Failed to perform a GET request to http://localhost:7480/some/path"
)
@patch.object(PgBackupApi, "_deserialize_response", Mock(return_value="test"))
@patch.object(PgBackupApi, "_serialize_request")
def test__post_request(self, mock_serialize):
mock_request = self.api._http.request
# with no error
self.assertEqual(self.api._post_request("/some/path", "some body"), "test")
mock_serialize.assert_called_once_with("some body")
mock_request.assert_called_once_with("POST", f"{API_URL}/some/path", body=mock_serialize.return_value,
headers={"Content-Type": "application/json"})
# with HTTPError
http_error = MaxRetryError(self.api._http, f"{API_URL}/some/path")
mock_request.side_effect = http_error
with self.assertRaises(RetriesExceeded) as exc:
self.assertIsNone(self.api._post_request("/some/path", "some body"))
self.assertEqual(
str(exc.exception),
f"Failed to perform a POST request to http://localhost:7480/some/path with {mock_serialize.return_value}"
)
@patch.object(PgBackupApi, "_get_request")
def test__ensure_api_ok(self, mock_get_request):
# API ok
mock_get_request.return_value = "OK"
self.assertIsNone(self.api._ensure_api_ok())
# API not ok
mock_get_request.return_value = "random"
with self.assertRaises(ApiNotOk) as exc:
self.assertIsNone(self.api._ensure_api_ok())
self.assertEqual(
str(exc.exception),
"pg-backup-api is currently not up and running at http://localhost:7480: random",
)
@patch("patroni.scripts.barman.utils.OperationStatus")
@patch("logging.warning")
@patch("time.sleep")
@patch.object(PgBackupApi, "_get_request")
def test_get_operation_status(self, mock_get_request, mock_sleep, mock_logging, mock_op_status):
# well formed response
mock_get_request.return_value = {"status": "some status"}
mock_op_status.__getitem__.return_value = "SOME_STATUS"
self.assertEqual(self.api.get_operation_status(BARMAN_SERVER, "some_id"), "SOME_STATUS")
mock_get_request.assert_called_once_with(f"servers/{BARMAN_SERVER}/operations/some_id")
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
mock_op_status.__getitem__.assert_called_once_with("some status")
# malformed response
mock_get_request.return_value = {"statuss": "some status"}
with self.assertRaises(RetriesExceeded) as exc:
self.api.get_operation_status(BARMAN_SERVER, "some_id")
self.assertEqual(str(exc.exception),
"Maximum number of retries exceeded for method PgBackupApi.get_operation_status.")
self.assertEqual(mock_sleep.call_count, self.api.max_retries)
mock_sleep.assert_has_calls([mock.call(self.api.retry_wait)] * self.api.max_retries)
self.assertEqual(mock_logging.call_count, self.api.max_retries)
for i in range(mock_logging.call_count):
call_args = mock_logging.call_args_list[i][0]
self.assertEqual(len(call_args), 5)
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
self.assertEqual(call_args[1], i + 1)
self.assertEqual(call_args[2], self.api.max_retries)
self.assertEqual(call_args[3], "PgBackupApi.get_operation_status")
self.assertIsInstance(call_args[4], KeyError)
self.assertEqual(call_args[4].args, ('status',))
@patch("logging.warning")
@patch("time.sleep")
@patch.object(PgBackupApi, "_post_request")
def test_create_recovery_operation(self, mock_post_request, mock_sleep, mock_logging):
# well formed response
mock_post_request.return_value = {"operation_id": "some_id"}
self.assertEqual(
self.api.create_recovery_operation(BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY),
"some_id",
)
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
mock_post_request.assert_called_once_with(
f"servers/{BARMAN_SERVER}/operations",
{
"type": "recovery",
"backup_id": BACKUP_ID,
"remote_ssh_command": SSH_COMMAND,
"destination_directory": DATA_DIRECTORY,
}
)
# malformed response
mock_post_request.return_value = {"operation_idd": "some_id"}
with self.assertRaises(RetriesExceeded) as exc:
self.api.create_recovery_operation(BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY)
self.assertEqual(str(exc.exception),
"Maximum number of retries exceeded for method PgBackupApi.create_recovery_operation.")
self.assertEqual(mock_sleep.call_count, self.api.max_retries)
mock_sleep.assert_has_calls([mock.call(self.api.retry_wait)] * self.api.max_retries)
self.assertEqual(mock_logging.call_count, self.api.max_retries)
for i in range(mock_logging.call_count):
call_args = mock_logging.call_args_list[i][0]
self.assertEqual(len(call_args), 5)
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
self.assertEqual(call_args[1], i + 1)
self.assertEqual(call_args[2], self.api.max_retries)
self.assertEqual(call_args[3], "PgBackupApi.create_recovery_operation")
self.assertIsInstance(call_args[4], KeyError)
self.assertEqual(call_args[4].args, ('operation_id',))
@patch("logging.warning")
@patch("time.sleep")
@patch.object(PgBackupApi, "_post_request")
def test_create_config_switch_operation(self, mock_post_request, mock_sleep, mock_logging):
# well formed response -- sample 1
mock_post_request.return_value = {"operation_id": "some_id"}
self.assertEqual(
self.api.create_config_switch_operation(BARMAN_SERVER, BARMAN_MODEL, None),
"some_id",
)
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
mock_post_request.assert_called_once_with(
f"servers/{BARMAN_SERVER}/operations",
{
"type": "config_switch",
"model_name": BARMAN_MODEL,
}
)
# well formed response -- sample 2
mock_post_request.reset_mock()
self.assertEqual(
self.api.create_config_switch_operation(BARMAN_SERVER, None, True),
"some_id",
)
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
mock_post_request.assert_called_once_with(
f"servers/{BARMAN_SERVER}/operations",
{
"type": "config_switch",
"reset": True,
}
)
# malformed response
mock_post_request.return_value = {"operation_idd": "some_id"}
with self.assertRaises(RetriesExceeded) as exc:
self.api.create_config_switch_operation(BARMAN_SERVER, BARMAN_MODEL, None)
self.assertEqual(str(exc.exception),
"Maximum number of retries exceeded for method PgBackupApi.create_config_switch_operation.")
self.assertEqual(mock_sleep.call_count, self.api.max_retries)
mock_sleep.assert_has_calls([mock.call(self.api.retry_wait)] * self.api.max_retries)
self.assertEqual(mock_logging.call_count, self.api.max_retries)
for i in range(mock_logging.call_count):
call_args = mock_logging.call_args_list[i][0]
self.assertEqual(len(call_args), 5)
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
self.assertEqual(call_args[1], i + 1)
self.assertEqual(call_args[2], self.api.max_retries)
self.assertEqual(call_args[3], "PgBackupApi.create_config_switch_operation")
self.assertIsInstance(call_args[4], KeyError)
self.assertEqual(call_args[4].args, ('operation_id',))
# stuff from patroni.scripts.barman.recover
class TestBarmanRecover(unittest.TestCase):
def setUp(self):
self.api = MagicMock()
# Reset the mock as the same instance is used across tests
self.api._http.request.reset_mock()
self.api._http.request.side_effect = None
@patch("time.sleep")
@patch("logging.info")
@patch("logging.error")
def test__restore_backup(self, mock_log_error, mock_log_info, mock_sleep):
mock_create_op = self.api.create_recovery_operation
mock_get_status = self.api.get_operation_status
# successful fast restore
mock_create_op.return_value = "some_id"
mock_get_status.return_value = OperationStatus.DONE
self.assertEqual(
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
BarmanRecoverExitCode.RECOVERY_DONE,
)
mock_create_op.assert_called_once_with(BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY)
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
mock_log_info.assert_has_calls([
mock.call("Created the recovery operation with ID %s", "some_id"),
mock.call("Recovery operation finished successfully."),
])
mock_log_error.assert_not_called()
mock_sleep.assert_not_called()
# successful slow restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.DONE]
self.assertEqual(
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
BarmanRecoverExitCode.RECOVERY_DONE,
)
mock_create_op.assert_called_once()
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 22)
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20
+ [mock.call("Recovery operation finished successfully.")])
mock_log_error.assert_not_called()
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
# failed fast restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = None
mock_get_status.return_value = OperationStatus.FAILED
self.assertEqual(
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
BarmanRecoverExitCode.RECOVERY_FAILED,
)
mock_create_op.assert_called_once()
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
mock_log_info.assert_has_calls([
mock.call("Created the recovery operation with ID %s", "some_id"),
])
mock_log_error.assert_has_calls([
mock.call("Recovery operation failed."),
])
mock_sleep.assert_not_called()
# failed slow restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_log_error.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.FAILED]
self.assertEqual(
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
BarmanRecoverExitCode.RECOVERY_FAILED,
)
mock_create_op.assert_called_once()
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 21)
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20)
mock_log_error.assert_has_calls([
mock.call("Recovery operation failed."),
])
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
# create retries exceeded
mock_log_info.reset_mock()
mock_log_error.reset_mock()
mock_sleep.reset_mock()
mock_create_op.side_effect = RetriesExceeded()
mock_get_status.side_effect = None
self.assertEqual(
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
BarmanRecoverExitCode.HTTP_ERROR,
)
mock_log_info.assert_not_called()
mock_log_error.assert_called_once_with("An issue was faced while trying to create a recovery operation: %r",
mock_create_op.side_effect)
mock_sleep.assert_not_called()
# get status retries exceeded
mock_create_op.reset_mock()
mock_create_op.side_effect = None
mock_log_error.reset_mock()
mock_log_info.reset_mock()
mock_get_status.side_effect = RetriesExceeded
self.assertEqual(
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
BarmanRecoverExitCode.HTTP_ERROR,
)
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
mock_log_error.assert_called_once_with("Maximum number of retries exceeded, exiting.")
mock_sleep.assert_not_called()
class TestBarmanRecoverCli(unittest.TestCase):
@patch("patroni.scripts.barman.recover._restore_backup")
def test_run_barman_recover(self, mock_rb):
api = MagicMock()
args = MagicMock()
# successful execution
mock_rb.return_value = BarmanRecoverExitCode.RECOVERY_DONE
self.assertEqual(
run_barman_recover(api, args),
BarmanRecoverExitCode.RECOVERY_DONE,
)
mock_rb.assert_called_once_with(api, args.barman_server, args.backup_id,
args.ssh_command, args.data_directory,
args.loop_wait)
# failed execution
mock_rb.reset_mock()
mock_rb.return_value = BarmanRecoverExitCode.RECOVERY_FAILED
self.assertEqual(
run_barman_recover(api, args),
BarmanRecoverExitCode.RECOVERY_FAILED,
)
mock_rb.assert_called_once_with(api, args.barman_server, args.backup_id,
args.ssh_command, args.data_directory,
args.loop_wait)
# stuff from patroni.scripts.barman.config_switch
class TestBarmanConfigSwitch(unittest.TestCase):
def setUp(self):
self.api = MagicMock()
# Reset the mock as the same instance is used across tests
self.api._http.request.reset_mock()
self.api._http.request.side_effect = None
@patch("time.sleep")
@patch("logging.info")
@patch("logging.error")
def test__switch_config(self, mock_log_error, mock_log_info, mock_sleep):
mock_create_op = self.api.create_config_switch_operation
mock_get_status = self.api.get_operation_status
# successful fast config-switch
mock_create_op.return_value = "some_id"
mock_get_status.return_value = OperationStatus.DONE
self.assertEqual(
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE,
)
mock_create_op.assert_called_once_with(BARMAN_SERVER, BARMAN_MODEL, None)
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
mock_log_info.assert_has_calls([
mock.call("Created the config switch operation with ID %s", "some_id"),
mock.call("Config switch operation finished successfully."),
])
mock_log_error.assert_not_called()
mock_sleep.assert_not_called()
# successful slow config-switch
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.DONE]
self.assertEqual(
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE,
)
mock_create_op.assert_called_once_with(BARMAN_SERVER, BARMAN_MODEL, None)
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 22)
mock_log_info.assert_has_calls([mock.call("Created the config switch operation with ID %s", "some_id")]
+ [mock.call("Config switch operation %s is still in progress", "some_id")] * 20
+ [mock.call("Config switch operation finished successfully.")])
mock_log_error.assert_not_called()
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(5)] * 20)
# failed fast config-switch
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = None
mock_get_status.return_value = OperationStatus.FAILED
self.assertEqual(
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED,
)
mock_create_op.assert_called_once()
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
mock_log_info.assert_called_once_with("Created the config switch operation with ID %s", "some_id")
mock_log_error.assert_called_once_with("Config switch operation failed.")
mock_sleep.assert_not_called()
# failed slow config-switch
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_log_error.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.FAILED]
self.assertEqual(
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED,
)
mock_create_op.assert_called_once()
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 21)
mock_log_info.assert_has_calls([mock.call("Created the config switch operation with ID %s", "some_id")]
+ [mock.call("Config switch operation %s is still in progress", "some_id")] * 20)
mock_log_error.assert_called_once_with("Config switch operation failed.")
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(5)] * 20)
# create retries exceeded
mock_log_info.reset_mock()
mock_log_error.reset_mock()
mock_sleep.reset_mock()
mock_create_op.side_effect = RetriesExceeded()
mock_get_status.side_effect = None
self.assertEqual(
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
BarmanConfigSwitchExitCode.HTTP_ERROR,
)
mock_log_info.assert_not_called()
mock_log_error.assert_called_once_with("An issue was faced while trying to create a config switch operation: "
"%r",
mock_create_op.side_effect)
mock_sleep.assert_not_called()
# get status retries exceeded
mock_create_op.reset_mock()
mock_create_op.side_effect = None
mock_log_error.reset_mock()
mock_get_status.side_effect = RetriesExceeded
self.assertEqual(
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
BarmanConfigSwitchExitCode.HTTP_ERROR,
)
mock_log_info.assert_called_once_with("Created the config switch operation with ID %s", "some_id")
mock_log_error.assert_called_once_with("Maximum number of retries exceeded, exiting.")
mock_sleep.assert_not_called()
class TestBarmanConfigSwitchCli(unittest.TestCase):
def test__should_skip_switch(self):
args = MagicMock()
for role, switch_when, expected in [
("master", "promoted", False),
("master", "demoted", True),
("master", "always", False),
("primary", "promoted", False),
("primary", "demoted", True),
("primary", "always", False),
("promoted", "promoted", False),
("promoted", "demoted", True),
("promoted", "always", False),
("standby_leader", "promoted", True),
("standby_leader", "demoted", True),
("standby_leader", "always", False),
("replica", "promoted", True),
("replica", "demoted", False),
("replica", "always", False),
("demoted", "promoted", True),
("demoted", "demoted", False),
("demoted", "always", False),
]:
args.role = role
args.switch_when = switch_when
self.assertEqual(_should_skip_switch(args), expected)
@patch("patroni.scripts.barman.config_switch._should_skip_switch")
@patch("patroni.scripts.barman.config_switch._switch_config")
@patch("logging.error")
@patch("logging.info")
def test_run_barman_config_switch(self, mock_log_info, mock_log_error, mock_sc, mock_skip):
api = MagicMock()
args = MagicMock()
args.reset = None
# successful execution
mock_skip.return_value = False
mock_sc.return_value = BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE
self.assertEqual(
run_barman_config_switch(api, args),
BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE,
)
mock_sc.assert_called_once_with(api, args.barman_server, args.barman_model,
args.reset)
# failed execution
mock_sc.reset_mock()
mock_sc.return_value = BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED
self.assertEqual(
run_barman_config_switch(api, args),
BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED,
)
mock_sc.assert_called_once_with(api, args.barman_server, args.barman_model,
args.reset)
# skipped execution
mock_sc.reset_mock()
mock_skip.return_value = True
self.assertEqual(
run_barman_config_switch(api, args),
BarmanConfigSwitchExitCode.CONFIG_SWITCH_SKIPPED
)
mock_sc.assert_not_called()
mock_log_info.assert_called_once_with("Config switch operation was skipped (role=%s, "
"switch_when=%s).", args.role, args.switch_when)
mock_log_error.assert_not_called()
# invalid args -- sample 1
mock_skip.return_value = False
args = MagicMock()
args.barman_server = BARMAN_SERVER
args.barman_model = BARMAN_MODEL
args.reset = True
self.assertEqual(
run_barman_config_switch(api, args),
BarmanConfigSwitchExitCode.INVALID_ARGS,
)
mock_log_error.assert_called_once_with("One, and only one among 'barman_model' ('%s') and 'reset' "
"('%s') should be given", BARMAN_MODEL, True)
api.assert_not_called()
# invalid args -- sample 2
args = MagicMock()
args.barman_server = BARMAN_SERVER
args.barman_model = None
args.reset = None
mock_log_error.reset_mock()
api.reset_mock()
self.assertEqual(
run_barman_config_switch(api, args),
BarmanConfigSwitchExitCode.INVALID_ARGS,
)
mock_log_error.assert_called_once_with("One, and only one among 'barman_model' ('%s') and 'reset' "
"('%s') should be given", None, None)
api.assert_not_called()
# stuff from patroni.scripts.barman.cli
class TestMain(unittest.TestCase):
@patch("patroni.scripts.barman.cli.PgBackupApi")
@patch("patroni.scripts.barman.cli.set_up_logging")
@patch("patroni.scripts.barman.cli.ArgumentParser")
def test_main(self, mock_arg_parse, mock_set_up_log, mock_api):
# sub-command specified
args = MagicMock()
args.func.return_value = 0
mock_arg_parse.return_value.parse_known_args.return_value = (args, None)
with self.assertRaises(SystemExit) as exc:
main()
mock_arg_parse.assert_called_once()
mock_set_up_log.assert_called_once_with(args.log_file)
mock_api.assert_called_once_with(args.api_url, args.cert_file,
args.key_file, args.retry_wait,
args.max_retries)
mock_arg_parse.return_value.print_help.assert_not_called()
args.func.assert_called_once_with(mock_api.return_value, args)
self.assertEqual(exc.exception.code, 0)
# Issue in the API
mock_arg_parse.reset_mock()
mock_set_up_log.reset_mock()
mock_api.reset_mock()
mock_api.side_effect = ApiNotOk()
with self.assertRaises(SystemExit) as exc:
main()
mock_arg_parse.assert_called_once()
mock_set_up_log.assert_called_once_with(args.log_file)
mock_api.assert_called_once_with(args.api_url, args.cert_file,
args.key_file, args.retry_wait,
args.max_retries)
mock_arg_parse.return_value.print_help.assert_not_called()
self.assertEqual(exc.exception.code, -2)
# sub-command not specified
mock_arg_parse.reset_mock()
mock_set_up_log.reset_mock()
mock_api.reset_mock()
delattr(args, "func")
mock_api.side_effect = None
with self.assertRaises(SystemExit) as exc:
main()
mock_arg_parse.assert_called_once()
mock_set_up_log.assert_called_once_with(args.log_file)
mock_api.assert_not_called()
mock_arg_parse.return_value.print_help.assert_called_once_with()
self.assertEqual(exc.exception.code, -1)
-366
View File
@@ -1,366 +0,0 @@
import logging
import mock
from mock import MagicMock, Mock, patch
import unittest
from urllib3.exceptions import MaxRetryError
from patroni.scripts.barman_recover import BarmanRecover, ExitCode, RetriesExceeded, main, set_up_logging
API_URL = "http://localhost:7480"
BARMAN_SERVER = "my_server"
BACKUP_ID = "backup_id"
SSH_COMMAND = "ssh postgres@localhost"
DATA_DIRECTORY = "/path/to/pgdata"
LOOP_WAIT = 10
RETRY_WAIT = 2
MAX_RETRIES = 5
class TestBarmanRecover(unittest.TestCase):
@patch.object(BarmanRecover, "_ensure_api_ok", Mock())
@patch("patroni.scripts.barman_recover.PoolManager", MagicMock())
def setUp(self):
self.br = BarmanRecover(API_URL, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT, RETRY_WAIT,
MAX_RETRIES)
# Reset the mock as the same instance is used across tests
self.br.http.request.reset_mock()
self.br.http.request.side_effect = None
def test__build_full_url(self):
self.assertEqual(self.br._build_full_url("/some/path"), f"{API_URL}/some/path")
@patch("json.loads")
def test__deserialize_response(self, mock_json_loads):
mock_response = MagicMock()
self.assertIsNotNone(self.br._deserialize_response(mock_response))
mock_json_loads.assert_called_once_with(mock_response.data.decode("utf-8"))
@patch("json.dumps")
def test__serialize_request(self, mock_json_dumps):
body = "some_body"
ret = self.br._serialize_request(body)
self.assertIsNotNone(ret)
mock_json_dumps.assert_called_once_with(body)
mock_json_dumps.return_value.encode.assert_called_once_with("utf-8")
@patch.object(BarmanRecover, "_deserialize_response", Mock(return_value="test"))
@patch("logging.critical")
def test__get_request(self, mock_logging):
mock_request = self.br.http.request
# with no error
self.assertEqual(self.br._get_request("/some/path"), "test")
mock_request.assert_called_once_with("GET", f"{API_URL}/some/path")
# with MaxRetryError
http_error = MaxRetryError(self.br.http, f"{API_URL}/some/path")
mock_request.side_effect = http_error
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br._get_request("/some/path"))
mock_logging.assert_called_once_with("An error occurred while performing an HTTP GET request: %r", http_error)
self.assertEqual(exc.exception.code, ExitCode.HTTP_REQUEST_ERROR)
# with Exception
mock_logging.reset_mock()
mock_request.side_effect = Exception("Some error.")
with patch("sys.exit") as mock_sys:
with self.assertRaises(Exception):
self.assertIsNone(self.br._get_request("/some/path"))
mock_logging.assert_not_called()
mock_sys.assert_not_called()
@patch.object(BarmanRecover, "_deserialize_response", Mock(return_value="test"))
@patch("logging.critical")
@patch.object(BarmanRecover, "_serialize_request")
def test__post_request(self, mock_serialize, mock_logging):
mock_request = self.br.http.request
# with no error
self.assertEqual(self.br._post_request("/some/path", "some body"), "test")
mock_serialize.assert_called_once_with("some body")
mock_request.assert_called_once_with("POST", f"{API_URL}/some/path", body=mock_serialize.return_value,
headers={"Content-Type": "application/json"})
# with HTTPError
http_error = MaxRetryError(self.br.http, f"{API_URL}/some/path")
mock_request.side_effect = http_error
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br._post_request("/some/path", "some body"))
mock_logging.assert_called_once_with("An error occurred while performing an HTTP POST request: %r", http_error)
self.assertEqual(exc.exception.code, ExitCode.HTTP_REQUEST_ERROR)
# with Exception
mock_logging.reset_mock()
mock_request.side_effect = Exception("Some error.")
with patch("sys.exit") as mock_sys:
with self.assertRaises(Exception):
self.br._post_request("/some/path", "some body")
mock_logging.assert_not_called()
mock_sys.assert_not_called()
@patch("logging.critical")
@patch.object(BarmanRecover, "_get_request")
def test__ensure_api_ok(self, mock_get_request, mock_logging):
# API ok
mock_get_request.return_value = "OK"
with patch("sys.exit") as mock_sys:
self.assertIsNone(self.br._ensure_api_ok())
mock_logging.assert_not_called()
mock_sys.assert_not_called()
# API not ok
mock_get_request.return_value = "random"
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br._ensure_api_ok())
mock_logging.assert_called_once_with("pg-backup-api is not working: %s", "random")
self.assertEqual(exc.exception.code, ExitCode.API_NOT_OK)
@patch("logging.warning")
@patch("time.sleep")
@patch.object(BarmanRecover, "_post_request")
def test__create_recovery_operation(self, mock_post_request, mock_sleep, mock_logging):
# well formed response
mock_post_request.return_value = {"operation_id": "some_id"}
self.assertEqual(self.br._create_recovery_operation(), "some_id")
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
mock_post_request.assert_called_once_with(
f"servers/{BARMAN_SERVER}/operations",
{
"type": "recovery",
"backup_id": BACKUP_ID,
"remote_ssh_command": SSH_COMMAND,
"destination_directory": DATA_DIRECTORY,
}
)
# malformed response
mock_post_request.return_value = {"operation_idd": "some_id"}
with self.assertRaises(RetriesExceeded) as exc:
self.br._create_recovery_operation()
self.assertEqual(str(exc.exception),
"Maximum number of retries exceeded for method BarmanRecover._create_recovery_operation.")
self.assertEqual(mock_sleep.call_count, self.br.max_retries)
mock_sleep.assert_has_calls([mock.call(self.br.retry_wait)] * self.br.max_retries)
self.assertEqual(mock_logging.call_count, self.br.max_retries)
for i in range(mock_logging.call_count):
call_args = mock_logging.call_args_list[i][0]
self.assertEqual(len(call_args), 5)
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
self.assertEqual(call_args[1], i + 1)
self.assertEqual(call_args[2], self.br.max_retries)
self.assertEqual(call_args[3], "BarmanRecover._create_recovery_operation")
self.assertIsInstance(call_args[4], KeyError)
self.assertEqual(call_args[4].args, ('operation_id',))
@patch("logging.warning")
@patch("time.sleep")
@patch.object(BarmanRecover, "_get_request")
def test__get_recovery_operation_status(self, mock_get_request, mock_sleep, mock_logging):
# well formed response
mock_get_request.return_value = {"status": "some status"}
self.assertEqual(self.br._get_recovery_operation_status("some_id"), "some status")
mock_get_request.assert_called_once_with(f"servers/{BARMAN_SERVER}/operations/some_id")
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
# malformed response
mock_get_request.return_value = {"statuss": "some status"}
with self.assertRaises(RetriesExceeded) as exc:
self.br._get_recovery_operation_status("some_id")
self.assertEqual(str(exc.exception),
"Maximum number of retries exceeded for method BarmanRecover._get_recovery_operation_status.")
self.assertEqual(mock_sleep.call_count, self.br.max_retries)
mock_sleep.assert_has_calls([mock.call(self.br.retry_wait)] * self.br.max_retries)
self.assertEqual(mock_logging.call_count, self.br.max_retries)
for i in range(mock_logging.call_count):
call_args = mock_logging.call_args_list[i][0]
self.assertEqual(len(call_args), 5)
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
self.assertEqual(call_args[1], i + 1)
self.assertEqual(call_args[2], self.br.max_retries)
self.assertEqual(call_args[3], "BarmanRecover._get_recovery_operation_status")
self.assertIsInstance(call_args[4], KeyError)
self.assertEqual(call_args[4].args, ('status',))
@patch.object(BarmanRecover, "_get_recovery_operation_status")
@patch("time.sleep")
@patch("logging.info")
@patch("logging.critical")
@patch.object(BarmanRecover, "_create_recovery_operation")
def test_restore_backup(self, mock_create_op, mock_log_critical, mock_log_info, mock_sleep, mock_get_status):
# successful fast restore
mock_create_op.return_value = "some_id"
mock_get_status.return_value = "DONE"
self.assertTrue(self.br.restore_backup())
mock_create_op.assert_called_once()
mock_get_status.assert_called_once_with("some_id")
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
mock_log_critical.assert_not_called()
mock_sleep.assert_not_called()
# successful slow restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_get_status.side_effect = ["IN_PROGRESS"] * 20 + ["DONE"]
self.assertTrue(self.br.restore_backup())
mock_create_op.assert_called_once()
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call("some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 21)
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20)
mock_log_critical.assert_not_called()
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
# failed fast restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = None
mock_get_status.return_value = "FAILED"
self.assertFalse(self.br.restore_backup())
mock_create_op.assert_called_once()
mock_get_status.assert_called_once_with("some_id")
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
mock_log_critical.assert_not_called()
mock_sleep.assert_not_called()
# failed slow restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = ["IN_PROGRESS"] * 20 + ["FAILED"]
self.assertFalse(self.br.restore_backup())
mock_create_op.assert_called_once()
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call("some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 21)
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20)
mock_log_critical.assert_not_called()
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
# create retries exceeded
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_create_op.side_effect = RetriesExceeded
mock_get_status.side_effect = None
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br.restore_backup())
self.assertEqual(exc.exception.code, ExitCode.HTTP_RESPONSE_MALFORMED)
mock_log_info.assert_not_called()
mock_log_critical.assert_called_once_with("Maximum number of retries exceeded, exiting.")
mock_sleep.assert_not_called()
# get status retries exceeded
mock_create_op.reset_mock()
mock_create_op.side_effect = None
mock_log_critical.reset_mock()
mock_log_info.reset_mock()
mock_get_status.side_effect = RetriesExceeded
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br.restore_backup())
self.assertEqual(exc.exception.code, ExitCode.HTTP_RESPONSE_MALFORMED)
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
mock_log_critical.assert_called_once_with("Maximum number of retries exceeded, exiting.")
mock_sleep.assert_not_called()
class TestMain(unittest.TestCase):
@patch("logging.basicConfig")
def test_set_up_logging(self, mock_log_config):
log_file = "/path/to/some/file.log"
set_up_logging(log_file)
mock_log_config.assert_called_once_with(filename=log_file, level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")
@patch("logging.critical")
@patch("logging.info")
@patch("patroni.scripts.barman_recover.set_up_logging")
@patch("patroni.scripts.barman_recover.BarmanRecover")
@patch("patroni.scripts.barman_recover.ArgumentParser")
def test_main(self, mock_arg_parse, mock_br, mock_set_up_log, mock_log_info, mock_log_critical):
# successful restore
args = MagicMock()
mock_arg_parse.return_value.parse_known_args.return_value = (args, None)
mock_br.return_value.restore_backup.return_value = True
with self.assertRaises(SystemExit) as exc:
main()
mock_arg_parse.assert_called_once()
mock_set_up_log.assert_called_once_with(args.log_file)
mock_br.assert_called_once_with(args.api_url, args.barman_server, args.backup_id, args.ssh_command,
args.data_directory, args.loop_wait, args.retry_wait, args.max_retries,
args.cert_file, args.key_file)
mock_log_info.assert_called_once_with("Recovery operation finished successfully.")
mock_log_critical.assert_not_called()
self.assertEqual(exc.exception.code, ExitCode.RECOVERY_DONE)
# failed restore
mock_arg_parse.reset_mock()
mock_set_up_log.reset_mock()
mock_br.reset_mock()
mock_log_info.reset_mock()
mock_br.return_value.restore_backup.return_value = False
with self.assertRaises(SystemExit) as exc:
main()
mock_arg_parse.assert_called_once()
mock_set_up_log.assert_called_once_with(args.log_file)
mock_br.assert_called_once_with(args.api_url, args.barman_server, args.backup_id, args.ssh_command,
args.data_directory, args.loop_wait, args.retry_wait, args.max_retries,
args.cert_file, args.key_file)
mock_log_info.assert_not_called()
mock_log_critical.assert_called_once_with("Recovery operation failed.")
self.assertEqual(exc.exception.code, ExitCode.RECOVERY_FAILED)
+15 -3
View File
@@ -4,10 +4,11 @@ import sys
from mock import Mock, PropertyMock, patch
from patroni.async_executor import CriticalTask
from patroni.collections import CaseInsensitiveDict
from patroni.postgresql import Postgresql
from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.config import ConfigHandler
from patroni.postgresql.config import ConfigHandler, get_param_diff
from . import psycopg_connect, BaseTestPostgresql, mock_available_gucs
@@ -142,6 +143,16 @@ class TestBootstrap(BaseTestPostgresql):
(), error_handler
),
["--key=value with spaces"])
# not allowed options in list of dicts/strs are filtered out
self.assertEqual(
self.b.process_user_options(
'pg_basebackup',
[{'checkpoint': 'fast'}, {'dbname': 'dbname=postgres'}, 'gzip', {'label': 'standby'}, 'verbose'],
('dbname', 'verbose'),
print
),
['--checkpoint=fast', '--gzip', '--label=standby'],
)
@patch.object(CancellableSubprocess, 'call', Mock())
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
@@ -235,8 +246,9 @@ class TestBootstrap(BaseTestPostgresql):
self.assertTrue(task.result)
self.b.bootstrap(config)
with patch.object(Postgresql, 'pending_restart', PropertyMock(return_value=True)), \
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
with patch.object(Postgresql, 'pending_restart_reason',
PropertyMock(CaseInsensitiveDict({'max_connections': get_param_diff('200', '100')}))), \
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
self.b.post_bootstrap({}, task)
mock_restart.assert_called_once()
+31 -21
View File
@@ -1,25 +1,26 @@
import time
from mock import Mock, patch
from patroni.postgresql.citus import CitusHandler
from mock import Mock, patch, PropertyMock
from patroni.postgresql.mpp.citus import CitusHandler
from patroni.psycopg import ProgrammingError
from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException
from .test_ha import get_cluster_initialized_with_leader
@patch('patroni.postgresql.citus.Thread', Mock())
@patch('patroni.postgresql.mpp.citus.Thread', Mock())
@patch('patroni.psycopg.connect', psycopg_connect)
class TestCitus(BaseTestPostgresql):
def setUp(self):
super(TestCitus, self).setUp()
self.c = self.p.citus_handler
self.c = self.p.mpp_handler
self.cluster = get_cluster_initialized_with_leader()
self.cluster.workers[1] = self.cluster
@patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280, 310, 340, 370]))
@patch('patroni.postgresql.citus.logger.exception', Mock(side_effect=SleepException))
@patch('patroni.postgresql.citus.logger.warning')
@patch('patroni.postgresql.citus.PgDistNode.wait', Mock())
@patch('patroni.postgresql.mpp.citus.logger.exception', Mock(side_effect=SleepException))
@patch('patroni.postgresql.mpp.citus.logger.warning')
@patch('patroni.postgresql.mpp.citus.PgDistNode.wait', Mock())
@patch.object(CitusHandler, 'is_alive', Mock(return_value=True))
def test_run(self, mock_logger_warning):
# `before_demote` or `before_promote` REST API calls starting a
@@ -39,10 +40,10 @@ class TestCitus(BaseTestPostgresql):
@patch.object(CitusHandler, 'is_alive', Mock(return_value=False))
@patch.object(CitusHandler, 'start', Mock())
def test_sync_pg_dist_node(self):
def test_sync_meta_data(self):
with patch.object(CitusHandler, 'is_enabled', Mock(return_value=False)):
self.c.sync_pg_dist_node(self.cluster)
self.c.sync_pg_dist_node(self.cluster)
self.c.sync_meta_data(self.cluster)
self.c.sync_meta_data(self.cluster)
def test_handle_event(self):
self.c.handle_event(self.cluster, {})
@@ -51,22 +52,22 @@ class TestCitus(BaseTestPostgresql):
'leader': 'leader', 'timeout': 30, 'cooldown': 10})
def test_add_task(self):
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
patch('patroni.postgresql.citus.urlparse', Mock(side_effect=Exception)):
with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \
patch('patroni.postgresql.mpp.citus.urlparse', Mock(side_effect=Exception)):
self.c.add_task('', 1, None)
mock_logger.assert_called_once()
with patch('patroni.postgresql.citus.logger.debug') as mock_logger:
with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger:
self.c.add_task('before_demote', 1, 'postgres://host:5432/postgres', 30)
mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Adding the new task:'))
with patch('patroni.postgresql.citus.logger.debug') as mock_logger:
with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger:
self.c.add_task('before_promote', 1, 'postgres://host:5432/postgres', 30)
mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:'))
# add_task called from sync_pg_dist_node should not override already scheduled or in flight task until deadline
# add_task called from sync_meta_data should not override already scheduled or in flight task until deadline
self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres', 30))
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres'))
self.c._in_flight = self.c._tasks.pop()
@@ -106,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.mpp.citus.logger.error') as mock_logger, \
patch.object(CitusHandler, 'query', Mock(side_effect=Exception)):
self.c.process_tasks()
mock_logger.assert_called_once()
@@ -115,7 +116,7 @@ class TestCitus(BaseTestPostgresql):
def test_on_demote(self):
self.c.on_demote()
@patch('patroni.postgresql.citus.logger.error')
@patch('patroni.postgresql.mpp.citus.logger.error')
@patch.object(MockCursor, 'execute', Mock(side_effect=Exception))
def test_load_pg_dist_node(self, mock_logger):
# load_pg_dist_node() triggers, query fails and exception is property handled
@@ -140,10 +141,6 @@ class TestCitus(BaseTestPostgresql):
self.assertEqual(parameters['wal_level'], 'logical')
self.assertEqual(parameters['citus.local_hostname'], '/tmp')
def test_bootstrap(self):
self.c._config = None
self.c.bootstrap()
def test_ignore_replication_slot(self):
self.assertFalse(self.c.ignore_replication_slot({'name': 'foo', 'type': 'physical',
'database': 'bar', 'plugin': 'wal2json'}))
@@ -161,3 +158,16 @@ class TestCitus(BaseTestPostgresql):
'type': 'logical', 'database': 'citus', 'plugin': 'pgoutput'}))
self.assertTrue(self.c.ignore_replication_slot({'name': 'citus_shard_split_slot_1_2_3',
'type': 'logical', 'database': 'citus', 'plugin': 'citus'}))
@patch('patroni.postgresql.mpp.citus.logger.debug')
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
def test_bootstrap_duplicate_database(self, mock_logger):
with patch.object(MockCursor, 'execute', Mock(side_effect=ProgrammingError)):
self.assertRaises(ProgrammingError, self.c.bootstrap)
with patch.object(MockCursor, 'execute', Mock(side_effect=[ProgrammingError, None, None, None])), \
patch.object(ProgrammingError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='42P04')
self.c.bootstrap()
mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when creating database'))
+30 -39
View File
@@ -35,6 +35,7 @@ class TestConfig(unittest.TestCase):
'PATRONI_NAMESPACE': '/patroni/',
'PATRONI_SCOPE': 'batman2',
'PATRONI_LOGLEVEL': 'ERROR',
'PATRONI_LOG_FORMAT': '["message", {"levelname": "level"}]',
'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG',
'PATRONI_LOG_FILE_NUM': '5',
'PATRONI_CITUS_DATABASE': 'citus',
@@ -159,48 +160,38 @@ class TestConfig(unittest.TestCase):
@patch('patroni.config.logger')
def test__validate_failover_tags(self, mock_logger, mock_get):
"""Ensures that only one of `nofailover` or `failover_priority` can be provided"""
mock_logger.warning.reset_mock()
config = Config("postgres0.yml")
# Providing one of `nofailover` or `failover_priority` is fine
just_nofailover = {"nofailover": True}
mock_get.side_effect = [just_nofailover] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_not_called()
just_failover_priority = {"failover_priority": 1}
mock_get.side_effect = [just_failover_priority] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_not_called()
for single_param in ({"nofailover": True}, {"failover_priority": 1}, {"failover_priority": 0}):
mock_get.side_effect = [single_param] * 2
self.assertIsNone(self.config._validate_failover_tags())
mock_logger.warning.assert_not_called()
# Providing both `nofailover` and `failover_priority` is fine if consistent
consistent_false = {"nofailover": False, "failover_priority": 1}
mock_get.side_effect = [consistent_false] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_not_called()
consistent_true = {"nofailover": True, "failover_priority": 0}
mock_get.side_effect = [consistent_true] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_not_called()
for consistent_state in (
{"nofailover": False, "failover_priority": 1},
{"nofailover": True, "failover_priority": 0},
{"nofailover": "False", "failover_priority": 0}
):
mock_get.side_effect = [consistent_state] * 2
self.assertIsNone(self.config._validate_failover_tags())
mock_logger.warning.assert_not_called()
# Providing both inconsistently should log a warning
inconsistent_false = {"nofailover": False, "failover_priority": 0}
mock_get.side_effect = [inconsistent_false] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_called_once_with(
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
+ ' Defaulting to nofailover: %s',
False,
0,
False
)
mock_logger.warning.reset_mock()
inconsistent_true = {"nofailover": True, "failover_priority": 1}
mock_get.side_effect = [inconsistent_true] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_called_once_with(
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
+ ' Defaulting to nofailover: %s',
True,
1,
True
)
for inconsistent_state in (
{"nofailover": False, "failover_priority": 0},
{"nofailover": True, "failover_priority": 1},
{"nofailover": "False", "failover_priority": 1},
{"nofailover": "", "failover_priority": 0}
):
mock_get.side_effect = [inconsistent_state] * 2
self.assertIsNone(self.config._validate_failover_tags())
mock_logger.warning.assert_called_once_with(
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
+ ' Defaulting to nofailover: %s',
inconsistent_state['nofailover'],
inconsistent_state['failover_priority'],
inconsistent_state['nofailover'])
mock_logger.warning.reset_mock()
def test__process_postgresql_parameters(self):
expected_params = {
+3 -1
View File
@@ -62,9 +62,10 @@ class TestGenerateConfig(unittest.TestCase):
'scope': self.environ['PATRONI_SCOPE'],
'name': HOSTNAME,
'log': {
'type': PatroniLogger.DEFAULT_TYPE,
'format': PatroniLogger.DEFAULT_FORMAT,
'level': PatroniLogger.DEFAULT_LEVEL,
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
'format': PatroniLogger.DEFAULT_FORMAT,
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
},
'restapi': {
@@ -141,6 +142,7 @@ class TestGenerateConfig(unittest.TestCase):
'noloadbalance': False,
'clonefrom': True,
'nosync': False,
'nostream': False
}
}
patch_config(self.config, conf)
+16 -12
View File
@@ -3,8 +3,10 @@ import unittest
from consul import ConsulException, NotFound
from mock import Mock, PropertyMock, patch
from patroni.dcs import get_dcs
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession, RetryFailedError
from patroni.postgresql.mpp import get_mpp
from . import SleepException
@@ -91,13 +93,17 @@ class TestConsul(unittest.TestCase):
@patch.object(consul.Consul.KV, 'get', kv_get)
@patch.object(consul.Consul.KV, 'delete', Mock())
def setUp(self):
Consul({'ttl': 30, 'scope': 't', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
'verify': 'on', 'key': 'foo', 'cert': 'bar', 'cacert': 'buz', 'token': 'asd', 'dc': 'dc1',
'register_service': True})
Consul({'ttl': 30, 'scope': 't_', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
'verify': 'on', 'cert': 'bar', 'cacert': 'buz', 'register_service': True})
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10,
'register_service': True, 'service_check_tls_server_name': True})
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 't', 'name': 'p', 'retry_timeout': 10,
'consul': {'url': 'https://l:1', 'verify': 'on',
'key': 'foo', 'cert': 'bar', 'cacert': 'buz',
'token': 'asd', 'dc': 'dc1', 'register_service': True}}), Consul)
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 't_', 'name': 'p', 'retry_timeout': 10,
'consul': {'url': 'https://l:1', 'verify': 'on',
'cert': 'bar', 'cacert': 'buz', 'register_service': True}}), Consul)
self.c = get_dcs({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'retry_timeout': 10,
'consul': {'host': 'localhost:1', 'register_service': True,
'service_check_tls_server_name': True}})
self.assertIsInstance(self.c, Consul)
self.c._base_path = 'service/good'
self.c.get_cluster()
@@ -130,7 +136,7 @@ class TestConsul(unittest.TestCase):
self.assertIsInstance(self.c.get_cluster(), Cluster)
def test__get_citus_cluster(self):
self.c._citus_group = '0'
self.c._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
cluster = self.c.get_cluster()
self.assertIsInstance(cluster, Cluster)
self.assertIsInstance(cluster.workers[1], Cluster)
@@ -154,10 +160,8 @@ class TestConsul(unittest.TestCase):
self.c.set_ttl(20)
self.c._do_refresh_session = Mock()
self.assertFalse(self.c.take_leader())
with patch('time.time', Mock(side_effect=[0, 100])):
self.assertRaises(ConsulError, self.c.take_leader)
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 0, 100])):
self.assertRaises(ConsulError, self.c.take_leader)
with patch('time.time', Mock(side_effect=[0, 0, 0, 100, 100, 100])):
self.assertFalse(self.c.take_leader())
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_set_failover_value(self):
+29 -6
View File
@@ -12,6 +12,8 @@ from patroni.ctl import ctl, load_config, output_members, get_dcs, parse_dcs, \
get_all_members, get_any_member, get_cursor, query_member, PatroniCtlException, apply_config_changes, \
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH, PatronictlPrettyTable
from patroni.dcs import Cluster, Failover
from patroni.postgresql.config import get_param_diff
from patroni.postgresql.mpp import get_mpp
from patroni.psycopg import OperationalError
from patroni.utils import tzutc
from prettytable import PrettyTable, ALL
@@ -69,7 +71,7 @@ class TestCtl(unittest.TestCase):
@patch('patroni.psycopg.connect', psycopg_connect)
def test_get_cursor(self):
with click.Context(click.Command('query')) as ctx:
ctx.obj = {'__config': {}}
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
for role in self.TEST_ROLES:
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), None, {}, role=role))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role=role))
@@ -107,7 +109,7 @@ class TestCtl(unittest.TestCase):
def test_output_members(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {}}
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
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']
@@ -157,7 +159,8 @@ class TestCtl(unittest.TestCase):
# Target and source are equal
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nleader\n\ny')
self.assertEqual(result.exit_code, 1)
self.assertIn('Switchover target and source are the same', result.output)
self.assertIn("Candidate ['other']", result.output)
self.assertIn('Member leader is already the leader of cluster dummy', result.output)
# Candidate is not a member of the cluster
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nReality\n\ny')
@@ -220,6 +223,11 @@ class TestCtl(unittest.TestCase):
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='0\n')
self.assertIn('Failover could be performed only to a specific candidate', result.output)
# Candidate is the same as the leader
result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0'], input='leader\n')
self.assertIn("Candidate ['other']", result.output)
self.assertIn('Member leader is already the leader of cluster dummy', result.output)
# Temp test to check a fallback to switchover if leader is specified
with patch('patroni.ctl._do_failover_or_switchover') as failover_func_mock:
result = self.runner.invoke(ctl, ['failover', '--leader', 'leader', 'dummy'], input='0\n')
@@ -244,7 +252,7 @@ class TestCtl(unittest.TestCase):
@patch('patroni.dynamic_loader.iter_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd']))
def test_get_dcs(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {'dummy': {}}}
ctx.obj = {'__config': {'dummy': {}}, '__mpp': get_mpp({})}
self.assertRaises(PatroniCtlException, get_dcs, 'dummy', 0)
@patch('patroni.psycopg.connect', psycopg_connect)
@@ -433,7 +441,7 @@ class TestCtl(unittest.TestCase):
def test_get_any_member(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {}}
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
for role in self.TEST_ROLES:
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), None, role=role))
@@ -442,7 +450,7 @@ class TestCtl(unittest.TestCase):
def test_get_all_members(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {}}
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
for role in self.TEST_ROLES:
self.assertEqual(list(get_all_members(get_cluster_initialized_without_leader(), None, role=role)), [])
@@ -475,6 +483,21 @@ class TestCtl(unittest.TestCase):
with patch('patroni.ctl.load_config', Mock(return_value={})):
self.runner.invoke(ctl, ['list'])
cluster = get_cluster_initialized_with_leader()
cluster.members[1].data['pending_restart'] = True
cluster.members[1].data['pending_restart_reason'] = {'param': get_param_diff('', 'very l' + 'o' * 34 + 'ng')}
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
for cmd in ('list', 'topology'):
result = self.runner.invoke(ctl, [cmd, 'dummy'])
self.assertIn('param: [hidden - too long]', result.output)
result = self.runner.invoke(ctl, ['list', 'dummy', '-f', 'tsv'])
self.assertIn('param: ->very l' + 'o' * 34 + 'ng', result.output)
cluster.members[1].data['pending_restart_reason'] = {'param': get_param_diff('', 'new')}
result = self.runner.invoke(ctl, ['list', 'dummy'])
self.assertIn('param: ->new', result.output)
def test_list_extended(self):
result = self.runner.invoke(ctl, ['list', 'dummy', '--extended', '--timestamp'])
assert '2100' in result.output
+7 -4
View File
@@ -5,8 +5,10 @@ import unittest
from dns.exception import DNSException
from mock import Mock, PropertyMock, patch
from patroni.dcs import get_dcs
from patroni.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver
from patroni.exceptions import DCSError
from patroni.postgresql.mpp import get_mpp
from patroni.utils import Retry
from urllib3.exceptions import ReadTimeoutError
@@ -138,8 +140,9 @@ class TestClient(unittest.TestCase):
@patch.object(EtcdClient, '_get_machines_list',
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
def setUp(self):
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 3,
'srv': 'test', 'scope': 'test', 'name': 'foo'})
self.etcd = get_dcs({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 3,
'etcd': {'srv': 'test'}, 'scope': 'test', 'name': 'foo'})
self.assertIsInstance(self.etcd, Etcd)
self.client = self.etcd._client
self.client.http.request = http_request
self.client.http.request_encode_body = http_request
@@ -235,7 +238,7 @@ class TestEtcd(unittest.TestCase):
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
def setUp(self):
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'}, get_mpp({}))
def test_base_path(self):
self.assertEqual(self.etcd._base_path, '/patroni/test')
@@ -270,7 +273,7 @@ class TestEtcd(unittest.TestCase):
self.assertRaises(EtcdError, self.etcd.get_cluster)
def test__get_citus_cluster(self):
self.etcd._citus_group = '0'
self.etcd._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
cluster = self.etcd.get_cluster()
self.assertIsInstance(cluster, Cluster)
self.assertIsInstance(cluster.workers[1], Cluster)
+13 -9
View File
@@ -4,10 +4,12 @@ import unittest
import urllib3
from mock import Mock, PropertyMock, patch
from patroni.dcs import get_dcs
from patroni.dcs.etcd import DnsCachingResolver
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \
Etcd3Error, Etcd3ClientError, ReAuthenticateMode, RetryFailedError, InvalidAuthToken, Unavailable, \
Etcd3Error, Etcd3ClientError, RetryFailedError, InvalidAuthToken, Unavailable, \
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, AuthOldRevision, base64_encode
from patroni.postgresql.mpp import get_mpp
from threading import Thread
from . import SleepException, MockResponse
@@ -80,9 +82,9 @@ class BaseTestEtcd3(unittest.TestCase):
@patch.object(Thread, 'start', Mock())
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
def setUp(self):
self.etcd3 = Etcd3({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
'host': 'localhost:2378', 'scope': 'test', 'name': 'foo',
'username': 'etcduser', 'password': 'etcdpassword'})
self.etcd3 = get_dcs({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10, 'name': 'foo', 'scope': 'test',
'etcd3': {'host': 'localhost:2378', 'username': 'etcduser', 'password': 'etcdpassword'}})
self.assertIsInstance(self.etcd3, Etcd3)
self.client = self.etcd3._client
self.kv_cache = self.client._kv_cache
@@ -164,12 +166,14 @@ class TestPatroniEtcd3Client(BaseTestEtcd3):
retry = self.etcd3._retry.copy()
with patch('time.time', Mock(side_effect=[0, 10, 20, 30, 40])):
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
with patch('time.time', Mock(side_effect=[0, 10])):
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
self.client.username = None
self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
self.client._reauthenticate = False
retry = self.etcd3._retry.copy()
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
mock_urlopen.return_value.content = '{"code":3,"error":"etcdserver: revision of auth store is old"}'
self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
self.client._reauthenticate = False
self.assertRaises(AuthOldRevision, retry, self.client.deleteprefix, 'foo', retry=retry)
def test__handle_server_response(self):
@@ -236,7 +240,7 @@ class TestEtcd3(BaseTestEtcd3):
self.assertRaises(Etcd3Error, self.etcd3.get_cluster)
def test__get_citus_cluster(self):
self.etcd3._citus_group = '0'
self.etcd3._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
cluster = self.etcd3.get_cluster()
self.assertIsInstance(cluster, Cluster)
self.assertIsInstance(cluster.workers[1], Cluster)
@@ -269,8 +273,8 @@ class TestEtcd3(BaseTestEtcd3):
def test_attempt_to_acquire_leader(self):
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 100, 200])):
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 100, 200, 300])):
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
with patch('time.time', Mock(side_effect=[0, 100, 200, 300, 400])):
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
with patch.object(PatroniEtcd3Client, 'put', Mock(return_value=False)):
+4 -2
View File
@@ -2,6 +2,7 @@ import unittest
import urllib3
from mock import Mock, patch
from patroni.dcs import get_dcs
from patroni.dcs.exhibitor import ExhibitorEnsembleProvider, Exhibitor
from patroni.dcs.zookeeper import ZooKeeperError
@@ -26,8 +27,9 @@ class TestExhibitor(unittest.TestCase):
status=200, body=b'{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}')))
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
def setUp(self):
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
self.e = get_dcs({'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181},
'scope': 'test', 'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
self.assertIsInstance(self.e, Exhibitor)
@patch.object(ExhibitorEnsembleProvider, 'poll', Mock(return_value=True))
@patch.object(MockKazooClient, 'get_children', Mock(side_effect=Exception))
+21 -20
View File
@@ -151,6 +151,7 @@ zookeeper:
self.api.connection_string = 'http://127.0.0.1:8008'
self.clonefrom = None
self.nosync = False
self.nostream = False
self.scheduled_restart = {'schedule': future_restart_time,
'postmaster_start_time': str(postmaster_start_time)}
self.watchdog = Watchdog(self.config)
@@ -197,7 +198,7 @@ def run_async(self, func, args=()):
@patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=False))
@patch('patroni.async_executor.AsyncExecutor.run_async', run_async)
@patch('patroni.postgresql.rewind.Thread', Mock())
@patch('patroni.postgresql.citus.CitusHandler.start', Mock())
@patch('patroni.postgresql.mpp.citus.CitusHandler.start', Mock())
@patch('subprocess.call', Mock(return_value=0))
@patch('time.sleep', Mock())
class TestHa(PostgresInit):
@@ -255,8 +256,6 @@ class TestHa(PostgresInit):
self.p.data_directory_empty = true
self.ha.cluster = get_cluster_not_initialized_without_leader(
cluster_config=ClusterConfig(1, {"standby_cluster": {"port": 5432}}, 1))
global_config.update(self.ha.cluster)
self.ha.cluster = get_cluster_not_initialized_without_leader(cluster_config=ClusterConfig(0, {}, 0))
self.assertEqual(self.ha.run_cycle(), 'trying to bootstrap a new standby leader')
def test_bootstrap_waiting_for_standby_leader(self):
@@ -322,7 +321,6 @@ class TestHa(PostgresInit):
self.ha.state_handler.cancellable._process = Mock()
self.ha._crash_recovery_started -= 600
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 10})
global_config.update(self.ha.cluster)
self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout')
@patch.object(Rewind, 'ensure_clean_shutdown', Mock())
@@ -474,6 +472,11 @@ class TestHa(PostgresInit):
self.p.is_primary = false
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
def test_get_node_to_follow_nostream(self):
self.ha.patroni.nostream = True
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEqual(self.ha._get_node_to_follow(self.ha.cluster), None)
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
def test_follow(self):
self.p.is_primary = false
@@ -593,8 +596,8 @@ class TestHa(PostgresInit):
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('patroni.postgresql.citus.connect', psycopg_connect)
@patch('patroni.postgresql.citus.quote_ident', Mock())
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
@patch.object(Postgresql, 'connection', Mock(return_value=None))
def test_bootstrap_initialized_new_cluster(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
@@ -615,8 +618,8 @@ class TestHa(PostgresInit):
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('patroni.postgresql.citus.connect', psycopg_connect)
@patch('patroni.postgresql.citus.quote_ident', Mock())
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
@patch.object(Postgresql, 'connection', Mock(return_value=None))
def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
@@ -659,7 +662,7 @@ class TestHa(PostgresInit):
@patch.object(ConfigHandler, 'replace_pg_hba', Mock())
@patch.object(ConfigHandler, 'replace_pg_ident', Mock())
@patch.object(PostmasterProcess, 'start', Mock(return_value=MockPostmaster()))
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
def test_worker_restart(self):
self.ha.has_lock = true
self.ha.patroni.request = Mock()
@@ -694,7 +697,7 @@ class TestHa(PostgresInit):
self.ha.is_paused = true
self.assertEqual(self.ha.run_cycle(), 'PAUSE: restart in progress')
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
def test_manual_failover_from_leader(self):
self.ha.has_lock = true # I am the leader
@@ -733,7 +736,7 @@ class TestHa(PostgresInit):
('Member %s exceeds maximum replication lag', 'b'))
self.ha.cluster.members.pop()
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
def test_manual_switchover_from_leader(self):
self.ha.has_lock = true # I am the leader
@@ -770,11 +773,10 @@ class TestHa(PostgresInit):
with patch('patroni.ha.logger.info') as mock_info:
self.ha.fetch_node_status = get_node_status(wal_position=1)
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5})
global_config.update(self.ha.cluster)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.assertEqual(mock_info.call_args_list[0][0], ('Member %s exceeds maximum replication lag', 'leader'))
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
def test_scheduled_switchover_from_leader(self):
self.ha.has_lock = true # I am the leader
@@ -1276,7 +1278,6 @@ class TestHa(PostgresInit):
self.p.is_running = false
self.ha.cluster = get_cluster_initialized_with_leader(sync=(self.p.name, 'other'))
self.ha.cluster.config.data.update({'synchronous_mode': True, 'primary_start_timeout': 0})
global_config.update(self.ha.cluster)
self.ha.has_lock = true
self.ha.update_lock = true
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
@@ -1385,7 +1386,6 @@ class TestHa(PostgresInit):
mock_set_sync.reset_mock()
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet()))
self.ha.cluster.config.data['synchronous_mode_strict'] = True
global_config.update(self.ha.cluster)
self.ha.run_cycle()
mock_set_sync.assert_called_once_with(CaseInsensitiveSet('*'))
@@ -1544,7 +1544,7 @@ class TestHa(PostgresInit):
self.ha.is_failover_possible = true
self.ha.shutdown()
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
def test_shutdown_citus_worker(self):
self.ha.is_leader = true
self.p.is_running = Mock(side_effect=[Mock(), False])
@@ -1656,15 +1656,16 @@ class TestHa(PostgresInit):
self.assertRaises(DCSError, self.ha.acquire_lock)
self.assertFalse(self.ha.acquire_lock())
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
def test_notify_citus_coordinator(self):
self.ha.patroni.request = Mock()
self.ha.notify_citus_coordinator('before_demote')
self.ha.notify_mpp_coordinator('before_demote')
self.ha.patroni.request.assert_called_once()
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 30)
self.ha.patroni.request = Mock(side_effect=Exception)
with patch('patroni.ha.logger.warning') as mock_logger:
self.ha.notify_citus_coordinator('before_promote')
self.ha.notify_mpp_coordinator('before_promote')
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 2)
mock_logger.assert_called()
self.assertTrue(mock_logger.call_args[0][0].startswith('Request to Citus coordinator'))
self.assertTrue(mock_logger.call_args[0][0].startswith('Request to %s coordinator leader'))
self.assertEqual(mock_logger.call_args[0][1], 'Citus')
+40 -18
View File
@@ -8,14 +8,17 @@ import unittest
import urllib3
from mock import Mock, PropertyMock, mock_open, patch
from patroni.dcs import get_dcs
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 patroni.postgresql.mpp import get_mpp
from threading import Thread
from . import MockResponse, SleepException
def mock_list_namespaced_config_map(*args, **kwargs):
k8s_group_label = get_mpp({'citus': {'group': 0, 'database': 'postgres'}}).k8s_group_label
metadata = {'resource_version': '1', 'labels': {'f': 'b'}, 'name': 'test-config',
'annotations': {'initialize': '123', 'config': '{}'}}
items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))]
@@ -26,16 +29,16 @@ def mock_list_namespaced_config_map(*args, **kwargs):
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
metadata.update({'name': 'test-sync', 'annotations': {'leader': 'p-0'}})
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
metadata.update({'name': 'test-0-leader', 'labels': {Kubernetes._CITUS_LABEL: '0'},
metadata.update({'name': 'test-0-leader', 'labels': {k8s_group_label: '0'},
'annotations': {'optime': '1234x', 'leader': 'p-0', 'ttl': '30s', 'slots': '{', 'failsafe': '{'}})
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
metadata.update({'name': 'test-0-config', 'labels': {Kubernetes._CITUS_LABEL: '0'},
metadata.update({'name': 'test-0-config', 'labels': {k8s_group_label: '0'},
'annotations': {'initialize': '123', 'config': '{}'}})
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
metadata.update({'name': 'test-1-leader', 'labels': {Kubernetes._CITUS_LABEL: '1'},
metadata.update({'name': 'test-1-leader', 'labels': {k8s_group_label: '1'},
'annotations': {'leader': 'p-3', 'ttl': '30s'}})
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
metadata.update({'name': 'test-2-config', 'labels': {Kubernetes._CITUS_LABEL: '2'}, 'annotations': {}})
metadata.update({'name': 'test-2-config', 'labels': {k8s_group_label: '2'}, 'annotations': {}})
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
metadata = k8s_client.V1ObjectMeta(resource_version='1')
@@ -60,7 +63,8 @@ def mock_list_namespaced_endpoints(*args, **kwargs):
def mock_list_namespaced_pod(*args, **kwargs):
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', Kubernetes._CITUS_LABEL: '1'},
k8s_group_label = get_mpp({'citus': {'group': 0, 'database': 'postgres'}}).k8s_group_label
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', k8s_group_label: '1'},
name='p-0', annotations={'status': '{}'},
uid='964dfeae-e79b-4476-8a5a-1920b5c2a69d')
status = k8s_client.V1PodStatus(pod_ip='10.0.0.1')
@@ -76,7 +80,7 @@ def mock_namespaced_kind(*args, **kwargs):
def mock_load_k8s_config(self, *args, **kwargs):
self._server = ''
self._server = 'http://localhost'
class TestK8sConfig(unittest.TestCase):
@@ -225,11 +229,12 @@ class BaseTestKubernetes(unittest.TestCase):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_pod', mock_list_namespaced_pod, create=True)
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
def setUp(self, config=None):
config = config or {}
config.update(ttl=30, scope='test', name='p-0', loop_wait=10, group=0,
retry_timeout=10, labels={'f': 'b'}, bypass_api_service=True)
self.k = Kubernetes(config)
self.k._citus_group = None
config = {'ttl': 30, 'scope': 'test', 'name': 'p-0', 'loop_wait': 10, 'retry_timeout': 10,
'kubernetes': {'labels': {'f': 'b'}, 'bypass_api_service': True, **(config or {})},
'citus': {'group': 0, 'database': 'postgres'}}
self.k = get_dcs(config)
self.assertIsInstance(self.k, Kubernetes)
self.k._mpp = get_mpp({})
self.assertRaises(AttributeError, self.k._pods._build_cache)
self.k._pods._is_ready = True
self.assertRaises(TypeError, self.k._kinds._build_cache)
@@ -237,6 +242,7 @@ class BaseTestKubernetes(unittest.TestCase):
self.k.get_cluster()
@patch('urllib3.PoolManager.request', Mock())
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', mock_namespaced_kind, create=True)
class TestKubernetesConfigMaps(BaseTestKubernetes):
@@ -254,18 +260,31 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
self.assertRaises(KubernetesError, self.k.get_cluster)
def test__get_citus_cluster(self):
self.k._citus_group = '0'
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
cluster = self.k.get_cluster()
self.assertIsInstance(cluster, Cluster)
self.assertIsInstance(cluster.workers[1], Cluster)
@patch('patroni.dcs.kubernetes.logger.error')
def test_get_citus_coordinator(self, mock_logger):
self.assertIsInstance(self.k.get_citus_coordinator(), Cluster)
with patch.object(Kubernetes, '_cluster_loader', Mock(side_effect=Exception)):
self.assertIsNone(self.k.get_citus_coordinator())
def test_get_mpp_coordinator(self, mock_logger):
self.assertIsInstance(self.k.get_mpp_coordinator(), Cluster)
with patch.object(Kubernetes, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
self.assertIsNone(self.k.get_mpp_coordinator())
mock_logger.assert_called()
self.assertTrue(mock_logger.call_args[0][0].startswith('Failed to load Citus coordinator'))
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from Kubernetes: %r')
self.assertEqual(mock_logger.call_args[0][1], 'Null')
self.assertIsInstance(mock_logger.call_args[0][2], KubernetesError)
@patch('patroni.dcs.kubernetes.logger.error')
def test_get_citus_coordinator(self, mock_logger):
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
self.assertIsInstance(self.k.get_mpp_coordinator(), Cluster)
with patch.object(Kubernetes, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
self.assertIsNone(self.k.get_mpp_coordinator())
mock_logger.assert_called()
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from Kubernetes: %r')
self.assertEqual(mock_logger.call_args[0][1], 'Citus')
self.assertIsInstance(mock_logger.call_args[0][2], KubernetesError)
def test_attempt_to_acquire_leader(self):
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', create=True) as mock_patch:
@@ -356,6 +375,7 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
mock_warning.assert_called_once()
@patch('urllib3.PoolManager.request', Mock())
class TestKubernetesEndpointsNoPodIP(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True)
def setUp(self, config=None):
@@ -370,6 +390,7 @@ class TestKubernetesEndpointsNoPodIP(BaseTestKubernetes):
self.assertEqual(args[2].subsets[0].addresses[0].ip, '10.0.0.1')
@patch('urllib3.PoolManager.request', Mock())
class TestKubernetesEndpoints(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True)
@@ -460,13 +481,14 @@ def mock_watch(*args):
return urllib3.HTTPResponse()
@patch('urllib3.PoolManager.request', Mock())
class TestCacheBuilder(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
@patch('patroni.dcs.kubernetes.ObjectCache._watch', mock_watch)
@patch.object(urllib3.HTTPResponse, 'read_chunked')
def test__build_cache(self, mock_read_chunked):
self.k._citus_group = '0'
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
mock_read_chunked.return_value = [json.dumps(
{'type': 'MODIFIED', 'object': {'metadata': {
'name': self.k.config_path, 'resourceVersion': '2', 'annotations': {self.k._CONFIG: 'foo'}}}}
+209
View File
@@ -3,12 +3,23 @@ import os
import sys
import unittest
import yaml
from io import StringIO
from mock import Mock, patch
from patroni.config import Config
from patroni.log import PatroniLogger
from queue import Queue, Full
try:
from pythonjsonlogger import jsonlogger
jsonlogger.JsonFormatter(None, None, rename_fields={}, static_fields={})
json_formatter_is_available = True
import json # we need json.loads() function
except Exception:
json_formatter_is_available = False
_LOG = logging.getLogger(__name__)
@@ -72,3 +83,201 @@ class TestPatroniLogger(unittest.TestCase):
_LOG.info('blabla')
logger.shutdown()
self.assertEqual(logger.records_lost, 0)
def test_json_list_format(self):
config = {
'type': 'json',
'format': [
{'asctime': '@timestamp'},
{'levelname': 'level'},
'message'
],
'static_fields': {
'app': 'patroni'
}
}
test_message = 'test json logging in case of list format'
with patch('sys.stderr', StringIO()) as stderr_output:
logger = PatroniLogger()
logger.reload_config(config)
_LOG.info(test_message)
if json_formatter_is_available:
target_log = json.loads(stderr_output.getvalue().split('\n')[-2])
self.assertIn('@timestamp', target_log)
self.assertEqual(target_log['message'], test_message)
self.assertEqual(target_log['level'], 'INFO')
self.assertEqual(target_log['app'], 'patroni')
self.assertEqual(len(target_log), len(config['format']) + len(config['static_fields']))
def test_json_str_format(self):
config = {
'type': 'json',
'format': '%(asctime)s %(levelname)s %(message)s',
'static_fields': {
'app': 'patroni'
}
}
test_message = 'test json logging in case of string format'
with patch('sys.stderr', StringIO()) as stderr_output:
logger = PatroniLogger()
logger.reload_config(config)
_LOG.info(test_message)
if json_formatter_is_available:
target_log = json.loads(stderr_output.getvalue().split('\n')[-2])
self.assertIn('asctime', target_log)
self.assertEqual(target_log['message'], test_message)
self.assertEqual(target_log['levelname'], 'INFO')
self.assertEqual(target_log['app'], 'patroni')
def test_plain_format(self):
config = {
'type': 'plain',
'format': '[%(asctime)s] %(levelname)s %(message)s',
}
test_message = 'test plain logging'
with patch('sys.stderr', StringIO()) as stderr_output:
logger = PatroniLogger()
logger.reload_config(config)
_LOG.info(test_message)
target_log = stderr_output.getvalue()
self.assertRegex(target_log, fr'^\[.*\] INFO {test_message}$')
def test_dateformat(self):
config = {
'format': '[%(asctime)s] %(message)s',
'dateformat': '%Y-%m-%dT%H:%M:%S'
}
test_message = 'test date format'
with patch('sys.stderr', StringIO()) as stderr_output:
logger = PatroniLogger()
logger.reload_config(config)
_LOG.info(test_message)
target_log = stderr_output.getvalue()
self.assertRegex(target_log, r'\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\]')
def test_invalid_dateformat(self):
config = {
'format': '[%(asctime)s] %(message)s',
'dateformat': 5
}
with self.assertLogs() as captured_log:
logger = PatroniLogger()
logger.reload_config(config)
captured_log_level = captured_log.records[0].levelname
captured_log_message = captured_log.records[0].message
self.assertEqual(captured_log_level, 'WARNING')
self.assertRegex(
captured_log_message,
fr'Expected log dateformat to be a string, but got "{type(config["dateformat"])}"'
)
def test_invalid_plain_format(self):
config = {
'type': 'plain',
'format': ['message']
}
with self.assertLogs() as captured_log:
logger = PatroniLogger()
logger.reload_config(config)
captured_log_level = captured_log.records[0].levelname
captured_log_message = captured_log.records[0].message
self.assertEqual(captured_log_level, 'WARNING')
self.assertRegex(
captured_log_message,
r'Expected log format to be a string when log type is plain, but got ".*"'
)
def test_invalid_json_format(self):
config = {
'type': 'json',
'format': {
'asctime': 'timestamp',
'message': 'message'
}
}
with self.assertLogs() as captured_log:
logger = PatroniLogger()
logger.reload_config(config)
captured_log_level = captured_log.records[0].levelname
captured_log_message = captured_log.records[0].message
self.assertEqual(captured_log_level, 'WARNING')
self.assertRegex(captured_log_message, r'Expected log format to be a string or a list, but got ".*"')
with self.assertLogs() as captured_log:
config['format'] = [['levelname']]
logger.reload_config(config)
captured_log_level = captured_log.records[0].levelname
captured_log_message = captured_log.records[0].message
self.assertEqual(captured_log_level, 'WARNING')
self.assertRegex(
captured_log_message,
r'Expected each item of log format to be a string or dictionary, but got ".*"'
)
with self.assertLogs() as captured_log:
config['format'] = ['message', {'asctime': ['timestamp']}]
logger.reload_config(config)
captured_log_level = captured_log.records[0].levelname
captured_log_message = captured_log.records[0].message
self.assertEqual(captured_log_level, 'WARNING')
self.assertRegex(captured_log_message, r'Expected renamed log field to be a string, but got ".*"')
def test_fail_to_use_python_json_logger(self):
with self.assertLogs() as captured_log:
logger = PatroniLogger()
with patch('builtins.__import__', Mock(side_effect=ImportError)):
logger.reload_config({'type': 'json'})
captured_log_level = captured_log.records[0].levelname
captured_log_message = captured_log.records[0].message
self.assertEqual(captured_log_level, 'ERROR')
self.assertRegex(
captured_log_message,
r'Failed to import "python-json-logger" library: .*. Falling back to the plain logger'
)
with self.assertLogs() as captured_log:
logger = PatroniLogger()
pythonjsonlogger = Mock()
pythonjsonlogger.jsonlogger.JsonFormatter = Mock(side_effect=Exception)
with patch('builtins.__import__', Mock(return_value=pythonjsonlogger)):
logger.reload_config({'type': 'json'})
captured_log_level = captured_log.records[0].levelname
captured_log_message = captured_log.records[0].message
self.assertEqual(captured_log_level, 'ERROR')
self.assertRegex(
captured_log_message,
r'Failed to initialize JsonFormatter: .*. Falling back to the plain logger'
)
+52
View File
@@ -0,0 +1,52 @@
from typing import Any
from patroni.exceptions import PatroniException
from patroni.postgresql.mpp import AbstractMPP, get_mpp, Null
from . import BaseTestPostgresql
from .test_ha import get_cluster_initialized_with_leader
class TestMPP(BaseTestPostgresql):
def setUp(self):
super(TestMPP, self).setUp()
self.cluster = get_cluster_initialized_with_leader()
def test_get_handler_impl_exception(self):
class DummyMPP(AbstractMPP):
def __init__(self) -> None:
super().__init__({})
@staticmethod
def validate_config(config: Any) -> bool:
return True
@property
def group(self) -> None:
return None
@property
def coordinator_group_id(self) -> None:
return None
@property
def type(self) -> str:
return "dummy"
mpp = DummyMPP()
self.assertRaises(PatroniException, mpp.get_handler_impl, self.p)
def test_null_handler(self):
config = {}
mpp = get_mpp(config)
self.assertIsInstance(mpp, Null)
self.assertIsNone(mpp.group)
self.assertTrue(mpp.validate_config(config))
nullHandler = mpp.get_handler_impl(self.p)
self.assertIsNone(nullHandler.handle_event(self.cluster, {}))
self.assertIsNone(nullHandler.sync_meta_data(self.cluster))
self.assertIsNone(nullHandler.on_demote())
self.assertIsNone(nullHandler.schedule_cache_rebuild())
self.assertIsNone(nullHandler.bootstrap())
self.assertIsNone(nullHandler.adjust_postgres_gucs({}))
self.assertFalse(nullHandler.ignore_replication_slot({}))
+26
View File
@@ -175,6 +175,20 @@ class TestPatroni(unittest.TestCase):
self.p.next_run = time.time() - self.p.dcs.loop_wait - 1
self.p.schedule_next_run()
def test__filter_tags(self):
tags = {'noloadbalance': False, 'clonefrom': False, 'nosync': False, 'smth': 'random'}
self.assertEqual(self.p._filter_tags(tags), {'smth': 'random'})
tags['clonefrom'] = True
tags['smth'] = False
self.assertEqual(self.p._filter_tags(tags), {'clonefrom': True, 'smth': False})
tags = {'nofailover': False, 'failover_priority': 0}
self.assertEqual(self.p._filter_tags(tags), tags)
tags = {'nofailover': True, 'failover_priority': 1}
self.assertEqual(self.p._filter_tags(tags), tags)
def test_noloadbalance(self):
self.p.tags['noloadbalance'] = True
self.assertTrue(self.p.noloadbalance)
@@ -186,9 +200,11 @@ class TestPatroni(unittest.TestCase):
# Setting `nofailover: True` has precedence
(True, 0, True),
(True, 1, True),
('False', 1, True), # because we use bool() for the value
# Similarly, setting `nofailover: False` has precedence
(False, 0, False),
(False, 1, False),
('', 0, False),
# Only when we have `nofailover: None` should we got based on priority
(None, 0, True),
(None, 1, False),
@@ -233,6 +249,16 @@ class TestPatroni(unittest.TestCase):
self.p.tags['nosync'] = None
self.assertFalse(self.p.nosync)
def test_nostream(self):
self.p.tags['nostream'] = 'True'
self.assertTrue(self.p.nostream)
self.p.tags['nostream'] = 'None'
self.assertFalse(self.p.nostream)
self.p.tags['nostream'] = 'foo'
self.assertFalse(self.p.nostream)
self.p.tags['nostream'] = ''
self.assertFalse(self.p.nostream)
@patch.object(Thread, 'join', Mock())
def test_shutdown(self):
self.p.api.shutdown = Mock(side_effect=Exception)
+126 -45
View File
@@ -7,17 +7,19 @@ import time
from copy import deepcopy
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
from pathlib import Path
import patroni.psycopg as psycopg
from patroni import global_config
from patroni.async_executor import CriticalTask
from patroni.collections import CaseInsensitiveSet
from patroni.collections import CaseInsensitiveDict, CaseInsensitiveSet
from patroni.dcs import RemoteMember
from patroni.exceptions import PostgresConnectionException, PatroniException
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.callback_executor import CallbackAction
from patroni.postgresql.config import get_param_diff, _false_validator
from patroni.postgresql.postmaster import PostmasterProcess
from patroni.postgresql.validator import (ValidatorFactoryNoType, ValidatorFactoryInvalidType,
ValidatorFactoryInvalidSpec, ValidatorFactory, InvalidGucValidatorsFile,
@@ -362,7 +364,7 @@ class TestPostgresql(BaseTestPostgresql):
@patch.object(Postgresql, 'start', Mock())
def test_follow(self):
self.p.call_nowait(CallbackAction.ON_START)
m = RemoteMember('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': 'foo,bar'}})
self.p.follow(m)
with patch.object(Postgresql, 'ensure_major_version_is_known', Mock(return_value=False)):
self.assertIsNone(self.p.follow(m))
@@ -570,7 +572,15 @@ class TestPostgresql(BaseTestPostgresql):
self.p.reload_config(config)
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
mock_warning.assert_not_called()
self.assertEqual(self.p.pending_restart, False)
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
mock_info.reset_mock()
# Ignored params changed
config['parameters']['archive_cleanup_command'] = 'blabla'
self.p.reload_config(config)
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
mock_info.reset_mock()
@@ -578,7 +588,7 @@ class TestPostgresql(BaseTestPostgresql):
self.p.config._config['parameters']['wal_buffers'] = '512'
self.p.reload_config(config)
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
self.assertEqual(self.p.pending_restart, False)
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
mock_info.reset_mock()
config = deepcopy(self.p.config._config)
@@ -588,51 +598,60 @@ class TestPostgresql(BaseTestPostgresql):
config['pg_ident'] = ['']
self.p.reload_config(config)
mock_info.assert_called_once_with('Reloading PostgreSQL configuration.')
self.assertEqual(self.p.pending_restart, False)
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
mock_info.reset_mock()
# Postmaster parameter change (pending_restart)
init_max_worker_processes = config['parameters']['max_worker_processes']
config['parameters']['max_worker_processes'] *= 2
with patch('patroni.postgresql.Postgresql._query', Mock(side_effect=[GET_PG_SETTINGS_RESULT, [(1,)]])):
new_max_worker_processes = config['parameters']['max_worker_processes']
# stale reason to be removed
self.p._pending_restart_reason = CaseInsensitiveDict({'max_connections': get_param_diff('200', '100')})
with patch.object(Postgresql, 'get_guc_value', Mock(return_value=str(new_max_worker_processes))), \
patch('patroni.postgresql.Postgresql._query', Mock(side_effect=[
GET_PG_SETTINGS_RESULT, [('max_worker_processes', str(init_max_worker_processes), None, 'integer')]])):
self.p.reload_config(config)
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s (restart might be required)',
'max_worker_processes', str(init_max_worker_processes),
config['parameters']['max_worker_processes']))
self.assertEqual(mock_info.call_args_list[0][0],
("Changed %s from '%s' to '%s' (restart might be required)", 'max_worker_processes',
str(init_max_worker_processes), config['parameters']['max_worker_processes']))
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
self.assertEqual(self.p.pending_restart, True)
self.assertEqual(self.p.pending_restart_reason,
CaseInsensitiveDict({'max_worker_processes': get_param_diff(init_max_worker_processes,
new_max_worker_processes)}))
mock_info.reset_mock()
# Reset to the initial value without restart
config['parameters']['max_worker_processes'] = init_max_worker_processes
self.p.reload_config(config)
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s', 'max_worker_processes',
self.assertEqual(mock_info.call_args_list[0][0], ("Changed %s from '%s' to '%s'", 'max_worker_processes',
init_max_worker_processes * 2,
str(config['parameters']['max_worker_processes'])))
config['parameters']['max_worker_processes']))
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
self.assertEqual(self.p.pending_restart, False)
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
mock_info.reset_mock()
# User-defined parameter changed (removed)
config['parameters'].pop('f.oo')
self.p.reload_config(config)
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s', 'f.oo', 'bar', None))
self.assertEqual(mock_info.call_args_list[0][0], ("Changed %s from '%s' to '%s'", 'f.oo', 'bar', None))
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
self.assertEqual(self.p.pending_restart, False)
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
mock_info.reset_mock()
# Non-postmaster parameter change
config['parameters']['autovacuum'] = 'off'
config['parameters']['vacuum_cost_delay'] = 2.5
self.p.reload_config(config)
self.assertEqual(mock_info.call_args_list[0][0], ("Changed %s from %s to %s", 'autovacuum', 'on', 'off'))
self.assertEqual(mock_info.call_args_list[0][0],
("Changed %s from '%s' to '%s'", 'vacuum_cost_delay', '200ms', 2.5))
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
self.assertEqual(self.p.pending_restart, False)
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
config['parameters']['autovacuum'] = 'on'
config['parameters']['vacuum_cost_delay'] = 200
mock_info.reset_mock()
# Remove invalid parameter
@@ -645,13 +664,35 @@ class TestPostgresql(BaseTestPostgresql):
mock_warning.reset_mock()
mock_info.reset_mock()
# Non-empty result (outside changes) and exception while querying pending_restart parameters
with patch('patroni.postgresql.Postgresql._query',
Mock(side_effect=[GET_PG_SETTINGS_RESULT, [(1,)], GET_PG_SETTINGS_RESULT, Exception])):
# Non-empty result (outside changes)
with patch.object(Postgresql, 'get_guc_value', Mock(side_effect=['73', None, ''])), \
patch('patroni.postgresql.Postgresql._query',
Mock(side_effect=[GET_PG_SETTINGS_RESULT, [('shared_buffers', '128MB', '8kB', 'integer')]] * 3)):
# pg_settings shared_buffers (current value) == 128MB (16384)
# Patroni config shared_buffers == 42MB (should not end up in the restart reason diff)
# get_guc_value (will be used after restart) == 73 (584kB)
config['parameters']['shared_buffers'] = '42MB'
self.p.reload_config(config, True)
self.assertEqual(mock_info.call_args_list[0][0], ('Reloading PostgreSQL configuration.',))
self.assertEqual(self.p.pending_restart, True)
self.assertEqual(mock_info.call_args_list[0][0],
("Changed %s from '%s' to '%s' (restart might be required)",
'shared_buffers', '128MB', '42MB'))
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
self.assertEqual(mock_info.call_args_list[2][0], ("PostgreSQL configuration parameters requiring restart"
" (%s) seem to be changed bypassing Patroni config."
" Setting 'Pending restart' flag", 'shared_buffers'))
self.assertEqual(self.p.pending_restart_reason,
CaseInsensitiveDict({'shared_buffers': get_param_diff('128MB', '584kB')}))
self.p.reload_config(config, True)
self.assertEqual(self.p.pending_restart_reason,
CaseInsensitiveDict({'shared_buffers': get_param_diff('128MB', '?')}))
self.p.reload_config(config, True)
self.assertEqual(self.p.pending_restart_reason,
CaseInsensitiveDict({'shared_buffers': get_param_diff('128MB', '')}))
# Exception while querying pending_restart parameters
with patch('patroni.postgresql.Postgresql._query', Mock(side_effect=[GET_PG_SETTINGS_RESULT, Exception])):
# Invalid values, just to increase silly coverage in postgresql.validator.
# One day we will have proper tests there.
config['parameters']['autovacuum'] = 'of' # Bool.transform()
@@ -800,22 +841,36 @@ class TestPostgresql(BaseTestPostgresql):
@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',
'max_locks_per_xact setting': '100',
'max_wal_senders setting': 10}))
@patch('patroni.postgresql.config.logger.warning')
@patch('patroni.postgresql.config.logger')
def test_effective_configuration(self, mock_logger):
self.p.cancellable.cancel()
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
self.assertFalse(self.p.start())
mock_logger.assert_called_once()
self.assertTrue('is missing from pg_controldata output' in mock_logger.call_args[0][0])
controldata = {'max_connections setting': '100', 'max_worker_processes setting': '8',
'max_locks_per_xact setting': '64', 'max_wal_senders setting': 5}
self.assertTrue(self.p.pending_restart)
with patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
with patch.object(Postgresql, 'controldata', Mock(return_value=controldata)), \
patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
self.p.cancellable.cancel()
self.assertFalse(self.p.start())
self.assertTrue(self.p.pending_restart)
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
mock_logger.warning.assert_called_once()
self.assertEqual(mock_logger.warning.call_args[0],
('%s is missing from pg_controldata output', 'max_prepared_xacts setting'))
mock_logger.reset_mock()
controldata['max_prepared_xacts setting'] = 0
controldata['max_wal_senders setting'] *= 2
with patch.object(Postgresql, 'controldata', Mock(return_value=controldata)):
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
self.assertFalse(self.p.start())
mock_logger.warning.assert_not_called()
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict({
'max_wal_senders': get_param_diff('10', '5')
}))
mock_logger.info.assert_called_once()
self.assertEqual(mock_logger.info.call_args[0],
("%s value in pg_controldata: %d, in the global configuration: %d."
" pg_controldata value will be used. Setting 'Pending restart' flag",
'max_wal_senders', 10, 5))
@patch('os.path.exists', Mock(return_value=True))
@patch('os.path.isfile', Mock(return_value=False))
@@ -1010,7 +1065,7 @@ class TestPostgresql(BaseTestPostgresql):
def test__read_postgres_gucs_validators_file(self):
# raise exception
with self.assertRaises(InvalidGucValidatorsFile) as exc:
_read_postgres_gucs_validators_file('random_file.yaml')
_read_postgres_gucs_validators_file(Path('random_file.yaml'))
self.assertEqual(
str(exc.exception),
"Unexpected issue while reading parameters file `random_file.yaml`: `[Errno 2] No such file or directory: "
@@ -1019,17 +1074,32 @@ class TestPostgresql(BaseTestPostgresql):
def test__load_postgres_gucs_validators(self):
# log messages
with patch('os.walk', Mock(return_value=iter([('.', [], ['file.txt', 'random.yaml'])]))), \
patch('patroni.postgresql.validator.logger.info') as mock_info, \
file1_attrs = {'is_file.return_value': True, 'is_dir.return_value': False}
file1_mock = MagicMock(**file1_attrs)
file1_mock.name = '__init__.py'
file2_attrs = {'is_file.return_value': False, 'is_dir.return_value': True, 'iterdir.return_value': []}
file2_mock = MagicMock(**file2_attrs)
file2_mock.name = '__pycache__'
file3_attrs = {'is_file.return_value': True, 'is_dir.return_value': False}
file3_mock = MagicMock(**file3_attrs)
file3_mock.name = file3_mock.__str__.return_value = 'random.yaml'
file3_mock.open.side_effect = FileNotFoundError('[Errno 2] No such file or directory: random.yaml')
file4_attrs = {'is_file.return_value': True, 'is_dir.return_value': False}
file4_mock = MagicMock(**file4_attrs)
file4_mock.name = 'file.txt'
dir_attrs = {'name': 'available_parameters', 'is_file.return_value': False, 'is_dir.return_value': True}
dir_mock = MagicMock(**dir_attrs)
dir_mock.iterdir.return_value = [file1_mock, file2_mock, file3_mock, file4_mock]
with patch('patroni.postgresql.available_parameters.conf_dir', dir_mock), \
patch('patroni.postgresql.available_parameters.logger.info') as mock_info, \
patch('patroni.postgresql.validator.logger.warning') as mock_warning:
_load_postgres_gucs_validators()
mock_info.assert_called_once_with('Ignored a non-YAML file found under `available_parameters` directory: '
'`%s`.', os.path.join('.', 'file.txt'))
mock_info.assert_called_once_with('Ignored a non-YAML file found under `%s` '
'directory: `%s`.', 'available_parameters', file4_mock)
mock_warning.assert_called_once()
self.assertIn(
"Unexpected issue while reading parameters file `{0}`: `[Errno 2] No such file or "
"directory:".format(os.path.join('.', 'random.yaml')),
mock_warning.call_args[0][0]
"Unexpected issue while reading parameters file `random.yaml`: `[Errno 2] No such file or "
"directory:", mock_warning.call_args[0][0]
)
@@ -1058,3 +1128,14 @@ class TestPostgresql2(BaseTestPostgresql):
self.assertIn('diff(pg_catalog.pg_current_xlog_flush_location(', self.p.cluster_info_query)
self.p._major_version = 90500
self.assertIn('diff(pg_catalog.pg_current_xlog_location(', self.p.cluster_info_query)
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
@patch.object(Postgresql, '_query', Mock(return_value=[('primary_conninfo', 'host=a port=5433 passfile=/blabla')]))
def test_load_current_server_parameters(self):
keep_values = {name: self.p.config._server_parameters[name]
for name, value in self.p.config.CMDLINE_OPTIONS.items() if value[1] == _false_validator}
self.p.config.load_current_server_parameters()
self.assertTrue(all(self.p.config._server_parameters[name] == value for name, value in keep_values.items()))
self.assertEqual(dict(self.p.config._recovery_params),
{'primary_conninfo': {'host': 'a', 'port': '5433', 'passfile': '/blabla',
'gssencmode': 'prefer', 'sslmode': 'prefer', 'channel_binding': 'prefer'}})
+13 -10
View File
@@ -4,8 +4,10 @@ import tempfile
import time
from mock import Mock, PropertyMock, patch
from patroni.dcs import get_dcs
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL, \
Raft, RaftError, SyncObjUtility, TCPTransport, _TCPTransport
from patroni.postgresql.mpp import get_mpp
from pysyncobj import SyncObjConf, FAIL_REASON
@@ -128,9 +130,10 @@ class TestRaft(unittest.TestCase):
_TMP = tempfile.gettempdir()
def test_raft(self):
raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234',
'retry_timeout': 10, 'data_dir': self._TMP,
'database': 'citus', 'group': 0})
raft = get_dcs({'ttl': 30, 'scope': 'test', 'name': 'pg', 'retry_timeout': 10,
'raft': {'self_addr': '127.0.0.1:1234', 'data_dir': self._TMP},
'citus': {'group': 0, 'database': 'postgres'}})
self.assertIsInstance(raft, Raft)
raft.reload_config({'retry_timeout': 20, 'ttl': 60, 'loop_wait': 10})
self.assertTrue(raft._sync_obj.set(raft.members_path + 'legacy', '{"version":"2.0.0"}'))
self.assertTrue(raft.touch_member(''))
@@ -139,9 +142,9 @@ class TestRaft(unittest.TestCase):
self.assertTrue(raft.set_config_value('{}'))
self.assertTrue(raft.write_sync_state('foo', 'bar'))
self.assertFalse(raft.write_sync_state('foo', 'bar', 1))
raft._citus_group = '1'
raft._mpp = get_mpp({'citus': {'group': 1, 'database': 'postgres'}})
self.assertTrue(raft.manual_failover('foo', 'bar'))
raft._citus_group = '0'
raft._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
self.assertTrue(raft.take_leader())
cluster = raft.get_cluster()
self.assertIsInstance(cluster, Cluster)
@@ -153,13 +156,13 @@ class TestRaft(unittest.TestCase):
self.assertTrue(raft.update_leader(leader, '1', failsafe={'foo': 'bat'}))
self.assertTrue(raft._sync_obj.set(raft.failsafe_path, '{"foo"}'))
self.assertTrue(raft._sync_obj.set(raft.status_path, '{'))
raft.get_citus_coordinator()
raft.get_mpp_coordinator()
self.assertTrue(raft.delete_sync_state())
self.assertTrue(raft.set_history_value(''))
self.assertTrue(raft.delete_cluster())
raft._citus_group = '1'
raft._mpp = get_mpp({'citus': {'group': 1, 'database': 'postgres'}})
self.assertTrue(raft.delete_cluster())
raft._citus_group = None
raft._mpp = get_mpp({})
raft.get_cluster()
raft.watch(None, 0.001)
raft._sync_obj.destroy()
@@ -175,5 +178,5 @@ class TestRaft(unittest.TestCase):
def test_init(self, mock_event, mock_kvstore):
mock_kvstore.return_value.applied_local_log = False
mock_event.return_value.is_set.side_effect = [False, True]
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
'self_addr': '1', 'data_dir': self._TMP}))
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
'raft': {'self_addr': '1', 'data_dir': self._TMP}}), Raft)
+2 -1
View File
@@ -98,7 +98,8 @@ class TestRewind(BaseTestPostgresql):
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
@patch.object(CancellableSubprocess, 'call', mock_cancellable_call)
@patch.object(Postgresql, 'checkpoint', side_effect=['', '1'],)
@patch.object(Postgresql, 'get_guc_value', Mock(return_value=''))
@patch.object(Postgresql, 'checkpoint', side_effect=['', '1'])
@patch.object(Postgresql, 'stop', Mock(return_value=False))
@patch.object(Postgresql, 'start', Mock())
def test_execute(self, mock_checkpoint):
+62
View File
@@ -124,6 +124,68 @@ class TestSlotsHandler(BaseTestPostgresql):
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])]
self.assertEqual(self.p.slots(), {})
def test_nostream_slot_processing(self):
config = ClusterConfig(
1, {'slots': {'foo': {'type': 'logical', 'database': 'a', 'plugin': 'b'}, 'bar': {'type': 'physical'}}}, 1)
nostream_node = Member(0, 'test-2', 28, {
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
'tags': {'nostream': 'True'}
})
cascade_node = Member(0, 'test-3', 28, {
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
'tags': {'replicatefrom': 'test-2'}
})
stream_node = Member(0, 'test-4', 28, {
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
cluster = Cluster(
True, config, self.leader, Status.empty(),
[self.leadermem, nostream_node, cascade_node, stream_node], None, SyncState.empty(), None, None)
global_config.update(cluster)
# sanity for primary
self.p.name = self.leadermem.name
self.assertEqual(
cluster._get_permanent_slots(self.p, self.leadermem, 'primary'),
{'foo': {'type': 'logical', 'database': 'a', 'plugin': 'b'}, 'bar': {'type': 'physical'}})
self.assertEqual(
cluster._get_members_slots(self.p.name, 'primary'),
{'test_4': {'type': 'physical'}})
# nostream node must not have slot on primary
self.p.name = nostream_node.name
# permanent logical slots are not allowed on nostream node
self.assertEqual(
cluster._get_permanent_slots(self.p, nostream_node, 'replica'),
{'bar': {'type': 'physical'}})
self.assertEqual(
cluster.get_slot_name_on_primary(self.p.name, nostream_node),
None)
# check cascade member-slot existence on nostream node
self.assertEqual(
cluster._get_members_slots(nostream_node.name, 'replica'),
{'test_3': {'type': 'physical'}})
# cascade also does not entitled to have logical slot on itself ...
self.p.name = cascade_node.name
self.assertEqual(
cluster._get_permanent_slots(self.p, cascade_node, 'replica'),
{'bar': {'type': 'physical'}})
# ... and member-slot on primary
self.assertEqual(
cluster.get_slot_name_on_primary(self.p.name, cascade_node),
None)
# simple replica must have every permanent slot ...
self.p.name = stream_node.name
self.assertEqual(
cluster._get_permanent_slots(self.p, stream_node, 'replica'),
{'foo': {'type': 'logical', 'database': 'a', 'plugin': 'b'}, 'bar': {'type': 'physical'}})
# ... and member-slot on primary
self.assertEqual(
cluster.get_slot_name_on_primary(self.p.name, stream_node),
'test_4')
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
def test__ensure_logical_slots_replica(self):
self.p.set_role('replica')
+43 -1
View File
@@ -13,6 +13,21 @@ available_dcs = [m.split(".")[-1] for m in dcs_modules()]
config = {
"name": "string",
"scope": "string",
"log": {
"type": "plain",
"level": "DEBUG",
"traceback_level": "DEBUG",
"format": "%(asctime)s %(levelname)s: %(message)s",
"dateformat": "%Y-%m-%d %H:%M:%S",
"max_queue_size": 100,
"dir": "/tmp",
"file_num": 10,
"file_size": 1000000,
"loggers": {
"patroni.postmaster": "WARNING",
"urllib3": "DEBUG"
}
},
"restapi": {
"listen": "127.0.0.2:800",
"connect_address": "127.0.0.2:800",
@@ -88,7 +103,8 @@ config = {
"nofailover": False,
"clonefrom": False,
"noloadbalance": False,
"nosync": False
"nosync": False,
"nostream": False
}
}
@@ -357,3 +373,29 @@ class TestValidator(unittest.TestCase):
c["tags"]["failover_priority"] = -6
errors = schema(c)
self.assertIn('tags.failover_priority -6 didn\'t pass validation: Wrong value', errors)
def test_json_log_format(self, *args):
c = copy.deepcopy(config)
c["log"]["type"] = "json"
c["log"]["format"] = {"levelname": "level"}
errors = schema(c)
self.assertIn("log.format {'levelname': 'level'} didn't pass validation: Should be a string or a list", errors)
c["log"]["format"] = []
errors = schema(c)
self.assertIn("log.format [] didn't pass validation: should contain at least one item", errors)
c["log"]["format"] = [{"levelname": []}]
errors = schema(c)
self.assertIn("log.format [{'levelname': []}] didn't pass validation: "
"each item should be a string or a dictionary with string values", errors)
c["log"]["format"] = [[]]
errors = schema(c)
self.assertIn("log.format [[]] didn't pass validation: "
"each item should be a string or a dictionary with string values", errors)
c["log"]["format"] = ['foo']
errors = schema(c)
output = "\n".join(errors)
self.assertEqual(['postgresql.bin_dir', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
+31 -12
View File
@@ -7,8 +7,10 @@ from kazoo.handlers.threading import SequentialThreadingHandler
from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
from kazoo.retry import RetryFailedError
from mock import Mock, PropertyMock, patch
from patroni.dcs import get_dcs
from patroni.dcs.zookeeper import Cluster, PatroniKazooClient, \
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
from patroni.postgresql.mpp import get_mpp
class MockKazooClient(Mock):
@@ -148,9 +150,9 @@ class TestZooKeeper(unittest.TestCase):
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
def setUp(self):
self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
'set_acls': {'CN=principal2': ['ALL']}})
self.zk = get_dcs({'scope': 'test', 'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
'zookeeper': {'hosts': ['localhost:2181'], 'set_acls': {'CN=principal2': ['ALL']}}})
self.assertIsInstance(self.zk, ZooKeeper)
def test_reload_config(self):
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 10})
@@ -164,30 +166,47 @@ class TestZooKeeper(unittest.TestCase):
def test__cluster_loader(self):
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk._cluster_loader(self.zk.client_path(''))
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
self.zk._base_path = self.zk._base_path = '/broken'
self.zk._cluster_loader(self.zk.client_path(''))
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
self.zk._base_path = self.zk._base_path = '/legacy'
self.zk._cluster_loader(self.zk.client_path(''))
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
self.zk._base_path = self.zk._base_path = '/no_node'
self.zk._cluster_loader(self.zk.client_path(''))
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
def test_get_cluster(self):
cluster = self.zk.get_cluster()
self.assertEqual(cluster.last_lsn, 500)
def test__get_citus_cluster(self):
self.zk._citus_group = '0'
self.zk._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
for _ in range(0, 2):
cluster = self.zk.get_cluster()
self.assertIsInstance(cluster, Cluster)
self.assertIsInstance(cluster.workers[1], Cluster)
@patch('patroni.dcs.zookeeper.logger.error')
@patch.object(ZooKeeper, '_cluster_loader', Mock(side_effect=Exception))
@patch('patroni.dcs.logger.error')
def test_get_mpp_coordinator(self, mock_logger):
self.assertIsInstance(self.zk.get_mpp_coordinator(), Cluster)
with patch.object(ZooKeeper, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
self.assertIsNone(self.zk.get_mpp_coordinator())
mock_logger.assert_called_once()
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from %s: %r')
self.assertEqual(mock_logger.call_args[0][1], 'Null')
self.assertEqual(mock_logger.call_args[0][2], 'ZooKeeper')
self.assertIsInstance(mock_logger.call_args[0][3], ZooKeeperError)
@patch('patroni.dcs.logger.error')
def test_get_citus_coordinator(self, mock_logger):
self.assertIsNone(self.zk.get_citus_coordinator())
mock_logger.assert_called_once()
self.zk._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
self.assertIsInstance(self.zk.get_mpp_coordinator(), Cluster)
with patch.object(ZooKeeper, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
self.assertIsNone(self.zk.get_mpp_coordinator())
mock_logger.assert_called_once()
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from %s: %r')
self.assertEqual(mock_logger.call_args[0][1], 'Citus')
self.assertEqual(mock_logger.call_args[0][2], 'ZooKeeper')
self.assertIsInstance(mock_logger.call_args[0][3], ZooKeeperError)
def test_delete_leader(self):
self.assertTrue(self.zk.delete_leader(self.zk.get_cluster().leader))