Compare commits

...
404 Commits
Author SHA1 Message Date
Oleksii Kliukin f801a08974 Bumped version to 0.90 2016-04-27 12:04:52 +02:00
Alexander Kukushkin eabfd82a5d Implement Consul support 2016-04-27 10:59:01 +02:00
Alexander Kukushkin ec7779fed6 Merge pull request #188 from zalando/bugfix/python351
urllib3.exceptions.HTTPError fixes for python 3.5.1
2016-04-26 11:55:19 +02:00
Alexander Kukushkin bcbc080350 urllib3.exceptions.HTTPError fixes for python 3.5.1
Somehow when you import only urllib3 it's not possible work with
urllib3.exceptions.HTTPError exception (it looks like it is imported
from some other place. from urllib3.exceptions import HTTPError solves
the problem.
2016-04-24 14:18:34 +02:00
Feike Steenbergen 9a79ba25ef Fix tests
Remove unnecessary patching of functions.
Update mocked response to be inline with introduced config change.
2016-04-22 16:28:00 +02:00
Alexander Kukushkin 0c6d259a0a Merge pull request #187 from zalando/feature/zookeeper-rename-variable
Rename me->member (variable name was too confusing)
2016-04-22 16:10:32 +02:00
Feike Steenbergen dcdaabc747 Merge pull request #184 from zalando/feature/unify_patronictl_config_with_patroni
Unify Patroni and Patronictl configuration
2016-04-22 16:08:36 +02:00
Feike Steenbergen c7ebe4aa63 Unify Patroni and Patronictl configuration
A Patroni configuration should be enough for Patronictl
The previous dcs_api url style is still supported.

To remove duplicate magic variables a DCS_DEFAULTS was introduced.
Some behaviour has changed: If you do not specify a DCS at all
(not in configuration, not on commandline, not in environment), it will
not default to etcd://localhost:4001

More test coverage for patronictl
2016-04-22 16:04:41 +02:00
Feike SteenbergenandFeike Steenbergen b727626f19 Update README.rst 2016-04-22 16:02:52 +02:00
Feike Steenbergen 5ca43c1a3d Remove pg_hba injection and filtering
Previously we explicitly injected a replication record into pg_hba.conf.
This doesn't allow users to explicitly write their configurations.

This change will just write the lines specified by the user.
2016-04-22 16:02:52 +02:00
Alexander Kukushkin a18c878a56 Rename me->member (variable name was too confusing) 2016-04-22 15:28:41 +02:00
Alexander Kukushkin e275a34f6a Merge pull request #183 from zalando/feature/api-refactoring
Refactor api.py
2016-04-22 13:47:14 +02:00
Alexander Kukushkin 84fcc7712b Refactor api.py
All responses to the client sent by single method `_write_response`
encode('utf-8') is done only inside this method. It make easier to
support existing code because eliminates need to put b'' everywhere
2016-04-22 10:38:23 +02:00
Alexander Kukushkin 3e32c226c0 Merge pull request #185 from zalando/feature/noloadbalance
Implement noloadbalance support
2016-04-22 10:31:20 +02:00
Alexander Kukushkin 499061918d Implement noloadbalance support
Mostly this tag is necessary to give a hint to load balancer
auto-configuration tool that node should not be included into
LB configuration.
In addition to that Patroni also should not return status_code=200
for a health check if the tag is present and value is not `False`.
2016-04-22 09:46:34 +02:00
Feike Steenbergen 8d129c0209 Bugfix: Network is now a null and void option for replication 2016-04-21 15:55:35 +02:00
Feike Steenbergen ae700062b6 Merge pull request #180 from zalando/bugfix/environment_leakage
Clear environment of PostgreSQL
2016-04-21 15:23:54 +02:00
Feike Steenbergen 07edbd2c42 Merge pull request #182 from zalando/bugfix/patroni_on_dc_fixes
Make sure present but empty optional sections don't cause errors.
2016-04-21 15:23:31 +02:00
Feike Steenbergen a5990de800 Merge pull request #181 from zalando/bugfix/acceptance-tests-connection-string
Do not assume that connection user is postgres, but take it from config.yml
2016-04-21 14:54:12 +02:00
Alexander Kukushkin fd4f12aac8 Do not assume that connection user is postgres, but take it from config.yml 2016-04-21 13:56:09 +02:00
Feike Steenbergen fbf44d3219 Merge pull request #177 from zalando/feature/remove_pghba_magic
Remove pg_hba injection and filtering
2016-04-21 10:24:52 +02:00
Feike Steenbergen f98f56af41 Clear environment of PostgreSQL
Environment variables available to the postmaster can be examined
by non-superusers (e.g. plperl is trusted, yet you can show the environment).

In some environments secrets may be written in environment variables.
Commit 9744019341 introduced the ability to
specify the full Patroni configuration as an environment variable,
PATRONI_CONFIGURATION.

PATRONI_CONFIGURATION will by definition contain secrets, the passwords for superuser,
replcation user etc.

We therefore only retain a small subset of the environment for pg_ctl start, to ensure
no leakage of these values are possible.
2016-04-21 10:16:48 +02:00
Alexander Kukushkin 578bd606cc Merge pull request #179 from zalando/bugfix/recovery-conf
Append values from postgresql.recovery_conf to recovery.conf
2016-04-20 16:45:56 +02:00
Feike Steenbergen a203690f07 Update README.rst 2016-04-20 16:44:02 +02:00
Alexander Kukushkin 122895e1d6 Append values from postgresql.recovery_conf to recovery.conf
Previously these values were appended only when the leader was known and
it was preventing replica from restoring xlogs with `restore_command`.
2016-04-20 15:56:33 +02:00
Alexander Kukushkin 2ba1b5e206 Merge pull request #171 from zalando/feature/pg_rewind_unconditionally
Remove restrictions on running pg_rewind.
2016-04-20 11:22:39 +02:00
Feike Steenbergen 28d5de17e1 Remove pg_hba injection and filtering
Previously we explicitly injected a replication record into pg_hba.conf.
This doesn't allow users to explicitly write their configurations.

This change will just write the lines specified by the user.
2016-04-20 11:06:36 +02:00
Alexander Kukushkin 56f9366aa5 Merge pull request #176 from zalando/feature/get_dcs
Refactor patroni/ctl.py
2016-04-20 10:29:05 +02:00
Feike Steenbergen d55893aa6d Merge pull request #175 from zalando/feature/cluster_id
Include database system identifier in cluster info
2016-04-20 08:58:39 +02:00
Alexander Kukushkin e4005ac76c Join multiline string with \n 2016-04-19 10:30:04 +02:00
Alexander Kukushkin fee07efabb Fix python3 bugs 2016-04-18 18:09:36 +02:00
Alexander Kukushkin cd6537c4d3 Refactor patroni/ctl.py
Until now get_dcs method was doing absolutely the same job as method
from Patroni class.
In addition to that I did small refactoring and clean up of unit tests
2016-04-18 17:36:30 +02:00
Feike Steenbergen f317b9b9a6 Include database system identifier in cluster info 2016-04-18 10:44:35 +02:00
Oleksii Kliukin a07b015679 Move the recovery.conf based role check to a function. 2016-04-15 17:32:29 +02:00
Oleksii Kliukin c15c13876a HA tests assume the default role as replica. 2016-04-15 10:08:31 +02:00
Alexander Kukushkin bd27669bec Merge pull request #173 from zalando/feature/travis-build-matrix
Disable sudo, reshuffle travis tasks and introduce caching
2016-04-15 09:35:04 +02:00
Oleksii Kliukin 0de3a6ece4 Try to guess the postgres role before it is started by examinging the recovery.conf. 2016-04-15 09:34:34 +02:00
Alexander Kukushkin 6dc4aacf05 Add some comments to .travis.yml 2016-04-14 13:06:42 +02:00
Alexander Kukushkin 7006a4ee14 Sometimes replica can't attach to the master after pg_rewind
The reason for that is: it takes up to 10 seconds to create replication
slot + up to 5 seconds to start straming and recover.
2016-04-13 14:28:00 +02:00
Alexander Kukushkin d57310bbc0 Fix one more corner-case
It could take up to 10 seconds to create replication slot.
In addition to that when replica fails to connect to the master via
streaming replication it doesn't retry immediately, but with some
timeout (5 seconds). 10 + 5 == 15 what causes replication check
scenarios fail.
2016-04-13 14:09:45 +02:00
Alexander Kukushkin 946d8ef4f2 Do not try to query xlog_location when async_executor is busy 2016-04-13 13:32:39 +02:00
Alexander Kukushkin 01da5266a0 Give time for running healh-checks when promoting replica 2016-04-13 13:32:39 +02:00
Alexander Kukushkin 0ebfb30192 break on test failure 2016-04-13 13:32:39 +02:00
Alexander Kukushkin b4e86f0809 Make it possible to schedule failover in less then 10 seconds
But only when API request was posted to the leader
2016-04-13 13:32:39 +02:00
Alexander Kukushkin 94331fde6d Do not try to fetch xlog_location when async_executor is busy 2016-04-13 13:32:39 +02:00
Alexander Kukushkin ff41818a84 Make unit-tests output less verbose
It will capture output to stdout and stderr and print it when test is
failed. Please set LOGLEVEL env variable to INFO or DEBUG if you want
to see everything (as it was before).
2016-04-13 13:32:39 +02:00
Alexander Kukushkin 15d30a2d35 Try to stabilize acceptance tests 2016-04-13 13:32:39 +02:00
Alexander Kukushkin f8bf1bb0ab Disable sudo, reshuffle travis tasks and introduce caching
Without sudo travis is executing build tasks using docker and waiting
time in this case is really small, usually not longer then 10 seconds.

postgresql-9.5 is installed via addons.apt.packages (without sudo)
But ports 5432 and 5433 are busy. So I had to ajust environment.py to
assign port from higher diapason.

And a few words about build tasks:
First task is used for executing unit tests for all different python versions
The second one is used for executing acceptance tests against etcd
The third one is used for executing acceptance tests against zookeeper
acceptance tests are executed with python2.7 and python3.5

In addition that I've introduced caching of python virtual environment.
It really helps to reduce time needed to install python modules.
2016-04-13 13:32:39 +02:00
Oleksii Kliukin 309b5d4803 Remove restrictions on running pg_rewind.
Previously, pg_rewind was called only if a crashed master tried
to rejoin the cluster. It didn't cover the important case of a
master shut down cleanly, but with a combination of a smart
shutdown and subsequently a fast shutdown. Since out pg_rewind
code does not depend on the "uncleanness" of the master's shutdown,
we can call it unconditionally in all cases where the former master
tries to rejoin as a replica.

This resolves  #167.
2016-04-11 17:56:18 +02:00
Alexander Kukushkin 24a2ea6cef Refactor acceptance tests to make them work against ZooKeeper
and make it easier to implement controllers for new DCS, i.e. consul
2016-04-10 10:37:43 +02:00
Alexander Kukushkin c6cc731bf0 Merge pull request #166 from zalando/feature/clonefrom
Correct implementation of 'clonefrom' feature
2016-04-10 10:33:18 +02:00
Alexander Kukushkin 2c3f38f522 Merge pull request #170 from zalando/bugfix/patronictl-output_members
In some cases output_members function was showing cluster=None
2016-04-10 10:32:59 +02:00
Alexander Kukushkin d7d69af12f In some cases output_members function was showing cluster=None 2016-04-08 16:50:22 +02:00
Alexander Kukushkin 836cf3a1b8 Merge pull request #169 from jankeirse/master
Add KillMode to avoid issues when restarting
2016-04-05 17:04:09 +02:00
Jan Keirse 2fc01137b7 add killmode to avoid restart issues
This fixes https://github.com/zalando/patroni/issues/167
2016-04-05 16:47:21 +02:00
Jan Keirse 0eaf08541b Merge remote-tracking branch 'refs/remotes/zalando/master' 2016-04-05 16:44:21 +02:00
Oleksii Kliukin 708f45568b Merge pull request #168 from zalando/feature/patroni_config_from_environment
Read Patroni configuration from the environment.
2016-04-04 17:27:32 +02:00
Oleksii Kliukin 4ad190549a Fix a typo in the help message. 2016-04-04 12:33:05 +02:00
Oleksii Kliukin 60e6ed67e3 Add unit tests. 2016-04-04 10:06:28 +02:00
Oleksii Kliukin 9744019341 Read Patroni configuration from the environment.
The environment variable name is PATRONI_CONFIGURATION.
If both the filename and the variable supplied, the file takes priority.
2016-04-04 09:40:30 +02:00
Alexander Kukushkin 727021306d Merge pull request #164 from zalando/feature/fast-reattach-former-leader
Speed up reattach of former leader to the cluster
2016-03-31 21:57:24 +02:00
Alexander Kukushkin e76f820cb5 Merge pull request #165 from zalando/feature/abc-metaclass
Set metaclass of AbstractDCS with `six` to be python 2&3 compatible
2016-03-31 21:57:12 +02:00
Alexander Kukushkin ada50e418c Update scenario description 2016-03-31 17:13:29 +02:00
Alexander Kukushkin 7c683336fb Don't expose empty tags dict in API and DCS 2016-03-30 15:16:57 +02:00
Alexander Kukushkin 24cc93ac70 Don't expose known boolean tags with value not True 2016-03-30 15:00:25 +02:00
Alexander Kukushkin db5999a639 Correct implementation of 'clonefrom' feature
According to https://github.com/zalando/patroni/issues/48 'clonefrom'
tag should be boolean and it should be used to mark node as a suitable
for creation of a new replica from. If there are more then one such node
in the cluster (with tag clonefrom=true), one of them will be chosed
randomly.
2016-03-30 11:30:05 +02:00
Alexander Kukushkin 0469d4374d Set metaclass of AbstractDCS with six to be python 2&3 compatible 2016-03-24 17:07:56 +01:00
Alexander Kukushkin d4cb15179f Return "demote reason" more precise depending on cluster state 2016-03-24 14:52:27 +01:00
Alexander Kukushkin 9c41ce9f1c We should run checkpoint before shutdown all the time except one case
(when patroni is being shutdown)
2016-03-24 14:50:34 +01:00
Alexander Kukushkin e6af18f0bb Former leader was not able to reattach to cluster without pg_rewind
It was shutdown correctly and I expected such 'join' working, but it was
not, because new leader didn't had enough time to catch up with the
master before promote.
2016-03-24 14:45:21 +01:00
Alexander Kukushkin 454c9dcb33 Merge pull request #163 from zalando/bugfix/zookeeper
Bugfix: In some cases Ha.cluster can be None after calling `get_cluster`
2016-03-24 12:36:25 +01:00
Alexander Kukushkin 9dc588231f Speed up reattach of former leader to the cluster
Instead of starting it up in "read-only", it wil wait 2 seconds, to give
a time to somebody to prompte and after it will execute normal `recover`
procedure.
2016-03-24 12:29:30 +01:00
Alexander Kukushkin 0d3dca56ff In some cases Ha.cluster can be None after calling get_cluster
Such situation is causing patroni crash. Usually it was happening during
manual failover, after former master has demoted and `reset_cluster`
method has been called. In this case `fetch_cluster` was `False` and
`_load_cluster` method was returning value from `self._cluster`, which
was `None`.
2016-03-24 12:06:39 +01:00
Oleksii Kliukin 699b53964e Make sure present but empty optional sections don't cause errors. 2016-03-24 10:48:29 +01:00
Alexander Kukushkin 786dbcca24 Merge pull request #161 from zalando/feature/remove-unused-code
Remove unused code from unit tests
2016-03-24 08:57:34 +01:00
Alexander Kukushkin 3a7d2c3874 Remove unused code from unit tests 2016-03-21 20:48:17 +01:00
Alexander Kukushkin 6c0c1b1341 Merge pull request #160 from zalando/feature/failover-candidate
Rename ambiguous `Failover.member` to candidate
2016-03-21 18:00:08 +01:00
Alexander Kukushkin 9fec8a41e4 Return different status if failed over not to candidate 2016-03-19 13:15:05 +01:00
Alexander Kukushkin 54055c1ff8 Rename ambiguous Failover.member to candidate
But! 'member' is still accepted by REST API and also name 'member' is
used to strore/read this value to/from DCS (for backward comatibility)
2016-03-18 15:59:47 +01:00
Alexander Kukushkin ba1dc49294 Merge pull request #159 from zalando/bugfix/scheduled-failover
Ha loop was writing into scheduled_at during "manual_failover"
2016-03-18 13:38:57 +01:00
Alexander Kukushkin ca3414bda0 Ha loop was writing into scheduled_at during "manual_failover" 2016-03-18 13:24:30 +01:00
Alexander Kukushkin 92394598c7 Merge pull request #156 from zalando/feature/delete-cluster-iface
Implement `delete_cluster` interface in for all available dcs
2016-03-18 08:54:09 +01:00
Alexander Kukushkin 8a7d2b53e0 Add forgotten mock to the test_ha.py 2016-03-15 16:40:55 +01:00
Alexander Kukushkin 0e0c8ed8d7 Implement delete_cluster interface in for all available dcs
In addition to that rename confusing `Etcd.client` and
`ZooKeeper.client` into `_client`. This attribute is available from
AbstractDCS and people had wrong impression that it provides the same
interface for different DCS implementations, which is obviously not the
case. For Etcd it has type etcd.Client and for ZooKeeper - KazooClient.
2016-03-15 16:25:48 +01:00
Oleksii Kliukin 3fdf249f4b Bumped version to 0.80 2016-03-14 16:02:49 +01:00
Oleksii Kliukin 75d196a1f4 Update the TODO list. 2016-03-14 15:57:34 +01:00
Oleksii Kliukin f132e2b4ad Merge pull request #153 from zalando/feature/acceptance_tests_behave
Feature/acceptance tests behave
2016-03-14 15:24:52 +01:00
Alexander Kukushkin ffebfd84fd Merge branch 'master' of github.com:zalando/patroni into feature/acceptance_tests_behave 2016-03-14 11:54:44 +01:00
Alexander Kukushkin 780c2bc594 Merge pull request #155 from zalando/bugfix/python-etcd-0.4.3
Migrate to python-etcd 0.4.3
2016-03-14 10:56:19 +01:00
Alexander Kukushkin 7d4ec19bca Enable etcd cluster id check 2016-03-14 10:51:50 +01:00
Alexander Kukushkin 79f4d9a13b Attempt to export acceptance tests coverage results to coveralls 2016-03-13 09:42:02 +01:00
Alexander Kukushkin f3a238ccbc Attempt to export acceptance tests coverage results to coveralls 2016-03-13 09:24:01 +01:00
Alexander Kukushkin 7e0723a7fc Attempt to export acceptance tests coverage results to coveralls 2016-03-13 09:17:52 +01:00
Alexander Kukushkin 62f11ab747 Attempt to export acceptance tests coverage results to coveralls 2016-03-13 09:09:31 +01:00
Alexander Kukushkin cdee1c2911 update requirements.txt 2016-03-12 16:06:35 +01:00
Alexander Kukushkin 01afd09ca2 Migrate to python-etcd 0.4.3
Despite this release was very buggy it has really nice features:
* EtcdWatchTimedOut exception is raised when `watch` call timed out
* it supports SRV autodiscovery

Since we already implemented our own SRV discovery this feature is not
really interesting for us, but it solves the problem of having two
requirements files for different python versions, because python-etcd
will install dnspython or dnspython3 as a dependency.

In order to fix https://github.com/jplana/python-etcd/issues/152 and
https://github.com/jplana/python-etcd/pull/154 I had to override
`api_execute` method.
2016-03-12 15:49:42 +01:00
Oleksii Kliukin 6985df3aca Restore the test for the clone from the replica. 2016-03-11 16:59:35 +01:00
Oleksii Kliukin 59836f8775 Merge branch 'master' of https://github.com/zalando/patroni into feature/acceptance_tests_behave 2016-03-11 16:58:56 +01:00
Oleksii Kliukin e802bba5f9 Merge pull request #151 from zalando/feature/base_backup_from_the_replica
First implementation of cloning from the replica.
2016-03-11 16:57:06 +01:00
Oleksii Kliukin 25106e97f3 Merge pull request #146 from zalando/feature/remove-unused-code
Feature/remove unused code
2016-03-11 16:47:23 +01:00
Oleksii Kliukin 18f3284bf2 Merge pull request #143 from zalando/bugfix/zookeeper-support-for-ctl
Add support for ZooKeeper/Exhibitor DCS URI in patronictl ... -d
2016-03-11 16:45:25 +01:00
Oleksii Kliukin 8b96741e8e Merge pull request #129 from zalando/feature/xlog_lag_interval
Include timestamp of last replayed location in api call.
2016-03-11 16:42:16 +01:00
Oleksii Kliukin 6c63d32253 zappr config must be .yml. 2016-03-11 16:39:57 +01:00
Oleksii Kliukin 9ca1b754a5 Add zappr configuration. 2016-03-11 16:33:26 +01:00
Alexander Kukushkin 3319c3eeea replace unused variables with _ 2016-03-11 15:36:24 +01:00
Alexander Kukushkin ba444adb67 make codacy and quantifiedcode happier 2016-03-11 15:32:16 +01:00
Alexander Kukushkin d3c2b8b2aa replace unused variables with _ 2016-03-11 15:18:58 +01:00
Alexander Kukushkin ea59692b82 Merge branch 'master' of github.com:zalando/patroni into feature/remove-unused-code 2016-03-11 15:15:13 +01:00
Alexander Kukushkin 5f6beae22f Enforce data-type checks for step matcher
and increase default timeout for patroni start
2016-03-11 14:46:14 +01:00
Alexander Kukushkin 8b81d270bc BUGFIX: Assertion Failed: Steps must be unicode 2016-03-11 13:47:57 +01:00
Alexander Kukushkin 30d3982d25 Acceptance tests with behave 2016-03-11 12:56:29 +01:00
Oleksii Kliukin d965d21ada Unit-tests for clone from the replica.
Remove clonefrom function from dcs, since it's not used.
2016-03-11 10:48:58 +01:00
Oleksii Kliukin 805716ed68 Variables and parameters renaming.
Previously, "without_leader" suffix was used in the name of methods
and functions that initialize a replica without an active replication
connection, and leader was part of the name for parameters and messages
that require an active replication conneciton. Since we support init
from the members other than the leader, those conventions have to be
changed.
2016-03-11 10:19:00 +01:00
Oleksii Kliukin da53452a53 Merge pull request #152 from zalando/feature/acceptance_tests_travis
Run acceptance tests on travis
2016-03-11 09:32:05 +01:00
Alexander Kukushkin ad7910c025 Merge branch 'feature/acceptance_tests' of github.com:zalando/patroni into feature/acceptance_tests_travis 2016-03-11 09:04:08 +01:00
Alexander Kukushkin 60997b463d Merge branch 'master' of github.com:zalando/patroni into feature/acceptance_tests 2016-03-10 20:40:22 +01:00
Alexander Kukushkin c955e29805 Disable gce boto plugins by overriding BOTO_CONFIG
These plugins are not compatible with python 3 and breaking unit tests
2016-03-10 20:02:40 +01:00
Alexander Kukushkin 33a1de7828 Fix .travis.yml 2016-03-10 17:23:44 +01:00
Alexander Kukushkin c2d1eea7d0 disable clonefrom test 2016-03-10 17:19:43 +01:00
Alexander Kukushkin 42d798a3de acceptance tests on travis 2016-03-10 17:19:10 +01:00
Alexander Kukushkin 77e6795432 Merge branch 'master' of github.com:zalando/patroni into feature/acceptance_tests_travis 2016-03-10 16:19:06 +01:00
Oleksii Kliukin 9057ddeb7c First implementation of cloning from the replica.
At the moment we just replace the master with the
node at the 'clonefrom' tag if it's present. Master
should be available anyway, otherwise, it will not
even try to do cloning.

Acceptance tests:
https://github.com/zalando/patroni/pull/144/commits
2016-03-10 16:06:31 +01:00
Oleksii Kliukin 998f0da3d8 Add cascading replication (backup from the replica) tests. 2016-03-10 16:05:06 +01:00
Alexander Kukushkin 5decc61fc8 Merge branch 'master' of github.com:zalando/patroni into bugfix/zookeeper-support-for-ctl 2016-03-07 12:02:57 +01:00
Oleksii Kliukin d65d47b517 Merge pull request #147 from zalando/features/acceptance_test_related_fixes
Features/acceptance test related fixes
2016-03-07 11:05:54 +01:00
Oleksii Kliukin ede5661da3 Remove hard-coded values from the failover code. 2016-03-04 19:21:48 +01:00
Oleksii Kliukin aa844b63d0 Avoid an unhandled exception in the API thread.
When receiving a failover request with no data or
non-JSON data, emit a message to the client instead
of crashing.
2016-03-04 19:21:14 +01:00
Oleksii Kliukin 3f1c34f557 Add tests for the scheduled failover.
The actual amount of time to establish the master and the replication
after the scheduled failover seems sufficient (15 seconds with the
failover in 10 seconds), but occasionally leads to test failures.
This is unlikely the test issue and should be investigated inside
the patroni.
2016-03-02 19:39:12 +01:00
Oleksii Kliukin 58749b1dd6 Merge branch 'master' into feature/acceptance_tests 2016-03-02 16:17:21 +01:00
Oleksii Kliukin 069440be15 Improve the "replication work" sentence definition.
Add an ability to specify the origin and the destination for
the replication works clause. Use this ability in the API
promotion test to ensure the replication from the former
replica to the former master.
2016-03-02 15:43:44 +01:00
Oleksii Kliukin 24ebcc72f6 Add more tests for the restart and promotion. 2016-03-01 22:07:18 +01:00
Oleksii Kliukin ed15f7cd73 Improve tests start/stop, add etcd logging.
Toggle the etcd debug logging and write the log to the test dir.

Make sure etcd and patroni are terminated when the tests finish
by sending SIGKILL in case SIGTERM does not work.

Make sure before.all code does the proper cleanup when the exception
is thrown.
2016-03-01 22:03:38 +01:00
Oleksii Kliukin fa1a7687e5 Correct the step definition, randomize the table.
Make sure the step definition does not include "command" worlds.
Use the table name that includes current timestamp in the tests.
2016-03-01 22:00:30 +01:00
Oleksii Kliukin 0d44e3eb7c Add simple API tests for 2 nodes, to be extended. 2016-02-26 18:00:11 +01:00
Oleksii Kliukin 4e9ebf48a8 Add API tests for a stand-alone node. Bugfixes.
Add tests for patroni API.
Fix test failures when an already running etcd is used.
2016-02-26 17:37:37 +01:00
Alexander Kukushkin b77daffc98 Merge branch 'master' of github.com:zalando/patroni into feature/remove-unused-code 2016-02-26 11:39:28 +01:00
Oleksii Kliukin 45cb3f9696 Merge branch 'master' of https://github.com/zalando/patroni 2016-02-26 10:54:18 +01:00
Oleksii Kliukin e564fa7f08 Update DCS status right after acquiring the lock.
This commit only handles the initial bootstrap case, uncovered
by the upcoming lettuce tests
2016-02-26 10:53:00 +01:00
Alexander Kukushkin cb38e50ac1 Remove unused code 2016-02-26 08:50:53 +01:00
Oleksii Kliukin 83b7c34b00 Do not try to close an already closed file. 2016-02-25 15:35:15 +01:00
Oleksii Kliukin a84a3fc5e1 Changeset missing in the previous commit. 2016-02-25 15:16:35 +01:00
Oleksii Kliukin 67f55b4606 Stylistic issues: clearly mark unused variables. 2016-02-25 15:11:48 +01:00
Oleksii Kliukin 4a8edf44e6 Convert normal methods to static methods when possible. 2016-02-25 14:55:29 +01:00
Oleksii Kliukin 481a80a3ce Fix another couple of warnings from the QuantifiedCode and Co. 2016-02-25 14:39:22 +01:00
Oleksii Kliukin 4986db5c6a Code refactoring, no functional changes.
Move etcd code into a separate class.
Reduce the number of global interdependencies.
Clearly define private members of PatroniController and EtcdController.

It would not make the QuantifiedCode entirely happy, since lettuce
passes the step argument to the step definition, that is not used
in the client code, but internally (via the @steps decorator on
the steps class), but that's the issue of the tool used.
2016-02-25 12:52:45 +01:00
Oleksii Kliukin 53b5dfe39e Remove an unused function. 2016-02-24 19:24:46 +01:00
Oleksii Kliukin c9b8c2d3a9 Bugfixes, add a function to kill patroni daemon, make the feature description more concise. 2016-02-24 19:22:42 +01:00
Oleksii Kliukin 6f03953268 Merge basic failover and basic replication scenarios in one feature. 2016-02-24 17:12:45 +01:00
Oleksii Kliukin 6ec3523748 Collect test output, add basic failover test. 2016-02-24 16:30:52 +01:00
Alexander Kukushkin 9c12eb671d Fix unit tests 2016-02-24 12:10:58 +01:00
Oleksandr Shulgin 16b321e0a5 Add dummy cluster name to test_ctl / zookeeper 2016-02-23 17:33:15 +01:00
Oleksandr Shulgin 524cfafbbe Don't mock get_dcs() for ZK, we are trying to test it 2016-02-23 17:18:16 +01:00
Oleksandr Shulgin c630b60ca9 Merge remote-tracking branch 'origin/master' into bugfix/zookeeper-support-for-ctl 2016-02-23 17:12:41 +01:00
Oleksandr Shulgin a1417875a2 Add dummy patronictl tests with ZooKeeper 2016-02-23 17:04:16 +01:00
Oleksandr Shulgin 2f84e9f4ec Add support for ZooKeeper/Exhibitor DCS URI in patronictl ... -d 2016-02-23 16:44:09 +01:00
Alexander Kukushkin a38af0949b Merge branch 'master' of github.com:zalando/patroni into feature/xlog_lag_interval 2016-02-23 14:48:49 +01:00
Alexander Kukushkin 70742e6aba Merge pull request #139 from zalando/codequality
remove unused code
2016-02-23 13:24:53 +01:00
Oleksii Kliukin 6ebfbd241a Merge pull request #141 from jankeirse/master
sample systemd service file
2016-02-23 12:35:37 +01:00
Alexander Kukushkin ec85e2eb49 make quantifiedcode happier 2016-02-23 12:05:02 +01:00
Alexander Kukushkin 756158a735 make codacy and quantifiedcode happier 2016-02-23 11:59:02 +01:00
Alexander Kukushkin 6b3c4697fc Remove unused code 2016-02-23 11:49:22 +01:00
Alexander Kukushkin ce33090c0d Mock dcs.watch directly instead of using wraper 2016-02-23 11:48:52 +01:00
Alexander Kukushkin dd20fc7e71 Refactor follow method 2016-02-23 11:47:47 +01:00
Alexander Kukushkin 641cc4013e Mock a few of methods in Postgresql class instead of the whole class 2016-02-23 11:46:49 +01:00
Alexander Kukushkin dbb3e8308b Merge branch 'master' of github.com:zalando/patroni into codequality 2016-02-22 19:20:23 +01:00
Oleksii Kliukin cde61187c2 Merge pull request #134 from zalando/feature/scheduled_failover_squashed
Feature/scheduled failover squashed
2016-02-22 16:40:17 +01:00
Oleksii Kliukin 287c0b3125 Fix the call to the function that was forgotten to be renamed. 2016-02-22 14:31:29 +01:00
Oleksii Kliukin 26e1586288 Make the patronictl test provide an input for the schedule, even if it's empty. 2016-02-22 12:20:20 +01:00
Jan Keirse 753ba835f1 wrong comment about restart 2016-02-18 11:08:18 +01:00
Jan Keirse e68e253d16 Add patroni.service file documentation. 2016-02-18 11:06:40 +01:00
Jan Keirse eb1e678820 sample systemd service file 2016-02-18 11:04:41 +01:00
Alexander Kukushkin 4038d94c5a Fix more codacy issues 2016-02-17 14:59:17 +01:00
Alexander Kukushkin a210cfd1ab Fix more codacy issues 2016-02-17 14:51:59 +01:00
Alexander Kukushkin f7d60c61b6 remove unused code 2016-02-17 14:09:00 +01:00
Alexander Kukushkin 61feb47da8 Merge pull request #137 from LappleApple/patch-2
Rename LICENCE to LICENSE
2016-02-17 13:33:05 +01:00
Lauri at Zalando 602b21ac7d Rename LICENCE to LICENSE
spelling error
2016-02-17 13:15:41 +01:00
Alexander Kukushkin de129b733d Fix unit tests 2016-02-17 12:46:32 +01:00
Alexander Kukushkin 7928a3a0d6 Merge branch 'master' of github.com:zalando/patroni into feature/xlog_lag_interval 2016-02-17 12:41:09 +01:00
Alexander Kukushkin f079a9f308 remove unused code 2016-02-17 12:34:22 +01:00
Alexander Kukushkin 1b9e77fe83 pep8 formatting 2016-02-17 12:34:04 +01:00
Alexander Kukushkin 1b14229da4 Catch TypeError within ha loop not in the unit test
In addition to that use sleep function from patroni.utils instead of
time.sleep which is interruptable
2016-02-17 12:18:50 +01:00
Alexander Kukushkin a875e93f2e Merge branch 'master' of github.com:zalando/patroni into feature/scheduled_failover_squashed 2016-02-17 12:14:10 +01:00
Alexander Kukushkin f131961d8b Merge pull request #136 from zalando/codequality
Improve quality of code by resolving issues found by quantifiedcode and codacy
2016-02-17 09:26:21 +01:00
Oleksii Kliukin f781d0b9fe Address the code review by Alex Shulgin. 2016-02-16 16:50:50 +01:00
Alexander Kukushkin 0710bdfead directory could disappear after successfull call of isdir 2016-02-15 16:01:11 +01:00
Alexander Kukushkin 1bc22727d5 patroni/postgresql.py
directory could disappear after successfull call of isdir
2016-02-15 14:50:59 +01:00
Alexander Kukushkin 3d4fdea8d5 remove unused import 2016-02-15 13:46:53 +01:00
Alexander Kukushkin 31bad6df49 revert some changes which changed functionality of original code 2016-02-15 13:41:50 +01:00
Alexander Kukushkin 58508c3404 remove uneeded code 2016-02-12 16:57:56 +01:00
Alexander Kukushkin b973ed7e4f improve test coverage 2016-02-12 16:52:26 +01:00
Alexander Kukushkin 544ecdc1be make quantifiedcode and codacy happier 2016-02-12 15:53:24 +01:00
Alexander Kukushkin ab0ef91f24 Improve quality of code by resolving issues found by quantifiedcode and codacy 2016-02-12 14:52:23 +01:00
Alexander Kukushkin 36aef07738 Merge branch 'master' of github.com:zalando/patroni into codequality 2016-02-12 12:33:02 +01:00
Alexander Kukushkin df9b8fed2e Improve quality of code by resolving issues found by quantifiedcode and codacy 2016-02-12 12:23:49 +01:00
Feike Steenbergen 0c2efeb7a7 Change default http status code to 500.
Instead of returning 503 (Service Unavailable) we no default to returning 500 (Internal Server Error).
2016-02-11 09:01:44 +01:00
Feike Steenbergen d6c9eb0791 Merge pull request #135 from zalando/feature/setsid
Create new session and set the process group ID for the postmaster
2016-02-10 16:59:59 +01:00
Feike Steenbergen d530133f62 Fixes #133 2016-02-10 16:33:47 +01:00
Alexander Kukushkin 7b524bc557 Create new session and set the process group ID for the postmaster
Otherwice it was receiving signal when you pressed Ctrl+C in the
terminal where patroni is running.
2016-02-10 14:51:20 +01:00
Feike Steenbergen 854ad293c5 Scheduled failover: Add requirements 2016-02-10 14:26:25 +01:00
Feike Steenbergen 1e2fdac891 Scheduled Failover tests
Add tests for the scheduled failover feature, also add more and better tests for patronictl.
2016-02-10 14:19:41 +01:00
Alexander Kukushkin 50512fef45 Merge pull request #132 from zalando/bugfix/superuser_username
Create superuser with the name specified in config file
2016-02-10 14:10:28 +01:00
Feike Steenbergen 37315903fa Implement scheduled failover.
Scheduled failover allows scheduling of a failover in the future. It does this by writing
a failover key in the DCS which contains the scheduled failover time.
The reason to allow a scheduled failover, is that it does not require one to use a
scheduler (e.g. cron) to schedule such a failover. One of the issues with using
a scheduler is that it may need to authenticate itself. With scheduled failover the authentication
takes place during the scheduling, not during the actual failover.

To allow the time of failover to be expressed, the failover key has changed its format; the old format however
can still be used. The new format expects the failover key to be a json-document with relevant
keys set. We need the timestamp specified to be time zone aware and to be expressed unambigiously, e.g. ISO 8601.
2016-02-10 14:04:14 +01:00
Alexander Kukushkin b4af126bc3 Apply superuser name and password when doing checkpoint 2016-02-10 13:50:05 +01:00
Alexander Kukushkin ad17b2070a Create superuser with the name specified in config file
And later use this name to connect to the cluster
2016-02-10 12:37:34 +01:00
Oleksii Kliukin 9d4a85e461 Merge pull request #131 from zalando/bugfix/superuser_username
Bugfix/superuser username
2016-02-10 10:48:03 +01:00
Oleksii Kliukin 435eeeb85d Add postgres2.yml as well. 2016-02-10 10:30:05 +01:00
Oleksii Kliukin a513893a19 Really fix the sample configuration files. 2016-02-10 10:29:21 +01:00
Oleksii Kliukin 7ab366a735 Fix psycopg2 username/user confusion.
Make sure username is translated into the user when calling
psycopg2.connect. Also, fix the sample configuration files to use
username everywhere.
2016-02-10 10:26:22 +01:00
Oleksii Kliukin 38bd037d99 Add the 1st lettuce test for the basic replication.
Basically check that the table inserted on the primary
will get its way to the secondary.
2016-02-05 13:30:42 +01:00
Feike Steenbergen c2d46a084e Include timestamp of last replayed location in api call. 2016-02-05 09:22:38 +01:00
Oleksii Kliukin d426a795c3 Merge pull request #122 from zalando/feature/replica_without_the_master
Run replicas without the master
2016-02-04 21:32:17 +01:00
Oleksii Kliukin 09ecd1cbec Fix another issue with replication slots.
Do not try to create replication slots on the replica for the
member that wants to replicate from it if the member's currently
holds the master role.

Remove a debug message.
2016-02-04 19:27:26 +01:00
Oleksii Kliukin 03b56ae5b9 Code refactoring per review by Alex Shulgin.
In particular, rename most of the functions that have leader
in the name if they can be called in the context where the
leader is None.
2016-02-04 19:14:22 +01:00
Oleksii Kliukin 5284a2144d Add maintainers file. 2016-02-04 18:55:02 +01:00
Oleksii Kliukin 1a87bbd830 Fix handling of replication slots on the master.
Master shouldn't keep a replication slot for the members that
replicate from other members instead of the master (replicatefrom).
Otherwise, the master will keep collecting WAL segments that won't
be requested ever. Of course, if the destination of replicatefrom
is not part of the cluster, master should create the slot.
2016-02-04 15:31:22 +01:00
Oleksii Kliukin 458f12f8a2 Rename the badly named parameter. 2016-02-04 12:24:31 +01:00
Feike Steenbergen 642ffe5151 Merge pull request #126 from zalando/feature/docker_improvements
Docker improvements.
2016-02-04 11:21:00 +01:00
Feike Steenbergen 600e3fe853 Merge pull request #124 from zalando/bugfix/wrong-username
Bugfix: Ensure to inject the superuser username when connecting.
2016-02-03 17:06:52 +01:00
Feike Steenbergen 29668556db Merge pull request #112 from zalando/feature/patronictl_password
Feature/patronictl password
2016-02-03 17:03:49 +01:00
Feike Steenbergen af0db5916d Docker improvements.
Latest greatest upstream (PostgreSQL 9.5, etcd 2.2.5)
Install python packages using requirements file (vs installing from apt-get).
2016-02-03 17:00:20 +01:00
Feike Steenbergen 7db5ec1269 Revert global Docker changes 2016-02-03 16:46:30 +01:00
Feike Steenbergen a5207e7d57 Superuser specification: only overwrite values if specified. 2016-02-01 14:58:40 +01:00
Feike Steenbergen b724757b8a Bugfix: Ensure to inject the superuser username when connecting.
When running patroni with a superuser different than the os-user, the connection
was being established using the os-username. This fixes this.
2016-02-01 14:53:55 +01:00
Feike Steenbergen 1a8eaf8b93 Spelling: Even spelling can be tested 2016-02-01 13:02:21 +01:00
Feike Steenbergen 1d689d1e27 Spelling 2016-02-01 12:54:23 +01:00
Feike Steenbergen 989443b4e3 Merge pull request #123 from zalando/feature/api-context
Provide more context for healthchecks and monitoring.
2016-02-01 12:33:08 +01:00
Oleksii Kliukin 2d457ae26a Fix a problem with mutable default arguments.
Also bump up the version of python-etcd in requirements to the
latest one that that does not have https://github.com/jplana/python-etcd/issues/152
2016-02-01 11:37:59 +01:00
Feike Steenbergen bce96df177 Add attributes to Mocked classes 2016-01-29 13:29:51 +01:00
Feike Steenbergen 704b29e686 Provide more context for healthchecks and monitoring.
Include version numbers of both PostgreSQL and patroni for the /patroni endpoint.
Scope is also returned.
2016-01-29 12:55:08 +01:00
Oleksii Kliukin abaef49670 Disable auth in order to use patronictl with the default configuration, remove obsolete replication_methods like. 2016-01-29 09:05:52 +01:00
Oleksii Kliukin d1e54174c7 Make the code slightly more readable. 2016-01-27 13:13:52 +01:00
Oleksii Kliukin 72d30974ad Another attempt at making the Quantifiedcode happy. 2016-01-26 16:22:28 +01:00
Oleksii Kliukin c9de062ef5 declare the test as a static method to make the code analyzing tool happy. 2016-01-26 16:07:17 +01:00
Oleksii Kliukin aa350b7139 Increase the tests coverage. 2016-01-26 15:57:23 +01:00
Oleksii Kliukin 34437550d4 Fix a new flake8 warning (line break before the binary operator) 2016-01-26 15:40:53 +01:00
Oleksii Kliukin 15bec1e28c Support new replicas without the master.
The replication method should have no_master flag set and
support getting the base backup from some external storage
(i.e. S3). At the moment we only support initialization of
replicas in the already existing cluster when no master is
present, since there is no 'one fits all' way to decide whether
to run initidb or wait for the replica data storage to become
available when dealing with the new cluster.
2016-01-26 15:24:32 +01:00
Oleksii Kliukin 70bae1b267 Note multiple PostgreSQL listen addresses. 2016-01-08 16:51:24 +01:00
Oleksii Kliukin c650dc092e Follow the node in the replicatefrom if present.
Rename the follow_the_leader to just follow, since the node to
be followed is not necessary a leader anymore. Extend the code
that manages replication slots to the non-master nodes if they
are mentioned in at least one replicatefrom tag.
Add the 3rd configuration in order to be able to run cascading
replicas.
2015-12-30 18:33:23 +01:00
Oleksii Kliukin bf52fa6f57 follow_the_leader unconditionally during recovery.
Otherwise, we may 'forget' to start the crashed node.
This fixes the regression from the former behavior introduced
in the previous commit.
2015-12-30 16:05:06 +01:00
Oleksii Kliukin 8ac731788d Merge branch 'master' into feature/cascading_replicas 2015-12-30 10:46:03 +01:00
Alexander Kukushkin b77908ce58 Execute tests against python 3.5 2015-12-24 09:59:01 +01:00
Alexander Kukushkin 1b032ab618 stick to the python-etcd=0.4.2 until issues=152 is not fixed 2015-12-24 09:56:24 +01:00
Alexander Kukushkin 41bf010cc1 stick to the python-etcd=0.4.2 until issues=152 is not fixed 2015-12-24 09:54:13 +01:00
Alexander Kukushkin 91a10bf122 Execute tests against python 3.5 2015-12-24 09:51:42 +01:00
Alexander Kukushkin 42a5cf70e6 mock socket.getaddrinfo for patroni-ctl unit tests 2015-12-24 09:49:58 +01:00
Feike Steenbergen a64c7abdcc Bugfix: Fixing python-etcd version, as behaviour has changed in newer version.
Our current master branch doesn't pass the code coverage test, due to behaviour changes
in upstream python-etcd.
As a bandaid, fix the version for now.

Reference build fail:

https://travis-ci.org/zalando/patroni/jobs/98470121
2015-12-23 09:13:23 +01:00
Feike Steenbergen feac841aad Docker: Install python packages via pip only, yaml consistency 2015-12-22 19:49:28 +01:00
Feike Steenbergen 6568c56c85 Patronictl: Add tests to increase coverage, fix regression issue. 2015-12-22 16:21:19 +01:00
Feike Steenbergen 42e0714801 Patronictl: Allow specification of dbname and user, as well as password prompting. 2015-12-22 15:30:49 +01:00
Feike Steenbergen 47007c3331 Dockerfile: Ensure all python packages are available and patronictl is configured 2015-12-22 15:30:11 +01:00
Feike Steenbergen 64e09f7ca7 Patronictl: Prettier error messages by inheriting from ClickException 2015-12-22 15:29:05 +01:00
Oleksii Kliukin 39cbd5f1d6 Unify all follow the leader calls from eventloop.
Call normal follow the leader method from HA even during recovery.
This provides a single place that changes recovery.conf, making
it easier to plug in a cascading replica in the future.

Remove an obsolete demote function from PostreSQL module, modified
the tests.
2015-12-11 18:54:38 +01:00
Oleksii Kliukin a4af9f2a4c Add replicafrom tag. 2015-12-11 18:54:03 +01:00
Oleksii Kliukin 146d9a688e Merge pull request #111 from zalando/bugfix/initdb_without_options
Fix for the crash due to a missing initidb section in the configurati…
2015-12-11 10:54:27 +01:00
Oleksii Kliukin b2cd6d9113 Fix for the crash due to a missing initidb section in the configuration file 2015-12-11 10:36:39 +01:00
Oleksii Kliukin 39beb9cbb3 Bump up minor version. 2015-12-10 09:51:28 +01:00
Oleksii Kliukin 17250a20aa Merge pull request #108 from zalando/pg_rewind_manual_failover
Call a checkpoint on master before pg_rewind.
2015-12-09 14:13:43 +01:00
Oleksii Kliukin d0c84c87ba Fix the formatting, add the missing changes to configuration files. 2015-12-09 13:56:37 +01:00
Oleksii Kliukin c5285bc293 Call a checkpoint on master before pg_rewind.
PostgreSQL does not run a checkpoint during promition.
Since pg_rewind relies on the last checkpoint to get the timeline,
there is a short race condition right after the promotion, when
it can get the timeline wrong and fail. We work around this by
calling the checkpoint manually.

Make sure our test configuration does both archive and recovery.
2015-12-09 13:17:36 +01:00
Feike Steenbergen 735fe0b345 Bumped version to 0.75 2015-11-25 16:27:02 +01:00
Feike Steenbergen c28e6a7067 Merge pull request #105 from zalando/patroni/add_wal_restore_to_pip
Enable installing scripts via pip and use patroni_ prefix for them.
2015-11-25 16:25:03 +01:00
Feike Steenbergen 781b1854eb Merge pull request #104 from zalando/feature/patroni_cli_or_ctl_tbd
Bugfixes for Patronictl and the Development Docker environment
2015-11-25 16:23:25 +01:00
Oleksii Kliukin 2e9ac89591 Install patroni scripts alongside patroni, fix Oleksii's name. 2015-11-25 16:09:47 +01:00
Oleksii Kliukin 78f0b7e681 Add main() function inside the scripts/
We need this in order to install those scripts via pip.
2015-11-25 16:07:13 +01:00
Feike Steenbergen 65674dcd38 Bump timeout for patronictl posting to Patroni api. 2015-11-25 15:40:06 +01:00
Feike Steenbergen 1606ab6f5a Bugfixes for Patronictl and the Development Docker environment
For easier development using Docker the $HOSTNAME variable will be used to
name the running Patroni. Bumped some _segments postgresql settings to ensure
WAL files are not removed very quickly.

Increased the timeout for the post request for Patroni, as some operations
(failover) may take considerable time to complete.

The failover to a specific member was broken in patronictl as it used a wrong
key to specify the member to failover to.

Pretty printing fix for xlog lag, to prevent false negatives to show up and have
good alignment.
2015-11-25 15:07:55 +01:00
Oleksii Kliukin daf9075b2f Merge branch 'master' of https://github.com/zalando/patroni 2015-11-25 15:05:11 +01:00
Oleksii KliukinandOleksii Kliukin 28f1d51cb6 Merge pull request #103 from zalando/pgexperts-restore/movebasebackup
Replica creation methods are configured via the 'create_replica_method'
parameter in the patroni configuration file. The value should contain a list
of acceptable replica creation method names. Patroni attempts to call them one
by one, until it finds one that returns 0. If there is a configuration section
with the same name as the replica creation method, the values in this section
are converted to the command-line options and appended to the command name.
The command name is constructued either from the 'command' key in the method
name configuration section, or by taking the replica creation method name
verbatim.

The wal_e replica creation method is included with Patroni (wale_restore.py
script), in addition to the 'basebackup', which is a special method: it's taken
by default, if no create_replica_method parameter is specified, or if it is
empty. This merge also reverses the decision to move the basebackup repli
method ouside of the core patroni.

Original pull request by Josh Berkus (@jberkus), with fixes and improvements
from Alexander Kukushkin (CyberDem0n) and Oleksii Kliukin (alexeyklyukin).

Per discussion at https://github.com/zalando/patroni/issues/57
2015-11-25 15:04:23 +01:00
Oleksii Kliukin ce1ff95f4c Merge pull request #103 from zalando/pgexperts-restore/movebasebackup
Replica creation methods are configured via the 'create_replica_method'
parameter in the patroni configuration file. The value should contain a list
of acceptable replica creation method names. Patroni attempts to call them one
by one, until it finds one that returns 0. If there is a configuration section
with the same name as the replica creation method, the values in this section
are converted to the command-line options and appended to the command name.
The command name is constructued either from the 'command' key in the method
name configuration section, or by taking the replica creation method name
verbatim.

The wal_e replica creation method is included with Patroni (wale_restore.py
script), in addition to the 'basebackup', which is a special method: it's taken
by default, if no create_replica_method parameter is specified, or if it is
empty. This merge also reverses the decision to move the basebackup repli
method ouside of the core patroni.

Original pull request by Josh Berkus, with fixes and inprovements
from Alexander Kukushkin and Oleksii Kliukin.

Per discussion at https://github.com/zalando/patroni/issues/57
2015-11-25 15:00:56 +01:00
Oleksii Kliukin d4ab4d1aef Output the method used to initialize the replica. 2015-11-25 14:48:59 +01:00
Oleksii Kliukin 14b8dfa3e8 Make create_replica_method a YAML array.
Make sure the absense of this key or empty value in it is handled
correctly. Update tests and sample configuration files.
2015-11-25 10:29:17 +01:00
Oleksii Kliukin be9e525739 Remove an unused line. 2015-11-24 17:23:38 +01:00
Oleksii Kliukin d8a8fe9a80 Convert build_connstring into a one-liner, per code review by Alex. 2015-11-24 16:40:18 +01:00
Oleksii Kliukin 4b1ff5a4bb Code refactoring, per code review by Alex. 2015-11-24 16:37:40 +01:00
Oleksii Kliukin 6d296b1b34 Make sure params passed to the replica creation method are always defined. Per code review by Alex. 2015-11-24 16:26:08 +01:00
Oleksii Kliukin 1838b633e5 Merge branch 'pgexperts-restore/movebasebackup' of https://github.com/zalando/patroni into pgexperts-restore/movebasebackup 2015-11-24 16:16:05 +01:00
Oleksii Kliukin aee7d32af6 Small code improvement, per comment by Alex. 2015-11-24 16:14:39 +01:00
Feike Steenbergen 875c82e833 Documentation bugfix 2015-11-24 16:02:46 +01:00
Oleksii Kliukin fcbb820949 Remove some cruft from the tests. 2015-11-24 15:43:56 +01:00
Oleksii Kliukin f3d9edb57f also add -p 1 to the restore commands provided with sample yaml files. 2015-11-24 15:32:44 +01:00
Oleksii Kliukin daf2a2686b Merge branch 'pgexperts-restore/movebasebackup' of https://github.com/zalando/patroni into pgexperts-restore/movebasebackup 2015-11-24 15:22:44 +01:00
Oleksii Kliukin 35efd36c5c Improve unittests and make minor bugfixes.
In particular, remove restore.py in favor of
wale_restore.py, fix minor bugs in the latter
and add unit tests.
2015-11-24 15:21:47 +01:00
Oleksii Kliukin 6c769554a3 Add -p 1 to an example restore_command in order to limit WAL-E to only a single thread
With out tests, the multi-threaded WAL fetch didn't work reliably with S3, resulting in stuck WAL-E processes.
2015-11-23 17:42:08 +01:00
Oleksii Kliukin e625c33bef Merge branch 'master' into pgexperts-restore/movebasebackup 2015-11-23 15:42:26 +01:00
Oleksii Kliukin ec1d1c827b Merge pull request #102 from zalando/bugfix/silenceapithread
Avoid excessive log output in the API thread.
2015-11-23 15:41:50 +01:00
Oleksii Kliukin 58d1a8a869 Add missing whitespaces 2015-11-23 15:38:50 +01:00
Oleksii Kliukin 5370b46c65 Make sure WAL-E restore script returns 1 if criterias to use WAL-E were not met. 2015-11-23 15:27:15 +01:00
Oleksii Kliukin e3961ee70b Simplify the condition when handling postgresql status request failures. 2015-11-23 12:59:01 +01:00
Oleksii Kliukin e6b6086e31 Avoid excessive log output in the API thread.
Set log level for BaseHTTPRequestHandler request logging to debug.
Avoid complains about PostgreSQL being unreachable if we know it
is not running.
2015-11-23 12:44:38 +01:00
Oleksii Kliukin 4814e82055 Fix a typo and an error in calling subprocess.call. 2015-11-20 17:55:51 +01:00
Oleksii Kliukin d59ccd1d8e Fix a couple of logical issues.
- command is deleted from method_config without checking whether
   it was there in the first place.
 - write_recovery_conf is called before the recovery file is restored
   from the backup location.
2015-11-20 14:22:25 +01:00
Oleksii Kliukin 7bc5ed7e4d Small fixes and an executable bit for wale script. 2015-11-18 18:03:38 +01:00
Feike Steenbergen 2a37e2c5d7 Bumped version to 0.7 2015-11-18 16:45:05 +01:00
Oleksii Kliukin c003af294a Merge pull request #82 from zalando/feature/patroni_cli_or_ctl_tbd
Feature/patroni cli or ctl tbd
2015-11-18 16:17:02 +01:00
Oleksii Kliukin 0e7adb38fa Merge pull request #100 from zalando/bugfix/etcd_exceptions
Handle unexpected exceptions in etcd.
2015-11-18 15:08:59 +01:00
Feike Steenbergen 897024a297 PatroniCtl: Bugfixes for formatting and timeouts 2015-11-18 15:01:35 +01:00
Feike Steenbergen f081f9d67e Bugfix for Patronictl tests 2015-11-18 12:55:11 +01:00
Feike Steenbergen cae025c3fb Testing patronictl: Mock configuration and bugfix 2015-11-18 12:50:36 +01:00
Feike Steenbergen 4bb1e060c0 Bugfix for patronictl tests 2015-11-18 12:00:51 +01:00
Feike Steenbergen ca4d9eaaf9 Patronictl: Expand tests to increase coverage 2015-11-18 11:51:24 +01:00
Oleksii Kliukin fef7d45208 Handle unexpected exceptions in etcd.
Previously, patroni would die after receiving an exception
other than RetryFailedError, etcd.EtcdException from etcd.
We have observed an AttributeError raised by etcd on some
occasions. With this change, we demote ourselves, but not
terminate on such exceptions.
2015-11-17 16:08:58 +01:00
Feike Steenbergen 2d9f5d9e4b Refactoring and adding a dsn option to patronictl.
Some refactoring to reuse some codepaths.
A dsn option is now added, it is useful in scripts like so:

psql -d "$(patronictl dsn alpha)"

Restarting has been extended to allow restarting based on role.
2015-11-17 15:42:43 +01:00
Feike Steenbergen e920416114 Patronictl: Extend test cases and create own Exception class. 2015-11-16 12:36:15 +01:00
Oleksii Kliukin 87a5646ad0 Merge branch 'restore/movebasebackup' of https://github.com/pgexperts/patroni into pgexperts-restore/movebasebackup 2015-11-16 12:04:32 +01:00
Alexander Kukushkin 57f19fb149 Merge pull request #80 from zalando/feature/nofailover
Feature/nofailover
2015-11-16 10:21:56 +01:00
Alexander Kukushkin 96dcb54429 Merge pull request #96 from zalando/bugfix/haproxy_errors
Handle haproxy requests. Improve failover status code.
2015-11-16 10:19:19 +01:00
Alexander Kukushkin 490a54f922 Merge pull request #94 from zalando/feature/initdb
Make it possible to specify custom options for initdb
2015-11-16 10:17:28 +01:00
Alexander Kukushkin 7d59a757fb Merge pull request #93 from zalando/bugfix/parameters
Do not fail when there is no postgresql/parameters section in a config file
2015-11-16 10:13:52 +01:00
Alexander Kukushkin 5290792dff Merge pull request #92 from zalando/feature/api
Call touch_member right after demote and promote.
2015-11-16 10:13:24 +01:00
Feike Steenbergen 990276c214 Install patronictl as a script 2015-11-13 13:02:40 +01:00
Feike Steenbergen dcb5113f9d Refactoring from patronicli to patronictl 2015-11-13 12:55:38 +01:00
Oleksii Kliukin 70cbbb4ef4 Ignore only socket.error at finalization stage of StreamRequestHandler, not other errors. 2015-11-12 17:55:02 +01:00
Oleksii Kliukin 28934350ef Handle haproxy requests. Improve failover status code.
By default, haproxy sens an OPTION request, which we didn't
handle until now. In addition, all haproxy requests that doesn't
examine the request body close the connection as soon as the status
code is obtained. Such behavior breaks BaseHTTPRequestHandler,
namely handle_one_request, which doesn't check for connection reset
by peer and throw this error on a higher level, but since we don't
call this function directly, there is no place in the code to catch
it, therefore, we have to patch this function in the base class.
In addition, patch the StreamRequestHandler finish() function in
order to handle the connection reset error.

Re-read the cluster from DCS right after the failover to supply
the correct new values to the API thread. Fix a typo.
2015-11-12 17:38:22 +01:00
Alexander Kukushkin 092bf8defa Merge branch 'master' of github.com:zalando/patroni into feature/initdb 2015-11-12 12:22:14 +01:00
Alexander Kukushkin 13cb8e7402 set synchronous_commit to local when creating roles 2015-11-12 12:20:05 +01:00
Oleksii Kliukin 9f0ee544cf Merge pull request #95 from LappleApple/patch-1
Documentation improvements by @LappleApple
2015-11-12 12:18:13 +01:00
Alexander Kukushkin d39d297d92 blacklist some options for initdb instead of having big whitelist 2015-11-12 12:07:36 +01:00
LappleApple eb83d556e8 Update README.rst 2015-11-12 11:43:49 +01:00
Alexander Kukushkin f0a6c86caa Make it possible to specify custom options for initdb
In the initial implementation we were using the only option
--encoding=UTF8. In order to have pg_rewind working with postgresql-9.3
we have to enable data-checksums. The naive approach was to enable it
globaly but taking into account some performance degradation it's better
not to do it but make it possible to configure it.

In addition to that fix all problems with setting up password of default
postgres user: execute CREATE ROLE | ALTER ROLE depending on content of
pg_authid
2015-11-11 15:59:34 +01:00
Alexander Kukushkin 9c16830fc0 Do not fail when there is no postgresql/parameters section in a config file 2015-11-11 10:46:37 +01:00
Oleksii Kliukin 194aa92fd2 Merge pull request #89 from zalando/bugfix/cleanup
Remove the useless code line and fix a typo in the error message. Per suggestion by @antban
2015-11-10 18:18:46 +01:00
Oleksii Kliukin a8312dfefc Fixed a typo in the error message. 2015-11-10 18:14:18 +01:00
Feike Steenbergen 92b23a2018 Merge branch 'feature/api' of github.com:zalando/patroni into feature/patroni_cli_or_ctl_tbd 2015-11-09 16:33:17 +01:00
Alexander Kukushkin 8a70cc4d7b Call touch_member right after demote and promote.
This is necessary to propagate actual information about node into DCS.
2015-11-09 16:22:51 +01:00
Feike Steenbergen da23dd12f3 Do not wait for leader key to change after failover.
Previously, the leader key was watched for changes after a failover. This resulted in a delay
of up to 10 seconds to report a healthy failover back to the client.

With this patch, we are not relying on the role of a member registered in the dcs anymore.
2015-11-06 15:02:13 +01:00
Oleksii Kliukin e52a34de1c Remove the useless codeline. 2015-11-03 16:04:01 +03:00
Josh Berkus 30aa83c5b2 Fixed failing tests, pep8 issues. 2015-11-02 17:51:01 -08:00
Oleksii Kliukin 87ead9ddaf Fix haproxy configuration to work by default with Patroni. 2015-11-01 15:29:45 +03:00
Josh Berkus e7a0ce57aa Fixes, changes per discussion on pull request. Fixed logic path errors in postgresql.py. Cleaned up and shortened wale_restore.py. Reverted bad merge for YML files. 2015-10-28 14:55:42 -07:00
Josh Berkus 06cd94b12d Commit addressing several issues around this branch. Major changes to wal_e_restore script. Updated postgres0.yml to show example options. 2015-10-27 17:35:44 -07:00
Feike Steenbergen 3938359828 Command Line: Add reinit and restart commands. 2015-10-27 12:13:26 +01:00
Oleksii Kliukin a1a7c9b4c5 Make sure nofailover tag is set to False by default. 2015-10-27 09:46:03 +01:00
Oleksii Kliukin 8d68130aac Fix the test to account for an extra parameter returned by fetch_node_statuses. 2015-10-27 09:32:09 +01:00
Feike Steenbergen 7784f7db3d Bumped version to 0.6 2015-10-27 09:20:24 +01:00
Oleksii Kliukin da74aee2cd Make sure that nofailover presents the node even from initializing the cluster. 2015-10-26 18:57:28 +01:00
Oleksii Kliukin 685363c5a8 Fix an invocation fo fetch_node_statuses in the API code to account for the tags 2015-10-26 18:57:05 +01:00
Oleksii Kliukin 1f9d8c039c Add tests for nofailover. 2015-10-26 17:19:35 +01:00
Oleksii Kliukin ed5e3e53e2 Use a shortcut to call patroni nofailover attribute. 2015-10-26 17:18:45 +01:00
Feike Steenbergen 98a0d8381b Add prettytable to requirements 2015-10-26 15:07:08 +01:00
Feike Steenbergen a2cb3f1889 Include Click as requirement for patroncli 2015-10-26 15:04:23 +01:00
Feike Steenbergen e2aff13d3e Patronictl: Create commandline tool that can manage clusters.
For managing Patroni clusters, the Patroni api can be used. For many tasks, a command line interface for
this api would be a useful addition. This commit adds patroncli (The name is still under debate).
The command line interface needs access to the DCS; this is required for any operation. For some tasks it is required
to have access to the Patroni api.

A small summary of the additions to get the cli/ctl started:

* Updated Docker image to use 'true' as the archive_command, to ensure disk not filling up during failover
  testing.
* The cli currently can list members, failover a master and remove a given cluster from DCS.
* The cli can be configured with a command, for repeated access to the same DCS
* Added some simple tests for the cli, code coverage is very low
2015-10-26 14:57:59 +01:00
Oleksii Kliukin 2044d4db96 Move nofailover checks on the upper level.
Instead of checking that nofailover node should not
be marked as healthiest in one of the _failover
functions, do make it unhealthy in the is_healthiest_node.
2015-10-26 14:52:34 +01:00
Alexander Kukushkin 2d709a48e6 fix test_create_replica unit test 2015-10-26 11:30:04 +01:00
Alexander Kukushkin ab64ae7fe7 execute delete_trigger_file() after successfull call of create_replica() 2015-10-26 11:29:32 +01:00
Oleksii Kliukin 84db64e0d5 Merge branch 'master' of https://github.com/zalando/patroni into feature/nofailover 2015-10-26 10:41:51 +01:00
Oleksii Kliukin a2736cd2b7 Add nofailover property to Patroni and Cluster.
Add checks for nofailover tag in is_failover_possible
in order to save on API calls for the nodes with this tag set.
2015-10-26 10:40:34 +01:00
Alexander Kukushkin acd21eae4c Fix obvious bugs and pep8 formatting 2015-10-25 18:11:45 +01:00
Alexander Kukushkin 364d9b5a8a Revert "Small changes added for testing, and failed merge from master."
This reverts commit fc68acd0ab.
2015-10-24 13:13:23 +02:00
Josh Berkus 8a8b1c4b2b Merge branch 'master' into restore/movebasebackup 2015-10-23 09:39:08 -07:00
Josh Berkus fc68acd0ab Small changes added for testing, and failed merge from master. 2015-10-23 09:37:17 -07:00
Alexander Kukushkin 2e5efc6cc9 Merge pull request #75 from zalando/feature/custom-namespace
Make it possible to override default namespace (/service/) from a config file
2015-10-23 16:21:19 +02:00
Alexander Kukushkin 553129a981 Revert "Fix unit-test for Postgresql.controldata()"
This reverts commit deaaf8ad1a.
2015-10-23 15:59:20 +02:00
Alexander Kukushkin ad719297c2 Merge branch 'master' of github.com:zalando/patroni into feature/custom-namespace 2015-10-23 15:58:42 +02:00
Oleksii Kliukin 5f9276f5dc Merge pull request #77 from zalando/bugfix/remove_initialize_key
Fix removal of keys on failed initialization. Fixes the recent problem reported at #17
2015-10-23 15:42:48 +02:00
Oleksii Kliukin 0c5a21e57d Fix removal of keys on failed initialization.
The initialize key was checked against the value
of the node name before removal, but it was changed
recently to contain either an empty string, or cluster
sysid. To fix this, the check for the previous value
was simply removed: we can guarantee that the code path
that removes the key is the one that created it.
2015-10-23 10:46:55 +02:00
Oleksii Kliukin b7b47ffd79 Add support for the nofailover tag. 2015-10-23 10:11:38 +02:00
Alexander Kukushkin 8e41d14283 Merge branch 'master' of github.com:zalando/patroni into feature/custom-namespace 2015-10-23 09:13:52 +02:00
Josh Berkus 98ea3fa74e Merge branch 'master' of github.com:zalando/patroni into restore/movebasebackup
Conflicts:
	patroni/postgresql.py
2015-10-22 18:06:41 -07:00
Josh Berkus fa7d36da9b Merged basebackup into postgresql.py; changed things to provide alternative, configurable basebackup methods. 2015-10-22 17:21:39 -07:00
Feike Steenbergen 5ae6f3a56c Change Docker registry 2015-10-22 09:30:12 +02:00
Feike Steenbergen eaf63db886 Use a different namespace in the Docker container.
Also bugfix: Patroni should advertise Docker ip as connect address
2015-10-22 09:28:00 +02:00
Feike Steenbergen 857caa1397 Revert casting to string 2015-10-22 09:24:31 +02:00
Feike Steenbergen c751dfdebf Typo in joining namespace to scope 2015-10-22 08:50:53 +02:00
Oleksii Kliukin 2200a4ce8c Add support for per-member tags.
Tags are labels assigned to individual members in order
to alter its default behavior, i.e. exclude from the
leader election or indicate a possibility to create base
backups from the member.

This commit only adds support for setting tags in the
configuration file, exposes the tags to DCS /member subkey
and returns the tags in a response of the API request. At
the moment the tag names are not validated, nor they are
interpreted in any way.

Support for setting tags via the API is also in the scope
of further work.
2015-10-21 15:51:21 +02:00
Feike Steenbergen e0e4789b8a Explicitly cast scope to string.
Fixes issue #74
2015-10-21 15:49:20 +02:00
Alexander Kukushkin deaaf8ad1a Fix unit-test for Postgresql.controldata() 2015-10-21 15:38:51 +02:00
Alexander Kukushkin 2c7e3f60cc Make possible to override default namespace (/service/) from a config file
If the namespace is not specified in a config file /service/ would be
used.
Also it's possible to use just '/' as a namespace. It means we would
have following structure:
  /scope1
  /scope2
  ...
2015-10-21 15:34:55 +02:00
Oleksii Kliukin c61ac39e8f Merge pull request #71 from zalando/feature/configurable_pgpass
Feature/configurable pgpass
2015-10-21 14:45:02 +02:00
Oleksii Kliukin c426b8c246 Merge branch 'master' of https://github.com/zalando/patroni into feature/configurable_pgpass 2015-10-21 13:07:42 +02:00
Oleksii Kliukin 9130891029 Move calculation of pgpass to the class constructor: better to fail fast in case of issues. 2015-10-21 13:06:54 +02:00
Oleksii Kliukin 3c3694dc7c Merge pull request #73 from zalando/bugfix/update-machines-cache
Bugfix/update machines cache
2015-10-21 12:11:11 +02:00
Oleksii Kliukin e2261f531b Merge pull request #72 from drnic/override-patroni-scope
allow $PATRONI_SCOPE to be set via 'docker run -e PATRONI_SCOPE=ironman'
2015-10-21 12:10:46 +02:00
Oleksii Kliukin 2d7909e357 Merge pull request #69 from zalando/feature/clusterid
Add clusterid to patroni in order to avoid connections from already existing nodes belonging to another cluster (master - replica system).
2015-10-21 12:09:55 +02:00
Oleksii Kliukin 0b69ef93cf Merge branch 'master' of https://github.com/zalando/patroni into feature/clusterid 2015-10-21 12:02:55 +02:00
Oleksii Kliukin 44a73982d4 Do not try to fetch the element from the get_node result if the node is not there. 2015-10-21 12:00:03 +02:00
Oleksii Kliukin b922b8ad0e Merge pull request #70 from zalando/bugfix/save_configuration_files
Add a missing call to restore_configuration_files.
2015-10-21 11:45:18 +02:00
Alexander Kukushkin c4a6dd48d3 remove debug print statement 2015-10-21 11:09:37 +02:00
Alexander Kukushkin 8bd28507a9 format tests according to the latest pep8 standards 2015-10-21 11:08:06 +02:00
Alexander Kukushkin 0096b6b06f Schedule update of machines cache when api_execute call has failed
Such situation could happen if we replaced all etcd nodes except one
which was used by patroni. After replacing the last node patroni will
try to execute request on all other nodes from machines_cache but non of
them are available. Michines cache would became empty and patroni will
stick to the latest node which was available in the machines_cache and
will never try to refresh machines_cache from dns for example.

Currently machines cache is refreshed only when one request to the etcd
cluster has failed, but probably it should be done periodically, for
example every minute...
2015-10-21 10:56:43 +02:00
Alexander Kukushkin 71275fc303 Merge pull request #67 from zalando/feature/api
Manual failover via REST API
2015-10-21 10:55:28 +02:00
Dr Nic Williams 5d7e4fe90a allow $PATRONI_SCOPE to be set via 'docker run -e PATRONI_SCOPE=ironman' 2015-10-20 14:32:59 -05:00
Alexander Kukushkin f53c968d8b Improve tests 2015-10-20 14:36:49 +02:00
Oleksii Kliukin 35641ac072 Use distinct paths for pgpass from test nodes. 2015-10-20 11:40:52 +02:00
Oleksii Kliukin 92fe6a1de9 Make pgpass location configurable.
One can use pgpass configuration parameter in the postgres
subsection of Patroni. By default pgpass is written in ~/.
Mock actual writes to pgpass in the tests.
2015-10-20 11:28:26 +02:00
Oleksii Kliukin 40c5d5e351 Match default param in the abstract class definition with those from the implementation. 2015-10-19 16:08:52 +02:00
Oleksii Kliukin 90c738d83a Address the code review by Alex. 2015-10-19 16:03:21 +02:00
Oleksii Kliukin 8f606e4ff9 Add a missing call to restore_configuration_files.
I accidentially removed the call when moving the backup functions
to the external script. It is intended to save the configuration,
so that at the restore phase one can just copy backup files.
Its primary intention was to save configuration files in the WAL-E
case (WAL-E just omits everything with .conf), but it is also
useful in the pg_basebackup case, which omits all symlinks, leaving
the cluster with .conf files symlinked in the broken state.
2015-10-19 15:13:24 +02:00
Alexander Kukushkin 18eebdadaa Watch for change of failover key.
If the value is empty and leader didn't changed, this probably means
that failover failed.

After 15 seconds timeout we will consider failover status = unknown
2015-10-19 15:00:06 +02:00
Oleksii Kliukin 4e448015f3 Increase the test coverage. 2015-10-19 10:13:14 +02:00
Oleksii Kliukin 11073ac644 Merge branch 'master' into feature/clusterid 2015-10-19 09:37:51 +02:00
Alexander Kukushkin 2ebbebac7f Merge branch 'master' of github.com:zalando/patroni into feature/api 2015-10-19 09:37:11 +02:00
Oleksii Kliukin a10b7248a6 Fix a flake8 warning 2015-10-19 09:19:25 +02:00
Oleksii Kliukin 83662f71cb Exit right away if the node sysid is different from the cluster's one 2015-10-16 16:38:05 +02:00
Oleksii Kliukin a844920489 Store the cluster sysid in the initialize flag.
Make sure that the new PostgreSQL node will only
join the cluster if its sysid matches the one
stored in DCS.
2015-10-16 16:14:45 +02:00
Alexander Kukushkin 57ace2009c Merge pull request #66 from zalando/fix/tests
Fix/tests
2015-10-16 10:30:32 +02:00
Alexander Kukushkin 921e4fc323 psycopg2 should be not older than 2.6.1 2015-10-16 10:28:15 +02:00
Oleksii Kliukin 75e5901360 Merge pull request #63 from zalando/bugfix/leader_crash_readonly
Make sure patroni is not using stale connections.
2015-10-16 09:40:55 +02:00
Alexander Kukushkin 3ed82ae22c Manual failover via rest api
curl -XPOST --data '{"leader": "leader_name", "member": "member_name"}' http://127.0.0.1:8008/failover

It will execute some preliminary checks and write failover key into DCS.
Afterward it will wait until new leader key will appear in a DCS.

It's better to execute this request on the master node. It will send a
signal to the main HA loop which makes possible to release leader key
immidiately even if you are working with etcd.
2015-10-15 16:18:28 +02:00
Alexander Kukushkin f35d109810 Bugfix: do not try to double encode data 2015-10-15 16:17:11 +02:00
Alexander Kukushkin 16a0a3481d fix pep8 formatting 2015-10-15 09:08:33 +02:00
Alexander Kukushkin 2f0cf1db06 Mock etcd client delete method 2015-10-15 09:08:16 +02:00
Oleksii Kliukin 5c86b60cd2 Fix an exception in the (rather unusual) case of attaching Patroni to an existing running replica. 2015-10-14 17:05:09 +02:00
Feike Steenbergen 98b59354a9 Exclude more files from git. 2015-10-14 14:37:05 +02:00
Oleksii Kliukin 953ea749bf Make sure patroni is not using stale connections.
After the PostgreSQL crash (i.e. with kill -9), the backend
patroni connects to may still exist. In this case, patroni
will get stale postgres role from this backend, preventing
a restarted node with a leader lock from being promoted.

Easily reproducible and also observed in a staging environment
after the postgres crash due to out of disk space.
2015-10-13 15:00:16 +02:00
55 changed files with 4258 additions and 1258 deletions
+9 -1
View File
@@ -1,3 +1,11 @@
data/* data/*
*.pyc *.pyc
helpers/*.pyc *.egg/
*.egg-info/
.cache/
.coverage
.eggs/
build/
coverage.xml
junit.xml
pgpass
+76 -8
View File
@@ -1,14 +1,82 @@
sudo: false
language: python language: python
python: python:
- "2.7" - "3.5"
- "3.3" addons:
- "3.4" apt:
packages:
- postgresql-contrib-9.5
postgresql: "9.5"
env:
global:
- ETCDVERSION=2.3.2 ZKVERSION=3.4.6 CONSULVERSION=0.6.4
matrix:
- TEST_SUITE="python setup.py"
- DCS="etcd" TEST_SUITE="behave"
- DCS="exhibitor" TEST_SUITE="behave"
- DCS="consul" TEST_SUITE="behave"
cache:
directories:
- $HOME/virtualenv/python2.7.9
- $HOME/virtualenv/python3.4.2
- $HOME/virtualenv/python3.5.0
install: install:
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install -r requirements-py2.txt --use-mirrors; fi - |
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -r requirements-py3.txt --use-mirrors; fi set -e
- pip install coveralls
if [[ $TEST_SUITE == "behave" ]]; then
if [[ $DCS == "consul" ]]; then
curl -L https://releases.hashicorp.com/consul/${CONSULVERSION}/consul_${CONSULVERSION}_linux_amd64.zip \
| gunzip > consul
chmod +x consul
fi
if [[ $DCS == "etcd" ]]; then
curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
| tar xz -C . --strip=1 --wildcards --no-anchored etcd
fi
if [[ $DCS == "exhibitor" ]]; then
curl -L http://www.apache.org/dist/zookeeper/zookeeper-${ZKVERSION}/zookeeper-${ZKVERSION}.tar.gz | tar xz
mv zookeeper-${ZKVERSION}/conf/zoo_sample.cfg zookeeper-${ZKVERSION}/conf/zoo.cfg
zookeeper-${ZKVERSION}/bin/zkServer.sh start
# following lines are 'emulating' exhibitor REST API
while true; do
echo -e 'HTTP/1.0 200 OK\nContent-Type: application/json\n\n{"servers":["127.0.0.1"],"port":2181}' \
| nc -l 8181 &> /dev/null
done&
fi
fi
for pv in "2.7" "3.4" "3.5"; do
source ~/virtualenv/python${pv}/bin/activate
# explicitly install all needed python modules to cache them
for p in '-r requirements.txt' 'behave codacy-coverage coverage coveralls flake8 mock pytest-cov pytest'; do
pip install $p
done
done
script: script:
- python setup.py test - |
- python setup.py flake8 for pv in "2.7" "3.4" "3.5"; do
source ~/virtualenv/python${pv}/bin/activate
if [[ $TEST_SUITE == "behave" ]]; then
if [[ $pv != "3.4" ]]; then
echo Running acceptance tests using python${pv}
if ! PATH=.:$PATH $TEST_SUITE; then
# output all log files when tests are failing
grep . features/output/*/*postgres?.*
exit 1
fi
fi
else
echo Running unit tests using python${pv}
$TEST_SUITE test
$TEST_SUITE flake8
fi
done
set +e
after_success: after_success:
- coveralls - coveralls
- if [[ $TEST_SUITE != "behave" ]]; then python-codacy-coverage -r coverage.xml; fi
+12
View File
@@ -0,0 +1,12 @@
approvals:
# PR needs at least 4 approvals
minimum: 1
# approval = comment that matches this regex
pattern: "^:?\\+1:?$"
from:
# commenter must be either one of:
# a public zalando org member
orgs:
- zalando
# a collaborator of the repo
collaborators: true
+10 -4
View File
@@ -12,16 +12,22 @@ RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN apt-get update -y RUN apt-get update -y
RUN apt-get upgrade -y RUN apt-get upgrade -y
ENV PGVERSION 9.4 ENV PGVERSION 9.5
RUN apt-get install python python-psycopg2 python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-kazoo python-pip -y RUN apt-get install postgresql-${PGVERSION} postgresql-server-dev-${PGVERSION} -y
RUN pip install python-etcd RUN apt-get install python python-dev python-pip -y
ADD requirements-py2.txt /requirements-py2.txt
RUN pip install -r /requirements-py2.txt
ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH
ADD patroni.py /patroni.py ADD patroni.py /patroni.py
ADD patronictl.py /patronictl.py
ADD patroni/ /patroni ADD patroni/ /patroni
ENV ETCDVERSION 2.0.13 RUN ln -s /patroni.py /usr/local/bin/patroni
RUN ln -s /patronictl.py /usr/local/bin/patronictl
ENV ETCDVERSION 2.2.5
RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C /bin --strip=1 --wildcards --no-anchored etcd etcdctl RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C /bin --strip=1 --wildcards --no-anchored etcd etcdctl
### Setting up a simple script that will serve as an entrypoint ### Setting up a simple script that will serve as an entrypoint
View File
+3
View File
@@ -0,0 +1,3 @@
Alexander Kukushkin <[email protected]>
Feike Steenbergen <[email protected]>
Oleksii Kliukin <[email protected]>
+82 -65
View File
@@ -1,14 +1,14 @@
|Build Status| |Coverage Status| |Build Status| |Coverage Status|
Patroni: A Template for PostgreSQL HA with ZooKeeper or etcd Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul
------------------------------------------------------------ ------------------------------------------------------------
Patroni was previously known as Governor. Patroni was previously known as Governor.
*There are many ways to run high availability with PostgreSQL; here we *There are many ways to run high availability with PostgreSQL. Here, we
present a template for you to create your own custom fit high present a template for you to create your own customized, high-availability
availability solution using python and distributed configuration store solution using Python and — for maximum accessibility — a distributed
(like ZooKeeper or etcd) for maximum accessibility.* configuration store like ZooKeeper, etcd or Consul.*
Getting Started Getting Started
--------------- ---------------
@@ -22,8 +22,8 @@ To get started, do the following from different terminals:
> ./patroni.py postgres1.yml > ./patroni.py postgres1.yml
From there, you will see a high-availability cluster start up. Test From there, you will see a high-availability cluster start up. Test
different settings in the YAML files to see how behavior changes. Kill different settings in the YAML files to see how its behavior changes. Kill
some of the different components to see how the system behaves. some of the components to see how the system behaves.
Add more ``postgres*.yml`` files to create an even larger cluster. Add more ``postgres*.yml`` files to create an even larger cluster.
@@ -39,77 +39,95 @@ run:
> psql --host 127.0.0.1 --port 5000 postgres > psql --host 127.0.0.1 --port 5000 postgres
How Patroni works How Patroni Works
----------------- -----------------
For a diagram of the high availability decision loop, see the included a For a diagram of the high availability decision loop, review this PDF:
PDF:
`postgres-ha.pdf <https://github.com/zalando/patroni/blob/master/postgres-ha.pdf>`__ `postgres-ha.pdf <https://github.com/zalando/patroni/blob/master/postgres-ha.pdf>`__
YAML Configuration YAML Configuration
------------------ ------------------
For an example file, see ``postgres0.yml``. Below is an explanation of For an example file, see ``postgres0.yml``. Regarding settings:
settings:
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before automatic failover process is initiated. - *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *loop\_wait*: the number of seconds the loop will sleep - *loop\_wait*: the number of seconds the loop will sleep
- *restapi*: - *restapi*:
- *listen*: ip address + port that Patroni will listen to provide health-check information for haproxy. - *listen*: IP address + port that Patroni will listen to, to provide health-check information for haproxy.
- *connect\_address*: ip address + port through which restapi is accessible. - *connect\_address*: IP address + port through which restapi is accessible.
- *auth*: (optional) 'username:password' to protect some dangerous REST API endpoints. - *auth*: (optional) 'username:password' to protect dangerous REST API endpoints.
- *certfile*: (optional) Specifies a file with the certificate in the PEM format. If certfile is not specified or empty API server will work without SSL. - *certfile*: (optional) Specifies a file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- *keyfile*: (optional) Specifies a file with the secret key in the PEM format. - *keyfile*: (optional) Specifies a file with the secret key in the PEM format.
- *etcd*: - *etcd*:
- *scope*: the relative path used on etcd's http api for this deployment, thus you can run multiple HA deployments from a single etcd - *scope*: the relative path used on etcd's HTTP API for this deployment; makes it possible to run multiple HA deployments from a single etcd cluster.
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before automatic failover process is initiated. - *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *host*: the host:port for the etcd endpoint - *host*: the host:port for the etcd endpoint.
- *consul*:
- *scope*: the relative path used on Consul's HTTP API for this deployment; makes it possible to run multiple HA deployments from a single Consul cluster.
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *host*: the host:port for the Consul endpoint.
- *zookeeper*: - *zookeeper*:
- *scope*: the relative path used on etcd's http api for this deployment, thus you can run multiple HA deployments from a single etcd - *scope*: the relative path used on ZooKeeper for this deployment; makes it possible to run multiple HA deployments from a single ZooKeeper cluster.
- *session\_timeout*: the TTL to acquire the leader lock. Think of it as the length of time before automatic failover process is initiated. - *session\_timeout*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *reconnect\_timeout*: how long we should try to reconnect to ZooKeeper after connection loss. After this timeout we assume that we don't have lock anymore and will restart in read-only mode. - *reconnect\_timeout*: how long we should try to reconnect to ZooKeeper after a connection loss. After this timeout, assume that you no longer have a lock and restart in read-only mode.
- *hosts*: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'] - *hosts*: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...']
- *exhibitor*: if you are running ZooKeeper cluster under Exhibitor supervisory the following section could be interesting for you - *exhibitor*: if you are running a ZooKeeper cluster under the Exhibitor supervisory, the following section might interest you:
- *poll\_interval*: how often list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor - *poll\_interval*: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor
- *port*: Exhibitor port - *port*: Exhibitor port.
- *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: ['host1', 'host2', 'etc...' ]. This list would be updated automatically when Exhibitor (ZooKeeper) cluster topology changes. - *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: ['host1', 'host2', 'etc...' ]. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- *postgresql*: - *postgresql*:
- *name*: the name of the Postgres host, must be unique for the cluster - *name*: the name of the Postgres host. Must be unique for the cluster.
- *listen*: ip address + port that Postgres listening. Must be accessible from other nodes in the cluster if using streaming replication. - *listen*: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. The first address from this list will be used by Patroni to establish local connections to the PostgreSQL node.
- *connect\_address*: ip address + port through which Postgres is accessible from other nodes and applications.
- *data\_dir*: file path to initialize and store Postgres data files - *connect\_address*: IP address + port through which Postgres is accessible from other nodes and applications.
- *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag - *data\_dir*: file path to initialize and store Postgres data files.
- *use\_slots*: whether or not to use replication_slots. Must be False for PostgreSQL 9.3, and you should comment out max_replication_slots. before it is not eligible become leader - *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag.
- *pg\_hba*: list of lines which should be added to pg\_hba.conf - *use\_slots*: whether or not to use replication_slots. Must be False for PostgreSQL 9.3. You should comment out max_replication_slots before it becomes ineligible for leader status.
- *- host all all 0.0.0.0/0 md5*
- *initdb*: List options to be passed on to initdb
- *encoding*: default encoding for new databases
- *locale*: default locale for new databases
- *data-checksums* # When pg_rewind is needed on 9.3, this needs to be enabled
- *pg\_hba*: list of lines which should be added to pg\_hba.conf.
- *- host all all 0.0.0.0/0 md5*.
- *- host replication replicator 127.0.0.1/32 md5* # A line like this is required for replication
- *replication*: - *replication*:
- *username*: replication username, user will be created during initialization - *username*: replication username; user will be created during initialization.
- *password*: replication password, user will be created during initialization - *password*: replication password; user will be created during initialization.
- *network*: network setting for replication in pg\_hba.conf
- *callbacks* callback scripts to run on certain actions. Patroni will pass current action, role and cluster name. See scripts/aws.py as an example on how to write them. - *callbacks* callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. See scripts/aws.py as an example on how to write them.
- *on\_start*: a script to run when the cluster starts - *on\_start*: a script to run when the cluster starts.
- *on\_stop*: a script to run when the cluster stops - *on\_stop*: a script to run when the cluster stops.
- *on\_restart*: a script to run when the cluster restarts - *on\_restart*: a script to run when the cluster restarts.
- *on\_reload*: a script to run when configuration reload is triggered - *on\_reload*: a script to run when configuration reload is triggered.
- *on\_role\_change*: a script to run when the cluster is being promoted or demoted - *on\_role\_change*: a script to run when the cluster is being promoted or demoted.
- *superuser*: - *superuser*:
- *password*: password for postgres user. It would be set during initialization - *password*: password for the Postgres user, set during initialization.
- *admin*: - *admin*:
- *username*: admin username, user will be created during initialization. It would have CREATEDB and CREATEROLE privileges - *username*: admin username; user is created during initialization. It will have CREATEDB and CREATEROLE privileges.
- *password*: admin password, user will be created during initialization. - *password*: admin password; user is created during initialization.
- *recovery\_conf*: additional configuration settings written to recovery.conf when configuring follower - *recovery\_conf*: additional configuration settings written to recovery.conf when configuring follower.
- *parameters*: list of configuration settings for Postgres. Many of these are required for replication to work. - *parameters*: list of configuration settings for Postgres. Many of these are required for replication to work.
Replication choices - *create\_replica\_methods*: an ordered list of the create methods for turning a patroni node into a new replica.
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured
as its own config item.
- *replica\_method* for each create_replica_method other than basebackup, you would add a configuration section
of the same name. At a minimum, this should include "command" with a full path to the actual script to be
executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
Replication Choices
------------------- -------------------
Patroni uses Postgres' streaming replication. By default, this Patroni uses Postgres' streaming replication. By default, this
@@ -123,43 +141,43 @@ not occur if a follower is more than a certain number of bytes behind
the follower. This setting should be increased or decreased based on the follower. This setting should be increased or decreased based on
business requirements. business requirements.
When asynchronous replication is not best for your use-case, investigate When asynchronous replication is not optimal for your use case, investigate
how Postgres's `synchronous how Postgres's `synchronous
replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__ replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__
works. Synchronous replication ensures consistency across a cluster by works. Synchronous replication ensures consistency across a cluster by
confirming that writes are written to a secondary before returning to confirming that writes are written to a secondary before returning to
the connecting client with a success. The cost of synchronous the connecting client with a success. The cost of synchronous
replication will be reduced throughput on writes. This throughput will replication: reduced throughput on writes. This throughput will
be entirely based on network performance. In hosted datacenter be entirely based on network performance. In hosted datacenter
environments (like AWS, Rackspace, or any network you do not control), environments (like AWS, Rackspace, or any network you do not control),
synchrous replication increases the variability of write performance synchrous replication significantly increases the variability of write
significantly. If followers become inaccessible from the leader, the performance. If followers become inaccessible from the leader, the
leader will becomes effectively readonly. leader effectively becomes readonly.
To enable a simple synchronous replication test, add the follow lines to To enable a simple synchronous replication test, add the follow lines to
the ``parameters`` section of your YAML configuration files. the ``parameters`` section of your YAML configuration files:
.. code:: YAML .. code:: YAML
synchronous_commit: "on" synchronous_commit: "on"
synchronous_standby_names: "*" synchronous_standby_names: "*"
When using synchronous replication, use at least a 3-Postgres data nodes When using synchronous replication, use at least three Postgres data nodes
to ensure write availability if one host fails. to ensure write availability if one host fails.
Choosing your replication schema is dependent on the many business Choosing your replication schema is dependent on your business
decisions. Investigate both async and sync replication, as well as other considerations. Investigate both async and sync replication, as well as other
HA solutions, to determine which solution is best for you. HA solutions, to determine which solution is best for you.
Applications should not use superusers Applications Should Not Use Superusers
-------------------------------------- --------------------------------------
When connecting from an application, always use a non-superuser. Patroni When connecting from an application, always use a non-superuser. Patroni
requires access to the database to function properly. By using a requires access to the database to function properly. By using a
superuser from application, you can potentially use the entire superuser from an application, you can potentially use the entire
connection pool, including the connections reserved for superusers with connection pool, including the connections reserved for superusers with
the ``superuser_reserved_connections`` setting. If Patroni cannot access the ``superuser_reserved_connections`` setting. If Patroni cannot access
the Primary, because the connection pool is full, behavior will be the Primary because the connection pool is full, behavior will be
undesireable. undesireable.
Requirements on a Mac Requirements on a Mac
@@ -175,14 +193,13 @@ Run the following on a Mac to install requirements:
Notice Notice
------ ------
There are many different ways to do HA with PostgreSQL, see `the There are many different ways to do HA with PostgreSQL: See `the
PostgreSQL PostgreSQL
documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__ documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__
for a complete list. for a complete list.
We call this project a "template" because it is far from a one-size fits We call Patroni a "template" because it is far from being a one-size-fits-all
all, or a plug-and-play replication system. It will have it's own or plug-and-play replication system. It will have its own caveats. Use wisely.
caveats. Use wisely.
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master .. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
:target: https://travis-ci.org/zalando/patroni :target: https://travis-ci.org/zalando/patroni
+14 -2
View File
@@ -1,4 +1,16 @@
Failover Failover
======== ========
- When determining who should become master, include the minor version of PostgreSQL in the decision - When determining who should become master, include the minor version of PostgreSQL in the decision.
- Create a way to disable governance of a cluster, something like the existence of a "nogover" or "admin" file in PGDATA will stop governor from changing the cluster state - Create a way to disable governance of a cluster, something like the existence of a "nogover" or "admin" file in PGDATA will stop patroni from changing the cluster state.
Configuration
==============
- Provide a way to change postgresql.conf and pg_hba.conf of a running cluster on the Patroni level, without changing individual nodes.
- Provide hooks to store and retrieve cluster-wide passwords without exposing them in a plain-text form to unauthorized users.
- Implement patronictl command to create initial configuration of the cluster with leader and member keys fixed to the user-supplied values in order to simplify migrations.
- Implement support for consul in addtion to etcd and zookeeper
- Complete zookeeper support in patronictl
Documentation
==============
- Document how to run cascading replication and possibly initialize the cluster without an access to the master node.
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
DOCKER_IMAGE="os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT" DOCKER_IMAGE="registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT"
MEMBERS=3 MEMBERS=3
+22 -14
View File
@@ -3,25 +3,25 @@
function usage() function usage()
{ {
cat <<__EOF__ cat <<__EOF__
Usage: $0 Usage: $0
Options: Options:
--etcd ETCD Provide an external etcd to connect to --etcd ETCD Provide an external etcd to connect to
--name NAME Give the cluster a specific name --name NAME Give the cluster a specific name
--etcd-only Do not run Patroni, run a standalone etcd --etcd-only Do not run Patroni, run a standalone etcd
Examples: Examples:
$0 --etcd=127.17.0.84:4001 $0 --etcd=127.17.0.84:4001
$0 --etcd-only $0 --etcd-only
$0 $0
$0 --name=true_scotsman $0 --name=true_scotsman
__EOF__ __EOF__
} }
DOCKER_IP=$(hostname --ip-address) DOCKER_IP=$(hostname --ip-address)
PATRONI_SCOPE=batman PATRONI_SCOPE=${PATRONI_SCOPE:-batman}
optspec=":vh-:" optspec=":vh-:"
while getopts "$optspec" optchar; do while getopts "$optspec" optchar; do
@@ -32,7 +32,7 @@ while getopts "$optspec" optchar; do
exec etcd --data-dir /tmp/etcd.data \ exec etcd --data-dir /tmp/etcd.data \
-advertise-client-urls=http://${DOCKER_IP}:4001 \ -advertise-client-urls=http://${DOCKER_IP}:4001 \
-listen-client-urls=http://0.0.0.0:4001 \ -listen-client-urls=http://0.0.0.0:4001 \
-listen-peer-urls=http://0.0.0.0:2380 -listen-peer-urls=http://0.0.0.0:2380
exit 0 exit 0
;; ;;
cheat) cheat)
@@ -79,20 +79,26 @@ then
ETCD_CLUSTER="127.0.0.1:4001" ETCD_CLUSTER="127.0.0.1:4001"
fi fi
cat > /patroni/postgres.yml <<__EOF__ mkdir -p ~postgres/.config/patroni
cat > ~postgres/.config/patroni/patronictl.yaml <<__EOF__
{dcs_api: 'etcd://${ETCD_CLUSTER}', namespace: /service/}
__EOF__
cat > /patroni/postgres.yaml <<__EOF__
ttl: &ttl 30 ttl: &ttl 30
loop_wait: &loop_wait 10 loop_wait: &loop_wait 10
scope: &scope ${PATRONI_SCOPE} scope: &scope '${PATRONI_SCOPE}'
namespace: 'patroni'
restapi: restapi:
listen: 127.0.0.1:8008 listen: 0.0.0.0:8008
connect_address: 127.0.0.1:8008 connect_address: ${DOCKER_IP}:8008
etcd: etcd:
scope: *scope scope: *scope
ttl: *ttl ttl: *ttl
host: ${ETCD_CLUSTER} host: ${ETCD_CLUSTER}
postgresql: postgresql:
name: postgresql_${DOCKER_IP//./_} ## Replication slots do not allow dots in their name name: ${HOSTNAME}
scope: *scope scope: *scope
listen: 0.0.0.0:5432 listen: 0.0.0.0:5432
connect_address: ${DOCKER_IP}:5432 connect_address: ${DOCKER_IP}:5432
@@ -115,16 +121,18 @@ postgresql:
parameters: parameters:
archive_mode: "on" archive_mode: "on"
wal_level: hot_standby wal_level: hot_standby
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f archive_command: 'true'
max_wal_senders: 20 max_wal_senders: 20
listen_addresses: 0.0.0.0 listen_addresses: 0.0.0.0
wal_keep_segments: 8 max_wal_size: 1GB
min_wal_size: 128MB
wal_keep_segments: 64
archive_timeout: 1800s archive_timeout: 1800s
max_replication_slots: 20 max_replication_slots: 20
hot_standby: "on" hot_standby: "on"
__EOF__ __EOF__
cat /patroni/postgres.yml cat /patroni/postgres.yaml
if [ ! -z $CHEAT ] if [ ! -z $CHEAT ]
then then
@@ -133,5 +141,5 @@ then
sleep 60 sleep 60
done done
else else
exec python /patroni.py /patroni/postgres.yml exec python /patroni.py /patroni/postgres.yaml
fi fi
+3
View File
@@ -8,3 +8,6 @@ Scripts supplied:
### patroni.upstart.conf ### patroni.upstart.conf
Upstart job for Ubuntu 12.04 or 14.04. Requires Upstart > 1.4. Intended for systems where Patroni has been installed on a base system, rather than in Docker. Upstart job for Ubuntu 12.04 or 14.04. Requires Upstart > 1.4. Intended for systems where Patroni has been installed on a base system, rather than in Docker.
### patroni.service
Systemd service file, to be copied to /etc/systemd/system/patroni.service, tested on Centos 7.1 with Patroni installed from pip.
+31
View File
@@ -0,0 +1,31 @@
# This is an example systemd config file for Patroni
# You can copy it to "/etc/systemd/system/patroni.service",
[Unit]
Description=Runners to orchestrate a high-availability PostgreSQL
After=syslog.target network.target
[Service]
Type=simple
User=postgres
Group=postgres
# Where to send early-startup messages from the server
# This is normally controlled by the global default set by systemd
# StandardOutput=syslog
ExecStart=/bin/patroni /etc/patroni.yml
# only kill the patroni process, not it's children, so it will gracefully stop postgres
KillMode=process
# Give a reasonable amount of time for the server to start up/shut down
TimeoutSec=10
# Do not restart the service if it crashes, we want to manually inspect database on failure
Restart=no
[Install]
WantedBy=multi-user.target
+17
View File
@@ -0,0 +1,17 @@
Feature: basic replication
We should check that the basic bootstrapping, replication and failover works.
Scenario: check replication of a single table
Given I start postgres0
And postgres0 is a leader after 10 seconds
And I start postgres1
When I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds
Scenario: check the basic failover
When I kill postgres0
Then postgres1 role is the primary after 32 seconds
When I start postgres0
Then postgres0 role is the secondary after 20 seconds
When I add the table bar to postgres1
Then table bar is present on postgres0 after 20 seconds
+13
View File
@@ -0,0 +1,13 @@
Feature: cascading replication
We should check that patroni can do base backup and streaming from the replica
Scenario: check a base backup and streaming replication from a replica
Given I start postgres0
And postgres0 is a leader after 10 seconds
And I configure and start postgres1 with a tag clonefrom true
And replication works from postgres0 to postgres1 after 20 seconds
And I create label with "postgres0" in postgres0 data directory
And I create label with "postgres1" in postgres1 data directory
And I configure and start postgres2 with a tag replicatefrom postgres1
Then replication works from postgres0 to postgres2 after 30 seconds
And there is a label with "postgres1" in postgres2 data directory
+385
View File
@@ -0,0 +1,385 @@
import abc
import consul
import etcd
import kazoo.client
import kazoo.exceptions
import os
import psycopg2
import shutil
import six
import subprocess
import tempfile
import time
import yaml
@six.add_metaclass(abc.ABCMeta)
class AbstractController(object):
def __init__(self, name, work_directory, output_dir):
self._name = name
self._work_directory = work_directory
self._output_dir = output_dir
self._handle = None
self._log = None
def _has_started(self):
return self._handle and self._handle.pid and self._handle.poll() is None
def _is_running(self):
return self._has_started()
@abc.abstractmethod
def _is_accessible(self):
"""process is accessible for queries"""
@abc.abstractmethod
def _start(self):
"""start process"""
def start(self, max_wait_limit=5):
if self._is_running():
return True
self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
self._handle = self._start()
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
for _ in range(max_wait_limit):
if self._is_accessible():
break
time.sleep(1)
else:
assert False,\
"{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit)
def stop(self, kill=False, timeout=15):
term = False
start_time = time.time()
while self._handle and self._is_running():
if kill:
self._handle.kill()
elif not term:
self._handle.terminate()
term = True
time.sleep(1)
if not kill and time.time() - start_time > timeout:
kill = True
if self._log:
self._log.close()
class PatroniController(AbstractController):
__PORT = 5440
PATRONI_CONFIG = '{}.yml'
""" starts and stops individual patronis"""
def __init__(self, dcs, name, work_directory, output_dir, tags=None):
super(PatroniController, self).__init__('patroni_' + name, work_directory, output_dir)
PatroniController.__PORT += 1
self._data_dir = os.path.join(work_directory, 'data', name)
self._connstring = None
self._config = self._make_patroni_test_config(name, dcs, tags)
self._conn = None
self._curs = None
def write_label(self, content):
with open(os.path.join(self._data_dir, 'label'), 'w') as f:
f.write(content)
def read_label(self):
try:
with open(os.path.join(self._data_dir, 'label'), 'r') as f:
return f.read().strip()
except IOError:
return None
def _start(self):
return subprocess.Popen(['coverage', 'run', '--source=patroni', '-p', 'patroni.py', self._config],
stdout=self._log, stderr=subprocess.STDOUT, cwd=self._work_directory)
def _is_accessible(self):
return self.query("SELECT 1", fail_ok=True) is not None
def _make_patroni_test_config(self, name, dcs, tags):
patroni_config_name = self.PATRONI_CONFIG.format(name)
patroni_config_path = os.path.join(self._output_dir, patroni_config_name)
with open(patroni_config_name) as f:
config = yaml.load(f)
host = config['postgresql']['listen'].split(':')[0]
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
user = config['postgresql'].get('superuser', {})
self._connkwargs = {k: user[n] for n, k in [('username', 'user'), ('password', 'password')] if n in user}
self._connkwargs.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
config['postgresql'].update({'name': name, 'data_dir': self._data_dir})
config['postgresql']['parameters'].update({
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
'log_filename': name + '.log', 'log_statement': 'all', 'log_min_messages': 'debug1'})
if tags:
config['tags'] = tags
if dcs != 'etcd':
dcs_config = config.pop('etcd')
dcs_config.pop('host')
if dcs == 'consul':
config[dcs] = dcs_config
else:
dcs_config.update({'session_timeout': dcs_config.pop('ttl'), 'reconnect_timeout': config['loop_wait']})
if dcs == 'exhibitor':
dcs_config['exhibitor'] = {'hosts': ['127.0.0.1'], 'port': 8181}
else:
dcs_config['hosts'] = ['127.0.0.1:2181']
config['zookeeper'] = dcs_config
with open(patroni_config_path, 'w') as f:
yaml.dump(config, f, default_flow_style=False)
return patroni_config_path
def _connection(self):
if not self._conn or self._conn.closed != 0:
self._conn = psycopg2.connect(**self._connkwargs)
self._conn.autocommit = True
return self._conn
def _cursor(self):
if not self._curs or self._curs.closed or self._curs.connection.closed != 0:
self._curs = self._connection().cursor()
return self._curs
def query(self, query, fail_ok=False):
try:
cursor = self._cursor()
cursor.execute(query)
return cursor
except psycopg2.Error:
if not fail_ok:
raise
def check_role_has_changed_to(self, new_role, timeout=10):
bound_time = time.time() + timeout
recovery_status = new_role != 'primary'
while time.time() < bound_time:
cur = self.query("SELECT pg_is_in_recovery()", fail_ok=True)
if cur:
row = cur.fetchone()
if row and row[0] == recovery_status:
return True
time.sleep(1)
return False
class AbstractDcsController(AbstractController):
_CLUSTER_NODE = 'service/batman'
def _is_accessible(self):
return self._is_running()
def stop_and_remove_work_directory(self, timeout=15):
""" terminate process and wipe out the temp work directory, but only if we actually started it"""
self.stop(timeout=timeout)
if self._work_directory:
shutil.rmtree(self._work_directory)
@abc.abstractmethod
def query(self, key):
""" query for a value of a given key """
@abc.abstractmethod
def cleanup_service_tree(self):
""" clean all contents stored in the tree used for the tests """
class ConsulController(AbstractDcsController):
def __init__(self, output_dir):
super(ConsulController, self).__init__('consul', tempfile.mkdtemp(), output_dir)
self._client = consul.Consul()
def _start(self):
return subprocess.Popen(['consul', 'agent', '-server', '-bootstrap', '-advertise=127.0.0.1',
'-data-dir', self._work_directory], stdout=self._log, stderr=subprocess.STDOUT)
def _is_running(self):
try:
return bool(self._client.status.leader())
except Exception:
return False
def query(self, key):
_, value = self._client.kv.get('{0}/{1}'.format(self._CLUSTER_NODE, key))
return value and value['Value'].decode('utf-8')
def cleanup_service_tree(self):
self._client.kv.delete(self._CLUSTER_NODE, recurse=True)
class EtcdController(AbstractDcsController):
""" handles all etcd related tasks, used for the tests setup and cleanup """
def __init__(self, output_dir):
super(EtcdController, self).__init__('etcd', tempfile.mkdtemp(), output_dir)
self._client = etcd.Client()
def _start(self):
return subprocess.Popen(["etcd", "--debug", "--data-dir", self._work_directory],
stdout=self._log, stderr=subprocess.STDOUT)
def query(self, key):
try:
return self._client.get('/{0}/{1}'.format(self._CLUSTER_NODE, key)).value
except etcd.EtcdKeyNotFound:
return None
def cleanup_service_tree(self):
try:
self._client.delete('/' + self._CLUSTER_NODE, recursive=True)
except (etcd.EtcdKeyNotFound, etcd.EtcdConnectionFailed):
return
except Exception as e:
assert False, "exception when cleaning up etcd contents: {0}".format(e)
def _is_running(self):
# if etcd is running, but we didn't start it
try:
return bool(self._client.machines)
except Exception:
return False
class ZooKeeperController(AbstractDcsController):
""" handles all zookeeper related tasks, used for the tests setup and cleanup """
def __init__(self, output_dir):
super(ZooKeeperController, self).__init__('zookeeper', None, output_dir)
self._client = kazoo.client.KazooClient()
def _start(self):
pass # TODO: implement later
def query(self, key):
try:
return self._client.get('/{0}/{1}'.format(self._CLUSTER_NODE, key))[0].decode('utf-8')
except kazoo.exceptions.NoNodeError:
return None
def cleanup_service_tree(self):
try:
self._client.delete('/' + self._CLUSTER_NODE, recursive=True)
except (kazoo.exceptions.NoNodeError):
return
except Exception as e:
assert False, "exception when cleaning up zookeeper contents: {0}".format(e)
def _is_running(self):
# if zookeeper is running, but we didn't start it
if self._client.connected:
return True
try:
return self._client.start(1) or True
except Exception:
return False
class PatroniPoolController(object):
KNOWN_DCS = {'consul': ConsulController, 'etcd': EtcdController,
'zookeeper': ZooKeeperController, 'exhibitor': ZooKeeperController}
def __init__(self):
self._dcs = None
self._output_dir = None
self._patroni_path = None
self._processes = {}
self.create_and_set_output_directory('')
@property
def patroni_path(self):
if self._patroni_path is None:
cwd = os.path.realpath(__file__)
while True:
cwd, entry = os.path.split(cwd)
if entry == 'features' or cwd == '/':
break
self._patroni_path = cwd
return self._patroni_path
@property
def output_dir(self):
return self._output_dir
def start(self, pg_name, max_wait_limit=20, tags=None):
if pg_name not in self._processes:
self._processes[pg_name] = PatroniController(self.dcs, pg_name, self.patroni_path, self._output_dir, tags)
self._processes[pg_name].start(max_wait_limit)
def __getattr__(self, func):
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to']:
raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
def wrapper(pg_name, *args, **kwargs):
return getattr(self._processes[pg_name], func)(*args, **kwargs)
return wrapper
def stop_all(self):
for ctl in self._processes.values():
ctl.stop()
self._processes.clear()
def create_and_set_output_directory(self, feature_name):
feature_dir = os.path.join(self.patroni_path, 'features/output', feature_name.replace(' ', '_'))
if os.path.exists(feature_dir):
shutil.rmtree(feature_dir)
os.makedirs(feature_dir)
self._output_dir = feature_dir
@property
def dcs(self):
if self._dcs is None:
self._dcs = os.environ.get('DCS', 'etcd')
assert self._dcs in self.KNOWN_DCS, 'Unsupported dcs: ' + self.dcs
return self._dcs
# actions to execute on start/stop of the tests and before running invidual features
def before_all(context):
context.pctl = PatroniPoolController()
context.dcs_ctl = context.pctl.KNOWN_DCS[context.pctl.dcs](context.pctl.output_dir)
context.dcs_ctl.start()
try:
context.dcs_ctl.cleanup_service_tree()
except AssertionError: # after_all handlers won't be executed in before_all
context.dcs_ctl.stop_and_remove_work_directory()
raise
def after_all(context):
context.dcs_ctl.stop_and_remove_work_directory()
subprocess.call(['coverage', 'combine'])
subprocess.call(['coverage', 'report'])
def before_feature(context, feature):
""" create per-feature output directory to collect Patroni and PostgreSQL logs """
context.pctl.create_and_set_output_directory(feature.name)
def after_feature(context, feature):
""" stop all Patronis, remove their data directory and cleanup the keys in etcd """
context.pctl.stop_all()
shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data'))
context.dcs_ctl.cleanup_service_tree()
+53
View File
@@ -0,0 +1,53 @@
Feature: patroni api
We should check that patroni correctly responds to valid and not-valid API requests.
Scenario: check API requests on a stand-alone server
Given I start postgres0
And postgres0 is a leader after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200
And I receive a response state running
And I receive a response role master
When I issue a GET request to http://127.0.0.1:8008/replica
Then I receive a response code 503
When I issue an empty POST request to http://127.0.0.1:8008/reinitialize
Then I receive a response code 503
And I receive a response text "I am the leader, can not reinitialize"
When I issue a POST request to http://127.0.0.1:8008/failover with leader=postgres0
Then I receive a response code 500
And I receive a response text "failover is not possible: cluster does not have members except leader"
When I issue an empty POST request to http://127.0.0.1:8008/failover
Then I receive a response code 400
And I receive a response text "No values given for required parameters leader and candidate"
Scenario: check API requests for the primary-replica pair
Given I start postgres1
And replication works from postgres0 to postgres1 after 20 seconds
When I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 200
And I receive a response state running
And I receive a response role replica
When I issue an empty POST request to http://127.0.0.1:8009/reinitialize
Then I receive a response code 200
When I issue an empty POST request to http://127.0.0.1:8008/restart
Then I receive a response code 200
And postgres0 role is the primary after 5 seconds
When I sleep for 10 seconds
Then postgres1 role is the secondary after 15 seconds
Scenario: check the failover via the API
Given I issue a POST request to http://127.0.0.1:8008/failover with leader=postgres0,candidate=postgres1
Then I receive a response code 200
And postgres1 is a leader after 5 seconds
And postgres1 role is the primary after 5 seconds
And postgres0 role is the secondary after 10 seconds
And replication works from postgres1 to postgres0 after 20 seconds
Scenario: check the scheduled failover
Given I issue a scheduled failover at http://127.0.0.1:8009 from postgres1 to postgres0 in 1 seconds
Then I receive a response code 200
And postgres0 is a leader after 20 seconds
And postgres0 role is the primary after 5 seconds
And postgres1 role is the secondary after 10 seconds
And replication works from postgres0 to postgres1 after 25 seconds
+54
View File
@@ -0,0 +1,54 @@
import psycopg2 as pg
from behave import step, then
from time import sleep, time
@step('I start {name:w}')
def start_patroni(context, name):
return context.pctl.start(name)
@step('I shut down {name:w}')
def stop_patroni(context, name):
return context.pctl.stop(name)
@step('I kill {name:w}')
def kill_patroni(context, name):
return context.pctl.stop(name, kill=True)
@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 {0}()".format(table_name))
except pg.Error as e:
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
@then('Table {table_name:w} is present on {pg_name:w} after {max_replication_delay:d} seconds')
def table_is_present_on(context, table_name, pg_name, max_replication_delay):
for _ in range(int(max_replication_delay)):
if context.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None:
break
sleep(1)
else:
assert False,\
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
@then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds')
def check_role(context, pg_name, pg_role, max_promotion_timeout):
assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)),\
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
@step('replication works from {master:w} to {replica:w} after {time_limit:d} seconds')
@then('replication works from {master:w} to {replica:w} after {time_limit:d} seconds')
def replication_works(context, master, replica, time_limit):
context.execute_steps(u"""
When I add the table test_{0} to {1}
Then table test_{0} is present on {2} after {3} seconds
""".format(int(time()), master, replica, time_limit))
+17
View File
@@ -0,0 +1,17 @@
from behave import step, then
@step('I configure and start {name:w} with a tag {tag_name:w} {tag_value:w}')
def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value):
return context.pctl.start(name, tags={tag_name: tag_value})
@then('There is a label with "{content:w}" in {name:w} data directory')
def check_label(context, content, name):
label = context.pctl.read_label(name)
assert label == content, "{0} is not equal to {1}".format(label, content)
@step('I create label with "{content:w}" in {name:w} data directory')
def write_label(context, content, name):
context.pctl.write_label(name, content)
+100
View File
@@ -0,0 +1,100 @@
import parse
import pytz
import requests
import time
from behave import register_type, step, then
from datetime import datetime, timedelta
@parse.with_pattern(r'https?://(?:\w|\.|:|/)+')
def parse_url(text):
return text
@parse.with_pattern(r'(?:\w+=(?:\w|\.|:|-|\+|\s)+,?)+')
def parse_data(text):
return text
register_type(url=parse_url, data=parse_data)
# there is no way we can find out if the node has already
# started as a leader without checking the DCS. We cannot
# just rely on the database availability, since there is
# a short gap between the time PostgreSQL becomes available
# and Patroni assuming the leader role.
@step('{name:w} is a leader after {time_limit:d} seconds')
@then('{name:w} is a leader after {time_limit:d} seconds')
def is_a_leader(context, name, time_limit):
max_time = time.time() + int(time_limit)
while (context.dcs_ctl.query("leader") != name):
time.sleep(1)
assert time.time() < max_time, "{0} is not a leader in dcs after {1} seconds".format(name, time_limit)
@step('I sleep for {value:d} seconds')
def sleep_for_n_seconds(context, value):
time.sleep(int(value))
@step('I issue a GET request to {url:url}')
def do_get(context, url):
try:
r = requests.get(url)
except requests.exceptions.RequestException:
context.status_code = None
context.response = None
else:
context.status_code = r.status_code
try:
context.response = r.json()
except ValueError:
context.response = r.content.decode('utf-8')
@step('I issue an empty POST request to {url:url}')
def do_post_empty(context, url):
do_post(context, url, None)
@step('I issue a POST request to {url:url} with {data:data}')
def do_post(context, url, data):
post_data = {}
if data:
post_components = data.split(',')
for pc in post_components:
if '=' in pc:
k, v = pc.split('=', 2)
post_data[k.strip()] = v.strip()
try:
r = requests.post(url, json=post_data)
except requests.exceptions.RequestException:
context.status_code = None
context.response = None
else:
context.status_code = r.status_code
try:
context.response = r.json()
except ValueError:
context.response = r.content.decode('utf-8')
@then('I receive a response {component:w} {data}')
def check_response(context, component, data):
if component == 'code':
assert context.status_code == int(data),\
"status code {0} != {1}, response: {2}".format(context.status_code, int(data), context.response)
elif component == 'text':
assert context.response == data.strip('"'), "response {0} does not contain {1}".format(context.response, data)
else:
assert component in context.response, "{0} is not part of the response".format(component)
assert context.response[component] == data, "{0} does not contain {1}".format(component, data)
@step('I issue a scheduled failover at {at_url:url} from {from_host:w} to {to_host:w} in {in_seconds:d} seconds')
def scheduled_failover(context, at_url, from_host, to_host, in_seconds):
context.execute_steps(u"""
Given I issue a POST request to {0}/failover with leader={1},candidate={2},scheduled_at={3}
""".format(at_url, from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds))))
+3 -3
View File
@@ -15,7 +15,7 @@ frontend ft_postgresql
default_backend bk_db default_backend bk_db
backend bk_db backend bk_db
option httpchk GET option httpchk
server postgresql_127.0.0.1_5432 127.0.0.1:5432 maxconn 100 check port 8008 server postgresql_127.0.0.1_5432 127.0.0.1:5432 maxconn 100 check port 8008
server postgresql_127.0.0.1_5433 127.0.0.1:5433 maxconn 100 check port 8009 server postgresql_127.0.0.1_5433 127.0.0.1:5433 maxconn 100 check port 8009
+44 -13
View File
@@ -5,33 +5,53 @@ import time
import yaml import yaml
from patroni.api import RestApiServer from patroni.api import RestApiServer
from patroni.etcd import Etcd from patroni.exceptions import PatroniException
from patroni.ha import Ha from patroni.ha import Ha
from patroni.postgresql import Postgresql from patroni.postgresql import Postgresql
from patroni.utils import setup_signal_handlers, reap_children from patroni.utils import reap_children, set_ignore_sigterm, setup_signal_handlers
from patroni.zookeeper import ZooKeeper from patroni.version import __version__
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Patroni: class Patroni(object):
PATRONI_CONFIG_VARIABLE = 'PATRONI_CONFIGURATION'
def __init__(self, config): def __init__(self, config):
self.nap_time = config['loop_wait'] self.nap_time = config['loop_wait']
self.tags = {tag: value for tag, value in config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance') or value}
self.postgresql = Postgresql(config['postgresql']) self.postgresql = Postgresql(config['postgresql'])
self.dcs = self.get_dcs(self.postgresql.name, config) self.dcs = self.get_dcs(self.postgresql.name, config)
host, port = config['restapi']['listen'].split(':') self.version = __version__
self.api = RestApiServer(self, config['restapi']) self.api = RestApiServer(self, config['restapi'])
self.ha = Ha(self) self.ha = Ha(self)
self.next_run = time.time() self.next_run = time.time()
@property
def noloadbalance(self):
return self.tags.get('noloadbalance', False)
@property
def nofailover(self):
return self.tags.get('nofailover', False)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@staticmethod @staticmethod
def get_dcs(name, config): def get_dcs(name, config):
if 'etcd' in config: if 'etcd' in config:
from patroni.etcd import Etcd
return Etcd(name, config['etcd']) return Etcd(name, config['etcd'])
if 'zookeeper' in config: if 'zookeeper' in config:
from patroni.zookeeper import ZooKeeper
return ZooKeeper(name, config['zookeeper']) return ZooKeeper(name, config['zookeeper'])
raise Exception('Can not find sutable configuration of distributed configuration store') if 'consul' in config:
from patroni.consul import Consul
return Consul(name, config['consul'])
raise PatroniException('Can not find suitable configuration of distributed configuration store')
def schedule_next_run(self): def schedule_next_run(self):
self.next_run += self.nap_time self.next_run += self.nap_time
@@ -57,19 +77,30 @@ def main():
logging.getLogger('requests').setLevel(logging.WARNING) logging.getLogger('requests').setLevel(logging.WARNING)
setup_signal_handlers() setup_signal_handlers()
if len(sys.argv) < 2 or not os.path.isfile(sys.argv[1]): # Patroni reads the configuration from the command-line argument if it exists, and from the environment otherwise.
print('Usage: {} config.yml'.format(sys.argv[0])) use_env = False
return use_file = (len(sys.argv) >= 2 and os.path.isfile(sys.argv[1]))
if not use_file:
config_env = os.environ.get(Patroni.PATRONI_CONFIG_VARIABLE)
use_env = config_env is not None
if not use_env:
print('Usage: {0} config.yml'.format(sys.argv[0]))
print('\tPatroni may also read the configuration from the {} environment variable'.
format(Patroni.PATRONI_CONFIG_VARIABLE))
return
with open(sys.argv[1], 'r') as f: if use_file:
config = yaml.load(f) with open(sys.argv[1], 'r') as f:
config = yaml.load(f)
elif use_env:
config = yaml.load(config_env)
patroni = Patroni(config) patroni = Patroni(config)
try: try:
patroni.run() patroni.run()
except KeyboardInterrupt: except KeyboardInterrupt:
pass set_ignore_sigterm()
finally: finally:
patroni.api.shutdown() patroni.api.shutdown()
patroni.postgresql.stop() patroni.postgresql.stop(checkpoint=False)
patroni.dcs.delete_leader() patroni.dcs.delete_leader()
+156 -41
View File
@@ -3,6 +3,11 @@ import fcntl
import json import json
import logging import logging
import psycopg2 import psycopg2
import socket
import time
import dateutil
import datetime
import pytz
from patroni.exceptions import PostgresConnectionException from patroni.exceptions import PostgresConnectionException
from patroni.utils import Retry, RetryFailedError from patroni.utils import Retry, RetryFailedError
@@ -29,19 +34,47 @@ def check_auth(func):
class RestApiHandler(BaseHTTPRequestHandler): class RestApiHandler(BaseHTTPRequestHandler):
def _write_response(self, status_code, body, headers=None):
self.send_response(status_code)
if body is not None:
headers = headers or {}
if 'Content-Type' not in headers:
headers['Content-Type'] = 'text/html'
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body.encode('utf-8'))
def send_auth_request(self, body): def send_auth_request(self, body):
self.send_response(401) self._write_response(401, body, {'WWW-Authenticate': 'Basic realm=\"Patroni\"'})
self.send_header('WWW-Authenticate', 'Basic realm=\"Patroni\"')
self.send_header('Content-type', 'text/html') def finish(self, *args, **kwargs):
self.end_headers() try:
self.wfile.write(body.encode('utf-8')) if not self.wfile.closed:
self.wfile.flush()
self.wfile.close()
except socket.error:
pass
self.rfile.close()
def check_auth_header(self): def check_auth_header(self):
auth_header = self.headers.get('Authorization') auth_header = self.headers.get('Authorization')
status = self.server.check_auth_header(auth_header) status = self.server.check_auth_header(auth_header)
return not status or self.send_auth_request(status) return not status or self.send_auth_request(status)
def do_GET(self): def _write_status_response(self, status_code, response, options=False):
if options:
body = None
else:
patroni = self.server.patroni
response.update({'tags': patroni.tags} if patroni.tags else {})
if patroni.postgresql.sysid:
response['database_system_identifier'] = patroni.postgresql.sysid
response['patroni'] = {'version': patroni.version, 'scope': patroni.postgresql.scope}
body = json.dumps(response)
self._write_response(status_code, body, {'Content-Type': 'application/json'})
def do_GET(self, options=False):
"""Default method for processing all GET requests which can not be routed to other methods""" """Default method for processing all GET requests which can not be routed to other methods"""
path = '/master' if self.path == '/' else self.path path = '/master' if self.path == '/' else self.path
@@ -56,46 +89,36 @@ class RestApiHandler(BaseHTTPRequestHandler):
status_code = 503 status_code = 503
elif response['role'] == 'master': # running as master but without leader lock!!!! elif response['role'] == 'master': # running as master but without leader lock!!!!
status_code = 503 status_code = 503
elif response['role'] in path: elif response['role'] in path: # response['role'] != 'master'
status_code = 200 status_code = 503 if patroni.noloadbalance else 200
else: else:
status_code = 503 status_code = 503
elif 'role' in response and response['role'] in path: elif 'role' in response and response['role'] in path:
status_code = 200 status_code = 503 if response['role'] != 'master' and patroni.noloadbalance else 200
elif patroni.ha.restart_scheduled() and patroni.postgresql.role == 'master' and 'master' in path: elif patroni.ha.restart_scheduled() and patroni.postgresql.role == 'master' and 'master' in path:
# exceptional case for master node when the postgres is being restarted via API # exceptional case for master node when the postgres is being restarted via API
status_code = 200 status_code = 200
else: else:
status_code = 503 status_code = 503
self._write_status_response(status_code, response, options)
self.send_response(status_code) def do_OPTIONS(self):
self.send_header('Content-Type', 'application/json') self.do_GET(options=True)
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
def do_GET_patroni(self): def do_GET_patroni(self):
response = self.get_postgresql_status(True) response = self.get_postgresql_status(True)
self._write_status_response(200, response)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
@check_auth @check_auth
def do_POST_restart(self): def do_POST_restart(self):
status_code = 503 status_code = 500
data = b'restart failed' data = 'restart failed'
try: try:
status, msg = self.server.patroni.ha.restart() status, data = self.server.patroni.ha.restart()
status_code = 200 if status else 503 status_code = 200 if status else 503
data = msg.encode('utf-8') except Exception:
except:
logger.exception('Exception during restart') logger.exception('Exception during restart')
self._write_response(status_code, data)
self.send_response(status_code)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(data)
@check_auth @check_auth
def do_POST_reinitialize(self): def do_POST_reinitialize(self):
@@ -103,23 +126,103 @@ class RestApiHandler(BaseHTTPRequestHandler):
cluster = ha.dcs.get_cluster() cluster = ha.dcs.get_cluster()
if cluster.is_unlocked(): if cluster.is_unlocked():
status_code = 503 status_code = 503
data = b'Cluster has no leader, can not reinitialize' data = 'Cluster has no leader, can not reinitialize'
elif cluster.leader.name == ha.state_handler.name: elif cluster.leader.name == ha.state_handler.name:
status_code = 503 status_code = 503
data = b'I am the leader, can not reinitialize' data = 'I am the leader, can not reinitialize'
else: else:
action = ha.schedule_reinitialize() action = ha.schedule_reinitialize()
if action is not None: if action is not None:
status_code = 503 status_code = 503
data = (action + ' already in progress').encode('utf-8') data = action + ' already in progress'
else: else:
status_code = 200 status_code = 200
data = b'reinitialize scheduled' data = 'reinitialize scheduled'
self._write_response(status_code, data)
self.send_response(status_code) def poll_failover_result(self, leader, candidate):
self.send_header('Content-Type', 'text/html') for _ in range(0, 15):
self.end_headers() time.sleep(1)
self.wfile.write(data) try:
cluster = self.server.patroni.dcs.get_cluster()
if cluster.leader and cluster.leader.name != leader:
if not candidate or candidate == cluster.leader.name:
return 200, 'Successfully failed over to "{0}"'.format(cluster.leader.name)
else:
return 200, 'Failed over to "{0}" instead of "{1}"'.format(cluster.leader.name, candidate)
if not cluster.failover:
return 503, 'Failover failed'
except Exception as e:
logger.debug('Exception occured during polling failover result: %s', e)
return 503, 'Failover status unknown'
def is_failover_possible(self, cluster, leader, candidate):
if leader and not cluster.leader or cluster.leader.name != leader:
return 'leader name does not match'
if candidate:
members = [m for m in cluster.members if m.name == candidate]
if not members:
return 'candidate does not exists'
else:
members = [m for m in cluster.members if m.name != cluster.leader.name and m.api_url]
if not members:
return 'failover is not possible: cluster does not have members except leader'
for _, reachable, _, _, tags in self.server.patroni.ha.fetch_nodes_statuses(members):
if reachable and not tags.get('nofailover', False):
return None
return 'failover is not possible: no good candidates have been found'
@check_auth
def do_POST_failover(self):
content_length = int(self.headers.get('content-length', 0))
try:
request = json.loads(self.rfile.read(content_length).decode('utf-8'))
except ValueError:
request = {}
leader = request.get('leader')
candidate = request.get('candidate') or request.get('member')
scheduled_at = request.get('scheduled_at')
cluster = self.server.patroni.ha.dcs.get_cluster()
status_code = 500
logger.info("received failover request with leader=%s candidate=%s scheduled_at=%s",
leader, candidate, scheduled_at)
data = ''
if leader or candidate:
if scheduled_at:
try:
scheduled_at = dateutil.parser.parse(scheduled_at)
if scheduled_at.tzinfo is None:
data = 'Timezone information is mandatory for scheduled_at'
status_code = 400
elif scheduled_at < datetime.datetime.now(pytz.utc):
data = 'Cannot schedule failover in the past'
status_code = 422
elif self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
self.server.patroni.dcs.event.set()
data = 'Failover scheduled'
status_code = 200
else:
data = 'failed to write failover key into DCS'
status_code = 503
except (ValueError, TypeError):
logger.exception('Invalid scheduled failover time: %s', request['scheduled_at'])
data = 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601'
status_code = 422
else:
data = self.is_failover_possible(cluster, leader, candidate)
if not data:
if self.server.patroni.dcs.manual_failover(leader, candidate):
self.server.patroni.dcs.event.set()
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name, candidate)
else:
data = 'failed to write failover key into DCS'
status_code = 503
else:
status_code = 400
data = 'No values given for required parameters leader and candidate'
self._write_response(status_code, data)
def parse_request(self): def parse_request(self):
"""Override parse_request method to enrich basic functionality of `BaseHTTPRequestHandler` class """Override parse_request method to enrich basic functionality of `BaseHTTPRequestHandler` class
@@ -139,6 +242,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.command = mname self.command = mname
return ret return ret
def handle_one_request(self):
try:
BaseHTTPRequestHandler.handle_one_request(self)
except socket.error:
pass
def query(self, sql, *params, **kwargs): def query(self, sql, *params, **kwargs):
if not kwargs.get('retry', False): if not kwargs.get('retry', False):
return self.server.query(sql, *params) return self.server.query(sql, *params)
@@ -155,25 +264,31 @@ class RestApiHandler(BaseHTTPRequestHandler):
END, END,
pg_xlog_location_diff(pg_last_xlog_receive_location(), '0/0')::bigint, pg_xlog_location_diff(pg_last_xlog_receive_location(), '0/0')::bigint,
pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')::bigint, pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')::bigint,
to_char(pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
pg_is_in_recovery() AND pg_is_xlog_replay_paused()""", retry=retry)[0] pg_is_in_recovery() AND pg_is_xlog_replay_paused()""", retry=retry)[0]
return { return {
'state': self.server.patroni.postgresql.state, 'state': self.server.patroni.postgresql.state,
'postmaster_start_time': row[0], 'postmaster_start_time': row[0],
'role': 'replica' if row[1] else 'master', 'role': 'replica' if row[1] else 'master',
'server_version': self.server.patroni.postgresql.server_version,
'xlog': ({ 'xlog': ({
'received_location': row[3], 'received_location': row[3],
'replayed_location': row[4], 'replayed_location': row[4],
'paused': row[5]} if row[1] else { 'replayed_timestamp': row[5],
'paused': row[6]} if row[1] else {
'location': row[2] 'location': row[2]
}) })
} }
except (psycopg2.Error, RetryFailedError, PostgresConnectionException): except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
state = self.server.patroni.postgresql.state state = self.server.patroni.postgresql.state
if state in ['stopped', 'starting', 'stopping', 'restarting', 'running']: if state == 'running':
logger.exception('get_postgresql_status') logger.exception('get_postgresql_status')
state = 'unknown' if state == 'running' else state state = 'unknown'
return {'state': state} return {'state': state}
def log_message(self, fmt, *args):
logger.debug("API thread: %s - - [%s] %s", self.client_address[0], self.log_date_time_string(), fmt % args)
class RestApiServer(ThreadingMixIn, HTTPServer, Thread): class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
@@ -189,12 +304,12 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
# wrap socket with ssl if 'certfile' is defined in a config.yaml # wrap socket with ssl if 'certfile' is defined in a config.yaml
# Sometime it's also needed to pass reference to a 'keyfile'. # Sometime it's also needed to pass reference to a 'keyfile'.
options = {option: config[option] for option in ['certfile', 'keyfile'] if option in config} options = {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
if options.get('certfile', None): if options.get('certfile'):
import ssl import ssl
self.socket = ssl.wrap_socket(self.socket, server_side=True, **options) self.socket = ssl.wrap_socket(self.socket, server_side=True, **options)
protocol = 'https' protocol = 'https'
self.connection_string = '{}://{}/patroni'.format(protocol, config.get('connect_address', config['listen'])) self.connection_string = '{0}://{1}/patroni'.format(protocol, config.get('connect_address', config['listen']))
self.patroni = patroni self.patroni = patroni
self.daemon = True self.daemon = True
+2 -3
View File
@@ -4,10 +4,9 @@ from threading import Lock, Thread
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AsyncExecutor: class AsyncExecutor(object):
def __init__(self): def __init__(self):
Lock.__init__(self)
self._busy = False self._busy = False
self._thread_lock = Lock() self._thread_lock = Lock()
self._scheduled_action = None self._scheduled_action = None
@@ -51,5 +50,5 @@ class AsyncExecutor:
def __enter__(self): def __enter__(self):
self._thread_lock.acquire() self._thread_lock.acquire()
def __exit__(self, type, value, traceback): def __exit__(self, *args):
self._thread_lock.release() self._thread_lock.release()
+244
View File
@@ -0,0 +1,244 @@
from __future__ import absolute_import
import logging
import os
import time
import six
from consul import ConsulException, NotFound, base, std
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import sleep
from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
class ConsulError(DCSError):
pass
class HTTPClient(std.HTTPClient):
def __init__(self, *args, **kwargs):
super(HTTPClient, self).__init__(*args, **kwargs)
self._patch_default_timeout()
def _patch_default_timeout(self):
# Set a default timeout for the `request.session.request` method, that is used
# internally by the methods request.session.get, request.session.post and
# others. We monkey-patch here to avoid reimplementing each individual method from
# `std.HTTPClient`. By default, the timeout is not set. It means that a new
# session may hang almost indefinitely waiting for the server to respond,
# which is not what we want in Patroni.
request_func = getattr(self.session.request, '__func__' if six.PY3 else 'im_func')
defaults_attr_name = '__defaults__' if six.PY3 else 'func_defaults'
defaults = list(getattr(request_func, defaults_attr_name))
code = request_func.__code__ if six.PY3 else request_func.func_code
defaults[code.co_varnames[code.co_argcount - len(defaults):code.co_argcount].index('timeout')] = 5
setattr(request_func, defaults_attr_name, tuple(defaults)) # monkeypatching
def get(self, callback, path, params=None):
# The get function is overridden to handle a special case of it being called
# with an index and wait parameters. That form indicates that a user needs to
# wait for the given key to change its value, with a wait timeout supplied. We
# don't want our monkey-patched timeout to be less than the value of the wait
# parameter, therefore, we set it to either the value of wait or a default of 5 minutes.
if isinstance(params, dict) and 'index' in params:
timeout = (float(params['wait'][:-1]) if 'wait' in params else 300) + 1
else:
timeout = None
return callback(self.response(self.session.get(self.uri(path, params), verify=self.verify, timeout=timeout)))
class ConsulClient(base.Consul):
@staticmethod
def connect(host, port, scheme, verify=True):
return HTTPClient(host, port, scheme, verify)
def catch_consul_errors(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (ConsulException, RequestException):
return False
return wrapper
class Consul(AbstractDCS):
def __init__(self, name, config):
super(Consul, self).__init__(name, config)
self.ttl = int((config.get('ttl') or 30)/2) # My experiments have shown that session expires after 2*ttl time
host, port = config.get('host', '127.0.0.1:8500').split(':')
self._client = ConsulClient(host=host, port=port)
self._scope = config['scope']
self._session = None
self._my_member_data = None
self.create_or_restore_session()
def create_or_restore_session(self):
while not self._session:
try:
_, member = self._client.kv.get(self.member_path)
self._session = (member or {}).get('Session')
if self.refresh_session():
self._client.kv.delete(self.member_path)
except (ConsulException, RequestException):
logger.info('waiting on consul')
sleep(5)
def refresh_session(self):
""":returns: `!True` if it had to create new session"""
if self._session:
try:
return self._client.session.renew(self._session) is None
except NotFound:
self._session = None
if not self._session:
name = self._scope + '-' + self._name
try:
self._session = self._client.session.create(name=name, lock_delay=0, behavior='delete', ttl=self.ttl)
except (ConsulException, RequestException):
logger.exception('session.create')
if not self._session:
raise ConsulError('Failed to renew/create session')
return True
def client_path(self, path):
return super(Consul, self).client_path(path)[1:]
@staticmethod
def member(node):
return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value'])
def _load_cluster(self):
try:
path = self.client_path('/')
_, results = self._client.kv.get(path, recurse=True)
if results is None:
raise NotFound
nodes = {}
for node in results:
node['Value'] = (node['Value'] or b'').decode('utf-8')
nodes[os.path.relpath(node['Key'], path)] = node
# get initialize flag
initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['Value']
# get last leader operation
last_leader_operation = nodes.get(self._LEADER_OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['Value'])
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader
leader = nodes.get(self._LEADER)
if leader and leader['Value'] == self._name and self._session != leader.get('Session', 'x'):
logger.info('I am leader but not owner of the session. Removing leader node')
self._client.kv.delete(self.leader_path, cas=leader['ModifyIndex'])
leader = None
if leader:
member = Member(-1, leader['Value'], None, {})
member = ([m for m in members if m.name == leader['Value']] or [member])[0]
leader = Leader(leader['ModifyIndex'], leader.get('Session'), member)
# failover key
failover = nodes.get(self._FAILOVER)
if failover:
failover = Failover.from_node(failover['ModifyIndex'], failover['Value'])
self._cluster = Cluster(initialize, leader, last_leader_operation, members, failover)
except NotFound:
self._cluster = Cluster(False, None, None, [], None)
except:
logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly')
def touch_member(self, data, **kwargs):
create_member = self.refresh_session()
cluster = self.cluster
member = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
if create_member and member:
try:
self._client.kv.delete(self.member_path)
except Exception:
return False
if not create_member and member and data == self._my_member_data:
return True
try:
self._client.kv.put(self.member_path, data, acquire=self._session)
self._my_member_data = data
return True
except Exception:
logger.exception('touch_member')
return False
@catch_consul_errors
def attempt_to_acquire_leader(self):
ret = self._client.kv.put(self.leader_path, self._name, acquire=self._session)
if not ret:
logger.info('Could not take out TTL lock')
return ret
def take_leader(self):
return self.attempt_to_acquire_leader()
@catch_consul_errors
def set_failover_value(self, value, index=None):
return self._client.kv.put(self.failover_path, value, cas=index)
@catch_consul_errors
def write_leader_optime(self, last_operation):
return self._client.kv.put(self.leader_optime_path, last_operation)
@staticmethod
def update_leader():
return True
@catch_consul_errors
def initialize(self, create_new=True, sysid=''):
kwargs = {'cas': 0} if create_new else {}
return self._client.kv.put(self.initialize_path, sysid, **kwargs)
@catch_consul_errors
def cancel_initialization(self):
return self._client.kv.delete(self.initialize_path)
@catch_consul_errors
def delete_cluster(self):
return self._client.kv.delete(self.client_path(''), recurse=True)
@catch_consul_errors
def delete_leader(self):
cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
return self._client.kv.delete(self.leader_path, cas=cluster.leader.index)
def watch(self, timeout):
cluster = self.cluster
if cluster and cluster.leader and cluster.leader.name != self._name and cluster.leader.index:
end_time = time.time() + timeout
while timeout >= 1:
try:
idx, _ = self._client.kv.get(self.leader_path, index=cluster.leader.index, wait=str(timeout) + 's')
return str(idx) != str(cluster.leader.index)
except (ConsulException, RequestException):
logging.exception('watch')
timeout = end_time - time.time()
try:
return super(Consul, self).watch(timeout)
finally:
self.event.clear()
+633
View File
@@ -0,0 +1,633 @@
'''
Patroni Control
'''
import click
import datetime
import dateutil
import json
import logging
import os
import psycopg2
import random
import requests
import time
import tzlocal
import yaml
from click import ClickException
from patroni import Patroni, PatroniException
from patroni.postgresql import parseurl
from prettytable import PrettyTable
from six.moves.urllib_parse import urlparse
CONFIG_DIR_PATH = click.get_app_dir('patroni')
CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'patronictl.yaml')
LOGLEVEL = 'WARNING'
DCS_DEFAULTS = {'zookeeper': {'port': 2181, 'template': "zookeeper:\n hosts: ['{host}:{port}']"},
'exhibitor': {'port': 8181, 'template': "zookeeper:\n exhibitor:\n hosts: [{host}]\n port: {port}"},
'consul': {'port': 8500, 'template': "consul:\n host: '{host}:{port}'"},
'etcd': {'port': 4001, 'template': "etcd:\n host: '{host}:{port}'"}}
class PatroniCtlException(ClickException):
pass
def parse_dcs(dcs):
if dcs is None:
return None
parsed = urlparse(dcs)
scheme = parsed.scheme
if scheme == '' and parsed.netloc == '':
parsed = urlparse('//' + dcs)
port = int(parsed.port) if parsed.port else None
if scheme == '':
scheme = ([k for k, v in DCS_DEFAULTS.items() if v['port'] == port] or ['etcd'])[0]
elif scheme not in DCS_DEFAULTS:
raise PatroniCtlException('Unknown dcs scheme: {}'.format(scheme))
dcs_info = DCS_DEFAULTS[scheme]
return yaml.load(dcs_info['template'].format(host=parsed.hostname or 'localhost', port=port or dcs_info['port']))
def load_config(path, dcs):
logging.debug('Loading configuration from file %s', path)
config = dict()
try:
with open(path, 'rb') as fd:
config = yaml.safe_load(fd)
except (IOError, yaml.YAMLError):
logging.exception('Could not load configuration file')
config.update(parse_dcs(dcs) or parse_dcs(config.get('dcs_api')) or {})
return config
def store_config(config, path):
dir_path = os.path.dirname(path)
if dir_path and not os.path.isdir(dir_path):
os.makedirs(dir_path)
with open(path, 'w') as fd:
yaml.dump(config, fd)
option_config_file = click.option('--config-file', '-c', help='Configuration file', default=CONFIG_FILE_PATH)
option_format = click.option('--format', '-f', 'fmt', help='Output format (pretty, json)', default='pretty')
option_dcs = click.option('--dcs', '-d', help='Use this DCS', envvar='DCS')
option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds')
option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds')
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
@click.group()
@click.pass_context
def ctl(ctx):
global LOGLEVEL
LOGLEVEL = os.environ.get('LOGLEVEL', LOGLEVEL)
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=LOGLEVEL)
def get_dcs(config, scope):
for k in set(DCS_DEFAULTS.keys()) & set(config.keys()):
config[k].setdefault('scope', scope)
try:
return Patroni.get_dcs(scope, config)
except PatroniException as e:
raise PatroniCtlException(str(e))
def post_patroni(member, endpoint, content, headers=None):
url = urlparse(member.api_url)
logging.debug(url)
return requests.post('{0}://{1}/{2}'.format(url.scheme, url.netloc, endpoint),
headers=headers or {'Content-Type': 'application/json'},
data=json.dumps(content), timeout=60)
def print_output(columns, rows=None, alignment=None, fmt='pretty', header=True, delimiter='\t'):
rows = rows or []
if fmt == 'pretty':
t = PrettyTable(columns)
for k, v in (alignment or {}).items():
t.align[k] = v
for r in rows:
t.add_row(r)
click.echo(t)
return
if fmt == 'json':
elements = list()
for r in rows:
elements.append(dict(zip(columns, r)))
click.echo(json.dumps(elements))
if fmt == 'tsv':
if columns is not None and header:
click.echo(delimiter.join(columns) + '\n')
for r in rows:
c = [str(c) for c in r]
click.echo(delimiter.join(c))
def watching(w, watch, max_count=None, clear=True):
"""
>>> len(list(watching(True, 1, 0)))
1
>>> len(list(watching(True, 1, 1)))
2
>>> len(list(watching(True, None, 0)))
1
"""
if w and not watch:
watch = 2
if watch and clear:
click.clear()
yield 0
if max_count is not None and max_count < 1:
return
counter = 1
while watch and counter <= (max_count or counter):
time.sleep(watch)
counter += 1
if clear:
click.clear()
yield 0
def build_connect_parameters(conn_url, connect_parameters=None):
params = (connect_parameters or {}).copy()
parsed = parseurl(conn_url)
params['host'] = parsed['host']
params['port'] = parsed['port']
params['fallback_application_name'] = 'Patroni ctl'
params['connect_timeout'] = '5'
return params
def get_all_members(cluster, role='master'):
if role == 'master':
if cluster.leader is not None:
yield cluster.leader
return
leader_name = (cluster.leader.member.name if cluster.leader else None)
for m in cluster.members:
if role == 'any' or role == 'replica' and m.name != leader_name:
yield m
def get_any_member(cluster, role='master', member=None):
members = get_all_members(cluster, role)
for m in members:
if member is None or m.name == member:
return m
def get_cursor(cluster, role='master', member=None, connect_parameters=None):
member = get_any_member(cluster, role=role, member=member)
if member is None:
return None
params = build_connect_parameters(member.conn_url, connect_parameters)
conn = psycopg2.connect(**params)
conn.autocommit = True
cursor = conn.cursor()
if role == 'any':
return cursor
cursor.execute('SELECT pg_is_in_recovery()')
in_recovery = cursor.fetchone()[0]
if in_recovery and role == 'replica' or not in_recovery and role == 'master':
return cursor
conn.close()
return None
@ctl.command('dsn', help='Generate a dsn for the provided member, defaults to a dsn of the master')
@click.option('--role', '-r', help='Give a dsn of any member with this role', type=click.Choice(['master', 'replica',
'any']), default=None)
@click.option('--member', '-m', help='Generate a dsn for this member', type=str)
@option_dcs
@option_config_file
@click.argument('cluster_name')
def dsn(cluster_name, config_file, dcs, role, member):
if role is not None and member is not None:
raise PatroniCtlException('--role and --member are mutually exclusive options')
if member is None and role is None:
role = 'master'
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
m = get_any_member(cluster, role=role, member=member)
if m is None:
raise PatroniCtlException('Can not find a suitable member')
params = build_connect_parameters(m.conn_url)
click.echo('host={host} port={port}'.format(**params))
@ctl.command('query', help='Query a Patroni PostgreSQL member')
@click.argument('cluster_name')
@option_config_file
@option_format
@click.option('--format', 'fmt', help='Output format (pretty, json)', default='tsv')
@click.option('--file', '-f', 'p_file', help='Execute the SQL commands from this file', type=click.File('rb'))
@click.option('--password', help='force password prompt', is_flag=True)
@click.option('-U', '--username', help='database user name', type=str)
@option_dcs
@option_watch
@option_watchrefresh
@click.option('--role', '-r', help='The role of the query', type=click.Choice(['master', 'replica', 'any']),
default=None)
@click.option('--member', '-m', help='Query a specific member', type=str)
@click.option('--delimiter', help='The column delimiter', default='\t')
@click.option('--command', '-c', help='The SQL commands to execute')
@click.option('-d', '--dbname', help='database name to connect to', type=str)
def query(
cluster_name,
config_file,
dcs,
role,
member,
w,
watch,
delimiter,
command,
p_file,
password,
username,
dbname,
fmt='tsv',
):
if role is not None and member is not None:
raise PatroniCtlException('--role and --member are mutually exclusive options')
if member is None and role is None:
role = 'master'
if p_file is not None and command is not None:
raise PatroniCtlException('--file and --command are mutually exclusive options')
if p_file is None and command is None:
raise PatroniCtlException('You need to specify either --command or --file')
connect_parameters = dict()
if username:
connect_parameters['user'] = username
if password:
connect_parameters['password'] = click.prompt('Password', hide_input=True, type=str)
if dbname:
connect_parameters['database'] = dbname
if p_file is not None:
command = p_file.read()
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
cursor = None
for _ in watching(w, watch, clear=False):
output, cursor = query_member(cluster, cursor, member, role, command, connect_parameters)
print_output(None, output, fmt=fmt, delimiter=delimiter)
if cursor is None:
cluster = dcs.get_cluster()
def query_member(cluster, cursor, member, role, command, connect_parameters=None):
try:
if cursor is None:
cursor = get_cursor(cluster, role=role, member=member, connect_parameters=connect_parameters)
if cursor is None:
if role is None:
message = 'No connection to member {0} is available'.format(member)
else:
message = 'No connection to role={0} is available'.format(role)
logging.debug(message)
return [[timestamp(0), message]], None
cursor.execute('SELECT pg_is_in_recovery()')
in_recovery = cursor.fetchone()[0]
if in_recovery and role == 'master' or not in_recovery and role == 'replica':
cursor.connection.close()
return None, None
cursor.execute(command)
return cursor.fetchall(), cursor
except (psycopg2.OperationalError, psycopg2.DatabaseError) as oe:
logging.debug(oe)
if cursor is not None and not cursor.connection.closed:
cursor.connection.close()
message = oe.pgcode or oe.pgerror or str(oe)
message = message.replace('\n', ' ')
return [[timestamp(0), 'ERROR, SQLSTATE: {0}'.format(message)]], None
@ctl.command('remove', help='Remove cluster from DCS')
@click.argument('cluster_name')
@option_config_file
@option_format
@option_dcs
def remove(config_file, cluster_name, fmt, dcs):
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
output_members(cluster, cluster_name, fmt)
confirm = click.prompt('Please confirm the cluster name to remove', type=str)
if confirm != cluster_name:
raise PatroniCtlException('Cluster names specified do not match')
message = 'Yes I am aware'
confirm = \
click.prompt('You are about to remove all information in DCS for {0}, please type: "{1}"'.format(cluster_name,
message), type=str)
if message != confirm:
raise PatroniCtlException('You did not exactly type "{0}"'.format(message))
if cluster.leader:
confirm = click.prompt('This cluster currently is healthy. Please specify the master name to continue')
if confirm != cluster.leader.name:
raise PatroniCtlException('You did not specify the current master of the cluster')
dcs.delete_cluster()
def wait_for_leader(dcs, timeout=30):
t_stop = time.time() + timeout
timeout /= 2
while time.time() < t_stop:
dcs.watch(timeout)
cluster = dcs.get_cluster()
if cluster.leader:
return cluster
raise PatroniCtlException('Timeout occured')
def empty_post_to_members(cluster, member_names, force, endpoint):
candidates = dict()
for m in cluster.members:
candidates[m.name] = m
if not member_names:
member_names = [click.prompt('Which member do you want to {0} [{1}]?'.format(endpoint,
', '.join(candidates.keys())), type=str, default='')]
for mn in member_names:
if mn not in candidates.keys():
raise PatroniCtlException('{0} is not a member of cluster'.format(mn))
if not force:
confirm = click.confirm('Are you sure you want to {0} members {1}?'.format(endpoint, ', '.join(member_names)))
if not confirm:
raise PatroniCtlException('Aborted {0}'.format(endpoint))
for mn in member_names:
r = post_patroni(candidates[mn], endpoint, '')
if r.status_code != 200:
click.echo('{0} failed for member {1}, status code={2}, ({3})'.format(endpoint, mn, r.status_code, r.text))
else:
click.echo('Succesful {0} on member {1}'.format(endpoint, mn))
def ctl_load_config(cluster_name, config_file, dcs):
config = load_config(config_file, dcs)
dcs = get_dcs(config, cluster_name)
cluster = dcs.get_cluster()
return config, dcs, cluster
@ctl.command('restart', help='Restart cluster member')
@click.argument('cluster_name')
@click.argument('member_names', nargs=-1)
@click.option('--role', '-r', help='Restart only members with this role', default='any',
type=click.Choice(['master', 'replica', 'any']))
@click.option('--any', 'p_any', help='Restart a single member only', is_flag=True)
@option_config_file
@option_force
@option_dcs
def restart(cluster_name, member_names, config_file, dcs, force, role, p_any):
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
role_names = [m.name for m in get_all_members(cluster, role)]
if member_names:
member_names = list(set(member_names) & set(role_names))
else:
member_names = role_names
if p_any:
random.shuffle(member_names)
member_names = member_names[:1]
output_members(cluster, cluster_name)
empty_post_to_members(cluster, member_names, force, 'restart')
@ctl.command('reinit', help='Reinitialize cluster member')
@click.argument('cluster_name')
@click.argument('member_names', nargs=-1)
@option_config_file
@option_force
@option_dcs
def reinit(cluster_name, member_names, config_file, dcs, force):
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
empty_post_to_members(cluster, member_names, force, 'reinitialize')
@ctl.command('failover', help='Failover to a replica')
@click.argument('cluster_name')
@click.option('--master', help='The name of the current master', default=None)
@click.option('--candidate', help='The name of the candidate', default=None)
@click.option('--scheduled', help='Timestamp of a scheduled failover in unambiguous format (e.g. ISO 8601)',
default=None)
@click.option('--force', is_flag=True)
@option_config_file
@option_dcs
def failover(config_file, cluster_name, master, candidate, force, dcs, scheduled):
"""
We want to trigger a failover for the specified cluster name.
We verify that the cluster name, master name and candidate name are correct.
If so, we trigger a failover and keep the client up to date.
"""
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
if cluster.leader is None:
raise PatroniCtlException('This cluster has no master')
if master is None:
if force:
master = cluster.leader.member.name
else:
master = click.prompt('Master', type=str, default=cluster.leader.member.name)
if cluster.leader.member.name != master:
raise PatroniCtlException('Member {0} is not the leader of cluster {1}'.format(master, cluster_name))
candidate_names = [str(m.name) for m in cluster.members if m.name != master]
# We sort the names for consistent output to the client
candidate_names.sort()
if not candidate_names:
raise PatroniCtlException('No candidates found to failover to')
if candidate is None and not force:
candidate = click.prompt('Candidate ' + str(candidate_names), type=str, default='')
if candidate == master:
raise PatroniCtlException('Failover target and source are the same.')
if candidate and candidate not in candidate_names:
raise PatroniCtlException('Member {0} does not exist in cluster {1}'.format(candidate, cluster_name))
if scheduled is None and not force:
scheduled = click.prompt('When should the failover take place (e.g. 2015-10-01T14:30) ', type=str,
default='now')
if (scheduled or 'now') == 'now':
scheduled_at = None
else:
try:
scheduled_at = dateutil.parser.parse(scheduled)
if scheduled_at.tzinfo is None:
scheduled_at = tzlocal.get_localzone().localize(scheduled_at)
except (ValueError, TypeError):
message = 'Unable to parse scheduled timestamp ({0}). It should be in an unambiguous format (e.g. ISO 8601)'
raise PatroniCtlException(message.format(scheduled))
scheduled_at = scheduled_at.isoformat()
failover_value = {'leader': master, 'candidate': candidate, 'scheduled_at': scheduled_at}
logging.debug(failover_value)
# By now we have established that the leader exists and the candidate exists
click.echo('Current cluster topology')
output_members(dcs.get_cluster(), cluster_name)
if not force:
a = \
click.confirm('Are you sure you want to failover cluster {0}, demoting current master {1}?'.format(
cluster_name, master))
if not a:
raise PatroniCtlException('Aborting failover')
r = None
try:
r = post_patroni(cluster.leader.member, 'failover', failover_value)
if r.status_code == 200:
logging.debug(r)
cluster = dcs.get_cluster()
logging.debug(cluster)
click.echo('{0} {1}'.format(timestamp(), r.text))
else:
click.echo('Failover failed, details: {0}, {1}'.format(r.status_code, r.text))
return
except Exception:
logging.exception(r)
logging.warning('Failing over to DCS')
click.echo(timestamp() + ' Could not failover using Patroni api, falling back to DCS')
click.echo(timestamp() + ' Initializing failover from master {0}'.format(master))
dcs.manual_failover(master, candidate, scheduled_at=failover_value)
output_members(cluster, cluster_name)
def output_members(cluster, name, fmt='pretty'):
rows = []
logging.debug(cluster)
leader_name = None
if cluster.leader:
leader_name = cluster.leader.member.name
xlog_location_cluster = cluster.last_leader_operation or 0
# Mainly for consistent pretty printing and watching we sort the output
cluster.members.sort(key=lambda x: x.name)
for m in cluster.members:
logging.debug(m)
leader = ''
if m.name == leader_name:
leader = '*'
host = build_connect_parameters(m.conn_url)['host']
xlog_location = m.data.get('xlog_location') or 0
lag = ''
if (xlog_location_cluster >= xlog_location):
lag = round((xlog_location_cluster - xlog_location)/1024/1024)
rows.append([
name,
m.name,
host,
leader,
m.data.get('state', ''),
lag
])
columns = [
'Cluster',
'Member',
'Host',
'Leader',
'State',
'Lag in MB',
]
alignment = {'Cluster': 'l', 'Member': 'l', 'Host': 'l', 'Lag in MB': 'r'}
print_output(columns, rows, alignment, fmt)
@ctl.command('list', help='List the Patroni members for a given Patroni')
@click.argument('cluster_names', nargs=-1)
@option_config_file
@option_format
@option_watch
@option_watchrefresh
@option_dcs
def members(config_file, cluster_names, fmt, watch, w, dcs):
if not cluster_names:
logging.warning('Listing members: No cluster names were provided')
return
config = load_config(config_file, dcs)
for cluster_name in cluster_names:
dcs = get_dcs(config, cluster_name)
for _ in watching(w, watch):
output_members(dcs.get_cluster(), cluster_name, fmt)
def timestamp(precision=6):
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:precision - 7]
@ctl.command('configure', help='Create configuration file')
@click.option('--config-file', '-c', help='Configuration file', prompt='Configuration file', default=CONFIG_FILE_PATH)
@click.option('--dcs', '-d', help='The DCS connect url', prompt='DCS connect url', default='etcd://localhost:4001')
@click.option('--namespace', '-n', help='The namespace', prompt='Namespace', default='/service/')
def configure(config_file, dcs, namespace):
config = dict()
config['dcs_api'] = str(dcs)
config['namespace'] = str(namespace)
store_config(config, config_file)
+89 -20
View File
@@ -1,8 +1,10 @@
import abc import abc
import dateutil
import json import json
import six
from collections import namedtuple from collections import namedtuple
from patroni.exceptions import DCSError from random import randint
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
from threading import Event, Lock from threading import Event, Lock
@@ -51,17 +53,33 @@ class Member(namedtuple('Member', 'index,name,session,data')):
else: else:
try: try:
data = json.loads(data) data = json.loads(data)
except: except (TypeError, ValueError):
data = {} data = {}
return Member(index, name, session, data) return Member(index, name, session, data)
@property @property
def conn_url(self): def conn_url(self):
return self.data.get('conn_url', None) return self.data.get('conn_url')
@property @property
def api_url(self): def api_url(self):
return self.data.get('api_url', None) return self.data.get('api_url')
@property
def tags(self):
return self.data.get('tags', {})
@property
def nofailover(self):
return self.tags.get('nofailover', False)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@property
def clonefrom(self):
return self.tags.get('clonefrom', False)
class Leader(namedtuple('Leader', 'index,session,member')): class Leader(namedtuple('Leader', 'index,session,member')):
@@ -81,12 +99,44 @@ class Leader(namedtuple('Leader', 'index,session,member')):
return self.member.conn_url return self.member.conn_url
class Failover(namedtuple('Failover', 'index,leader,member')): class Failover(namedtuple('Failover', 'index,leader,candidate,scheduled_at')):
"""
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader"}'))
True
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader", "member": "cluster_candidate"}'))
True
>>> Failover.from_node(1, 'null') is None
True
>>> n = '{"leader": "cluster_leader", "member": "cluster_candidate", "scheduled_at": "2016-01-14T10:09:57.1394Z"}'
>>> 'tzinfo=' in str(Failover.from_node(1, n))
True
>>> Failover.from_node(1, None) is None
True
>>> Failover.from_node(1, '{}') is None
True
>>> 'abc' in Failover.from_node(1, 'abc:def')
True
"""
@staticmethod @staticmethod
def from_node(index, value): def from_node(index, value):
t = [a.strip() for a in value.split(':')] + [''] if not value:
return Failover(index, t[0], t[1]) if t[0] or t[1] else None return None
try:
data = json.loads(value)
if not data:
return None
except ValueError:
t = [a.strip() for a in value.split(':')]
leader = t[0]
candidate = t[1] if len(t) > 1 else None
return Failover(index, leader, candidate, None) if leader or candidate else None
if data.get('scheduled_at'):
data['scheduled_at'] = dateutil.parser.parse(data['scheduled_at'])
return Failover(index, data.get('leader'), data.get('member'), data.get('scheduled_at'))
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members,failover')): class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members,failover')):
@@ -103,10 +153,19 @@ class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,mem
def is_unlocked(self): def is_unlocked(self):
return not (self.leader and self.leader.name) return not (self.leader and self.leader.name)
def has_member(self, member_name):
return any(m for m in self.members if m.name == member_name)
class AbstractDCS: def get_member(self, member_name, fallback_to_leader=True):
return ([m for m in self.members if m.name == member_name] or [self.leader if fallback_to_leader else None])[0]
__metaclass__ = abc.ABCMeta def get_clone_member(self):
candidates = [m for m in self.members if m.clonefrom and (not self.leader or m.name != self.leader.name)]
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
@six.add_metaclass(abc.ABCMeta)
class AbstractDCS(object):
_INITIALIZE = 'initialize' _INITIALIZE = 'initialize'
_LEADER = 'leader' _LEADER = 'leader'
@@ -122,8 +181,8 @@ class AbstractDCS:
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc... i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
""" """
self._name = name self._name = name
self._scope = config['scope'] self._namespace = '/{0}'.format(config.get('namespace', '/service/').strip('/'))
self._base_path = '/service/' + self._scope self._base_path = '/'.join([self._namespace, config['scope']])
self._cluster = None self._cluster = None
self._cluster_thread_lock = Lock() self._cluster_thread_lock = Lock()
@@ -212,15 +271,18 @@ class AbstractDCS:
def set_failover_value(self, value, index=None): def set_failover_value(self, value, index=None):
"""Create or update `/failover` key""" """Create or update `/failover` key"""
def manual_failover(self, leader, member, index=None): def manual_failover(self, leader, candidate, scheduled_at=None, index=None):
return self.set_failover_value(leader + (':' + member if member else ''), index) failover_value = {}
if leader:
failover_value['leader'] = leader
def current_leader(self): if candidate:
try: failover_value['member'] = candidate
cluster = self.get_cluster()
return None if cluster.is_unlocked() else cluster.leader if scheduled_at:
except DCSError: failover_value['scheduled_at'] = scheduled_at.isoformat()
return None
return self.set_failover_value(json.dumps(failover_value), index)
@abc.abstractmethod @abc.abstractmethod
def touch_member(self, connection_string, ttl=None): def touch_member(self, connection_string, ttl=None):
@@ -240,8 +302,11 @@ class AbstractDCS:
overwriting the key if necessary.""" overwriting the key if necessary."""
@abc.abstractmethod @abc.abstractmethod
def initialize(self): def initialize(self, create_new=True, sysid=""):
"""Race for cluster initialization. """Race for cluster initialization.
:param create_new: False if the key should already exist (in the case we are setting the system_id)
:param sysid: PostgreSQL cluster system identifier, if specified, is written to the key
:returns: `!True` if key has been created successfully. :returns: `!True` if key has been created successfully.
this method should create atomically initialize key and return `!True` this method should create atomically initialize key and return `!True`
@@ -256,6 +321,10 @@ class AbstractDCS:
def cancel_initialization(self): def cancel_initialization(self):
""" Removes the initialize key for a cluster """ """ Removes the initialize key for a cluster """
@abc.abstractmethod
def delete_cluster(self):
"""Delete cluster from DCS"""
def watch(self, timeout): def watch(self, timeout):
"""If the current node is a master it should just sleep. """If the current node is a master it should just sleep.
Any other node should watch for changes of leader key with a given timeout Any other node should watch for changes of leader key with a given timeout
+95 -43
View File
@@ -6,14 +6,15 @@ import random
import requests import requests
import socket import socket
import time import time
import urllib3
from dns.exception import DNSException from dns.exception import DNSException
from dns import resolver from dns import resolver
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError from patroni.exceptions import DCSError
from patroni.utils import Retry, RetryFailedError, sleep from patroni.utils import Retry, RetryFailedError, sleep
from urllib3.exceptions import HTTPError, ReadTimeoutError
from requests.exceptions import RequestException from requests.exceptions import RequestException
from six.moves.http_client import HTTPException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,10 +50,60 @@ class Client(etcd.Client):
self._update_machines_cache = True self._update_machines_cache = True
return [self._base_uri] return [self._base_uri]
def api_execute(self, path, method, **kwargs): def _do_http_request(self, request_executor, method, url, fields=None, **kwargs):
try:
response = request_executor(method, url, fields=fields, **kwargs)
response.data.decode('utf-8')
self._check_cluster_id(response)
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
if (isinstance(fields, dict) and fields.get("wait") == "true" and
isinstance(e, ReadTimeoutError)):
logger.debug("Watch timed out.")
raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e)
logger.error("Request to server %s failed: %r", self._base_uri, e)
logger.info("Reconnection allowed, looking for another server.")
self._base_uri = self._next_server(cause=e)
response = False
return response
def api_execute(self, path, method, params=None, timeout=None):
if not path.startswith('/'):
raise ValueError('Path does not start with /')
if timeout is None:
timeout = self.read_timeout
if timeout == 0:
timeout = None
kwargs = {'timeout': timeout, 'fields': params, 'redirect': self.allow_redirect,
'headers': self._get_headers(), 'preload_content': False}
if method in [self._MGET, self._MDELETE]:
request_executor = self.http.request
elif method in [self._MPUT, self._MPOST]:
request_executor = self.http.request_encode_body
kwargs['encode_multipart'] = False
else:
raise etcd.EtcdException('HTTP method {0} not supported'.format(method))
# Update machines_cache if previous attempt of update has failed # Update machines_cache if previous attempt of update has failed
self._update_machines_cache and self._load_machines_cache() if self._update_machines_cache:
return super(Client, self).api_execute(path, method, **kwargs) self._load_machines_cache()
response = False
try:
while not response:
response = self._do_http_request(request_executor, method, self._base_uri + path, **kwargs)
if response is False and not self._use_proxies:
self._machines_cache = self.machines
self._machines_cache.remove(self._base_uri)
return self._handle_server_response(response)
except etcd.EtcdConnectionFailed:
self._update_machines_cache = True
raise
@staticmethod @staticmethod
def get_srv_record(host): def get_srv_record(host):
@@ -62,16 +113,6 @@ class Client(etcd.Client):
logger.exception('Can not resolve SRV for %s', host) logger.exception('Can not resolve SRV for %s', host)
return [] return []
# try to workarond bug in python-etcd: https://github.com/jplana/python-etcd/issues/81
def _result_from_response(self, response):
try:
response.data.decode('utf-8')
except urllib3.exceptions.TimeoutError:
raise
except Exception as e:
raise etcd.EtcdException('Unable to decode server response: %s' % e)
return super(Client, self)._result_from_response(response)
def _get_machines_cache_from_srv(self, discovery_srv): def _get_machines_cache_from_srv(self, discovery_srv):
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record. """Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
This record should contain list of host and peer ports which could be used to run This record should contain list of host and peer ports which could be used to run
@@ -79,7 +120,7 @@ class Client(etcd.Client):
ret = [] ret = []
for host, port in self.get_srv_record(discovery_srv): for host, port in self.get_srv_record(discovery_srv):
url = '{}://{}:{}/members'.format(self._protocol, host, port) url = '{0}://{1}:{2}/members'.format(self._protocol, host, port)
try: try:
response = requests.get(url, timeout=5) response = requests.get(url, timeout=5)
if response.ok: if response.ok:
@@ -97,10 +138,10 @@ class Client(etcd.Client):
host, port = addr.split(':') host, port = addr.split(':')
try: try:
for r in set(socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)): for r in set(socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)):
ret.append('{}://{}:{}'.format(self._protocol, r[4][0], r[4][1])) ret.append('{0}://{1}:{2}'.format(self._protocol, r[4][0], r[4][1]))
except socket.error: except socket.error:
logger.exception('Can not resolve %s', host) logger.exception('Can not resolve %s', host)
return list(set(ret)) if ret else ['{}://{}:{}'.format(self._protocol, host, port)] return list(set(ret)) if ret else ['{0}://{1}:{2}'.format(self._protocol, host, port)]
def _load_machines_cache(self): def _load_machines_cache(self):
"""This method should fill up `_machines_cache` from scratch. """This method should fill up `_machines_cache` from scratch.
@@ -128,7 +169,9 @@ class Client(etcd.Client):
# After filling up initial list of machines_cache we should ask etcd-cluster about actual list # After filling up initial list of machines_cache we should ask etcd-cluster about actual list
self._base_uri = self._machines_cache.pop(0) self._base_uri = self._machines_cache.pop(0)
self._machines_cache = self.machines self._machines_cache = self.machines
self._base_uri in self._machines_cache and self._machines_cache.remove(self._base_uri)
if self._base_uri in self._machines_cache:
self._machines_cache.remove(self._base_uri)
self._update_machines_cache = False self._update_machines_cache = False
@@ -136,9 +179,13 @@ class Client(etcd.Client):
def catch_etcd_errors(func): def catch_etcd_errors(func):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
return not func(*args, **kwargs) is None return func(*args, **kwargs) is not None
except (RetryFailedError, etcd.EtcdException): except (RetryFailedError, etcd.EtcdException):
return False return False
except:
logger.exception("")
raise EtcdError("unexpected error")
return wrapper return wrapper
@@ -146,18 +193,19 @@ class Etcd(AbstractDCS):
def __init__(self, name, config): def __init__(self, name, config):
super(Etcd, self).__init__(name, config) super(Etcd, self).__init__(name, config)
self.ttl = config['ttl'] self.ttl = config.get('ttl', 30)
self._retry = Retry(deadline=10, max_delay=1, max_tries=-1, self._retry = Retry(deadline=10, max_delay=1, max_tries=-1,
retry_exceptions=(etcd.EtcdConnectionFailed, retry_exceptions=(etcd.EtcdConnectionFailed,
etcd.EtcdLeaderElectionInProgress, etcd.EtcdLeaderElectionInProgress,
etcd.EtcdWatcherCleared, etcd.EtcdWatcherCleared,
etcd.EtcdEventIndexCleared)) etcd.EtcdEventIndexCleared))
self.client = self.get_etcd_client(config) self._client = self.get_etcd_client(config)
def retry(self, *args, **kwargs): def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs) return self._retry.copy()(*args, **kwargs)
def get_etcd_client(self, config): @staticmethod
def get_etcd_client(config):
client = None client = None
while not client: while not client:
try: try:
@@ -173,28 +221,29 @@ class Etcd(AbstractDCS):
def _load_cluster(self): def _load_cluster(self):
try: try:
result = self.retry(self.client.read, self.client_path(''), recursive=True) result = self.retry(self._client.read, self.client_path(''), recursive=True)
nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves} nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves}
# get initialize flag # get initialize flag
initialize = bool(nodes.get(self._INITIALIZE, False)) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize.value
# get last leader operation # get last leader operation
last_leader_operation = nodes.get(self._LEADER_OPTIME, None) last_leader_operation = nodes.get(self._LEADER_OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value) last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
# get list of members # get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader # get leader
leader = nodes.get(self._LEADER, None) leader = nodes.get(self._LEADER)
if leader: if leader:
member = Member(-1, leader.value, None, {}) member = Member(-1, leader.value, None, {})
member = ([m for m in members if m.name == leader.value] or [member])[0] member = ([m for m in members if m.name == leader.value] or [member])[0]
leader = Leader(leader.modifiedIndex, leader.ttl, member) leader = Leader(leader.modifiedIndex, leader.ttl, member)
# failover key # failover key
failover = nodes.get(self._FAILOVER, None) failover = nodes.get(self._FAILOVER)
if failover: if failover:
failover = Failover.from_node(failover.modifiedIndex, failover.value) failover = Failover.from_node(failover.modifiedIndex, failover.value)
@@ -207,15 +256,15 @@ class Etcd(AbstractDCS):
@catch_etcd_errors @catch_etcd_errors
def touch_member(self, connection_string, ttl=None): def touch_member(self, connection_string, ttl=None):
return self.retry(self.client.set, self.member_path, connection_string, ttl or self.ttl) return self.retry(self._client.set, self.member_path, connection_string, ttl or self.ttl)
@catch_etcd_errors @catch_etcd_errors
def take_leader(self): def take_leader(self):
return self.retry(self.client.set, self.leader_path, self._name, self.ttl) return self.retry(self._client.set, self.leader_path, self._name, self.ttl)
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self):
try: try:
return bool(self.retry(self.client.write, self.leader_path, self._name, ttl=self.ttl, prevExist=False)) return bool(self.retry(self._client.write, self.leader_path, self._name, ttl=self.ttl, prevExist=False))
except etcd.EtcdAlreadyExist: except etcd.EtcdAlreadyExist:
logger.info('Could not take out TTL lock') logger.info('Could not take out TTL lock')
except (RetryFailedError, etcd.EtcdException): except (RetryFailedError, etcd.EtcdException):
@@ -224,43 +273,46 @@ class Etcd(AbstractDCS):
@catch_etcd_errors @catch_etcd_errors
def set_failover_value(self, value, index=None): def set_failover_value(self, value, index=None):
return self.client.write(self.failover_path, value, prevIndex=index or 0) return self._client.write(self.failover_path, value, prevIndex=index or 0)
@catch_etcd_errors @catch_etcd_errors
def write_leader_optime(self, last_operation): def write_leader_optime(self, last_operation):
return self.client.set(self.leader_optime_path, last_operation) return self._client.set(self.leader_optime_path, last_operation)
@catch_etcd_errors @catch_etcd_errors
def update_leader(self): def update_leader(self):
return self.retry(self.client.test_and_set, self.leader_path, self._name, self._name, self.ttl) return self.retry(self._client.test_and_set, self.leader_path, self._name, self._name, self.ttl)
@catch_etcd_errors @catch_etcd_errors
def initialize(self): def initialize(self, create_new=True, sysid=""):
return self.retry(self.client.write, self.initialize_path, self._name, prevExist=False) return self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new))
@catch_etcd_errors @catch_etcd_errors
def delete_leader(self): def delete_leader(self):
return self.client.delete(self.leader_path, prevValue=self._name) return self._client.delete(self.leader_path, prevValue=self._name)
@catch_etcd_errors @catch_etcd_errors
def cancel_initialization(self): def cancel_initialization(self):
return self.retry(self.client.delete, self.initialize_path, prevValue=self._name) return self.retry(self._client.delete, self.initialize_path)
@catch_etcd_errors
def delete_cluster(self):
return self.retry(self._client.delete, self.client_path(''), recursive=True)
def watch(self, timeout): def watch(self, timeout):
cluster = self.cluster cluster = self.cluster
# watch on leader key changes if it is defined and current node is not lock owner # watch on leader key changes if it is defined and current node is not lock owner
if cluster and cluster.leader and cluster.leader.name != self._name: if cluster and cluster.leader and cluster.leader.name != self._name and cluster.leader.index:
end_time = time.time() + timeout end_time = time.time() + timeout
index = cluster.leader.index
while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try: try:
self.client.watch(self.leader_path, index=index + 1, timeout=timeout + 0.5) self._client.watch(self.leader_path, index=cluster.leader.index + 1, timeout=timeout + 0.5)
# Synchronous work of all cluster members with etcd is less expensive # Synchronous work of all cluster members with etcd is less expensive
# than reestablishing http connection every time from every replica. # than reestablishing http connection every time from every replica.
return True return True
except urllib3.exceptions.TimeoutError: except etcd.EtcdWatchTimedOut:
self.client.http.clear() self._client.http.clear()
return False return False
except etcd.EtcdException: except etcd.EtcdException:
logging.exception('watch') logging.exception('watch')
+171 -80
View File
@@ -2,15 +2,19 @@ import json
import logging import logging
import psycopg2 import psycopg2
import requests import requests
import sys
import datetime
import pytz
from multiprocessing.pool import ThreadPool
from patroni.async_executor import AsyncExecutor from patroni.async_executor import AsyncExecutor
from patroni.exceptions import DCSError, PostgresConnectionException from patroni.exceptions import DCSError, PostgresConnectionException
from multiprocessing.pool import ThreadPool from patroni.utils import sleep
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Ha: class Ha(object):
def __init__(self, patroni): def __init__(self, patroni):
self.patroni = patroni self.patroni = patroni
@@ -18,12 +22,13 @@ class Ha:
self.dcs = patroni.dcs self.dcs = patroni.dcs
self.cluster = None self.cluster = None
self.old_cluster = None self.old_cluster = None
self.recovering = False
self._async_executor = AsyncExecutor() self._async_executor = AsyncExecutor()
def load_cluster_from_dcs(self): def load_cluster_from_dcs(self):
cluster = self.dcs.get_cluster() cluster = self.dcs.get_cluster()
# We want to keep the state of cluster when it was healhy # We want to keep the state of cluster when it was healthy
if not cluster.is_unlocked() or not self.old_cluster: if not cluster.is_unlocked() or not self.old_cluster:
self.old_cluster = cluster self.old_cluster = cluster
self.cluster = cluster self.cluster = cluster
@@ -33,7 +38,7 @@ class Ha:
def update_lock(self): def update_lock(self):
ret = self.dcs.update_leader() ret = self.dcs.update_leader()
if ret: if ret and not self._async_executor.busy:
try: try:
self.dcs.write_leader_optime(self.state_handler.last_operation()) self.dcs.write_leader_optime(self.state_handler.last_operation())
except: except:
@@ -52,30 +57,36 @@ class Ha:
'state': self.state_handler.state, 'state': self.state_handler.state,
'role': self.state_handler.role 'role': self.state_handler.role
} }
if data['state'] in ['running', 'restarting', 'starting']: if self.patroni.tags:
data['tags'] = self.patroni.tags
if not self._async_executor.busy and data['state'] in ['running', 'restarting', 'starting']:
try: try:
data['xlog_location'] = self.state_handler.xlog_position() data['xlog_location'] = self.state_handler.xlog_position()
except: except:
pass pass
self.dcs.touch_member(json.dumps(data, separators=(',', ':'))) self.dcs.touch_member(json.dumps(data, separators=(',', ':')))
def copy_backup_from_leader(self, leader): def clone(self, clone_member=None, msg='(without leader)'):
if self.state_handler.bootstrap(leader): if self.state_handler.bootstrap(cluster_initialized=True, clone_member=clone_member):
logger.info('bootstrapped from leader') logger.info('bootstrapped %s', msg)
else: else:
logger.error('failed to bootstrap %s', msg)
self.state_handler.stop('immediate') self.state_handler.stop('immediate')
self.state_handler.remove_data_directory() self.state_handler.remove_data_directory()
logger.error('failed to bootstrap from leader')
def bootstrap(self): def bootstrap(self):
if not self.cluster.is_unlocked(): # cluster already has leader if not self.cluster.is_unlocked(): # cluster already has leader
self._async_executor.schedule('bootstrap from leader') clone_member = self.cluster.get_clone_member()
self._async_executor.run_async(self.copy_backup_from_leader, args=(self.cluster.leader, )) member_role = 'leader' if clone_member == self.cluster.leader else 'replica'
return 'trying to bootstrap from leader' msg = "from {0} '{1}'".format(member_role, clone_member.name)
elif not self.cluster.initialize: # no initialize key self._async_executor.schedule('bootstrap {0}'.format(msg))
if self.dcs.initialize(): # race for initialization self._async_executor.run_async(self.clone, args=(clone_member, msg))
return 'trying to bootstrap {0}'.format(msg)
elif not self.cluster.initialize and not self.patroni.nofailover: # no initialize key
if self.dcs.initialize(create_new=True): # race for initialization
try: try:
self.state_handler.bootstrap() self.state_handler.bootstrap()
self.dcs.initialize(create_new=False, sysid=self.state_handler.sysid)
except: # initdb or start failed except: # initdb or start failed
# remove initialization key and give a chance to other members # remove initialization key and give a chance to other members
logger.info("removing initialize key after failed attempt to initialize the cluster") logger.info("removing initialize key after failed attempt to initialize the cluster")
@@ -84,45 +95,39 @@ class Ha:
self.state_handler.move_data_directory() self.state_handler.move_data_directory()
raise raise
self.dcs.take_leader() self.dcs.take_leader()
self.load_cluster_from_dcs()
return 'initialized a new cluster' return 'initialized a new cluster'
else: else:
return 'failed to acquire initialize lock' return 'failed to acquire initialize lock'
else: else:
if self.state_handler.can_create_replica_without_replication_connection():
self._async_executor.run_async(self.clone)
return "trying to bootstrap (without leader)"
return 'waiting for leader to bootstrap' return 'waiting for leader to bootstrap'
def recover(self): def recover(self):
has_lock = self.has_lock() self.recovering = True
return self.follow("starting as readonly because i had the session lock", "starting as a secondary", True, True)
# try to see if we are the former master that crashed. If so - we likely need to run pg_rewind def follow(self, demote_reason, follow_reason, refresh=True, recovery=False):
# in order to join the former standby being promoted. if refresh:
pg_controldata = self.state_handler.controldata() self.load_cluster_from_dcs()
if not has_lock and pg_controldata and\
pg_controldata.get('Database cluster state', '') == 'in production': # crashed master
self.state_handler.require_rewind()
# XXX: follow the leader calls stop, which might take quite some time. ret = demote_reason if not recovery and self.state_handler.is_leader() else follow_reason
# perhaps we should run sync asynchronously
# (we still need the exit code from follow_the_leader)
ret = self.state_handler.follow_the_leader(None if has_lock else self.cluster.leader, recovery=True)
if not ret:
if not has_lock:
return 'failed to start postgres'
self.dcs.delete_leader()
self.dcs.reset_cluster()
return 'removed leader key after trying and failing to start postgres'
if not has_lock:
return 'started as a secondary'
logger.info('started as readonly because i had the session lock')
self.load_cluster_from_dcs()
def follow_the_leader(self, demote_reason, follow_reason, refresh=True): # determine the node to follow. If replicatefrom tag is set,
refresh and self.load_cluster_from_dcs() # try to follow the node mentioned there, otherwise, follow the leader.
ret = demote_reason if self.state_handler.is_leader() else follow_reason
leader = self.cluster.leader if self.patroni.replicatefrom:
leader = None if (leader and leader.name) == self.state_handler.name else leader node_to_follow = self.cluster.get_member(self.patroni.replicatefrom, fallback_to_leader=True)
if not self.state_handler.check_recovery_conf(leader): else:
node_to_follow = self.cluster.leader
if node_to_follow and node_to_follow.name == self.state_handler.name:
ret = demote_reason
node_to_follow = None
if not self.state_handler.check_recovery_conf(node_to_follow) or recovery:
self._async_executor.schedule('changing primary_conninfo and restarting') self._async_executor.schedule('changing primary_conninfo and restarting')
self._async_executor.run_async(self.state_handler.follow_the_leader, (leader, )) self._async_executor.run_async(self.state_handler.follow, (node_to_follow, recovery))
return ret return ret
def enforce_master_role(self, message, promote_message): def enforce_master_role(self, message, promote_message):
@@ -130,6 +135,7 @@ class Ha:
return message return message
else: else:
self.state_handler.promote() self.state_handler.promote()
self.touch_member()
return promote_message return promote_message
@staticmethod @staticmethod
@@ -139,7 +145,9 @@ class Ha:
reachable - `!False` if the node is not reachable or is not responding with correct JSON reachable - `!False` if the node is not reachable or is not responding with correct JSON
in_recovery - `!True` if pg_is_in_recovery() == true in_recovery - `!True` if pg_is_in_recovery() == true
xlog_location - value of `replayed_location` or `location` from JSON, dependin on its role.""" xlog_location - value of `replayed_location` or `location` from JSON, dependin on its role.
tags - dictionary with values of different tags (i.e. nofailover)
"""
try: try:
response = requests.get(member.api_url, timeout=2, verify=False) response = requests.get(member.api_url, timeout=2, verify=False)
@@ -147,10 +155,11 @@ class Ha:
json = response.json() json = response.json()
is_master = json['role'] == 'master' is_master = json['role'] == 'master'
xlog_location = json['xlog']['location' if is_master else 'replayed_location'] xlog_location = json['xlog']['location' if is_master else 'replayed_location']
return (member, True, not is_master, xlog_location) tags = json.get('tags', dict())
return (member, True, not is_master, xlog_location, tags)
except: except:
logging.exception('request failed: GET %s', member.api_url) logging.exception('request failed: GET %s', member.api_url)
return (member, False, None, 0) return (member, False, None, 0, {})
def fetch_nodes_statuses(self, members): def fetch_nodes_statuses(self, members):
pool = ThreadPool(len(members)) pool = ThreadPool(len(members))
@@ -165,16 +174,19 @@ class Ha:
if self.state_handler.is_leader(): if self.state_handler.is_leader():
return True return True
if self.patroni.nofailover is True:
return False
if check_replication_lag and not self.state_handler.check_replication_lag(self.cluster.last_leader_operation): if check_replication_lag and not self.state_handler.check_replication_lag(self.cluster.last_leader_operation):
return False # Too far behind last reported xlog location on master return False # Too far behind last reported xlog location on master
# Prepare list of nodes to run check against # Prepare list of nodes to run check against
members = [m for m in members if m.name != self.state_handler.name and m.api_url] members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
if members: if members:
my_xlog_location = self.state_handler.xlog_position() my_xlog_location = self.state_handler.xlog_position()
for member, reachable, in_recovery, xlog_location in self.fetch_nodes_statuses(members): for member, reachable, in_recovery, xlog_location, tags in self.fetch_nodes_statuses(members):
if reachable: # If the node is unreachable it's not healhy if reachable and not tags.get('nofailover', False): # If the node is unreachable it's not healhy
if not in_recovery: if not in_recovery:
logger.warning('Master (%s) is still alive', member.name) logger.warning('Master (%s) is still alive', member.name)
return False return False
@@ -184,41 +196,46 @@ class Ha:
def is_failover_possible(self, members): def is_failover_possible(self, members):
ret = False ret = False
members = [m for m in members if m.name != self.state_handler.name and m.api_url] members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
if members: if members:
for member, reachable, in_recovery, xlog_location in self.fetch_nodes_statuses(members): for member, reachable, _, _, tags in self.fetch_nodes_statuses(members):
if reachable: if reachable and not tags.get('nofailover', False):
ret = True # TODO: check xlog_location ret = True # TODO: check xlog_location
else: elif not reachable:
logger.info('Member %s is not reachable', member.name) logger.info('Member %s is not reachable', member.name)
elif tags.get('nofailover', False):
logger.info('Member %s is not allowed to promote', member.name)
else: else:
logger.warning('manual failover: members list is empty') logger.warning('manual failover: members list is empty')
return ret return ret
def manual_failover_process_no_leader(self): def manual_failover_process_no_leader(self):
failover = self.cluster.failover failover = self.cluster.failover
if failover.member: # manual failover to specific member if failover.candidate: # manual failover to specific member
if failover.member == self.state_handler.name: # manual failover to me if failover.candidate == self.state_handler.name: # manual failover to me
return True return True
# find specific node and check that it is healthy # find specific node and check that it is healthy
members = [m for m in self.cluster.members if m.name == failover.member] member = self.cluster.get_member(failover.candidate, fallback_to_leader=False)
if members: if member:
member, reachable, in_recovery, xlog_location = self.fetch_node_status(members[0]) member, reachable, _, _, tags = self.fetch_node_status(member)
if reachable: # node is healthy if reachable and not tags.get('nofailover', False): # node is healthy
logger.info('manual failover: to %s, i am %s', member.name, self.state_handler.name) logger.info('manual failover: to %s, i am %s', member.name, self.state_handler.name)
return False return False
# we wanted to failover to specific member but it is not healthy # we wanted to failover to specific member but it is not healthy
logger.warning('manual failover: member %s is unhealthy', member.name) if not reachable:
logger.warning('manual failover: member %s is unhealthy', member.name)
elif tags.get('nofailover', False):
logger.warning('manual failover: member %s is not allowed to promote', member.name)
# at this point we should consider all members as a candidates for failover # at this point we should consider all members as a candidates for failover
# i.e. we assume that failover.member is None # i.e. we assume that failover.candidate is None
# try to pick some other members to failover and check that they are healthy # try to pick some other members to failover and check that they are healthy
if failover.leader: if failover.leader:
if self.state_handler.name == failover.leader: # I was the leader if self.state_handler.name == failover.leader: # I was the leader
# exclude me and desired member which is unhealthy (failover.member can be None) # exclude me and desired member which is unhealthy (failover.candidate can be None)
members = [m for m in self.cluster.members if m.name != failover.member] members = [m for m in self.cluster.members if m.name not in (failover.candidate, failover.leader)]
if self.is_failover_possible(members): # check that there are healthy members if self.is_failover_possible(members): # check that there are healthy members
return False return False
else: # I was the leader and it looks like currently I am the only healthy member else: # I was the leader and it looks like currently I am the only healthy member
@@ -231,6 +248,13 @@ class Ha:
return self._is_healthiest_node(members, check_replication_lag=False) return self._is_healthiest_node(members, check_replication_lag=False)
def is_healthiest_node(self): def is_healthiest_node(self):
if self.state_handler.is_leader(): # leader is always the healthiest
return True
if self.patroni.nofailover: # nofailover tag makes node always unhealthy
return False
if self.cluster.failover: if self.cluster.failover:
return self.manual_failover_process_no_leader() return self.manual_failover_process_no_leader()
@@ -242,14 +266,42 @@ class Ha:
if delete_leader: if delete_leader:
self.state_handler.stop() self.state_handler.stop()
self.dcs.delete_leader() self.dcs.delete_leader()
self.touch_member()
self.dcs.reset_cluster() self.dcs.reset_cluster()
self.state_handler.follow_the_leader(None) sleep(2) # Give a time to somebody to promote
self.recover()
else:
self.state_handler.follow(None)
def process_manual_failover_from_leader(self): def process_manual_failover_from_leader(self):
failover = self.cluster.failover failover = self.cluster.failover
if failover.scheduled_at:
# If the failover is in the far future, we shouldn't do anything and just return.
# If the failover is in the past, we consider the value to be stale and we remove
# the value.
# If the value is close to now, we initiate the failover
now = datetime.datetime.now(pytz.utc)
try:
delta = (failover.scheduled_at - now).total_seconds()
if delta > self.patroni.nap_time:
logging.info('Awaiting failover at %s (in %.0f seconds)', failover.scheduled_at.isoformat(), delta)
return
elif delta < - int(self.patroni.nap_time * 1.5):
logger.warning('Found a stale failover value, cleaning up: %s', failover.scheduled_at)
self.dcs.manual_failover('', '', index=self.cluster.failover.index)
return
# The value is very close to now
sleep(max(delta, 0))
logger.info('Manual scheduled failover at {}'.format(failover.scheduled_at.isoformat()))
except TypeError:
logger.warning('Incorrect value in of scheduled_at: %s', failover.scheduled_at)
if not failover.leader or failover.leader == self.state_handler.name: if not failover.leader or failover.leader == self.state_handler.name:
if not failover.member or failover.member != self.state_handler.name: if not failover.candidate or failover.candidate != self.state_handler.name:
members = [m for m in self.cluster.members if not failover.member or m.name == failover.member] members = [m for m in self.cluster.members if not failover.candidate or m.name == failover.candidate]
if self.is_failover_possible(members): # check that there are healthy members if self.is_failover_possible(members): # check that there are healthy members
self._async_executor.schedule('manual failover: demote') self._async_executor.schedule('manual failover: demote')
self._async_executor.run_async(self.demote) self._async_executor.run_async(self.demote)
@@ -263,22 +315,26 @@ class Ha:
self.cluster.failover.leader, self.state_handler.name) self.cluster.failover.leader, self.state_handler.name)
logger.info('Trying to clean up failover key') logger.info('Trying to clean up failover key')
self.dcs.manual_failover('', '', self.cluster.failover.index) self.dcs.manual_failover('', '', index=self.cluster.failover.index)
def process_unhealthy_cluster(self): def process_unhealthy_cluster(self):
if self.is_healthiest_node(): if self.is_healthiest_node():
if self.acquire_lock(): if self.acquire_lock():
if self.cluster.failover: if self.cluster.failover:
logger.info('Cleanning up failover key after acquiring leader lock...') logger.info('Cleaning up failover key after acquiring leader lock...')
self.dcs.manual_failover('', '') self.dcs.manual_failover('', '')
self.load_cluster_from_dcs()
return self.enforce_master_role('acquired session lock as a leader', return self.enforce_master_role('acquired session lock as a leader',
'promoted self to leader by acquiring session lock') 'promoted self to leader by acquiring session lock')
else: else:
return self.follow_the_leader('demoted self due after trying and failing to obtain lock', return self.follow('demoted self after trying and failing to obtain lock',
'following new leader after trying and failing to obtain lock') 'following new leader after trying and failing to obtain lock')
else: else:
return self.follow_the_leader('demoting self because i am not the healthiest node', if self.patroni.nofailover:
'following a different leader because i am not the healthiest node') return self.follow('demoting self because I am not allowed to become master',
'following a different leader because I am not allowed to promote')
return self.follow('demoting self because i am not the healthiest node',
'following a different leader because i am not the healthiest node')
def process_healthy_cluster(self): def process_healthy_cluster(self):
if self.has_lock(): if self.has_lock():
@@ -296,8 +352,8 @@ class Ha:
self.load_cluster_from_dcs() self.load_cluster_from_dcs()
else: else:
logger.info('does not have lock') logger.info('does not have lock')
return self.follow_the_leader('demoting self because i do not have the lock and i was a leader', return self.follow('demoting self because i do not have the lock and i was a leader',
'no action. i am a secondary and i am following a leader', False) 'no action. i am a secondary and i am following a leader', False)
def schedule(self, action): def schedule(self, action):
with self._async_executor: with self._async_executor:
@@ -325,7 +381,10 @@ class Ha:
def reinitialize(self, cluster): def reinitialize(self, cluster):
self.state_handler.stop('immediate') self.state_handler.stop('immediate')
self.state_handler.remove_data_directory() self.state_handler.remove_data_directory()
self.copy_backup_from_leader(cluster.leader)
clone_member = cluster.get_clone_member()
member_role = 'leader' if clone_member == cluster.leader else 'replica'
self.clone(clone_member, "from {0} '{1}'".format(member_role, clone_member.name))
def process_scheduled_action(self): def process_scheduled_action(self):
if self.reinitialize_scheduled(): if self.reinitialize_scheduled():
@@ -350,6 +409,21 @@ class Ha:
else: else:
return self._async_executor.scheduled_action + ' in progress' return self._async_executor.scheduled_action + ' in progress'
@staticmethod
def sysid_valid(sysid):
# sysid does tv_sec << 32, where tv_sec is the number of seconds sine 1970,
# so even 1 << 32 would have 10 digits.
return str(sysid) and len(str(sysid)) >= 10 and str(sysid).isdigit()
def post_recover(self):
if not self.state_handler.is_running():
if self.has_lock():
self.dcs.delete_leader()
self.dcs.reset_cluster()
return 'removed leader key after trying and failing to start postgres'
return 'failed to start postgres'
return None
def _run_cycle(self): def _run_cycle(self):
try: try:
self.load_cluster_from_dcs() self.load_cluster_from_dcs()
@@ -357,12 +431,19 @@ class Ha:
self.touch_member() self.touch_member()
# cluster has leader key but not initialize key # cluster has leader key but not initialize key
if not self.cluster.is_unlocked() and not self.cluster.initialize: if not self.cluster.is_unlocked() and not self.sysid_valid(self.cluster.initialize) and self.has_lock():
self.dcs.initialize() # fix it self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
if self._async_executor.busy: if self._async_executor.busy:
return self.handle_long_action_in_progress() return self.handle_long_action_in_progress()
# we've got here, so any async action has finished. Check if we tried to recover and failed
if self.recovering:
self.recovering = False
msg = self.post_recover()
if msg is not None:
return msg
# currently it can trigger only reinitialize # currently it can trigger only reinitialize
msg = self.process_scheduled_action() msg = self.process_scheduled_action()
if msg is not None: if msg is not None:
@@ -372,8 +453,14 @@ class Ha:
if self.state_handler.data_directory_empty(): if self.state_handler.data_directory_empty():
return self.bootstrap() # new node return self.bootstrap() # new node
# "bootstrap", but data directory is not empty # "bootstrap", but data directory is not empty
elif not self.cluster.initialize and self.cluster.is_unlocked(): elif not self.sysid_valid(self.cluster.initialize) and self.cluster.is_unlocked():
self.dcs.initialize() self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
else:
# check if we are allowed to join
if self.sysid_valid(self.cluster.initialize) and self.cluster.initialize != self.state_handler.sysid:
logger.fatal("system ID mismatch, node %s belongs to a different cluster: %s != %s",
self.state_handler.name, self.cluster.initialize, self.state_handler.sysid)
sys.exit(1)
# try to start dead postgres # try to start dead postgres
if not self.state_handler.is_healthy(): if not self.state_handler.is_healthy():
@@ -387,14 +474,18 @@ class Ha:
else: else:
return self.process_healthy_cluster() return self.process_healthy_cluster()
finally: finally:
self.state_handler.sync_replication_slots(self.cluster) # we might not have a valid PostgreSQL connection here if another thread
# stops PostgreSQL, therefore, we only reload replication slots if no
# asynchronous processes are running (should be always the case for the master)
if not self._async_executor.busy:
self.state_handler.sync_replication_slots(self.cluster)
except DCSError: except DCSError:
logger.error('Error communicating with DCS') logger.error('Error communicating with DCS')
if self.state_handler.is_running() and self.state_handler.is_leader(): if self.state_handler.is_running() and self.state_handler.is_leader():
self.demote(delete_leader=False) self.demote(delete_leader=False)
return 'demoted self because DCS is not accessible and i was a leader' return 'demoted self because DCS is not accessible and i was a leader'
except (psycopg2.Error, PostgresConnectionException): except (psycopg2.Error, PostgresConnectionException):
logger.exception('Error communicating with Postgresql. Will try again later') logger.exception('Error communicating with PostgreSQL. Will try again later')
def run_cycle(self): def run_cycle(self):
with self._async_executor: with self._async_executor:
+340 -163
View File
@@ -4,10 +4,12 @@ import psycopg2
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
import tempfile
import time import time
from patroni.exceptions import PostgresConnectionException, PostgresException from patroni.exceptions import PostgresConnectionException, PostgresException
from patroni.utils import Retry, RetryFailedError from patroni.utils import Retry, RetryFailedError
from six import string_types
from six.moves.urllib_parse import urlparse from six.moves.urllib_parse import urlparse
from threading import Lock from threading import Lock
@@ -37,44 +39,48 @@ def parseurl(url):
return ret return ret
class Postgresql: class Postgresql(object):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.name = config['name'] self.name = config['name']
self.server_parameters = config.get('parameters') or {}
self.scope = config['scope'] self.scope = config['scope']
self.listen_addresses, self.port = config['listen'].split(':') self.listen_addresses, self.port = config['listen'].split(':')
self.data_dir = config['data_dir'] self.data_dir = config['data_dir']
self.replication = config['replication'] self.replication = config['replication']
self.superuser = config['superuser'] self.superuser = config.get('superuser') or {}
self.admin = config['admin'] self.admin = config.get('admin') or {}
self.pg_rewind = config.get('pg_rewind', {})
self.callback = config.get('callbacks', {}) self.initdb_options = config.get('initdb') or []
self.pgpass = config.get('pgpass') or os.path.join(os.path.expanduser('~'), 'pgpass')
self.pg_rewind = config.get('pg_rewind') or {}
self.callback = config.get('callbacks') or {}
self.use_slots = config.get('use_slots', True) self.use_slots = config.get('use_slots', True)
self.schedule_load_slots = self.use_slots self.schedule_load_slots = self.use_slots
self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf') self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf')
self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'), self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'),
os.path.join(self.data_dir, 'postgresql.conf')) os.path.join(self.data_dir, 'postgresql.conf'))
self.postmaster_pid = os.path.join(self.data_dir, 'postmaster.pid') self.postmaster_pid = os.path.join(self.data_dir, 'postmaster.pid')
self.trigger_file = config.get('recovery_conf', {}).get('trigger_file', None) or 'promote' self.trigger_file = config.get('recovery_conf', {}).get('trigger_file') or 'promote'
self.trigger_file = os.path.abspath(os.path.join(self.data_dir, self.trigger_file)) self.trigger_file = os.path.abspath(os.path.join(self.data_dir, self.trigger_file))
self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir] self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir]
self.local_address = self.get_local_address() self.local_address = self.get_local_address()
connect_address = config.get('connect_address', None) or self.local_address connect_address = config.get('connect_address') or self.local_address
self.connection_string = 'postgres://{username}:{password}@{connect_address}/postgres'.format( self.connection_string = 'postgres://{username}:{password}@{connect_address}/postgres'.format(
connect_address=connect_address, **self.replication) connect_address=connect_address, **self.replication)
self._connection = None self._connection = None
self._cursor_holder = None self._cursor_holder = None
self._need_rewind = False self._sysid = None
self.replication_slots = [] # list of already existing replication slots self.replication_slots = [] # list of already existing replication slots
self.retry = Retry(max_tries=-1, deadline=5, max_delay=1, retry_exceptions=PostgresConnectionException) self.retry = Retry(max_tries=-1, deadline=5, max_delay=1, retry_exceptions=PostgresConnectionException)
self._state = 'stopped' self._state = 'stopped'
self._state_lock = Lock() self._state_lock = Lock()
self._role = 'replica' self._role = self.get_postgres_role_from_data_directory()
self._role_lock = Lock() self._role_lock = Lock()
if self.is_running(): if self.is_running():
@@ -100,13 +106,14 @@ class Postgresql:
return False return False
# check if the cluster's configuration permits pg_rewind # check if the cluster's configuration permits pg_rewind
data = self.controldata() data = self.controldata()
if data: return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
return data.get('wal_log_hints setting', 'off') == 'on' or\
data.get('Data page checksum version', '0') != '0'
return False
def require_rewind(self): @property
self._need_rewind = True def sysid(self):
if not self._sysid:
data = self.controldata()
self._sysid = data.get('Database system identifier', "")
return self._sysid
def get_local_address(self): def get_local_address(self):
listen_addresses = self.listen_addresses.split(',') listen_addresses = self.listen_addresses.split(',')
@@ -118,18 +125,36 @@ class Postgresql:
break break
return local_address + ':' + self.port return local_address + ':' + self.port
def get_postgres_role_from_data_directory(self):
return 'replica' if os.path.exists(self.recovery_conf) else 'master'
@property
def _connect_kwargs(self):
r = parseurl('postgres://{0}/postgres'.format(self.local_address))
if 'username' in self.superuser:
r['user'] = self.superuser['username']
if 'password' in self.superuser:
r['password'] = self.superuser['password']
return r
def connection(self): def connection(self):
if not self._connection or self._connection.closed != 0: if not self._connection or self._connection.closed != 0:
r = parseurl('postgres://{}/postgres'.format(self.local_address)) self._connection = psycopg2.connect(**self._connect_kwargs)
self._connection = psycopg2.connect(**r)
self._connection.autocommit = True self._connection.autocommit = True
self.server_version = self._connection.server_version
return self._connection return self._connection
def _cursor(self): def _cursor(self):
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0: if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
logger.info("establishing a new patroni connection to the postgres cluster")
self._cursor_holder = self.connection().cursor() self._cursor_holder = self.connection().cursor()
return self._cursor_holder return self._cursor_holder
def close_connection(self):
if self._cursor_holder and self._cursor_holder.connection and self._cursor_holder.connection.closed == 0:
self._cursor_holder.connection.close()
logger.info("closed patroni connection to the postgresql cluster")
def _query(self, sql, *params): def _query(self, sql, *params):
cursor = None cursor = None
try: try:
@@ -152,9 +177,43 @@ class Postgresql:
def data_directory_empty(self): def data_directory_empty(self):
return not os.path.exists(self.data_dir) or os.listdir(self.data_dir) == [] return not os.path.exists(self.data_dir) or os.listdir(self.data_dir) == []
@staticmethod
def initdb_allowed_option(name):
if name in ['pgdata', 'nosync', 'pwfile', 'sync-only']:
raise Exception('{0} option for initdb is not allowed'.format(name))
return True
def get_initdb_options(self):
options = []
for o in self.initdb_options:
if isinstance(o, string_types) and self.initdb_allowed_option(o):
options.append('--{0}'.format(o))
elif isinstance(o, dict):
keys = list(o.keys())
if len(keys) != 1 or not isinstance(keys[0], string_types) or not self.initdb_allowed_option(keys[0]):
raise Exception('Invalid option: {0}'.format(o))
options.append('--{0}={1}'.format(keys[0], o[keys[0]]))
else:
raise Exception('Unknown type of initdb option: {0}'.format(o))
return options
def initialize(self): def initialize(self):
self.set_state('initalizing new cluster') self.set_state('initalizing new cluster')
ret = subprocess.call(self._pg_ctl + ['initdb', '-o', '--encoding=UTF8']) == 0 options = self.get_initdb_options()
pwfile = None
if self.superuser:
if 'username' in self.superuser:
options.append('--username={0}'.format(self.superuser['username']))
if 'password' in self.superuser:
(fd, pwfile) = tempfile.mkstemp()
os.write(fd, self.superuser['password'].encode('utf-8'))
os.close(fd)
options.append('--pwfile={0}'.format(pwfile))
ret = subprocess.call(self._pg_ctl + ['initdb'] + (['-o', ' '.join(options)] if options else [])) == 0
if pwfile:
os.remove(pwfile)
if ret: if ret:
self.write_pg_hba() self.write_pg_hba()
else: else:
@@ -162,39 +221,93 @@ class Postgresql:
return ret return ret
def delete_trigger_file(self): def delete_trigger_file(self):
os.path.exists(self.trigger_file) and os.unlink(self.trigger_file) if os.path.exists(self.trigger_file):
os.unlink(self.trigger_file)
def write_pgpass(self, record): def write_pgpass(self, record):
pgpass = 'pgpass' with open(self.pgpass, 'w') as f:
with open(pgpass, 'w') as f:
os.fchmod(f.fileno(), 0o600) os.fchmod(f.fileno(), 0o600)
f.write('{host}:{port}:*:{user}:{password}\n'.format(**record)) f.write('{host}:{port}:*:{user}:{password}\n'.format(**record))
env = os.environ.copy() env = os.environ.copy()
env['PGPASSFILE'] = pgpass env['PGPASSFILE'] = self.pgpass
return env return env
def sync_from_leader(self, leader): def sync_replica(self, clone_member):
r = parseurl(leader.conn_url) # add the credentials to connect to the replica origin to pgpass.
env = self.write_pgpass(parseurl(clone_member.conn_url)) if clone_member else os.environ.copy()
env = self.write_pgpass(r) if self.create_replica(clone_member, env) == 0:
return self.create_replica(r, env) == 0 self.delete_trigger_file()
return True
return False
@staticmethod @staticmethod
def build_connstring(conn): def build_connstring(conn):
return "host={host} port={port} user={user}".format(**conn) """
>>> Postgresql.build_connstring({'host': '127.0.0.1', 'port': '5432'}) == 'host=127.0.0.1 port=5432'
True
"""
return ' '.join('{0}={1}'.format(param, val) for param, val in sorted(conn.items()))
def replica_method_can_work_without_replication_connection(self, method):
return method != 'basebackup' and self.config and self.config.get(method, {}).get('no_master')
def can_create_replica_without_replication_connection(self):
""" go through the replication methods to see if there are ones
that does not require a working replication connection.
"""
replica_methods = self.config.get('create_replica_method', [])
return any(self.replica_method_can_work_without_replication_connection(replica_method)
for replica_method in replica_methods)
def create_replica(self, clone_member, env):
# create the replica according to the replica_method
# defined by the user. this is a list, so we need to
# loop through all methods the user supplies
connstring = clone_member.conn_url if clone_member else ""
# get list of replica methods from config.
# If there is no configuration key, or no value is specified, use basebackup
replica_methods = self.config.get('create_replica_method') or ['basebackup']
# if we don't have any source, leave only replica methods that work without it
replica_methods = replica_methods if clone_member else \
[r for r in replica_methods if self.replica_method_can_work_without_replication_connection(r)]
# go through them in priority order
ret = 1
for replica_method in replica_methods:
# if the method is basebackup, then use the built-in
if replica_method == "basebackup":
ret = self.basebackup(clone_member, env)
if ret == 0:
logger.info("replica has been created using basebackup")
# if basebackup succeeds, exit with success
break
else:
cmd = replica_method
method_config = {}
# user-defined method; check for configuration
# not required, actually
if replica_method in self.config:
method_config = self.config[replica_method].copy()
# look to see if the user has supplied a full command path
# if not, use the method name as the command
cmd = method_config.pop('command', cmd)
# add the default parameters
try:
method_config.update({"scope": self.scope,
"role": "replica",
"datadir": self.data_dir,
"connstring": connstring})
params = ["--{0}={1}".format(arg, val) for arg, val in method_config.items()]
# call script with the full set of parameters
ret = subprocess.call(shlex.split(cmd) + params, env=env)
# if we succeeded, stop
if ret == 0:
logger.info("replica has been created using {0}".format(replica_method))
break
except Exception as e:
logger.exception('Error creating replica using method {0}: {1}'.format(replica_method, str(e)))
ret = 1
def create_replica(self, master_connection, env):
self.set_state('building replica from {host}:{port}'.format(**master_connection))
connstring = self.build_connstring(master_connection)
cmd = self.config['restore']
try:
ret = subprocess.call(shlex.split(cmd) + [self.scope, "replica", self.data_dir, connstring], env=env)
self.delete_trigger_file()
except:
logger.exception('Error when creating replica')
ret = 1
if ret != 0:
self.set_state('failed to build replica from {host}:{port}'.format(**master_connection))
return ret return ret
def is_leader(self): def is_leader(self):
@@ -210,7 +323,7 @@ class Postgresql:
cmd = self.callback[cb_name] cmd = self.callback[cb_name]
try: try:
subprocess.Popen(shlex.split(cmd) + [cb_name, self.role, self.scope]) subprocess.Popen(shlex.split(cmd) + [cb_name, self.role, self.scope])
except: except OSError:
logger.exception('callback %s %s %s %s failed', cmd, cb_name, self.role, self.scope) logger.exception('callback %s %s %s %s failed', cmd, cb_name, self.role, self.scope)
return False return False
return True return True
@@ -238,7 +351,7 @@ class Postgresql:
logger.error('Cannot start PostgreSQL because one is already running.') logger.error('Cannot start PostgreSQL because one is already running.')
return True return True
self.set_role('replica' if os.path.exists(self.recovery_conf) else 'master') self.set_role(self.get_postgres_role_from_data_directory())
if os.path.exists(self.postmaster_pid): if os.path.exists(self.postmaster_pid):
os.remove(self.postmaster_pid) os.remove(self.postmaster_pid)
logger.info('Removed %s', self.postmaster_pid) logger.info('Removed %s', self.postmaster_pid)
@@ -246,7 +359,11 @@ class Postgresql:
if not block_callbacks: if not block_callbacks:
self.set_state('starting') self.set_state('starting')
ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0 env = {'PATH': os.environ.get('PATH')}
# pg_ctl will write a FATAL if the username is incorrect. exporting PGUSER if necessary
if 'username' in self.superuser and self.superuser['username'] != os.environ.get('USER'):
env['PGUSER'] = self.superuser['username']
ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()], env=env, preexec_fn=os.setsid) == 0
self.set_state('running' if ret else 'start failed') self.set_state('running' if ret else 'start failed')
@@ -254,29 +371,39 @@ class Postgresql:
self.save_configuration_files() self.save_configuration_files()
# block_callbacks is used during restart to avoid # block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones # running start/stop callbacks in addition to restart ones
ret and not block_callbacks and self.call_nowait(ACTION_ON_START) if ret and not block_callbacks:
self.call_nowait(ACTION_ON_START)
return ret return ret
def checkpoint(self): def checkpoint(self, connect_kwargs=None):
connect_kwargs = connect_kwargs or self._connect_kwargs
for p in ['connect_timeout', 'options']:
connect_kwargs.pop(p, None)
try: try:
r = parseurl('postgres://{}/postgres'.format(self.local_address)) with psycopg2.connect(**connect_kwargs) as conn:
r['options'] = '-c statement_timeout=0'
with psycopg2.connect(**r) as conn:
conn.autocommit = True conn.autocommit = True
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SET statement_timeout = 0")
cur.execute('CHECKPOINT') cur.execute('CHECKPOINT')
except: except psycopg2.Error:
logging.exception('Exception during CHECKPOINT') logging.exception('Exception during CHECKPOINT')
def stop(self, mode='fast', block_callbacks=False): def stop(self, mode='fast', block_callbacks=False, checkpoint=True):
# make sure we close all connections established against
# the former node, otherwise, we might get a stalled one
# after kill -9, which would report incorrect data to
# patroni.
self.close_connection()
if not self.is_running(): if not self.is_running():
if not block_callbacks: if not block_callbacks:
self.set_state('stopped') self.set_state('stopped')
return True return True
if block_callbacks: if checkpoint:
self.checkpoint() self.checkpoint()
else:
if not block_callbacks:
self.set_state('stopping') self.set_state('stopping')
ret = subprocess.call(self._pg_ctl + ['stop', '-m', mode]) == 0 ret = subprocess.call(self._pg_ctl + ['stop', '-m', mode]) == 0
@@ -291,7 +418,8 @@ class Postgresql:
def reload(self): def reload(self):
ret = subprocess.call(self._pg_ctl + ['reload']) == 0 ret = subprocess.call(self._pg_ctl + ['reload']) == 0
ret and self.call_nowait(ACTION_ON_RELOAD) if ret:
self.call_nowait(ACTION_ON_RELOAD)
return ret return ret
def restart(self): def restart(self):
@@ -300,13 +428,13 @@ class Postgresql:
if ret: if ret:
self.call_nowait(ACTION_ON_RESTART) self.call_nowait(ACTION_ON_RESTART)
else: else:
self.set_state('restart failed ({})'.format(self.state)) self.set_state('restart failed ({0})'.format(self.state))
return ret return ret
def server_options(self): def server_options(self):
options = "--listen_addresses='{}' --port={}".format(self.listen_addresses, self.port) options = "--listen_addresses='{0}' --port={1}".format(self.listen_addresses, self.port)
for setting, value in self.config['parameters'].items(): for setting, value in self.server_parameters.items():
options += " --{}='{}'".format(setting, value) options += " --{0}='{1}'".format(setting, value)
return options return options
def is_healthy(self): def is_healthy(self):
@@ -316,15 +444,11 @@ class Postgresql:
return True return True
def check_replication_lag(self, last_leader_operation): def check_replication_lag(self, last_leader_operation):
return last_leader_operation - self.xlog_position() <= self.config.get('maximum_lag_on_failover', 0) return (last_leader_operation or 0) - self.xlog_position() <= self.config.get('maximum_lag_on_failover', 0)
def write_pg_hba(self): def write_pg_hba(self):
with open(os.path.join(self.data_dir, 'pg_hba.conf'), 'a') as f: with open(os.path.join(self.data_dir, 'pg_hba.conf'), 'a') as f:
f.write('\nhost replication {username} {network} md5\n'.format(**self.replication)) f.write('\n{}\n'.format('\n'.join(self.config.get('pg_hba', []))))
for line in self.config.get('pg_hba', []):
if line.split()[0].strip() == 'hostssl' and self.config['parameters'].get('ssl', 'off').lower() != 'on':
continue
f.write(line + '\n')
@staticmethod @staticmethod
def primary_conninfo(leader_url): def primary_conninfo(leader_url):
@@ -345,28 +469,30 @@ class Postgresql:
def write_recovery_conf(self, leader): def write_recovery_conf(self, leader):
with open(self.recovery_conf, 'w') as f: with open(self.recovery_conf, 'w') as f:
f.write("""standby_mode = 'on' f.write("standby_mode = 'on'\nrecovery_target_timeline = 'latest'\n")
recovery_target_timeline = 'latest'
""")
if leader and leader.conn_url: if leader and leader.conn_url:
f.write("""primary_conninfo = '{}'\n""".format(self.primary_conninfo(leader.conn_url))) f.write("primary_conninfo = '{0}'\n".format(self.primary_conninfo(leader.conn_url)))
if self.use_slots: if self.use_slots:
f.write("""primary_slot_name = '{}'\n""".format(self.name)) f.write("primary_slot_name = '{0}'\n".format(self.name))
for name, value in self.config.get('recovery_conf', {}).items(): for name, value in self.config.get('recovery_conf', {}).items():
f.write("{} = '{}'\n".format(name, value)) if name not in ('standby_mode', 'recovery_target_timeline', 'primary_conninfo', 'primary_slot_name'):
f.write("{0} = '{1}'\n".format(name, value))
def rewind(self, leader): def rewind(self, leader):
# prepare pg_rewind connection # prepare pg_rewind connection
r = parseurl(leader.conn_url) r = parseurl(leader.conn_url)
r.update(self.pg_rewind) r.update(self.pg_rewind)
r['user'] = r['username'] r['user'] = r.pop('username')
env = self.write_pgpass(r) env = self.write_pgpass(r)
pc = "user={user} host={host} port={port} dbname=postgres sslmode=prefer sslcompression=1".format(**r) pc = "user={user} host={host} port={port} dbname=postgres sslmode=prefer sslcompression=1".format(**r)
logger.info("running pg_rewind from {}".format(pc)) # first run a checkpoint on a promoted master in order
# to make it store the new timeline ([email protected])
self.checkpoint(r)
logger.info("running pg_rewind from %s", pc)
pg_rewind = ['pg_rewind', '-D', self.data_dir, '--source-server', pc] pg_rewind = ['pg_rewind', '-D', self.data_dir, '--source-server', pc]
try: try:
ret = (subprocess.call(pg_rewind, env=env) == 0) ret = subprocess.call(pg_rewind, env=env) == 0
except: except OSError:
ret = False ret = False
if ret: if ret:
self.write_recovery_conf(leader) self.write_recovery_conf(leader)
@@ -378,12 +504,11 @@ recovery_target_timeline = 'latest'
try: try:
data = subprocess.check_output(['pg_controldata', self.data_dir]) data = subprocess.check_output(['pg_controldata', self.data_dir])
if data: if data:
data = data.splitlines() data = data.decode('utf-8').splitlines()
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':')[1].strip() for l in data if l} result = {l.split(':')[0].replace('Current ', '', 1): l.split(':')[1].strip() for l in data if l}
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
logger.exception("Error when calling pg_controldata") logger.exception("Error when calling pg_controldata")
finally: return result
return result
def read_postmaster_opts(self): def read_postmaster_opts(self):
""" returns the list of option names/values from postgres.opts, Empty dict if read failed or no file """ """ returns the list of option names/values from postgres.opts, Empty dict if read failed or no file """
@@ -399,26 +524,26 @@ recovery_target_timeline = 'latest'
result[name] = val result[name] = val
except IOError: except IOError:
logger.exception('Error when reading postmaster.opts') logger.exception('Error when reading postmaster.opts')
finally: return result
return result
def single_user_mode(self, command=None, options={}): def single_user_mode(self, command=None, options=None):
""" run a given command in a single-user mode. If the command is empty - then just start and stop """ """ run a given command in a single-user mode. If the command is empty - then just start and stop """
cmd = ['postgres', '--single', '-D', self.data_dir] cmd = ['postgres', '--single', '-D', self.data_dir]
for opt in sorted(options): for opt, val in sorted((options or {}).items()):
cmd.extend(['-c', '{0}={1}'.format(opt, options[opt])]) cmd.extend(['-c', '{0}={1}'.format(opt, val)])
# need a database name to connect # need a database name to connect
cmd.append('postgres') cmd.append('postgres')
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT) p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
if p: if p:
command and p.communicate('{}\n'.format(command)) if command:
p.communicate('{0}\n'.format(command))
p.stdin.close() p.stdin.close()
return p.wait() return p.wait()
return 1 return 1
def cleanup_archive_status(self): def cleanup_archive_status(self):
status_dir = os.path.join(self.data_dir, 'pg_xlog', 'archive_status') status_dir = os.path.join(self.data_dir, 'pg_xlog', 'archive_status')
if os.path.isdir(status_dir): try:
for f in os.listdir(status_dir): for f in os.listdir(status_dir):
path = os.path.join(status_dir, f) path = os.path.join(status_dir, f)
try: try:
@@ -426,65 +551,70 @@ recovery_target_timeline = 'latest'
os.unlink(path) os.unlink(path)
elif os.path.isfile(path): elif os.path.isfile(path):
os.remove(path) os.remove(path)
except: except OSError:
logger.exception("Unable to remove {}".format(path)) logger.exception("Unable to remove %s", path)
except OSError:
logger.exception("Unable to list %s", status_dir)
def follow_the_leader(self, leader, recovery=False): def follow(self, leader, recovery=False):
if not self.check_recovery_conf(leader) or recovery: if self.check_recovery_conf(leader) and not recovery:
change_role = (self.role == 'master')
self._need_rewind = (self._need_rewind or change_role) and self.can_rewind
if self._need_rewind:
logger.info("set the rewind flag after demote")
self.write_recovery_conf(leader)
if not leader or not self._need_rewind: # do not rewind until the leader becomes available
ret = self.restart()
else: # we have a leader and need to rewind
if self.is_running():
self.stop()
# at present, pg_rewind only runs when the cluster is shut down cleanly
# and not shutdown in recovery. We have to remove the recovery.conf if present
# and start/shutdown in a single user mode to emulate this.
# XXX: if recovery.conf is linked, it will be written anew as a normal file.
if os.path.islink(self.recovery_conf):
os.unlink(self.recovery_conf)
else:
os.remove(self.recovery_conf)
# Archived segments might be useful to pg_rewind,
# clean the flags that tell we should remove them.
self.cleanup_archive_status()
# Start in a single user mode and stop to produce a clean shutdown
opts = self.read_postmaster_opts()
opts['archive_mode'] = 'on'
opts['archive_command'] = 'false'
self.single_user_mode(options=opts)
if self.rewind(leader):
ret = self.start()
else:
logger.error("unable to rewind the former master")
self.remove_data_directory()
ret = True
self._need_rewind = False
change_role and ret and self.call_nowait(ACTION_ON_ROLE_CHANGE)
return ret
else:
return True return True
change_role = self.role == 'master'
need_rewind = change_role and self.can_rewind
if need_rewind:
logger.info("set the rewind flag after demote")
self.write_recovery_conf(leader)
if leader and need_rewind: # we have a leader and need to rewind
if self.is_running():
self.stop()
# at present, pg_rewind only runs when the cluster is shut down cleanly
# and not shutdown in recovery. We have to remove the recovery.conf if present
# and start/shutdown in a single user mode to emulate this.
# XXX: if recovery.conf is linked, it will be written anew as a normal file.
if os.path.islink(self.recovery_conf):
os.unlink(self.recovery_conf)
else:
os.remove(self.recovery_conf)
# Archived segments might be useful to pg_rewind,
# clean the flags that tell we should remove them.
self.cleanup_archive_status()
# Start in a single user mode and stop to produce a clean shutdown
opts = self.read_postmaster_opts()
opts.update({'archive_mode': 'on', 'archive_command': 'false'})
self.single_user_mode(options=opts)
if self.rewind(leader):
ret = self.start()
else:
logger.error("unable to rewind the former master")
self.remove_data_directory()
ret = True
else: # do not rewind until the leader becomes available
ret = self.restart()
if change_role and ret:
self.call_nowait(ACTION_ON_ROLE_CHANGE)
return ret
def save_configuration_files(self): def save_configuration_files(self):
""" """
copy postgresql.conf to postgresql.conf.backup to preserve it in the WAL-e backup. copy postgresql.conf to postgresql.conf.backup to be able to retrive configuration files
see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239 - originally stored as symlinks, those are normally skipped by pg_basebackup
- in case of WAL-E basebackup (see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239)
""" """
for f in self.configuration_to_save: try:
shutil.copy(f, f + '.backup') for f in self.configuration_to_save:
if os.path.isfile(f):
shutil.copy(f, f + '.backup')
except IOError:
logger.exception('unable to create backup copies of configuration files')
def restore_configuration_files(self): def restore_configuration_files(self):
""" restore a previously saved postgresql.conf """ """ restore a previously saved postgresql.conf """
try: try:
for f in self.configuration_to_save: for f in self.configuration_to_save:
shutil.copy(f + '.backup', f) if not os.path.isfile(f) and os.path.isfile(f + '.backup'):
except: shutil.copy(f + '.backup', f)
logger.exception('unable to restore configuration from WAL-E backup') except IOError:
logger.exception('unable to restore configuration files from backup')
def promote(self): def promote(self):
if self.role == 'master': if self.role == 'master':
@@ -493,28 +623,28 @@ recovery_target_timeline = 'latest'
if ret: if ret:
self.set_role('master') self.set_role('master')
logger.info("cleared rewind flag after becoming the leader") logger.info("cleared rewind flag after becoming the leader")
self._need_rewind = False
self.call_nowait(ACTION_ON_ROLE_CHANGE) self.call_nowait(ACTION_ON_ROLE_CHANGE)
return ret return ret
def demote(self): def create_or_update_role(self, name, password, options):
self.follow_the_leader(None) self.query("""DO $$
BEGIN
SET local synchronous_commit = 'local';
PERFORM * FROM pg_authid WHERE rolname = %s;
IF FOUND THEN
ALTER ROLE "{0}" WITH LOGIN {1} PASSWORD %s;
ELSE
CREATE ROLE "{0}" WITH LOGIN {1} PASSWORD %s;
END IF;
END;
$$""".format(name, options), name, password, password)
def create_replication_user(self): def create_replication_user(self):
self.query('CREATE USER "{}" WITH REPLICATION ENCRYPTED PASSWORD %s'.format( self.create_or_update_role(self.replication['username'], self.replication['password'], 'REPLICATION')
self.replication['username']), self.replication['password'])
def create_connection_users(self): def create_connection_user(self):
if self.superuser:
if 'username' in self.superuser:
self.query('CREATE ROLE "{0}" WITH LOGIN SUPERUSER PASSWORD %s'.format(
self.superuser['username']), self.superuser['password'])
else:
rolsuper = self.query("""SELECT rolname FROM pg_authid WHERE rolsuper = 't'""").fetchone()[0]
self.query('ALTER ROLE "{0}" WITH PASSWORD %s'.format(rolsuper), self.superuser['password'])
if self.admin: if self.admin:
self.query('CREATE ROLE "{0}" WITH LOGIN CREATEDB CREATEROLE PASSWORD %s'.format( self.create_or_update_role(self.admin['username'], self.admin['password'], 'CREATEDB CREATEROLE')
self.admin['username']), self.admin['password'])
def xlog_position(self): def xlog_position(self):
return self.query("""SELECT pg_xlog_location_diff(CASE WHEN pg_is_in_recovery() return self.query("""SELECT pg_xlog_location_diff(CASE WHEN pg_is_in_recovery()
@@ -532,7 +662,18 @@ recovery_target_timeline = 'latest'
if self.use_slots: if self.use_slots:
try: try:
self.load_replication_slots() self.load_replication_slots()
slots = [m.name for m in cluster.members if m.name != self.name] if self.role == 'master' else [] # if the replicatefrom tag is set on the member - we should not create the replication slot for it on
# the current master, because that member would replicate from elsewhere. We still create the slot if
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
# master), or if replicatefrom destination member happens to be the current master
if self.role == 'master':
slots = [m.name for m in cluster.members if m.name != self.name and
(m.replicatefrom is None or m.replicatefrom == self.name or
not cluster.has_member(m.replicatefrom))]
else:
# only manage slots for replicas that replicate from this one, except for the leader among them
slots = [m.name for m in cluster.members if m.replicatefrom == self.name and
m.name != cluster.leader.name]
# drop unused slots # drop unused slots
for slot in set(self.replication_slots) - set(slots): for slot in set(self.replication_slots) - set(slots):
self.query("""SELECT pg_drop_replication_slot(%s) self.query("""SELECT pg_drop_replication_slot(%s)
@@ -546,33 +687,45 @@ recovery_target_timeline = 'latest'
WHERE slot_name = %s)""", slot, slot) WHERE slot_name = %s)""", slot, slot)
self.replication_slots = slots self.replication_slots = slots
except: except psycopg2.Error:
logger.exception('Exception when changing replication slots') logger.exception('Exception when changing replication slots')
def last_operation(self): def last_operation(self):
return str(self.xlog_position()) return str(self.xlog_position())
def bootstrap(self, current_leader=None): def bootstrap(self, cluster_initialized=False, clone_member=None):
""" """
Initially bootstrap PostgreSQL, either by creating a data Populate PostgreSQL data directory by doing one of the following:
directory with initdb, or by initalizing a replica from an - create with initdb if there is no master.
exiting leader. Failure in the first case always leads to - initialize the replica from an existing member (master or replica)
exception, since there is no point in continuing if initdb failed. - initialize the replica using the replica creation method that
In the second case, however, a False is returned on failure, since works without the replication connection (i.e. restore from on-disk
it is normal for the replica to retry a failed attempt to initialize base backup)
from the master.
The choice between the last 2 is triggered by the initialize flag.
We should never try to initdb an already initialized cluster, nor
try to bootstrap the cluster that lacks the initialize key using the
master-less replica creation method (in the latter case, there is
no clear inidicator of the moment we should abandon our attempts and
swich to initdb).
Failure during initdb always leads to an exception, since there is
no point in continuing if initdb fails. For the rest of the cases,
the function returns False in order to inidicate a failed attempt
that should be retried in the future.
""" """
ret = False ret = False
if not current_leader: if not (cluster_initialized or clone_member):
ret = self.initialize() and self.start() ret = self.initialize() and self.start()
if ret: if ret:
self.create_replication_user() self.create_replication_user()
self.create_connection_users() self.create_connection_user()
else: else:
raise PostgresException("Could not bootstrap master PostgreSQL") raise PostgresException("Could not bootstrap master PostgreSQL")
else: else:
if self.sync_from_leader(current_leader): if self.sync_replica(clone_member):
self.write_recovery_conf(current_leader) self.restore_configuration_files()
self.write_recovery_conf(clone_member)
ret = self.start() ret = self.start()
return ret return ret
@@ -582,7 +735,7 @@ recovery_target_timeline = 'latest'
new_name = '{0}_{1}'.format(self.data_dir, time.strftime('%Y-%m-%d-%H-%M-%S')) new_name = '{0}_{1}'.format(self.data_dir, time.strftime('%Y-%m-%d-%H-%M-%S'))
logger.info('renaming data directory to %s', new_name) logger.info('renaming data directory to %s', new_name)
os.rename(self.data_dir, new_name) os.rename(self.data_dir, new_name)
except: except OSError:
logger.exception("Could not rename data directory %s", self.data_dir) logger.exception("Could not rename data directory %s", self.data_dir)
def remove_data_directory(self): def remove_data_directory(self):
@@ -596,6 +749,30 @@ recovery_target_timeline = 'latest'
os.remove(self.data_dir) os.remove(self.data_dir)
elif os.path.isdir(self.data_dir): elif os.path.isdir(self.data_dir):
shutil.rmtree(self.data_dir) shutil.rmtree(self.data_dir)
except: except (IOError, OSError):
logger.exception('Could not remove data directory %s', self.data_dir) logger.exception('Could not remove data directory %s', self.data_dir)
self.move_data_directory() self.move_data_directory()
def basebackup(self, clone_member, env):
# creates a replica data dir using pg_basebackup.
# this is the default, built-in create_replica_method
# tries twice, then returns failure (as 1)
# uses "stream" as the xlog-method to avoid sync issues
master_connection = clone_member.conn_url
maxfailures = 2
ret = 1
for bbfailures in range(0, maxfailures):
try:
ret = subprocess.call(['pg_basebackup', '--pgdata=' + self.data_dir,
'--xlog-method=stream', "--dbname=" + master_connection], env=env)
if ret == 0:
break
except Exception as e:
logger.error('Error when fetching backup with pg_basebackup: {0}'.format(e))
if bbfailures < maxfailures - 1:
logger.error('Trying again in 5 seconds')
time.sleep(5)
return ret
+7 -3
View File
@@ -9,7 +9,7 @@ import boto.ec2
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AWSConnection: class AWSConnection(object):
def __init__(self, cluster_name): def __init__(self, cluster_name):
self.available = False self.available = False
self.cluster_name = cluster_name if cluster_name is not None else 'unknown' self.cluster_name = cluster_name if cluster_name is not None else 'unknown'
@@ -56,7 +56,7 @@ class AWSConnection:
conn = boto.ec2.connect_to_region(self.region) conn = boto.ec2.connect_to_region(self.region)
conn.create_tags([self.instance_id], tags) conn.create_tags([self.instance_id], tags)
except Exception as e: except Exception as e:
logger.info("could not set tags for EC2 instance {}: {}".format(self.instance_id, e)) logger.info("could not set tags for EC2 instance %s: %s", self.instance_id, e)
return False return False
return True return True
@@ -65,8 +65,12 @@ class AWSConnection:
return self._tag_ebs(new_role) and ret return self._tag_ebs(new_role) and ret
if __name__ == '__main__': def main():
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'): if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'):
AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2]) AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2])
else: else:
sys.exit("Usage: {0} action role name".format(sys.argv[0])) sys.exit("Usage: {0} action role name".format(sys.argv[0]))
if __name__ == '__main__':
main()
-216
View File
@@ -1,216 +0,0 @@
#!/usr/bin/env python
# arguments are:
# - cluster scope
# - cluster role
# - master connection string
# for the AWS, the folliowing environment variables should be defined:
# - WALE_ENV_DIR: directory where WAL-E environment is kept
# - WAL_S3_BUCKET: a name of the S3 bucket for WAL-E
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
# latest backup size
from collections import namedtuple
import logging
import os
import psycopg2
import subprocess
import sys
if sys.hexversion >= 0x03000000:
long = int
logger = logging.getLogger(__name__)
class Restore(object):
def __init__(self, scope, role, datadir, connstring, env=None):
self.scope = scope
self.role = role
self.master_connection = Restore.parse_connstring(connstring)
self.data_dir = datadir
self.env = os.environ.copy() if not env else env
@staticmethod
def parse_connstring(connstring):
# the connection string is in the form host= port= user=
# return the dictionary with all components as separare keys
result = {}
if connstring:
for x in connstring.split():
if x and '=' in x:
key, val = x.split('=')
result[key.strip()] = val.strip()
return result
def setup(self):
pass
def replica_method(self):
return self.create_replica_with_pg_basebackup
def replica_fallback_method(self):
return None
def run(self):
""" creates a new replica using either pg_basebackup or WAL-E """
method_fn = self.replica_method()
ret = method_fn() if method_fn else 1
if ret != 0 and self.replica_fallback_method() is not None:
ret = (self.replica_fallback_method())()
return ret
def create_replica_with_pg_basebackup(self):
try:
ret = subprocess.call(['pg_basebackup', '-R', '-D',
self.data_dir, '--host=' + self.master_connection['host'],
'--port=' + str(self.master_connection['port']),
'-U', self.master_connection['user']],
env=self.env)
except Exception as e:
logger.error('Error when fetching backup with pg_basebackup: {0}'.format(e))
return 1
return ret
class WALERestore(Restore):
def __init__(self, scope, role, datadir, connstring, env=None):
super(WALERestore, self).__init__(scope, role, datadir, connstring, env)
# check the environment variables
self.init_error = False
def setup(self):
if (self.env.get('WAL_S3_BUCKET') and
self.env.get('WALE_BACKUP_THRESHOLD_PERCENTAGE') and
self.env.get('WALE_BACKUP_THRESHOLD_MEGABYTES')) is None:
self.init_error = True
else:
self.wal_e = namedtuple('WALE',
'threshold_megabytes threshold_backup_size_percentage s3_bucket cmd dir env_file')
self.wal_e.dir = self.env.get('WALE_ENV_DIR', '/home/postgres/etc/wal-e.d/env')
self.wal_e.env_file = os.path.join(self.wal_e.dir, 'WALE_S3_PREFIX')
self.wal_e.cmd = 'envdir {} wal-e --aws-instance-profile '.\
format(self.wal_e.dir)
self.wal_e.s3_bucket = self.env['WAL_S3_BUCKET']
self.wal_e.threshold_megabytes = self.env['WALE_BACKUP_THRESHOLD_MEGABYTES']
self.wal_e.threshold_backup_size_percentage = self.env['WALE_BACKUP_THRESHOLD_PERCENTAGE']
# check that the env file exists, create it otherwise
try:
if not os.path.exists(self.wal_e.dir):
os.makedirs(self.wal_e.dir)
# if this is a directory - make sure we have full access there
elif not (os.path.isdir(self.wal_e.dir) and os.access(self.wal_e.dir, os.R_OK | os.W_OK | os.X_OK)):
logger.error("Unable to access {} or not a directory".format(self.wal_e.dir))
self.init_error = True
# if WAL_S3_PREFIX is not there - create it and write the full path to bucket
if not self.init_error and not os.path.exists(self.wal_e.env_file):
with open(self.wal_e.env_file, 'w') as f:
f.write("s3://{0}/spilo/{1}/wal/\n".format(self.wal_e.s3_bucket, self.scope))
except (os.error, IOError) as e:
logger.error("{0}: WAL-e archiving is disabled".format(e))
self.init_error = True
def replica_method(self):
if self.should_use_s3_to_create_replica():
return self.create_replica_with_s3
return None
def replica_fallback_method(self):
return self.create_replica_with_pg_basebackup
def should_use_s3_to_create_replica(self):
""" determine whether it makes sense to use S3 and not pg_basebackup """
if self.init_error:
return False
threshold_megabytes = self.wal_e.threshold_megabytes
threshold_backup_size_percentage = self.wal_e.threshold_backup_size_percentage
try:
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'],
env=self.env)
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
# wal_segment_backup_stop wal_segment_offset_backup_stop
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
# 20310671 00000001000000000000007F 00000040
# 00000001000000000000007F 00000240
backup_strings = latest_backup.splitlines() if latest_backup else ()
if len(backup_strings) != 2:
return False
names = backup_strings[0].split()
vals = backup_strings[1].split()
if (len(names) != len(vals)) or (len(names) != 7):
return False
backup_info = dict(zip(names, vals))
except subprocess.CalledProcessError as e:
logger.error("could not query wal-e latest backup: {}".format(e))
return False
try:
backup_size = backup_info['expanded_size_bytes']
backup_start_segment = backup_info['wal_segment_backup_start']
backup_start_offset = backup_info['wal_segment_offset_backup_start']
except Exception as e:
logger.error("unable to get some of S3 backup parameters: {}".format(e))
return False
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
# that we have to convert to hex and 'prepend' to the high offset digits.
lsn_segment = backup_start_segment[8:16]
# first 2 characters of the result are 0x and the last one is L
lsn_offset = hex((long(backup_start_segment[16:32], 16) << 24) + long(backup_start_offset))[2:-1]
# construct the LSN from the segment and offset
backup_start_lsn = '{}/{}'.format(lsn_segment, lsn_offset)
conn = None
cursor = None
diff_in_bytes = long(backup_size)
try:
# get the difference in bytes between the current WAL location and the backup start offset
conn = psycopg2.connect(**(self.master_connection))
conn.autocommit = True
cursor = conn.cursor()
cursor.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)", (backup_start_lsn,))
diff_in_bytes = long(cursor.fetchone()[0])
except psycopg2.Error as e:
logger.error('could not determine difference with the master location: {}'.format(e))
return False
finally:
cursor and cursor.close()
conn and conn.close()
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\
(diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100)
def create_replica_with_s3(self):
if self.init_error:
return 1
try:
ret = subprocess.call(self.wal_e.cmd + ' backup-fetch {} LATEST'.format(self.data_dir), env=self.env)
except Exception as e:
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
return 1
return ret
if __name__ == '__main__':
if len(sys.argv) == 5:
# scope, role, datadir, connstring
restore = WALERestore(*(sys.argv[1:]))
restore.setup()
sys.exit(restore.run())
sys.exit("Usage: {0} scope role datadir connstring".format(sys.argv[0]))
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python
# sample script to clone new replicas using WAL-E restore
# falls back to pg_basebackup if WAL-E restore fails, or if
# WAL-E backup is too far behind
# note that pg_basebackup still expects to use restore from
# WAL-E for transaction logs
# theoretically should work with SWIFT, but not tested on it
# arguments are:
# - cluster scope
# - cluster role
# - master connection string
# - number of retries
# - envdir for the WALE env
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
# this script depends on an envdir defining the S3 bucket (or SWIFT dir),and login
# credentials per WALE Documentation.
# currently also requires that you configure the restore_command to use wal_e, example:
# recovery_conf:
# restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
from collections import namedtuple
import logging
import os
import psycopg2
import subprocess
import sys
import argparse
if sys.hexversion >= 0x03000000:
long = int
logger = logging.getLogger(__name__)
class WALERestore(object):
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb, threshold_pct, use_iam, no_master):
self.scope = scope
self.master_connection = connstring
self.data_dir = datadir
self.wal_e = namedtuple('wale', 'dir,threshold_mb,threshold_pct,iam_string,cmd')
self.wal_e.dir = env_dir
self.wal_e.threshold_mb = threshold_mb
self.wal_e.threshold_pct = threshold_pct
self.wal_e.iam_string = ' --aws-instance-profile ' if use_iam == 1 else ''
self.no_master = no_master
self.wal_e.cmd = 'envdir {0} wal-e {1} '.format(self.wal_e.dir, self.wal_e.iam_string)
self.init_error = (not os.path.exists(self.wal_e.dir))
def run(self):
""" creates a new replica using WAL-E """
if not self.init_error and self.should_use_s3_to_create_replica():
return self.create_replica_with_s3()
return 2
def should_use_s3_to_create_replica(self):
""" determine whether it makes sense to use S3 and not pg_basebackup """
threshold_megabytes = self.wal_e.threshold_mb
threshold_backup_size_percentage = self.wal_e.threshold_pct
try:
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'])
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
# wal_segment_backup_stop wal_segment_offset_backup_stop
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
# 20310671 00000001000000000000007F 00000040
# 00000001000000000000007F 00000240
backup_strings = latest_backup.splitlines() if latest_backup else ()
if len(backup_strings) != 2:
return False
names = backup_strings[0].split()
vals = backup_strings[1].split()
if (len(names) != len(vals)) or (len(names) != 7):
return False
backup_info = dict(zip(names, vals))
except subprocess.CalledProcessError as e:
logger.error("could not query wal-e latest backup: {}".format(e))
return False
try:
backup_size = backup_info['expanded_size_bytes']
backup_start_segment = backup_info['wal_segment_backup_start']
backup_start_offset = backup_info['wal_segment_offset_backup_start']
except Exception as e:
logger.error("unable to get some of WALE backup parameters: {}".format(e))
return False
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
# that we have to convert to hex and 'prepend' to the high offset digits.
lsn_segment = backup_start_segment[8:16]
# first 2 characters of the result are 0x and the last one is L
lsn_offset = hex((long(backup_start_segment[16:32], 16) << 24) + long(backup_start_offset))[2:-1]
# construct the LSN from the segment and offset
backup_start_lsn = '{0}/{1}'.format(lsn_segment, lsn_offset)
diff_in_bytes = long(backup_size)
if not self.no_master:
try:
# get the difference in bytes between the current WAL location and the backup start offset
with psycopg2.connect(self.master_connection) as con:
con.autocommit = True
with con.cursor() as cur:
cur.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)", (backup_start_lsn,))
diff_in_bytes = long(cur.fetchone()[0])
except psycopg2.Error as e:
logger.error('could not determine difference with the master location: %s', e)
return False
else:
# always try to use WAL-E if base backup is available
diff_in_bytes = 0
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\
(diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100)
def create_replica_with_s3(self):
# if we're set up, restore the replica using fetch latest
try:
ret = subprocess.call(self.wal_e.cmd.split() + ['backup-fetch', '{}'.format(self.data_dir), 'LATEST'])
except Exception as e:
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
return 1
return ret
def main():
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
parser = argparse.ArgumentParser(description='Script to image replicas using WAL-E')
parser.add_argument('--scope', required=True)
parser.add_argument('--role', required=False)
parser.add_argument('--datadir', required=True)
parser.add_argument('--connstring', required=True)
parser.add_argument('--retries', type=int, default=1)
parser.add_argument('--envdir', required=True)
parser.add_argument('--threshold_megabytes', type=int, default=10240)
parser.add_argument('--threshold_backup_size_percentage', type=int, default=30)
parser.add_argument('--use_iam', type=int, default=0)
parser.add_argument('--no_master', type=int, default=0)
args = parser.parse_args()
# retry cloning in a loop
for _ in range(0, args.retries + 1):
restore = WALERestore(scope=args.scope, datadir=args.datadir, connstring=args.connstring,
env_dir=args.envdir, threshold_mb=args.threshold_megabytes,
threshold_pct=args.threshold_backup_size_percentage, use_iam=args.use_iam,
no_master=args.no_master)
ret = restore.run()
if ret == 0:
break
sys.exit(ret)
if __name__ == '__main__':
main()
+29 -40
View File
@@ -1,76 +1,65 @@
import datetime import datetime
import os import os
import random import random
import re
import signal import signal
import sys import sys
import time import time
import pytz
import dateutil.parser
from patroni.exceptions import PatroniException from patroni.exceptions import PatroniException
ignore_sigterm = False __ignore_sigterm = False
interrupted_sleep = False __interrupted_sleep = False
reap_children = False __reap_children = False
_DATE_TIME_RE = re.compile(r'''^
(?P<year>\d{4})\-(?P<month>\d{2})\-(?P<day>\d{2}) # date
T
(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})\.(?P<microsecond>\d{6}) # time
\d*Z$''', re.X)
def parse_datetime(time_str):
"""
>>> parse_datetime('2015-06-10T12:56:30.552539016Z')
datetime.datetime(2015, 6, 10, 12, 56, 30, 552539)
>>> parse_datetime('2015-06-10 12:56:30.552539016Z')
"""
m = _DATE_TIME_RE.match(time_str)
if not m:
return None
p = dict((n, int(m.group(n))) for n in 'year month day hour minute second microsecond'.split(' '))
return datetime.datetime(**p)
def calculate_ttl(expiration): def calculate_ttl(expiration):
""" """
>>> calculate_ttl(None) >>> calculate_ttl(None)
>>> calculate_ttl('2015-06-10 12:56:30.552539016Z') >>> calculate_ttl('2015-06-10 12:56:30.552539016Z') < 0
True
>>> calculate_ttl('2015-06-10T12:56:30.552539016Z') < 0 >>> calculate_ttl('2015-06-10T12:56:30.552539016Z') < 0
True True
>>> calculate_ttl('fail-06-10T12:56:30.552539016Z')
""" """
if not expiration: if not expiration:
return None return None
expiration = parse_datetime(expiration) try:
if not expiration: expiration = dateutil.parser.parse(expiration)
except (ValueError, TypeError):
return None return None
now = datetime.datetime.utcnow() now = datetime.datetime.now(pytz.utc)
return int((expiration - now).total_seconds()) return int((expiration - now).total_seconds())
def set_ignore_sigterm(value=True):
global __ignore_sigterm
__ignore_sigterm = value
def sigterm_handler(signo, stack_frame): def sigterm_handler(signo, stack_frame):
global ignore_sigterm if not __ignore_sigterm:
if not ignore_sigterm: set_ignore_sigterm()
ignore_sigterm = True
sys.exit() sys.exit()
def sigchld_handler(signo, stack_frame): def sigchld_handler(signo, stack_frame):
global interrupted_sleep, reap_children global __interrupted_sleep, __reap_children
reap_children = interrupted_sleep = True __reap_children = __interrupted_sleep = True
def sleep(interval): def sleep(interval):
global interrupted_sleep global __interrupted_sleep
current_time = time.time() current_time = time.time()
end_time = current_time + interval end_time = current_time + interval
while current_time < end_time: while current_time < end_time:
interrupted_sleep = False __interrupted_sleep = False
time.sleep(end_time - current_time) time.sleep(end_time - current_time)
if not interrupted_sleep: # we will ignore only sigchld if not __interrupted_sleep: # we will ignore only sigchld
break break
current_time = time.time() current_time = time.time()
interrupted_sleep = False __interrupted_sleep = False
def setup_signal_handlers(): def setup_signal_handlers():
@@ -79,8 +68,8 @@ def setup_signal_handlers():
def reap_children(): def reap_children():
global reap_children global __reap_children
if reap_children: if __reap_children:
try: try:
while True: while True:
ret = os.waitpid(-1, os.WNOHANG) ret = os.waitpid(-1, os.WNOHANG)
@@ -89,7 +78,7 @@ def reap_children():
except OSError: except OSError:
pass pass
finally: finally:
reap_children = False __reap_children = False
class RetryFailedError(PatroniException): class RetryFailedError(PatroniException):
@@ -97,7 +86,7 @@ class RetryFailedError(PatroniException):
"""Raised when retrying an operation ultimately failed, after retrying the maximum number of attempts.""" """Raised when retrying an operation ultimately failed, after retrying the maximum number of attempts."""
class Retry: class Retry(object):
"""Helper for retrying a method in the face of retry-able exceptions""" """Helper for retrying a method in the face of retry-able exceptions"""
+1 -1
View File
@@ -1 +1 @@
__version__ = '0.2' __version__ = '0.90'
+58 -54
View File
@@ -17,7 +17,7 @@ class ZooKeeperError(DCSError):
pass pass
class ExhibitorEnsembleProvider: class ExhibitorEnsembleProvider(object):
TIMEOUT = 3.1 TIMEOUT = 3.1
@@ -54,7 +54,7 @@ class ExhibitorEnsembleProvider:
def _query_exhibitors(self, exhibitors): def _query_exhibitors(self, exhibitors):
random.shuffle(exhibitors) random.shuffle(exhibitors)
for host in exhibitors: for host in exhibitors:
uri = 'http://{}:{}{}'.format(host, self._exhibitor_port, self._uri_path) uri = 'http://{0}:{1}{2}'.format(host, self._exhibitor_port, self._uri_path)
try: try:
response = requests.get(uri, timeout=self.TIMEOUT) response = requests.get(uri, timeout=self.TIMEOUT)
return response.json() return response.json()
@@ -83,32 +83,29 @@ class ZooKeeper(AbstractDCS):
self.exhibitor = ExhibitorEnsembleProvider(exhibitor['hosts'], exhibitor['port'], poll_interval=interval) self.exhibitor = ExhibitorEnsembleProvider(exhibitor['hosts'], exhibitor['port'], poll_interval=interval)
hosts = self.exhibitor.zookeeper_hosts hosts = self.exhibitor.zookeeper_hosts
self.client = KazooClient(hosts=hosts, self._client = KazooClient(hosts=hosts, timeout=(config.get('session_timeout') or 30),
timeout=(config.get('session_timeout', None) or 30), command_retry={'deadline': (config.get('reconnect_timeout') or 10),
command_retry={ 'max_delay': 1, 'max_tries': -1},
'deadline': (config.get('reconnect_timeout', None) or 10), connection_retry={'max_delay': 1, 'max_tries': -1})
'max_delay': 1, self._client.add_listener(self.session_listener)
'max_tries': -1},
connection_retry={'max_delay': 1, 'max_tries': -1})
self.client.add_listener(self.session_listener)
self._my_member_data = None self._my_member_data = None
self.fetch_cluster = True self._fetch_cluster = True
self.last_leader_operation = 0 self._last_leader_operation = 0
self.client.start(None) self._client.start()
def session_listener(self, state): def session_listener(self, state):
if state in [KazooState.SUSPENDED, KazooState.LOST]: if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None) self.cluster_watcher(None)
def cluster_watcher(self, event): def cluster_watcher(self, event):
self.fetch_cluster = True self._fetch_cluster = True
self.event.set() self.event.set()
def get_node(self, key, watch=None): def get_node(self, key, watch=None):
try: try:
ret = self.client.get(key, watch) ret = self._client.get(key, watch)
return (ret[0].decode('utf-8'), ret[1]) return (ret[0].decode('utf-8'), ret[1])
except NoNodeError: except NoNodeError:
return None return None
@@ -119,7 +116,7 @@ class ZooKeeper(AbstractDCS):
def get_children(self, key, watch=None): def get_children(self, key, watch=None):
try: try:
return self.client.get_children(key, watch) return self._client.get_children(key, watch)
except NoNodeError: except NoNodeError:
return [] return []
@@ -132,14 +129,14 @@ class ZooKeeper(AbstractDCS):
return members return members
def _inner_load_cluster(self): def _inner_load_cluster(self):
self.fetch_cluster = False self._fetch_cluster = False
self.event.clear() self.event.clear()
nodes = set(self.get_children(self.client_path(''), self.cluster_watcher)) nodes = set(self.get_children(self.client_path(''), self.cluster_watcher))
if not nodes: if not nodes:
self.fetch_cluster = True self._fetch_cluster = True
# get initialize flag # get initialize flag
initialize = self._INITIALIZE in nodes initialize = (self.get_node(self.initialize_path) or [None])[0] if self._INITIALIZE in nodes else None
# get list of members # get list of members
members = self.load_members() if self._MEMBERS[:-1] in nodes else [] members = self.load_members() if self._MEMBERS[:-1] in nodes else []
@@ -147,17 +144,17 @@ class ZooKeeper(AbstractDCS):
# get leader # get leader
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
if leader: if leader:
client_id = self.client.client_id client_id = self._client.client_id
if leader[0] == self._name and client_id is not None and client_id[0] != leader[1].ephemeralOwner: if leader[0] == self._name and client_id is not None and client_id[0] != leader[1].ephemeralOwner:
logger.info('I am leader but not owner of the session. Removing leader node') logger.info('I am leader but not owner of the session. Removing leader node')
self.client.delete(self.leader_path) self._client.delete(self.leader_path)
leader = None leader = None
if leader: if leader:
member = Member(-1, leader[0], None, {}) member = Member(-1, leader[0], None, {})
member = ([m for m in members if m.name == leader[0]] or [member])[0] member = ([m for m in members if m.name == leader[0]] or [member])[0]
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member) leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self.fetch_cluster = member.index == -1 self._fetch_cluster = member.index == -1
# failover key # failover key
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
@@ -165,17 +162,17 @@ class ZooKeeper(AbstractDCS):
failover = Failover.from_node(failover[1].version, failover[0]) failover = Failover.from_node(failover[1].version, failover[0])
# get last leader operation # get last leader operation
optime = self.get_node(self.leader_optime_path) if self._OPTIME in nodes and self.fetch_cluster else None optime = self.get_node(self.leader_optime_path) if self._OPTIME in nodes and self._fetch_cluster else None
self.last_leader_operation = 0 if optime is None else int(optime[0]) self._last_leader_operation = 0 if optime is None else int(optime[0])
self._cluster = Cluster(initialize, leader, self.last_leader_operation, members, failover) self._cluster = Cluster(initialize, leader, self._last_leader_operation, members, failover)
def _load_cluster(self): def _load_cluster(self):
if self.exhibitor and self.exhibitor.poll(): if self.exhibitor and self.exhibitor.poll():
self.client.set_hosts(self.exhibitor.zookeeper_hosts) self._client.set_hosts(self.exhibitor.zookeeper_hosts)
if self.fetch_cluster: if self._fetch_cluster or self._cluster is None:
try: try:
self.client.retry(self._inner_load_cluster) self._client.retry(self._inner_load_cluster)
except: except:
logger.exception('get_cluster') logger.exception('get_cluster')
self.session_listener(KazooState.LOST) self.session_listener(KazooState.LOST)
@@ -183,57 +180,58 @@ class ZooKeeper(AbstractDCS):
def _create(self, path, value, **kwargs): def _create(self, path, value, **kwargs):
try: try:
self.client.retry(self.client.create, path, value.encode('utf-8'), **kwargs) self._client.retry(self._client.create, path, value.encode('utf-8'), **kwargs)
return True return True
except: except:
return False return False
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self):
ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=True) ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=True)
ret or logger.info('Could not take out TTL lock') if not ret:
logger.info('Could not take out TTL lock')
return ret return ret
def set_failover_value(self, value, index=None): def set_failover_value(self, value, index=None):
try: try:
self.client.retry(self.client.set, self.failover_path, value.encode('utf-8'), version=index or -1) self._client.retry(self._client.set, self.failover_path, value.encode('utf-8'), version=index or -1)
return True return True
except NoNodeError: except NoNodeError:
return value == '' or (not index and self._create(self.failover_path, value.encode('utf-8'))) return value == '' or (not index and self._create(self.failover_path, value))
except: except:
logging.exception('set_failover_value') logging.exception('set_failover_value')
return False return False
def initialize(self): def initialize(self, create_new=True, sysid=""):
return self._create(self.initialize_path, self._name, makepath=True) return self._create(self.initialize_path, sysid, makepath=True) if create_new \
else self._client.retry(self._client.set, self.initialize_path, sysid.encode("utf-8"))
def touch_member(self, data, ttl=None): def touch_member(self, data, ttl=None):
cluster = self.cluster cluster = self.cluster
me = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0] member = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
path = self.member_path path = self.member_path
data = data.encode('utf-8') data = data.encode('utf-8')
create = not me if member and self._client.client_id is not None and member.session != self._client.client_id[0]:
if me and self.client.client_id is not None and me.session != self.client.client_id[0]:
try: try:
self.client.retry(self.client.delete, path) self._client.retry(self._client.delete, path)
except NoNodeError: except NoNodeError:
pass pass
except: except:
return False return False
create = True member = None
if not create and data == self._my_member_data: if member and data == self._my_member_data:
return True return True
try: try:
if create: if member:
self.client.retry(self.client.create, path, data, makepath=True, ephemeral=True) self._client.retry(self._client.set, path, data)
else: else:
self.client.retry(self.client.set, path, data) self._client.retry(self._client.create, path, data, makepath=True, ephemeral=True)
self._my_member_data = data self._my_member_data = data
return True return True
except NodeExistsError: except NodeExistsError:
try: try:
self.client.retry(self.client.set, path, data) self._client.retry(self._client.set, path, data)
self._my_member_data = data self._my_member_data = data
return True return True
except: except:
@@ -247,14 +245,14 @@ class ZooKeeper(AbstractDCS):
def write_leader_optime(self, last_operation): def write_leader_optime(self, last_operation):
last_operation = last_operation.encode('utf-8') last_operation = last_operation.encode('utf-8')
if last_operation != self.last_leader_operation: if last_operation != self._last_leader_operation:
self.last_leader_operation = last_operation self._last_leader_operation = last_operation
path = self.leader_optime_path path = self.leader_optime_path
try: try:
self.client.retry(self.client.set, path, last_operation) self._client.retry(self._client.set, path, last_operation)
except NoNodeError: except NoNodeError:
try: try:
self.client.retry(self.client.create, path, last_operation, makepath=True) self._client.retry(self._client.create, path, last_operation, makepath=True)
except: except:
logger.exception('Failed to create %s', path) logger.exception('Failed to create %s', path)
except: except:
@@ -264,22 +262,28 @@ class ZooKeeper(AbstractDCS):
return True return True
def delete_leader(self): def delete_leader(self):
self.client.restart() self._client.restart()
self._my_member_data = None self._my_member_data = None
return True return True
def _cancel_initialization(self): def _cancel_initialization(self):
node = self.get_node(self.initialize_path) node = self.get_node(self.initialize_path)
if node and node[0] == self._name: if node:
self.client.delete(self.initialize_path, version=node[1].version) self._client.delete(self.initialize_path, version=node[1].version)
def cancel_initialization(self): def cancel_initialization(self):
try: try:
self.client.retry(self._cancel_initialization) self._client.retry(self._cancel_initialization)
except: except:
logger.exception("Unable to delete initialize key") logger.exception("Unable to delete initialize key")
def delete_cluster(self):
try:
return self._client.retry(self._client.delete, self.client_path(''), recursive=True)
except NoNodeError:
return True
def watch(self, timeout): def watch(self, timeout):
if super(ZooKeeper, self).watch(timeout): if super(ZooKeeper, self).watch(timeout):
self.fetch_cluster = True self._fetch_cluster = True
return self.fetch_cluster return self._fetch_cluster
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python
from patroni.ctl import ctl
if __name__ == '__main__':
ctl(None)
+47 -12
View File
@@ -4,7 +4,7 @@ scope: &scope batman
restapi: restapi:
listen: 127.0.0.1:8008 listen: 127.0.0.1:8008
connect_address: 127.0.0.1:8008 connect_address: 127.0.0.1:8008
auth: 'username:password' # auth: 'username:password'
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem # certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key # keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
etcd: etcd:
@@ -12,6 +12,10 @@ etcd:
ttl: *ttl ttl: *ttl
host: 127.0.0.1:4001 host: 127.0.0.1:4001
#discovery_srv: my-etcd.domain #discovery_srv: my-etcd.domain
#consul:
# scope: *scope
# ttl: *ttl
# host: 127.0.0.1:8500
#zookeeper: #zookeeper:
# scope: *scope # scope: *scope
# session_timeout: *ttl # session_timeout: *ttl
@@ -34,36 +38,67 @@ postgresql:
data_dir: data/postgresql0 data_dir: data/postgresql0
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
use_slots: True use_slots: True
pgpass: /tmp/pgpass0
initdb: ## We allow the following options to be passed on to initdb
# - auth: authmethod
# - auth-host: authmethod
# - auth-local: authmethod
- encoding: UTF8
# - data-checksums # When pg_rewind is needed on 9.3, this needs to be enabled
# - locale: locale
# - lc-collate: locale
# - lc-ctype: locale
# - lc-messages: locale
# - lc-monetary: locale
# - lc-numeric: locale
# - lc-time: locale
# - text-search-config: CFG
# - xlogdir: directory
# - debug
# - noclean
pg_rewind: pg_rewind:
username: postgres username: postgres
password: zalando password: zalando
pg_hba: pg_hba:
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5 - host all all 0.0.0.0/0 md5
- hostssl all all 0.0.0.0/0 md5 # - hostssl all all 0.0.0.0/0 md5
replication: replication:
username: replicator username: replicator
password: rep-pass password: rep-pass
network: 127.0.0.1/32
superuser: superuser:
username: postgres username: postgres
password: zalando password: zalando
admin: admin:
username: admin username: admin
password: admin password: admin
wal_e: create_replica_method:
env_dir: /home/postgres/etc/wal-e.d/env - basebackup
threshold_megabytes: 10240 # - wal_e
threshold_backup_size_percentage: 30 # commented-out example for wal-e provisioning
restore: patroni/scripts/restore.py #wal_e:
#command: /patroni/scripts/wale_restore.py
#env_dir: /etc/wal-e.d/env
#threshold_megabytes: 10240
#threshold_backup_size_percentage: 30
#retries: 2
#use_iam: 1
#recovery_conf: #recovery_conf:
#restore_command: cp ../wal_archive/%f %p #restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
recovery_conf:
restore_command: cp ../wal_archive/%f %p
parameters: parameters:
archive_mode: "on" archive_mode: "on"
wal_level: hot_standby wal_level: hot_standby
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
max_wal_senders: 5 max_wal_senders: 10
wal_keep_segments: 8 wal_keep_segments: 8
archive_timeout: 1800s archive_timeout: 1800s
max_replication_slots: 5 max_replication_slots: 10
hot_standby: "on" hot_standby: "on"
wal_log_hints: "on" wal_log_hints: "on"
unix_socket_directories: '.'
tags:
nofailover: False
noloadbalance: False
clonefrom: False
+49 -13
View File
@@ -4,7 +4,7 @@ scope: &scope batman
restapi: restapi:
listen: 127.0.0.1:8009 listen: 127.0.0.1:8009
connect_address: 127.0.0.1:8009 connect_address: 127.0.0.1:8009
auth: 'username:password' # auth: 'username:password'
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem # certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key # keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
etcd: etcd:
@@ -12,6 +12,10 @@ etcd:
ttl: *ttl ttl: *ttl
host: 127.0.0.1:4001 host: 127.0.0.1:4001
#discovery_srv: my-etcd.domain #discovery_srv: my-etcd.domain
#consul:
# scope: *scope
# ttl: *ttl
# host: 127.0.0.1:8500
#zookeeper: #zookeeper:
# scope: *scope # scope: *scope
# session_timeout: *ttl # session_timeout: *ttl
@@ -34,36 +38,68 @@ postgresql:
data_dir: data/postgresql1 data_dir: data/postgresql1
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
use_slots: True use_slots: True
pgpass: /tmp/pgpass1
initdb: ## We allow the following options to be passed on to initdb
# - auth: authmethod
# - auth-host: authmethod
# - auth-local: authmethod
- encoding: UTF8
# - data-checksums # When pg_rewind is needed on 9.3, this needs to be enabled
# - locale: locale
# - lc-collate: locale
# - lc-ctype: locale
# - lc-messages: locale
# - lc-monetary: locale
# - lc-numeric: locale
# - lc-time: locale
# - text-search-config: CFG
# - xlogdir: directory
# - debug
# - noclean
pg_rewind: pg_rewind:
username: postgres username: postgres
password: zalando password: zalando
pg_hba: pg_hba:
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5 - host all all 0.0.0.0/0 md5
- hostssl all all 0.0.0.0/0 md5 # - hostssl all all 0.0.0.0/0 md5
replication: replication:
username: replicator username: replicator
password: rep-pass password: rep-pass
network: 127.0.0.1/32
superuser: superuser:
user: postgres username: postgres
password: zalando password: zalando
admin: admin:
username: admin username: admin
password: admin password: admin
# commented-out example for wal-e provisioning
create_replica_method:
- basebackup
# - wal_e
# commented-out example for wal-e provisioning
#wal_e:
#command: /patroni/scripts/wale_restore.py
#env_dir: /home/postgres/etc/wal-e.d/env
#threshold_megabytes: 10240
#threshold_backup_size_percentage: 30
#retries: 2
#use_iam: 1
#recovery_conf: #recovery_conf:
#restore_command: cp ../wal_archive/%f %p #restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
wal_e: recovery_conf:
env_dir: /home/postgres/etc/wal-e.d/env restore_command: cp ../wal_archive/%f %p
threshold_megabytes: 10240
threshold_backup_size_percentage: 30
restore: patroni/scripts/restore.py
parameters: parameters:
archive_mode: "on" archive_mode: "on"
wal_level: hot_standby wal_level: hot_standby
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
max_wal_senders: 5 max_wal_senders: 10
wal_keep_segments: 8 wal_keep_segments: 8
archive_timeout: 1800s archive_timeout: 1800s
max_replication_slots: 5 max_replication_slots: 10
hot_standby: "on" hot_standby: "on"
wal_log_hints: "on" wal_log_hints: "on"
unix_socket_directories: '.'
tags:
nofailover: False
noloadbalance: False
clonefrom: False
+106
View File
@@ -0,0 +1,106 @@
ttl: &ttl 30
loop_wait: &loop_wait 10
scope: &scope batman
restapi:
listen: 127.0.0.1:8010
connect_address: 127.0.0.1:8010
auth: 'username:password'
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
etcd:
scope: *scope
ttl: *ttl
host: 127.0.0.1:4001
#discovery_srv: my-etcd.domain
#consul:
# scope: *scope
# ttl: *ttl
# host: 127.0.0.1:8500
#zookeeper:
# scope: *scope
# session_timeout: *ttl
# reconnect_timeout: *loop_wait
# hosts:
# - 127.0.0.1:2181
# - 127.0.0.2:2181
# exhibitor:
# poll_interval: 300
# port: 8181
# hosts:
# - host1
# - host2
# - host3
postgresql:
name: postgresql2
scope: *scope
listen: 127.0.0.1:5434
connect_address: 127.0.0.1:5434
data_dir: data/postgresql2
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
use_slots: True
pgpass: /tmp/pgpass2
initdb: ## We allow the following options to be passed on to initdb
# - auth: authmethod
# - auth-host: authmethod
# - auth-local: authmethod
- encoding: UTF8
# - data-checksums # When pg_rewind is needed on 9.3, this needs to be enabled
# - locale: locale
# - lc-collate: locale
# - lc-ctype: locale
# - lc-messages: locale
# - lc-monetary: locale
# - lc-numeric: locale
# - lc-time: locale
# - text-search-config: CFG
# - xlogdir: directory
# - debug
# - noclean
pg_rewind:
username: postgres
password: zalando
pg_hba:
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
replication:
username: replicator
password: rep-pass
superuser:
username: postgres
password: zalando
admin:
username: admin
password: admin
# commented-out example for wal-e provisioning
create_replica_method:
- basebackup
# - wal_e
# commented-out example for wal-e provisioning
#wal_e:
#command: /patroni/scripts/wale_restore.py
#env_dir: /home/postgres/etc/wal-e.d/env
#threshold_megabytes: 10240
#threshold_backup_size_percentage: 30
#retries: 2
#use_iam: 1
#recovery_conf:
#restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
recovery_conf:
restore_command: cp ../wal_archive/%f %p
parameters:
archive_mode: "on"
wal_level: hot_standby
archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
max_wal_senders: 10
wal_keep_segments: 8
archive_timeout: 1800s
max_replication_slots: 10
hot_standby: "on"
wal_log_hints: "on"
unix_socket_directories: '.'
tags:
nofailover: False
noloadbalance: False
clonefrom: False
replicatefrom: postgresql1
-9
View File
@@ -1,9 +0,0 @@
boto
dnspython
mock
psycopg2
PyYAML
requests
six >= 1.7
kazoo>=2.2.1
python-etcd>=0.4.1
-9
View File
@@ -1,9 +0,0 @@
boto
mock
dnspython3
psycopg2
PyYAML
requests
six
kazoo>=2.2.1
python-etcd>=0.4.1
+12
View File
@@ -0,0 +1,12 @@
boto
psycopg2>=2.6.1
PyYAML
requests
six >= 1.7
kazoo>=2.2.1
python-etcd==0.4.3
python-consul==0.6.0
click>=4.1
prettytable>=0.7
tzlocal
python-dateutil
+20 -14
View File
@@ -4,13 +4,12 @@
Setup file for patroni Setup file for patroni
""" """
import sys
import os
import inspect import inspect
import os
import sys
import setuptools
from setuptools.command.test import test as TestCommand from setuptools.command.test import test as TestCommand
from setuptools import setup from setuptools import find_packages, setup
if sys.version_info < (2, 7, 0): if sys.version_info < (2, 7, 0):
sys.stderr.write('FATAL: patroni needs to be run with Python 2.7+\n') sys.stderr.write('FATAL: patroni needs to be run with Python 2.7+\n')
@@ -32,7 +31,7 @@ VERSION = read_version(MAIN_PACKAGE)
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI' DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
LICENSE = 'The MIT License' LICENSE = 'The MIT License'
URL = 'https://github.com/zalando/patroni' URL = 'https://github.com/zalando/patroni'
AUTHOR = 'Alexander Kukushkin, Alexey Klyukin, Feike Steenbergen' AUTHOR = 'Alexander Kukushkin, Oleksii Kliukin, Feike Steenbergen'
AUTHOR_EMAIL = '[email protected], [email protected], [email protected]' AUTHOR_EMAIL = '[email protected], [email protected], [email protected]'
KEYWORDS = 'etcd governor patroni postgresql postgres ha zookeeper streaming replication' KEYWORDS = 'etcd governor patroni postgresql postgres ha zookeeper streaming replication'
@@ -51,12 +50,15 @@ CLASSIFIERS = [
'Operating System :: POSIX :: Linux', 'Operating System :: POSIX :: Linux',
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: CPython',
] ]
CONSOLE_SCRIPTS = ['patroni = patroni:main'] CONSOLE_SCRIPTS = ['patroni = patroni:main',
'patronictl = patroni.ctl:ctl',
"patroni_wale_restore = patroni.scripts.wale_restore:main",
"patroni_aws = patroni.scripts.aws:main"]
class PyTest(TestCommand): class PyTest(TestCommand):
@@ -73,7 +75,7 @@ class PyTest(TestCommand):
def finalize_options(self): def finalize_options(self):
TestCommand.finalize_options(self) TestCommand.finalize_options(self)
if self.cov_xml or self.cov_html: if self.cov_xml or self.cov_html:
self.cov = ['--cov', MAIN_PACKAGE, '--cov', MAIN_PACKAGE, '--cov-report', 'term-missing'] self.cov = ['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing']
if self.cov_xml: if self.cov_xml:
self.cov.extend(['--cov-report', 'xml']) self.cov.extend(['--cov-report', 'xml'])
if self.cov_html: if self.cov_html:
@@ -91,7 +93,12 @@ class PyTest(TestCommand):
params['args'] += self.cov params['args'] += self.cov
if self.junitxml: if self.junitxml:
params['args'] += self.junitxml params['args'] += self.junitxml
params['args'] += ['--doctest-modules', MAIN_PACKAGE, '-s', '-vv'] params['args'] += ['--doctest-modules', MAIN_PACKAGE, '-vv']
import logging
silence = logging.WARNING
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
params['args'] += ['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd']
errno = pytest.main(**params) errno = pytest.main(**params)
sys.exit(errno) sys.exit(errno)
@@ -113,8 +120,7 @@ def setup_package():
# Some helper variables # Some helper variables
version = os.getenv('GO_PIPELINE_LABEL', VERSION) version = os.getenv('GO_PIPELINE_LABEL', VERSION)
requirements = 'requirements-py2.txt' if sys.version_info[0] == 2 else 'requirements-py3.txt' install_reqs = get_install_requirements('requirements.txt')
install_reqs = get_install_requirements(requirements)
command_options = {'test': {'test_suite': ('setup.py', 'tests')}} command_options = {'test': {'test_suite': ('setup.py', 'tests')}}
if JUNIT_XML: if JUNIT_XML:
@@ -136,12 +142,12 @@ def setup_package():
long_description=read('README.rst'), long_description=read('README.rst'),
classifiers=CLASSIFIERS, classifiers=CLASSIFIERS,
test_suite='tests', test_suite='tests',
packages=setuptools.find_packages(exclude=['tests', 'tests.*']), packages=find_packages(exclude=['tests', 'tests.*']),
package_data={MAIN_PACKAGE: ["*.json"]}, package_data={MAIN_PACKAGE: ["*.json"]},
install_requires=install_reqs, install_requires=install_reqs,
setup_requires=['six', 'flake8'], setup_requires=['flake8'],
cmdclass=cmdclass, cmdclass=cmdclass,
tests_require=['pytest-cov', 'pytest'], tests_require=['mock', 'pytest-cov', 'pytest'],
command_options=command_options, command_options=command_options,
entry_points={'console_scripts': CONSOLE_SCRIPTS}, entry_points={'console_scripts': CONSOLE_SCRIPTS},
) )
+112 -21
View File
@@ -3,50 +3,61 @@ import unittest
from mock import Mock, patch from mock import Mock, patch
from patroni.api import RestApiHandler, RestApiServer from patroni.api import RestApiHandler, RestApiServer
from patroni.dcs import Member
from six import BytesIO as IO from six import BytesIO as IO
from six.moves import BaseHTTPServer from six.moves import BaseHTTPServer
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler
import socket
from test_postgresql import psycopg2_connect, MockCursor from test_postgresql import psycopg2_connect, MockCursor
class MockPostgresql(Mock): class MockPostgresql(object):
name = 'test' name = 'test'
state = 'running' state = 'running'
role = 'master' role = 'master'
server_version = '999999'
sysid = 'dummysysid'
scope = 'dummy'
def connection(self): @staticmethod
def connection():
return psycopg2_connect() return psycopg2_connect()
def is_running(self):
return True
class MockHa(object):
class MockHa(Mock):
dcs = Mock() dcs = Mock()
state_handler = MockPostgresql() state_handler = MockPostgresql()
def schedule_restart(self): @staticmethod
return 'restart' def schedule_reinitialize():
def schedule_reinitialize(self):
return 'reinitialize' return 'reinitialize'
def restart(self): @staticmethod
def restart():
return (True, '') return (True, '')
def restart_scheduled(self): @staticmethod
def restart_scheduled():
return False return False
@staticmethod
def fetch_nodes_statuses(members):
return [[None, True, None, None, {}]]
class MockPatroni:
class MockPatroni(object):
postgresql = MockPostgresql() postgresql = MockPostgresql()
ha = MockHa() ha = MockHa()
dcs = Mock() dcs = Mock()
tags = {}
version = '0.00'
noloadbalance = Mock(return_value=False)
class MockRequest: class MockRequest(object):
def __init__(self, path): def __init__(self, path):
self.path = path self.path = path
@@ -84,18 +95,32 @@ class TestRestApiHandler(unittest.TestCase):
MockRestApiServer(RestApiHandler, b'GET /master') MockRestApiServer(RestApiHandler, b'GET /master')
with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)): with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, b'GET /master') MockRestApiServer(RestApiHandler, b'GET /master')
MockRestApiServer(RestApiHandler, b'GET /master') self.assertIsNotNone(MockRestApiServer(RestApiHandler, b'GET /master'))
def test_do_OPTIONS(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, b'OPTIONS / HTTP/1.0'))
with patch.object(BaseHTTPRequestHandler, 'handle_one_request') as mock_handle_request:
mock_handle_request.side_effect = socket.error("foo")
MockRestApiServer(RestApiHandler, b'OPTIONS / HTTP/1.0')
# make sure socket.error gets propagated via wfile object in finalize()
with patch.object(MockRequest, 'makefile') as makefile:
makefile.return_value.closed = False
makefile.return_value.readline = Mock(return_value=b'foo')
makefile.return_value.flush = Mock(side_effect=socket.error('foo'))
MockRestApiServer(RestApiHandler, b'OPTIONS / HTTP/1.0')
def test_do_GET_patroni(self): def test_do_GET_patroni(self):
MockRestApiServer(RestApiHandler, b'GET /patroni') self.assertIsNotNone(MockRestApiServer(RestApiHandler, b'GET /patroni'))
def test_basicauth(self): def test_basicauth(self):
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0') self.assertIsNotNone(MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0'))
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0\nAuthorization:') MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0\nAuthorization:')
def test_do_POST_restart(self): def test_do_POST_restart(self):
request = b'POST /restart HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0' request = b'POST /restart HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
MockRestApiServer(RestApiHandler, request) self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
with patch.object(MockHa, 'restart', Mock(side_effect=Exception)): with patch.object(MockHa, 'restart', Mock(side_effect=Exception)):
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
@@ -109,11 +134,77 @@ class TestRestApiHandler(unittest.TestCase):
with patch.object(MockHa, 'schedule_reinitialize', Mock(return_value=None)): with patch.object(MockHa, 'schedule_reinitialize', Mock(return_value=None)):
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'test' cluster.leader.name = 'test'
MockRestApiServer(RestApiHandler, request) self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
def test_RestApiServer_query(self): def test_RestApiServer_query(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)): with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)):
MockRestApiServer(RestApiHandler, b'GET /patroni') self.assertIsNotNone(MockRestApiServer(RestApiHandler, b'GET /patroni'))
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)): with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)):
MockRestApiServer(RestApiHandler, b'GET /patroni') self.assertIsNotNone(MockRestApiServer(RestApiHandler, b'GET /patroni'))
@patch('time.sleep', Mock())
@patch.object(MockHa, 'dcs')
def test_do_POST_failover(self, dcs):
cluster = dcs.get_cluster.return_value
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\n' +\
b'Content-Length: 0\n\n'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql1'
MockRestApiServer(RestApiHandler, request)
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\n' +\
b'Content-Length: 25\n\n{"leader": "postgresql1"}'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql2'
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\n' +\
b'Content-Length: 53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql1'
MockRestApiServer(RestApiHandler, request)
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}),
Member(0, 'postgresql2', 30, {'api_url': 'http'})]
MockRestApiServer(RestApiHandler, request)
with patch.object(MockPatroni, 'dcs') as d:
cluster = d.get_cluster.return_value
cluster.leader.name = 'postgresql0'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql2'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql1'
cluster.failover = None
MockRestApiServer(RestApiHandler, request)
d.get_cluster = Mock(side_effect=Exception)
MockRestApiServer(RestApiHandler, request)
d.manual_failover.return_value = False
MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'fetch_nodes_statuses', Mock(return_value=[])):
MockRestApiServer(RestApiHandler, request)
# Valid future date
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\nContent-Length: 103\n\n{"leader": ' +\
b'"postgresql1", "member": "postgresql2", "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}'
MockRestApiServer(RestApiHandler, request)
with patch.object(MockPatroni, 'dcs') as d:
d.manual_failover.return_value = False
MockRestApiServer(RestApiHandler, request)
# Exception: No timezone specified
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\nContent-Length: 97\n\n{"leader": ' +\
b'"postgresql1", "member": "postgresql2", "scheduled_at": "6016-02-15T18:13:30.568224"}'
MockRestApiServer(RestApiHandler, request)
# Exception: Scheduled in the past
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\nContent-Length: 103\n\n{"leader": ' +\
b'"postgresql1", "member": "postgresql2", "scheduled_at": "1016-02-15T18:13:30.568224+01:00"}'
MockRestApiServer(RestApiHandler, request)
# Invalid date
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\nContent-Length: 103\n\n{"leader": ' +\
b'"postgresql1", "member": "postgresql2", "scheduled_at": "2010-02-29T18:13:30.568224+01:00"}'
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
+18 -18
View File
@@ -1,12 +1,15 @@
import unittest
import requests
import boto.ec2 import boto.ec2
import requests
import sys
import unittest
from mock import Mock, patch
from collections import namedtuple from collections import namedtuple
from patroni.scripts.aws import AWSConnection from patroni.scripts.aws import AWSConnection, main as _main
from requests.exceptions import RequestException from requests.exceptions import RequestException
class MockEc2Connection: class MockEc2Connection(object):
def __init__(self, error=False): def __init__(self, error=False):
self.error = error self.error = error
@@ -23,7 +26,7 @@ class MockEc2Connection:
return True return True
class MockResponse: class MockResponse(object):
def __init__(self, content): def __init__(self, content):
self.content = content self.content = content
@@ -35,15 +38,6 @@ class MockResponse:
class TestAWSConnection(unittest.TestCase): class TestAWSConnection(unittest.TestCase):
def __init__(self, method_name='runTest'):
super(TestAWSConnection, self).__init__(method_name)
def set_error(self):
self.error = True
def set_json_error(self):
self.json_error = True
def boto_ec2_connect_to_region(self, region): def boto_ec2_connect_to_region(self, region):
return MockEc2Connection(self.error) return MockEc2Connection(self.error)
@@ -74,21 +68,27 @@ class TestAWSConnection(unittest.TestCase):
self.assertTrue(self.conn.on_role_change('master')) self.assertTrue(self.conn.on_role_change('master'))
def test_non_aws(self): def test_non_aws(self):
self.set_error() self.error = True
conn = AWSConnection('test') conn = AWSConnection('test')
self.assertFalse(conn.aws_available()) self.assertFalse(conn.aws_available())
self.assertFalse(conn._tag_ebs('master')) self.assertFalse(conn._tag_ebs('master'))
self.assertFalse(conn._tag_ec2('master')) self.assertFalse(conn._tag_ec2('master'))
def test_aws_bizare_response(self): def test_aws_bizare_response(self):
self.set_json_error() self.json_error = True
conn = AWSConnection('test') conn = AWSConnection('test')
self.assertFalse(conn.aws_available()) self.assertFalse(conn.aws_available())
def test_aws_tag_ebs_error(self): def test_aws_tag_ebs_error(self):
self.set_error() self.error = True
self.assertFalse(self.conn._tag_ebs("master")) self.assertFalse(self.conn._tag_ebs("master"))
def test_aws_tag_ec2_error(self): def test_aws_tag_ec2_error(self):
self.set_error() self.error = True
self.assertFalse(self.conn._tag_ec2("master")) self.assertFalse(self.conn._tag_ec2("master"))
@patch('sys.exit', Mock())
def test_main(self):
self.assertIsNone(_main())
sys.argv = ['aws.py', 'on_start', 'replica', 'foo']
self.assertIsNone(_main())
+128
View File
@@ -0,0 +1,128 @@
import consul
import unittest
from patroni.dcs import AbstractDCS
from mock import Mock, patch
from patroni.consul import Cluster, Consul, ConsulError, ConsulException, HTTPClient, NotFound
from test_etcd import SleepException
def kv_get(self, key, **kwargs):
if key == 'service/test/members/postgresql1':
return '1', {'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7'}
if key == 'service/test/':
return None, None
if key == 'service/good/leader':
return '1', None
if key == 'service/good/':
return ('6429',
[{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'failover', 'LockIndex': 0,
'ModifyIndex': 1334, 'Value': b''},
{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'initialize', 'LockIndex': 0,
'ModifyIndex': 1334, 'Value': b'postgresql0'},
{'CreateIndex': 2621, 'Flags': 0, 'Key': key + 'leader', 'LockIndex': 1,
'ModifyIndex': 2621, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7', 'Value': b'postgresql1'},
{'CreateIndex': 6156, 'Flags': 0, 'Key': key + 'members/postgresql0', 'LockIndex': 1,
'ModifyIndex': 6156, 'Session': '782e6da4-ed02-3aef-7963-99a90ed94b53',
'Value': ('postgres://replicator:[email protected]:5432/postgres' +
'?application_name=http://127.0.0.1:8008/patroni').encode('utf-8')},
{'CreateIndex': 2630, 'Flags': 0, 'Key': key + 'members/postgresql1', 'LockIndex': 1,
'ModifyIndex': 2630, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7',
'Value': ('postgres://replicator:[email protected]:5433/postgres' +
'?application_name=http://127.0.0.1:8009/patroni').encode('utf-8')},
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'optime/leader', 'LockIndex': 0,
'ModifyIndex': 6429, 'Value': b'4496294792'}])
raise ConsulException
class TestHTTPClient(unittest.TestCase):
def test_get(self):
self.client = HTTPClient('127.0.0.1', '8500', 'http', False)
self.client.session.get = Mock()
self.client.get(Mock(), '')
self.client.get(Mock(), '', {'wait': '1s', 'index': 1})
@patch.object(consul.Consul.KV, 'get', kv_get)
class TestConsul(unittest.TestCase):
@patch.object(consul.Consul.Session, 'create', Mock(return_value='fd4f44fe-2cac-bba5-a60b-304b51ff39b7'))
@patch.object(consul.Consul.Session, 'renew', Mock(side_effect=NotFound))
@patch.object(consul.Consul.KV, 'get', kv_get)
@patch.object(consul.Consul.KV, 'delete', Mock())
def setUp(self):
self.c = Consul('postgresql1', {'ttl': 30, 'scope': 'test', 'host': 'localhost:1'})
self.c._base_path = '/service/good'
self.c._load_cluster()
@patch('time.sleep', Mock(side_effect=SleepException))
def test_create_or_restore_session(self):
self.c._session = None
self.assertRaises(SleepException, self.c.create_or_restore_session)
@patch.object(consul.Consul.Session, 'renew', Mock(side_effect=NotFound))
@patch.object(consul.Consul.Session, 'create', Mock(side_effect=ConsulException))
def test_referesh_session(self):
self.c._session = '1'
self.c._name = ''
self.assertRaises(ConsulError, self.c.refresh_session)
@patch.object(consul.Consul.KV, 'delete', Mock())
def test_get_cluster(self):
self.c._base_path = '/service/test'
self.assertIsInstance(self.c.get_cluster(), Cluster)
self.assertIsInstance(self.c.get_cluster(), Cluster)
self.c._base_path = '/service/fail'
self.assertRaises(ConsulError, self.c.get_cluster)
self.c._base_path = '/service/good'
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
self.assertIsInstance(self.c.get_cluster(), Cluster)
@patch.object(consul.Consul.KV, 'delete', Mock(side_effect=[ConsulException, True, True]))
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException]))
def test_touch_member(self):
self.c.refresh_session = Mock(return_value=True)
self.c.touch_member('balbla')
self.c.touch_member('balbla')
self.c.touch_member('balbla')
self.c.refresh_session = Mock(return_value=False)
self.c.touch_member('balbla')
@patch.object(consul.Consul.KV, 'put', Mock(return_value=False))
def test_take_leader(self):
self.c.take_leader()
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_set_failover_value(self):
self.c.set_failover_value('')
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
def test_write_leader_optime(self):
self.c.write_leader_optime('')
def test_update_leader(self):
self.c.update_leader()
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
def test_delete_leader(self):
self.c.delete_leader()
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_initialize(self):
self.c.initialize()
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
def test_cancel_initialization(self):
self.c.cancel_initialization()
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
def test_delete_cluster(self):
self.c.delete_cluster()
@patch.object(AbstractDCS, 'watch', Mock())
def test_watch(self):
self.c._name = ''
self.c.watch(1)
with patch.object(consul.Consul.KV, 'get', Mock(side_effect=ConsulException)):
self.c.watch(1)
+311
View File
@@ -0,0 +1,311 @@
import etcd
import os
import pytest
import requests.exceptions
import unittest
from click.testing import CliRunner
from mock import patch, Mock
from patroni.ctl import ctl, members, store_config, load_config, output_members, post_patroni, get_dcs, parse_dcs, \
wait_for_leader, get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException
from patroni.etcd import Etcd, Client
from psycopg2 import OperationalError
from test_etcd import etcd_read, requests_get, socket_getaddrinfo, MockResponse
from test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
get_cluster_initialized_with_only_leader
from test_postgresql import MockConnect, psycopg2_connect
CONFIG_FILE_PATH = './test-ctl.yaml'
def test_rw_config():
runner = CliRunner()
config = {'a': 'b'}
with runner.isolated_filesystem():
store_config(config, CONFIG_FILE_PATH + '/dummy')
os.remove(CONFIG_FILE_PATH + '/dummy')
os.rmdir(CONFIG_FILE_PATH)
with pytest.raises(Exception):
result = load_config(CONFIG_FILE_PATH, None)
assert 'Could not load configuration file' in result.output
os.mkdir(CONFIG_FILE_PATH)
with pytest.raises(Exception):
store_config(config, CONFIG_FILE_PATH)
os.rmdir(CONFIG_FILE_PATH)
store_config(config, CONFIG_FILE_PATH)
load_config(CONFIG_FILE_PATH, None)
load_config(CONFIG_FILE_PATH, '0.0.0.0')
store_config({'dcs_api': None}, CONFIG_FILE_PATH)
load_config(CONFIG_FILE_PATH, None)
@patch('patroni.ctl.load_config', Mock(return_value={'etcd': {'host': 'localhost:4001'}}))
class TestCtl(unittest.TestCase):
@patch('socket.getaddrinfo', socket_getaddrinfo)
def setUp(self):
self.runner = CliRunner()
with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'})
@patch('psycopg2.connect', psycopg2_connect)
def test_get_cursor(self):
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), role='master'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), role='master'))
# MockCursor returns pg_is_in_recovery as false
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), role='replica'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), role='any'))
def test_parse_dcs(self):
assert parse_dcs(None) is None
assert parse_dcs('localhost') == {'etcd': {'host': 'localhost:4001'}}
assert parse_dcs('') == {'etcd': {'host': 'localhost:4001'}}
assert parse_dcs('localhost:8500') == {'consul': {'host': 'localhost:8500'}}
assert parse_dcs('zookeeper://localhost') == {'zookeeper': {'hosts': ['localhost:2181']}}
assert parse_dcs('exhibitor://dummy') == {'zookeeper': {'exhibitor': {'hosts': ['dummy'], 'port': 8181}}}
assert parse_dcs('consul://localhost') == {'consul': {'host': 'localhost:8500'}}
self.assertRaises(PatroniCtlException, parse_dcs, 'invalid://test')
def test_output_members(self):
cluster = get_cluster_initialized_with_leader()
self.assertIsNone(output_members(cluster, name='abc', fmt='pretty'))
self.assertIsNone(output_members(cluster, name='abc', fmt='json'))
self.assertIsNone(output_members(cluster, name='abc', fmt='tsv'))
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
@patch('patroni.ctl.post_patroni', Mock(return_value=MockResponse()))
def test_failover(self):
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n\ny''')
assert 'leader' in result.output
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n2100-01-01T12:23:00\ny''')
assert result.exit_code == 0
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n2030-01-01T12:23:00\ny''')
assert result.exit_code == 0
# Aborting failover,as we anser NO to the confirmation
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n\nN''')
assert result.exit_code == 1
# Target and source are equal
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nleader\n\ny''')
assert result.exit_code == 1
# Reality is not part of this cluster
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nReality\n\ny''')
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force'])
assert 'Member' in result.output
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', '2015-01-01T12:00:00+01:00'])
assert result.exit_code == 0
# Invalid timestamp
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', 'invalid'])
assert result.exit_code != 0
# Invalid timestamp
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', '2115-02-30T12:00:00+01:00'])
assert result.exit_code != 0
# Specifying wrong leader
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='dummy')
assert result.exit_code == 1
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_only_leader())):
# No members available
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n\ny''')
assert result.exit_code == 1
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())):
# No master available
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n\ny''')
assert result.exit_code == 1
with patch('patroni.ctl.post_patroni', Mock(side_effect=Exception)):
# Non-responding patroni
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n\ny''')
assert 'falling back to DCS' in result.output
with patch('patroni.ctl.post_patroni') as mocked:
mocked.return_value.status_code = 500
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='''leader\nother\n\ny''')
assert 'Failover failed' in result.output
def test_get_dcs(self):
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy')
with patch('patroni.Patroni.get_dcs', Mock(return_value=self.e)):
assert get_dcs({'etcd': {'host': 'none'}}, 'dummy').client_path('') == '/service/test/'
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
@patch.object(etcd.Client, 'read', etcd_read)
def test_query(self):
with patch('patroni.ctl.get_dcs', Mock(return_value=self.e)):
# Mutually exclusive
result = self.runner.invoke(ctl, ['query', 'alpha', '--member', 'abc', '--role', 'master'])
assert result.exit_code == 1
with self.runner.isolated_filesystem():
with open('dummy', 'w') as dummy_file:
dummy_file.write('SELECT 1')
# Mutually exclusive
result = self.runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy', '--command', 'dummy'])
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy'])
assert result.exit_code == 0
os.remove('dummy')
result = self.runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1'])
assert 'mock column' in result.output
# --command or --file is mandatory
result = self.runner.invoke(ctl, ['query', 'alpha'])
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1', '--username', 'root',
'--password', '--dbname', 'postgres'], input='ab\nab')
assert 'mock column' in result.output
def test_query_member(self):
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
rows = query_member(None, None, None, 'master', 'SELECT pg_is_in_recovery()')
self.assertTrue('False' in str(rows))
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
self.assertEquals(rows, (None, None))
with patch('test_postgresql.MockCursor.execute', Mock(side_effect=OperationalError('bla'))):
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
rows = query_member(None, None, None, None, 'SELECT pg_is_in_recovery()')
self.assertTrue('No connection to' in str(rows))
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
self.assertTrue('No connection to' in str(rows))
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
def test_dsn(self):
with patch('patroni.ctl.get_dcs', Mock(return_value=self.e)):
result = self.runner.invoke(ctl, ['dsn', 'alpha'])
assert 'host=127.0.0.1 port=5435' in result.output
# Mutually exclusive options
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--role', 'master', '--member', 'dummy'])
assert result.exit_code == 1
# Non-existing member
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
assert result.exit_code == 1
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
@patch('requests.post', requests_get)
def test_restart_reinit(self):
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='y')
assert 'restart failed for' in result.output
assert result.exit_code == 0
result = self.runner.invoke(ctl, ['reinit', 'alpha'], input='y')
assert result.exit_code == 1
# Aborted restart
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='N')
assert result.exit_code == 1
# Not a member
result = self.runner.invoke(ctl, ['restart', 'alpha', 'dummy', '--any'], input='y')
assert result.exit_code == 1
with patch('requests.post', Mock(return_value=MockResponse())):
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='y')
assert result.exit_code == 0
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
@patch.object(etcd.Client, 'delete', Mock(side_effect=etcd.EtcdException))
def test_remove(self):
with patch('patroni.ctl.get_dcs', Mock(return_value=self.e)):
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='alpha\nslave')
assert 'Please confirm' in result.output
assert 'You are about to remove all' in result.output
# Not typing an exact confirmation
assert result.exit_code == 1
# master specified does not match master of cluster
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='''alpha\nYes I am aware\nslave''')
assert result.exit_code == 1
# cluster specified on cmdline does not match verification prompt
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='beta\nleader')
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='''alpha\nYes I am aware\nleader''')
assert result.exit_code == 0
@patch('patroni.etcd.Etcd.watch', Mock(return_value=None))
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
def test_wait_for_leader(self):
self.assertRaises(PatroniCtlException, wait_for_leader, self.e, 0)
cluster = wait_for_leader(self.e, timeout=2)
assert cluster.leader.member.name == 'leader'
@patch('requests.post', Mock(side_effect=requests.exceptions.ConnectionError('foo')))
def test_post_patroni(self):
member = get_cluster_initialized_with_leader().leader.member
self.assertRaises(requests.exceptions.ConnectionError, post_patroni, member, 'dummy', {})
def test_ctl(self):
self.runner.invoke(ctl, ['list'])
result = self.runner.invoke(ctl, ['--help'])
assert 'Usage:' in result.output
def test_get_any_member(self):
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), role='master'))
m = get_any_member(get_cluster_initialized_with_leader(), role='master')
self.assertEquals(m.name, 'leader')
def test_get_all_members(self):
self.assertEquals(list(get_all_members(get_cluster_initialized_without_leader(), role='master')), [])
r = list(get_all_members(get_cluster_initialized_with_leader(), role='master'))
self.assertEquals(len(r), 1)
self.assertEquals(r[0].name, 'leader')
r = list(get_all_members(get_cluster_initialized_with_leader(), role='replica'))
self.assertEquals(len(r), 1)
self.assertEquals(r[0].name, 'other')
self.assertEquals(len(list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))), 2)
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
def test_members(self):
result = self.runner.invoke(members, ['alpha'])
assert '127.0.0.1' in result.output
assert result.exit_code == 0
def test_configure(self):
result = self.runner.invoke(configure, ['--dcs', 'abc', '-c', 'dummy', '-n', 'bla'])
assert result.exit_code == 0
+52 -58
View File
@@ -1,48 +1,41 @@
import etcd import etcd
import json import json
import requests import requests
import urllib3
import socket import socket
import unittest import unittest
from dns.exception import DNSException from dns.exception import DNSException
from mock import Mock, patch from mock import Mock, patch
from patroni.dcs import Cluster, DCSError, Leader from patroni.dcs import Cluster, AbstractDCS
from patroni.etcd import Client, Etcd from patroni.etcd import Client, Etcd, EtcdError
from patroni.exceptions import DCSError
from urllib3.exceptions import ReadTimeoutError
class MockResponse: class MockResponse(object):
def __init__(self): def __init__(self):
self.status_code = 200 self.status_code = 200
self.content = '{}' self.content = '{}'
self.ok = True self.ok = True
self.text = ''
def json(self): def json(self):
return json.loads(self.content) return json.loads(self.content)
@property @property
def data(self): def data(self):
if self.content == 'TimeoutError': return self.content.encode('utf-8')
raise urllib3.exceptions.TimeoutError
if self.content == 'Exception':
raise Exception
return self.content
@property @property
def status(self): def status(self):
return self.status_code return self.status_code
@staticmethod
def getheader(*args): def getheader(*args):
return '' return ''
class MockPostgresql(Mock):
def last_operation(self):
return '0'
def requests_get(url, **kwargs): def requests_get(url, **kwargs):
members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\ members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\
'"name":"default","clientURLs":["http://localhost:2379","http://localhost:4001"]}]' '"name":"default","clientURLs":["http://localhost:2379","http://localhost:4001"]}]'
@@ -50,12 +43,9 @@ def requests_get(url, **kwargs):
if url.startswith('http://local'): if url.startswith('http://local'):
raise requests.exceptions.RequestException() raise requests.exceptions.RequestException()
elif ':8011/patroni' in url: elif ':8011/patroni' in url:
response.content = '{"role": "replica", "xlog": {"replayed_location": 0}}' response.content = '{"role": "replica", "xlog": {"replayed_location": 0}, "tags": {}}'
elif url.endswith('/members'): elif url.endswith('/members'):
if url.startswith('http://error'): response.content = '[{}]' if url.startswith('http://error') else members
response.content = '[{}]'
else:
response.content = members
elif url.startswith('http://exhibitor'): elif url.startswith('http://exhibitor'):
response.content = '{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}' response.content = '{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}'
else: else:
@@ -64,29 +54,25 @@ def requests_get(url, **kwargs):
return response return response
def etcd_watch(key, index=None, timeout=None, recursive=None): def etcd_watch(self, key, index=None, timeout=None, recursive=None):
if timeout == 2.0: if timeout == 2.0:
raise urllib3.exceptions.TimeoutError raise etcd.EtcdWatchTimedOut
elif timeout == 5.0: elif timeout == 5.0:
return etcd.EtcdResult('delete', {}) return etcd.EtcdResult('delete', {})
elif timeout == 10.0: elif timeout == 10.0:
raise etcd.EtcdException raise etcd.EtcdException
elif index == 20729:
return etcd.EtcdResult('set', {'value': 'postgresql1', 'modifiedIndex': index + 1})
elif index == 20731:
return etcd.EtcdResult('set', {'value': 'postgresql2', 'modifiedIndex': index + 1})
def etcd_write(key, value, **kwargs): def etcd_write(self, key, value, **kwargs):
if key == '/service/exists/leader': if key == '/service/exists/leader':
raise etcd.EtcdAlreadyExist raise etcd.EtcdAlreadyExist
if key == '/service/test/leader': if key in ['/service/test/leader', '/patroni/test/leader'] and \
if kwargs.get('prevValue', None) == 'foo' or not kwargs.get('prevExist', True): (kwargs.get('prevValue') == 'foo' or not kwargs.get('prevExist', True)):
return True return True
raise etcd.EtcdException raise etcd.EtcdException
def etcd_read(key, **kwargs): def etcd_read(self, key, **kwargs):
if key == '/service/noleader/': if key == '/service/noleader/':
raise DCSError('noleader') raise DCSError('noleader')
elif key == '/service/nocluster/': elif key == '/service/nocluster/':
@@ -106,13 +92,13 @@ def etcd_read(key, **kwargs):
"modifiedIndex": 20437, "createdIndex": 20437}, "modifiedIndex": 20437, "createdIndex": 20437},
{"key": "/service/batman5/members", "dir": True, "nodes": [ {"key": "/service/batman5/members", "dir": True, "nodes": [
{"key": "/service/batman5/members/postgresql1", {"key": "/service/batman5/members/postgresql1",
"value": "postgres://replicator:[email protected]:5434/postgres" "value": "postgres://replicator:[email protected]:5434/postgres" +
+ "?application_name=http://127.0.0.1:8009/patroni", "?application_name=http://127.0.0.1:8009/patroni",
"expiration": "2015-05-15T09:10:59.949384522Z", "ttl": 21, "expiration": "2015-05-15T09:10:59.949384522Z", "ttl": 21,
"modifiedIndex": 20727, "createdIndex": 20727}, "modifiedIndex": 20727, "createdIndex": 20727},
{"key": "/service/batman5/members/postgresql0", {"key": "/service/batman5/members/postgresql0",
"value": "postgres://replicator:[email protected]:5433/postgres" "value": "postgres://replicator:[email protected]:5433/postgres" +
+ "?application_name=http://127.0.0.1:8008/patroni", "?application_name=http://127.0.0.1:8008/patroni",
"expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30, "expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30,
"modifiedIndex": 20730, "createdIndex": 20730}], "modifiedIndex": 20730, "createdIndex": 20730}],
"modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}} "modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}}
@@ -123,12 +109,12 @@ class SleepException(Exception):
pass pass
class MockSRV: class MockSRV(object):
port = 2380 port = 2380
target = '127.0.0.1' target = '127.0.0.1'
def dns_query(name, type): def dns_query(name, _):
if name == '_etcd-server._tcp.blabla': if name == '_etcd-server._tcp.blabla':
return [] return []
elif name == '_etcd-server._tcp.exception': elif name == '_etcd-server._tcp.exception':
@@ -143,6 +129,8 @@ def socket_getaddrinfo(*args):
def http_request(method, url, **kwargs): def http_request(method, url, **kwargs):
if url == 'http://localhost:2379/timeout':
raise ReadTimeoutError(None, None, None)
if url == 'http://localhost:2379/': if url == 'http://localhost:2379/':
return MockResponse() return MockResponse()
raise socket.error raise socket.error
@@ -160,25 +148,27 @@ class TestClient(unittest.TestCase):
mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001']) mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001'])
self.client = Client({'discovery_srv': 'test'}) self.client = Client({'discovery_srv': 'test'})
self.client.http.request = http_request self.client.http.request = http_request
self.client.http.request_encode_body = http_request
def test_api_execute(self): def test_api_execute(self):
self.client._base_uri = 'http://localhost:4001' self.client._base_uri = 'http://localhost:4001'
self.client._machines_cache = ['http://localhost:2379'] self.client._machines_cache = ['http://localhost:2379']
self.client.api_execute('/', 'GET') self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
self.client._update_machines_cache = False
self.client.api_execute('/', 'POST', timeout=0)
self.client._update_machines_cache = False
self.client._base_uri = 'http://localhost:4001'
self.client._machines_cache = []
self.assertRaises(etcd.EtcdConnectionFailed, self.client.api_execute, '/', 'GET')
self.assertTrue(self.client._update_machines_cache)
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', 'GET')
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', '')
self.assertRaises(ValueError, self.client.api_execute, '', '')
def test_get_srv_record(self): def test_get_srv_record(self):
self.assertEquals(self.client.get_srv_record('blabla'), []) self.assertEquals(self.client.get_srv_record('blabla'), [])
self.assertEquals(self.client.get_srv_record('exception'), []) self.assertEquals(self.client.get_srv_record('exception'), [])
def test__result_from_response(self):
response = MockResponse()
response.content = 'TimeoutError'
self.assertRaises(urllib3.exceptions.TimeoutError, self.client._result_from_response, response)
response.content = 'Exception'
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
response.content = b'{}'
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
def test__get_machines_cache_from_srv(self): def test__get_machines_cache_from_srv(self):
self.client.get_srv_record = Mock(return_value=[('localhost', 2380)]) self.client.get_srv_record = Mock(return_value=[('localhost', 2380)])
self.client._get_machines_cache_from_srv('blabla') self.client._get_machines_cache_from_srv('blabla')
@@ -194,15 +184,18 @@ class TestClient(unittest.TestCase):
@patch('requests.get', requests_get) @patch('requests.get', requests_get)
@patch.object(etcd.Client, 'write', etcd_write)
@patch.object(etcd.Client, 'read', etcd_read)
@patch.object(etcd.Client, 'delete', Mock(side_effect=etcd.EtcdException))
class TestEtcd(unittest.TestCase): class TestEtcd(unittest.TestCase):
def setUp(self): def setUp(self):
with patch.object(Client, 'machines') as mock_machines: with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001']) mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001'])
self.etcd = Etcd('foo', {'ttl': 30, 'host': 'localhost:2379', 'scope': 'test'}) self.etcd = Etcd('foo', {'namespace': '/patroni/', 'ttl': 30, 'host': 'localhost:2379', 'scope': 'test'})
self.etcd.client.write = etcd_write
self.etcd.client.read = etcd_read def test_base_path(self):
self.etcd.client.delete = Mock(side_effect=etcd.EtcdException()) self.assertEquals(self.etcd._base_path, '/patroni/test')
@patch('dns.resolver.query', dns_query) @patch('dns.resolver.query', dns_query)
def test_get_etcd_client(self): def test_get_etcd_client(self):
@@ -217,11 +210,8 @@ class TestEtcd(unittest.TestCase):
cluster = self.etcd.get_cluster() cluster = self.etcd.get_cluster()
self.assertIsInstance(cluster, Cluster) self.assertIsInstance(cluster, Cluster)
self.assertIsNone(cluster.leader) self.assertIsNone(cluster.leader)
def test_current_leader(self):
self.assertIsInstance(self.etcd.current_leader(), Leader)
self.etcd._base_path = '/service/noleader' self.etcd._base_path = '/service/noleader'
self.assertIsNone(self.etcd.current_leader()) self.assertRaises(EtcdError, self.etcd.get_cluster)
def test_touch_member(self): def test_touch_member(self):
self.assertFalse(self.etcd.touch_member('', '')) self.assertFalse(self.etcd.touch_member('', ''))
@@ -250,11 +240,15 @@ class TestEtcd(unittest.TestCase):
def test_delete_leader(self): def test_delete_leader(self):
self.assertFalse(self.etcd.delete_leader()) self.assertFalse(self.etcd.delete_leader())
@patch.object(etcd.Client, 'watch', etcd_watch)
def test_watch(self): def test_watch(self):
self.etcd.client.watch = etcd_watch
self.etcd.watch(0) self.etcd.watch(0)
self.etcd.get_cluster() self.etcd.get_cluster()
self.etcd.watch(1.5) self.etcd.watch(1.5)
self.etcd.watch(4.5) self.etcd.watch(4.5)
self.etcd.watch(9.5) with patch.object(AbstractDCS, 'watch', Mock()):
self.etcd.watch(100) self.etcd.watch(9.5)
@patch('patroni.etcd.Etcd.retry', Mock(side_effect=AttributeError("foo")))
def test_other_exceptions(self):
self.assertRaises(EtcdError, self.etcd.cancel_initialization)
+153 -81
View File
@@ -1,10 +1,14 @@
import etcd
import unittest import unittest
import datetime
import pytz
from mock import Mock, patch from mock import Mock, MagicMock, patch
from patroni.dcs import Cluster, Failover, Leader, Member from patroni.dcs import Cluster, Failover, Leader, Member
from patroni.etcd import Client, Etcd from patroni.etcd import Client, Etcd
from patroni.exceptions import DCSError, PostgresException from patroni.exceptions import DCSError, PostgresException
from patroni.ha import Ha from patroni.ha import Ha
from patroni.postgresql import Postgresql
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
@@ -17,7 +21,7 @@ def false(*args, **kwargs):
def get_cluster(initialize, leader, members, failover): def get_cluster(initialize, leader, members, failover):
return Cluster(initialize, leader, None, members, failover) return Cluster(initialize, leader, 10, members, failover)
def get_cluster_not_initialized_without_leader(): def get_cluster_not_initialized_without_leader():
@@ -25,84 +29,77 @@ def get_cluster_not_initialized_without_leader():
def get_cluster_initialized_without_leader(leader=False, failover=None): def get_cluster_initialized_without_leader(leader=False, failover=None):
m = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres', m1 = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres',
'api_url': 'http://127.0.0.1:8008/patroni'}) 'api_url': 'http://127.0.0.1:8008/patroni', 'xlog_location': 4})
l = Leader(0, 0, m) if leader else None l = Leader(0, 0, m1) if leader else None
o = Member(0, 'other', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres', m2 = Member(0, 'other', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
'api_url': 'http://127.0.0.1:8011/patroni'}) 'api_url': 'http://127.0.0.1:8011/patroni', 'tags': {'clonefrom': True}})
return get_cluster(True, l, [m, o], failover) return get_cluster(True, l, [m1, m2], failover)
def get_cluster_initialized_with_leader(failover=None): def get_cluster_initialized_with_leader(failover=None):
return get_cluster_initialized_without_leader(leader=True, failover=failover) return get_cluster_initialized_without_leader(leader=True, failover=failover)
class MockPostgresql(Mock): def get_cluster_initialized_with_only_leader(failover=None):
l = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
name = 'postgresql0' return get_cluster(True, l, [l], failover)
role = 'replica'
state = 'running'
connection_string = 'postgres://foo@bar/postgres'
def is_healthy(self):
return True
def start(self):
return True
def is_healthiest_node(self, members):
return True
def is_leader(self):
return True
def xlog_position(self):
return 0
def last_operation(self):
return 0
def data_directory_empty(self):
return False
def bootstrap(self, *args, **kwargs):
return True
def check_replication_lag(self, last_leader_operation):
return True
def check_recovery_conf(self, leader):
return False
class MockPatroni: class MockPatroni(object):
def __init__(self, p, d): def __init__(self, p, d):
self.postgresql = p self.postgresql = p
self.dcs = d self.dcs = d
self.api = Mock() self.api = Mock()
self.tags = {'foo': 'bar'}
self.nofailover = None
self.nap_time = 10
self.replicatefrom = None
self.api.connection_string = 'http://127.0.0.1:8008' self.api.connection_string = 'http://127.0.0.1:8008'
self.clonefrom = None
def run_async(func, args=()): def run_async(func, args=()):
func(*args) if args else func() return func(*args) if args else func()
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
@patch.object(Postgresql, 'is_leader', Mock(return_value=True))
@patch.object(Postgresql, 'xlog_position', Mock(return_value=0))
@patch.object(Postgresql, 'call_nowait', Mock(return_value=True))
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database system identifier': '1234567890'}))
@patch.object(Postgresql, 'sync_replication_slots', Mock())
@patch.object(Postgresql, 'write_pg_hba', Mock())
@patch.object(Postgresql, 'write_pgpass', Mock())
@patch.object(Postgresql, 'write_recovery_conf', Mock())
@patch.object(Postgresql, 'query', Mock())
@patch.object(Postgresql, 'checkpoint', Mock())
@patch.object(etcd.Client, 'write', etcd_write)
@patch.object(etcd.Client, 'read', etcd_read)
@patch.object(etcd.Client, 'delete', Mock(side_effect=etcd.EtcdException))
@patch('subprocess.call', Mock(return_value=0))
class TestHa(unittest.TestCase): class TestHa(unittest.TestCase):
@patch('socket.getaddrinfo', socket_getaddrinfo) @patch('socket.getaddrinfo', socket_getaddrinfo)
@patch.object(Client, 'machines') @patch.object(etcd.Client, 'read', etcd_read)
def setUp(self, mock_machines): def setUp(self):
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379']) with patch.object(Client, 'machines') as mock_machines:
self.p = MockPostgresql() mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'}) self.p = Postgresql({'name': 'postgresql0', 'scope': 'dummy', 'listen': '127.0.0.1:5432',
self.e.client.read = etcd_read 'data_dir': 'data/postgresql0', 'superuser': {}, 'admin': {},
self.e.client.write = etcd_write 'replication': {'username': '', 'password': '', 'network': ''}})
self.ha = Ha(MockPatroni(self.p, self.e)) self.p.set_state('running')
self.ha._async_executor.run_async = run_async self.p.set_role('replica')
self.ha.old_cluster = self.e.get_cluster() self.p.check_replication_lag = true
self.ha.cluster = get_cluster_not_initialized_without_leader() self.p.can_create_replica_without_replication_connection = MagicMock(return_value=False)
self.ha.load_cluster_from_dcs = Mock() self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'})
self.ha = Ha(MockPatroni(self.p, self.e))
self.ha._async_executor.run_async = run_async
self.ha.old_cluster = self.e.get_cluster()
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.ha.load_cluster_from_dcs = Mock()
def test_update_lock(self): def test_update_lock(self):
self.p.last_operation = Mock(side_effect=PostgresException('')) self.p.last_operation = Mock(side_effect=PostgresException(''))
@@ -114,23 +111,36 @@ class TestHa(unittest.TestCase):
def test_start_as_replica(self): def test_start_as_replica(self):
self.p.is_healthy = false self.p.is_healthy = false
self.assertEquals(self.ha.run_cycle(), 'started as a secondary') self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
def test_recover_replica_failed(self): def test_recover_replica_failed(self):
self.p.controldata = lambda: {'Database cluster state': 'in production'} self.p.controldata = lambda: {'Database cluster state': 'in production'}
self.p.is_healthy = false self.p.is_healthy = false
self.p.follow_the_leader = false self.p.is_running = false
self.p.follow = false
self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
self.assertEquals(self.ha.run_cycle(), 'failed to start postgres') self.assertEquals(self.ha.run_cycle(), 'failed to start postgres')
def test_recover_master_failed(self): def test_recover_master_failed(self):
self.p.follow_the_leader = false self.p.follow = false
self.p.is_healthy = false self.p.is_healthy = false
self.ha.has_lock = true self.p.is_running = false
self.assertEquals(self.ha.run_cycle(), 'removed leader key after trying and failing to start postgres') self.p.name = 'leader'
self.p.set_role('master')
self.p.controldata = lambda: {'Database cluster state': 'in production'}
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEquals(self.ha.run_cycle(), 'starting as readonly because i had the session lock')
@patch('sys.exit', return_value=1)
@patch('patroni.ha.Ha.sysid_valid', MagicMock(return_value=True))
def test_sysid_no_match(self, exit_mock):
self.ha.run_cycle()
exit_mock.assert_called_once_with(1)
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False)) @patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
def test_start_as_readonly(self): def test_start_as_readonly(self):
self.p.is_leader = self.p.is_healthy = false self.p.is_leader = false
self.p.is_healthy = true
self.ha.has_lock = true self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock') self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
@@ -144,7 +154,7 @@ class TestHa(unittest.TestCase):
def test_demote_after_failing_to_obtain_lock(self): def test_demote_after_failing_to_obtain_lock(self):
self.ha.acquire_lock = false self.ha.acquire_lock = false
self.assertEquals(self.ha.run_cycle(), 'demoted self due after trying and failing to obtain lock') self.assertEquals(self.ha.run_cycle(), 'demoted self after trying and failing to obtain lock')
def test_follow_new_leader_after_failing_to_obtain_lock(self): def test_follow_new_leader_after_failing_to_obtain_lock(self):
self.ha.is_healthiest_node = true self.ha.is_healthiest_node = true
@@ -182,24 +192,30 @@ class TestHa(unittest.TestCase):
self.ha.update_lock = false self.ha.update_lock = false
self.assertEquals(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader') self.assertEquals(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
def test_follow_the_leader(self): def test_follow(self):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.p.is_leader = false self.p.is_leader = false
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader') self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
self.ha.patroni.replicatefrom = "foo"
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
def test_no_etcd_connection_master_demote(self): def test_no_etcd_connection_master_demote(self):
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly')) self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
self.assertEquals(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader') self.assertEquals(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader')
def test_bootstrap_from_leader(self): def test_bootstrap_from_another_member(self):
self.ha.cluster = get_cluster_initialized_with_leader() self.ha.cluster = get_cluster_initialized_with_leader()
self.p.bootstrap = false self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from replica \'other\'')
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from leader')
def test_bootstrap_waiting_for_leader(self): def test_bootstrap_waiting_for_leader(self):
self.ha.cluster = get_cluster_initialized_without_leader() self.ha.cluster = get_cluster_initialized_without_leader()
self.assertEquals(self.ha.bootstrap(), 'waiting for leader to bootstrap') self.assertEquals(self.ha.bootstrap(), 'waiting for leader to bootstrap')
def test_bootstrap_without_leader(self):
self.ha.cluster = get_cluster_initialized_without_leader()
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap (without leader)')
def test_bootstrap_initialize_lock_failed(self): def test_bootstrap_initialize_lock_failed(self):
self.ha.cluster = get_cluster_not_initialized_without_leader() self.ha.cluster = get_cluster_not_initialized_without_leader()
self.assertEquals(self.ha.bootstrap(), 'failed to acquire initialize lock') self.assertEquals(self.ha.bootstrap(), 'failed to acquire initialize lock')
@@ -253,43 +269,90 @@ class TestHa(unittest.TestCase):
self.assertEquals(self.ha.run_cycle(), 'failed to update leader lock during restart') self.assertEquals(self.ha.run_cycle(), 'failed to update leader lock during restart')
@patch('requests.get', requests_get) @patch('requests.get', requests_get)
@patch('time.sleep', Mock())
def test_manual_failover_from_leader(self): def test_manual_failover_from_leader(self):
self.ha.has_lock = true self.ha.has_lock = true
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '')) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock') self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', MockPostgresql.name)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock') self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla')) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock') self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
f = Failover(0, MockPostgresql.name, '') f = Failover(0, self.p.name, '', None)
self.ha.cluster = get_cluster_initialized_with_leader(f) self.ha.cluster = get_cluster_initialized_with_leader(f)
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself') self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'})
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
# manual failover from the previous leader to us won't happen if we hold the nofailover flag
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
# Failover scheduled time must include timezone
scheduled = datetime.datetime.now()
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.ha.run_cycle()
scheduled = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=30)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=-600)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
scheduled = None
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
@patch('requests.get', requests_get) @patch('requests.get', requests_get)
def test_manual_failover_process_no_leader(self): def test_manual_failover_process_no_leader(self):
self.p.is_leader = false self.p.is_leader = false
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', MockPostgresql.name)) self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader')) self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader', None))
self.p.set_role('replica')
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
self.ha.fetch_node_status = lambda e: (e, True, True, 0) # accessible, in_recovery self.ha.fetch_node_status = lambda e: (e, True, True, 0, {}) # accessible, in_recovery
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, MockPostgresql.name, '')) self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, self.p.name, '', None))
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
self.ha.fetch_node_status = lambda e: (e, False, True, 0) # accessible, in_recovery self.ha.fetch_node_status = lambda e: (e, False, True, 0, {}) # inaccessible, in_recovery
self.p.set_role('replica')
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
# set failover flag to True for all members of the cluster
# this should elect the current member, as we are not going to call the API for it.
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'}) # accessible, in_recovery
self.p.set_role('replica')
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
# same as previous, but set the current member to nofailover. In no case it should be elected as a leader
self.ha.patroni.nofailover = True
self.assertEquals(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
def test_is_healthiest_node(self):
self.ha.state_handler.is_leader = false
self.ha.patroni.nofailover = False
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {})
self.assertTrue(self.ha.is_healthiest_node())
def test__is_healthiest_node(self): def test__is_healthiest_node(self):
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.p.is_leader = false self.p.is_leader = false
self.ha.fetch_node_status = lambda e: (e, True, True, 0) # accessible, in_recovery self.ha.fetch_node_status = lambda e: (e, True, True, 0, {}) # accessible, in_recovery
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.fetch_node_status = lambda e: (e, True, False, 0) # accessible, not in_recovery self.ha.fetch_node_status = lambda e: (e, True, False, 0, {}) # accessible, not in_recovery
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.fetch_node_status = lambda e: (e, True, True, 1) # accessible, in_recovery, xlog location ahead self.ha.fetch_node_status = lambda e: (e, True, True, 1, {}) # accessible, in_recovery, xlog location ahead
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.p.check_replication_lag = false self.p.check_replication_lag = false
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.patroni.nofailover = True
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.patroni.nofailover = False
@patch('requests.get', requests_get) @patch('requests.get', requests_get)
def test_fetch_node_status(self): def test_fetch_node_status(self):
@@ -297,3 +360,12 @@ class TestHa(unittest.TestCase):
self.ha.fetch_node_status(member) self.ha.fetch_node_status(member)
member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'}) member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'})
self.ha.fetch_node_status(member) self.ha.fetch_node_status(member)
def test_post_recover(self):
self.p.is_running = false
self.ha.has_lock = true
self.assertEqual(self.ha.post_recover(), 'removed leader key after trying and failing to start postgres')
self.ha.has_lock = false
self.assertEqual(self.ha.post_recover(), 'failed to start postgres')
self.p.is_running = true
self.assertIsNone(self.ha.post_recover())
+49 -29
View File
@@ -1,3 +1,5 @@
import etcd
import os
import sys import sys
import time import time
import unittest import unittest
@@ -6,8 +8,9 @@ import yaml
from mock import Mock, patch from mock import Mock, patch
from patroni.api import RestApiServer from patroni.api import RestApiServer
from patroni.async_executor import AsyncExecutor from patroni.async_executor import AsyncExecutor
from patroni.consul import Consul
from patroni.etcd import Etcd from patroni.etcd import Etcd
from patroni import Patroni, main from patroni import Patroni, PatroniException, main as _main
from patroni.zookeeper import ZooKeeper from patroni.zookeeper import ZooKeeper
from six.moves import BaseHTTPServer from six.moves import BaseHTTPServer
from test_etcd import Client, SleepException, etcd_read, etcd_write from test_etcd import Client, SleepException, etcd_read, etcd_write
@@ -15,10 +18,6 @@ from test_postgresql import Postgresql, psycopg2_connect
from test_zookeeper import MockKazooClient from test_zookeeper import MockKazooClient
def time_sleep(*args):
raise SleepException()
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect) @patch('psycopg2.connect', psycopg2_connect)
@@ -26,46 +25,52 @@ def time_sleep(*args):
@patch.object(Postgresql, 'write_recovery_conf', Mock()) @patch.object(Postgresql, 'write_recovery_conf', Mock())
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock()) @patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
@patch.object(AsyncExecutor, 'run', Mock()) @patch.object(AsyncExecutor, 'run', Mock())
@patch.object(etcd.Client, 'write', etcd_write)
@patch.object(etcd.Client, 'read', etcd_read)
class TestPatroni(unittest.TestCase): class TestPatroni(unittest.TestCase):
@patch.object(Client, 'machines') def setUp(self):
def setUp(self, mock_machines): with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379']) mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.touched = False self.touched = False
self.init_cancelled = False self.init_cancelled = False
RestApiServer._BaseServer__is_shut_down = Mock() RestApiServer._BaseServer__is_shut_down = Mock()
RestApiServer._BaseServer__shutdown_request = True RestApiServer._BaseServer__shutdown_request = True
RestApiServer.socket = 0 RestApiServer.socket = 0
with open('postgres0.yml', 'r') as f: with open('postgres0.yml', 'r') as f:
config = yaml.load(f) config = yaml.load(f)
self.p = Patroni(config) self.p = Patroni(config)
self.p.ha.dcs.client.write = etcd_write
self.p.ha.dcs.client.read = etcd_read
@patch('patroni.zookeeper.KazooClient', MockKazooClient()) @patch('patroni.zookeeper.KazooClient', MockKazooClient())
@patch.object(Consul, 'create_or_restore_session', Mock())
def test_get_dcs(self): def test_get_dcs(self):
self.assertIsInstance(self.p.get_dcs('', {'zookeeper': {'scope': '', 'hosts': ''}}), ZooKeeper) self.assertIsInstance(self.p.get_dcs('', {'zookeeper': {'scope': '', 'hosts': ''}}), ZooKeeper)
self.assertRaises(Exception, self.p.get_dcs, '', {}) self.assertIsInstance(self.p.get_dcs('', {'consul': {'scope': '', 'hosts': '127.0.0.1:1'}}), Consul)
self.assertRaises(PatroniException, self.p.get_dcs, '', {})
@patch('time.sleep', Mock(side_effect=SleepException())) @patch('time.sleep', Mock(side_effect=SleepException))
@patch.object(Etcd, 'delete_leader', Mock()) @patch.object(Etcd, 'delete_leader', Mock())
@patch.object(Client, 'machines') @patch.object(Client, 'machines')
def test_patroni_main(self, mock_machines): def test_patroni_main(self, mock_machines):
main() _main()
sys.argv = ['patroni.py', 'postgres0.yml'] sys.argv = ['patroni.py', 'postgres0.yml']
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379']) mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
with patch.object(Patroni, 'run', Mock(side_effect=SleepException())): with patch.object(Patroni, 'run', Mock(side_effect=SleepException)):
self.assertRaises(SleepException, main) self.assertRaises(SleepException, _main)
with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())): with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())):
main() _main()
sys.argv = ['patroni.py']
# read the content of the yaml configuration file into the environment variable
# in order to test how does patroni handle the configuration passed from the environment.
with open('postgres0.yml', 'r') as f:
os.environ[Patroni.PATRONI_CONFIG_VARIABLE] = f.read()
with patch.object(Patroni, 'run', Mock(side_effect=SleepException())):
self.assertRaises(SleepException, _main)
del os.environ[Patroni.PATRONI_CONFIG_VARIABLE]
@patch('time.sleep', Mock(side_effect=SleepException()))
def test_run(self): def test_run(self):
self.p.ha.dcs.watch = time_sleep self.p.ha.dcs.watch = Mock(side_effect=SleepException)
self.assertRaises(SleepException, self.p.run)
self.p.ha.state_handler.is_leader = Mock(return_value=False)
self.p.api.start = Mock() self.p.api.start = Mock()
self.assertRaises(SleepException, self.p.run) self.assertRaises(SleepException, self.p.run)
@@ -74,3 +79,18 @@ class TestPatroni(unittest.TestCase):
self.p.schedule_next_run() self.p.schedule_next_run()
self.p.next_run = time.time() - self.p.nap_time - 1 self.p.next_run = time.time() - self.p.nap_time - 1
self.p.schedule_next_run() self.p.schedule_next_run()
def test_noloadbalance(self):
self.p.tags['noloadbalance'] = True
self.assertTrue(self.p.noloadbalance)
def test_nofailover(self):
self.p.tags['nofailover'] = True
self.assertTrue(self.p.nofailover)
self.p.tags['nofailover'] = None
self.assertFalse(self.p.nofailover)
def test_replicatefrom(self):
self.assertIsNone(self.p.replicatefrom)
self.p.tags['replicatefrom'] = 'foo'
self.assertEqual(self.p.replicatefrom, 'foo')
+141 -80
View File
@@ -2,24 +2,19 @@ import mock # for the mock.call method, importing it without a namespace breaks
import os import os
import psycopg2 import psycopg2
import shutil import shutil
import subprocess
import unittest import unittest
from sys import version_info
if version_info.major == 2:
import __builtin__ as builtins
else:
import builtins
from mock import Mock, MagicMock, PropertyMock, patch, mock_open from mock import Mock, MagicMock, PropertyMock, patch, mock_open
from patroni.dcs import Cluster, Leader, Member from patroni.dcs import Cluster, Leader, Member
from patroni.exceptions import PostgresException, PostgresConnectionException from patroni.exceptions import PostgresException, PostgresConnectionException
from patroni.postgresql import Postgresql from patroni.postgresql import Postgresql
from patroni.utils import RetryFailedError from patroni.utils import RetryFailedError
from six.moves import builtins
from test_ha import false from test_ha import false
import subprocess
class MockCursor: class MockCursor(object):
def __init__(self, connection): def __init__(self, connection):
self.connection = connection self.connection = connection
@@ -38,7 +33,7 @@ class MockCursor:
elif sql == 'SELECT pg_is_in_recovery()': elif sql == 'SELECT pg_is_in_recovery()':
self.results = [(False, )] self.results = [(False, )]
elif sql.startswith('SELECT to_char(pg_postmaster_start_time'): elif sql.startswith('SELECT to_char(pg_postmaster_start_time'):
self.results = [('', True, '', '', '', False)] self.results = [('', True, '', '', '', '', False)]
else: else:
self.results = [( self.results = [(
None, None,
@@ -56,8 +51,8 @@ class MockCursor:
def fetchone(self): def fetchone(self):
return self.results[0] return self.results[0]
def close(self): def fetchall(self):
pass return self.results
def __iter__(self): def __iter__(self):
for i in self.results: for i in self.results:
@@ -70,8 +65,9 @@ class MockCursor:
pass pass
class MockConnect(Mock): class MockConnect(object):
server_version = '99999'
autocommit = False autocommit = False
closed = 0 closed = 0
@@ -84,9 +80,13 @@ class MockConnect(Mock):
def __exit__(self, *args): def __exit__(self, *args):
pass pass
@staticmethod
def close():
pass
def pg_controldata_string(*args, **kwargs): def pg_controldata_string(*args, **kwargs):
return """ return b"""
pg_control version number: 942 pg_control version number: 942
Catalog version number: 201509161 Catalog version number: 201509161
Database system identifier: 6200971513092291716 Database system identifier: 6200971513092291716
@@ -141,19 +141,22 @@ Data page checksum version: 0
def postmaster_opts_string(*args, **kwargs): def postmaster_opts_string(*args, **kwargs):
return '/usr/local/pgsql/bin/postgres "-D" "data/postgresql0" "--listen_addresses=127.0.0.1" "--port=5432"'\ return '/usr/local/pgsql/bin/postgres "-D" "data/postgresql0" "--listen_addresses=127.0.0.1" \
' "--hot_standby=on" "--wal_keep_segments=8" "--wal_level=hot_standby" "--archive_command=mkdir -p ../wal_archive \n'\ "--port=5432" "--hot_standby=on" "--wal_keep_segments=8" "--wal_level=hot_standby" \
'&& cp %p ../wal_archive/%f" "--wal_log_hints=on" "--max_wal_senders=5" "--archive_timeout=1800s" "--archive_mode=on"'\ "--archive_command=mkdir -p ../wal_archive && cp %p ../wal_archive/%f" "--wal_log_hints=on" \
' "--max_replication_slots=5"\n' "--max_wal_senders=5" "--archive_timeout=1800s" "--archive_mode=on" "--max_replication_slots=5"\n'
def psycopg2_connect(*args, **kwargs): def psycopg2_connect(*args, **kwargs):
return MockConnect() return MockConnect()
def fake_listdir(path):
return ["a", "b", "c"] if path.endswith('pg_xlog/archive_status') else []
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect) @patch('psycopg2.connect', psycopg2_connect)
@patch('shutil.copy', Mock())
class TestPostgresql(unittest.TestCase): class TestPostgresql(unittest.TestCase):
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@@ -161,13 +164,14 @@ class TestPostgresql(unittest.TestCase):
def setUp(self): def setUp(self):
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0', self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0',
'listen': '127.0.0.1, *:5432', 'connect_address': '127.0.0.2:5432', 'listen': '127.0.0.1, *:5432', 'connect_address': '127.0.0.2:5432',
'pg_hba': ['hostssl all all 0.0.0.0/0 md5', 'host all all 0.0.0.0/0 md5'], 'pg_hba': ['host replication replicator 127.0.0.1/32 md5',
'superuser': {'password': ''}, 'hostssl all all 0.0.0.0/0 md5',
'host all all 0.0.0.0/0 md5'],
'superuser': {'username': 'test', 'password': 'test'},
'admin': {'username': 'admin', 'password': 'admin'}, 'admin': {'username': 'admin', 'password': 'admin'},
'pg_rewind': {'username': 'admin', 'password': 'admin'}, 'pg_rewind': {'username': 'admin', 'password': 'admin'},
'replication': {'username': 'replicator', 'replication': {'username': 'replicator',
'password': 'rep-pass', 'password': 'rep-pass'},
'network': '127.0.0.1/32'},
'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'}, 'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'},
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'callbacks': {'on_start': 'true', 'on_stop': 'true',
'on_restart': 'true', 'on_role_change': 'true', 'on_restart': 'true', 'on_role_change': 'true',
@@ -178,7 +182,8 @@ class TestPostgresql(unittest.TestCase):
os.makedirs(self.p.data_dir) os.makedirs(self.p.data_dir)
self.leadermem = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres'}) self.leadermem = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
self.leader = Leader(-1, 28, self.leadermem) self.leader = Leader(-1, 28, self.leadermem)
self.other = Member(0, 'test1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres'}) self.other = Member(0, 'test1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
'tags': {'replicatefrom': 'leader'}})
self.me = Member(0, 'test0', 28, {'conn_url': 'postgres://replicator:[email protected]:5434/postgres'}) self.me = Member(0, 'test0', 28, {'conn_url': 'postgres://replicator:[email protected]:5434/postgres'})
def tearDown(self): def tearDown(self):
@@ -187,10 +192,30 @@ class TestPostgresql(unittest.TestCase):
def test_data_directory_empty(self): def test_data_directory_empty(self):
self.assertTrue(self.p.data_directory_empty()) self.assertTrue(self.p.data_directory_empty())
def test_get_initdb_options(self):
self.p.initdb_options = [{'encoding': 'UTF8'}, 'data-checksums']
self.assertEquals(self.p.get_initdb_options(), ['--encoding=UTF8', '--data-checksums'])
self.p.initdb_options = [{'pgdata': 'bar'}]
self.assertRaises(Exception, self.p.get_initdb_options)
self.p.initdb_options = [{'foo': 'bar', 1: 2}]
self.assertRaises(Exception, self.p.get_initdb_options)
self.p.initdb_options = [1]
self.assertRaises(Exception, self.p.get_initdb_options)
def test_initialize(self): def test_initialize(self):
self.assertTrue(self.p.initialize()) self.assertTrue(self.p.initialize())
self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf'))) self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf')))
with open(os.path.join(self.p.data_dir, 'pg_hba.conf')) as f:
lines = f.readlines()
assert 'host replication replicator 127.0.0.1/32 md5\n' in lines
assert 'host all all 0.0.0.0/0 md5\n' in lines
@patch('os.path.exists', Mock(return_value=True))
@patch('os.unlink', Mock())
def test_delete_trigger_file(self):
self.p.delete_trigger_file()
def test_start(self): def test_start(self):
self.assertTrue(self.p.start()) self.assertTrue(self.p.start())
self.p.is_running = false self.p.is_running = false
@@ -210,10 +235,18 @@ class TestPostgresql(unittest.TestCase):
self.assertFalse(self.p.restart()) self.assertFalse(self.p.restart())
self.assertEquals(self.p.state, 'restart failed (restarting)') self.assertEquals(self.p.state, 'restart failed (restarting)')
def test_sync_from_leader(self): @patch.object(builtins, 'open', MagicMock())
self.assertTrue(self.p.sync_from_leader(self.leader)) def test_write_pgpass(self):
self.p.write_pgpass({'host': 'localhost', 'port': '5432', 'user': 'foo', 'password': 'bar'})
@patch('subprocess.call', side_effect=Exception("Test")) @patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
def test_sync_replica(self):
self.assertTrue(self.p.sync_replica(self.leader))
self.p.create_replica = Mock(return_value=1)
self.assertFalse(self.p.sync_replica(self.leader))
@patch('subprocess.call', side_effect=OSError)
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
def test_pg_rewind(self, mock_call): def test_pg_rewind(self, mock_call):
self.assertTrue(self.p.rewind(self.leader)) self.assertTrue(self.p.rewind(self.leader))
subprocess.call = mock_call subprocess.call = mock_call
@@ -222,26 +255,27 @@ class TestPostgresql(unittest.TestCase):
@patch('patroni.postgresql.Postgresql.rewind', return_value=False) @patch('patroni.postgresql.Postgresql.rewind', return_value=False)
@patch('patroni.postgresql.Postgresql.remove_data_directory', MagicMock(return_value=True)) @patch('patroni.postgresql.Postgresql.remove_data_directory', MagicMock(return_value=True))
@patch('patroni.postgresql.Postgresql.single_user_mode', MagicMock(return_value=1)) @patch('patroni.postgresql.Postgresql.single_user_mode', MagicMock(return_value=1))
def test_follow_the_leader(self, mock_pg_rewind): @patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
self.p.demote() @patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
self.p.follow_the_leader(None) def test_follow(self, mock_pg_rewind):
self.p.demote() self.p.follow(None)
self.p.follow_the_leader(self.leader) self.p.follow(self.leader)
self.p.follow_the_leader(Leader(-1, 28, self.other)) self.p.follow(Leader(-1, 28, self.other))
self.p.rewind = mock_pg_rewind self.p.rewind = mock_pg_rewind
self.p.follow_the_leader(self.leader) self.p.follow(self.leader)
self.p.require_rewind()
with mock.patch('os.path.islink', MagicMock(return_value=True)): with mock.patch('os.path.islink', MagicMock(return_value=True)):
with mock.patch('os.unlink', MagicMock(return_value=True)): with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)):
with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)): with mock.patch('os.unlink', MagicMock(return_value=True)):
self.p.follow_the_leader(self.leader, recovery=True) self.p.follow(self.leader, recovery=True)
self.p.require_rewind()
with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)): with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)):
self.p.rewind.return_value = True self.p.rewind.return_value = True
self.p.follow_the_leader(self.leader, recovery=True) self.p.follow(self.leader, recovery=True)
self.p.rewind.return_value = False self.p.rewind.return_value = False
self.p.follow_the_leader(self.leader, recovery=True) self.p.follow(self.leader, recovery=True)
with mock.patch('patroni.postgresql.Postgresql.check_recovery_conf', MagicMock(return_value=True)):
self.assertTrue(self.p.follow(None))
@patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
def test_can_rewind(self): def test_can_rewind(self):
tmp = self.p.pg_rewind tmp = self.p.pg_rewind
self.p.pg_rewind = None self.p.pg_rewind = None
@@ -249,22 +283,30 @@ class TestPostgresql(unittest.TestCase):
self.p.pg_rewind = tmp self.p.pg_rewind = tmp
with mock.patch('subprocess.call', MagicMock(return_value=1)): with mock.patch('subprocess.call', MagicMock(return_value=1)):
self.assertFalse(self.p.can_rewind) self.assertFalse(self.p.can_rewind)
with mock.patch('subprocess.call', side_effect=OSError("foo")): with mock.patch('subprocess.call', side_effect=OSError):
self.assertFalse(self.p.can_rewind) self.assertFalse(self.p.can_rewind)
tmp = self.p.controldata() tmp = self.p.controldata
self.p.controldata = lambda: {'wal_log_hints setting': 'on'} self.p.controldata = lambda: {'wal_log_hints setting': 'on'}
self.assertTrue(self.p.can_rewind) self.assertTrue(self.p.can_rewind)
self.p.controldata = tmp self.p.controldata = tmp
@patch('time.sleep', Mock())
def test_create_replica(self): def test_create_replica(self):
self.p.delete_trigger_file = Mock(side_effect=OSError()) self.p.delete_trigger_file = Mock(side_effect=OSError)
self.assertEquals(self.p.create_replica({'host': '', 'port': '', 'user': ''}, ''), 1) with patch('subprocess.call', Mock(side_effect=[1, 0])):
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
with patch('subprocess.call', Mock(side_effect=[Exception(), 0])):
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
def test_create_connection_users(self): self.p.config['create_replica_method'] = ['wale', 'basebackup']
cfg = self.p.config self.p.config['wale'] = {'command': 'foo'}
cfg['superuser']['username'] = 'test' with patch('subprocess.call', Mock(return_value=0)):
p = Postgresql(cfg) self.assertEquals(self.p.create_replica(self.leader, ''), 0)
p.create_connection_users() del self.p.config['wale']
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
with patch('subprocess.call', Mock(side_effect=Exception("foo"))):
self.assertEquals(self.p.create_replica(self.leader, ''), 1)
def test_sync_replication_slots(self): def test_sync_replication_slots(self):
self.p.start() self.p.start()
@@ -273,6 +315,9 @@ class TestPostgresql(unittest.TestCase):
self.p.query = Mock(side_effect=psycopg2.OperationalError) self.p.query = Mock(side_effect=psycopg2.OperationalError)
self.p.schedule_load_slots = True self.p.schedule_load_slots = True
self.p.sync_replication_slots(cluster) self.p.sync_replication_slots(cluster)
self.p.schedule_load_slots = False
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
self.p.sync_replication_slots(cluster)
@patch.object(MockConnect, 'closed', 2) @patch.object(MockConnect, 'closed', 2)
def test__query(self): def test__query(self):
@@ -304,7 +349,7 @@ class TestPostgresql(unittest.TestCase):
def test_last_operation(self): def test_last_operation(self):
self.assertEquals(self.p.last_operation(), '0') self.assertEquals(self.p.last_operation(), '0')
@patch('subprocess.Popen', Mock(side_effect=OSError())) @patch('subprocess.Popen', Mock(side_effect=OSError))
def test_call_nowait(self): def test_call_nowait(self):
self.assertFalse(self.p.call_nowait('on_start')) self.assertFalse(self.p.call_nowait('on_start'))
@@ -324,14 +369,16 @@ class TestPostgresql(unittest.TestCase):
def test_move_data_directory(self): def test_move_data_directory(self):
self.p.is_running = false self.p.is_running = false
self.p.move_data_directory() self.p.move_data_directory()
with patch('os.rename', Mock(side_effect=OSError())): with patch('os.rename', Mock(side_effect=OSError)):
self.p.move_data_directory() self.p.move_data_directory()
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
def test_bootstrap(self): def test_bootstrap(self):
with patch('subprocess.call', Mock(return_value=1)): with patch('subprocess.call', Mock(return_value=1)):
self.assertRaises(PostgresException, self.p.bootstrap) self.assertRaises(PostgresException, self.p.bootstrap)
self.p.bootstrap() self.p.bootstrap()
self.p.bootstrap(self.leader) with patch('patroni.postgresql.Postgresql.sync_replica', MagicMock(return_value=True)):
self.p.bootstrap(self.leader)
def test_remove_data_directory(self): def test_remove_data_directory(self):
self.p.data_dir = 'data_dir' self.p.data_dir = 'data_dir'
@@ -341,26 +388,20 @@ class TestPostgresql(unittest.TestCase):
open(self.p.data_dir, 'w').close() open(self.p.data_dir, 'w').close()
self.p.remove_data_directory() self.p.remove_data_directory()
os.symlink('unexisting', self.p.data_dir) os.symlink('unexisting', self.p.data_dir)
with patch('os.unlink', Mock(side_effect=Exception)): with patch('os.unlink', Mock(side_effect=OSError)):
self.p.remove_data_directory() self.p.remove_data_directory()
self.p.remove_data_directory() self.p.remove_data_directory()
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string)) def test_controldata(self):
@patch('subprocess.check_output', side_effect=subprocess.CalledProcessError) with patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string)):
@patch('subprocess.check_output', side_effect=Exception('Failed')) data = self.p.controldata()
def test_controldata(self, check_output_call_error, check_output_generic_exception): self.assertEquals(len(data), 50)
data = self.p.controldata() self.assertEquals(data['Database cluster state'], 'shut down in recovery')
self.assertEquals(len(data), 50) self.assertEquals(data['wal_log_hints setting'], 'on')
self.assertEquals(data['Database cluster state'], 'shut down in recovery') self.assertEquals(int(data['Database block size']), 8192)
self.assertEquals(data['wal_log_hints setting'], 'on')
self.assertEquals(int(data['Database block size']), 8192)
subprocess.check_output = check_output_call_error with patch('subprocess.check_output', Mock(side_effect=subprocess.CalledProcessError(1, ''))):
data = self.p.controldata() self.assertEquals(self.p.controldata(), {})
self.assertEquals(data, dict())
subprocess.check_output = check_output_generic_exception
self.assertRaises(Exception, self.p.controldata())
def test_read_postmaster_opts(self): def test_read_postmaster_opts(self):
m = mock_open(read_data=postmaster_opts_string()) m = mock_open(read_data=postmaster_opts_string())
@@ -370,13 +411,10 @@ class TestPostgresql(unittest.TestCase):
self.assertEquals(int(data['max_replication_slots']), 5) self.assertEquals(int(data['max_replication_slots']), 5)
self.assertEqual(data.get('D'), None) self.assertEqual(data.get('D'), None)
m.side_effect = IOError("foo") m.side_effect = IOError
data = self.p.read_postmaster_opts() data = self.p.read_postmaster_opts()
self.assertEqual(data, dict()) self.assertEqual(data, dict())
m.side_effect = Exception("foo")
self.assertRaises(Exception, self.p.read_postmaster_opts())
@patch('subprocess.Popen') @patch('subprocess.Popen')
@patch.object(builtins, 'open', MagicMock(return_value=42)) @patch.object(builtins, 'open', MagicMock(return_value=42))
def test_single_user_mode(self, subprocess_popen_mock): def test_single_user_mode(self, subprocess_popen_mock):
@@ -396,13 +434,7 @@ class TestPostgresql(unittest.TestCase):
subprocess_popen_mock.return_value = None subprocess_popen_mock.return_value = None
self.assertEquals(self.p.single_user_mode(), 1) self.assertEquals(self.p.single_user_mode(), 1)
def fake_listdir(path):
if path.endswith(os.path.join('pg_xlog', 'archive_status')):
return ["a", "b", "c"]
return []
@patch('os.listdir', MagicMock(side_effect=fake_listdir)) @patch('os.listdir', MagicMock(side_effect=fake_listdir))
@patch('os.path.isdir', MagicMock(return_value=True))
@patch('os.unlink', return_value=True) @patch('os.unlink', return_value=True)
@patch('os.remove', return_value=True) @patch('os.remove', return_value=True)
@patch('os.path.islink', return_value=False) @patch('os.path.islink', return_value=False)
@@ -424,8 +456,37 @@ class TestPostgresql(unittest.TestCase):
mock_unlink.reset_mock() mock_unlink.reset_mock()
mock_remove.reset_mock() mock_remove.reset_mock()
mock_file.side_effect = Exception("foo") mock_file.side_effect = OSError
mock_link.side_effect = Exception("foo") mock_link.side_effect = OSError
self.p.cleanup_archive_status() self.p.cleanup_archive_status()
mock_unlink.assert_not_called() mock_unlink.assert_not_called()
mock_remove.assert_not_called() mock_remove.assert_not_called()
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
def test_sysid(self):
self.assertEqual(self.p.sysid, "6200971513092291716")
@patch('os.path.isfile', Mock(return_value=True))
@patch('shutil.copy', Mock(side_effect=IOError))
def test_save_configuration_files(self):
self.p.save_configuration_files()
@patch('os.path.isfile', Mock(side_effect=[False, True]))
@patch('shutil.copy', Mock(side_effect=IOError))
def test_restore_configuration_files(self):
self.p.restore_configuration_files()
def test_can_create_replica_without_replication_connection(self):
self.p.config['create_replica_method'] = []
self.assertFalse(self.p.can_create_replica_without_replication_connection())
self.p.config['create_replica_method'] = ['wale', 'basebackup']
self.p.config['wale'] = {'command': 'foo', 'no_master': 1}
self.assertTrue(self.p.can_create_replica_without_replication_connection())
def test_replica_method_can_work_without_replication_connection(self):
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('basebackup'))
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('foobar'))
self.p.config['foo'] = {'command': 'bar', 'no_master': 1}
self.assertTrue(self.p.replica_method_can_work_without_replication_connection('foo'))
self.p.config['foo'] = {'command': 'bar'}
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('foo'))
-111
View File
@@ -1,111 +0,0 @@
import unittest
from mock import MagicMock, patch
import os
from patroni.scripts.restore import Restore, WALERestore
def fake_cursor_fetchone(*args, **kwargs):
return ('16777216',)
def fake_call_fail_for_wal_e(*args, **kwargs):
if len(args) > 0 and 'backup-fetch' in args[0]:
return 1
return 0
def fake_call_fail_for_base_backup(*args, **kwargs):
if len(args) > 0 and 'backup-fetch' in args[0]:
return 0
return 1
def fake_backup_data(self, *args, **kwargs):
""" return the fake result of WAL-E backup-list"""
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
"""
class TestRestore(unittest.TestCase):
def setUp(self):
self.restore = Restore("batman", "master", "/data", "host=batman port=5432 user=batman")
pass
def tearDown(self):
pass
def test_parse_connstring(self):
self.assertDictEqual(self.restore.master_connection, {'host': 'batman', 'port': '5432', 'user': 'batman'})
@patch('subprocess.call', MagicMock(return_value=0))
def test_run(self):
ret = self.restore.run()
self.assertEqual(ret, 0)
@patch('subprocess.call', MagicMock(return_value=1))
def test_run_fail(self):
ret = self.restore.run()
self.assertEqual(ret, 1)
@patch('os.access', MagicMock(return_value=True))
@patch('os.makedirs', MagicMock(return_value=True))
@patch('os.path.exists', MagicMock(return_value=True))
@patch('os.path.isdir', MagicMock(return_value=True))
@patch('psycopg2.extensions.cursor.fetchone', MagicMock(side_effect=fake_cursor_fetchone))
@patch('psycopg2.extensions.cursor', MagicMock(autospec=True))
@patch('psycopg2.extensions.connection', MagicMock(autospec=True))
@patch('psycopg2.connect', MagicMock(autospec=True))
@patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data))
class TestWALERestore(unittest.TestCase):
def setUp(self):
env = {}
env['WAL_S3_BUCKET'] = 'batman'
env['WALE_BACKUP_THRESHOLD_PERCENTAGE'] = 100
env['WALE_BACKUP_THRESHOLD_MEGABYTES'] = 100
self.wale_restore = WALERestore("batman", "master", "/data", "host=batman port=5432 user=batman", env=env)
def tearDown(self):
pass
def test_setup(self):
self.wale_restore.setup()
self.assertFalse(self.wale_restore.init_error)
# have to redefine the class-level os.access mock inside the function
# since the class-level mock will be applied after the function level one.
@patch('os.access', return_value=False)
def test_setup_fail(self, mock_no_access):
os.access = mock_no_access
self.wale_restore.setup()
self.assertTrue(self.wale_restore.init_error)
# The 3 tests above only differ with the mock function instead of a subprocess call
# in the first one, subprocess call should return success only for wal-e command,
# checking the primary use-case of restoring from WAL-E backup.
# In the second one, we test fallbacks by failing at WAL-E, but succeeding at
# pg_basebackup.
# Finally, the last use case is when all subprocess.call fails. resulting in a
# failure to restore from replica
@patch('subprocess.call',
MagicMock(side_effect=lambda *args, **kwargs: 0 if 'wal-e' in args[0] else 1))
def test_run(self):
self.wale_restore.setup()
ret = self.wale_restore.run()
self.assertEqual(ret, 0)
@patch('subprocess.call',
MagicMock(side_effect=lambda *args, **kwargs: 0 if 'pg_basebackup' in args[0] else 1))
def test_run_fallback(self):
self.wale_restore.setup()
ret = self.wale_restore.run()
self.assertEqual(ret, 0)
@patch('subprocess.call', MagicMock(return_value=1))
def test_run_all_fail(self):
self.wale_restore.setup()
ret = self.wale_restore.run()
self.assertEqual(ret, 1)
+16 -14
View File
@@ -2,7 +2,8 @@ import unittest
from mock import Mock, patch from mock import Mock, patch
from patroni.exceptions import PatroniException from patroni.exceptions import PatroniException
from patroni.utils import Retry, RetryFailedError, reap_children, sigchld_handler, sigterm_handler, sleep from patroni.utils import reap_children, Retry, RetryFailedError, set_ignore_sigterm,\
sigchld_handler, sigterm_handler, sleep
def time_sleep(_): def time_sleep(_):
@@ -12,24 +13,26 @@ def time_sleep(_):
class TestUtils(unittest.TestCase): class TestUtils(unittest.TestCase):
def test_sigterm_handler(self): def test_sigterm_handler(self):
set_ignore_sigterm(False)
self.assertRaises(SystemExit, sigterm_handler, None, None) self.assertRaises(SystemExit, sigterm_handler, None, None)
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
def test_reap_children(self): def test_reap_children(self):
reap_children() self.assertIsNone(reap_children())
with patch('os.waitpid', Mock(return_value=(0, 0))): with patch('os.waitpid', Mock(return_value=(0, 0))):
sigchld_handler(None, None) sigchld_handler(None, None)
reap_children() self.assertIsNone(reap_children())
@patch('time.sleep', time_sleep) @patch('time.sleep', time_sleep)
def test_sleep(self): def test_sleep(self):
sleep(0.01) self.assertIsNone(sleep(0.01))
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
class TestRetrySleeper(unittest.TestCase): class TestRetrySleeper(unittest.TestCase):
def _fail(self, times=1): @staticmethod
def _fail(times=1):
scope = dict(times=0) scope = dict(times=0)
def inner(): def inner():
@@ -40,34 +43,33 @@ class TestRetrySleeper(unittest.TestCase):
raise PatroniException('Failed!') raise PatroniException('Failed!')
return inner return inner
def _makeOne(self, *args, **kwargs):
return Retry(*args, **kwargs)
def test_reset(self): def test_reset(self):
retry = self._makeOne(delay=0, max_tries=2) retry = Retry(delay=0, max_tries=2)
retry(self._fail()) retry(self._fail())
self.assertEquals(retry._attempts, 1) self.assertEquals(retry._attempts, 1)
retry.reset() retry.reset()
self.assertEquals(retry._attempts, 0) self.assertEquals(retry._attempts, 0)
def test_too_many_tries(self): def test_too_many_tries(self):
retry = self._makeOne(delay=0) retry = Retry(delay=0)
self.assertRaises(RetryFailedError, retry, self._fail(times=999)) self.assertRaises(RetryFailedError, retry, self._fail(times=999))
self.assertEquals(retry._attempts, 1) self.assertEquals(retry._attempts, 1)
def test_maximum_delay(self): def test_maximum_delay(self):
retry = self._makeOne(delay=10, max_tries=100) retry = Retry(delay=10, max_tries=100)
retry(self._fail(times=10)) retry(self._fail(times=10))
self.assertTrue(retry._cur_delay < 4000, retry._cur_delay) self.assertTrue(retry._cur_delay < 4000, retry._cur_delay)
# gevent's sleep function is picky about the type # gevent's sleep function is picky about the type
self.assertEquals(type(retry._cur_delay), float) self.assertEquals(type(retry._cur_delay), float)
def test_deadline(self): def test_deadline(self):
retry = self._makeOne(deadline=0.0001) retry = Retry(deadline=0.0001)
self.assertRaises(RetryFailedError, retry, self._fail(times=100)) self.assertRaises(RetryFailedError, retry, self._fail(times=100))
def test_copy(self): def test_copy(self):
_sleep = lambda t: None def _sleep(t):
retry = self._makeOne(sleep_func=_sleep) pass
retry = Retry(sleep_func=_sleep)
rcopy = retry.copy() rcopy = retry.copy()
self.assertTrue(rcopy.sleep_func is _sleep) self.assertTrue(rcopy.sleep_func is _sleep)
+80
View File
@@ -0,0 +1,80 @@
import psycopg2
import subprocess
import unittest
from mock import MagicMock, patch, PropertyMock
from patroni.scripts.wale_restore import WALERestore, main as _main
def fake_backup_data(self, *args, **kwargs):
""" return the fake result of WAL-E backup-list"""
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
"""
def fake_backup_data_2(self, *args, **kwargs):
""" return the fake result of WAL-E backup-list"""
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop """
def fake_backup_data_3(self, *args, **kwargs):
""" return the fake result of WAL-E backup-list"""
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
"""
def fake_backup_data_4(self, *args, **kwargs):
""" return the fake result of WAL-E backup-list"""
return """name last_modified expanded_size_foo wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
"""
@patch('os.access', MagicMock(return_value=True))
@patch('os.makedirs', MagicMock(return_value=True))
@patch('os.path.exists', MagicMock(return_value=True))
@patch('os.path.isdir', MagicMock(return_value=True))
@patch('psycopg2.extensions.cursor', MagicMock(autospec=True))
@patch('psycopg2.extensions.connection', MagicMock(autospec=True))
@patch('psycopg2.connect', MagicMock(autospec=True))
@patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data))
class TestWALERestore(unittest.TestCase):
def setUp(self):
self.wale_restore = WALERestore("batman", "/data", "host=batman port=5432 user=batman", "/etc", 100, 100, 1, 0)
def test_should_use_s3_to_create_replica(self):
with patch('psycopg2.connect', MagicMock(side_effect=psycopg2.Error("foo"))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output', MagicMock(side_effect=subprocess.CalledProcessError(1, "cmd", "foo"))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data_2)):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data_3)):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data_4)):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
self.wale_restore.should_use_s3_to_create_replica()
self.wale_restore.no_master = 1
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
def test_create_replica_with_s3(self):
with patch('subprocess.call', MagicMock(return_value=0)):
self.assertEqual(self.wale_restore.create_replica_with_s3(), 0)
with patch('subprocess.call', MagicMock(side_effect=Exception("foo"))):
self.assertEqual(self.wale_restore.create_replica_with_s3(), 1)
def test_run(self):
with patch.object(self.wale_restore, 'init_error', PropertyMock(return_value=True)):
self.assertEqual(self.wale_restore.run(), 2)
with patch.object(self.wale_restore, 'should_use_s3_to_create_replica', MagicMock(return_value=True)):
with patch.object(self.wale_restore, 'create_replica_with_s3', MagicMock(return_value=0)):
self.assertEqual(self.wale_restore.run(), 0)
@patch('sys.exit', MagicMock())
@patch.object(WALERestore, 'run', MagicMock(return_value=0))
def test_main(self):
self.assertEqual(_main(), None)
+16 -14
View File
@@ -14,13 +14,13 @@ class MockKazooClient(Mock):
leader = False leader = False
exists = True exists = True
handler = Mock()
@property @property
def client_id(self): def client_id(self):
return (-1, '') return (-1, '')
def retry(self, func, *args, **kwargs): @staticmethod
def retry(func, *args, **kwargs):
func(*args, **kwargs) func(*args, **kwargs)
def get(self, path, watch=None): def get(self, path, watch=None):
@@ -33,8 +33,6 @@ class MockKazooClient(Mock):
b'postgres://repuser:rep-pass@localhost:5434/postgres?application_name=http://127.0.0.1:8009/patroni', b'postgres://repuser:rep-pass@localhost:5434/postgres?application_name=http://127.0.0.1:8009/patroni',
ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0 if self.exists else -1, 0, 0, 0) ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0 if self.exists else -1, 0, 0, 0)
) )
elif path.endswith('/optime/leader'):
return (b'1', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
elif path.endswith('/leader'): elif path.endswith('/leader'):
if self.leader: if self.leader:
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0)) return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
@@ -43,7 +41,8 @@ class MockKazooClient(Mock):
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
def get_children(self, path, watch=None, include_data=False): @staticmethod
def get_children(path, watch=None, include_data=False):
if not isinstance(path, six.string_types): if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)") raise TypeError("Invalid type for 'path' (string expected)")
if path.startswith('/no_node'): if path.startswith('/no_node'):
@@ -62,16 +61,16 @@ class MockKazooClient(Mock):
elif value == b'retry' or (value == b'exists' and self.exists): elif value == b'retry' or (value == b'exists' and self.exists):
raise NodeExistsError raise NodeExistsError
def set(self, path, value, version=-1): @staticmethod
def set(path, value, version=-1):
if not isinstance(path, six.string_types): if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)") raise TypeError("Invalid type for 'path' (string expected)")
if not isinstance(value, (six.binary_type,)): if not isinstance(value, (six.binary_type,)):
raise TypeError("Invalid type for 'value' (must be a byte string)") raise TypeError("Invalid type for 'value' (must be a byte string)")
if path == '/service/bla/optime/leader': if path == '/service/bla/optime/leader':
raise Exception raise Exception
if path == '/service/test/members/bar': if path == '/service/test/members/bar' and value == b'retry':
if value == b'retry': return
return
if path == '/service/test/failover': if path == '/service/test/failover':
if value == b'Exception': if value == b'Exception':
raise Exception raise Exception
@@ -84,13 +83,11 @@ class MockKazooClient(Mock):
raise TypeError("Invalid type for 'path' (string expected)") raise TypeError("Invalid type for 'path' (string expected)")
self.exists = False self.exists = False
if path == '/service/test/leader': if path == '/service/test/leader':
if self.leader:
return
self.leader = True self.leader = True
raise Exception raise Exception
elif path == '/service/test/members/buzz': elif path == '/service/test/members/buzz':
raise Exception raise Exception
elif path.endswith('/initialize') or path == '/service/test/members/bar': elif path.endswith('/') or path.endswith('/initialize') or path == '/service/test/members/bar':
raise NoNodeError raise NoNodeError
@@ -150,16 +147,18 @@ class TestZooKeeper(unittest.TestCase):
self.zk._name = 'bar' self.zk._name = 'bar'
self.zk.touch_member('new') self.zk.touch_member('new')
self.zk._name = 'na' self.zk._name = 'na'
self.zk.client.exists = 1 self.zk._client.exists = 1
self.zk.touch_member('exists') self.zk.touch_member('exists')
self.zk._name = 'bar' self.zk._name = 'bar'
self.zk.touch_member('retry') self.zk.touch_member('retry')
self.zk.fetch_cluster = True self.zk._fetch_cluster = True
self.zk.get_cluster() self.zk.get_cluster()
self.zk.touch_member('retry') self.zk.touch_member('retry')
def test_take_leader(self): def test_take_leader(self):
self.zk.take_leader() self.zk.take_leader()
with patch.object(MockKazooClient, 'create', Mock(side_effect=Exception)):
self.zk.take_leader()
def test_update_leader(self): def test_update_leader(self):
self.assertTrue(self.zk.update_leader()) self.assertTrue(self.zk.update_leader())
@@ -170,6 +169,9 @@ class TestZooKeeper(unittest.TestCase):
self.zk._base_path = self.zk._base_path.replace('test', 'bla') self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk.write_leader_optime('2') self.zk.write_leader_optime('2')
def test_delete_cluster(self):
self.assertTrue(self.zk.delete_cluster())
def test_watch(self): def test_watch(self):
self.zk.watch(0) self.zk.watch(0)
self.zk.event.isSet = lambda: True self.zk.event.isSet = lambda: True