mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries
This commit is contained in:
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
- uses: jakebailey/pyright-action@v1
|
||||
with:
|
||||
version: 1.1.338
|
||||
version: 1.1.347
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,56 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
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
|
||||
-------------
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,62 @@
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
+10
-7
@@ -256,10 +256,18 @@ class PatroniController(AbstractController):
|
||||
'parameters': {
|
||||
'wal_keep_segments': 100,
|
||||
'archive_mode': 'on',
|
||||
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
||||
'archive_command':
|
||||
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
||||
+ ' --mode archive '
|
||||
+ '--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self._work_directory, 'data', 'wal_archive'))
|
||||
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'})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,7 +17,6 @@ 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
|
||||
|
||||
@@ -36,6 +34,6 @@ Feature: priority replication
|
||||
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:8010/failover with {"candidate": "postgres1"}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+15
-4
@@ -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,6 +1156,14 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_POST_citus(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/citus`` path.
|
||||
|
||||
.. 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``.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+15
-3
@@ -1,4 +1,5 @@
|
||||
"""Facilities related to Patroni configuration."""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -534,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'):
|
||||
@@ -582,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`.
|
||||
|
||||
@@ -597,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:
|
||||
|
||||
@@ -99,6 +99,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 +126,7 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
'noloadbalance': False,
|
||||
'clonefrom': True,
|
||||
'nosync': False,
|
||||
'nostream': False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-5
@@ -346,7 +346,7 @@ def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
|
||||
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:
|
||||
@@ -1410,7 +1410,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::
|
||||
@@ -1473,7 +1473,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:
|
||||
@@ -1558,7 +1558,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)
|
||||
|
||||
@@ -1572,11 +1572,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'])])
|
||||
|
||||
+44
-34
@@ -782,7 +782,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__`
|
||||
@@ -799,8 +799,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):
|
||||
@@ -1039,6 +1039,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.
|
||||
|
||||
@@ -1054,7 +1056,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 {}
|
||||
|
||||
@@ -1069,6 +1071,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
|
||||
@@ -1083,8 +1089,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
|
||||
@@ -1172,7 +1179,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::
|
||||
@@ -1186,6 +1193,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)
|
||||
@@ -1263,11 +1272,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.
|
||||
@@ -1337,7 +1346,7 @@ class AbstractDCS(abc.ABC):
|
||||
_FAILSAFE = 'failsafe'
|
||||
|
||||
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
|
||||
"""Prepare DCS paths, Citus group ID, initial values for state information and processing dependencies.
|
||||
"""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...
|
||||
@@ -1472,22 +1481,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
|
||||
@@ -1502,13 +1510,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.
|
||||
@@ -1517,39 +1526,40 @@ 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 the MPP Coordinator.
|
||||
"""
|
||||
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.
|
||||
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:
|
||||
return self.__get_patroni_cluster(f'{self._base_path}/{self._mpp.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(self._mpp.coordinator_group_id, Cluster.empty())
|
||||
@@ -1563,12 +1573,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
|
||||
|
||||
+20
-5
@@ -420,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()
|
||||
@@ -431,7 +437,13 @@ 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 = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
|
||||
for node in results or []:
|
||||
@@ -565,14 +577,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
|
||||
|
||||
+14
-2
@@ -710,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:
|
||||
@@ -718,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:
|
||||
|
||||
+36
-31
@@ -198,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)
|
||||
@@ -215,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)
|
||||
@@ -294,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)
|
||||
@@ -316,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)
|
||||
@@ -347,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:
|
||||
raise exc
|
||||
self._reauthenticate = True
|
||||
if retry:
|
||||
logger.error('retry = %s', retry)
|
||||
retry.ensure_deadline(0.5, exc)
|
||||
elif reauthenticated:
|
||||
raise exc
|
||||
|
||||
@_handle_auth_errors
|
||||
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
|
||||
@@ -603,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():
|
||||
@@ -733,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:
|
||||
@@ -787,13 +778,25 @@ 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):
|
||||
@@ -850,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
|
||||
|
||||
+29
-12
@@ -746,8 +746,6 @@ class ObjectCache(Thread):
|
||||
|
||||
class Kubernetes(AbstractDCS):
|
||||
|
||||
_CITUS_LABEL = 'citus-group'
|
||||
|
||||
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']
|
||||
@@ -761,7 +759,7 @@ class Kubernetes(AbstractDCS):
|
||||
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
|
||||
super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
|
||||
if self._mpp.is_enabled():
|
||||
self._labels[self._CITUS_LABEL] = str(self._mpp.group)
|
||||
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)
|
||||
@@ -936,19 +934,31 @@ 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)
|
||||
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)
|
||||
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())
|
||||
@@ -965,9 +975,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')
|
||||
@@ -979,14 +989,21 @@ class Kubernetes(AbstractDCS):
|
||||
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(self._mpp.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:
|
||||
|
||||
+14
-2
@@ -375,14 +375,26 @@ 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():
|
||||
|
||||
@@ -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 self._mpp.group_re.match(node):
|
||||
ret[int(node)] = self._cluster_loader(path + node + '/')
|
||||
ret[int(node)] = self._postgresql_cluster_loader(path + node + '/')
|
||||
return ret
|
||||
|
||||
def _load_cluster(
|
||||
|
||||
@@ -44,19 +44,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.
|
||||
|
||||
+32
-19
@@ -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_meta_data(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
|
||||
|
||||
+166
-20
@@ -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,30 @@ 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)
|
||||
if not isinstance(handler, RotatingFileHandler):
|
||||
handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
|
||||
|
||||
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
|
||||
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 {})
|
||||
|
||||
@@ -26,7 +26,7 @@ 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
|
||||
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
|
||||
@@ -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 = mpp.get_handler_impl(self)
|
||||
self.mpp_handler = mpp.get_handler_impl(self)
|
||||
self.config = ConfigHandler(self, config)
|
||||
self.config.check_directories()
|
||||
|
||||
@@ -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:
|
||||
@@ -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,58 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
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)
|
||||
@@ -100,10 +100,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))
|
||||
@@ -463,15 +464,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)
|
||||
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
@@ -17,7 +17,8 @@ from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
|
||||
from ..exceptions import PatroniFatalException, PostgresConnectionException
|
||||
from ..file_perm import pg_perm
|
||||
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
|
||||
from ..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)
|
||||
@@ -603,8 +639,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
|
||||
|
||||
@@ -956,7 +991,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 +1100,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 +1127,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 +1163,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 +1192,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'
|
||||
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 self._RECOVERY_PARAMETERS])[0][0] > 0
|
||||
self._postgresql.set_pending_restart(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 +1256,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 +1266,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 +1286,6 @@ class ConfigHandler(object):
|
||||
|
||||
if disable_hot_standby:
|
||||
effective_configuration['hot_standby'] = 'off'
|
||||
self._postgresql.set_pending_restart(True)
|
||||
|
||||
return effective_configuration
|
||||
|
||||
|
||||
@@ -65,6 +65,25 @@ class AbstractMPP(abc.ABC):
|
||||
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.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Set,
|
||||
|
||||
from . import AbstractMPP, AbstractMPPHandler
|
||||
from ...dcs import Cluster
|
||||
from ...psycopg import connect, quote_ident, DuplicateDatabase
|
||||
from ...psycopg import connect, quote_ident, ProgrammingError
|
||||
from ...utils import parse_int
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -706,8 +706,11 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE DATABASE {0}'.format(
|
||||
quote_ident(self._config['database'], conn)).encode('utf-8'))
|
||||
except DuplicateDatabase as e:
|
||||
logger.debug('Exception when creating database: %r', e)
|
||||
except ProgrammingError as exc:
|
||||
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
|
||||
logger.debug('Exception when creating database: %r', exc)
|
||||
else:
|
||||
raise exc
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -209,9 +209,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-4
@@ -9,8 +9,7 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Connection
|
||||
from psycopg2 import connection, cursor
|
||||
|
||||
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError',
|
||||
'DuplicateDatabase']
|
||||
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError']
|
||||
|
||||
_legacy = False
|
||||
try:
|
||||
@@ -19,7 +18,6 @@ try:
|
||||
if parse_version(__version__) < MIN_PSYCOPG2:
|
||||
raise ImportError
|
||||
from psycopg2 import connect as _connect, Error, DatabaseError, OperationalError, ProgrammingError
|
||||
from psycopg2.errors import DuplicateDatabase
|
||||
from psycopg2.extensions import adapt
|
||||
|
||||
try:
|
||||
@@ -45,7 +43,6 @@ try:
|
||||
return value.getquoted().decode('utf-8')
|
||||
except ImportError:
|
||||
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
|
||||
from psycopg.errors import DuplicateDatabase
|
||||
|
||||
def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
|
||||
"""Call :func:`psycopg.connect` with *dsn* and ``**kwargs``.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Create :mod:`patroni.scripts.barman`."""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
+11
-3
@@ -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,7 +23,7 @@ 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 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.
|
||||
@@ -31,7 +34,7 @@ class Tags(abc.ABC):
|
||||
tag value.
|
||||
"""
|
||||
return {tag: value for tag, value in tags.items()
|
||||
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync'),
|
||||
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync', 'nostream'),
|
||||
value,
|
||||
tag == 'nofailover' and 'failover_priority' in tags))}
|
||||
|
||||
@@ -89,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
|
||||
|
||||
+177
-26
@@ -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)
|
||||
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:
|
||||
|
||||
+48
-2
@@ -16,6 +16,49 @@ from .collections import CaseInsensitiveSet
|
||||
from .dcs import dcs_modules
|
||||
from .exceptions import ConfigParseError
|
||||
from .utils import parse_int, split_host_port, data_directory_is_empty, 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:
|
||||
@@ -938,11 +981,13 @@ 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"): str,
|
||||
Optional("format"): validate_log_format,
|
||||
Optional("dateformat"): str,
|
||||
Optional("static_fields"): dict,
|
||||
Optional("max_queue_size"): int,
|
||||
Optional("dir"): str,
|
||||
Optional("file_num"): int,
|
||||
@@ -1127,6 +1172,7 @@ schema = Schema({
|
||||
Optional("clonefrom"): bool,
|
||||
Optional("noloadbalance"): bool,
|
||||
Optional("replicatefrom"): str,
|
||||
Optional("nosync"): bool
|
||||
Optional("nosync"): bool,
|
||||
Optional("nostream"): bool
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
:var __version__: the current Patroni version.
|
||||
"""
|
||||
__version__ = '3.2.1'
|
||||
__version__ = '3.2.2'
|
||||
|
||||
@@ -136,3 +136,4 @@ tags:
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
nosync: false
|
||||
nostream: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
+6
-8
@@ -55,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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -129,8 +129,6 @@ class MockCursor(object):
|
||||
sql = sql.decode('utf-8')
|
||||
if sql.startswith('blabla'):
|
||||
raise psycopg.ProgrammingError()
|
||||
if sql.startswith('CREATE DATABASE'):
|
||||
raise psycopg.DuplicateDatabase()
|
||||
elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'):
|
||||
raise psycopg.OperationalError()
|
||||
elif sql.startswith('RetryFailedError'):
|
||||
@@ -153,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')),
|
||||
@@ -170,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 '):
|
||||
|
||||
+10
-2
@@ -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):
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+14
-2
@@ -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,7 +246,8 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
self.assertTrue(task.result)
|
||||
|
||||
self.b.bootstrap(config)
|
||||
with patch.object(Postgresql, 'pending_restart', PropertyMock(return_value=True)), \
|
||||
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()
|
||||
|
||||
+8
-2
@@ -2,9 +2,10 @@ import time
|
||||
import unittest
|
||||
|
||||
from copy import deepcopy
|
||||
from mock import Mock, patch
|
||||
from mock import Mock, patch, PropertyMock
|
||||
from typing import List
|
||||
from patroni.postgresql.mpp.citus import CitusHandler, PgDistGroup, PgDistNode
|
||||
from patroni.psycopg import ProgrammingError
|
||||
|
||||
from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException
|
||||
from .test_ha import get_cluster_initialized_with_leader
|
||||
@@ -16,7 +17,7 @@ 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
|
||||
|
||||
@@ -176,6 +177,11 @@ class TestCitus(BaseTestPostgresql):
|
||||
@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'))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -160,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])):
|
||||
self.assertFalse(self.c.take_leader())
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||
def test_set_failover_value(self):
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -482,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
-5
@@ -7,7 +7,7 @@ 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
|
||||
@@ -166,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):
|
||||
@@ -271,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])):
|
||||
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)):
|
||||
|
||||
+10
-9
@@ -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)
|
||||
@@ -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
|
||||
@@ -770,7 +773,6 @@ 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'))
|
||||
|
||||
@@ -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('*'))
|
||||
|
||||
@@ -1659,12 +1659,13 @@ class TestHa(PostgresInit):
|
||||
@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')
|
||||
|
||||
+25
-10
@@ -18,6 +18,7 @@ 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))]
|
||||
@@ -28,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')
|
||||
@@ -62,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')
|
||||
@@ -263,12 +265,25 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
|
||||
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:
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -249,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)
|
||||
|
||||
+125
-44
@@ -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):
|
||||
controldata = {'max_connections setting': '100', 'max_worker_processes setting': '8',
|
||||
'max_locks_per_xact setting': '64', 'max_wal_senders setting': 5}
|
||||
|
||||
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.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.assert_called_once()
|
||||
self.assertTrue('is missing from pg_controldata output' in mock_logger.call_args[0][0])
|
||||
|
||||
self.assertTrue(self.p.pending_restart)
|
||||
with patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
|
||||
self.assertFalse(self.p.start())
|
||||
self.assertTrue(self.p.pending_restart)
|
||||
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'}})
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ 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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
+29
-1
@@ -14,6 +14,7 @@ config = {
|
||||
"name": "string",
|
||||
"scope": "string",
|
||||
"log": {
|
||||
"type": "plain",
|
||||
"level": "DEBUG",
|
||||
"traceback_level": "DEBUG",
|
||||
"format": "%(asctime)s %(levelname)s: %(message)s",
|
||||
@@ -102,7 +103,8 @@ config = {
|
||||
"nofailover": False,
|
||||
"clonefrom": False,
|
||||
"noloadbalance": False,
|
||||
"nosync": False
|
||||
"nosync": False,
|
||||
"nostream": False
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,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))
|
||||
|
||||
+25
-8
@@ -166,13 +166,13 @@ 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()
|
||||
@@ -185,11 +185,28 @@ class TestZooKeeper(unittest.TestCase):
|
||||
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))
|
||||
def test_get_citus_coordinator(self, mock_logger):
|
||||
self.assertIsNone(self.zk.get_citus_coordinator())
|
||||
@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.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))
|
||||
|
||||
Reference in New Issue
Block a user