Compare commits

..
256 Commits
Author SHA1 Message Date
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 5747f561c0 Merge pull request #64 from zalando/bugfix/pg_controldata_changes
Work around the differences in pg_controldata names.
2015-10-14 12:14:07 +02:00
Oleksii Kliukin c7246e48d9 Work around the differences in pg_controldata names. 2015-10-14 09:46:20 +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
Oleksii Kliukin 7b079067b9 Merge pull request #59 from zalando/feature/pg_rewind_unclean_shutdown
Feature/pg rewind unclean shutdown. Allow pg_rewind to rewind the former master if it was uncleanly shutdown or just lost the connection and advanced past the point where the new master had promoted. Add configuration block pg_rewind with username and password as mandatory parameters inside this block.
2015-10-13 09:15:25 +02:00
Oleksii Kliukin 101082fa3b more tests. 2015-10-13 09:08:27 +02:00
Oleksii Kliukin 94aa6873f4 Add more tests for the new postgresql methods. 2015-10-13 08:19:44 +02:00
Oleksii Kliukin 46f4788c28 Do not try to run postgres -D during unit tests. 2015-10-12 17:06:13 +02:00
Oleksii Kliukin d7988384d3 Address the code review by Alex Kukushkin:
- check the link before checking the file when deciding to remove it,
  as isfile follows symlinks and, therefore, may return True on them.
- Remove append mode from write_pgpass, as it is always written anew
  before it is used.
- make pg_controldata return an empty hash in case of an error, and
  check for the empty value return by this function before using it.
 some other minior fixed and test updates.
2015-10-12 16:24:02 +02:00
Oleksii Kliukin ce7169f61d Add new tests ha and postgresql. 2015-10-12 15:29:47 +02:00
Oleksii Kliukin 42137e689b Merge remote-tracking branch 'origin/master' into feature/pg_rewind_unclean_shutdown
Modify HA tests to reflect the changes in HA::recover.
2015-10-12 09:22:38 +02:00
Oleksii Kliukin b629e0852f Call pg_rewind in case of the master's unclean shutdown.
If patroni detects the former master was killed, it runs
it first in a single-user mode and then shuts down normally,
to make sure pg_rewind will see a normal shut down status
in pg_controldata.

Add a flag need_rewind, since the point where it is detected
that rewind might be necessary is moved out the code that
runs rewind.
2015-10-12 08:34:08 +02:00
Alexander Kukushkin 2c863ce9a6 Merge pull request #56 from zalando/feature/manual-failover
Feature: manual failover
2015-10-09 16:23:45 +02:00
Alexander Kukushkin cf6be5f58e add missing tests for async_executor 2015-10-09 16:02:34 +02:00
Alexander Kukushkin a588ddc23c Merge branch 'master' of github.com:zalando/patroni into feature/manual-failover 2015-10-09 15:58:38 +02:00
Alexander Kukushkin a6603e8b48 bugfix in zookeeper module:
when master node was being attached to patroni/zookeeper (no cluster in
zookeeper yet) patroni has never tried to "refetch" cluster from DCS.
It was leeding to demote...
2015-10-08 13:07:38 +02:00
Oleksii Kliukin 52c4826569 Reflect the renaming of os-registry.stups.zalan.do to registry.opensource.zalan.do 2015-10-08 12:40:21 +02:00
Alexander Kukushkin 8a844285ff Set fetch_cluster flag to False when _inner_load_cluster called
Set the same flag to True if the cluster does not yet exists in
ZooKeeper
2015-10-07 16:48:39 +02:00
Alexander Kukushkin d48f8384ed leader variable should be None if the leader.name == my name.
This check has to be performed even check_recovery_conf call
2015-10-06 10:07:40 +02:00
Alexander Kukushkin 601ba7db8d Make work with dcs.cluster thread-safe 2015-10-05 14:30:47 +02:00
Alexander Kukushkin 4c444c943e tests for Api.do_GET method 2015-10-02 13:17:58 +02:00
Oleksii Kliukin bad37a5a21 Always check that cluster is configured correctly right before running pg_rewind. 2015-10-02 10:57:49 +02:00
Alexander Kukushkin d8f4b09478 use Event.wait instead of sleep
it makes possible to break "sleep" for example from API

plus small bugfix: catch ValueError exception from json.loads
2015-10-02 10:26:48 +02:00
Alexander Kukushkin d09875a056 refactoring:
1. run touch_member from the main loop
2. move code which takes care about long tasks into separate class
3. change format of data stored in a DCS: use json instead of url
4. change Member class: from now it deserialize everything into data property
5. rework API: from now it takes into account state of the current node in a dcs
2015-10-01 17:06:42 +02:00
Alexander Kukushkin a6cb7563e5 catch all exceptions in change_replication_slots method 2015-10-01 08:06:00 +02:00
Oleksii Kliukin ea910a8987 Make sure pgpass file name is also passed in the PGPASSFILE environment variable. 2015-09-30 18:02:07 +02:00
Oleksii Kliukin b223319183 use the PATH to get the python interpreter path for the scripts. 2015-09-30 18:00:28 +02:00
Oleksii Kliukin d7403f999d Merge branch 'feature/pg_rewind' of https://github.com/zalando/patroni into feature/pg_rewind 2015-09-30 17:13:16 +02:00
Oleksii Kliukin 316e4f8d74 Merge branch 'master' into feature/pg_rewind 2015-09-30 17:12:03 +02:00
Alexander Kukushkin 1997f15a7a Run long time operations asynchronously
i.e. restart, reinitialize, demote
2015-09-30 17:08:15 +02:00
Oleksii Kliukin b4a2ed874f Merge branch 'master' of https://github.com/zalando/patroni 2015-09-30 16:47:07 +02:00
Oleksii Kliukin 182a90973d Make sure tests work with the new pytest. 2015-09-30 16:46:53 +02:00
Alexander Kukushkin 2fdf02e0be Merge pull request #55 from pgexperts/scripts/init-scripts
Added Upstart job for running patroni under Ubuntu 14.04 or 12.04.
2015-09-30 16:39:07 +02:00
Oleksii Kliukin a500781b6d Mock remove_data_directory in the pg_rewind test. 2015-09-30 16:32:56 +02:00
Josh Berkus c4cc0be4fe Added Upstart job for running patroni under Ubuntu 14.04 or 12.04. 2015-09-29 17:35:45 -07:00
Alexander Kukushkin 0572fec6a3 remove leader lock after stop of postgres to speed up failover 2015-09-29 12:59:26 +02:00
Alexander Kukushkin a259764458 Suppress logging from API when postgres is being bootstrapped/initialized 2015-09-29 08:39:05 +02:00
Alexander Kukushkin c218054d05 Implement manual failover
Implementation is done on top of feature/is-healthiest-via-api and
feature/api branches.
In order to trigger manual failover one has to create 'failover' key in
a configuration store with the value in following format:
'leader_name:member_name'
leader_name can be empty or should match with the name of current leader
member_name can be empty or should match with the name one of cluster
nodes
Leader always checks that either desired member (if specified) or one of
the memners is accessible and healthy before demote.
After leader has deomted himself other nodes are performig checks that
desired node is healthy. If it is not they are participating in a leader
race. In some cases (when accidently there is no healthy nodes) former
leader can also participate in a leader race.

Current implementation does not provide REST API endpoint for a manual
failover.
2015-09-28 17:00:42 +02:00
Oleksii Kliukin e39d318732 Eliminate os.system call. 2015-09-28 12:04:06 +02:00
Oleksii Kliukin d44a54628a remove the data directory on an unsuccessfull rewind attempt. 2015-09-25 16:00:24 +02:00
Oleksii Kliukin b62d72a130 Merge branch 'master' into feature/pg_rewind 2015-09-25 13:08:42 +02:00
Oleksii Kliukin d6c8df45e1 Write the pg_rewind password in pgpass instead of passing it in the command line. 2015-09-25 13:08:12 +02:00
Alexander Kukushkin 3b1b6ff448 revert log level to INFO 2015-09-24 16:54:16 +02:00
Alexander Kukushkin dffa1767f2 Merge branch 'master' of github.com:zalando/patroni into feature/api 2015-09-24 16:53:16 +02:00
Oleksii Kliukin 36101974cf Merge pull request #49 from zalando/feature/bootstrap-from-main-loop
Run initial cluster bootstrap from the main loop
2015-09-24 15:04:52 +02:00
Alexander Kukushkin 6e9cb60fd5 Restart and reinitialize via api
POST /restart -- will restart postgres
You you are restartung leader node, lock would be maintained during
restart.

POST /reinitialize -- will reinitialize node from the leader.
It's not possible to reinitialize current leader.
Command will fail when the leader is unknown.
2015-09-24 14:52:03 +02:00
Oleksii Kliukin 027bcd39ce Move pg_rewind call into a separate sub.
Add a Postgresql method to call pg_rewind.
Improve the test coverage.
2015-09-24 12:46:36 +02:00
Oleksii Kliukin c8108f221e Check the exit code of the postgres start when determining whether to run the on_role_change callback. 2015-09-24 11:34:28 +02:00
Oleksii Kliukin 793325cb60 add support for pg_rewind. 2015-09-23 18:38:17 +02:00
Alexander Kukushkin a4266be3da remove unused function 2015-09-23 10:59:55 +02:00
Alexander Kukushkin e83651b57b Run initial cluster bootstrap from the main loop 2015-09-23 10:55:38 +02:00
Alexander Kukushkin 83c5416c82 Merge pull request #45 from zalando/feature/query-retry
Refactor Postgresql.query method to use common retry mechanism
2015-09-22 16:53:31 +02:00
Alexander Kukushkin d8e51f586f Merge branch 'feature/query-retry' of github.com:zalando/patroni into feature/query-retry 2015-09-20 13:58:12 +02:00
Alexander Kukushkin d8982e1e5a Refactor Postgresql.query method to use common retry mechanism
query method in an api.py also needs retry in some cases (for example
when we are running is_healthiest_node check).
In all cases we should retry only when connection is closed or broken.
BUT, the connection status must be checked via cursor.connection (old
implementation was using general connection object for that). For
multi-threaded applications this is not appropriate, because some other
thread might restore connection.

In addition to that I've changed most of the unit tests to use `Mock` and
`patch` where it is possible.
2015-09-20 13:54:30 +02:00
Alexander Kukushkin 9f9cb6005d Refactor Postgresql.query method to use commont retry mechanism
query method in an api.py also needs retry in some cases (for example
when we are running is_healthiest_node check).
In all cases We will retry only when connection is closed or broken, BUT
connection status must be checked via cursor.connection (old
implementation was using general connection object for that). For
multi-threaded applications this is not appropriate, because some other
thread might restore connection.

In appdition to that I've changed most of the unit tests to use `Mock` and
`patch` where it is possible.
2015-09-20 13:41:57 +02:00
Oleksii Kliukin 75be235d49 Merge pull request #40 from zalando/feature/ignore-second-sigterm
During shutdown sigterm is sent to all processes every second
2015-09-18 17:06:02 +02:00
Alexander Kukushkin 0a3a6d72cf Fix etcd unit tests 2015-09-18 15:33:58 +02:00
Alexander Kukushkin 9dfb562a4f wait for event on leader key a little bit longer than timeout 2015-09-18 15:29:50 +02:00
Alexander Kukushkin 3145e94797 check ignore_sigterm flag instead of setting new handler for SIGTERM 2015-09-18 15:27:09 +02:00
Alexander Kukushkin afe1a2c92c During shutdown sigterm is send to all processes every second
First time is triggers some shutdown mechanisms in a patroni, but the
second one just terminates patroni. Solution is pretty simple, we need
to ignore all other sigterms which are coming after the first one.
2015-09-18 12:41:36 +02:00
Alexander Kukushkin 8a0af6985f Merge pull request #35 from zalando/feature/basic-auth
Basic-auth and SSL support
2015-09-17 16:22:23 +02:00
Alexander Kukushkin 918ac6a87d Merge pull request #39 from zalando/feature/reschedule-next-run
Feature/reschedule next run of ha cycle depending on return value of `watch`
2015-09-17 16:22:12 +02:00
Alexander Kukushkin b9ccd411ed Merge pull request #19 from zalando/feature/faster_shutdown
Feature/faster shutdown
2015-09-17 16:21:59 +02:00
Alexander Kukushkin 6530e1f7aa Remove unused parameter in a is_leader method 2015-09-17 16:11:08 +02:00
Alexander Kukushkin 0b753d25e1 Get rid from is_promoted flag. use role == 'master' instead 2015-09-17 13:57:29 +02:00
Alexander Kukushkin dbcc5aff9b Track postgresql role in a Postgresql class 2015-09-16 16:22:10 +02:00
Alexander Kukushkin e204945b3d Merge branch 'master' of github.com:zalando/patroni into feature/faster_shutdown 2015-09-16 15:20:53 +02:00
Alexander Kukushkin 0435e36cad self.cluster = None if unexpected exception occured 2015-09-16 15:14:02 +02:00
Alexander Kukushkin 246f0dbbaf watch should return true if you are not leader 2015-09-16 14:23:47 +02:00
Alexander Kukushkin c240fd1ee8 More documentation for watch method 2015-09-16 11:09:53 +02:00
Alexander Kukushkin 7f8e95b334 Next run of ha cycle is rescheduled depending on return value of watch
Current etcd implementation does not yet support timeout option when
`wait=true`: https://github.com/coreos/etcd/issues/2468

Originaly I've implemented `watch` method for `Etcd` class in a
following manner: if the leader key was updated just because master
needs to update ttl and watch timeout is not yet expired, I was
recalculating timeout and starting `watch` call once again.
Usually after "restart" we were getting urllib3.exceptions.TimeoutError.
The only possible way to recover after such exception - close socket and
establish a new connection. With pure http it's relatively cheap, but
with https and some kind of authorization on etcd side it would became
rather expensive and should be avoided.
2015-09-16 10:38:34 +02:00
Alexander Kukushkin 01efcc8638 Merge branch 'master' of github.com:zalando/patroni into feature/basic-auth
Conflicts:
	tests/test_patroni.py
2015-09-15 09:33:18 +02:00
Alexander Kukushkin a8305079c3 Merge pull request #38 from zalando/bugfix/zookeeper
Bugfix/zookeeper
2015-09-15 09:29:18 +02:00
Alexander Kukushkin 90cfcf0c14 Make zookeeper module compatible with python3 2015-09-14 17:14:39 +02:00
Alexander Kukushkin 44a20f12a4 version field is znode is just version, not mzxid 2015-09-14 16:32:45 +02:00
Oleksii Kliukin bf5737614d Merge pull request #30 from zalando/feature/cleanup_on_failed_initialization
Make sure initialize flag is reset on failure.
2015-09-14 12:57:39 +02:00
Oleksii Kliukin d69403ab6f Merge pull request #37 from zalando/feature/zookeeper-fetch-initialize
Build Cluster object for ZooKeeper the same way as for Etcd
2015-09-14 12:55:10 +02:00
Oleksii Kliukin 51eacc5042 Handle the case when initialize flag is not set and leader is present. 2015-09-14 12:36:28 +02:00
Alexander Kukushkin 98488a00a2 Remove unused import of KazooException 2015-09-14 12:00:24 +02:00
Alexander Kukushkin 4a081bcb71 Run cancel_initialization with retry 2015-09-14 11:58:10 +02:00
Alexander Kukushkin 209c985420 get_node and get_children should catch only NoNodeError exception.
All other exceptions are needed to have retry functionality working
correctly.
2015-09-14 11:45:00 +02:00
Alexander Kukushkin f494d2ce64 Build Cluster object for ZooKeeper the same way as for Etcd
Previous implementation was always setting Cluster.initialize to True.
Also it was throwing ZooKeeperError when there were no members in a
cluster.

Plus BUGFIX of a bug introduced with
https://github.com/zalando/patroni/pull/34 in a `load_members` method.
- data = self.get_node(self.member_path)
+ data = self.get_node(self.members_path + member)
It was always fetching the same node for all cluster members.
Fortunately Etcd doesn't have such problem because we are fetching the
whole cluster directory with one recursive API call.
2015-09-14 11:19:46 +02:00
Oleksii Kliukin be110c4ba0 Do not try to stop postgres twice if initialization had failed. 2015-09-14 09:20:45 +02:00
Oleksii Kliukin 15cd10669d Change an outdated comment. 2015-09-10 18:04:47 +02:00
Oleksii Kliukin cd312de252 Fix a flake8 warning.
Improve some unit tests by expecting specific exceptions.
2015-09-10 17:15:43 +02:00
Alexander Kukushkin f292398e94 Merge branches with ssl and basic-auth support together 2015-09-10 16:49:55 +02:00
Alexander Kukushkin 3d7c6118de Add missing body to authenticate request 2015-09-10 16:29:17 +02:00
Oleksii Kliukin 2377c417e4 Fix etcd and zookeper interactions with initialize key.
Fix unittests as well.
2015-09-10 16:05:10 +02:00
Oleksii Kliukin 938b946e55 Merge branch 'master' into feature/cleanup_on_failed_initialization 2015-09-10 15:43:31 +02:00
Alexander Kukushkin e6f069018c Merge branch 'feature/api-https' of github.com:zalando/patroni into feature/api-https 2015-09-10 15:36:43 +02:00
Alexander Kukushkin 5d304e95ed Merge branch 'master' of github.com:zalando/patroni into feature/api-https 2015-09-10 15:34:43 +02:00
Oleksii Kliukin 30a9e0f7f5 Move PostgreSQL data directory if init had failed.
Prevent treating the incompletely-initialized PostgreSQL cluster
as a valid on restart by forcefully moving the data directory.
I don't want to remove it altogether, since a DBA might decide
to analyze the failed PG cluster in order to resolve the init
issue.
2015-09-10 15:34:29 +02:00
Alexander Kukushkin d830e8de7c Merge branch 'master' of github.com:zalando/patroni into feature/basic-auth 2015-09-10 15:33:31 +02:00
Alexander Kukushkin 30a7d50a56 Merge pull request #34 from zalando/feature/constants-for-key-names
Define initialize, leader, optime and members string constansts in Ab…
2015-09-10 15:33:08 +02:00
Alexander Kukushkin abcaf2b94a Possibility to protect some endpoints with basic-auth
user:passwd pair should be configured in restapi section of main
configuration file in following format:
restapi:
  auth: 'username:password'

Plus implemented some simple routing mechanisms:
GET /foo => do_GET_foo()
POST /bar => do_POST_bar()
2015-09-10 15:25:08 +02:00
Alexander Kukushkin 36cbd34ffc Fix zookeeper test coverage 2015-09-09 15:59:02 +02:00
Alexander Kukushkin 5bdb18761b Define initialize, leader, optime and members string constansts in AbstractDCS
Also define following properties:
* initialize_path
* members_path
* member_path
* leader_path
* leader_optime_path

And replace any occurrences of these strings or client_path calls in a
etcd and zookeeper implementations with given constants and properties.
2015-09-09 15:10:45 +02:00
Feike Steenbergen 5a99faf967 Update README.rst
Typo
2015-09-09 12:55:52 +02:00
Alexander Kukushkin c409ee4d37 Fix formatting in a README.rst 2015-09-09 12:39:48 +02:00
Alexander Kukushkin 1d9333dcdc Update documentation and configs with examples of usage of SSL. 2015-09-09 12:19:50 +02:00
Alexander Kukushkin b5a5ea2a75 Add SSL wrapper around restapi server socket
If config['restapi']['certfile'] is specified and not empty http server
would be wrapped into SSL and api connection string changed accordingly:
http:// => https://
2015-09-09 12:15:52 +02:00
Alexander Kukushkin e90b14cd3b Merge pull request #31 from zalando/bugfix/script_paths
Fix path to scripts subdirectory in configuration files.
2015-09-08 16:25:52 +02:00
Oleksii Kliukin 1c61280d70 Fix path to scripts subdirectory in configuration files. 2015-09-08 16:10:42 +02:00
Oleksii Kliukin ff499604f0 Act on removal of initialization flag.
If initializer node suddenly dies before the initialization is complete,
other nodes should try to take over.

Fix some unittests for etcd and zookeeper and add couple of new ones.
2015-09-08 16:04:54 +02:00
Oleksii Kliukin 92647b7aad Merge branch 'master' of https://github.com/zalando/patroni into feature/cleanup_on_failed_initialization 2015-09-08 14:54:52 +02:00
Feike Steenbergen dd8472f639 Tag on github is prefixed with v. 2015-09-08 13:17:36 +02:00
Oleksii Kliukin b842ed478b Make sure initialize flag is reset on failure.
Cleanup the initialize flag if the initializing node fails
to bootstrap its PostgreSQL database.

Rename dcs.race to initialize, since we only call it for the
initialize flag. Factored out PostgreSQL bootstrapping code
into a separate function.
2015-09-08 12:03:34 +02:00
Oleksii Kliukin b21bc89bd7 Merge branch 'master' into feature/faster_shutdown 2015-09-02 14:16:04 +02:00
Oleksii Kliukin 80f92b1dee Run CHECKPOINT before calling shutdown.
In addition, restart is now performed as stop/start,
which would allow it to benefit from the shutdown speedup.

The hooks in start/stop are modified in order not to run
when called as a part of restart.
2015-09-02 14:14:16 +02:00
42 changed files with 4052 additions and 1379 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
+3 -2
View File
@@ -13,8 +13,9 @@ RUN apt-get update -y
RUN apt-get upgrade -y RUN apt-get upgrade -y
ENV PGVERSION 9.4 ENV PGVERSION 9.4
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 python python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-kazoo python-pip -y
RUN pip install python-etcd RUN apt-get install python-dev postgresql-server-dev-${PGVERSION} -y
RUN pip install python-etcd psycopg2
ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH
+81 -102
View File
@@ -5,10 +5,10 @@ Patroni: A Template for PostgreSQL HA with ZooKeeper or etcd
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 or etcd.*
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,109 +39,89 @@ 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 - *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
of time before automatic failover process is initiated.
- *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 - *listen*: IP address + port that Patroni will listen to, to provide health-check information for haproxy.
health-check information for haproxy. - *connect\_address*: IP address + port through which restapi is accessible.
- *connect\_address*: ip address + port through which restapi is - *auth*: (optional) 'username:password' to protect dangerous REST API endpoints.
accessible. - *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.
- *etcd* - *etcd*:
- *scope*: the relative path used on etcd's http api for this - *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.
deployment, thus you can run multiple HA deployments from a single - *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
etcd - *host*: the host:port for the etcd endpoint.
- *ttl*: the TTL to acquire the leader lock. Think of it as the length
of time before automatic failover process is initiated.
- *host*: the host:port for the etcd endpoint
- *zookeeper* - *zookeeper*:
- *scope*: the relative path used on etcd's http api for this - *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.
deployment, thus you can run multiple HA deployments from a single - *session\_timeout*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
etcd - *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.
- *session\_timeout*: the TTL to acquire the leader lock. Think of it - *hosts*: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...']
as the length of time before automatic failover process is initiated. - *exhibitor*: if you are running a ZooKeeper cluster under the Exhibitor supervisory, the following section might interest you:
- *reconnect\_timeout*: how long we should try to reconnect to - *poll\_interval*: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor
ZooKeeper after connection loss. After this timeout we assume that we - *port*: Exhibitor port.
don't have lock anymore and will restart in read-only mode. - *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: ['host1', 'host2', 'etc...' ]. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- *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
- *poll\_interval*: how often list of ZooKeeper and Exhibitor nodes - *postgresql*:
should be updated from Exhibitor - *name*: the name of the Postgres host. Must be unique for the cluster.
- *port*: Exhibitor port - *listen*: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication.
- *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: [ - *connect\_address*: IP address + port through which Postgres is accessible from other nodes and applications.
'host1', 'host2', 'etc...' ]. This list would be updated - *data\_dir*: file path to initialize and store Postgres data files.
automatically when Exhibitor (ZooKeeper) cluster topology changes. - *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag.
- *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.
- *postgresql* - *initdb*: List options to be passed on to initdb
- *name*: the name of the Postgres host, must be unique for the cluster - *encoding*: default encoding for new databases
- *listen*: ip address + port that Postgres listening. Must be - *locale*: default locale for new databases
accessible from other nodes in the cluster if using streaming - *data-checksums* # When pg_rewind is needed on 9.3, this needs to be enabled
replication.
- *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
- *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag
- *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
- *pg\_hba*: list of lines which should be added to pg\_hba.conf
- *- host all all 0.0.0.0/0 md5* - *pg\_hba*: list of lines which should be added to pg\_hba.conf.
- *- host all all 0.0.0.0/0 md5*.
- *replication* - *replication*:
- *username*: replication username; user will be created during initialization.
- *password*: replication password; user will be created during initialization.
- *network*: network setting for replication in pg\_hba.conf.
- *username*: replication username, user will be created during - *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.
initialization - *on\_start*: a script to run when the cluster starts.
- *password*: replication password, user will be created during - *on\_stop*: a script to run when the cluster stops.
initialization - *on\_restart*: a script to run when the cluster restarts.
- *network*: network setting for replication in pg\_hba.conf - *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.
- *callbacks* callback scripts to run on certain actions. Patroni will - *superuser*:
pass current action, role and cluster name. See scripts/aws.py as an - *password*: password for the Postgres user, set during initialization.
example on how to write them.
- *on\_start*: a script to run when the cluster starts - *admin*:
- *on\_stop*: a script to run when the cluster stops - *username*: admin username; user is created during initialization. It will have CREATEDB and CREATEROLE privileges.
- *on\_restart*: a script to run when the cluster restarts - *password*: admin password; user is created during initialization.
- *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
- *superuser* - *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.
- *password*: password for postgres user. It would be set during - *create\_replica\_methods*: an ordered list of the create methods for turning a patroni node into a new replica.
initialization "basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured
as its own config item.
- *admin*: - *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".
- *username*: admin username, user will be created during Replication Choices
initialization. It would have CREATEDB and CREATEROLE privileges
- *password*: admin password, user will be created during
initialization.
- *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.
Replication choices
------------------- -------------------
Patroni uses Postgres' streaming replication. By default, this Patroni uses Postgres' streaming replication. By default, this
@@ -155,43 +135,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
@@ -207,14 +187,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
+6 -6
View File
@@ -1,7 +1,7 @@
# Patroni Dockerfile # Patroni Dockerfile
You can run Patroni in a docker container using this Dockerfile, or by using one of the Docker image at You can run Patroni in a docker container using this Dockerfile, or by using one of the Docker image at
https://os-registry.stups.zalan.do/v1/repositories/acid/patroni/tags https://registry.opensource.zalan.do/v1/repositories/acid/patroni/tags
This Dockerfile is meant in aiding development of Patroni and quick testing of features. It is not a production-worthy This Dockerfile is meant in aiding development of Patroni and quick testing of features. It is not a production-worthy
Dockerfile Dockerfile
@@ -10,7 +10,7 @@ Dockerfile
## Standalone Patroni ## Standalone Patroni
docker run -d os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT docker run -d registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT
## Multiple Patroni's communicating with a standalone etcd inside Docker ## Multiple Patroni's communicating with a standalone etcd inside Docker
@@ -36,12 +36,12 @@ To automate this you can run the following script:
Example session: Example session:
$ ./dev_patroni_cluster.sh --image os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT --members=2 --name=bravo $ ./dev_patroni_cluster.sh --image registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT --members=2 --name=bravo
The etcd container is 6be871a11cb373406ca5ea1c6b39e1.0-SNAPSHOTfdde9fb1d6177212d6ad0c0d1bd9b563, ip=172.17.1.24 The etcd container is 6be871a11cb373406ca5ea1c6b39e1.0-SNAPSHOTfdde9fb1d6177212d6ad0c0d1bd9b563, ip=172.17.1.24
Started Patroni container 67e611f2eca7c40f9e6e0e24a4a8f2cba7e3e56d22a420e15ab9240a37a9d7a4, ip=172.17.1.25 Started Patroni container 67e611f2eca7c40f9e6e0e24a4a8f2cba7e3e56d22a420e15ab9240a37a9d7a4, ip=172.17.1.25
Started Patroni container 47dd12ae635ab83b039f5889e250048b606ed5e48e3650b69e365e7e1d4acbcf, ip=172.17.1.26 Started Patroni container 47dd12ae635ab83b039f5889e250048b606ed5e48e3650b69e365e7e1d4acbcf, ip=172.17.1.26
$ docker ps $ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
47dd12ae635a os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 10 seconds ago Up 8 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_OR64g8bx 47dd12ae635a registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 10 seconds ago Up 8 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_OR64g8bx
67e611f2eca7 os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 11 seconds ago Up 10 seconds 2380/tcp, 4001/tcp, 5432/tcp bravo_si9no8iz 67e611f2eca7 registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 11 seconds ago Up 10 seconds 2380/tcp, 4001/tcp, 5432/tcp bravo_si9no8iz
6be871a11cb3 os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 12 seconds ago Up 10 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_etcd 6be871a11cb3 registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 12 seconds ago Up 10 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_etcd
+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
+13 -11
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)
@@ -83,16 +83,17 @@ cat > /patroni/postgres.yml <<__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,10 +116,11 @@ 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 checkpoint_segments: 64
wal_keep_segments: 64
archive_timeout: 1800s archive_timeout: 1800s
max_replication_slots: 20 max_replication_slots: 20
hot_standby: "on" hot_standby: "on"
+10
View File
@@ -0,0 +1,10 @@
# startup scripts for Patroni
This directory contains sample startup scripts for various OSes
and management tools for Patroni.
Scripts supplied:
### 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.
@@ -0,0 +1,36 @@
# patroni - patroni daemon
#
# controls startup/shutdown of postgres
# you should disable any postgres start jobs
#
# assumes that patroni has been installed into the
# pythonpath by using setup.py install
description "patroni start daemon"
start on net-device-up
stop on runlevel [06]
respawn
respawn limit 5 10
# set location of patroni
env PATRONI=/usr/local/bin/patroni
# virtualenv example
# env PATRONI=/var/lib/postgresql/patronienv/bin/patroni
# set location of config file
env PATRONICONF=/etc/patroni/patroni.yml
# set log dir for patroni logs
# postgres user must have write permission
env POSTGRESLOGDIR=/var/log/postgresql
setuid postgres
setgid postgres
script
exec start-stop-daemon --start \
--exec $PATRONI -- $PATRONICONF \
>> $POSTGRESLOGDIR/patroni.log 2>&1
end script
+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
+13 -55
View File
@@ -8,7 +8,7 @@ from patroni.api import RestApiServer
from patroni.etcd import Etcd from patroni.etcd import Etcd
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, sleep, reap_children from patroni.utils import setup_signal_handlers, reap_children
from patroni.zookeeper import ZooKeeper from patroni.zookeeper import ZooKeeper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,12 +18,16 @@ class Patroni:
def __init__(self, config): def __init__(self, config):
self.nap_time = config['loop_wait'] self.nap_time = config['loop_wait']
self.tags = config.get('tags', dict())
self.postgresql = Postgresql(config['postgresql']) self.postgresql = Postgresql(config['postgresql'])
self.ha = Ha(self.postgresql, self.get_dcs(self.postgresql.name, config)) self.dcs = self.get_dcs(self.postgresql.name, config)
host, port = config['restapi']['listen'].split(':')
self.api = RestApiServer(self, config['restapi']) self.api = RestApiServer(self, config['restapi'])
self.ha = Ha(self)
self.next_run = time.time() self.next_run = time.time()
self.shutdown_member_ttl = 300
@property
def nofailover(self):
return self.tags.get('nofailover', False)
@staticmethod @staticmethod
def get_dcs(name, config): def get_dcs(name, config):
@@ -31,68 +35,23 @@ class Patroni:
return Etcd(name, config['etcd']) return Etcd(name, config['etcd'])
if 'zookeeper' in config: if 'zookeeper' in config:
return ZooKeeper(name, config['zookeeper']) return ZooKeeper(name, config['zookeeper'])
raise Exception('Can not find sutable configuration of distributed configuration store') raise Exception('Can not find suitable configuration of distributed configuration store')
def touch_member(self, ttl=None):
connection_string = self.postgresql.connection_string + '?application_name=' + self.api.connection_string
if self.ha.cluster:
for m in self.ha.cluster.members:
# Do not update member TTL when it is far from being expired
if m.name == self.postgresql.name and m.real_ttl() > self.shutdown_member_ttl:
return True
return self.ha.dcs.touch_member(connection_string, ttl)
def initialize(self):
# wait for etcd to be available
while not self.touch_member():
logger.info('waiting on DCS')
sleep(5)
# is data directory empty?
if self.postgresql.data_directory_empty():
# racing to initialize
if self.ha.dcs.race('/initialize'):
self.postgresql.initialize()
self.ha.dcs.take_leader()
self.postgresql.start()
self.postgresql.create_replication_user()
self.postgresql.create_connection_users()
else:
while True:
leader = self.ha.dcs.current_leader()
if leader and self.postgresql.sync_from_leader(leader):
self.postgresql.write_recovery_conf(leader)
self.postgresql.start()
break
sleep(5)
elif self.postgresql.is_running():
self.postgresql.load_replication_slots()
def schedule_next_run(self): def schedule_next_run(self):
if self.postgresql.is_promoted:
self.next_run = time.time()
self.next_run += self.nap_time self.next_run += self.nap_time
current_time = time.time() current_time = time.time()
nap_time = self.next_run - current_time nap_time = self.next_run - current_time
if nap_time <= 0: if nap_time <= 0:
self.next_run = current_time self.next_run = current_time
else: elif self.dcs.watch(nap_time):
self.ha.dcs.watch(nap_time) self.next_run = time.time()
def run(self): def run(self):
self.api.start() self.api.start()
self.next_run = time.time() self.next_run = time.time()
while True: while True:
self.touch_member()
logger.info(self.ha.run_cycle()) logger.info(self.ha.run_cycle())
try:
if self.ha.state_handler.is_leader():
self.ha.cluster and self.ha.state_handler.create_replication_slots(self.ha.cluster)
else:
self.ha.state_handler.drop_replication_slots()
except:
logger.exception('Exception when changing replication slots')
reap_children() reap_children()
self.schedule_next_run() self.schedule_next_run()
@@ -111,11 +70,10 @@ def main():
patroni = Patroni(config) patroni = Patroni(config)
try: try:
patroni.initialize()
patroni.run() patroni.run()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
patroni.touch_member(patroni.shutdown_member_ttl) # schedule member removal patroni.api.shutdown()
patroni.postgresql.stop() patroni.postgresql.stop()
patroni.ha.dcs.delete_leader() patroni.dcs.delete_leader()
+255 -23
View File
@@ -1,8 +1,13 @@
import base64
import fcntl import fcntl
import json import json
import logging import logging
import psycopg2 import psycopg2
import socket
import time
from patroni.exceptions import PostgresConnectionException
from patroni.utils import Retry, RetryFailedError
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from six.moves.socketserver import ThreadingMixIn from six.moves.socketserver import ThreadingMixIn
from threading import Thread from threading import Thread
@@ -10,33 +15,224 @@ from threading import Thread
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def check_auth(func):
"""Decorator function to check authorization header.
Usage example:
@check_auth
def do_PUT_foo():
pass
"""
def wrapper(handler):
if handler.check_auth_header():
return func(handler)
return wrapper
class RestApiHandler(BaseHTTPRequestHandler): class RestApiHandler(BaseHTTPRequestHandler):
def do_GET(self): def send_auth_request(self, body):
response = self.get_postgresql_status() self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic realm=\"Patroni\"')
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(body.encode('utf-8'))
def finish(self, *args, **kwargs):
try:
if not self.wfile.closed:
self.wfile.flush()
self.wfile.close()
except socket.error:
pass
self.rfile.close()
def check_auth_header(self):
auth_header = self.headers.get('Authorization')
status = self.server.check_auth_header(auth_header)
return not status or self.send_auth_request(status)
def do_OPTIONS(self):
self.do_GET(options=True)
def do_GET(self, options=False):
"""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
status_code = 200 if response['running'] and 'role' in response and response['role'] in path else 503 response = self.get_postgresql_status()
response.update(self.get_tags())
patroni = self.server.patroni
cluster = patroni.dcs.cluster
if cluster: # dcs available
if cluster.leader and cluster.leader.name == patroni.postgresql.name: # is_leader
status_code = 200 if 'master' in path else 503
elif 'role' not in response:
status_code = 503
elif response['role'] == 'master': # running as master but without leader lock!!!!
status_code = 503
elif response['role'] in path:
status_code = 200
else:
status_code = 503
elif 'role' in response and response['role'] in path:
status_code = 200
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
status_code = 200
else:
status_code = 503
self.send_response(status_code) self.send_response(status_code)
if not options:
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
def do_GET_patroni(self):
response = self.get_postgresql_status(True)
response.update(self.get_tags())
self.send_response(200)
self.send_header('Content-Type', 'application/json') self.send_header('Content-Type', 'application/json')
self.end_headers() self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8')) self.wfile.write(json.dumps(response).encode('utf-8'))
def get_postgresql_status(self): @check_auth
def do_POST_restart(self):
status_code = 503
data = b'restart failed'
try: try:
row = self.server.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'), status, msg = self.server.patroni.ha.restart()
pg_is_in_recovery(), status_code = 200 if status else 503
CASE WHEN pg_is_in_recovery() data = msg.encode('utf-8')
THEN null except:
ELSE pg_current_xlog_location() END, logger.exception('Exception during restart')
pg_last_xlog_receive_location(),
pg_last_xlog_replay_location(), self.send_response(status_code)
pg_is_in_recovery() AND pg_is_xlog_replay_paused()""")[0] self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(data)
@check_auth
def do_POST_reinitialize(self):
ha = self.server.patroni.ha
cluster = ha.dcs.get_cluster()
if cluster.is_unlocked():
status_code = 503
data = b'Cluster has no leader, can not reinitialize'
elif cluster.leader.name == ha.state_handler.name:
status_code = 503
data = b'I am the leader, can not reinitialize'
else:
action = ha.schedule_reinitialize()
if action is not None:
status_code = 503
data = (action + ' already in progress').encode('utf-8')
else:
status_code = 200
data = b'reinitialize scheduled'
self.send_response(status_code)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(data)
def poll_failover_result(self, leader, member):
for a in range(0, 15):
time.sleep(1)
try:
cluster = self.server.patroni.dcs.get_cluster()
if cluster.leader and cluster.leader.name != leader:
return 200, ('Successfully failed over to ' + cluster.leader.name).encode('utf-8')
if not cluster.failover:
return 503, b'Failover failed'
except:
pass
return 503, b'Failover status unknown'
def is_failover_possible(self, cluster, leader, member):
if leader and not cluster.leader or cluster.leader.name != leader:
return b'leader name does not match'
if member:
members = [m for m in cluster.members if m.name == member]
if not members:
return b'member 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 b'failover is not possible: cluster does not have members except leader'
for member, reachable, in_recovery, xlog_location, tags in self.server.patroni.ha.fetch_nodes_statuses(members):
if reachable and not tags.get('nofailover', False):
return None
return b'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))
request = json.loads(self.rfile.read(content_length).decode('utf-8'))
leader = request.get('leader', None)
member = request.get('member', None)
cluster = self.server.patroni.ha.dcs.get_cluster()
status_code = 503
data = self.is_failover_possible(cluster, leader, member)
if not data:
if not self.server.patroni.dcs.manual_failover(leader, member):
data = b'failed to write failover key into DCS'
else:
self.server.patroni.dcs.event.set()
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name, member)
self.send_response(status_code)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(data)
def parse_request(self):
"""Override parse_request method to enrich basic functionality of `BaseHTTPRequestHandler` class
Original class can only invoke do_GET, do_POST, do_PUT, etc method implementations if they are defined.
But we would like to have at least some simple routing mechanism, i.e.:
GET /uri1/part2 request should invoke `do_GET_uri1()`
POST /other should invoke `do_POST_other()`
If the `do_<REQUEST_METHOD>_<first_part_url>` method does not exists we'll fallback to original behavior."""
ret = BaseHTTPRequestHandler.parse_request(self)
if ret:
mname = self.path.lstrip('/').split('/')[0]
mname = self.command + ('_' + mname if mname else '')
if hasattr(self, 'do_' + mname):
self.command = mname
return ret
def handle_one_request(self):
try:
BaseHTTPRequestHandler.handle_one_request(self)
except socket.error:
pass
def query(self, sql, *params, **kwargs):
if not kwargs.get('retry', False):
return self.server.query(sql, *params)
retry = Retry(delay=1, retry_exceptions=PostgresConnectionException)
return retry(self.server.query, sql, *params)
def get_postgresql_status(self, retry=False):
try:
row = self.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
pg_is_in_recovery(),
CASE WHEN pg_is_in_recovery()
THEN 0
ELSE pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::bigint
END,
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_is_in_recovery() AND pg_is_xlog_replay_paused()""", retry=retry)[0]
return { return {
'running': True, 'state': self.server.patroni.postgresql.state,
'postmaster_start_time': row[0], 'postmaster_start_time': row[0],
'role': 'slave' if row[1] else 'master', 'role': 'replica' if row[1] else 'master',
'xlog': ({ 'xlog': ({
'received_location': row[3], 'received_location': row[3],
'replayed_location': row[4], 'replayed_location': row[4],
@@ -44,30 +240,66 @@ class RestApiHandler(BaseHTTPRequestHandler):
'location': row[2] 'location': row[2]
}) })
} }
except (psycopg2.OperationalError, psycopg2.InterfaceError): except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
logger.exception('get_postgresql_status') state = self.server.patroni.postgresql.state
return {'running': self.server.patroni.postgresql.is_running()} if state == 'running':
logger.exception('get_postgresql_status')
state = 'unknown'
return {'state': state}
def get_tags(self):
return {'tags': self.server.patroni.tags}
def log_message(self, format, *args):
logger.debug("API thread: " + format % args)
class RestApiServer(ThreadingMixIn, HTTPServer, Thread): class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
def __init__(self, patroni, config): def __init__(self, patroni, config):
self.connection_string = 'http://{}/patroni'.format(config.get('connect_address', None) or config['listen']) self._auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
host, port = config['listen'].split(':') host, port = config['listen'].split(':')
HTTPServer.__init__(self, (host, int(port)), RestApiHandler) HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
Thread.__init__(self, target=self.serve_forever) Thread.__init__(self, target=self.serve_forever)
self._set_fd_cloexec(self.socket) self._set_fd_cloexec(self.socket)
protocol = 'http'
# wrap socket with ssl if 'certfile' is defined in a config.yaml
# Sometime it's also needed to pass reference to a 'keyfile'.
options = {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
if options.get('certfile', None):
import ssl
self.socket = ssl.wrap_socket(self.socket, server_side=True, **options)
protocol = 'https'
self.connection_string = '{}://{}/patroni'.format(protocol, config.get('connect_address', config['listen']))
self.patroni = patroni self.patroni = patroni
self.daemon = True self.daemon = True
def query(self, sql, *params): def query(self, sql, *params):
cursor = self.patroni.postgresql.connection().cursor() cursor = None
cursor.execute(sql, params) try:
ret = [r for r in cursor] with self.patroni.postgresql.connection().cursor() as cursor:
cursor.close() cursor.execute(sql, params)
return ret return [r for r in cursor]
except psycopg2.Error as e:
if cursor and cursor.connection.closed == 0:
raise e
raise PostgresConnectionException('connection problems')
@staticmethod @staticmethod
def _set_fd_cloexec(fd): def _set_fd_cloexec(fd):
flags = fcntl.fcntl(fd, fcntl.F_GETFD) flags = fcntl.fcntl(fd, fcntl.F_GETFD)
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
def check_basic_auth_key(self, key):
return self._auth_key == key
def check_auth_header(self, auth_header):
if self._auth_key:
if auth_header is None:
return 'no auth header received'
if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]):
return 'not authenticated'
+55
View File
@@ -0,0 +1,55 @@
import logging
from threading import Lock, Thread
logger = logging.getLogger(__name__)
class AsyncExecutor:
def __init__(self):
Lock.__init__(self)
self._busy = False
self._thread_lock = Lock()
self._scheduled_action = None
self._scheduled_action_lock = Lock()
@property
def busy(self):
return self._busy
def schedule(self, action, immediately=False):
with self._scheduled_action_lock:
if self._scheduled_action is not None:
return self._scheduled_action
self._scheduled_action = action
self._busy = immediately
return None
@property
def scheduled_action(self):
with self._scheduled_action_lock:
return self._scheduled_action
def reset_scheduled_action(self):
with self._scheduled_action_lock:
self._scheduled_action = None
def run(self, func, args=()):
try:
return func(*args) if args else func()
except:
logger.exception('Exception during execution of long running task %s', self.scheduled_action)
finally:
with self:
self._busy = False
self.reset_scheduled_action()
def run_async(self, func, args=()):
self._busy = True
Thread(target=self.run, args=(func, args)).start()
def __enter__(self):
self._thread_lock.acquire()
def __exit__(self, type, value, traceback):
self._thread_lock.release()
+616
View File
@@ -0,0 +1,616 @@
'''
Patroni Control
'''
import click
import os
import yaml
import json
import time
import psycopg2
import random
import requests
import datetime
from prettytable import PrettyTable
from six.moves.urllib_parse import urlparse
import logging
from .etcd import Etcd
from .exceptions import PatroniCtlException
from .postgresql import parseurl
CONFIG_DIR_PATH = click.get_app_dir('patroni')
CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'patronictl.yaml')
LOGLEVEL = 'WARNING'
def parse_dcs(dcs):
"""
Break up the provided dcs string
>>> parse_dcs('localhost') == {'scheme': 'etcd', 'hostname': 'localhost', 'port': 4001}
True
>>> parse_dcs('localhost:8500') == {'scheme': 'consul', 'hostname': 'localhost', 'port': 8500}
True
>>> parse_dcs('zookeeper://localhost') == {'scheme': 'zookeeper', 'hostname': 'localhost', 'port': 2181}
True
"""
if not dcs:
return {}
parsed = urlparse(dcs)
scheme = parsed.scheme
if scheme == '' and parsed.netloc == '':
parsed = urlparse('//' + dcs)
if scheme == '':
default_schemes = {'2181': 'zookeeper', '8500': 'consul'}
scheme = default_schemes.get(str(parsed.port), 'etcd')
port = parsed.port
if port is None:
default_ports = {'consul': 8500, 'zookeeper': 2181}
port = default_ports.get(str(scheme), 4001)
return {'scheme': str(scheme), 'hostname': str(parsed.hostname), 'port': int(port)}
def load_config(path, dcs):
logging.debug('Loading configuration from file {}'.format(path))
config = dict()
try:
with open(path, 'rb') as fd:
config = yaml.safe_load(fd)
except:
logging.exception('Could not load configuration file')
if dcs:
config['dcs'] = parse_dcs(dcs)
else:
config['dcs'] = parse_dcs(config.get('dcs_api'))
return config
def store_config(config, path):
dir_path = os.path.dirname(path)
if dir_path:
if 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', 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):
scheme, hostname, port = map(config.get('dcs', {}).get, ('scheme', 'hostname', 'port'))
if scheme == 'etcd':
return Etcd(name=scope, config={'scope': scope, 'host': '{}:{}'.format(hostname, port)})
raise PatroniCtlException('Can not find suitable configuration of distributed configuration store')
def post_patroni(member, endpoint, content, headers={'Content-Type': 'application/json'}):
url = urlparse(member.api_url)
logging.debug(url)
return requests.post('{}://{}/{}'.format(url.scheme, url.netloc, endpoint), headers=headers,
data=json.dumps(content), timeout=60)
def print_output(columns, rows=[], alignment=None, format='pretty', header=True, delimiter='\t'):
if format == '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 format == 'json':
elements = list()
for r in rows:
elements.append(dict(zip(columns, r)))
click.echo(json.dumps(elements))
if format == 'tsv':
if columns is not None and header:
click.echo(delimiter.join(columns) + '\n')
for r in rows or []:
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={}):
params = connect_parameters.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=cluster, role=role)
for m in members:
if member is None or m.name == member:
return m
return None
def get_cursor(cluster, role='master', member=None, connect_parameters={}):
member = get_any_member(cluster=cluster, role=role, member=member)
if member is None:
return None
params = build_connect_parameters(member.conn_url, connect_parameters=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'
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
m = get_any_member(cluster=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={} port={}'.format(params['host'], params['port']))
@ctl.command('query', help='Query a Patroni PostgreSQL member')
@click.argument('cluster_name')
@option_config_file
@option_format
@click.option('--format', help='Output format (pretty, json)', default='tsv')
@click.option('--file', '-f', help='Execute the SQL commands from this file', type=click.File('rb'))
@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')
def query(
cluster_name,
config_file,
dcs,
role,
member,
w,
watch,
delimiter,
command,
file,
format='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 file is not None and command is not None:
raise PatroniCtlException('--file and --command are mutually exclusive options')
if file is not None:
command = file.read()
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
cursor = None
for _ in watching(w, watch, clear=False):
output, cursor = query_member(cluster=cluster, cursor=cursor, member=member, role=role, command=command)
print_output(None, output, format=format, delimiter=delimiter)
if cursor is None:
cluster = dcs.get_cluster()
def query_member(cluster, cursor, member, role, command):
try:
if cursor is None:
cursor = get_cursor(cluster, role=role, member=member)
if cursor is None:
if role is None:
message = 'No connection to member {} is available'.format(member)
else:
message = 'No connection to role={} 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: {}'.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, format, dcs):
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
if not isinstance(dcs, Etcd):
raise PatroniCtlException('We have not implemented this for DCS of type {}'.format(type(dcs)))
output_members(cluster, format=format)
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 {}, please type: "{}"'.format(cluster_name,
message), type=str)
if message != confirm:
raise PatroniCtlException('You did not exactly type "{}"'.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.client.delete(dcs._base_path, recursive=True)
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 len(member_names) == 0:
member_names = [click.prompt('Which member do you want to {} [{}]?'.format(endpoint,
', '.join(candidates.keys())), type=str, default='')]
for mn in member_names:
if mn not in candidates.keys():
raise PatroniCtlException('{} is not a member of cluster'.format(mn))
if not force:
confirm = click.confirm('Are you sure you want to {} members {}?'.format(endpoint, ', '.join(member_names)))
if not confirm:
raise PatroniCtlException('Aborted {}'.format(endpoint))
for mn in member_names:
r = post_patroni(candidates[mn], endpoint, '')
if r.status_code != 200:
click.echo('{} failed for member {}, status code={}, ({})'.format(endpoint, mn, r.status_code, r.text))
else:
click.echo('Succesful {} on member {}'.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', 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, any):
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
role_names = [m.name for m in get_all_members(cluster=cluster, role=role)]
if len(member_names) > 0:
member_names = list(set(member_names) & set(role_names))
else:
member_names = role_names
if any:
random.shuffle(member_names)
member_names = member_names[:1]
output_members(cluster)
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):
config, 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('--force', is_flag=True)
@option_config_file
@option_dcs
def failover(config_file, cluster_name, master, candidate, force, dcs):
"""
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.
"""
config, 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 {} is not the leader of cluster {}'.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 len(candidate_names) == 0:
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 {} does not exist in cluster {}'.format(candidate, cluster_name))
# By now we have established that the leader exists and the candidate exists
click.echo('Current cluster topology')
output_members(dcs.get_cluster(), name=cluster_name)
if not force:
a = \
click.confirm('Are you sure you want to failover cluster {}, demoting current master {}?'.format(
cluster_name, master))
if not a:
raise PatroniCtlException('Aborting failover')
failover_value = '{}:{}'.format(master, candidate or '')
t_started = time.time()
r = None
try:
r = post_patroni(cluster.leader.member, 'failover', {'leader': master, 'member': candidate or ''})
if r.status_code == 200:
logging.debug(r)
logging.debug(r.text)
cluster = dcs.get_cluster()
click.echo(timestamp() + ' Failing over to new leader: {}'.format(cluster.leader.member.name))
else:
click.echo('Failover failed, details: {}, {}'.format(r.status_code, r.text))
return
except:
logging.exception(r)
logging.warning('Failing over to DCS')
click.echo(timestamp() + ' Could not failover using Patroni api, falling back to DCS')
dcs.set_failover_value(failover_value)
click.echo(timestamp() + ' Initialized failover from master {}'.format(master))
# The failover process should within a minute update the failover key, we will keep watching it until it changes
# or we timeout
cluster = wait_for_leader(dcs, timeout=60)
if cluster.leader.member.name == master:
click.echo('Failover failed, master did not change after {:0.1f} seconds'.format(time.time() - t_started))
return
click.echo(timestamp() + ' Failover completed in {:0.1f} seconds, new leader is {}'.format(time.time() - t_started,
str(cluster.leader.member.name)))
output_members(cluster, name=cluster_name)
def output_members(cluster, name=None, format='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')
if xlog_location is None or (xlog_location_cluster < xlog_location):
lag = ''
else:
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, format)
@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, format, watch, w, dcs):
if len(cluster_names) == 0:
logging.warning('Listing members: No cluster names were provided')
return
config = load_config(config_file, dcs)
for cn in cluster_names:
dcs = get_dcs(config, cn)
for _ in watching(w, watch):
output_members(dcs.get_cluster(), name=cn, format=format)
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)
+145 -27
View File
@@ -1,9 +1,10 @@
import abc import abc
import json
from collections import namedtuple from collections import namedtuple
from patroni.exceptions import DCSError from patroni.exceptions import DCSError
from patroni.utils import calculate_ttl, sleep
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
def parse_connection_string(value): def parse_connection_string(value):
@@ -23,28 +24,56 @@ def parse_connection_string(value):
return conn_url, api_url return conn_url, api_url
class Member(namedtuple('Member', 'index,name,conn_url,api_url,expiration,ttl')): class Member(namedtuple('Member', 'index,name,session,data')):
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster. """Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
Consists of the following fields: Consists of the following fields:
:param index: modification index of a given member key in a Configuration Store :param index: modification index of a given member key in a Configuration Store
:param name: name of PostgreSQL cluster member :param name: name of PostgreSQL cluster member
:param conn_url: connection string containing host, user and password which could be used to access this member. :param session: either session id or just ttl in seconds
:param api_url: REST API url of patroni instance :param data: arbitrary data i.e. conn_url, api_url, xlog location, state, role, tags, etc...
:param expiration: expiration time of given member key
:param ttl: ttl of given member key in seconds"""
def real_ttl(self): There are two mandatory keys in a data:
return calculate_ttl(self.expiration) or -1 conn_url: connection string containing host, user and password which could be used to access this member.
api_url: REST API url of patroni instance"""
@staticmethod
def from_node(index, name, session, data):
"""
>>> Member.from_node(-1, '', '', '{"conn_url": "postgres://foo@bar/postgres"}') is not None
True
>>> Member.from_node(-1, '', '', '{')
Member(index=-1, name='', session='', data={})
"""
if data.startswith('postgres'):
conn_url, api_url = parse_connection_string(data)
data = {'conn_url': conn_url, 'api_url': api_url}
else:
try:
data = json.loads(data)
except:
data = {}
return Member(index, name, session, data)
@property
def conn_url(self):
return self.data.get('conn_url', None)
@property
def api_url(self):
return self.data.get('api_url', None)
@property
def nofailover(self):
return self.data.get('tags', {}).get('nofailover', False)
class Leader(namedtuple('Leader', 'index,expiration,ttl,member')): class Leader(namedtuple('Leader', 'index,session,member')):
"""Immutable object (namedtuple) which represents leader key. """Immutable object (namedtuple) which represents leader key.
Consists of the following fields: Consists of the following fields:
:param index: modification index of a leader key in a Configuration Store :param index: modification index of a leader key in a Configuration Store
:param expiration: expiration time of the leader key :param session: either session id or just ttl in seconds
:param ttl: ttl of the leader key
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)""" :param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
@property @property
@@ -56,7 +85,15 @@ class Leader(namedtuple('Leader', 'index,expiration,ttl,member')):
return self.member.conn_url return self.member.conn_url
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members')): class Failover(namedtuple('Failover', 'index,leader,member')):
@staticmethod
def from_node(index, value):
t = [a.strip() for a in value.split(':')] + ['']
return Failover(index, t[0], t[1]) if t[0] or t[1] else None
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members,failover')):
"""Immutable object (namedtuple) which represents PostgreSQL cluster. """Immutable object (namedtuple) which represents PostgreSQL cluster.
Consists of the following fields: Consists of the following fields:
@@ -64,7 +101,8 @@ class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,mem
:param leader: `Leader` object which represents current leader of the cluster :param leader: `Leader` object which represents current leader of the cluster
:param last_leader_operation: int or long object containing position of last known leader operation. :param last_leader_operation: int or long object containing position of last known leader operation.
This value is stored in `/optime/leader` key This value is stored in `/optime/leader` key
:param members: list of Member object, all PostgreSQL cluster members including leader""" :param members: list of Member object, all PostgreSQL cluster members including leader
:param failover: reference to `Failover` object"""
def is_unlocked(self): def is_unlocked(self):
return not (self.leader and self.leader.name) return not (self.leader and self.leader.name)
@@ -74,6 +112,13 @@ class AbstractDCS:
__metaclass__ = abc.ABCMeta __metaclass__ = abc.ABCMeta
_INITIALIZE = 'initialize'
_LEADER = 'leader'
_FAILOVER = 'failover'
_MEMBERS = 'members/'
_OPTIME = 'optime'
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
def __init__(self, name, config): def __init__(self, name, config):
""" """
:param name: name of current instance (the same value as `~Postgresql.name`) :param name: name of current instance (the same value as `~Postgresql.name`)
@@ -81,27 +126,80 @@ 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 = '/{}'.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_thread_lock = Lock()
self.event = Event()
def client_path(self, path): def client_path(self, path):
return self._base_path + path return '/'.join([self._base_path, path.lstrip('/')])
@property
def initialize_path(self):
return self.client_path(self._INITIALIZE)
@property
def members_path(self):
return self.client_path(self._MEMBERS)
@property
def member_path(self):
return self.client_path(self._MEMBERS + self._name)
@property
def leader_path(self):
return self.client_path(self._LEADER)
@property
def failover_path(self):
return self.client_path(self._FAILOVER)
@property
def leader_optime_path(self):
return self.client_path(self._LEADER_OPTIME)
@abc.abstractmethod @abc.abstractmethod
def _load_cluster(self):
"""Internally this method should build `Cluster` object which
represents current state and topology of the cluster in DCS.
this method supposed to be called only by `get_cluster` method.
raise `~DCSError` in case of communication or other problems with DCS.
If the current node was running as a master and exception raised,
instance would be demoted."""
def get_cluster(self): def get_cluster(self):
""":returns: `Cluster` object which represent current state and topology of the cluster with self._cluster_thread_lock:
raise `~DCSError` in case of communication or other problems with DCS. If current instance was try:
running as a master and exception raised instance would be demoted.""" self._load_cluster()
except:
self._cluster = None
raise
return self._cluster
@property
def cluster(self):
with self._cluster_thread_lock:
return self._cluster
def reset_cluster(self):
with self._cluster_thread_lock:
self._cluster = None
@abc.abstractmethod @abc.abstractmethod
def update_leader(self, state_handler): def write_leader_optime(self, last_operation):
"""Update leader key (or session) ttl and `/optime/leader` key in DCS. """write current xlog location into `/optime/leader` key in DCS
:param last_operation: absolute xlog location in bytes"""
@abc.abstractmethod
def update_leader(self):
"""Update leader key (or session) ttl
:param state_handler: reference to `Postgresql` object
:returns: `!True` if leader key (or session) has been updated successfully. :returns: `!True` if leader key (or session) has been updated successfully.
If not, `!False` must be returned and current instance would be demoted. If not, `!False` must be returned and current instance would be demoted.
If you failed to update `/optime/leader` this error is not critical and you can return `!True`
You have to use CAS (Compare And Swap) operation in order to update leader key, You have to use CAS (Compare And Swap) operation in order to update leader key,
for example for etcd `prevValue` parameter must be used.""" for example for etcd `prevValue` parameter must be used."""
@@ -114,6 +212,13 @@ class AbstractDCS:
Key must be created atomically. In case if key already exists it should not be Key must be created atomically. In case if key already exists it should not be
overwritten and `!False` must be returned""" overwritten and `!False` must be returned"""
@abc.abstractmethod
def set_failover_value(self, value, index=None):
"""Create or update `/failover` key"""
def manual_failover(self, leader, member, index=None):
return self.set_failover_value(leader + (':' + member if member else ''), index)
def current_leader(self): def current_leader(self):
try: try:
cluster = self.get_cluster() cluster = self.get_cluster()
@@ -139,12 +244,14 @@ class AbstractDCS:
overwriting the key if necessary.""" overwriting the key if necessary."""
@abc.abstractmethod @abc.abstractmethod
def race(self, path): def initialize(self, create_new=True, sysid=""):
"""Race for cluster initialization. """Race for cluster initialization.
:param path: usually this is just '/initialize'
: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 `path` key and return `!True` this method should create atomically initialize key and return `!True`
otherwise it should return `!False`""" otherwise it should return `!False`"""
@abc.abstractmethod @abc.abstractmethod
@@ -152,5 +259,16 @@ class AbstractDCS:
"""Voluntarily remove leader key from DCS """Voluntarily remove leader key from DCS
This method should remove leader key if current instance is the leader""" This method should remove leader key if current instance is the leader"""
@abc.abstractmethod
def cancel_initialization(self):
""" Removes the initialize key for a cluster """
def watch(self, timeout): def watch(self, timeout):
sleep(timeout) """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
:param timeout: timeout in seconds
:returns: `!True` if you would like to reschedule the next run of ha cycle"""
self.event.wait(timeout)
return self.event.isSet()
+60 -42
View File
@@ -10,7 +10,8 @@ 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, DCSError, Leader, Member, parse_connection_string from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import Retry, RetryFailedError, sleep from patroni.utils import Retry, RetryFailedError, sleep
from requests.exceptions import RequestException from requests.exceptions import RequestException
@@ -51,7 +52,11 @@ class Client(etcd.Client):
def api_execute(self, path, method, **kwargs): def api_execute(self, path, method, **kwargs):
# 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() self._update_machines_cache and self._load_machines_cache()
return super(Client, self).api_execute(path, method, **kwargs) try:
return super(Client, self).api_execute(path, method, **kwargs)
except etcd.EtcdConnectionFailed:
self._update_machines_cache = True
raise
@staticmethod @staticmethod
def get_srv_record(host): def get_srv_record(host):
@@ -80,7 +85,7 @@ class Client(etcd.Client):
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 = '{}://{}:{}/members'.format(self._protocol, host, port)
try: try:
response = requests.get(url) response = requests.get(url, timeout=5)
if response.ok: if response.ok:
for member in response.json(): for member in response.json():
ret.extend(member['clientURLs']) ret.extend(member['clientURLs'])
@@ -138,6 +143,10 @@ def catch_etcd_errors(func):
return not func(*args, **kwargs) is None return not func(*args, **kwargs) is None
except (RetryFailedError, etcd.EtcdException): except (RetryFailedError, etcd.EtcdException):
return False return False
except:
logger.exception("")
raise EtcdError("unexpected error")
return wrapper return wrapper
@@ -145,15 +154,13 @@ 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.member_ttl = config.get('member_ttl', 3600)
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)
self.cluster = None
def retry(self, *args, **kwargs): def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs) return self._retry.copy()(*args, **kwargs)
@@ -170,53 +177,54 @@ class Etcd(AbstractDCS):
@staticmethod @staticmethod
def member(node): def member(node):
conn_url, api_url = parse_connection_string(node.value) return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
return Member(node.modifiedIndex, os.path.basename(node.key), conn_url, api_url, node.expiration, node.ttl)
def get_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('initialize', False)) initialize = nodes.get(self._INITIALIZE, None)
initialize = initialize and initialize.value
# get last leader operation # get last leader operation
last_leader_operation = nodes.get('optime/leader', None) last_leader_operation = nodes.get(self._LEADER_OPTIME, None)
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('members/') and len(k.split('/')) == 2] 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('leader', None) leader = nodes.get(self._LEADER, None)
if leader: if leader:
member = Member(-1, leader.value, None, None, None, 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.expiration, leader.ttl, member) leader = Leader(leader.modifiedIndex, leader.ttl, member)
self.cluster = Cluster(initialize, leader, last_leader_operation, members) # failover key
failover = nodes.get(self._FAILOVER, None)
if failover:
failover = Failover.from_node(failover.modifiedIndex, failover.value)
self._cluster = Cluster(initialize, leader, last_leader_operation, members, failover)
except etcd.EtcdKeyNotFound: except etcd.EtcdKeyNotFound:
self.cluster = Cluster(False, None, None, []) self._cluster = Cluster(False, None, None, [], None)
except: except:
self.cluster = None
logger.exception('get_cluster') logger.exception('get_cluster')
raise EtcdError('Etcd is not responding properly') raise EtcdError('Etcd is not responding properly')
return self.cluster
@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.client_path('/members/' + self._name), return self.retry(self.client.set, self.member_path, connection_string, ttl or self.ttl)
connection_string, ttl or self.member_ttl)
@catch_etcd_errors @catch_etcd_errors
def take_leader(self): def take_leader(self):
return self.retry(self.client.set, self.client_path('/leader'), 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 not self.retry(self.client.write, self.client_path('/leader'), return bool(self.retry(self.client.write, self.leader_path, self._name, ttl=self.ttl, prevExist=False))
self._name, ttl=self.ttl, prevExist=False) is None
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,41 +232,51 @@ class Etcd(AbstractDCS):
return False return False
@catch_etcd_errors @catch_etcd_errors
def write_leader_optime(self, state_handler): def set_failover_value(self, value, index=None):
return self.client.set(self.client_path('/optime/leader'), state_handler.last_operation()) return self.client.write(self.failover_path, value, prevIndex=index or 0)
@catch_etcd_errors @catch_etcd_errors
def update_leader(self, state_handler): def write_leader_optime(self, last_operation):
ret = self.retry(self.client.test_and_set, self.client_path('/leader'), self._name, self._name, self.ttl) return self.client.set(self.leader_optime_path, last_operation)
ret and self.write_leader_optime(state_handler)
return ret
@catch_etcd_errors @catch_etcd_errors
def race(self, path): def update_leader(self):
return self.retry(self.client.write, self.client_path(path), self._name, prevExist=False) return self.retry(self.client.test_and_set, self.leader_path, self._name, self._name, self.ttl)
@catch_etcd_errors
def initialize(self, create_new=True, sysid=""):
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.client_path('/leader'), prevValue=self._name) return self.client.delete(self.leader_path, prevValue=self._name)
@catch_etcd_errors
def cancel_initialization(self):
return self.retry(self.client.delete, self.initialize_path)
def watch(self, timeout): def watch(self, timeout):
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 self.cluster and self.cluster.leader and self.cluster.leader.name != self._name: if cluster and cluster.leader and cluster.leader.name != self._name:
end_time = time.time() + timeout end_time = time.time() + timeout
index = self.cluster.leader.index index = cluster.leader.index
while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try: try:
res = self.client.watch(self.client_path('/leader'), index=index + 1, timeout=timeout) self.client.watch(self.leader_path, index=index + 1, timeout=timeout + 0.5)
if res.action not in ['set', 'compareAndSwap'] or res.value != self.cluster.leader.name: # Synchronous work of all cluster members with etcd is less expensive
return # than reestablishing http connection every time from every replica.
index = res.modifiedIndex return True
except urllib3.exceptions.TimeoutError: except urllib3.exceptions.TimeoutError:
self.client.http.clear() self.client.http.clear()
return return False
except etcd.EtcdException: except etcd.EtcdException:
index = None logging.exception('watch')
timeout = end_time - time.time() timeout = end_time - time.time()
timeout > 0 and super(Etcd, self).watch(timeout) try:
return super(Etcd, self).watch(timeout)
finally:
self.event.clear()
+13 -1
View File
@@ -7,11 +7,23 @@ class PatroniException(Exception):
def __str__(self): def __str__(self):
""" """
>>> str(DCSError('foo')) >>> str(PatroniException('foo'))
"'foo'" "'foo'"
""" """
return repr(self.value) return repr(self.value)
class PatroniCtlException(Exception):
pass
class PostgresException(PatroniException):
pass
class DCSError(PatroniException): class DCSError(PatroniException):
pass pass
class PostgresConnectionException(PostgresException):
pass
+401 -63
View File
@@ -1,26 +1,31 @@
import json
import logging import logging
import psycopg2
import requests
import sys
from patroni.dcs import DCSError from patroni.async_executor import AsyncExecutor
from psycopg2 import InterfaceError, OperationalError from patroni.exceptions import DCSError, PostgresConnectionException
from multiprocessing.pool import ThreadPool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Ha: class Ha:
def __init__(self, state_handler, etcd): def __init__(self, patroni):
self.state_handler = state_handler self.patroni = patroni
self.dcs = etcd self.state_handler = patroni.postgresql
self.dcs = patroni.dcs
self.cluster = None self.cluster = None
self.old_cluster = None self.old_cluster = None
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 healhy
if cluster.is_unlocked() and self.cluster and not self.cluster.is_unlocked(): if not cluster.is_unlocked() or not self.old_cluster:
self.old_cluster = self.cluster
if not self.old_cluster:
self.old_cluster = cluster self.old_cluster = cluster
self.cluster = cluster self.cluster = cluster
@@ -28,74 +33,407 @@ class Ha:
return self.dcs.attempt_to_acquire_leader() return self.dcs.attempt_to_acquire_leader()
def update_lock(self): def update_lock(self):
return self.dcs.update_leader(self.state_handler) ret = self.dcs.update_leader()
if ret:
try:
self.dcs.write_leader_optime(self.state_handler.last_operation())
except:
pass
return ret
def has_lock(self): def has_lock(self):
lock_owner = self.cluster.leader and self.cluster.leader.name lock_owner = self.cluster.leader and self.cluster.leader.name
logger.info('Lock owner: %s; I am %s', lock_owner, self.state_handler.name) logger.info('Lock owner: %s; I am %s', lock_owner, self.state_handler.name)
return lock_owner == self.state_handler.name return lock_owner == self.state_handler.name
def demote(self): def touch_member(self):
return self.state_handler.demote(self.cluster.leader) data = {
'conn_url': self.state_handler.connection_string,
'api_url': self.patroni.api.connection_string,
'state': self.state_handler.state,
'role': self.state_handler.role,
'tags': self.patroni.tags
}
if data['state'] in ['running', 'restarting', 'starting']:
try:
data['xlog_location'] = self.state_handler.xlog_position()
except:
pass
self.dcs.touch_member(json.dumps(data, separators=(',', ':')))
def follow_the_leader(self): def copy_backup_from_leader(self, leader):
return self.state_handler.follow_the_leader(self.cluster.leader) if self.state_handler.bootstrap(leader):
logger.info('bootstrapped from leader')
else:
self.state_handler.stop('immediate')
self.state_handler.remove_data_directory()
logger.error('failed to bootstrap from leader')
def run_cycle(self): def bootstrap(self):
if not self.cluster.is_unlocked(): # cluster already has leader
self._async_executor.schedule('bootstrap from leader')
self._async_executor.run_async(self.copy_backup_from_leader, args=(self.cluster.leader, ))
return 'trying to bootstrap from leader'
elif not self.cluster.initialize and not self.patroni.nofailover: # no initialize key
if self.dcs.initialize(create_new=True): # race for initialization
try:
self.state_handler.bootstrap()
self.dcs.initialize(create_new=False, sysid=self.state_handler.sysid)
except: # initdb or start failed
# remove initialization key and give a chance to other members
logger.info("removing initialize key after failed attempt to initialize the cluster")
self.dcs.cancel_initialization()
self.state_handler.stop('immediate')
self.state_handler.move_data_directory()
raise
self.dcs.take_leader()
return 'initialized a new cluster'
else:
return 'failed to acquire initialize lock'
else:
return 'waiting for leader to bootstrap'
def recover(self):
has_lock = self.has_lock()
# try to see if we are the former master that crashed. If so - we likely need to run pg_rewind
# in order to join the former standby being promoted.
pg_controldata = self.state_handler.controldata()
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.
# 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):
refresh and self.load_cluster_from_dcs()
ret = demote_reason if self.state_handler.is_leader() else follow_reason
leader = self.cluster.leader
leader = None if (leader and leader.name) == self.state_handler.name else leader
if not self.state_handler.check_recovery_conf(leader):
self._async_executor.schedule('changing primary_conninfo and restarting')
self._async_executor.run_async(self.state_handler.follow_the_leader, (leader, ))
return ret
def enforce_master_role(self, message, promote_message):
if self.state_handler.is_leader() or self.state_handler.role == 'master':
return message
else:
self.state_handler.promote()
self.touch_member()
return promote_message
@staticmethod
def fetch_node_status(member):
"""This function perform http get request on member.api_url and fetches its status
:returns: tuple(`member`, reachable, in_recovery, xlog_location)
reachable - `!False` if the node is not reachable or is not responding with correct JSON
in_recovery - `!True` if pg_is_in_recovery() == true
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:
response = requests.get(member.api_url, timeout=2, verify=False)
logger.info('Got response from %s %s: %s', member.name, member.api_url, response.content)
json = response.json()
is_master = json['role'] == 'master'
xlog_location = json['xlog']['location' if is_master else 'replayed_location']
tags = json.get('tags', dict())
return (member, True, not is_master, xlog_location, tags)
except:
logging.exception('request failed: GET %s', member.api_url)
return (member, False, None, 0, {})
def fetch_nodes_statuses(self, members):
pool = ThreadPool(len(members))
results = pool.map(self.fetch_node_status, members) # Run API calls on members in parallel
pool.close()
pool.join()
return results
def _is_healthiest_node(self, members, check_replication_lag=True):
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
if self.state_handler.is_leader():
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):
return False # Too far behind last reported xlog location on master
# Prepare list of nodes to run check against
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
if members:
my_xlog_location = self.state_handler.xlog_position()
for member, reachable, in_recovery, xlog_location, tags in self.fetch_nodes_statuses(members):
if reachable and not tags.get('nofailover', False): # If the node is unreachable it's not healhy
if not in_recovery:
logger.warning('Master (%s) is still alive', member.name)
return False
if my_xlog_location < xlog_location:
return False
return True
def is_failover_possible(self, members):
ret = False
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
if members:
for member, reachable, in_recovery, xlog_location, tags in self.fetch_nodes_statuses(members):
if reachable and not tags.get('nofailover', False):
ret = True # TODO: check xlog_location
elif not reachable:
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:
logger.warning('manual failover: members list is empty')
return ret
def manual_failover_process_no_leader(self):
failover = self.cluster.failover
if failover.member: # manual failover to specific member
if failover.member == self.state_handler.name: # manual failover to me
return True
# find specific node and check that it is healthy
members = [m for m in self.cluster.members if m.name == failover.member]
if members:
member, reachable, in_recovery, xlog_location, tags = self.fetch_node_status(members[0])
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)
return False
# we wanted to failover to specific member but it is not healthy
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
# i.e. we assume that failover.member is None
# try to pick some other members to failover and check that they are healthy
if failover.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)
members = [m for m in self.cluster.members if m.name not in (failover.member, failover.leader)]
if self.is_failover_possible(members): # check that there are healthy members
return False
else: # I was the leader and it looks like currently I am the only healthy member
return True
# at this point we assume that our node is a candidate for a failover among all nodes except former leader
# exclude former leader from the list (failover.leader can be None)
members = [m for m in self.cluster.members if m.name != failover.leader]
return self._is_healthiest_node(members, check_replication_lag=False)
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:
return self.manual_failover_process_no_leader()
# run usual health check
members = {m.name: m for m in self.cluster.members + self.old_cluster.members}
return self._is_healthiest_node(members.values())
def demote(self, delete_leader=True):
if delete_leader:
self.state_handler.stop()
self.dcs.delete_leader()
self.touch_member()
self.dcs.reset_cluster()
self.state_handler.follow_the_leader(None)
def process_manual_failover_from_leader(self):
failover = self.cluster.failover
if not failover.leader or failover.leader == self.state_handler.name:
if not failover.member or failover.member != self.state_handler.name:
members = [m for m in self.cluster.members if not failover.member or m.name == failover.member]
if self.is_failover_possible(members): # check that there are healthy members
self._async_executor.schedule('manual failover: demote')
self._async_executor.run_async(self.demote)
return 'manual failover: demoting myself'
else:
logger.warning('manual failover: no healthy members found, failover is not possible')
else:
logger.warning('manual failover: I am already the leader, no need to failover')
else:
logger.warning('manual failover: leader name does not match: %s != %s',
self.cluster.failover.leader, self.state_handler.name)
logger.info('Trying to clean up failover key')
self.dcs.manual_failover('', '', self.cluster.failover.index)
def process_unhealthy_cluster(self):
if self.is_healthiest_node():
if self.acquire_lock():
if self.cluster.failover:
logger.info('Cleaning up failover key after acquiring leader lock...')
self.dcs.manual_failover('', '')
self.dcs.get_cluster()
return self.enforce_master_role('acquired session lock as a leader',
'promoted self to leader by acquiring session lock')
else:
return self.follow_the_leader('demoted self due after trying and failing to obtain lock',
'following new leader after trying and failing to obtain lock')
else:
if self.patroni.nofailover:
return self.follow_the_leader('demoting self because I am not allowed to become master',
'following a different leader because I am not allowed to promote')
return self.follow_the_leader('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):
if self.has_lock():
if self.cluster.failover:
msg = self.process_manual_failover_from_leader()
if msg is not None:
return msg
if self.update_lock():
return self.enforce_master_role('no action. i am the leader with the lock',
'promoted self to leader because i had the session lock')
else:
# Either there is no connection to DCS or someone else acquired the lock
logger.error('failed to update leader lock')
self.load_cluster_from_dcs()
else:
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',
'no action. i am a secondary and i am following a leader', False)
def schedule(self, action):
with self._async_executor:
return self._async_executor.schedule(action)
def restart_scheduled(self):
return self._async_executor.scheduled_action == 'restart'
def schedule_reinitialize(self):
return self.schedule('reinitialize')
def reinitialize_scheduled(self):
return self._async_executor.scheduled_action == 'reinitialize'
def restart(self):
with self._async_executor:
prev = self._async_executor.schedule('restart', True)
if prev is not None:
return (False, prev + ' already in progress')
if self._async_executor.run(self.state_handler.restart):
return (True, 'restarted successfully')
else:
return (False, 'restart failed')
def reinitialize(self, cluster):
self.state_handler.stop('immediate')
self.state_handler.remove_data_directory()
self.copy_backup_from_leader(cluster.leader)
def process_scheduled_action(self):
if self.reinitialize_scheduled():
if self.cluster.is_unlocked():
logger.error('Cluster has no leader, can not reinitialize')
self._async_executor.reset_scheduled_action()
elif self.has_lock():
logger.error('I am the leader, can not reinitialize')
self._async_executor.reset_scheduled_action()
else:
self._async_executor.run_async(self.reinitialize, args=(self.cluster, ))
return 'reinitialize started'
def handle_long_action_in_progress(self):
if self.has_lock():
if self.update_lock():
return 'updated leader lock during ' + self._async_executor.scheduled_action
else:
return 'failed to update leader lock during ' + self._async_executor.scheduled_action
elif self.cluster.is_unlocked():
return 'not healthy enough for leader race'
else:
return self._async_executor.scheduled_action + ' in progress'
def sysid_valid(self, 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 _run_cycle(self):
try: try:
self.load_cluster_from_dcs() self.load_cluster_from_dcs()
if not self.state_handler.is_healthy():
has_lock = self.has_lock()
self.state_handler.write_recovery_conf(None if has_lock else self.cluster.leader)
self.state_handler.start()
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()
if self.cluster.is_unlocked(): self.touch_member()
if self.state_handler.is_healthiest_node(self.old_cluster):
if self.acquire_lock(): # cluster has leader key but not initialize key
if self.state_handler.is_leader() or self.state_handler.is_promoted: if not self.cluster.is_unlocked() and not self.sysid_valid(self.cluster.initialize) and self.has_lock():
return 'acquired session lock as a leader' self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
else:
self.state_handler.promote() if self._async_executor.busy:
return 'promoted self to leader by acquiring session lock' return self.handle_long_action_in_progress()
else:
self.load_cluster_from_dcs() # currently it can trigger only reinitialize
if self.state_handler.is_leader(): msg = self.process_scheduled_action()
self.demote() if msg is not None:
return 'demoted self due after trying and failing to obtain lock' return msg
else:
self.follow_the_leader() # is data directory empty?
return 'following new leader after trying and failing to obtain lock' if self.state_handler.data_directory_empty():
else: return self.bootstrap() # new node
self.load_cluster_from_dcs() # "bootstrap", but data directory is not empty
if self.state_handler.is_leader(): elif not self.sysid_valid(self.cluster.initialize) and self.cluster.is_unlocked():
self.demote() self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
return 'demoting self because i am not the healthiest node'
else:
self.follow_the_leader()
return 'following a different leader because i am not the healthiest node'
else: else:
if self.has_lock() and self.update_lock(): # check if we are allowed to join
if self.state_handler.is_leader() or self.state_handler.is_promoted: if self.sysid_valid(self.cluster.initialize) and self.cluster.initialize != self.state_handler.sysid:
return 'no action. i am the leader with the lock' logger.fatal("system ID mismatch, node {0} belongs to a different cluster".
else: format(self.state_handler.name))
self.state_handler.promote() sys.exit(1)
return 'promoted self to leader because i had the session lock'
# try to start dead postgres
if not self.state_handler.is_healthy():
msg = self.recover()
if msg is not None:
return msg
try:
if self.cluster.is_unlocked():
return self.process_unhealthy_cluster()
else: else:
logger.info('does not have lock') return self.process_healthy_cluster()
if self.state_handler.is_leader(): finally:
self.demote() self.state_handler.sync_replication_slots(self.cluster)
return 'demoting self because i do not have the lock and i was a leader'
else:
self.follow_the_leader()
return 'no action. i am a secondary and i am following a leader'
except DCSError: except DCSError:
logger.error('Error communicating with DCS') logger.error('Error communicating with DCS')
if self.state_handler.is_leader(): if self.state_handler.is_running() and self.state_handler.is_leader():
self.state_handler.demote(None) 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 (InterfaceError, OperationalError): except (psycopg2.Error, PostgresConnectionException):
logger.error('Error communicating with Postgresql. Will try again') logger.exception('Error communicating with Postgresql. Will try again later')
def run_cycle(self):
with self._async_executor:
return self._run_cycle()
+514 -176
View File
@@ -4,9 +4,14 @@ import psycopg2
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
import tempfile
import time
from patroni.utils import sleep from patroni.exceptions import PostgresConnectionException, PostgresException
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,21 +44,25 @@ class Postgresql:
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', {})
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['superuser']
self.admin = config['admin'] self.admin = config['admin']
self.initdb_options = config.get('initdb', [])
self.pgpass = config.get('pgpass', None) or os.path.join(os.path.expanduser('~'), 'pgpass')
self.pg_rewind = config.get('pg_rewind', {})
self.callback = config.get('callbacks', {}) self.callback = config.get('callbacks', {})
self.use_slots = config.get('use_slots', True) self.use_slots = config.get('use_slots', True)
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', None) 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.is_promoted = False
self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir] self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir]
@@ -64,7 +73,50 @@ class Postgresql:
self._connection = None self._connection = None
self._cursor_holder = None self._cursor_holder = None
self.members = [] # list of already existing replication slots self._need_rewind = False
self._sysid = None
self.replication_slots = [] # list of already existing replication slots
self.retry = Retry(max_tries=-1, deadline=5, max_delay=1, retry_exceptions=PostgresConnectionException)
self._state = 'stopped'
self._state_lock = Lock()
self._role = 'replica'
self._role_lock = Lock()
if self.is_running():
self._state = 'running'
self._role = 'master' if self.is_leader() else 'replica'
@property
def can_rewind(self):
""" check if pg_rewind executable is there and that pg_controldata indicates
we have either wal_log_hints or checksums turned on
"""
# low-hanging fruit: check if pg_rewind configuration is there
if not self.pg_rewind or\
not (self.pg_rewind.get('username', '') and self.pg_rewind.get('password', '')):
return False
cmd = ['pg_rewind', '--help']
try:
ret = subprocess.call(cmd, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
if ret != 0: # pg_rewind is not there, close up the shop and go home
return False
except OSError:
return False
# check if the cluster's configuration permits pg_rewind
data = self.controldata()
return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
@property
def sysid(self):
if not self._sysid:
data = self.controldata()
self._sysid = data.get('Database system identifier', "")
return self._sysid
def require_rewind(self):
self._need_rewind = True
def get_local_address(self): def get_local_address(self):
listen_addresses = self.listen_addresses.split(',') listen_addresses = self.listen_addresses.split(',')
@@ -84,150 +136,267 @@ class Postgresql:
return self._connection return self._connection
def _cursor(self): def _cursor(self):
if not self._cursor_holder or self._cursor_holder.closed: if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
logger.info("established 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 disconnect(self): def close_connection(self):
self._connection and self._connection.close() if self._cursor_holder and self._cursor_holder.connection and self._cursor_holder.connection.closed == 0:
self._connection = self._cursor_holder = None self._cursor_holder.connection.close()
logger.info("closed patroni connection to the postgresql cluster")
def _query(self, sql, *params):
cursor = None
try:
cursor = self._cursor()
cursor.execute(sql, params)
return cursor
except psycopg2.Error as e:
if cursor and cursor.connection.closed == 0:
raise e
if self.state == 'restarting':
raise RetryFailedError('cluster is being restarted')
raise PostgresConnectionException('connection problems')
def query(self, sql, *params): def query(self, sql, *params):
max_attempts = 0 try:
while True: return self.retry(self._query, sql, *params)
ex = None except RetryFailedError as e:
try: raise PostgresConnectionException(str(e))
cursor = self._cursor()
cursor.execute(sql, params)
return cursor
except psycopg2.InterfaceError as e:
ex = e
except psycopg2.OperationalError as e:
if self._connection and self._connection.closed == 0:
raise e
ex = e
if ex:
self.disconnect()
max_attempts += 1
if max_attempts >= 3:
raise ex
sleep(5)
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('{} 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('--{}'.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: {}'.format(o))
options.append('--{}={}'.format(keys[0], o[keys[0]]))
else:
raise Exception('Unknown type of initdb option: {}'.format(o))
return options
def initialize(self): def initialize(self):
ret = subprocess.call(self._pg_ctl + ['initdb', '-o', '--encoding=UTF8']) == 0 self.set_state('initalizing new cluster')
ret and self.write_pg_hba() options = self.get_initdb_options()
pwfile = None
if self.superuser and 'username' not in self.superuser and 'password' in self.superuser:
(fd, pwfile) = tempfile.mkstemp()
os.write(fd, self.superuser['password'].encode())
os.close(fd)
options.append('--pwfile={}'.format(pwfile))
ret = subprocess.call(self._pg_ctl + ['initdb'] + ['-o', ' '.join(options)] if options else []) == 0
if pwfile:
os.remove(pwfile)
if ret:
self.write_pg_hba()
else:
self.set_state('initdb failed')
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) os.path.exists(self.trigger_file) and os.unlink(self.trigger_file)
def write_pgpass(self, record):
with open(self.pgpass, 'w') as f:
os.fchmod(f.fileno(), 0o600)
f.write('{host}:{port}:*:{user}:{password}\n'.format(**record))
env = os.environ.copy()
env['PGPASSFILE'] = self.pgpass
return env
def sync_from_leader(self, leader): def sync_from_leader(self, leader):
r = parseurl(leader.conn_url) r = parseurl(leader.conn_url)
pgpass = 'pgpass' env = self.write_pgpass(r)
with open(pgpass, 'w') as f: ret = self.create_replica(leader, env) == 0
os.fchmod(f.fileno(), 0o600) ret and self.delete_trigger_file()
f.write('{host}:{port}:*:{user}:{password}\n'.format(**r)) return ret
env = os.environ.copy()
env['PGPASSFILE'] = pgpass
return self.create_replica(r, env) == 0
@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('{}={}'.format(param, val) for param, val in sorted(conn.items()))
def create_replica(self, leader, 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 = leader.conn_url
# 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']
# 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(leader, 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):
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')
return 1
return ret return ret
def is_leader(self, check_only=False): def is_leader(self):
ret = not self.query('SELECT pg_is_in_recovery()').fetchone()[0] return not self.query('SELECT pg_is_in_recovery()').fetchone()[0]
if ret and self.is_promoted and not check_only:
self.delete_trigger_file()
self.is_promoted = False
return ret
def is_running(self): def is_running(self):
return subprocess.call(' '.join(self._pg_ctl) + ' status > /dev/null', shell=True) == 0 return subprocess.call(' '.join(self._pg_ctl) + ' status > /dev/null 2>&1', shell=True) == 0
def call_nowait(self, cb_name, is_leader=None): def call_nowait(self, cb_name):
""" pick a callback command and call it without waiting for it to finish """ """ pick a callback command and call it without waiting for it to finish """
if not self.callback or cb_name not in self.callback: if not self.callback or cb_name not in self.callback:
return False return False
cmd = self.callback[cb_name] cmd = self.callback[cb_name]
if is_leader is None:
try:
is_leader = self.is_leader(check_only=True)
except psycopg2.OperationalError as e:
logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e))
return False
try: try:
role = "master" if is_leader else "replica" subprocess.Popen(shlex.split(cmd) + [cb_name, self.role, self.scope])
subprocess.Popen(shlex.split(cmd) + [cb_name, role, self.scope])
except: except:
logger.exception('callback %s %s %s %s failed', cmd, cb_name, 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
def start(self): @property
if self.is_running(): def role(self):
self.load_replication_slots() with self._role_lock:
logger.error('Cannot start PostgreSQL because one is already running.') return self._role
return False
def set_role(self, value):
with self._role_lock:
self._role = value
@property
def state(self):
with self._state_lock:
return self._state
def set_state(self, value):
with self._state_lock:
self._state = value
def start(self, block_callbacks=False):
if self.is_running():
logger.error('Cannot start PostgreSQL because one is already running.')
return True
self.set_role('replica' if os.path.exists(self.recovery_conf) else 'master')
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)
if not block_callbacks:
self.set_state('starting')
ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0 ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0
ret and self.load_replication_slots()
self.set_state('running' if ret else 'start failed')
self.schedule_load_slots = ret and self.use_slots
self.save_configuration_files() self.save_configuration_files()
if ret and ACTION_ON_START in self.callback: # block_callbacks is used during restart to avoid
self.call_nowait(ACTION_ON_START) # running start/stop callbacks in addition to restart ones
ret and not block_callbacks and self.call_nowait(ACTION_ON_START)
return ret return ret
def stop(self): def checkpoint(self, connstring=None):
try: try:
is_leader = self.is_leader(check_only=True) connstring = connstring or 'postgres://{}/postgres'.format(self.local_address)
with psycopg2.connect(connstring) as conn:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute("SET statement_timeout = 0")
cur.execute('CHECKPOINT')
except: except:
is_leader = None logging.exception('Exception during CHECKPOINT')
pass
ret = subprocess.call(self._pg_ctl + ['stop', '-m', 'fast']) def stop(self, mode='fast', block_callbacks=False):
if ret == 0 and ACTION_ON_STOP in self.callback: # make sure we close all connections established against
self.call_nowait(ACTION_ON_STOP, is_leader=is_leader) # the former node, otherwise, we might get a stalled one
return ret == 0 # after kill -9, which would report incorrect data to
# patroni.
self.close_connection()
if not self.is_running():
if not block_callbacks:
self.set_state('stopped')
return True
if block_callbacks:
self.checkpoint()
else:
self.set_state('stopping')
ret = subprocess.call(self._pg_ctl + ['stop', '-m', mode]) == 0
# block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones
if not ret:
self.set_state('stop failed')
elif not block_callbacks:
self.set_state('stopped')
self.call_nowait(ACTION_ON_STOP)
return ret
def reload(self): def reload(self):
ret = subprocess.call(self._pg_ctl + ['reload']) ret = subprocess.call(self._pg_ctl + ['reload']) == 0
if ret == 0 and ACTION_ON_RELOAD in self.callback: ret and self.call_nowait(ACTION_ON_RELOAD)
self.call_nowait(ACTION_ON_RELOAD) return ret
return ret == 0
def restart(self): def restart(self):
try: self.set_state('restarting')
is_leader = self.is_leader(check_only=True) ret = self.stop(block_callbacks=True) and self.start(block_callbacks=True)
except: if ret:
is_leader = None self.call_nowait(ACTION_ON_RESTART)
pass else:
ret = subprocess.call(self._pg_ctl + ['restart', '-m', 'fast']) self.set_state('restart failed ({})'.format(self.state))
if ret == 0 and ACTION_ON_RESTART in self.callback: return ret
self.call_nowait(ACTION_ON_RESTART, is_leader=is_leader)
return ret == 0
def server_options(self): def server_options(self):
options = "--listen_addresses='{}' --port={}".format(self.listen_addresses, self.port) options = "--listen_addresses='{}' --port={}".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 += " --{}='{}'".format(setting, value)
return options return options
@@ -237,42 +406,15 @@ class Postgresql:
return False return False
return True return True
def is_healthiest_node(self, cluster): def check_replication_lag(self, last_leader_operation):
if self.is_leader(): return (last_leader_operation if last_leader_operation else 0) - self.xlog_position() <=\
return True self.config.get('maximum_lag_on_failover', 0)
if cluster.last_leader_operation - self.xlog_position() > self.config.get('maximum_lag_on_failover', 0):
return False
for member in cluster.members:
if member.name == self.name:
continue
try:
r = parseurl(member.conn_url)
member_conn = psycopg2.connect(**r)
member_conn.autocommit = True
member_cursor = member_conn.cursor()
member_cursor.execute(
"SELECT pg_is_in_recovery(), %s - pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')",
(self.xlog_position(),))
row = member_cursor.fetchone()
member_cursor.close()
member_conn.close()
logger.error([self.name, member.name, row])
if not row[0]:
logger.warning('Master (%s) is still alive', member.name)
return False
if row[1] < 0:
return False
except psycopg2.Error:
continue
return True
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('\nhost replication {username} {network} md5\n'.format(**self.replication))
for line in 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': if line.split()[0].strip() == 'hostssl' and self.server_parameters.get('ssl', 'off').lower() != 'on':
continue continue
f.write(line + '\n') f.write(line + '\n')
@@ -290,10 +432,7 @@ class Postgresql:
with open(self.recovery_conf, 'r') as f: with open(self.recovery_conf, 'r') as f:
for line in f: for line in f:
if line.startswith('primary_conninfo'): if line.startswith('primary_conninfo'):
if not pattern: return pattern and (pattern in line)
return False
return pattern in line
return not pattern return not pattern
def write_recovery_conf(self, leader): def write_recovery_conf(self, leader):
@@ -308,86 +447,285 @@ recovery_target_timeline = 'latest'
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)) f.write("{} = '{}'\n".format(name, value))
def follow_the_leader(self, leader): def rewind(self, leader):
if not self.check_recovery_conf(leader): # prepare pg_rewind connection
r = parseurl(leader.conn_url)
r.update(self.pg_rewind)
r['user'] = r['username']
env = self.write_pgpass(r)
pc = "user={user} host={host} port={port} dbname=postgres sslmode=prefer sslcompression=1".format(**r)
# first run a checkpoint on a promoted master in order
# to make it store the new timeline ([email protected])
self.checkpoint(pc)
logger.info("running pg_rewind from {}".format(pc))
pg_rewind = ['pg_rewind', '-D', self.data_dir, '--source-server', pc]
try:
ret = (subprocess.call(pg_rewind, env=env) == 0)
except:
ret = False
if ret:
self.write_recovery_conf(leader) self.write_recovery_conf(leader)
self.restart() return ret
if ACTION_ON_ROLE_CHANGE in self.callback:
self.call_nowait(ACTION_ON_ROLE_CHANGE) def controldata(self):
""" return the contents of pg_controldata, or non-True value if pg_controldata call failed """
result = {}
try:
data = subprocess.check_output(['pg_controldata', self.data_dir])
if data:
data = data.decode().splitlines()
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':')[1].strip() for l in data if l}
except subprocess.CalledProcessError:
logger.exception("Error when calling pg_controldata")
finally:
return result
def read_postmaster_opts(self):
""" returns the list of option names/values from postgres.opts, Empty dict if read failed or no file """
result = {}
try:
with open(os.path.join(self.data_dir, "postmaster.opts")) as f:
data = f.read()
opts = [opt.strip('"\n') for opt in data.split(' "')]
for opt in opts:
if '=' in opt and opt.startswith('--'):
name, val = opt.split('=', 1)
name = name.strip('-')
result[name] = val
except IOError:
logger.exception('Error when reading postmaster.opts')
finally:
return result
def single_user_mode(self, command=None, options={}):
""" 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]
for opt in sorted(options):
cmd.extend(['-c', '{0}={1}'.format(opt, options[opt])])
# need a database name to connect
cmd.append('postgres')
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
if p:
command and p.communicate('{}\n'.format(command))
p.stdin.close()
return p.wait()
return 1
def cleanup_archive_status(self):
status_dir = os.path.join(self.data_dir, 'pg_xlog', 'archive_status')
if os.path.isdir(status_dir):
for f in os.listdir(status_dir):
path = os.path.join(status_dir, f)
try:
if os.path.islink(path):
os.unlink(path)
elif os.path.isfile(path):
os.remove(path)
except:
logger.exception("Unable to remove {}".format(path))
def follow_the_leader(self, leader, recovery=False):
if not self.check_recovery_conf(leader) or 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
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:
os.path.isfile(f) and shutil.copy(f, f + '.backup')
except:
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) not os.path.isfile(f) and os.path.isfile(f + '.backup') and shutil.copy(f + '.backup', f)
except: except:
logger.exception('unable to restore configuration from WAL-E backup') logger.exception('unable to restore configuration files from backup')
def promote(self): def promote(self):
self.is_promoted = subprocess.call(self._pg_ctl + ['promote']) == 0 if self.role == 'master':
if self.is_promoted and ACTION_ON_ROLE_CHANGE in self.callback: return True
ret = subprocess.call(self._pg_ctl + ['promote']) == 0
if ret:
self.set_role('master')
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 self.is_promoted return ret
def demote(self, leader): def demote(self):
self.follow_the_leader(leader) self.follow_the_leader(None)
def create_or_update_role(self, name, password, options):
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_users(self):
if self.superuser: if 'username' in self.superuser:
if 'username' in self.superuser: self.create_or_update_role(self.superuser['username'], self.superuser['password'], '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()
THEN pg_last_xlog_replay_location() THEN pg_last_xlog_replay_location()
ELSE pg_current_xlog_location() ELSE pg_current_xlog_location()
END, '0/0')""").fetchone()[0] END, '0/0')::bigint""").fetchone()[0]
def load_replication_slots(self): def load_replication_slots(self):
if self.use_slots: if self.use_slots and self.schedule_load_slots:
cursor = self.query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'") cursor = self.query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'")
self.members = [r[0] for r in cursor] self.replication_slots = [r[0] for r in cursor]
self.schedule_load_slots = False
def sync_replication_slots(self, members): def sync_replication_slots(self, cluster):
if self.use_slots: if self.use_slots:
# drop unused slots try:
for slot in set(self.members) - set(members): self.load_replication_slots()
self.query("""SELECT pg_drop_replication_slot(%s) slots = [m.name for m in cluster.members if m.name != self.name] if self.role == 'master' else []
WHERE EXISTS(SELECT 1 FROM pg_replication_slots # drop unused slots
WHERE slot_name = %s)""", slot, slot) for slot in set(self.replication_slots) - set(slots):
self.query("""SELECT pg_drop_replication_slot(%s)
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot)
# create new slots # create new slots
for slot in set(members) - set(self.members): for slot in set(slots) - set(self.replication_slots):
self.query("""SELECT pg_create_physical_replication_slot(%s) self.query("""SELECT pg_create_physical_replication_slot(%s)
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot) WHERE slot_name = %s)""", slot, slot)
self.members = members self.replication_slots = slots
except:
def create_replication_slots(self, cluster): logger.exception('Exception when changing replication slots')
self.sync_replication_slots([m.name for m in cluster.members if m.name != self.name])
def drop_replication_slots(self):
self.sync_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):
"""
Initially bootstrap PostgreSQL, either by creating a data
directory with initdb, or by initalizing a replica from an
exiting leader. Failure in the first case always leads to
exception, since there is no point in continuing if initdb failed.
In the second case, however, a False is returned on failure, since
it is normal for the replica to retry a failed attempt to initialize
from the master.
"""
ret = False
if not current_leader:
ret = self.initialize() and self.start()
if ret:
self.create_replication_user()
self.create_connection_users()
else:
raise PostgresException("Could not bootstrap master PostgreSQL")
else:
if self.sync_from_leader(current_leader):
self.restore_configuration_files()
self.write_recovery_conf(current_leader)
ret = self.start()
return ret
def move_data_directory(self):
if os.path.isdir(self.data_dir) and not self.is_running():
try:
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)
os.rename(self.data_dir, new_name)
except:
logger.exception("Could not rename data directory %s", self.data_dir)
def remove_data_directory(self):
logger.info('Removing data directory: %s', self.data_dir)
try:
if os.path.islink(self.data_dir):
os.unlink(self.data_dir)
elif not os.path.exists(self.data_dir):
return
elif os.path.isfile(self.data_dir):
os.remove(self.data_dir)
elif os.path.isdir(self.data_dir):
shutil.rmtree(self.data_dir)
except:
logger.exception('Could not remove data directory %s', self.data_dir)
self.move_data_directory()
def basebackup(self, leader, 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 = leader.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
+5 -2
View File
@@ -1,4 +1,4 @@
#!/usr/bin/python #!/usr/bin/env python
import logging import logging
import requests import requests
@@ -65,8 +65,11 @@ class AWSConnection:
return self._tag_ebs(new_role) and ret return self._tag_ebs(new_role) and ret
if __name__ == '__main__': def main():
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/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]))
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/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
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
class WALERestore(object):
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb, threshold_pct, use_iam):
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.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 = '{}/{}'.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 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():
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)
args = parser.parse_args()
# retry cloning in a loop
for retry 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)
ret = restore.run()
if ret == 0:
break
sys.exit(ret)
if __name__ == '__main__':
main()
+13 -10
View File
@@ -6,8 +6,9 @@ import signal
import sys import sys
import time import time
from patroni.exceptions import DCSError from patroni.exceptions import PatroniException
ignore_sigterm = False
interrupted_sleep = False interrupted_sleep = False
reap_children = False reap_children = False
@@ -35,6 +36,8 @@ 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')
>>> calculate_ttl('2015-06-10T12:56:30.552539016Z') < 0
True
""" """
if not expiration: if not expiration:
return None return None
@@ -46,7 +49,10 @@ def calculate_ttl(expiration):
def sigterm_handler(signo, stack_frame): def sigterm_handler(signo, stack_frame):
sys.exit() global ignore_sigterm
if not ignore_sigterm:
ignore_sigterm = True
sys.exit()
def sigchld_handler(signo, stack_frame): def sigchld_handler(signo, stack_frame):
@@ -86,7 +92,7 @@ def reap_children():
reap_children = False reap_children = False
class RetryFailedError(DCSError): 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."""
@@ -96,7 +102,7 @@ class Retry:
"""Helper for retrying a method in the face of retry-able exceptions""" """Helper for retrying a method in the face of retry-able exceptions"""
def __init__(self, max_tries=1, delay=0.1, backoff=2, max_jitter=0.8, max_delay=3600, def __init__(self, max_tries=1, delay=0.1, backoff=2, max_jitter=0.8, max_delay=3600,
sleep_func=time.sleep, deadline=None, retry_exceptions=DCSError): sleep_func=sleep, deadline=None, retry_exceptions=PatroniException):
"""Create a :class:`Retry` instance for retrying function calls """Create a :class:`Retry` instance for retrying function calls
:param max_tries: How many times to retry the command. -1 means infinite tries. :param max_tries: How many times to retry the command. -1 means infinite tries.
@@ -150,13 +156,10 @@ class Retry:
if self._attempts == self.max_tries: if self._attempts == self.max_tries:
raise RetryFailedError("Too many retry attempts") raise RetryFailedError("Too many retry attempts")
self._attempts += 1 self._attempts += 1
sleeptime = self._cur_delay + ( sleeptime = self._cur_delay + (random.randint(0, self.max_jitter) / 100.0)
random.randint(0, self.max_jitter) / 100.0)
if self._cur_stoptime is not None and \ if self._cur_stoptime is not None and time.time() + sleeptime >= self._cur_stoptime:
time.time() + sleeptime >= self._cur_stoptime:
raise RetryFailedError("Exceeded retry deadline") raise RetryFailedError("Exceeded retry deadline")
else: else:
self.sleep_func(sleeptime) self.sleep_func(sleeptime)
self._cur_delay = min(self._cur_delay * self.backoff, self._cur_delay = min(self._cur_delay * self.backoff, self.max_delay)
self.max_delay)
+1 -1
View File
@@ -1 +1 @@
__version__ = '0.2' __version__ = '0.76'
+109 -49
View File
@@ -5,7 +5,8 @@ import time
from kazoo.client import KazooClient, KazooState from kazoo.client import KazooClient, KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError from kazoo.exceptions import NoNodeError, NodeExistsError
from patroni.dcs import AbstractDCS, Cluster, DCSError, Leader, Member, parse_connection_string from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import sleep from patroni.utils import sleep
from requests.exceptions import RequestException from requests.exceptions import RequestException
@@ -90,11 +91,9 @@ class ZooKeeper(AbstractDCS):
'max_tries': -1}, 'max_tries': -1},
connection_retry={'max_delay': 1, 'max_tries': -1}) connection_retry={'max_delay': 1, 'max_tries': -1})
self.client.add_listener(self.session_listener) self.client.add_listener(self.session_listener)
self.cluster_event = self.client.handler.event_object()
self._my_member_data = None
self.fetch_cluster = True self.fetch_cluster = True
self.members = []
self.leader = None
self.last_leader_operation = 0 self.last_leader_operation = 0
self.client.start(None) self.client.start(None)
@@ -105,54 +104,72 @@ class ZooKeeper(AbstractDCS):
def cluster_watcher(self, event): def cluster_watcher(self, event):
self.fetch_cluster = True self.fetch_cluster = True
self.cluster_event.set() self.event.set()
def get_node(self, name, watch=None): def get_node(self, key, watch=None):
try: try:
return self.client.get(self.client_path(name), watch) ret = self.client.get(key, watch)
return (ret[0].decode('utf-8'), ret[1])
except NoNodeError: except NoNodeError:
pass return None
except:
logger.exception('get_node')
return None
@staticmethod @staticmethod
def member(name, value, znode): def member(name, value, znode):
conn_url, api_url = parse_connection_string(value) return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
return Member(znode.mzxid, name, conn_url, api_url, None, None)
def get_children(self, key, watch=None):
try:
return self.client.get_children(key, watch)
except NoNodeError:
return []
def load_members(self): def load_members(self):
members = [] members = []
for member in self.client.get_children(self.client_path('/members'), self.cluster_watcher): for member in self.get_children(self.members_path, self.cluster_watcher):
data = self.get_node('/members/' + member) data = self.get_node(self.members_path + member)
if data is not None: if data is not None:
members.append(self.member(member, *data)) members.append(self.member(member, *data))
return members return members
def _inner_load_cluster(self): def _inner_load_cluster(self):
self.cluster_event.clear() self.fetch_cluster = False
leader = self.get_node('/leader', self.cluster_watcher) self.event.clear()
self.members = self.load_members() nodes = set(self.get_children(self.client_path(''), self.cluster_watcher))
if not nodes:
self.fetch_cluster = True
# get initialize flag
initialize = (self.get_node(self.initialize_path) or [None])[0] if self._INITIALIZE in nodes else None
# get list of members
members = self.load_members() if self._MEMBERS[:-1] in nodes else []
# get leader
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
if leader: 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.client_path('/leader')) self.client.delete(self.leader_path)
leader = None leader = None
if leader: if leader:
member = Member(-1, leader[0], None, None, None, None) member = Member(-1, leader[0], None, {})
member = ([m for m in self.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].mzxid, None, None, member) leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self.fetch_cluster = member.index == -1 self.fetch_cluster = member.index == -1
self.leader = leader # failover key
if self.fetch_cluster: failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
last_leader_operation = self.get_node('/optime/leader') if failover:
if last_leader_operation: failover = Failover.from_node(failover[1].version, failover[0])
self.last_leader_operation = int(last_leader_operation[0])
def get_cluster(self): # get last leader operation
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._cluster = Cluster(initialize, leader, self.last_leader_operation, members, failover)
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)
@@ -163,48 +180,77 @@ class ZooKeeper(AbstractDCS):
logger.exception('get_cluster') logger.exception('get_cluster')
self.session_listener(KazooState.LOST) self.session_listener(KazooState.LOST)
raise ZooKeeperError('ZooKeeper in not responding properly') raise ZooKeeperError('ZooKeeper in not responding properly')
return Cluster(True, self.leader, self.last_leader_operation, self.members)
def _create(self, path, value, **kwargs): def _create(self, path, value, **kwargs):
try: try:
self.client.retry(self.client.create, self.client_path(path), value, **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('/leader', 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') ret or logger.info('Could not take out TTL lock')
return ret return ret
def race(self, path): def set_failover_value(self, value, index=None):
return self._create(path, self._name, makepath=True)
def touch_member(self, connection_string, ttl=None):
for m in self.members:
if m.name == self._name:
return True
path = self.client_path('/members/' + self._name)
try: try:
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True) self.client.retry(self.client.set, self.failover_path, value.encode('utf-8'), version=index or -1)
return True
except NoNodeError:
return value == '' or (not index and self._create(self.failover_path, value))
except:
logging.exception('set_failover_value')
return False
def initialize(self, create_new=True, sysid=""):
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):
cluster = self.cluster
me = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
path = self.member_path
data = data.encode('utf-8')
create = not me
if me and self.client.client_id is not None and me.session != self.client.client_id[0]:
try:
self.client.retry(self.client.delete, path)
except NoNodeError:
pass
except:
return False
create = True
if not create and data == self._my_member_data:
return True
try:
if create:
self.client.retry(self.client.create, path, data, makepath=True, ephemeral=True)
else:
self.client.retry(self.client.set, path, data)
self._my_member_data = data
return True return True
except NodeExistsError: except NodeExistsError:
try: try:
self.client.retry(self.client.delete, path) self.client.retry(self.client.set, path, data)
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True) self._my_member_data = data
return True return True
except: except:
logger.exception('touch_member') logger.exception('touch_member')
except:
logger.exception('touch_member')
return False return False
def take_leader(self): def take_leader(self):
return self.attempt_to_acquire_leader() return self.attempt_to_acquire_leader()
def update_leader(self, state_handler): def write_leader_optime(self, last_operation):
last_operation = state_handler.last_operation() 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.client_path('/optime/leader') 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:
@@ -214,13 +260,27 @@ class ZooKeeper(AbstractDCS):
logger.exception('Failed to create %s', path) logger.exception('Failed to create %s', path)
except: except:
logger.exception('Failed to update %s', path) logger.exception('Failed to update %s', path)
def update_leader(self):
return True return True
def delete_leader(self): def delete_leader(self):
if isinstance(self.leader, Leader) and self.leader.name == self._name: self.client.restart()
self.client.delete(self.client_path('/leader')) self._my_member_data = None
return True
def _cancel_initialization(self):
node = self.get_node(self.initialize_path)
if node:
self.client.delete(self.initialize_path, version=node[1].version)
def cancel_initialization(self):
try:
self.client.retry(self._cancel_initialization)
except:
logger.exception("Unable to delete initialize key")
def watch(self, timeout): def watch(self, timeout):
self.cluster_event.wait(timeout) if super(ZooKeeper, self).watch(timeout):
if self.cluster_event.isSet():
self.fetch_cluster = True self.fetch_cluster = True
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()
+47 -7
View File
@@ -4,6 +4,9 @@ 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'
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
etcd: etcd:
scope: *scope scope: *scope
ttl: *ttl ttl: *ttl
@@ -31,6 +34,27 @@ 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:
username: postgres
password: zalando
pg_hba: pg_hba:
- 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
@@ -39,23 +63,39 @@ postgresql:
password: rep-pass password: rep-pass
network: 127.0.0.1/32 network: 127.0.0.1/32
superuser: superuser:
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: scripts/restore.py #create_replica_method: wal_e, basebackup
#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: 5
wal_keep_segments: 8 wal_keep_segments: 8
archive_timeout: 1800s archive_timeout: 1800s
max_replication_slots: 5 max_replication_slots: 5
hot_standby: "on" hot_standby: "on"
wal_log_hints: "on"
tags:
nofailover: False
noloadbalance: False
clonefrom: False
replicatefrom: 127.0.0.1
+47 -7
View File
@@ -4,6 +4,9 @@ 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'
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
etcd: etcd:
scope: *scope scope: *scope
ttl: *ttl ttl: *ttl
@@ -31,6 +34,27 @@ 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:
username: postgres
password: zalando
pg_hba: pg_hba:
- 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
@@ -39,23 +63,39 @@ postgresql:
password: rep-pass password: rep-pass
network: 127.0.0.1/32 network: 127.0.0.1/32
superuser: superuser:
user: 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: 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: 5
wal_keep_segments: 8 wal_keep_segments: 8
archive_timeout: 1800s archive_timeout: 1800s
max_replication_slots: 5 max_replication_slots: 5
hot_standby: "on" hot_standby: "on"
wal_log_hints: "on"
tags:
nofailover: False
noloadbalance: False
clonefrom: False
replicatefrom: 127.0.0.1
+1 -1
View File
@@ -27,5 +27,5 @@ git push
python3 setup.py sdist bdist_wheel upload python3 setup.py sdist bdist_wheel upload
git tag ${version} git tag v${version}
git push --tags git push --tags
+3 -1
View File
@@ -1,9 +1,11 @@
boto boto
dnspython dnspython
mock mock
psycopg2 psycopg2>=2.6.1
PyYAML PyYAML
requests requests
six >= 1.7 six >= 1.7
kazoo>=2.2.1 kazoo>=2.2.1
python-etcd>=0.4.1 python-etcd>=0.4.1
click>=4.1
prettytable>=0.7
+3 -1
View File
@@ -1,9 +1,11 @@
boto boto
mock mock
dnspython3 dnspython3
psycopg2 psycopg2>=2.6.1
PyYAML PyYAML
requests requests
six six
kazoo>=2.2.1 kazoo>=2.2.1
python-etcd>=0.4.1 python-etcd>=0.4.1
click>=4.1
prettytable>=0.7
+5 -3
View File
@@ -32,7 +32,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'
@@ -56,7 +56,10 @@ CLASSIFIERS = [
'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):
@@ -89,7 +92,6 @@ class PyTest(TestCommand):
params = {'args': self.test_args} params = {'args': self.test_args}
if self.cov: if self.cov:
params['args'] += self.cov params['args'] += self.cov
params['plugins'] = ['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, '-s', '-vv']
+131 -16
View File
@@ -1,16 +1,21 @@
import psycopg2 import psycopg2
import unittest import unittest
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 test_postgresql import psycopg2_connect from six.moves import BaseHTTPServer
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler
import socket
from test_postgresql import psycopg2_connect, MockCursor
def throws(*args, **kwargs): class MockPostgresql(Mock):
raise psycopg2.OperationalError()
name = 'test'
class MockPostgresql: state = 'running'
role = 'master'
def connection(self): def connection(self):
return psycopg2_connect() return psycopg2_connect()
@@ -19,10 +24,33 @@ class MockPostgresql:
return True return True
class MockHa(Mock):
dcs = Mock()
state_handler = MockPostgresql()
def schedule_restart(self):
return 'restart'
def schedule_reinitialize(self):
return 'reinitialize'
def restart(self):
return (True, '')
def restart_scheduled(self):
return False
def fetch_nodes_statuses(self, members):
return [[None, True, None, None, {}]]
class MockPatroni: class MockPatroni:
def __init__(self): postgresql = MockPostgresql()
self.postgresql = MockPostgresql() ha = MockHa()
dcs = Mock()
tags = {}
class MockRequest: class MockRequest:
@@ -36,18 +64,105 @@ class MockRequest:
class MockRestApiServer(RestApiServer): class MockRestApiServer(RestApiServer):
def __init__(self, Handler, path, *args): def __init__(self, Handler, path):
self.patroni = MockPatroni() self.socket = 0
if len(args) > 0: BaseHTTPServer.HTTPServer.__init__ = Mock()
self.query = args[0] MockRestApiServer._BaseServer__is_shut_down = Mock()
MockRestApiServer._BaseServer__shutdown_request = True
config = {'listen': '127.0.0.1:8008', 'auth': 'test:test', 'certfile': 'dumb'}
super(MockRestApiServer, self).__init__(MockPatroni(), config)
Handler(MockRequest(path), ('0.0.0.0', 8080), self) Handler(MockRequest(path), ('0.0.0.0', 8080), self)
@patch('ssl.wrap_socket', Mock(return_value=0))
class TestRestApiHandler(unittest.TestCase): class TestRestApiHandler(unittest.TestCase):
def __init__(self, method_name='runTest'):
super(TestRestApiHandler, self).__init__(method_name)
def test_do_GET(self): def test_do_GET(self):
MockRestApiServer(RestApiHandler, b'GET /') MockRestApiServer(RestApiHandler, b'GET /replica')
MockRestApiServer(RestApiHandler, b'GET /', throws) with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={})):
MockRestApiServer(RestApiHandler, b'GET /replica')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, b'GET /replica')
MockRestApiServer(RestApiHandler, b'GET /master')
MockPatroni.dcs.cluster.leader.name = MockPostgresql.name
MockRestApiServer(RestApiHandler, b'GET /replica')
MockPatroni.dcs.cluster = None
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, b'GET /master')
with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, b'GET /master')
MockRestApiServer(RestApiHandler, b'GET /master')
def test_do_OPTIONS(self):
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.side_effect = lambda x: 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):
MockRestApiServer(RestApiHandler, b'GET /patroni')
def test_basicauth(self):
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0')
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0\nAuthorization:')
def test_do_POST_restart(self):
request = b'POST /restart HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'restart', Mock(side_effect=Exception)):
MockRestApiServer(RestApiHandler, request)
@patch.object(MockHa, 'dcs')
def test_do_POST_reinitialize(self, dcs):
cluster = dcs.get_cluster.return_value
request = b'POST /reinitialize HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
MockRestApiServer(RestApiHandler, request)
cluster.is_unlocked.return_value = False
MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'schedule_reinitialize', Mock(return_value=None)):
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'test'
MockRestApiServer(RestApiHandler, request)
@patch('time.sleep', Mock())
def test_RestApiServer_query(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)):
MockRestApiServer(RestApiHandler, b'GET /patroni')
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)):
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: 25\n\n{"leader": "postgresql1"}'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql1'
MockRestApiServer(RestApiHandler, request)
cluster.members = [Member(0, 'postgresql0', 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 = '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)
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\n' +\
b'Content-Length: 50\n\n{"leader": "postgresql1", "member": "postgresql2"}'
MockRestApiServer(RestApiHandler, request)
+18
View File
@@ -0,0 +1,18 @@
import unittest
from mock import Mock, patch
from patroni.async_executor import AsyncExecutor
from threading import Thread
class TestAsyncExecutor(unittest.TestCase):
def setUp(self):
self.a = AsyncExecutor()
@patch.object(Thread, 'start', Mock())
def test_run_async(self):
self.a.run_async(Mock(return_value=True))
def test_run(self):
self.a.run(Mock(side_effect=Exception()))
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import pytest
import unittest
import psycopg2
import requests
import patroni.exceptions
import etcd
from mock import patch, Mock
from click.testing import CliRunner
from patroni.ctl import ctl, members, store_config, load_config, output_members, post_patroni, get_dcs, \
wait_for_leader, get_all_members, get_any_member, get_cursor, query_member, configure
from patroni.ha import Ha
from patroni.etcd import Etcd, Client
from test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
get_cluster_initialized_with_only_leader, MockPostgresql, MockPatroni, run_async, \
get_cluster_not_initialized_without_leader
from test_etcd import etcd_read, etcd_write, requests_get, MockResponse
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')
@patch('patroni.ctl.load_config', Mock(return_value={'dcs': {'scheme': 'etcd', 'hostname': 'localhost', 'port': 4001}}))
class TestCtl(unittest.TestCase):
@patch.object(Client, 'machines')
def setUp(self, mock_machines):
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.p = MockPostgresql()
self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'})
self.e.client.read = etcd_read
self.e.client.write = etcd_write
self.e.client.delete = Mock(side_effect=etcd.EtcdException())
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()
@patch('psycopg2.connect', psycopg2_connect)
def test_get_cursor(self):
c = get_cursor(get_cluster_initialized_without_leader(), role='master')
assert c is None
c = get_cursor(get_cluster_initialized_with_leader(), role='master')
assert c is not None
c = get_cursor(get_cluster_initialized_with_leader(), role='replica')
# # MockCursor returns pg_is_in_recovery as false
assert c is None
c = get_cursor(get_cluster_initialized_with_leader(), role='any')
assert c is not None
def test_output_members(self):
cluster = get_cluster_initialized_with_leader()
output_members(cluster, name='abc', format='pretty')
output_members(cluster, name='abc', format='json')
output_members(cluster, name='abc', format='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.etcd.Etcd.set_failover_value', Mock(return_value=None))
@patch('patroni.ctl.wait_for_leader', Mock(return_value=get_cluster_initialized_with_leader()))
@patch('requests.get', requests_get)
@patch('requests.post', requests_get)
@patch('patroni.ctl.post_patroni', Mock(return_value=MockResponse()))
def test_failover(self):
runner = CliRunner()
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())):
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
other
y''')
assert 'Failing over to new leader' in result.output
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
other
N''')
assert 'Aborting failover' in str(result.exception)
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
leader
y''')
assert 'target and source are the same' in str(result.exception)
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
Reality
y''')
assert 'Reality does not exist' in str(result.exception)
result = runner.invoke(ctl, ['failover', 'dummy', '--force'])
assert 'Failing over to new leader' in result.output
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='dummy')
assert 'is not the leader of cluster' in str(result.exception)
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_only_leader())):
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
other
y''')
assert 'No candidates found to failover to' in str(result.exception)
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())):
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
other
y''')
assert 'This cluster has no master' in str(result.exception)
with patch('patroni.ctl.post_patroni', Mock(side_effect=Exception())):
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
other
y''')
assert 'falling back to DCS' in result.output
assert 'Failover failed' in result.output
mocked = Mock()
mocked.return_value.status_code = 500
with patch('patroni.ctl.post_patroni', Mock(return_value=mocked)):
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
other
y''')
assert 'Failover failed, details' in result.output
# with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())):
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='nonsense')
# assert 'is not the leader of cluster' in str(result.exception)
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8', '--master', 'nonsense'])
# assert 'is not the leader of cluster' in str(result.exception)
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nn')
# assert 'Aborting failover' in str(result.exception)
# with patch('patroni.ctl.wait_for_leader', Mock(return_value = get_cluster_initialized_with_leader())):
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nY')
# assert 'master did not change after' in result.output
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nY')
# assert 'Failover failed' in result.output
def test_(self):
self.assertRaises(patroni.exceptions.PatroniCtlException, get_dcs, {'scheme': 'dummy'}, 'dummy')
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
def test_query(self):
runner = CliRunner()
with patch('patroni.ctl.get_dcs', Mock(return_value=self.e)):
result = runner.invoke(ctl, [
'query',
'alpha',
'--member',
'abc',
'--role',
'master',
])
assert 'mutually exclusive' in str(result.exception)
with runner.isolated_filesystem():
dummy_file = open('dummy', 'w')
dummy_file.write('SELECT 1')
dummy_file.close()
result = runner.invoke(ctl, [
'query',
'alpha',
'--file',
'dummy',
'--command',
'dummy',
])
assert 'mutually exclusive' in str(result.exception)
result = runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy'])
os.remove('dummy')
result = runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1'])
assert 'mock column' in result.output
@patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor()))
def test_query_member(self):
rows = query_member(None, None, None, 'master', 'SELECT pg_is_in_recovery()')
assert 'False' in str(rows)
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
assert rows == (None, None)
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
rows = query_member(None, None, None, None, 'SELECT pg_is_in_recovery()')
assert 'No connection to' in str(rows)
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
assert 'No connection to' in str(rows)
with patch('patroni.ctl.get_cursor', Mock(side_effect=psycopg2.OperationalError('bla'))):
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
with patch('test_postgresql.MockCursor.execute', Mock(side_effect=psycopg2.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):
runner = CliRunner()
with patch('patroni.ctl.get_dcs', Mock(return_value=self.e)):
result = runner.invoke(ctl, ['dsn', 'alpha', '--dcs', '8.8.8.8'])
assert 'host=127.0.0.1 port=5435' in result.output
result = runner.invoke(ctl, [
'dsn',
'alpha',
'--role',
'master',
'--member',
'dummy',
])
assert 'mutually exclusive' in str(result.exception)
result = runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
assert 'Can not find' in str(result.exception)
# result = runner.invoke(ctl, ['dsn', 'alpha', '--dcs', '8.8.8.8', '--role', 'replica'])
# assert 'host=127.0.0.1 port=5436' in result.output
@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.get', requests_get)
@patch('requests.post', requests_get)
def test_restart_reinit(self):
runner = CliRunner()
result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='y')
result = runner.invoke(ctl, ['reinit', 'alpha', '--dcs', '8.8.8.8'], input='y')
result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='N')
result = runner.invoke(ctl, [
'restart',
'alpha',
'--dcs',
'8.8.8.8',
'dummy',
'--any',
], input='y')
assert 'not a member' in str(result.exception)
with patch('requests.post', Mock(return_value=MockResponse())):
result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='y')
@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_remove(self):
runner = CliRunner()
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='alpha\nslave')
assert 'Please confirm' in result.output
assert 'You are about to remove all' in result.output
assert 'You did not exactly type' in str(result.exception)
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='''alpha
Yes I am aware
slave''')
assert 'You did not specify the current master of the cluster' in str(result.exception)
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='beta\nleader')
assert 'Cluster names specified do not match' in str(result.exception)
with patch('patroni.etcd.Etcd.get_cluster', get_cluster_initialized_with_leader):
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'],
input='''alpha
Yes I am aware
leader''')
assert 'object has no attribute' in str(result.exception)
with patch('patroni.ctl.get_dcs', Mock(return_value=Mock())):
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'],
input='''alpha
Yes I am aware
leader''')
assert 'We have not implemented this for DCS of type' in str(result.exception)
@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):
dcs = self.e
self.assertRaises(patroni.exceptions.PatroniCtlException, wait_for_leader, dcs, 0)
cluster = wait_for_leader(dcs=dcs, timeout=2)
assert cluster.leader.member.name == 'leader'
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):
runner = CliRunner()
runner.invoke(ctl, ['list'])
result = runner.invoke(ctl, ['--help'])
assert 'Usage:' in result.output
def test_get_any_member(self):
m = get_any_member(get_cluster_initialized_without_leader(), role='master')
assert m is None
m = get_any_member(get_cluster_initialized_with_leader(), role='master')
assert m.name == 'leader'
def test_get_all_members(self):
r = list(get_all_members(get_cluster_initialized_without_leader(), role='master'))
assert len(r) == 0
r = list(get_all_members(get_cluster_initialized_with_leader(), role='master'))
assert len(r) == 1
assert r[0].name == 'leader'
r = list(get_all_members(get_cluster_initialized_with_leader(), role='replica'))
assert len(r) == 1
assert r[0].name == 'other'
r = list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))
assert len(r) == 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))
@patch('requests.get', requests_get)
@patch('requests.post', requests_get)
def test_members(self):
runner = CliRunner()
result = runner.invoke(members, ['alpha'])
assert result.exit_code == 0
def test_configure(self):
runner = CliRunner()
result = runner.invoke(configure, [
'--dcs',
'abc',
'-c',
'dummy',
'-n',
'bla',
])
assert result.exit_code == 0
+62 -72
View File
@@ -1,17 +1,14 @@
import datetime
import dns.resolver
import etcd import etcd
import json import json
import requests import requests
import urllib3 import urllib3
import socket import socket
import time
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, Member from patroni.dcs import Cluster, DCSError, Leader
from patroni.etcd import Client, Etcd from patroni.etcd import Client, Etcd, EtcdError
class MockResponse: class MockResponse:
@@ -20,6 +17,7 @@ class MockResponse:
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)
@@ -40,11 +38,10 @@ class MockResponse:
return '' return ''
class MockPostgresql: class MockPostgresql(Mock):
name = ''
def last_operation(self): def last_operation(self):
return 0 return '0'
def requests_get(url, **kwargs): def requests_get(url, **kwargs):
@@ -53,6 +50,8 @@ def requests_get(url, **kwargs):
response = MockResponse() response = MockResponse()
if url.startswith('http://local'): if url.startswith('http://local'):
raise requests.exceptions.RequestException() raise requests.exceptions.RequestException()
elif ':8011/patroni' in url:
response.content = '{"role": "replica", "xlog": {"replayed_location": 0}, "tags": {}}'
elif url.endswith('/members'): elif url.endswith('/members'):
if url.startswith('http://error'): if url.startswith('http://error'):
response.content = '[{}]' response.content = '[{}]'
@@ -67,11 +66,11 @@ def requests_get(url, **kwargs):
def etcd_watch(key, index=None, timeout=None, recursive=None): def etcd_watch(key, index=None, timeout=None, recursive=None):
if timeout == 1: if timeout == 2.0:
raise urllib3.exceptions.TimeoutError raise urllib3.exceptions.TimeoutError
elif timeout == 5: elif timeout == 5.0:
return etcd.EtcdResult('delete', {}) return etcd.EtcdResult('delete', {})
elif timeout == 10: elif timeout == 10.0:
raise etcd.EtcdException raise etcd.EtcdException
elif index == 20729: elif index == 20729:
return etcd.EtcdResult('set', {'value': 'postgresql1', 'modifiedIndex': index + 1}) return etcd.EtcdResult('set', {'value': 'postgresql1', 'modifiedIndex': index + 1})
@@ -82,23 +81,21 @@ def etcd_watch(key, index=None, timeout=None, recursive=None):
def etcd_write(key, value, **kwargs): def etcd_write(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 == '/service/test/leader' or key == '/patroni/test/leader':
if kwargs.get('prevValue', None) == 'foo' or not kwargs.get('prevExist', True): if kwargs.get('prevValue', None) == 'foo' or not kwargs.get('prevExist', True):
return True return True
raise etcd.EtcdException raise etcd.EtcdException
def etcd_delete(key, **kwargs):
raise etcd.EtcdException
def etcd_read(key, **kwargs): def etcd_read(key, **kwargs):
if key == '/service/noleader': if key == '/service/noleader/':
raise DCSError('noleader') raise DCSError('noleader')
elif key == '/service/nocluster': elif key == '/service/nocluster/':
raise etcd.EtcdKeyNotFound raise etcd.EtcdKeyNotFound
response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [ response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [
{"key": "/service/batman5/failover", "value": "",
"modifiedIndex": 1582, "createdIndex": 1582},
{"key": "/service/batman5/initialize", "value": "postgresql0", {"key": "/service/batman5/initialize", "value": "postgresql0",
"modifiedIndex": 1582, "createdIndex": 1582}, "modifiedIndex": 1582, "createdIndex": 1582},
{"key": "/service/batman5/leader", "value": "postgresql1", {"key": "/service/batman5/leader", "value": "postgresql1",
@@ -110,31 +107,23 @@ 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}}
return etcd.EtcdResult(**response) return etcd.EtcdResult(**response)
def time_sleep(_):
pass
class SleepException(Exception): class SleepException(Exception):
pass pass
def time_sleep_exception(_):
raise SleepException()
class MockSRV: class MockSRV:
port = 2380 port = 2380
target = '127.0.0.1' target = '127.0.0.1'
@@ -151,7 +140,7 @@ def dns_query(name, type):
def socket_getaddrinfo(*args): def socket_getaddrinfo(*args):
if args[0] == 'ok': if args[0] == 'ok':
return [(2, 1, 6, '', ('127.0.0.1', 2379)), (2, 1, 6, '', ('127.0.0.1', 2379))] return [(2, 1, 6, '', ('127.0.0.1', 2379)), (2, 1, 6, '', ('127.0.0.1', 2379))]
raise socket.error() raise socket.error
def http_request(method, url, **kwargs): def http_request(method, url, **kwargs):
@@ -160,28 +149,14 @@ def http_request(method, url, **kwargs):
raise socket.error raise socket.error
class TestMember(unittest.TestCase): @patch('dns.resolver.query', dns_query)
@patch('socket.getaddrinfo', socket_getaddrinfo)
def __init__(self, method_name='runTest'): @patch('requests.get', requests_get)
super(TestMember, self).__init__(method_name)
def test_real_ttl(self):
now = datetime.datetime.utcnow()
member = Member(0, 'a', 'b', 'c', (now + datetime.timedelta(seconds=2)).strftime('%Y-%m-%dT%H:%M:%S.%fZ'), None)
self.assertLess(member.real_ttl(), 2)
self.assertEquals(Member(0, 'a', 'b', 'c', '', None).real_ttl(), -1)
class TestClient(unittest.TestCase): class TestClient(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch('dns.resolver.query', dns_query)
self.setUp = self.set_up @patch('requests.get', requests_get)
super(TestClient, self).__init__(method_name) def setUp(self):
def set_up(self):
socket.getaddrinfo = socket_getaddrinfo
requests.get = requests_get
dns.resolver.query = dns_query
with patch.object(etcd.Client, 'machines') as mock_machines: with patch.object(etcd.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.client = Client({'discovery_srv': 'test'}) self.client = Client({'discovery_srv': 'test'})
@@ -191,6 +166,11 @@ class TestClient(unittest.TestCase):
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.client.api_execute('/', 'GET')
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)
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'), [])
@@ -206,11 +186,11 @@ class TestClient(unittest.TestCase):
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response) 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 = lambda e: [('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')
def test__get_machines_cache_from_dns(self): def test__get_machines_cache_from_dns(self):
self.client._get_machines_cache_from_dns('ok:2379') self.client._get_machines_cache_from_dns('error:2379')
def test__load_machines_cache(self): def test__load_machines_cache(self):
self.client._config = {} self.client._config = {}
@@ -219,25 +199,26 @@ class TestClient(unittest.TestCase):
self.assertRaises(etcd.EtcdException, self.client._load_machines_cache) self.assertRaises(etcd.EtcdException, self.client._load_machines_cache)
@patch('requests.get', requests_get)
class TestEtcd(unittest.TestCase): class TestEtcd(unittest.TestCase):
def __init__(self, method_name='runTest'): def setUp(self):
self.setUp = self.set_up
super(TestEtcd, self).__init__(method_name)
def set_up(self):
time.sleep = time_sleep
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.write = etcd_write
self.etcd.client.read = etcd_read self.etcd.client.read = etcd_read
self.etcd.client.delete = Mock(side_effect=etcd.EtcdException())
def test_base_path(self):
self.assertEquals(self.etcd._base_path, '/patroni/test')
@patch('dns.resolver.query', dns_query)
def test_get_etcd_client(self): def test_get_etcd_client(self):
time.sleep = time_sleep_exception
with patch.object(etcd.Client, 'machines') as mock_machines: with patch.object(etcd.Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(side_effect=etcd.EtcdException) mock_machines.__get__ = Mock(side_effect=etcd.EtcdException)
self.assertRaises(SleepException, self.etcd.get_etcd_client, {'discovery_srv': 'test'}) with patch('time.sleep', Mock(side_effect=SleepException())):
self.assertRaises(SleepException, self.etcd.get_etcd_client, {'discovery_srv': 'test'})
def test_get_cluster(self): def test_get_cluster(self):
self.assertIsInstance(self.etcd.get_cluster(), Cluster) self.assertIsInstance(self.etcd.get_cluster(), Cluster)
@@ -257,27 +238,36 @@ class TestEtcd(unittest.TestCase):
def test_take_leader(self): def test_take_leader(self):
self.assertFalse(self.etcd.take_leader()) self.assertFalse(self.etcd.take_leader())
def testattempt_to_acquire_leader(self): def test_attempt_to_acquire_leader(self):
self.etcd._base_path = '/service/exists' self.etcd._base_path = '/service/exists'
self.assertFalse(self.etcd.attempt_to_acquire_leader()) self.assertFalse(self.etcd.attempt_to_acquire_leader())
self.etcd._base_path = '/service/failed' self.etcd._base_path = '/service/failed'
self.assertFalse(self.etcd.attempt_to_acquire_leader()) self.assertFalse(self.etcd.attempt_to_acquire_leader())
def test_update_leader(self): def test_write_leader_optime(self):
self.assertTrue(self.etcd.update_leader(MockPostgresql())) self.etcd.write_leader_optime('0')
def test_race(self): def test_update_leader(self):
self.assertFalse(self.etcd.race('')) self.assertTrue(self.etcd.update_leader())
def test_initialize(self):
self.assertFalse(self.etcd.initialize())
def test_cancel_initializion(self):
self.assertFalse(self.etcd.cancel_initialization())
def test_delete_leader(self): def test_delete_leader(self):
self.etcd.client.delete = etcd_delete
self.assertFalse(self.etcd.delete_leader()) self.assertFalse(self.etcd.delete_leader())
def test_watch(self): def test_watch(self):
self.etcd.client.watch = etcd_watch self.etcd.client.watch = etcd_watch
self.etcd.watch(100) self.etcd.watch(0)
self.etcd.get_cluster() self.etcd.get_cluster()
self.etcd.watch(1) self.etcd.watch(1.5)
self.etcd.watch(5) self.etcd.watch(4.5)
self.etcd.watch(10) self.etcd.watch(9.5)
self.etcd.watch(100) self.etcd.watch(100)
@patch('patroni.etcd.Etcd.retry', Mock(side_effect=AttributeError("foo")))
def test_other_exceptions(self):
self.assertRaises(EtcdError, self.etcd.cancel_initialization)
+238 -51
View File
@@ -1,10 +1,12 @@
import etcd
import unittest import unittest
from mock import Mock, patch from mock import Mock, MagicMock, patch
from patroni.dcs import Cluster, DCSError 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.ha import Ha from patroni.ha import Ha
from test_etcd import etcd_read, etcd_write from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
def true(*args, **kwargs): def true(*args, **kwargs):
@@ -15,18 +17,41 @@ def false(*args, **kwargs):
return False return False
class MockPostgresql: def get_cluster(initialize, leader, members, failover):
return Cluster(initialize, leader, None, members, failover)
def __init__(self):
self.name = 'postgresql0' def get_cluster_not_initialized_without_leader():
self.is_promoted = False return get_cluster(None, None, [], None)
def get_cluster_initialized_without_leader(leader=False, failover=None):
m = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres',
'api_url': 'http://127.0.0.1:8008/patroni', 'xlog_location':4})
l = Leader(0, 0, m) if leader else None
o = Member(0, 'other', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
'api_url': 'http://127.0.0.1:8011/patroni'})
return get_cluster(True, l, [m, o], failover)
def get_cluster_initialized_with_leader(failover=None):
return get_cluster_initialized_without_leader(leader=True, failover=failover)
def get_cluster_initialized_with_only_leader(failover=None):
l = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
return get_cluster(True, l, [l], failover)
class MockPostgresql(Mock):
name = 'postgresql0'
role = 'replica'
state = 'running'
connection_string = 'postgres://foo@bar/postgres'
def is_healthy(self): def is_healthy(self):
return True return True
def write_recovery_conf(self, _):
return True
def start(self): def start(self):
return True return True
@@ -36,64 +61,89 @@ class MockPostgresql:
def is_leader(self): def is_leader(self):
return True return True
def promote(self): def xlog_position(self):
return True return 0
def demote(self, _):
return True
def follow_the_leader(self, _):
return True
def create_replication_slots(self, _):
return True
def last_operation(self): def last_operation(self):
return 0 return 0
def data_directory_empty(self):
return False
def nop(*args, **kwargs): def bootstrap(self, *args, **kwargs):
pass return True
def check_replication_lag(self, last_leader_operation):
return True
def check_recovery_conf(self, leader):
return False
def dead_etcd(): class MockPatroni:
raise DCSError('Etcd is not responding properly')
def __init__(self, p, d):
self.postgresql = p
self.dcs = d
self.api = Mock()
self.tags = {}
self.nofailover = None
self.api.connection_string = 'http://127.0.0.1:8008'
def get_unlocked_cluster(): def run_async(func, args=()):
return Cluster(False, None, None, []) func(*args) if args else func()
class TestHa(unittest.TestCase): class TestHa(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch('socket.getaddrinfo', socket_getaddrinfo)
self.setUp = self.set_up @patch.object(Client, 'machines')
super(TestHa, self).__init__(method_name) def setUp(self, mock_machines):
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
def set_up(self):
self.p = MockPostgresql() self.p = MockPostgresql()
with patch.object(Client, 'machines') as mock_machines: self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'})
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379']) self.e.client.read = etcd_read
self.e = Etcd('foo', {'ttl': 30, 'host': 'remotehost:2379', 'scope': 'test'}) self.e.client.write = etcd_write
self.e.client.read = etcd_read self.e.client.delete = Mock(side_effect=etcd.EtcdException())
self.e.client.write = etcd_write self.ha = Ha(MockPatroni(self.p, self.e))
self.ha = Ha(self.p, self.e) self.ha._async_executor.run_async = run_async
self.ha.load_cluster_from_dcs() self.ha.old_cluster = self.e.get_cluster()
self.ha.cluster = get_unlocked_cluster() self.ha.cluster = get_cluster_not_initialized_without_leader()
self.ha.load_cluster_from_dcs = nop self.ha.load_cluster_from_dcs = Mock()
def test_load_cluster_from_dcs(self): def test_update_lock(self):
ha = Ha(self.p, self.e) self.p.last_operation = Mock(side_effect=PostgresException(''))
ha.load_cluster_from_dcs() self.assertTrue(self.ha.update_lock())
self.e.get_cluster = get_unlocked_cluster
ha.load_cluster_from_dcs()
def test_start_as_slave(self): def test_touch_member(self):
self.p.xlog_position = Mock(side_effect=Exception)
self.ha.touch_member()
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(), 'started as a secondary')
def test_recover_replica_failed(self):
self.p.controldata = lambda: {'Database cluster state': 'in production'}
self.p.is_healthy = false
self.p.follow_the_leader = false
self.assertEquals(self.ha.run_cycle(), 'failed to start postgres')
def test_recover_master_failed(self):
self.p.follow_the_leader = false
self.p.is_healthy = false
self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'removed leader key after trying and failing to start postgres')
@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))
def test_start_as_readonly(self): def test_start_as_readonly(self):
self.ha.cluster.is_unlocked = false
self.p.is_leader = self.p.is_healthy = false self.p.is_leader = self.p.is_healthy = false
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')
@@ -102,6 +152,7 @@ class TestHa(unittest.TestCase):
self.assertEquals(self.ha.run_cycle(), 'acquired session lock as a leader') self.assertEquals(self.ha.run_cycle(), 'acquired session lock as a leader')
def test_promoted_by_acquiring_lock(self): def test_promoted_by_acquiring_lock(self):
self.ha.is_healthiest_node = true
self.p.is_leader = false self.p.is_leader = false
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')
@@ -110,16 +161,17 @@ class TestHa(unittest.TestCase):
self.assertEquals(self.ha.run_cycle(), 'demoted self due after trying and failing to obtain lock') self.assertEquals(self.ha.run_cycle(), 'demoted self due 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.acquire_lock = false self.ha.acquire_lock = false
self.p.is_leader = false self.p.is_leader = false
self.assertEquals(self.ha.run_cycle(), 'following new leader after trying and failing to obtain lock') self.assertEquals(self.ha.run_cycle(), 'following new leader after trying and failing to obtain lock')
def test_demote_because_not_healthiest(self): def test_demote_because_not_healthiest(self):
self.p.is_healthiest_node = false self.ha.is_healthiest_node = false
self.assertEquals(self.ha.run_cycle(), 'demoting self because i am not the healthiest node') self.assertEquals(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
def test_follow_new_leader_because_not_healthiest(self): def test_follow_new_leader_because_not_healthiest(self):
self.p.is_healthiest_node = false self.ha.is_healthiest_node = false
self.p.is_leader = false self.p.is_leader = false
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')
@@ -138,11 +190,146 @@ class TestHa(unittest.TestCase):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = 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_demote_because_update_lock_failed(self):
self.ha.cluster.is_unlocked = false
self.ha.has_lock = true
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')
def test_follow_the_leader(self): def test_follow_the_leader(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')
def test_no_etcd_connection_master_demote(self): def test_no_etcd_connection_master_demote(self):
self.ha.load_cluster_from_dcs = dead_etcd 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):
self.ha.cluster = get_cluster_initialized_with_leader()
self.p.bootstrap = false
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from leader')
def test_bootstrap_waiting_for_leader(self):
self.ha.cluster = get_cluster_initialized_without_leader()
self.assertEquals(self.ha.bootstrap(), 'waiting for leader to bootstrap')
def test_bootstrap_initialize_lock_failed(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.assertEquals(self.ha.bootstrap(), 'failed to acquire initialize lock')
def test_bootstrap_initialized_new_cluster(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.e.initialize = true
self.assertEquals(self.ha.bootstrap(), 'initialized a new cluster')
def test_bootstrap_release_initialize_key_on_failure(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.e.initialize = true
self.p.bootstrap = Mock(side_effect=PostgresException("Could not bootstrap master PostgreSQL"))
self.assertRaises(PostgresException, self.ha.bootstrap)
def test_reinitialize(self):
self.ha.schedule_reinitialize()
self.ha.schedule_reinitialize()
self.ha.run_cycle()
self.assertIsNone(self.ha._async_executor.scheduled_action)
self.ha.cluster = get_cluster_initialized_with_leader()
self.ha.has_lock = true
self.ha.schedule_reinitialize()
self.ha.run_cycle()
self.assertIsNone(self.ha._async_executor.scheduled_action)
self.ha.has_lock = false
self.ha.schedule_reinitialize()
self.ha.run_cycle()
def test_restart(self):
self.assertEquals(self.ha.restart(), (True, 'restarted successfully'))
self.p.restart = false
self.assertEquals(self.ha.restart(), (False, 'restart failed'))
self.ha.schedule_reinitialize()
self.assertEquals(self.ha.restart(), (False, 'reinitialize already in progress'))
def test_restart_in_progress(self):
self.ha._async_executor.schedule('restart', True)
self.assertTrue(self.ha.restart_scheduled())
self.assertEquals(self.ha.run_cycle(), 'not healthy enough for leader race')
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEquals(self.ha.run_cycle(), 'restart in progress')
self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'updated leader lock during restart')
self.ha.update_lock = false
self.assertEquals(self.ha.run_cycle(), 'failed to update leader lock during restart')
@patch('requests.get', requests_get)
def test_manual_failover_from_leader(self):
self.ha.has_lock = true
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', ''))
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.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.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
f = Failover(0, MockPostgresql.name, '')
self.ha.cluster = get_cluster_initialized_with_leader(f)
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', MockPostgresql.name))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
@patch('requests.get', requests_get)
def test_manual_failover_process_no_leader(self):
self.p.is_leader = false
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', MockPostgresql.name))
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.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.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.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, {}) # inaccessible, in_recovery
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'))
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'}) # accessible, in_recovery
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):
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.p.is_leader = false
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.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.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.p.check_replication_lag = false
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)
def test_fetch_node_status(self):
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
self.ha.fetch_node_status(member)
member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'})
self.ha.fetch_node_status(member)
+40 -107
View File
@@ -1,7 +1,3 @@
import datetime
import patroni.zookeeper
import psycopg2
import subprocess
import sys import sys
import time import time
import unittest import unittest
@@ -9,141 +5,78 @@ import yaml
from mock import Mock, patch from mock import Mock, patch
from patroni.api import RestApiServer from patroni.api import RestApiServer
from patroni.dcs import Cluster, Member from patroni.async_executor import AsyncExecutor
from patroni.etcd import Etcd from patroni.etcd import Etcd
from patroni import Patroni, main from patroni import Patroni, 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, etcd_read, etcd_write from test_etcd import Client, SleepException, etcd_read, etcd_write
from test_ha import true, false from test_postgresql import Postgresql, psycopg2_connect
from test_postgresql import Postgresql, subprocess_call, psycopg2_connect
from test_zookeeper import MockKazooClient from test_zookeeper import MockKazooClient
def nop(*args, **kwargs):
pass
class SleepException(Exception):
pass
def time_sleep(*args): def time_sleep(*args):
raise SleepException() raise SleepException()
class Mock_BaseServer__is_shut_down: @patch('time.sleep', Mock())
@patch('subprocess.call', Mock(return_value=0))
def set(self): @patch('psycopg2.connect', psycopg2_connect)
pass @patch.object(Postgresql, 'write_pg_hba', Mock())
@patch.object(Postgresql, 'write_recovery_conf', Mock())
def clear(self): @patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
pass @patch.object(AsyncExecutor, 'run', Mock())
class TestPatroni(unittest.TestCase): class TestPatroni(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch.object(Client, 'machines')
self.setUp = self.set_up def setUp(self, mock_machines):
self.tearDown = self.tear_down mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
super(TestPatroni, self).__init__(method_name)
def set_up(self):
self.touched = False self.touched = False
subprocess.call = subprocess_call self.init_cancelled = False
psycopg2.connect = psycopg2_connect RestApiServer._BaseServer__is_shut_down = Mock()
self.time_sleep = time.sleep
time.sleep = nop
self.write_pg_hba = Postgresql.write_pg_hba
self.write_recovery_conf = Postgresql.write_recovery_conf
Postgresql.write_pg_hba = nop
Postgresql.write_recovery_conf = nop
BaseHTTPServer.HTTPServer.__init__ = nop
RestApiServer._BaseServer__is_shut_down = Mock_BaseServer__is_shut_down()
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)
with patch.object(Client, 'machines') as mock_machines: self.p = Patroni(config)
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379']) self.p.ha.dcs.client.write = etcd_write
self.p = Patroni(config) self.p.ha.dcs.client.read = etcd_read
def tear_down(self):
time.sleep = self.time_sleep
Postgresql.write_pg_hba = self.write_pg_hba
Postgresql.write_recovery_conf = self.write_recovery_conf
@patch('patroni.zookeeper.KazooClient', MockKazooClient())
def test_get_dcs(self): def test_get_dcs(self):
patroni.zookeeper.KazooClient = MockKazooClient
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.assertRaises(Exception, self.p.get_dcs, '', {})
def test_patroni_main(self): @patch('time.sleep', Mock(side_effect=SleepException()))
@patch.object(Etcd, 'delete_leader', Mock())
@patch.object(Client, 'machines')
def test_patroni_main(self, mock_machines):
main() main()
sys.argv = ['patroni.py', 'postgres0.yml'] sys.argv = ['patroni.py', 'postgres0.yml']
time.sleep = time_sleep
with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
Patroni.initialize = nop
touch_member = Patroni.touch_member
run = Patroni.run
Patroni.touch_member = self.touch_member
Patroni.run = time_sleep
Etcd.delete_leader = nop
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
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())):
main()
Patroni.run = run @patch('time.sleep', Mock(side_effect=SleepException()))
Patroni.touch_member = touch_member def test_run(self):
def test_patroni_run(self):
time.sleep = time_sleep
self.p.touch_member = self.touch_member
self.p.ha.state_handler.sync_replication_slots = time_sleep
self.p.ha.dcs.client.read = etcd_read
self.p.ha.dcs.watch = time_sleep self.p.ha.dcs.watch = time_sleep
self.assertRaises(SleepException, self.p.run) self.assertRaises(SleepException, self.p.run)
self.p.ha.state_handler.is_leader = lambda: False
self.p.api.start = nop self.p.ha.state_handler.is_leader = Mock(return_value=False)
self.p.api.start = Mock()
self.assertRaises(SleepException, self.p.run) self.assertRaises(SleepException, self.p.run)
def touch_member(self, ttl=None):
if not self.touched:
self.touched = True
return False
return True
def test_touch_member(self):
self.p.ha.dcs.client.write = etcd_write
self.p.touch_member()
now = datetime.datetime.utcnow()
member = Member(0, self.p.postgresql.name, 'b', 'c', (now + datetime.timedelta(
seconds=self.p.shutdown_member_ttl + 10)).strftime('%Y-%m-%dT%H:%M:%S.%fZ'), None)
self.p.ha.cluster = Cluster(True, member, 0, [member])
self.p.touch_member()
def test_patroni_initialize(self):
self.p.postgresql.should_use_s3_to_create_replica = false
self.p.ha.dcs.client.write = etcd_write
self.p.touch_member = self.touch_member
self.p.postgresql.data_directory_empty = true
self.p.ha.dcs.race = true
self.p.initialize()
self.p.ha.dcs.race = false
time.sleep = time_sleep
self.p.ha.dcs.client.read = etcd_read
self.p.initialize()
self.p.ha.dcs.current_leader = nop
self.assertRaises(Exception, self.p.initialize)
self.p.postgresql.data_directory_empty = false
self.p.initialize()
def test_schedule_next_run(self): def test_schedule_next_run(self):
self.p.ha.dcs.watch = Mock(return_value=True)
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_nofailover(self):
self.p.tags['nofailover'] = True
self.assertTrue(self.p.nofailover)
self.p.tags['nofailover'] = None
self.assertFalse(self.p.nofailover)
+356 -93
View File
@@ -1,50 +1,41 @@
import mock # for the mock.call method, importing it without a namespace breaks python3
import os import os
import psycopg2 import psycopg2
import shutil import shutil
import subprocess
import unittest import unittest
from six.moves import builtins
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.postgresql import Postgresql from patroni.postgresql import Postgresql
from patroni.utils import RetryFailedError
from test_ha import false
import subprocess
def nop(*args, **kwargs): def is_file_raise_on_backup(*args, **kwargs):
pass if args[0].endswith('.backup'):
raise Exception("foo")
def subprocess_call(cmd, shell=False, env=None):
return 0
def false(*args, **kwargs):
return False
class MockCursor: class MockCursor:
def __init__(self): def __init__(self, connection):
self.connection = connection
self.closed = False self.closed = False
self.results = [] self.results = []
def execute(self, sql, *params): def execute(self, sql, *params):
if sql.startswith('blabla'): if sql.startswith('blabla') or sql == 'CHECKPOINT':
raise psycopg2.OperationalError() raise psycopg2.OperationalError()
elif sql.startswith('InterfaceError'): elif sql.startswith('RetryFailedError'):
raise psycopg2.InterfaceError() raise RetryFailedError('retry')
elif sql.startswith('SELECT slot_name'): elif sql.startswith('SELECT slot_name'):
self.results = [('blabla',), ('foobar',)] self.results = [('blabla',), ('foobar',)]
elif sql.startswith('SELECT pg_current_xlog_location()'):
self.results = [(0,)]
elif sql.startswith('SELECT pg_is_in_recovery(), %s'):
if params[0][0] == 1:
raise psycopg2.OperationalError()
elif params[0][0] == 2:
self.results = [(True, -1)]
else:
self.results = [(False, 0)]
elif sql.startswith('SELECT pg_xlog_location_diff'): elif sql.startswith('SELECT pg_xlog_location_diff'):
self.results = [(0,)] self.results = [(0,)]
elif sql.startswith('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)]
@@ -65,6 +56,9 @@ class MockCursor:
def fetchone(self): def fetchone(self):
return self.results[0] return self.results[0]
def fetchall(self):
return self.results
def close(self): def close(self):
pass pass
@@ -72,44 +66,108 @@ class MockCursor:
for i in self.results: for i in self.results:
yield i yield i
def __enter__(self):
return self
class MockConnect: def __exit__(self, *args):
def __init__(self):
self.autocommit = False
self.closed = 0
def cursor(self):
return MockCursor()
def close(self):
pass pass
def psycopg2_connect(*args, **kwargs): class MockConnect(Mock):
autocommit = False
closed = 0
def cursor(self):
return MockCursor(self)
def __enter__(self):
return self
def __exit__(self, *args):
pass
def pg_controldata_string(*args, **kwargs):
return b"""
pg_control version number: 942
Catalog version number: 201509161
Database system identifier: 6200971513092291716
Database cluster state: shut down in recovery
pg_control last modified: Fri Oct 2 10:57:06 2015
Latest checkpoint location: 0/30000C8
Prior checkpoint location: 0/2000060
Latest checkpoint's REDO location: 0/3000090
Latest checkpoint's REDO WAL file: 000000020000000000000003
Latest checkpoint's TimeLineID: 2
Latest checkpoint's PrevTimeLineID: 2
Latest checkpoint's full_page_writes: on
Latest checkpoint's NextXID: 0/943
Latest checkpoint's NextOID: 24576
Latest checkpoint's NextMultiXactId: 1
Latest checkpoint's NextMultiOffset: 0
Latest checkpoint's oldestXID: 931
Latest checkpoint's oldestXID's DB: 1
Latest checkpoint's oldestActiveXID: 943
Latest checkpoint's oldestMultiXid: 1
Latest checkpoint's oldestMulti's DB: 1
Latest checkpoint's oldestCommitTs: 0
Latest checkpoint's newestCommitTs: 0
Time of latest checkpoint: Fri Oct 2 10:56:54 2015
Fake LSN counter for unlogged rels: 0/1
Minimum recovery ending location: 0/30241F8
Min recovery ending loc's timeline: 2
Backup start location: 0/0
Backup end location: 0/0
End-of-backup record required: no
wal_level setting: hot_standby
Current wal_log_hints setting: on
Current max_connections setting: 100
Current max_worker_processes setting: 8
Current max_prepared_xacts setting: 0
Current max_locks_per_xact setting: 64
Current track_commit_timestamp setting: off
Maximum data alignment: 8
Database block size: 8192
Blocks per segment of large relation: 131072
WAL block size: 8192
Bytes per WAL segment: 16777216
Maximum length of identifiers: 64
Maximum columns in an index: 32
Maximum size of a TOAST chunk: 1996
Size of a large-object chunk: 2048
Date/time type storage: 64-bit integers
Float4 argument passing: by value
Float8 argument passing: by value
Data page checksum version: 0
"""
def postmaster_opts_string(*args, **kwargs):
return '/usr/local/pgsql/bin/postgres "-D" "data/postgresql0" "--listen_addresses=127.0.0.1" \
"--port=5432" "--hot_standby=on" "--wal_keep_segments=8" "--wal_level=hot_standby" \
"--archive_command=mkdir -p ../wal_archive && cp %p ../wal_archive/%f" "--wal_log_hints=on" \
"--max_wal_senders=5" "--archive_timeout=1800s" "--archive_mode=on" "--max_replication_slots=5"\n'
def psycopg2_connect(*args, **kwargs):
return MockConnect() return MockConnect()
def is_running(): @patch('subprocess.call', Mock(return_value=0))
return False @patch('psycopg2.connect', psycopg2_connect)
@patch('shutil.copy', Mock())
class TestPostgresql(unittest.TestCase): class TestPostgresql(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch('subprocess.call', Mock(return_value=0))
self.setUp = self.set_up @patch('psycopg2.connect', psycopg2_connect)
self.tearDown = self.tear_down def setUp(self):
super(TestPostgresql, self).__init__(method_name)
def set_up(self):
subprocess.call = subprocess_call
shutil.copy = nop
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': ['hostssl all all 0.0.0.0/0 md5', 'host all all 0.0.0.0/0 md5'],
'superuser': {'password': ''}, 'superuser': {'password': 'test'},
'admin': {'username': 'admin', 'password': 'admin'}, 'admin': {'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'}, 'network': '127.0.0.1/32'},
@@ -119,44 +177,122 @@ class TestPostgresql(unittest.TestCase):
'on_reload': 'true' 'on_reload': 'true'
}, },
'restore': 'true'}) 'restore': 'true'})
psycopg2.connect = psycopg2_connect
if not os.path.exists(self.p.data_dir): if not os.path.exists(self.p.data_dir):
os.makedirs(self.p.data_dir) os.makedirs(self.p.data_dir)
self.leadermem = Member(0, 'leader', 'postgres://replicator:[email protected]:5435/postgres', None, None, 28) self.leadermem = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
self.leader = Leader(-1, None, 28, self.leadermem) self.leader = Leader(-1, 28, self.leadermem)
self.other = Member(0, 'test1', 'postgres://replicator:[email protected]:5433/postgres', None, None, 28) self.other = Member(0, 'test1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres'})
self.me = Member(0, 'test0', 'postgres://replicator:[email protected]:5434/postgres', None, None, 28) self.me = Member(0, 'test0', 28, {'conn_url': 'postgres://replicator:[email protected]:5434/postgres'})
def tear_down(self): def tearDown(self):
shutil.rmtree('data') shutil.rmtree('data')
def mock_query(self, p):
raise psycopg2.OperationalError("not supported")
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')))
def test_start_stop(self): def test_start(self):
self.assertFalse(self.p.start()) self.assertTrue(self.p.start())
self.p.is_running = is_running self.p.is_running = false
with open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w'): open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w').close()
pass
self.assertTrue(self.p.start()) self.assertTrue(self.p.start())
self.assertTrue(self.p.stop())
def test_stop(self):
self.assertTrue(self.p.stop())
with patch('subprocess.call', Mock(return_value=1)):
self.assertTrue(self.p.stop())
self.p.is_running = Mock(return_value=True)
self.assertFalse(self.p.stop())
def test_restart(self):
self.p.start = false
self.p.is_running = false
self.assertFalse(self.p.restart())
self.assertEquals(self.p.state, 'restart failed (restarting)')
@patch.object(builtins, 'open', MagicMock())
def test_write_pgpass(self):
self.p.write_pgpass({'host': 'localhost', 'port': '5432', 'user': 'foo', 'password': 'bar'})
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
def test_sync_from_leader(self): def test_sync_from_leader(self):
self.assertTrue(self.p.sync_from_leader(self.leader)) self.assertTrue(self.p.sync_from_leader(self.leader))
def test_follow_the_leader(self): @patch('subprocess.call', side_effect=Exception("Test"))
self.p.demote(self.leader) @patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
def test_pg_rewind(self, mock_call):
self.assertTrue(self.p.rewind(self.leader))
subprocess.call = mock_call
self.assertFalse(self.p.rewind(self.leader))
@patch('patroni.postgresql.Postgresql.rewind', return_value=False)
@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.write_pgpass', MagicMock(return_value=dict()))
def test_follow_the_leader(self, mock_pg_rewind):
self.p.demote()
self.p.follow_the_leader(None) self.p.follow_the_leader(None)
self.p.demote(self.leader) self.p.demote()
self.p.follow_the_leader(self.leader) self.p.follow_the_leader(self.leader)
self.p.follow_the_leader(Leader(-1, None, 28, self.other)) self.p.follow_the_leader(Leader(-1, 28, self.other))
self.p.rewind = mock_pg_rewind
self.p.follow_the_leader(self.leader)
self.p.require_rewind()
with mock.patch('os.path.islink', MagicMock(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.require_rewind()
with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)):
self.p.rewind.return_value = True
self.p.follow_the_leader(self.leader, recovery=True)
self.p.rewind.return_value = False
self.p.follow_the_leader(self.leader, recovery=True)
def test_can_rewind(self):
tmp = self.p.pg_rewind
self.p.pg_rewind = None
self.assertFalse(self.p.can_rewind)
self.p.pg_rewind = tmp
with mock.patch('subprocess.call', MagicMock(return_value=1)):
self.assertFalse(self.p.can_rewind)
with mock.patch('subprocess.call', side_effect=OSError("foo")):
self.assertFalse(self.p.can_rewind)
tmp = self.p.controldata()
self.p.controldata = lambda: {'wal_log_hints setting': 'on'}
self.assertTrue(self.p.can_rewind)
self.p.controldata = tmp
@patch('time.sleep', Mock())
def test_create_replica(self):
self.p.delete_trigger_file = Mock(side_effect=OSError())
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)
self.p.config['create_replica_method'] = ['wale', 'basebackup']
self.p.config['wale'] = {'command': 'foo'}
with patch('subprocess.call', Mock(return_value=0)):
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
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_create_connection_users(self): def test_create_connection_users(self):
cfg = self.p.config cfg = self.p.config
@@ -164,56 +300,183 @@ class TestPostgresql(unittest.TestCase):
p = Postgresql(cfg) p = Postgresql(cfg)
p.create_connection_users() p.create_connection_users()
def test_create_replication_slots(self): def test_sync_replication_slots(self):
self.p.start() self.p.start()
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem]) cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem], None)
self.p.create_replication_slots(cluster) self.p.sync_replication_slots(cluster)
self.p.query = Mock(side_effect=psycopg2.OperationalError)
self.p.schedule_load_slots = True
self.p.sync_replication_slots(cluster)
@patch.object(MockConnect, 'closed', 2)
def test__query(self):
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
self.p._state = 'restarting'
self.assertRaises(RetryFailedError, self.p._query, 'blabla')
def test_query(self): def test_query(self):
self.p.query('select 1') self.p.query('select 1')
self.assertRaises(psycopg2.InterfaceError, self.p.query, 'InterfaceError') self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError')
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla') self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
self.p._connection.closed = 2
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
self.p._connection.closed = 2
self.p.disconnect = false
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
def test_is_healthiest_node(self):
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem])
self.assertTrue(self.p.is_healthiest_node(cluster))
self.p.is_leader = false
self.assertFalse(self.p.is_healthiest_node(cluster))
self.p.xlog_position = lambda: 1
self.assertTrue(self.p.is_healthiest_node(cluster))
self.p.xlog_position = lambda: 2
self.assertFalse(self.p.is_healthiest_node(cluster))
self.p.config['maximum_lag_on_failover'] = -3
self.assertFalse(self.p.is_healthiest_node(cluster))
def test_is_leader(self): def test_is_leader(self):
self.p.is_promoted = True
self.assertTrue(self.p.is_leader()) self.assertTrue(self.p.is_leader())
self.assertFalse(self.p.is_promoted)
def test_reload(self): def test_reload(self):
self.assertTrue(self.p.reload()) self.assertTrue(self.p.reload())
def test_is_healthy(self): def test_is_healthy(self):
self.assertTrue(self.p.is_healthy()) self.assertTrue(self.p.is_healthy())
self.p.is_running = is_running self.p.is_running = false
self.assertFalse(self.p.is_healthy()) self.assertFalse(self.p.is_healthy())
def test_promote(self): def test_promote(self):
self.p._role = 'replica'
self.assertTrue(self.p.promote())
self.assertTrue(self.p.promote()) self.assertTrue(self.p.promote())
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()))
def test_call_nowait(self):
self.assertFalse(self.p.call_nowait('on_start'))
def test_non_existing_callback(self): def test_non_existing_callback(self):
self.assertFalse(self.p.call_nowait('foobar')) self.assertFalse(self.p.call_nowait('foobar'))
def test_is_leader_exception(self): def test_is_leader_exception(self):
self.p.start() self.p.start()
self.p.query = self.mock_query self.p.query = Mock(side_effect=psycopg2.OperationalError("not supported"))
self.assertTrue(self.p.stop()) self.assertTrue(self.p.stop())
def test_check_replication_lag(self):
self.assertTrue(self.p.check_replication_lag(0))
@patch('os.rename', Mock())
@patch('os.path.isdir', Mock(return_value=True))
def test_move_data_directory(self):
self.p.is_running = false
self.p.move_data_directory()
with patch('os.rename', Mock(side_effect=OSError())):
self.p.move_data_directory()
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
def test_bootstrap(self):
with patch('subprocess.call', Mock(return_value=1)):
self.assertRaises(PostgresException, self.p.bootstrap)
self.p.bootstrap()
self.p.bootstrap(self.leader)
def test_remove_data_directory(self):
self.p.data_dir = 'data_dir'
self.p.remove_data_directory()
os.mkdir(self.p.data_dir)
self.p.remove_data_directory()
open(self.p.data_dir, 'w').close()
self.p.remove_data_directory()
os.symlink('unexisting', self.p.data_dir)
with patch('os.unlink', Mock(side_effect=Exception)):
self.p.remove_data_directory()
self.p.remove_data_directory()
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
@patch('subprocess.check_output', side_effect=subprocess.CalledProcessError)
@patch('subprocess.check_output', side_effect=Exception('Failed'))
def test_controldata(self, check_output_call_error, check_output_generic_exception):
data = self.p.controldata()
self.assertEquals(len(data), 50)
self.assertEquals(data['Database cluster state'], 'shut down in recovery')
self.assertEquals(data['wal_log_hints setting'], 'on')
self.assertEquals(int(data['Database block size']), 8192)
subprocess.check_output = check_output_call_error
data = 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):
m = mock_open(read_data=postmaster_opts_string())
with patch.object(builtins, 'open', m):
data = self.p.read_postmaster_opts()
self.assertEquals(data['wal_level'], 'hot_standby')
self.assertEquals(int(data['max_replication_slots']), 5)
self.assertEqual(data.get('D'), None)
m.side_effect = IOError("foo")
data = self.p.read_postmaster_opts()
self.assertEqual(data, dict())
m.side_effect = Exception("foo")
self.assertRaises(Exception, self.p.read_postmaster_opts())
@patch('subprocess.Popen')
@patch.object(builtins, 'open', MagicMock(return_value=42))
def test_single_user_mode(self, subprocess_popen_mock):
subprocess_popen_mock.return_value.wait.return_value = 0
self.assertEquals(self.p.single_user_mode(options=dict(archive_mode='on', archive_command='false')), 0)
subprocess_popen_mock.assert_called_once_with(['postgres', '--single', '-D', self.p.data_dir,
'-c', 'archive_command=false', '-c', 'archive_mode=on',
'postgres'], stdin=subprocess.PIPE,
stdout=42,
stderr=subprocess.STDOUT)
subprocess_popen_mock.reset_mock()
self.assertEquals(self.p.single_user_mode(command="CHECKPOINT"), 0)
subprocess_popen_mock.assert_called_once_with(['postgres', '--single', '-D', self.p.data_dir,
'postgres'], stdin=subprocess.PIPE,
stdout=42,
stderr=subprocess.STDOUT)
subprocess_popen_mock.return_value = None
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.path.isdir', MagicMock(return_value=True))
@patch('os.unlink', return_value=True)
@patch('os.remove', return_value=True)
@patch('os.path.islink', return_value=False)
@patch('os.path.isfile', return_value=True)
def test_cleanup_archive_status(self, mock_file, mock_link, mock_remove, mock_unlink):
ap = os.path.join(self.p.data_dir, 'pg_xlog', 'archive_status/')
self.p.cleanup_archive_status()
mock_remove.assert_has_calls([mock.call(ap+'a'), mock.call(ap+'b'), mock.call(ap+'c')])
mock_unlink.assert_not_called()
mock_remove.reset_mock()
mock_file.return_value = False
mock_link.return_value = True
self.p.cleanup_archive_status()
mock_unlink.assert_has_calls([mock.call(ap+'a'), mock.call(ap+'b'), mock.call(ap+'c')])
mock_remove.assert_not_called()
mock_unlink.reset_mock()
mock_remove.reset_mock()
mock_file.side_effect = Exception("foo")
mock_link.side_effect = Exception("foo")
self.p.cleanup_archive_status()
mock_unlink.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', MagicMock(return_value=True))
@patch('shutil.copy', side_effect=Exception)
def test_save_configuration_files(self, mock_copy):
shutil.copy = mock_copy
self.p.save_configuration_files()
@patch('os.path.isfile', MagicMock(side_effect=is_file_raise_on_backup))
@patch('shutil.copy', side_effect=Exception)
def test_restore_configuration_files(self, mock_copy):
shutil.copy = mock_copy
self.p.restore_configuration_files()
-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)
+14 -40
View File
@@ -1,56 +1,34 @@
import os
import time
import unittest import unittest
from patroni.exceptions import DCSError from mock import Mock, patch
from patroni.exceptions import PatroniException
from patroni.utils import Retry, RetryFailedError, reap_children, sigchld_handler, sigterm_handler, sleep from patroni.utils import Retry, RetryFailedError, reap_children, sigchld_handler, sigterm_handler, sleep
def nop(*args, **kwargs):
pass
def os_waitpid(a, b):
return (0, 0)
def time_sleep(_): def time_sleep(_):
sigchld_handler(None, None) sigchld_handler(None, None)
class TestUtils(unittest.TestCase): class TestUtils(unittest.TestCase):
def __init__(self, method_name='runTest'):
self.setUp = self.set_up
self.tearDown = self.tear_down
super(TestUtils, self).__init__(method_name)
def set_up(self):
self.time_sleep = time.sleep
time.sleep = nop
def tear_down(self):
time.sleep = self.time_sleep
def test_sigterm_handler(self): def test_sigterm_handler(self):
self.assertRaises(SystemExit, sigterm_handler, None, None) self.assertRaises(SystemExit, sigterm_handler, None, None)
@patch('time.sleep', Mock())
def test_reap_children(self): def test_reap_children(self):
reap_children() reap_children()
os.waitpid = os_waitpid with patch('os.waitpid', Mock(return_value=(0, 0))):
sigchld_handler(None, None) sigchld_handler(None, None)
reap_children() reap_children()
@patch('time.sleep', time_sleep)
def test_sleep(self): def test_sleep(self):
time.sleep = time_sleep
sleep(0.01) sleep(0.01)
@patch('time.sleep', Mock())
class TestRetrySleeper(unittest.TestCase): class TestRetrySleeper(unittest.TestCase):
def _pass(self):
pass
def _fail(self, times=1): def _fail(self, times=1):
scope = dict(times=0) scope = dict(times=0)
@@ -59,7 +37,7 @@ class TestRetrySleeper(unittest.TestCase):
pass pass
else: else:
scope['times'] += 1 scope['times'] += 1
raise DCSError('Failed!') raise PatroniException('Failed!')
return inner return inner
def _makeOne(self, *args, **kwargs): def _makeOne(self, *args, **kwargs):
@@ -78,24 +56,20 @@ class TestRetrySleeper(unittest.TestCase):
self.assertEquals(retry._attempts, 1) self.assertEquals(retry._attempts, 1)
def test_maximum_delay(self): def test_maximum_delay(self):
def sleep_func(_time): retry = self._makeOne(delay=10, max_tries=100)
pass
retry = self._makeOne(delay=10, max_tries=100, sleep_func=sleep_func)
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):
def sleep_func(_time): retry = self._makeOne(deadline=0.0001)
pass
retry = self._makeOne(deadline=0.0001, sleep_func=sleep_func)
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):
None
retry = self._makeOne(sleep_func=_sleep) retry = self._makeOne(sleep_func=_sleep)
rcopy = retry.copy() rcopy = retry.copy()
self.assertTrue(rcopy.sleep_func is _sleep) self.assertTrue(rcopy.sleep_func is _sleep)
+91
View File
@@ -0,0 +1,91 @@
import unittest
from mock import MagicMock, patch, PropertyMock
import os
import psycopg2
import subprocess
from patroni.scripts.wale_restore import 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
"""
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.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):
self.wale_restore = WALERestore("batman", "/data",
"host=batman port=5432 user=batman", "/etc", 100, 100, 1)
def tearDown(self):
pass
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()
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)
+86 -78
View File
@@ -1,130 +1,112 @@
import patroni.zookeeper import six
import requests
import unittest import unittest
from mock import Mock, patch
from patroni.dcs import Leader from patroni.dcs import Leader
from patroni.zookeeper import ExhibitorEnsembleProvider, ZooKeeper, ZooKeeperError from patroni.zookeeper import ExhibitorEnsembleProvider, ZooKeeper, ZooKeeperError
from kazoo.client import KazooState from kazoo.client import KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError from kazoo.exceptions import NoNodeError, NodeExistsError
from kazoo.protocol.states import ZnodeStat from kazoo.protocol.states import ZnodeStat
from test_etcd import MockPostgresql, requests_get from test_etcd import SleepException, requests_get
class MockEvent: class MockKazooClient(Mock):
def clear(self): leader = False
pass exists = True
handler = Mock()
def set(self):
pass
def wait(self, timeout):
pass
def isSet(self):
return True
class MockEventHandler:
def event_object(self):
return MockEvent()
class SleepException(Exception):
pass
class MockKazooClient:
def __init__(self, **kwargs):
self.handler = MockEventHandler()
self.leader = False
self.exists = True
def start(self, timeout):
pass
@property @property
def client_id(self): def client_id(self):
return (-1, '') return (-1, '')
def add_listener(self, cb):
pass
def retry(self, func, *args, **kwargs): def retry(self, func, *args, **kwargs):
func(*args, **kwargs) func(*args, **kwargs)
def get(self, path, watch=None): def get(self, path, watch=None):
if path == '/service/test/no_node': if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)")
if path == '/no_node':
raise NoNodeError raise NoNodeError
elif path == '/service/test/other_exception':
raise Exception()
elif '/members/' in path: elif '/members/' in path:
return ( return (
'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, 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'): elif path.endswith('/optime/leader'):
return '1' 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 ('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))
return ('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))
elif path.endswith('/initialize'):
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))
def get_children(self, path, watch=None, include_data=False): def get_children(self, path, watch=None, include_data=False):
if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)")
if path.startswith('/no_node'):
raise NoNodeError
elif path in ['/service/bla/', '/service/test/']:
return ['initialize', 'leader', 'members', 'optime', 'failover']
return ['foo', 'bar', 'buzz'] return ['foo', 'bar', 'buzz']
def create(self, path, value="", acl=None, ephemeral=False, sequence=False, makepath=False): def create(self, path, value=b"", acl=None, ephemeral=False, sequence=False, makepath=False):
if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)")
if not isinstance(value, (six.binary_type,)):
raise TypeError("Invalid type for 'value' (must be a byte string)")
if path.endswith('/initialize') or path == '/service/test/optime/leader': if path.endswith('/initialize') or path == '/service/test/optime/leader':
raise Exception raise Exception
elif value == 'retry' or (value == '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): def set(self, path, value, version=-1):
if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)")
if not isinstance(value, (six.binary_type,)):
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 value == b'retry':
return
if path == '/service/test/failover':
if value == b'Exception':
raise Exception
elif value == b'ok':
return
raise NoNodeError raise NoNodeError
def delete(self, path, version=-1, recursive=False): def delete(self, path, version=-1, recursive=False):
if not isinstance(path, six.string_types):
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: if self.leader:
return return
self.leader = True self.leader = True
raise Exception raise Exception
elif path == '/service/test/members/buzz':
def set_hosts(self, hosts, randomize_hosts=None): raise Exception
pass elif path.endswith('/initialize') or path == '/service/test/members/bar':
raise NoNodeError
def exhibitor_sleep(_):
raise SleepException
@patch('requests.get', requests_get)
@patch('patroni.zookeeper.sleep', Mock(side_effect=SleepException()))
class TestExhibitorEnsembleProvider(unittest.TestCase): class TestExhibitorEnsembleProvider(unittest.TestCase):
def __init__(self, method_name='runTest'):
self.setUp = self.set_up
super(TestExhibitorEnsembleProvider, self).__init__(method_name)
def set_up(self):
requests.get = requests_get
patroni.zookeeper.sleep = exhibitor_sleep
def test_init(self): def test_init(self):
self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181) self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181)
class TestZooKeeper(unittest.TestCase): class TestZooKeeper(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch('requests.get', requests_get)
self.setUp = self.set_up @patch('patroni.zookeeper.KazooClient', MockKazooClient)
super(TestZooKeeper, self).__init__(method_name) def setUp(self):
def set_up(self):
requests.get = requests_get
patroni.zookeeper.KazooClient = MockKazooClient
self.zk = ZooKeeper('foo', {'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181}, 'scope': 'test'}) self.zk = ZooKeeper('foo', {'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181}, 'scope': 'test'})
def test_session_listener(self): def test_session_listener(self):
@@ -132,11 +114,15 @@ class TestZooKeeper(unittest.TestCase):
def test_get_node(self): def test_get_node(self):
self.assertIsNone(self.zk.get_node('/no_node')) self.assertIsNone(self.zk.get_node('/no_node'))
self.assertIsNone(self.zk.get_node('/other_exception'))
def test_get_children(self):
self.assertListEqual(self.zk.get_children('/no_node'), [])
def test__inner_load_cluster(self): def test__inner_load_cluster(self):
self.zk._base_path = self.zk._base_path.replace('test', 'bla') self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk._inner_load_cluster() self.zk._inner_load_cluster()
self.zk._base_path = self.zk._base_path = '/no_node'
self.zk._inner_load_cluster()
def test_get_cluster(self): def test_get_cluster(self):
self.assertRaises(ZooKeeperError, self.zk.get_cluster) self.assertRaises(ZooKeeperError, self.zk.get_cluster)
@@ -146,23 +132,45 @@ class TestZooKeeper(unittest.TestCase):
self.zk.touch_member('foo') self.zk.touch_member('foo')
self.zk.delete_leader() self.zk.delete_leader()
def test_race(self): def test_set_failover_value(self):
self.assertFalse(self.zk.race('/initialize')) self.zk.set_failover_value('')
self.zk.set_failover_value('ok')
self.zk.set_failover_value('Exception')
def test_initialize(self):
self.assertFalse(self.zk.initialize())
def test_cancel_initialization(self):
self.zk.cancel_initialization()
def test_touch_member(self): def test_touch_member(self):
self.zk._name = 'buzz'
self.zk.get_cluster()
self.zk.touch_member('new') self.zk.touch_member('new')
self.zk._name = 'bar'
self.zk.touch_member('new')
self.zk._name = 'na'
self.zk.client.exists = 1
self.zk.touch_member('exists') self.zk.touch_member('exists')
self.zk._name = 'bar'
self.zk.touch_member('retry')
self.zk.fetch_cluster = True
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()
def test_update_leader(self): def test_update_leader(self):
self.zk.last_leader_operation = -1 self.assertTrue(self.zk.update_leader())
self.assertTrue(self.zk.update_leader(MockPostgresql()))
def test_write_leader_optime(self):
self.zk.last_leader_operation = '0'
self.zk.write_leader_optime('1')
self.zk._base_path = self.zk._base_path.replace('test', 'bla') self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk.last_leader_operation = -1 self.zk.write_leader_optime('2')
self.assertTrue(self.zk.update_leader(MockPostgresql()))
def test_watch(self): def test_watch(self):
self.zk.watch(0) self.zk.watch(0)
self.zk.event.isSet = lambda: True
self.zk.watch(0)