Compare commits

...
830 Commits
Author SHA1 Message Date
Feike Steenbergen 05951f9b5b Bumped version to 1.1 2016-09-07 14:51:50 +02:00
Alexander KukushkinandGitHub 41d1724705 Merge pull request #274 from zalando/feature/disable-automatic-failover
Feature/disable automatic failover
2016-09-07 14:48:19 +02:00
Feike SteenbergenandGitHub 0742ffe02e Merge pull request #292 from CartoDB/fix/remove-long-dashes-from-readme
Remove unicode em dashes and single quotes from README
2016-09-07 12:40:15 +02:00
Alejandro Martínez 4ca514ceb3 Remove em dashes from README
In Python 3.5 and certain locales / environments, they make pip install from the git
repo crash.
2016-09-07 10:58:33 +02:00
Alexander Kukushkin c2b91d0195 Merge branch 'master' of github.com:zalando/patroni into feature/disable-automatic-failover 2016-09-05 16:03:55 +02:00
Alexander KukushkinandGitHub 53bcc5c9bb Merge pull request #290 from zalando/feature/pyinstaller
Binary build with PyInstaller
2016-09-05 14:52:13 +02:00
Feike SteenbergenandGitHub 6bdaa7fb88 Merge pull request #288 from zalando/bugfix/python3_wale_restore
Decode output from wal-e list backup
2016-09-05 14:45:14 +02:00
Alexander Kukushkin 2086c90a4a Try to get rid from hardcoded names when building binary 2016-09-05 14:11:53 +02:00
Alexander Kukushkin 5c7efa3a65 Update gitignore 2016-09-05 14:10:21 +02:00
Alexander KukushkinandGitHub dff79395a9 Merge pull request #258 from alkersan/master
Binary build with PyInstaller
2016-09-05 13:57:02 +02:00
Alexander KukushkinandGitHub 39d16fe2f9 Merge pull request #281 from CartoDB/feature/add_custom_conf_location
Add configuration parameter to specify a path to a custom postgresql.base.conf and disable its backup
2016-09-05 13:53:04 +02:00
Alexander Kukushkin 57a0ac9086 pep8 format of test_wale_restore.py 2016-09-05 12:15:28 +02:00
Feike Steenbergen 5ba1294d60 Fix tests for wal-e restore 2016-09-02 17:04:37 +02:00
Oleksii KliukinandGitHub 3f7fa4b41f Avoid retries when syncing replication slots. (#282)
* Avoid retries when syncing replication slots.

Do not retry postgres queries that fetch, create and drop slots at the end of
the HA cycle. The complete run_cycle routine executes with the async_executor
lock. This lock is also used with scheduling operations like reinit or restart
in different threads. Looks like CPython threading class has fairness issues
when multiple threads try to acquire the same lock and one of them executes
long-running actions while holding it: the others have little chances of
acquiring the lock in order. To get around this issue, the long action (i.e.
retrying the query) is removed.

Investigation by Ants Aasma and Alexander Kukushkin.
2016-09-02 17:00:37 +02:00
Alejandro Martínez d98f255b64 Do not fetch values in configuration checks 2016-09-02 16:44:40 +02:00
Alejandro Martínez 80abe67ed2 Fix missing self 2016-09-02 16:42:40 +02:00
Feike Steenbergen ebf64828e1 Decode output from wal-e list backup
When running this script using Python3, the output is bytestring instead of string.
We explicitly decode it to ensure checks further down are ok.

The trigger for this patch is:

ERROR: unable to get some of WALE backup parameters: 'expanded_size_bytes'
2016-09-02 16:37:06 +02:00
Alejandro Martínez 07e95912a2 Fetch custom_conf dynamically 2016-09-02 16:22:11 +02:00
Alexander KukushkinandGitHub 19c80df442 Try to mitigate EtcdEventIndexCleared exception (#287)
This error is send by etcd when Patroni is doing "watch" on leader key
which is never updated after creation and etcd cluster receives a lot of
updates, what cleans history of events.

Instead of doing watch on modifiedIndex + 1 we will do watch on X-Etcd-Index,
which is probably still available...
2016-09-02 13:44:47 +02:00
Oleksii KliukinandGitHub b0beecffea Merge pull request #276 from zalando/docs/pause_mode_description
Describe the decisions made for the pause mode.
2016-09-02 11:55:39 +02:00
Oleksii KliukinandGitHub 5de0790e89 Merge pull request #284 from zalando/feature/reload_callbacks
Callbacks should be loaded not only on init.
2016-09-02 11:55:21 +02:00
Feike SteenbergenandGitHub 3cd5c8d3dd Merge pull request #286 from zalando/bugfix/external_master_replica
Do not attempt to parse missing conn url
2016-09-02 11:53:42 +02:00
Feike Steenbergen 75cd8422eb Do not attempt to parse missing conn url 2016-09-02 11:25:11 +02:00
Oleksii Kliukin fef4e046e1 Avoid setting the role to unknown during rewind.
Previously, that was necessary in order to avoid repeating the rewind
after failure. Nowadays, depending on the failure, we either want to
retry (if PostgreSQL was not stopped on time or leader did not manage
to acquire a master role yet), or won't retry at all if the leader is
not available, assuming the replica role. In both cases, the hack with
setting the role to unknown seems to be unnecessary and actually stops
callbacks from running if rewind is done not from the first attempt.
2016-09-02 09:00:03 +02:00
Oleksii Kliukin 005d181507 Callbacks should be loaded not only on init.
Load callbacks from config (which is properly reloaded) instead of
reading them from init only once.
2016-09-02 08:56:25 +02:00
Alejandro Martínez f58ff3a96f Document custom_conf parameter 2016-09-01 17:59:47 +02:00
Alejandro Martínez a642860ae8 Turn _configuration_to_save into a property method 2016-09-01 17:06:33 +02:00
Alexander Kukushkin f082ecf60b Set _need_rewind to True if the node was previously known as a master 2016-09-01 11:29:45 +02:00
Alexander Kukushkin db9b62b7ed Merge branch 'master' of github.com:zalando/patroni into feature/disable-automatic-failover 2016-09-01 11:09:09 +02:00
Alexander Kukushkin 33ff372ef6 Always try to rewind on manual failover 2016-09-01 11:08:26 +02:00
Alejandro Martínez 1fb562e118 Add custom_conf parameter documentation 2016-08-31 15:38:42 +02:00
Alejandro Martínez 5c2cad20d7 Add custom_conf configuration parameter
This will be used in place of postgresql.base.conf, to be included on
the main postgresql.conf.
2016-08-31 15:30:31 +02:00
Alexander Kukushkin 0e8220f9f2 BUGFIX: dcs configuration need to be updated from dcs...
loop_wait and ttl is configured cluster-wide via config key stored in
DCS. Depending on values of these parameters we are configuring
different kind of timeouts used in DCS controllers.
In order to fetch this configuration we first need to create DCS
controller and only after apply parameters...
2016-08-31 15:08:31 +02:00
Oleksii KliukinandGitHub 46f1c5b690 Merge pull request #269 from zalando/feature/replica-info
Return replication information on the api
2016-08-31 13:58:19 +02:00
Oleksii KliukinandGitHub adbea3929c Merge pull request #277 from ants/feature/translate-slot-names
Replace invalid characters in member names for replication slot names
2016-08-31 13:56:53 +02:00
Alexander Kukushkin 4d72eef164 Execute API restart outside of lock
Otherwise it was blocking HA loop...
2016-08-31 12:38:02 +02:00
Alexander Kukushkin c0fae1b2e9 Merge branch 'feature/disable-automatic-failover' of github.com:zalando/patroni into feature/disable-automatic-failover 2016-08-30 17:03:37 +02:00
Alexander Kukushkin 1dcdd6eaa0 Acceptance tests for pause mode 2016-08-30 16:50:07 +02:00
Alexander Kukushkin 8028877be0 Remove failover key only after becoming master 2016-08-30 16:49:28 +02:00
Oleksii Kliukin 11359a26a9 Improve incomplete failover is a paused mode.
Instead of empying the stale failover key as a master and bailing
out, continue with the healthiest node evaluation. This should make
the actual master acquire the leader key faster. Emit the warning
message as well and add unit tests.
2016-08-30 12:00:51 +02:00
Oleksii KliukinandGitHub 030ba4c898 Address the review
- fix the false claim that Patroni in a paused mode may promote a master.
- merge all allowed API actions together.
- describe the payload of the API pause request as JSON.
2016-08-30 10:58:20 +02:00
Oleksii Kliukin 0afdb816ba Unfinished promote may not break paused cluster.
When a node to promote dies before finishing the promote and the
cluster is in a standby mode, the failover key sticks indefinitely,
preventing any master to take over the leader role. Prevent it by
letting the node in a master role cleanup the failover key if the
node to failover is not present among the members.

The master check cannot be performed by the node role alone, since
the node will not change its cached role on a manual promote. We
need to check the DB state as well.
2016-08-30 10:38:40 +02:00
Alexander Kukushkin 1374fb3a2d Set role to uninitialized when removing data directory 2016-08-30 10:30:28 +02:00
Ants Aasma fa6bd51ad1 Appease Quantifiedcode about stylistic issues 2016-08-30 00:40:19 +03:00
Ants Aasma e428c8d0fa Replace invalid characters in member names for replication slot names
PostgreSQL replication slot names only allow names consisting of [a-z0-9_].
Invalid characters cause replication slot creation and standby startup to fail.
This change substitutes the invalid characters with underscores or unicode
codepoints. In case multiple member names map to identical replication slots
master log will contain a corresponding error message.

Motivated by wanting to use hostnames as member names. Hostnames often
contain periods and dashes.
2016-08-30 00:21:33 +03:00
Oleksii KliukinandGitHub 64e3e2fb2d Formatting fix 2016-08-29 18:15:16 +02:00
Oleksii KliukinandGitHub de6cba6e04 Rephrase the leader lock section, mention reinit 2016-08-29 18:13:45 +02:00
Alexander Kukushkin 418f8a082f Merge branch 'feature/disable-automatic-failover' of github.com:zalando/patroni into feature/disable-automatic-failover 2016-08-29 15:39:55 +02:00
Alexander Kukushkin 366ed9cc52 fix pep8 formatting and implement missing tests 2016-08-29 15:39:24 +02:00
Alexander Kukushkin 6dc1d9c88e Trigger reinitialize from api
and make it possible to reinitialize in a pause state
2016-08-29 15:38:58 +02:00
Oleksii KliukinandGitHub 2743dc0f7a Spellcheck minor stylistic fixes 2016-08-29 15:35:57 +02:00
Oleksii KliukinandGitHub b498b22584 Minor formatting fix. 2016-08-29 15:23:59 +02:00
Oleksii KliukinandGitHub ebbc8bad4c Create pause.rst 2016-08-29 15:19:18 +02:00
Oleksii Kliukin 8c88e5c7b2 Merge branch 'feature/disable-automatic-failover' of https://github.com/zalando/patroni into feature/disable-automatic-failover 2016-08-29 15:10:34 +02:00
Oleksii Kliukin a9a70d44e2 Make the cached role coherrent with the actual one.
When observing the leader running a master role, set the cached role
stored in the state_handler to master as well. Failure to do so
resulted in the manually promoted node to continue running with a
cached 'replica' role. This led to the failure to create replication
slots for the new replicas.

We could do it conditionally, but both reading and writing the role
require the same lock, and the unconditional approach makes the unit
tests simpler.
2016-08-29 15:04:50 +02:00
Murat Kabilov 799d4c9bb8 Disable command renamed to pause 2016-08-29 14:30:19 +02:00
Murat Kabilov 62f14dfd10 Fix master check 2016-08-29 12:29:34 +02:00
Murat Kabilov 22e4af3fb1 Fix failover in the paused state 2016-08-29 12:04:30 +02:00
Alexander Kukushkin e643321ab7 Address code-review 2016-08-29 11:34:34 +02:00
Alexander Kukushkin 9fdd021e08 Fix unit-tests for api 2016-08-29 10:25:46 +02:00
Murat Kabilov 3d1fe3fa49 Introduce is_paused method in the Cluster 2016-08-29 09:29:49 +02:00
Murat Kabilov 89ef5da5ae Add tests for api; add checks for ctl and api for the paused state case 2016-08-29 08:36:35 +02:00
Alexander Kukushkin 1635f5269e Merge branch 'master' of github.com:zalando/patroni into feature/disable-automatic-failover 2016-08-26 11:09:43 +02:00
Alexander Kukushkin ac49835a3c Possibility to disable automatic failover cluster-wide
Any node of the cluster will maintain it's member key until Patroni is
running there.

Master node will also maintain the leader key until postgres is running
as a master. If there is not postgres or it is running 'in_recovery',
Patroni will release leader lock.

Bootstrap of a new cluster will work (it is possible to specify
paused: true) in the `bootstrap.dcs`. Replicas also will be able to join
the cluster if the leader lock exist.

If the postgres is not running on the node it will not try to bring it
up. Also it disables reinitialize and all kind of scheduled actions, i.e.
scheduled restart and scheduled failover.

In case if DCS stops being reachable Patroni will not "demote" master if
the automatic failover was disabled.

Patroni will not stop postgres on exit.
2016-08-26 10:51:43 +02:00
Alexander Kukushkin 93b9046aed pep8 formatting 2016-08-26 10:51:03 +02:00
Alexander Kukushkin 3977626fc2 Bugfix: and has precedence over or 2016-08-26 10:50:34 +02:00
Murat Kabilov 5c63c9ffbd Pause state improvements 2016-08-25 12:01:05 +02:00
Alexander KukushkinandGitHub 05bcbe90eb close connection on start instead of stop (#272) 2016-08-25 10:43:50 +02:00
Alexander Kukushkin 74166e996c Fix tests and formatting 2016-08-25 10:09:32 +02:00
Alexander Kukushkin 9bb07fb419 Merge branch 'master' of github.com:zalando/patroni into feature/replica-info 2016-08-25 10:09:06 +02:00
Murat Kabilov 4e61ef06a8 Add coverage in requirements
Add some tests for patroni ctl
2016-08-24 18:08:23 +02:00
Murat Kabilov a388fdb99d add paused state actions 2016-08-24 16:13:51 +02:00
Murat KabilovandGitHub b472a32589 Suppress error output on master check (#256) 2016-08-24 12:46:25 +02:00
Feike Steenbergen d4e98b1d5c Merge branch 'feature/replica-info' of github.com:zalando/patroni into feature/replica-info 2016-08-24 12:29:09 +02:00
Feike Steenbergen a09f905a78 Only add replication info if it is found 2016-08-24 12:28:31 +02:00
Feike SteenbergenandGitHub dd5bc1bc9b Merge branch 'master' into feature/replica-info 2016-08-24 11:55:33 +02:00
Feike Steenbergen a573983753 Include usename in replication information
Also only return the key if any replication information is known
2016-08-24 11:54:40 +02:00
Oleksii KliukinandGitHub 52806bb04d Merge pull request #266 from zalando/feature/do_not_drop_active_slots
Do not drop active replication slots.
2016-08-24 11:50:57 +02:00
Alexander KukushkinandGitHub 688736c6b1 Merge branch 'master' into feature/do_not_drop_active_slots 2016-08-24 09:47:12 +02:00
Alexander KukushkinandGitHub 96da6340a9 Calculate future restart time dynamically (#268)
`do_POST_restart` was ramdomly showing not 100% coverage after 2016-08-20 due to hardcoded timestamps.
2016-08-24 09:46:56 +02:00
Feike Steenbergen 1fc8b43b36 Return replication information on the api
To enable better monitoring, it is useful to have replication statistics.
Addresses issue #261
2016-08-24 09:31:49 +02:00
Murat Kabilov 97f7576fab Do not drop active replication slots 2016-08-23 17:30:24 +02:00
Oleksii Kliukin b58ddc559e Do not drop active replication slots.
Master tried to delete all slots that did not correspond to the
replica registered in Patroni. That produced an error for the slots
that were active, potentially preventing drop and creation of other
slots.

Reported by Murat Kabilov.
2016-08-23 17:29:21 +02:00
Alexander KukushkinandGitHub ef0b3c2296 Bring all configs to the new format (#265)
The v1.0 has been released more than one month ago and the new version
is coming. It doesn't make a lot of sense to keep configuration files in
the old format anymore.
In addition to that I've also commented out all the lines enabling and
configuring "archiving" to avoid incidents like here:
https://github.com/zalando/patroni/issues/264
2016-08-23 11:46:16 +02:00
Murat Kabilov c50f072b31 Avoid stopping pg instance when in paused state 2016-08-23 11:38:30 +02:00
Murat Kabilov b5d6b7d13d Check if config is loaded 2016-08-18 17:53:18 +02:00
Murat Kabilov 296f4a7ff8 Introduce disable/resume commands 2016-08-18 17:40:16 +02:00
Ants AasmaandAlexander Kukushkin 494887f47e Enable configuration of PostgreSQL binary locations. (#263)
Adds a bin_dir parameter to PostgreSQL settings that will be prefixed to all command invocations.
2016-08-18 14:06:11 +02:00
Alexander KukushkinandGitHub fa7aa71092 Always call on_start callback when starting Patroni (#262)
When Patroni was "joining" already running postgres it was not calling
callbacks, what in some cases causing issues (callback could be used to
change routing/load-balancer or assign/remove floating (service) ip.

In addition to that we should `start` postgres instead of `restart`-ing
it when doing recovery, because in this case 'on_start' callback should
be called, instead of 'on_restart'
2016-08-18 09:35:13 +02:00
Dmytro AleksandrovandDmytro Aleksandrov e86cf9a722 Attempt to make single binary build with pyinstaller 2016-08-15 23:19:14 +03:00
Oleksii KliukinandGitHub 5b9411b9da Merge pull request #252 from zalando/feature/ctl_scaffolding
Add patronictl scaffold command.
2016-08-10 12:21:12 +02:00
Oleksii Kliukin 179131893e Merge branch 'master' into feature/ctl_scaffolding 2016-08-10 11:49:08 +02:00
Alexander KukushkinandGitHub 8ef7178ddf Refactor code dealing with database connection string/params (#255)
In the original code we were parsing/deparsing url-style connection
strings back and forth. That was not really resource greedy but rather
annoying. Also it was not really obvious how to switch all local
connections to unix-sockets (preferably).

This commit isolates different use-cases of working with connection
strings and minimizes amount of code parsing and deparsing them. Also it
introduces one new helper method in the `Member` object - `conn_kwargs`.
This method can accept as a parameter dict object with credentials
(username and password). As a result it returns dict object which could
be used by `psycopg2.connect` or for building connection urls for
pg_rewind, pg_basebackup or some other replica creation methods.

Params for local connection are builded in the `_local_connect_kwargs`
method and could be changed to unix-socket later easily.
2016-08-10 10:19:52 +02:00
Alexander KukushkinandGitHub 413a84836b Update etcd topology only after original request succeed (#254)
There is no point to try to update topology until original request is
not performed. Also for us it is more important to execute original
request rather then keep topology of etcd cluster in sync.

In addition to that implement the same retry-timeout logic in the
`machines` property which already is used in `api_execute` method.
2016-08-10 10:17:37 +02:00
Alexander KukushkinandGitHub 702ab261a2 Use pgkutil to find dcs modules (#253) 2016-08-10 10:15:55 +02:00
Alexander KukushkinandGitHub 5fe74bec3b Make different kazoo timeouts depend on loop_wait (#243)
* Make different kazoo timeouts dependant on loop_wait

ping timeout ~ 1/2 * loop_wait
connect_timeout ~ 1/2 * loop_wait

Originally these values were calculated from negotiated session timeout
and didn't worked very well, because it was taking significant time to
figure out that connection is dead and reconnect (up to session timeout)
and not giving us time to retry.

* Address the code review
2016-08-10 10:15:09 +02:00
Murat KabilovandGitHub a47a2bceff Manage scheduled restarts using patronictl (#248)
Manage scheduled restarts using patronictl
2016-08-09 12:54:48 +02:00
Oleksii Kliukin ac7abfdd74 Minor fixes, address final rounds of code review. 2016-08-09 10:00:46 +02:00
Oleksii Kliukin 595598533a Add missing file. 2016-08-08 17:44:32 +02:00
Oleksii Kliukin 8416fecfd8 Mix more flake8 warnings. 2016-08-08 17:15:45 +02:00
Oleksii Kliukin 9fd01f6af4 Remove unused imports. 2016-08-08 16:48:14 +02:00
Oleksii Kliukin d9102d2703 Remove the necessity of creating a RESTAPI object.
- We don't want to export RestApi object, since it initializes the
  socket and listens on it.
- Change get_dcs, so that the explicit scope passed to it will take
  priority over the one in the configuration file.
2016-08-08 16:15:57 +02:00
Oleksii Kliukin 53f991df0f More code-review related fixes
- Add missing delete_cluster.
- Simplify parts of the code by removing exception handlers where
  they are not needed.
- Fix typos.
2016-08-08 15:30:33 +02:00
Oleksii Kliukin eeb8f1b694 Further address code reviews.
- Fix the issue in ctl that would result in setting the  listen_address to True.
- Minor stylistic issues.
- Add unit-tests.
2016-08-08 12:21:01 +02:00
Oleksii Kliukin e3cdeb3244 Address code review. 2016-08-05 10:55:38 +02:00
Oleksii Kliukin 1834c087e7 Merge branch 'feature/ctl_scaffolding' of https://github.com/zalando/patroni into feature/ctl_scaffolding 2016-08-04 16:05:51 +02:00
Oleksii Kliukin 113ab6379a Address code review
- Add a new param to the abstract DCS attempt_to_take_leader
- Make sure the cluster is wiped-out properly if we created the
  initialize key, but failed to populate it with leader and member.
  This actually means that we may wipe out the running cluster without
  the intialization key, but that is a very unlikely case in practice.
2016-08-04 15:59:33 +02:00
Oleksii KliukinandGitHub 0a2de034e8 Merge branch 'master' into feature/ctl_scaffolding 2016-08-01 16:28:55 +02:00
Oleksii Kliukin 949821c57b Add patronictl scaffold command.
Creates the cluster structure in DCS as long as the initialize
key does not exist.

The structure consists of the leader and member keys. Both are
intentionally set to never expire, in order to support running the
cluster with the master that doesn't run the Patroni (external master).

Changes to the DCS code as well, in order to support non-expiring
leader and member keys.

Some silly default settings had to be applied if Patroni is unable
to find the configuration file. In particular, the connect address
will point to the localhost. Perhaps we should avoid running with
wihtout the valid configuration altogether, but currently there is
a valid use-case for this behavior, namely the replicas that are
running with the inaccessible master and getting up-to-date with
WAL segments only.
2016-08-01 16:23:08 +02:00
Alexander KukushkinandGitHub 13a9198013 Merge pull request #249 from zalando/travis/python
Travis changed python version to 3.5.2
2016-08-01 12:19:27 +02:00
Oleksii KliukinandGitHub b8a69f11d9 Merge branch 'master' into travis/python 2016-08-01 12:18:28 +02:00
Oleksii KliukinandGitHub af5441eb82 Merge pull request #250 from zalando/delete_old_zappr_conf
Delete .zappr.yml
2016-08-01 12:18:11 +02:00
Oleksii KliukinandGitHub b66fad81cc Delete .zappr.yml 2016-08-01 12:16:33 +02:00
Alexander Kukushkin 1dc16fc12b Travis changed python version to 3.5.2
Plus stick to the flake8==2.6.0 until
https://gitlab.com/pycqa/flake8/issues/164 is not fixed
2016-08-01 09:19:19 +02:00
Oleksii Kliukin 6eb2e21144 Add .zappr.yaml 2016-07-18 17:03:17 +02:00
Oleksii KliukinandGitHub 4f7a0e014b Merge pull request #227 from zalando/feature/scheduled_restarts
Implement scheduled restarts for Patroni via the API. 

Even normal (immediate) restarts will take advantage of additional modifiers you can supply to the restart endpoint:

- restart_pending: restart only if the pending restart flag is set (because of the configuration change)
- role: restart if the Postgres role is set to a certain value
- postgres_version (x.y.z) - restart if the current Postgres version is less than the one specified.

For the scheduled restart, the schedule parameter can be used the same way as it is currently used for the scheduled failovers. Particularly, we don't allow restarts in the past, and always require the timezone to be present in the request in order to avoid client/server TZ difference issues.

Unify the code that evaluates the schedule for the  scheduled restarts and scheduled failovers.
Use the RLock instead of Lock in the async_executor to avoid hanging if the thread takes the lock multiple times (mostly for the with blocks in the api).
2016-07-18 13:30:18 +02:00
Feike Steenbergen f7c44945b7 Fix > 9 PostgreSQL version numbering 2016-07-18 10:35:23 +02:00
Oleksii Kliukin 13b4306f40 Remove one more occurrence of the time bomb 2016-07-14 16:53:02 +02:00
Oleksii Kliukin 6c9ffa4d3c Address the code review
In particular, replace the fixed dates for the future actions
in the unit tests with those that depend on the current date,
avoiding the "timebomb" effect.
2016-07-14 16:39:35 +02:00
Oleksii Kliukin ffd27b5705 Rename with_pending_restart to restart_pending. 2016-07-13 11:07:37 +02:00
Oleksii Kliukin 3181c4e59f Code review, asynchronous restarts.
- Make the restart initiated by the schedule asynchronous
- Fix the placeholders in logs.
- Fix the regexp to detect the PostgreSQL version.
2016-07-12 20:25:01 +02:00
Oleksii Kliukin ec160f0d59 Do not send 2 quotes for the empty request, instead, send None. 2016-07-11 18:20:42 +02:00
Oleksii Kliukin bf95b75489 Use the parameter that really sets the pending_restart flag. 2016-07-11 18:20:15 +02:00
Oleksii KliukinandGitHub 405dbb1cbe Merge pull request #242 from zalando/feature/avoid_initial_wait_for_bootstrap
Set the initialize flag to None and not False initially.
2016-07-11 16:23:58 +02:00
Oleksii Kliukin 1412d16f6e Set the initialize flag to None and not False..
Initially, when the Patroni cluster key is not there, the etcd
and consul modules return an empty cluster value, with the
initialize flag set to False and not None. However, some checks
explictely verify that this flag is None, specifically, the one
that decides whether the new cluster should be bootstrapped.
This leads to the master waiting for the whole loop_wait before
running the initdb (and showing the "waiting for leader to bootstrap"
error): on the second loop_wait, the /service/cluster_name flag
is already there (because of the touch_member code).

Since I don't see where the initialize = False is explictely
differentiated from the None value, I think it makes sense to
set it to None uniformely; that's AFAIK always the case if you
use Zookeeper.
2016-07-11 15:44:20 +02:00
Oleksii Kliukin b17483b7dd Fix the PG version regex. 2016-07-11 15:21:31 +02:00
Oleksii Kliukin c91eda8d78 Merge branch 'master' into feature/scheduled_restarts 2016-07-11 12:56:24 +02:00
Oleksii Kliukin 6da2eecb90 Increase the test coverage. 2016-07-11 11:51:07 +03:00
Oleksii Kliukin 8548956370 Bumped version to 1.0 2016-07-05 17:03:19 +02:00
Oleksii Kliukin 34d18cc182 Set the standard name for the docker-compose file. 2016-07-05 17:02:40 +02:00
Alexander KukushkinandGitHub 58ad8399cf Merge pull request #238 from zalando/bugfix/pg_rewind
One more try to fix pg_rewind
2016-07-05 16:25:03 +02:00
Alexander Kukushkin 659f7617f5 New option: remove_data_directory_on_rewind_failure
One more try to fix pg_rewind
2016-07-05 12:11:15 +02:00
Oleksii Kliukin 8834f929aa Improve the unit tests/coverage. 2016-07-05 10:07:29 +02:00
Oleksii Kliukin 36a86c67d0 Enable the conditions on normal restart. 2016-07-04 15:46:22 +02:00
Alexander KukushkinandGitHub a19dbfaddf Merge pull request #232 from zalando/bugfix/pg_rewind
Start readonly when holding leader lock
2016-07-04 13:11:35 +02:00
Alexander KukushkinandGitHub 40d48eae76 Merge pull request #237 from zalando/bugfix/etcd-retry
Calculate numer of retries and timeout
2016-07-04 13:11:25 +02:00
Alexander Kukushkin f7b9709907 Calculate numer of retries and timeout 2016-07-04 12:01:54 +02:00
Alexander Kukushkin 2944a4bcbd Start readonly when holding the leader lock 2016-07-04 11:08:24 +02:00
Alexander Kukushkin b84e22c4ea Implement more checks in the follow method
Although such situation should not happen in reality (follow method is
not supposed to be called when when the node is holding leader lock and
postgres is running), but to be on the safe side it is better to
implement as much checks as possible, because this method could
potentially remove data directory.
2016-07-04 10:56:37 +02:00
Alexander KukushkinandGitHub f9298d30ca Merge pull request #231 from zalando/bugfix/etcd-retry
Fix retry logic in etcd.py
2016-07-04 10:37:55 +02:00
Alexander KukushkinandGitHub e025f335dc Merge pull request #233 from zalando/bugfix/initialize
Initialize key can be present but empty
2016-07-04 09:48:09 +02:00
Alexander KukushkinandGitHub a6d840d6be Merge pull request #234 from zalando/feature/zookeeper-connect-strategy
Implement different connect strategy for zookeeper
2016-07-04 09:47:55 +02:00
Alexander KukushkinandGitHub cd752fea14 Merge pull request #235 from zalando/bugfix/docker-volumes
Volume could be passed to the docker only with absolute path
2016-07-04 09:47:35 +02:00
Alexander KukushkinandGitHub 3f1d06da32 Merge pull request #236 from zalando/bugfix/strtol
bugfix: strtol didn't worked correctly with 1 digit numbers
2016-07-04 09:47:09 +02:00
Alexander Kukushkin bc9aec9076 bugfix: strtol didn't worked correctly with 1 digit numbers 2016-07-01 16:19:57 +02:00
Alexander Kukushkin b7639f5b22 Volume could be passed to the docker only with absolute path
In addition to that add zookeeper support to the docker.
2016-07-01 12:43:33 +02:00
Alexander Kukushkin f7c6bd4eab Implement different connect strategy for zookeeper
Originally it was trying to connect during session_timeout time.
Such strategy doesn't work good during short network hiccups...
2016-07-01 12:31:29 +02:00
Alexander Kukushkin ee529669d2 Start readonly when holding leader lock
Not starting of postgres was causeing situation when there were no
master running...
2016-07-01 12:28:02 +02:00
Alexander Kukushkin 8bd071d9a9 Initialize key can be present but empty
Nodes were trying to grab initialize key when it didn't contained sysid
2016-07-01 12:25:00 +02:00
Alexander KukushkinandGitHub dc27a30800 Merge pull request #230 from zalando/bugfix/pg_rewind
Try to cover as much as possible pg_rewind corner-cases
2016-06-30 12:09:10 +02:00
Alexander KukushkinandGitHub 489ed20331 Merge pull request #229 from zalando/bugfix/env-zookeeper-hosts
Document that every single zookeeper host:port MUST be quoted
2016-06-30 12:09:03 +02:00
Alexander Kukushkin aa10f42913 checkpoint method returns string status message 2016-06-30 10:45:54 +02:00
Feike Steenbergen 72f8fcbb5b Document the per node timeout 2016-06-29 16:40:39 +02:00
Alexander Kukushkin 876cfdfb2d Fix retry logic in etcd.py
Client class takes care about retrying when connection to the etcd node
fails. It calculates amount of retries and timeout depending on etcd
cluster size.

Etcd class should not retry when EtcdConnectionFailed exception is
raised (this case is already handled in the Client).

Besides that adjust retry timeouts in the Client class.
2016-06-29 15:30:54 +02:00
Alexander Kukushkin 4b67008488 Try to cover as much as possible pg_rewind corner-cases
rewind is not possible when:
1) trying to rewind from themself
2) leader is not reachable
3) leader is_in_recovery

All these cases were leading to removing of data directory...
In all cases except 1) it should "retry" when leader will became
available and not is_in_recovery.
2016-06-29 14:29:31 +02:00
Alexander Kukushkin ae88e7c96e Document that every single zookeeper host:port MUST be quoted
otherwise yaml library can not parse the list.
And make visible yaml exception when trying to parse this list.
2016-06-29 14:25:50 +02:00
Oleksii Kliukin 7a1e2e0c72 Fix the assert message. 2016-06-28 17:11:13 +02:00
Oleksii Kliukin d2832ee43b Address the code review.
Fix return  value in the should_run_scheduled_action and the comments.
Correct the json composition in the scheduled_restart test.
Fix the delete in case there is no scheduled restart.
Fix the usage of format in the logger output.
Fix the indentation in the evaluate_scheduled_restart.
Fix the condition related to the body_is_optional in the do_POST_restart.
Fix a few typos in the error messages.
Fix the _read_json_content
Make the scheduled restart unit-tests a bit less ugly
2016-06-28 16:54:20 +02:00
Alexander KukushkinandGitHub 0c6a1307da Merge pull request #228 from zalando/bugfix/always-expose-role
bugfix: api must report role=master during pg_ctl stop
2016-06-28 15:24:16 +02:00
Alexander KukushkinandGitHub cae3b8ec1e Merge pull request #226 from zalando/bugfix/sigterm
Setup signal handler before creating dcs
2016-06-28 15:24:05 +02:00
Alexander KukushkinandGitHub 6fdd01a8eb Merge pull request #225 from zalando/feature/confd
sample haproxy templates for confd
2016-06-28 15:23:54 +02:00
Alexander KukushkinandGitHub fe0bc2f3eb Merge pull request #223 from zalando/feature/patronictl-at
Acceptance tests for patronictl
2016-06-28 15:23:44 +02:00
Alexander Kukushkin 0318749b56 bugfix: api must report role=master during pg_ctl stop
In addition for that make pg_ctl --timeout option configurable.
If the stop or start didn't succeeded during given timeout when demoting
master, role will be forcibly changed to 'unknown' and all needed
callbacks executed.
2016-06-28 14:14:42 +02:00
Oleksii Kliukin 854ff27e56 Allow normal conditional restarts.
In addition, use the RLock instead of the Lock in async executor
to make sure the lock can be acquired more than once from a single
thread.
2016-06-27 09:50:09 +02:00
Oleksii Kliukin 568eb730bc Clear the scheduled restart after the normal one.
Make sure the scheduled restart flag is cleared when the
postmaster_start_time changes since the time restart was scheduled.

Additionally, separate the logic of checking the restart conditions
into the function in order to support conditions for the normal
restart as well.
2016-06-24 17:39:04 +02:00
Oleksii Kliukin 29845dd383 Restart the node according to the schedule.
The scheduled restart data structures are now independent of those
used by the normal restarts. This would be fixed in subsequent
commits.
Add the behave tests, that cover the POST /restart (but not DELETE).
2016-06-23 10:43:54 +02:00
Alexander Kukushkin 44433c2d42 Setup signal handler before creating dcs
Otherwise it was swallowing SysExit exception in an infinite loop.
2016-06-22 09:13:47 +02:00
Alexander Kukushkin d65d1028a7 Add patroni-compose-etcd-3.yml
For starting up cluster easy with docker-compose.
And unify Dockerfile and scripts to be able to work with docker-compose
and the old one dev_patroni_cluster.sh script
2016-06-21 17:07:24 +02:00
Oleksii Kliukin e5cf06101a Fix line is too long warnings. 2016-06-21 11:19:33 +02:00
Oleksii Kliukin 6a8bfdeb76 Decouple the schedule check from the failover. 2016-06-21 11:02:10 +02:00
Oleksii Kliukin 70195bec2d Handle empty body correctly when reading requests. 2016-06-21 10:56:18 +02:00
Oleksii Kliukin c2490d4831 Merge branch 'master' into feature/scheduled_restarts 2016-06-20 15:38:20 +02:00
Oleksii Kliukin 80b5a370b0 API support restarts when a "pending restart" flag is set. 2016-06-20 15:21:33 +02:00
Oleksii Kliukin 318ca6be38 Implement scheduling and deleting a restart.
The scheduled restart API extends the already existing restart
endpoint by processing the parameters in the request body.

Only one scheduled restart at a time is support. DELETE method
on the /restart endpoint is used to remove an existing restart.
2016-06-20 15:16:22 +02:00
Oleksii Kliukin 95efd72679 Make container name predictable. 2016-06-17 16:37:28 +02:00
Alexander Kukushkin fa01cc828a No need to create symlink for patroni.py 2016-06-17 12:59:12 +02:00
Alexander Kukushkin 5683880de6 bugfix: old mock module does not mock open properly 2016-06-17 12:51:09 +02:00
Alexander Kukushkin ba2c0627be Merge branch 'master' of github.com:zalando/patroni into feature/confd 2016-06-17 12:21:13 +02:00
Alexander Kukushkin e09a012016 extend list of keywords 2016-06-17 12:20:20 +02:00
Alexander Kukushkin bd1e658080 Bugfix: obviously sys.hexversion was one symbol shorter
plus remove some unneeded code
2016-06-17 12:18:41 +02:00
Alexander Kukushkin 23e0eb0aa7 Fix flake8 check with python3 2016-06-17 11:52:47 +02:00
Alexander Kukushkin 50e269470e Add haproxy and confd to docker image
and start them on the node where etcd is running
2016-06-17 11:51:37 +02:00
Alexander Kukushkin 4fbdd3f8a0 Add haproxy confd templates 2016-06-17 11:51:05 +02:00
Alexander KukushkinandGitHub 26b475753e Merge pull request #222 from zalando/bugfix/sigterm
SystemExit exception was swallowed in in thread
2016-06-17 11:06:15 +02:00
Alexander KukushkinandGitHub 8aa6e6050a Merge pull request #219 from zalando/bugfix/docker_fixe
Bugfix/docker fixe
2016-06-17 11:06:01 +02:00
Alexander Kukushkin 27bdc65e46 Fix acceptance tests with python3 2016-06-16 15:27:41 +02:00
Alexander Kukushkin bd5440a102 Fix a typo and call sys.exit on sigterm
otherwise it will wait up to `loop_wait` seconds berfore exiting...
2016-06-16 15:19:21 +02:00
Alexander Kukushkin fcde17583c Acceptance tests for patronictl
Call patronictl.py when it's possible instead of doing REST API calls.
2016-06-16 15:06:18 +02:00
Alexander Kukushkin 69099b060e SystemExit exception was swallowed in in thread
It was causing patroni failing to stop after receiving SIGTERM.
Acceptance tests was killing it with SIGKILL which was causing further tests fail because postgres was still running:
2016-06-16 14:36:24,444 INFO: no action.  i am the leader with the lock
2016-06-16 14:36:25,448 INFO: Lock owner: postgres0; I am postgres0
2016-06-16 14:36:25,452 ERROR: Failed to update /service/batman/optime/leader
Traceback (most recent call last):
  File "/home/akukushkin/git/patroni/patroni/dcs/zookeeper.py", line 208, in write_leader_optime
    self._client.retry(self._client.set, path, last_operation)
  File "/home/akukushkin/git/patroni/py2/local/lib/python2.7/site-packages/kazoo/client.py", line 273, in _retry
    return self._retry.copy()(*args, **kwargs)
  File "/home/akukushkin/git/patroni/py2/local/lib/python2.7/site-packages/kazoo/retry.py", line 123, in __call__
    return func(*args, **kwargs)
  File "/home/akukushkin/git/patroni/py2/local/lib/python2.7/site-packages/kazoo/client.py", line 1219, in set
    return self.set_async(path, value, version).get()
  File "/home/akukushkin/git/patroni/py2/local/lib/python2.7/site-packages/kazoo/handlers/utils.py", line 74, in get
    self._condition.wait(timeout)
  File "/usr/lib/python2.7/threading.py", line 340, in wait
    waiter.acquire()
  File "/home/akukushkin/git/patroni/patroni/utils.py", line 219, in sigterm_handler
    sys.exit()
SystemExit
2016-06-16 14:36:25,453 INFO: no action.  i am the leader with the lock
2016-06-16 14:36:26,443 INFO: Lock owner: postgres0; I am postgres0
2016-06-16 14:36:26,444 INFO: no action.  i am the leader with the lock
2016-06-16 14:59:13 +02:00
Alexander KukushkinandGitHub 17f317665f Merge pull request #221 from zalando/feature/patronictl-auth
patronictl will send authorization header if it is configured
2016-06-16 12:57:14 +02:00
Alexander KukushkinandGitHub 010a2961cb Merge pull request #220 from zalando/feature/patronictl-newconf
Feature/patronictl newconf
2016-06-16 12:56:47 +02:00
Alexander KukushkinandGitHub e29bfaf5cc Merge pull request #218 from zalando/feature/do-not-expose-repl-passwd
Don't expose replication user/passwd in DCS
2016-06-16 12:56:35 +02:00
Alexander Kukushkin 9f5276dd2b patronictl will send authorization header if it is configured
username:password can be configured in the 'restapi' section of config
file or via environment
2016-06-16 12:16:16 +02:00
Alexander Kukushkin fe3a999cb2 Enforce name requirements for dcs implementations
Class implementing AbstractDCS must have name similar to the module
name. I.e. Patroni will load ZooKeeper from zookeeper.py, but not from
exhibitor.py, although it (ZooKeeper) is also available there.
2016-06-16 11:32:16 +02:00
Alexander Kukushkin bd6070e2b0 Make patronictl use config.py for loading config_file
config.py is not only loading config_file but also can build
configuration from environment variables.
2016-06-16 08:50:44 +02:00
Alexander Kukushkin c1b6f1ef24 Make list of available dcs implementations unique.
And exclude AbstractDCS from it.
2016-06-16 08:48:49 +02:00
Alexander Kukushkin 6cf63d1366 Implement copy method
It returns copy of `effective_configuration`.
Don't check that PATRONI_*_USERNAME and PATRONI_*_PASSWORD are set
together. User may want to set only PASSWORD.
2016-06-16 08:45:52 +02:00
Feike Steenbergen 8e59118271 Remove leftovers 2016-06-15 13:37:58 +02:00
Feike Steenbergen 8ddb5908f2 Reduce configuration for Docker dev environment
And rely mostly on the newly implemented environment variables
2016-06-15 13:15:17 +02:00
Feike Steenbergen 3d47814c5e Upgrade to Ubuntu 16.04
And make the Dockerfile build again
2016-06-15 12:51:07 +02:00
Alexander Kukushkin 57807ff337 Don't expose replication user/passwd in DCS 2016-06-15 09:34:04 +02:00
Oleksii KliukinandGitHub 5ffc8206b0 Merge pull request #217 from zalando/LappleApple-patch-2
Updated README to include note to Kubernetes users
2016-06-14 15:15:53 +02:00
Lauri at ZalandoandGitHub 0a2129a5ea Update README.rst 2016-06-14 14:05:25 +02:00
Lauri at ZalandoandGitHub 7561f73f16 Updated README to include note to Kubernetes users
Take a look :)
2016-06-14 14:00:11 +02:00
Alexander Kukushkin 25f20ca7d7 Fix documentation 2016-06-14 10:13:47 +02:00
Alexander KukushkinandGitHub dc6ae7a380 Merge pull request #216 from zalando/feature/dynamic-configuration-docs
Update dynamic_configuration.rst
2016-06-14 10:10:40 +02:00
Alexander KukushkinandGitHub 0a32005cc8 Merge pull request #202 from zalando/feature/dynamic-configuration
Feature/dynamic configuration
2016-06-14 10:10:26 +02:00
Alexander KukushkinandGitHub f2980b13fb Merge pull request #211 from zalando/feature/environment-configuration
Implement possibility to configure Patroni via environment
2016-06-14 10:10:09 +02:00
Oleksii KliukinandGitHub 148562786d Some editorializing and fixing of typos. 2016-06-13 15:48:23 +02:00
Oleksii KliukinandGitHub 16b8afab20 Patroni is capitalized, Postgres is used consistently. 2016-06-13 15:41:41 +02:00
Feike SteenbergenandGitHub dae1ab8c0a Documentation language 2016-06-13 14:55:24 +02:00
Alexander KukushkinandGitHub 8d38887ca1 Update dynamic_configuration.rst 2016-06-13 14:20:20 +02:00
Alexander Kukushkin 3ff1106526 Reset restart_pending flag when parameter was set to the old value
but restart didn't happened.
And small bugfix: node can't rewind from themself.
2016-06-13 14:11:35 +02:00
Alexander Kukushkin a24b29deec use_slots can be changed only globally 2016-06-13 12:54:33 +02:00
Alexander Kukushkin 8829ef6bab Make QuantifiedCode happy 2016-06-13 10:55:15 +02:00
Alexander Kukushkin c64170ef33 Extend list of postgres parameters controlled by Patroni
These parameters usually must be the same across all cluster nodes and
therefore must be set only via global configuration and always passed as
a list of postgres arguments (via pg_ctl) to make it not possible
accidentally change them by 'ALTER SYSTEM'
2016-06-13 10:33:14 +02:00
Alexander Kukushkin e373a1e0bb Hexadecimal can be written as 0X 2016-06-13 10:32:54 +02:00
Alexander Kukushkin 9ecff0f64d Bugfixes
* GET /config was returning latesy "correct" version of dynamic
  configuration.
* PATCH /config was breaking when trying to patch not dict with dict
2016-06-10 12:35:04 +02:00
Alexander Kukushkin 19037daa05 Merge branch 'feature/dynamic-configuration' of github.com:zalando/patroni into feature/environment-configuration 2016-06-10 12:30:37 +02:00
Feike SteenbergenandGitHub 1893f769a8 Merge pull request #214 from zalando/feature/dynamic_configuration
Feature/dynamic configuration
2016-06-10 09:22:48 +02:00
Alexander Kukushkin 95db7259d4 Implement strtol as close as possible to stdlib.strtol 2016-06-10 08:43:08 +02:00
Feike Steenbergen 4ba19d5207 Move documentation into docs directory 2016-06-09 15:32:37 +02:00
Feike Steenbergen 622b62f58d Merge branch 'feature/dynamic_configuration' of github.com:zalando/patroni into feature/dynamic_configuration 2016-06-09 15:31:16 +02:00
Feike Steenbergen d2b701c001 Remove obsolete configuration example 2016-06-09 15:30:22 +02:00
Alexander Kukushkin 49efb371f9 Make it possible to work without config.yml
Most of the basic configuration could be done via ENV
2016-06-09 14:44:29 +02:00
Alexander Kukushkin 7244739e26 Fix link to the libpq-pgpass.html 2016-06-09 12:10:37 +02:00
Alexander Kukushkin e9be5e8462 Configure exhibitor port via ENV 2016-06-09 11:40:10 +02:00
Alexander Kukushkin 4edac15dce Merge branch 'feature/dynamic-configuration' of github.com:zalando/patroni into feature/environment-configuration 2016-06-09 11:24:20 +02:00
Alexander Kukushkin f576311532 Add special treatment for zookeeper.exhibitor section 2016-06-09 11:19:31 +02:00
Alexander Kukushkin 5314433b70 Merge branch 'feature/dynamic-configuration' of github.com:zalando/patroni into feature/environment-configuration 2016-06-09 11:09:30 +02:00
Alexander Kukushkin ce4d814819 Merge branch 'feature/dynamic-configuration' of github.com:zalando/patroni into feature/dynamic-configuration 2016-06-09 11:05:17 +02:00
Alexander Kukushkin 5f4e582660 Merge branch 'master' of github.com:zalando/patroni into feature/dynamic-configuration 2016-06-09 11:04:28 +02:00
Alexander Kukushkin c51039a5e3 Merge pull request #210 from misja/dbname
make dbname and config file configurable
2016-06-09 10:14:28 +02:00
Alexander Kukushkin 96bb2312cb Merge pull request #212 from zalando/feature/split-exhibitor
Split ZooKeeper and Exhibitor
2016-06-09 10:14:20 +02:00
Alexander Kukushkin 5372604170 Merge pull request #213 from zalando/feature/pg_ctl_status
Reimplement pg_ctl status in python
2016-06-09 10:14:13 +02:00
Alexander Kukushkin 57c6641683 Reimplement pg_ctl status in python
subprocess.call was causing problems when server is running under high
load.
2016-06-09 08:28:11 +02:00
Alexander Kukushkin 50d118c3aa Split ZooKeeper and Exhibitor
Originally Exhibitor was supported in the ZooKeeper class and
configuration for Exhibitor was taken also from `zookeeper` section in
the yaml config file. In fact, Exhibitor just extends ZooKeeper and now
it is reflected in the code and also Exhibitor got it's own section in
the config.yaml file. It will make it easier to configure Exhibitor
hosts and port via environment variables when PR#211 will be merged.
2016-06-08 19:21:18 +02:00
Misja Hoebe 6159d92f74 use config_base_name as suggested in
https://github.com/zalando/patroni/pull/210#discussion_r66249672
2016-06-08 15:37:54 +02:00
Alexander Kukushkin 23c5040ce5 Update documentation 2016-06-08 12:35:53 +02:00
Alexander Kukushkin efe45bac63 Merge branch 'feature/environment-configuration' of github.com:zalando/patroni into feature/environment-configuration 2016-06-08 10:32:15 +02:00
Alexander Kukushkin f2fc68acde Fix pep8 formatting 2016-06-08 10:31:59 +02:00
Alexander Kukushkin b65dc9a827 Update ENVIRONMENT.rst 2016-06-08 10:29:37 +02:00
Alexander Kukushkin b7d87f7d07 Implement possibility to configure Patroni via environment 2016-06-08 10:15:24 +02:00
Misja Hoebe b8f5576a51 make config filename configurable 2016-06-07 14:07:05 +02:00
Misja Hoebe 90c1e65adf make database configurable 2016-06-07 12:16:10 +02:00
Alexander Kukushkin 53891ee98e Merge branch 'master' of github.com:zalando/patroni into feature/dynamic-configuration 2016-06-06 10:50:49 +02:00
Alexander Kukushkin 24822bd9ac Returning 304 for POST, PATCH, PUT is not good idea 2016-06-06 10:50:42 +02:00
Alexander Kukushkin 10e4e8354b Merge pull request #204 from zalando/bugfix/password-visibility
Remove password from backup connection string
2016-06-06 10:49:29 +02:00
Alexander Kukushkin 789ce297e9 Merge pull request #206 from zalando/bugfix/demote-follow
BUGFIX: demote is already running in a thread
2016-06-06 10:49:19 +02:00
Alexander Kukushkin 40529d718c Get rid from unused variable 2016-06-03 12:28:31 +02:00
Alexander Kukushkin 16771f37d5 Compare old and new user-defined-parameters to avoid reload
when parameters didn't changed.
Plus get wal_segment_size from pg_settings instead of hardcoding it's value.
2016-06-03 12:11:14 +02:00
Alexander Kukushkin d536b4b62a Rollback changes regarding config_file
It could be set only on the postgres command line anyway.
2016-06-02 16:45:17 +02:00
Alexander Kukushkin 2e5ce4a303 "Smart" compare of postgres parameters
to decide do we need to reload/restart
2016-06-02 16:34:34 +02:00
Alexander Kukushkin ebb9e252d8 Rename restart_pending to pending_restart for compatibility 2016-06-02 09:31:30 +02:00
Alexander Kukushkin 2d78ef0922 CREATE/ALTER USER=>ROLE 2016-06-02 09:28:11 +02:00
Alexander Kukushkin 1c30948ef9 Implement PUT /config and enhance some checks 2016-06-01 17:06:31 +02:00
Alexander Kukushkin c8b5003b86 Set __do_not_watch flag when ttl needs to be changed
it's more readable comparing to `reset_cluster`
2016-06-01 13:41:49 +02:00
Alexander Kukushkin aad2433440 Make QuantifiedCode happier 2016-06-01 10:04:50 +02:00
Alexander Kukushkin 140917ba37 Fix a typo 2016-06-01 09:27:00 +02:00
Alexander Kukushkin 60f7759c5e Small optimization
Don't compare values of configuration if modify_index didn't changed
2016-06-01 09:21:42 +02:00
Alexander Kukushkin a55cbff865 Compare configuration objects "smart" and "deep" 2016-05-31 16:16:29 +02:00
Alexander Kukushkin 1c2e1755cb Explicitly cast some parameters from DCS to int (ttl, loop_wait, etc...) 2016-05-31 15:51:49 +02:00
Alexander Kukushkin e10873dd9c RestApiHandler._patch_config returns True if configuration was changed 2016-05-31 15:49:55 +02:00
Alexander Kukushkin 1cd42d4e47 Get rid from some stupid logic with options=True/False
And some other tricks with overriding handle_one_request and finish
methods from the parent class which were necessary only to make OPTIONS
request from haproxy work with python2, but in fact it was still not
working with python3. Instead of doing all the magic we should simply
give to haproxy what it wants to get: HTTP response code and nothing
more.
2016-05-31 14:42:00 +02:00
Alexander Kukushkin d47671e5b6 ALTER USER does not add LOGIN to the non-login role 2016-05-31 13:18:18 +02:00
Alexander Kukushkin a40377fac1 rename options to only_status_code 2016-05-31 10:34:21 +02:00
Alexander Kukushkin b3ada161cf Implement possibility to configure retry_timeout globally
Previously it was hardcoded all over the place.
2016-05-31 10:30:53 +02:00
Alexander Kukushkin 515e9e34f4 Update SETTINGS.rst accordingly to the new config 2016-05-31 08:58:21 +02:00
Alexander Kukushkin 25f4f943ae Merge branch 'master' of github.com:zalando/patroni into feature/dynamic-configuration 2016-05-31 08:22:07 +02:00
Alexander Kukushkin 9379c036d5 Add comments to set_ttl method
To explain how it's supposed to work and why it manupulates with the
cache of `Cluster` object (calls `reset_cluster`)
2016-05-30 17:02:29 +02:00
Oleksii Kliukin fb29b8426a Remove the obsolete diagram. 2016-05-30 16:22:00 +02:00
Oleksii Kliukin 677bd05369 Add badges back, remove the reference to the outdated flow diagram. 2016-05-30 16:20:58 +02:00
Oleksii Kliukin 1c6113eb97 Remove the quote 2016-05-30 16:18:00 +02:00
Oleksii Kliukin 455b7951b3 Fix the description of the zookeeper and initdb. 2016-05-30 16:17:17 +02:00
Lauri at Zalando 79206b1fc4 Update README.rst 2016-05-30 15:58:34 +02:00
Oleksii Kliukin 3508b33f8d Remove the done items 2016-05-30 15:55:55 +02:00
Oleksii Kliukin 7d7cd2b4e2 Merge pull request #207 from zalando/LappleApple-patch-1
Edited README + added new SETTINGS.rst file.
2016-05-30 15:37:32 +02:00
Lauri at Zalando c346e31d50 Edited README + added new SETTINGS.rst file
Check it out and let me know what you think. Here is the Settings file with the YAML Config Settings:
https://github.com/zalando/patroni/blob/master/SETTINGS.rst

We still need to do a bit more work on the intro paragraph to make Patroni's advantages/benefits a bit more clear. I also might do a bit more magic with the headers/subheads, to stack things a bit more clearly.
2016-05-30 15:25:58 +02:00
Lauri at Zalando 260cb17794 Update SETTINGS.rst 2016-05-30 14:51:29 +02:00
Lauri at Zalando 00cadde425 Update SETTINGS.rst 2016-05-30 14:46:53 +02:00
Lauri at Zalando 0f31a9918a Update SETTINGS.rst 2016-05-30 14:45:47 +02:00
Lauri at Zalando 9a12f6371c Update SETTINGS.rst 2016-05-30 14:44:16 +02:00
Lauri at Zalando e30755ddeb Update SETTINGS.rst 2016-05-30 14:41:56 +02:00
Lauri at Zalando bb00ef40b6 Update SETTINGS.rst 2016-05-30 14:35:15 +02:00
Lauri at Zalando e84b633abd Update SETTINGS.rst 2016-05-30 14:33:50 +02:00
Lauri at Zalando 6fddabf48c Update SETTINGS.rst 2016-05-30 14:31:31 +02:00
Lauri at Zalando 9b34f000a8 Create SETTINGS.rst 2016-05-30 14:29:35 +02:00
Alexander Kukushkin b7359e7b0d Rollback all changes to basic_replication.feature
since I moved all functionality to patroni_api.feature
2016-05-30 12:40:52 +02:00
Alexander Kukushkin f7912991a8 Reshuffle acceptance tests one more time 2016-05-30 12:37:14 +02:00
Alexander Kukushkin 33b6c88fd5 state_handler.follow needs to know cluster.leader 2016-05-30 11:59:58 +02:00
Alexander Kukushkin e085c866dc Reshuffle acceptance tests
Move dynamic config tests from basic_replication to patroni_api
2016-05-30 11:30:41 +02:00
Alexander Kukushkin dd70a3f325 BUGFIX: demote is already running in a thread
It should not call `recover` but `state_handler.follow` directly
(like it is already done in the `clone`).
Otherwise it tries to create a new thread and all async functionality
became borken...
2016-05-30 11:06:16 +02:00
Alexander Kukushkin 619699269b Merge pull request #203 from zalando/bugfix/pg_rewind
Bugfix: pg_rewind can work only with master
2016-05-30 11:01:49 +02:00
Oleksii Kliukin ea29760bd2 Clarify our relationship with the Governor. 2016-05-30 09:52:03 +02:00
Alexander Kukushkin 8b5d6e83e7 fix some bugs revaled by acceptance tests 2016-05-27 17:38:19 +02:00
Alexander Kukushkin 073ef3784f Implement PATCH /config 2016-05-27 16:29:33 +02:00
Alexander Kukushkin 6700cd0aa6 Implement reload of config.yml with REST API call
and acceptance tests for that
2016-05-26 17:09:40 +02:00
Alexander Kukushkin 7fec42c12f Remove password from backup connection string
It's written into pgpass file anyway and ENV variable is set.
2016-05-26 12:01:28 +02:00
Alexander Kukushkin 45cbc8ca70 Implement acceptance test for dynamic configuration functionality
and fix some bugs revealed by acceptance tests
2016-05-26 10:16:24 +02:00
Alexander Kukushkin 342eec5c2f Bugfix: pg_rewind can work only with master 2016-05-25 20:50:28 +02:00
Alexander Kukushkin 89adc0717a Set loglevel back to INFO 2016-05-25 15:02:42 +02:00
Alexander Kukushkin ceace03646 Address codacy and travis issues 2016-05-25 14:49:33 +02:00
Alexander Kukushkin 7827951c8c Dynamic configuration 2016-05-25 14:17:05 +02:00
Oleksii Kliukin 539a23041e Move some parameters around, add new special ones
- name is moved to globals, maximum_lag_on_failover to the bootstrap
- add new parameters that cannot be changed (or those the values of which cannot be lower than default) into the postgresql parameters section.
2016-05-24 14:48:00 +02:00
Alexander Kukushkin 6104d688d9 Merge branch 'master' of github.com:zalando/patroni into feature/sighup 2016-05-19 14:27:04 +02:00
Alexander Kukushkin 082b6f818a Merge pull request #201 from zalando/feature/dcs-package
Find and load dcs class implementations dynamically
2016-05-19 14:25:06 +02:00
Feike Steenbergen dcfbdc7d29 Update .zappr.yml 2016-05-19 14:22:58 +02:00
Feike Steenbergen 4186e73c13 Update .zappr.yml 2016-05-19 14:21:33 +02:00
Feike Steenbergen 79ecfd994a Update .zappr.yml 2016-05-19 14:18:56 +02:00
Feike Steenbergen b43b670195 Update .zappr.yml 2016-05-19 14:17:55 +02:00
Alexander Kukushkin 6a4793bba8 Find and load dcs class implementation dynamically 2016-05-19 12:42:19 +02:00
Alexander Kukushkin 0c2aad98a3 Move dcs implementations into dcs package 2016-05-19 10:57:18 +02:00
Alexander Kukushkin 73cd9951af Merge pull request #200 from zalando/feature/tests-refactoring
Mininize number of references to dcs implementations from tests
2016-05-19 10:38:17 +02:00
Feike Steenbergen 5bfc41d475 Update .zappr.yml 2016-05-19 10:22:13 +02:00
Alexander Kukushkin 1741fa7e0f Mininize number of references to dcs implementations from tests
where it is not necessary (test_ha, test_ctl, etc...)
It will simplyfy further refactoring and make it possible to install
implementations of AbstractDCS independant of each other.
2016-05-19 10:00:32 +02:00
Feike Steenbergen 3ff7850dae Shuffle configuration items 2016-05-18 12:51:05 +02:00
Feike Steenbergen a841cfeec3 Further restructuring of patroni.yml 2016-05-18 11:20:25 +02:00
Feike Steenbergen 22a2bf73fa Merge pull request #197 from zalando/feature/dynamic_config_addons
Document passing on of run-time parameters
2016-05-17 08:51:21 +02:00
Alexander Kukushkin 9323cbd5d6 Add proposal of a new structure of config.yml 2016-05-13 16:53:20 +02:00
Alexander Kukushkin 98c505a16b Remove unused argument 2016-05-13 16:12:46 +02:00
Alexander Kukushkin d422e16aad Implement reload of config.yaml on SIGHUP
If some changes require restart of postgres patroni will expose
`restart_pending` flag in DCS and via REST API
2016-05-13 13:31:21 +02:00
Alexander Kukushkin 6a9fb4fcec Merge pull request #196 from zalando/feature/write-options-to-postgresql.conf
Write postgres options to postgresql.conf (except listen_addresses and port)
2016-05-13 13:29:23 +02:00
Alexander Kukushkin 45a52e21f0 Write postgres options to postgresql.conf
Originally we were passing postgresql options as an argument of `pg_ctl
start`. It was nice and convenient because doesn't require to touch
configuration files but this method has one significant drawback: it
wasn't possible to change values of options which were passed as an
arguments without restart (event for the case when option reqires only
reload). Instead of doing that (passing options as arguments) we will:
1) rename original postgresql.conf to postgresql-base.conf
2) write options into postgresql.conf which has `include
  'postgresql-base.conf'` on the the third line after comment that this
  file is generated by Patroni and you should not change it manually
3) listen_addresses and port are still passed as an arguments to the
  pg_ctl (just to be foolproof against ALTER SYSTEM set port to 'random')

In addition to that this commit makes some attributes of `Postgresql`
class private (prefixes them with _)
2016-05-13 12:40:04 +02:00
Feike Steenbergen 7ed2052707 Document passing on of run-time parameters 2016-05-13 12:30:12 +02:00
Feike Steenbergen c3317f748e Spelling only 2016-05-13 09:17:23 +02:00
Alexander Kukushkin 8a60c915cf Merge pull request #195 from zalando/bugfix/do-not-remove-data
Remove data directory only if replica creation failed
2016-05-11 12:12:41 +02:00
Oleksii Kliukin 622e110f0a More formatting fixes 2016-05-11 09:57:40 +02:00
Oleksii Kliukin 688a09396d Fix list formatting 2016-05-11 09:55:39 +02:00
Oleksii Kliukin c1140ed7a8 Change the default options to bootstrap 2016-05-11 09:53:18 +02:00
Oleksii Kliukin 9e6ca617de Add spec for the dynamic configuration. 2016-05-11 09:48:54 +02:00
Alexander Kukushkin d316a08c26 Merge branch 'master' of github.com:zalando/patroni into bugfix/do-not-remove-data 2016-05-10 13:14:58 +02:00
Alexander Kukushkin 14527c69d1 Merge pull request #194 from zalando/feature/api-request-handler-finish
finish method does not have any arguments
2016-05-10 13:10:32 +02:00
Alexander Kukushkin 5f33cf32f4 Merge pull request #193 from zalando/feature/primary_conninfo-application_name
Set application_name to node name in primary_conninfo
2016-05-10 13:10:19 +02:00
Alexander Kukushkin a615339b48 Merge pull request #192 from zalando/feature/api-sysid-no-errors
Reset _sysid and don't call pg_controldata when restore of backup in progress
2016-05-10 13:10:07 +02:00
Alexander Kukushkin 1a0aefeb63 Merge pull request #191 from zalando/feature/api-tests-request-encode
Encode request body only once in a MockRequest
2016-05-10 13:09:31 +02:00
Oleksii Kliukin e839386283 Remove the comment that is oudated since the bootstrap separation from the create replica. 2016-05-10 12:56:36 +02:00
Alexander Kukushkin 36d187ee1f Remove data directory only if replica creation failed
And follow the right node after replica creation (it was following
the same node from which it took the backup)
2016-05-10 10:59:26 +02:00
Alexander Kukushkin edf372e8b6 Reset _sysid and don't call pg_controldata when restore of backup in progress
Otherwise there were some errors in a log from rest-api healthcheck endpoint
2016-05-09 15:36:49 +02:00
Alexander Kukushkin 73119f96aa Set application_name to node name in primary_conninfo
It will make it simplier to identify node the in pg_stat_replication view
2016-05-09 09:47:32 +02:00
Alexander Kukushkin defc987328 Encode request body only once in a MockRequest
to avoid using bytestrings all over the file
2016-05-09 09:33:54 +02:00
Alexander Kukushkin 2adf593fb8 finish method does not have any arguments 2016-05-09 09:31:29 +02:00
Oleksii Kliukin f801a08974 Bumped version to 0.90 2016-04-27 12:04:52 +02:00
Alexander Kukushkin eabfd82a5d Implement Consul support 2016-04-27 10:59:01 +02:00
Alexander Kukushkin ec7779fed6 Merge pull request #188 from zalando/bugfix/python351
urllib3.exceptions.HTTPError fixes for python 3.5.1
2016-04-26 11:55:19 +02:00
Alexander Kukushkin bcbc080350 urllib3.exceptions.HTTPError fixes for python 3.5.1
Somehow when you import only urllib3 it's not possible work with
urllib3.exceptions.HTTPError exception (it looks like it is imported
from some other place. from urllib3.exceptions import HTTPError solves
the problem.
2016-04-24 14:18:34 +02:00
Feike Steenbergen 9a79ba25ef Fix tests
Remove unnecessary patching of functions.
Update mocked response to be inline with introduced config change.
2016-04-22 16:28:00 +02:00
Alexander Kukushkin 0c6d259a0a Merge pull request #187 from zalando/feature/zookeeper-rename-variable
Rename me->member (variable name was too confusing)
2016-04-22 16:10:32 +02:00
Feike Steenbergen dcdaabc747 Merge pull request #184 from zalando/feature/unify_patronictl_config_with_patroni
Unify Patroni and Patronictl configuration
2016-04-22 16:08:36 +02:00
Feike Steenbergen c7ebe4aa63 Unify Patroni and Patronictl configuration
A Patroni configuration should be enough for Patronictl
The previous dcs_api url style is still supported.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reference build fail:

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

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

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

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

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

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

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

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

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

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

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

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

psql -d "$(patronictl dsn alpha)"

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

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

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

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

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

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

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

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

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

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

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

It's better to execute this request on the master node. It will send a
signal to the main HA loop which makes possible to release leader key
immidiately even if you are working with etcd.
2015-10-15 16:18:28 +02:00
Alexander Kukushkin f35d109810 Bugfix: do not try to double encode data 2015-10-15 16:17:11 +02:00
Alexander Kukushkin 16a0a3481d fix pep8 formatting 2015-10-15 09:08:33 +02:00
Alexander Kukushkin 2f0cf1db06 Mock etcd client delete method 2015-10-15 09:08:16 +02:00
Oleksii Kliukin 5c86b60cd2 Fix an exception in the (rather unusual) case of attaching Patroni to an existing running replica. 2015-10-14 17:05:09 +02:00
Feike Steenbergen 98b59354a9 Exclude more files from git. 2015-10-14 14:37:05 +02:00
Oleksii Kliukin 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
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
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
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 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
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
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
78 changed files with 9502 additions and 2690 deletions
+48 -3
View File
@@ -1,3 +1,48 @@
data/* *.py[cod]
*.pyc
helpers/*.pyc # vi(m) swap files:
*.sw?
# C extensions
*.so
# Packages
.cache/
*.egg
*.eggs
*.egg-info
dist
build
eggs
parts
bin
var
sdist
develop-eggs
.installed.cfg
lib
lib64
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
nosetests.xml
coverage.xml
htmlcov
junit.xml
features/output
dummy
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
pgpass
scm-source.json
+76 -8
View File
@@ -1,14 +1,82 @@
sudo: false
language: python language: python
python: python:
- "2.7" - "3.5"
- "3.3" addons:
- "3.4" apt:
packages:
- postgresql-contrib-9.5
postgresql: "9.5"
env:
global:
- ETCDVERSION=2.3.2 ZKVERSION=3.4.6 CONSULVERSION=0.6.4
matrix:
- TEST_SUITE="python setup.py"
- DCS="etcd" TEST_SUITE="behave"
- DCS="exhibitor" TEST_SUITE="behave"
- DCS="consul" TEST_SUITE="behave"
cache:
directories:
- $HOME/virtualenv/python2.7.9
- $HOME/virtualenv/python3.4.2
- $HOME/virtualenv/python3.5.2
install: install:
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install -r requirements-py2.txt --use-mirrors; fi - |
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -r requirements-py3.txt --use-mirrors; fi set -e
- pip install coveralls
if [[ $TEST_SUITE == "behave" ]]; then
if [[ $DCS == "consul" ]]; then
curl -L https://releases.hashicorp.com/consul/${CONSULVERSION}/consul_${CONSULVERSION}_linux_amd64.zip \
| gunzip > consul
chmod +x consul
fi
if [[ $DCS == "etcd" ]]; then
curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
| tar xz -C . --strip=1 --wildcards --no-anchored etcd
fi
if [[ $DCS == "exhibitor" ]]; then
curl -L http://www.apache.org/dist/zookeeper/zookeeper-${ZKVERSION}/zookeeper-${ZKVERSION}.tar.gz | tar xz
mv zookeeper-${ZKVERSION}/conf/zoo_sample.cfg zookeeper-${ZKVERSION}/conf/zoo.cfg
zookeeper-${ZKVERSION}/bin/zkServer.sh start
# following lines are 'emulating' exhibitor REST API
while true; do
echo -e 'HTTP/1.0 200 OK\nContent-Type: application/json\n\n{"servers":["127.0.0.1"],"port":2181}' \
| nc -l 8181 &> /dev/null
done&
fi
fi
for pv in "2.7" "3.4" "3.5"; do
source ~/virtualenv/python${pv}/bin/activate
# explicitly install all needed python modules to cache them
for p in '-r requirements.txt' 'behave codacy-coverage coverage coveralls flake8==2.6.0 mock>=2.0.0 pytest-cov pytest'; do
pip install $p
done
done
script: script:
- python setup.py test - |
- python setup.py flake8 for pv in "2.7" "3.4" "3.5"; do
source ~/virtualenv/python${pv}/bin/activate
if [[ $TEST_SUITE == "behave" ]]; then
if [[ $pv != "3.4" ]]; then
echo Running acceptance tests using python${pv}
if ! PATH=.:$PATH $TEST_SUITE; then
# output all log files when tests are failing
grep . features/output/*/*postgres?.*
exit 1
fi
fi
else
echo Running unit tests using python${pv}
$TEST_SUITE test
$TEST_SUITE flake8
fi
done
set +e
after_success: after_success:
- coveralls - coveralls
- if [[ $TEST_SUITE != "behave" ]]; then python-codacy-coverage -r coverage.xml; fi
+13
View File
@@ -0,0 +1,13 @@
# for github.com
approvals:
groups:
zalando:
minimum: 2
from:
orgs:
- "zalando"
# team should be valid team id in team service https://teams.auth.zalando.com/api/teams/:id
X-Zalando-Team: "acid"
# type should be one of [code, doc, config, tools, secrets]
# code will be the default value, if X-Zalando-Type is not found in .zappr.yml
X-Zalando-Type: code
+30 -21
View File
@@ -1,35 +1,44 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine ## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
## It has all the necessary components to play/debug with a single node appliance, running etcd ## It has all the necessary components to play/debug with a single node appliance, running etcd
FROM ubuntu:14.04 FROM ubuntu:16.04
MAINTAINER Feike Steenbergen <[email protected]> MAINTAINER Feike Steenbergen <[email protected]>
# We need curl RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend \
RUN apt-get update -y && apt-get install curl -y && echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend
# Add PGDG repositories
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list
RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN apt-get update -y
RUN apt-get upgrade -y
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 pip install python-etcd
ENV PGVERSION 9.5
ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH
RUN apt-get update -y \
&& apt-get upgrade -y \
&& apt-get install -y curl jq haproxy zookeeper postgresql-${PGVERSION} python-psycopg2 python-yaml \
python-requests python-six python-click python-dateutil python-tzlocal python-urllib3 \
python-dnspython python-pip python-setuptools python-kazoo python-prettytable python \
&& pip install python-etcd==0.4.3 python-consul==0.6.0 --upgrade \
&& apt-get remove -y python-pip python-setuptools \
&& apt-get autoremove -y \
# Clean up
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* /root/.cache
ADD patroni.py /patroni.py ENV ETCDVERSION 2.3.6
ADD patroni/ /patroni RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
ENV ETCDVERSION 2.0.13 ENV CONFDVERSION 0.11.0
RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C /bin --strip=1 --wildcards --no-anchored etcd etcdctl RUN curl -L https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-amd64 > /usr/local/bin/confd \
&& chmod +x /usr/local/bin/confd
ADD patronictl.py patroni.py docker/entrypoint.sh /
ADD patroni /patroni/
ADD extras/confd /etc/confd
RUN ln -s /patronictl.py /usr/local/bin/patronictl
### Setting up a simple script that will serve as an entrypoint ### Setting up a simple script that will serve as an entrypoint
RUN mkdir /data/ && touch /var/log/etcd.log /var/log/etcd.err /pgpass /patroni/postgres.yml RUN mkdir /data/ && touch /pgpass /patroni.yml \
RUN chown postgres:postgres -R /patroni/ /data/ /pgpass /var/log/etcd.* /patroni/postgres.yml && chown postgres:postgres -R /patroni/ /data/ /pgpass /patroni.yml /etc/haproxy /var/run/ /var/lib/ /var/log/ \
ADD docker/entrypoint.sh /entrypoint.sh && echo 1 > /etc/zookeeper/conf/myid
EXPOSE 4001 5432 2380 EXPOSE 2379 5432 8008
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
USER postgres USER postgres
View File
+3
View File
@@ -0,0 +1,3 @@
Alexander Kukushkin <[email protected]>
Feike Steenbergen <[email protected]>
Oleksii Kliukin <[email protected]>
+75 -169
View File
@@ -1,34 +1,68 @@
|Build Status| |Coverage Status| |Build Status| |Coverage Status|
Patroni: A Template for PostgreSQL HA with ZooKeeper or etcd Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul
------------------------------------------------------------ ------------------------------------------------------------
There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
Patroni was previously known as Governor. Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__ or `Consul <https://github.com/hashicorp/consul>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful.
*There are many ways to run high availability with PostgreSQL; here we We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
present a template for you to create your own custom fit high
availability solution using python and distributed configuration store
(like ZooKeeper or etcd) for maximum accessibility.*
Getting Started **Note to Kubernetes users**: We're currently developing Patroni to be as useful as possible for teams running Kubernetes on top of Google Compute Engine; Patroni can be the HA solution for Postgres in such an environment. Please contact us via our Issues Tracker if this describes your team's current setup, and we'll follow up.
---------------
.. contents::
:local:
:depth: 1
:backlinks: none
==============
How Patroni Works
==============
Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando.
For additional background info, see:
* `PostgreSQL HA with Kubernetes and Patroni <https://www.youtube.com/watch?v=iruaCgeG7qs>`__, talk by Josh Berkus at KubeCon 2016 (video)
* `Feb. 2016 Zalando Tech blog post <https://tech.zalando.de/blog/zalandos-patroni-a-template-for-high-availability-postgresql/>`__
================
Development Status
================
Patroni is in active development and accepts contributions. See our `Contributing <https://github.com/zalando/patroni/blob/master/README.rst#contributing>`__ section below for more details.
===========================
Technical Requirements/Installation
===========================
**For Mac**
To install requirements on a Mac, run the following:
::
brew install postgresql etcd haproxy libyaml python
pip install psycopg2 pyyaml
===================
Running and Configuring
===================
To get started, do the following from different terminals: To get started, do the following from different terminals:
:: ::
> etcd --data-dir=data/etcd > etcd --data-dir=data/etcd
> ./patroni.py postgres0.yml > ./patroni.py postgres0.yml
> ./patroni.py postgres1.yml > ./patroni.py postgres1.yml
From there, you will see a high-availability cluster start up. Test You will then see a high-availability cluster start up. Test different settings in the YAML files to see how the cluster's behavior changes. Kill some of the components to see how the system behaves.
different settings in the YAML files to see how behavior changes. Kill
some of the different 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.
We provide a haproxy configuration, which will give your application a Patroni provides an `HAProxy <http://www.haproxy.org/>`__ configuration, which will give your application a single endpoint for connecting to the cluster's leader. To configure,
single endpoint for connecting to the cluster's leader. To configure,
run: run:
:: ::
@@ -39,182 +73,54 @@ run:
> psql --host 127.0.0.1 --port 5000 postgres > psql --host 127.0.0.1 --port 5000 postgres
How Patroni works ===============
-----------------
For a diagram of the high availability decision loop, see the included a
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 Go `here <https://github.com/zalando/patroni/blob/master/docs/SETTINGS.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
settings:
- *ttl*: the TTL to acquire the leader lock. Think of it as the length =========================
of time before automatic failover process is initiated. Environment Configuration
- *loop\_wait*: the number of seconds the loop will sleep =========================
- *restapi* Go `here <https://github.com/zalando/patroni/blob/master/docs/ENVIRONMENT.rst>`__ for comprehensive information about configuring(overriding) settings via environment variables.
- *listen*: ip address + port that Patroni will listen to provide
health-check information for haproxy.
- *connect\_address*: ip address + port through which restapi is
accessible.
- *etcd* ===============
- *scope*: the relative path used on etcd's http api for this Replication Choices
deployment, thus you can run multiple HA deployments from a single ===============
etcd
- *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* Patroni uses Postgres' streaming replication, which is asynchronous by default. For more information, see the `Postgres documentation on streaming replication <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__.
- *scope*: the relative path used on etcd's http api for this
deployment, thus you can run multiple HA deployments from a single
etcd
- *session\_timeout*: the TTL to acquire the leader lock. Think of it
as the length of time before automatic failover process is initiated.
- *reconnect\_timeout*: how long we should try to reconnect to
ZooKeeper after connection loss. After this timeout we assume that we
don't have lock anymore and will restart in read-only mode.
- *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 Patroni's asynchronous replication configuration allows for ``maximum_lag_on_failover`` settings. This setting ensures failover will 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 business requirements.
should be updated from Exhibitor
- *port*: Exhibitor port
- *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: [
'host1', 'host2', 'etc...' ]. This list would be updated
automatically when Exhibitor (ZooKeeper) cluster topology changes.
- *postgresql* When asynchronous replication is not optimal for your use case, investigate Postgres's `synchronous replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__. Synchronous replication ensures consistency across a cluster by confirming that writes are written to a secondary before returning to the connecting client with a success. The cost of synchronous replication: reduced throughput on writes. This throughput will be entirely based on network performance.
- *name*: the name of the Postgres host, must be unique for the cluster
- *listen*: ip address + port that Postgres listening. Must be
accessible from other nodes in the cluster if using streaming
replication.
- *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* In hosted datacenter environments (like AWS, Rackspace, or any network you do not control), synchronous replication significantly increases the variability of write performance. If followers become inaccessible from the leader, the leader effectively becomes read-only.
- *replication* To enable a simple synchronous replication test, add the follow lines to the ``parameters`` section of your YAML configuration files:
- *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
- *callbacks* callback scripts to run on certain actions. Patroni will
pass current action, role and cluster name. See scripts/aws.py as an
example on how to write them.
- *on\_start*: a script to run when the cluster starts
- *on\_stop*: a script to run when the cluster stops
- *on\_restart*: a script to run when the cluster restarts
- *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*
- *password*: password for postgres user. It would be set during
initialization
- *admin*:
- *username*: admin username, user will be created during
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
replication is asynchronous. For more information, see the `Postgres
documentation on streaming
replication <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__.
Patroni's asynchronous replication configuration allows for
``maximum_lag_on_failover`` settings. This setting ensures failover will
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
business requirements.
When asynchronous replication is not best for your use-case, investigate
how Postgres's `synchronous
replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__
works. Synchronous replication ensures consistency across a cluster by
confirming that writes are written to a secondary before returning to
the connecting client with a success. The cost of synchronous
replication will be reduced throughput on writes. This throughput will
be entirely based on network performance. In hosted datacenter
environments (like AWS, Rackspace, or any network you do not control),
synchrous replication increases the variability of write performance
significantly. If followers become inaccessible from the leader, the
leader will becomes effectively readonly.
To enable a simple synchronous replication test, add the follow lines to
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 considerations. Investigate both async and sync replication, as well as other HA solutions, to determine which solution is best for you.
decisions. Investigate both async and sync replication, as well as other
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 superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable.
requires access to the database to function properly. By using a
superuser from application, you can potentially use the entire
connection pool, including the connections reserved for superusers with
the ``superuser_reserved_connections`` setting. If Patroni cannot access
the Primary, because the connection pool is full, behavior will be
undesireable.
Requirements on a Mac ================
--------------------- Contributing
================
Patroni accepts contributions from the open-source community; see the `Issues Tracker <https://github.com/zalando/patroni/issues>`__ for current needs.
Run the following on a Mac to install requirements: Before making a contribution, please let us know by posting a comment to the relevant issue.
If you would like to propose a new feature, please first file a new issue explaining the feature you'd like to create.
::
brew install postgresql etcd haproxy libyaml python
pip install psycopg2 pyyaml
Notice
------
There are many different ways to do HA with PostgreSQL, see `the
PostgreSQL
documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__
for a complete list.
We call this project a "template" because it is far from a one-size fits
all, or a plug-and-play replication system. It will have it's own
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
+10 -2
View File
@@ -1,4 +1,12 @@
Failover Failover
======== ========
- When determining who should become master, include the minor version of PostgreSQL in the decision - When determining who should become master, include the minor version of PostgreSQL in the decision.
- Create a way to disable governance of a cluster, something like the existence of a "nogover" or "admin" file in PGDATA will stop governor from changing the cluster state
Configuration
==============
- Provide a way to change pg_hba.conf of a running cluster on the Patroni level, without changing individual nodes.
- Provide hooks to store and retrieve cluster-wide passwords without exposing them in a plain-text form to unauthorized users.
Documentation
==============
- Document how to run cascading replication and possibly initialize the cluster without an access to the master node.
+58
View File
@@ -0,0 +1,58 @@
# docker compose file for running a 3-node PostgreSQL cluster
# with etcd as the SIS
patroni_etcd:
container_name: patroni_etcd
image: patroni
command: --etcd
dbnode1:
image: patroni
hostname: dbnode1
links:
- patroni_etcd:patroni_etcd
volumes:
- ./patroni:/patroni
env_file: docker/patroni-secrets.env
environment:
PATRONI_ETCD_HOST: patroni_etcd:2379
PATRONI_NAME: dbnode1
PATRONI_SCOPE: testcluster
dbnode2:
image: patroni
hostname: dbnode2
links:
- patroni_etcd:patroni_etcd
volumes:
- ./patroni:/patroni
env_file: docker/patroni-secrets.env
environment:
PATRONI_ETCD_HOST: patroni_etcd:2379
PATRONI_NAME: dbnode2
PATRONI_SCOPE: testcluster
dbnode3:
image: patroni
hostname: dbnode3
links:
- patroni_etcd:patroni_etcd
volumes:
- ./patroni:/patroni
env_file: docker/patroni-secrets.env
environment:
PATRONI_ETCD_HOST: patroni_etcd:2379
PATRONI_NAME: dbnode3
PATRONI_SCOPE: testcluster
haproxy:
image: patroni
links:
- patroni_etcd:patroni_etcd
ports:
- "5000"
- "5001"
environment:
PATRONI_ETCD_HOST: patroni_etcd:2379
PATRONI_SCOPE: testcluster
command: --confd
+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
+32 -18
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
@@ -67,24 +67,38 @@ while getopts "$optspec" optchar; do
esac esac
done done
function random_name() if [ -z ${PATRONI_SCOPE} ]; then
{ PATRONI_SCOPE=$(cat /dev/urandom | LC_ALL=C tr -dc 'a-z0-9' | head -c 8)
cat /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c 8
}
if [ -z ${PATRONI_SCOPE} ]
then
PATRONI_SCOPE=$(random_name)
fi fi
etcd_container=$(docker run -P -d --name="${PATRONI_SCOPE}_etcd" "${DOCKER_IMAGE}" --etcd-only) function docker_run()
etcd_container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${etcd_container}) {
echo "The etcd container is ${etcd_container}, ip=${etcd_container_ip}" local name=$1
shift
container=$(docker run -d --name=$name $*)
container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${container})
echo "Started container ${name}, ip=${container_ip}"
}
for i in $(seq 1 "${MEMBERS}")
do ETCD_CONTAINER="${PATRONI_SCOPE}_etcd"
container_name=$(random_name) docker_run ${ETCD_CONTAINER} ${DOCKER_IMAGE} --etcd
patroni_container=$(docker run -P -d --name="${PATRONI_SCOPE}_${container_name}" "${DOCKER_IMAGE}" --etcd="${etcd_container_ip}:4001" --name="${PATRONI_SCOPE}")
patroni_container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${patroni_container}) DOCKER_ARGS="--link=${ETCD_CONTAINER}:${ETCD_CONTAINER} -e PATRONI_SCOPE=${PATRONI_SCOPE} -e PATRONI_ETCD_HOST=${ETCD_CONTAINER}:2379"
echo "Started Patroni container ${patroni_container}, ip=${patroni_container_ip}" PATRONI_ENV=$(sed 's/#.*//g' docker/patroni-secrets.env | sed -n 's/^PATRONI_.*$/-e &/p' | tr '\n' ' ')
PATRONI_VOLUME="-v $(dirname $(dirname $(realpath $0)))/patroni:/patroni"
for i in $(seq 1 "${MEMBERS}"); do
container_name=postgres${i}
docker_run "${PATRONI_SCOPE}_${container_name}" \
$PATRONI_VOLUME \
$DOCKER_ARGS \
$PATRONI_ENV \
-e PATRONI_NAME=${container_name} \
${DOCKER_IMAGE}
done done
docker_run "${PATRONI_SCOPE}_haproxy" \
-p=5000 -p=5001 \
$DOCKER_ARGS \
${DOCKER_IMAGE} --confd
+61 -83
View File
@@ -3,53 +3,56 @@
function usage() function usage()
{ {
cat <<__EOF__ cat <<__EOF__
Usage: $0 Usage: $0
Options: Options:
--etcd ETCD Provide an external etcd to connect to --etcd Do not run Patroni, run a standalone etcd
--name NAME Give the cluster a specific name --confd Do not run Patroni, run a standalone confd
--etcd-only Do not run Patroni, run a standalone etcd --zookeeper Do not run Patroni, run a standalone zookeeper
Examples: Examples:
$0 --etcd=127.17.0.84:4001 $0 --etcd
$0 --etcd-only $0 --confd
$0 $0 --zookeeper
$0 --name=true_scotsman $0
__EOF__ __EOF__
} }
DOCKER_IP=$(hostname --ip-address) DOCKER_IP=$(hostname --ip-address)
PATRONI_SCOPE=batman PATRONI_SCOPE=${PATRONI_SCOPE:-batman}
ETCD_ARGS="--data-dir /tmp/etcd.data -advertise-client-urls=http://${DOCKER_IP}:2379 -listen-client-urls=http://0.0.0.0:2379 -listen-peer-urls=http://0.0.0.0:2380"
optspec=":vh-:" optspec=":vh-:"
while getopts "$optspec" optchar; do while getopts "$optspec" optchar; do
case "${optchar}" in case "${optchar}" in
-) -)
case "${OPTARG}" in case "${OPTARG}" in
etcd-only) confd)
exec etcd --data-dir /tmp/etcd.data \ haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D
-advertise-client-urls=http://${DOCKER_IP}:4001 \ CONFD="confd -prefix=${PATRONI_NAMESPACE:-/service}/$PATRONI_SCOPE -interval=10 -backend"
-listen-client-urls=http://0.0.0.0:4001 \ if [ ! -z ${PATRONI_ZOOKEEPER_HOSTS} ]; then
-listen-peer-urls=http://0.0.0.0:2380 while ! /usr/share/zookeeper/bin/zkCli.sh -server ${PATRONI_ZOOKEEPER_HOSTS} ls /; do
exit 0 sleep 1
done
exec $CONFD zookeeper -node ${PATRONI_ZOOKEEPER_HOSTS}
else
while ! curl -s ${PATRONI_ETCD_HOST}/v2/members | jq -r '.members[0].clientURLs[0]' | grep -q http; do
sleep 1
done
exec $CONFD etcd -node $PATRONI_ETCD_HOST
fi
;;
etcd)
exec etcd $ETCD_ARGS
;;
zookeeper)
exec /usr/share/zookeeper/bin/zkServer.sh start-foreground
;; ;;
cheat) cheat)
CHEAT=1 CHEAT=1
;; ;;
name)
PATRONI_SCOPE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
;;
name=*)
PATRONI_SCOPE=${OPTARG#*=}
;;
etcd)
ETCD_CLUSTER="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
;;
etcd=*)
ETCD_CLUSTER=${OPTARG#*=}
;;
help) help)
usage usage
exit 0 exit 0
@@ -70,68 +73,43 @@ while getopts "$optspec" optchar; do
esac esac
done done
if [ -z ${ETCD_CLUSTER} ] ## We start an etcd
then if [[ -z ${PATRONI_ETCD_HOST} && -z ${PATRONI_ZOOKEEPER_HOSTS} ]]; then
etcd --data-dir /tmp/etcd.data \ etcd $ETCD_ARGS > /var/log/etcd.log 2> /var/log/etcd.err &
-advertise-client-urls=http://${DOCKER_IP}:4001 \ export PATRONI_ETCD_HOST="127.0.0.1:2379"
-listen-client-urls=http://0.0.0.0:4001 \
-listen-peer-urls=http://0.0.0.0:2380 > /var/log/etcd.log 2> /var/log/etcd.err &
ETCD_CLUSTER="127.0.0.1:4001"
fi fi
cat > /patroni/postgres.yml <<__EOF__ export PATRONI_SCOPE
export PATRONI_NAME="${PATRONI_NAME:-${HOSTNAME}}"
export PATRONI_RESTAPI_CONNECT_ADDRESS="${DOCKER_IP}:8008"
export PATRONI_RESTAPI_LISTEN="0.0.0.0:8008"
export PATRONI_admin_PASSWORD="${PATRONI_admin_PASSWORD:=admin}"
export PATRONI_admin_OPTIONS="${PATRONI_admin_OPTIONS:-createdb, createrole}"
export PATRONI_POSTGRESQL_CONNECT_ADDRESS="${DOCKER_IP}:5432"
export PATRONI_POSTGRESQL_LISTEN="0.0.0.0:5432"
export PATRONI_POSTGRESQL_DATA_DIR="data/${PATRONI_SCOPE}"
export PATRONI_REPLICATION_USERNAME="${PATRONI_REPLICATION_USERNAME:-replicator}"
export PATRONI_REPLICATION_PASSWORD="${PATRONI_REPLICATION_PASSWORD:-abcd}"
export PATRONI_SUPERUSER_USERNAME="${PATRONI_SUPERUSER_USERNAME:-postgres}"
export PATRONI_SUPERUSER_PASSWORD="${PATRONI_SUPERUSER_PASSWORD:-postgres}"
export PATRONI_POSTGRESQL_PGPASS="$HOME/.pgpass"
cat > /patroni.yml <<__EOF__
bootstrap:
dcs:
postgresql:
use_pg_rewind: true
ttl: &ttl 30
loop_wait: &loop_wait 10
scope: &scope ${PATRONI_SCOPE}
restapi:
listen: 127.0.0.1:8008
connect_address: 127.0.0.1:8008
etcd:
scope: *scope
ttl: *ttl
host: ${ETCD_CLUSTER}
postgresql:
name: postgresql_${DOCKER_IP//./_} ## Replication slots do not allow dots in their name
scope: *scope
listen: 0.0.0.0:5432
connect_address: ${DOCKER_IP}:5432
data_dir: data/postgresql0
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
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
- host replication replicator ${DOCKER_IP}/16 md5 - host replication replicator ${DOCKER_IP}/16 md5
replication:
username: replicator
password: rep-pass
network: 127.0.0.1/32
superuser:
password: zalando
restore: patroni/scripts/restore.py
admin:
username: admin
password: admin
parameters:
archive_mode: "on"
wal_level: hot_standby
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
max_wal_senders: 20
listen_addresses: 0.0.0.0
wal_keep_segments: 8
archive_timeout: 1800s
max_replication_slots: 20
hot_standby: "on"
__EOF__ __EOF__
cat /patroni/postgres.yml mkdir -p "$HOME/.config/patroni"
[ -h "$HOME/.config/patroni/patronictl.yaml" ] || ln -s /patroni.yml "$HOME/.config/patroni/patronictl.yaml"
if [ ! -z $CHEAT ] [ -z $CHEAT ] && exec python /patroni.py /patroni.yml
then
while : while true; do
do sleep 60
sleep 60 done
done
else
exec python /patroni.py /patroni/postgres.yml
fi
+8
View File
@@ -0,0 +1,8 @@
PATRONI_RESTAPI_USERNAME=admin
PATRONI_RESTAPI_PASSWORD=admin
PATRONI_SUPERUSER_USERNAME=postgres
PATRONI_SUPERUSER_PASSWORD=postgres
PATRONI_REPLICATION_USERNAME=replicator
PATRONI_REPLICATION_PASSWORD=replicate
PATRONI_admin_PASSWORD=admin
PATRONI_admin_OPTIONS=createdb,createrole
+59
View File
@@ -0,0 +1,59 @@
==================================
Environment Configuration Settings
==================================
It is possible to override some of the configuration parameters defined in the Patroni configuration file using the system environment variables. This document lists all environment variables handled by Patroni. The values set via those variables always take precedence over the ones set in the Patroni configuration file.
Global/Universal
----------------
- **PATRONI\_CONFIGURATION**: it is possible to set the entire configuration for the Patroni via ``PATRONI_CONFIGURATION`` environment variable. In this case any other environment variables will not be considered!
- **PATRONI\_NAME**: name of the node where the current instance of Patroni is running. Must be unique for the cluster.
- **PATRONI\_NAMESPACE**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **PATRONI\_SCOPE**: cluster name
Bootstrap configuration
-----------------------
It is possible to create new database users right after the successful initialization of a new cluster. This process is defined by the following variables:
- **PATRONI\_<username>\_PASSWORD='<password>'**
- **PATRONI\_<username>\_OPTIONS='list,of,options'**
Example: defining ``PATRONI_admin_PASSWORD=strongpasswd`` and ``PATRONI_admin_OPTIONS='createrole,createdb'`` will cause creation of the user **admin** with the password **strongpasswd** that is allowed to create other users and databases.
Consul
------
- **PATRONI\_CONSUL\_HOST**: the host:port for the Consul endpoint.
Etcd
----
- **PATRONI\_ETCD\_HOST**: the host:port for the etcd endpoint.
Exhibitor
---------
- **PATRONI\_EXHIBITOR\_HOSTS**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- **PATRONI\_EXHIBITOR\_PORT**: Exhibitor port.
PostgreSQL
----------
- **PATRONI\_POSTGRESQL\_LISTEN**: IP address + port that Postgres listens to. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
- **PATRONI\_POSTGRESQL\_CONNECT\_ADDRESS**: IP address + port through which Postgres is accessible from other nodes and applications.
- **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
- **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
- **PATRONI\_POSTGRESQL\_PGPASS**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup and under some other circumstances. The location must be writable by Patroni.
- **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access master via streaming replication
- **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization.
- **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind.
- **PATRONI\_SUPERUSER\_PASSWORD**: password for the superuser, set during initialization (initdb).
REST API
--------
- **PATRONI\_RESTAPI\_CONNECT\_ADDRESS**: IP address and port to access the REST API.
- **PATRONI\_RESTAPI\_LISTEN**: IP address and port that Patroni will listen to, to provide health-check information for HAProxy.
- **PATRONI\_RESTAPI\_USERNAME**: Basic-auth username to protect unsafe REST API endpoints.
- **PATRONI\_RESTAPI\_PASSWORD**: Basic-auth password to protect unsafe REST API endpoints.
- **PATRONI\_RESTAPI\_CERTFILE**: Specifies the 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.
- **PATRONI\_RESTAPI\_KEYFILE**: Specifies the file with the secret key in the PEM format.
ZooKeeper
---------
- **PATRONI\_ZOOKEEPER\_HOSTS**: comma separated list of ZooKeeper cluster members: "'host1:port1','host2:port2','etc...'". It is important to quote every single entity!
+93
View File
@@ -0,0 +1,93 @@
===========================
YAML Configuration Settings
===========================
Global/Universal
----------------
- **name**: the name of the host. Must be unique for the cluster.
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
Bootstrap configuration
-----------------------
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of a given configuration store after initializing of new cluster. This is the global configuration for the cluster. If you want to change some parameters for all cluster nodes - just do it in DCS (or via Patroni API) and all nodes will apply this configuration.
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
- **ttl**: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process. Default value: 30
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **postgresql**:
- **use\_pg\_rewind**:whether or not to use pg_rewind
- **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.
- **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.
- **initdb**: List options to be passed on to initdb.
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
- **- encoding: UTF8**: default encoding for new databases.
- **- locale: UTF8**: default locale for new databases.
- **pg\_hba**: list of lines that you should add to pg\_hba.conf.
- **- host all all 0.0.0.0/0 md5**.
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- **users**: Some additional users users which needs to be created after initializing new cluster
- **admin**: the name of user
- **password: zalando**:
- **options**: list of options for CREATE USER statement
- **- createrole**
- **- createdb**
Consul
------
- **host**: the host:port for the Consul endpoint.
Etcd
----
- **host**: the host:port for the etcd endpoint.
Exhibitor
---------
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor
- **port**: Exhibitor port.
PostgreSQL
----------
- **authentication**:
- **superuser**:
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
- **password**: password for the superuser, set during initialization (initdb).
- **replication**:
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access master via streaming replication
- **password**: replication password; the user will be created during initialization.
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
- **on\_reload**: run this script when configuration reload is triggered.
- **on\_restart**: run this script when the cluster restarts.
- **on\_role\_change**: run this script when the cluster is being promoted or demoted.
- **on\_start**: run this script when the cluster starts.
- **on\_stop**: run this script when the cluster stops.
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica. "basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its own config item.
- **data\_dir**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
- **bin\_dir**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup and under some other circumstances. The location must be writable by Patroni.
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
- **custom_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overriden by Patroni's own configuration facilities - see `dynamic configuration <https://github.com/zalando/patroni/blob/master/docs/dynamic_configuration.rst>`__ for details.
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove postgres data directory and recreate replica. Otherwise it will try to follow the new leader. Default value is **false**.
- **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".
REST API
--------
- **connect\_address**: IP address and port to access the REST API.
- **listen**: IP address and port that Patroni will listen to, to provide health-check information for HAProxy.
- **Optional**:
- **authentication**:
- **username**: Basic-auth username to protect unsafe REST API endpoints.
- **password**: Basic-auth password to protect unsafe REST API endpoints.
- **certfile**: Specifies the 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**: Specifies the file with the secret key in the PEM format.
ZooKeeper
----------
- **hosts**: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
+226
View File
@@ -0,0 +1,226 @@
Patroni configuration
=====================
Patroni configuration is stored in the DCS (Distributed Configuration Store). There are 3 types of configuration:
- Dynamic configuration.
These options can be set in DCS at any time. If the options changed are not part of the startup configuration,
they are applied asynchronously (upon the next wake up cycle) to every node, which gets subsequently reloaded.
If the node requires a restart to apply the configuration (for options with context postmaster, if their values
have changed), a special flag, ``pending_restart`` indicating this, is set in the members.data JSON.
Additionally, the node status also indicates this, by showing ``"restart_pending": true``.
- Local `configuration <https://github.com/zalando/patroni/blob/master/docs/SETTINGS.rst>`__ (patroni.yml).
These options are defined in the configuration file and take precedence over dynamic configuration.
patroni.yml could be changed and reload in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process or by performing ``POST /reload`` REST-API request.
- Environment `configuration <https://github.com/zalando/patroni/blob/master/docs/ENVIRONMENT.rst>`__ .
It is possible to set/override some of the "Local" configuration parameters with environment variables.
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know you external IP address when you are running inside ``docker``).
Some of the PostgreSQL parameters must hold the same values on the master and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
- max_connections: 100
- max_locks_per_transaction: 64
- max_worker_processes: 8
- max_prepared_transactions: 0
- wal_level: hot_standby
- wal_log_hints: on
- track_commit_timestamp: off
For the parameters below, PostgreSQL does not require equal values among the master and all the replicas. However, considering the possibility of a replica to become the master at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration
- max_wal_senders: 5
- max_replication_slots: 5
- wal_keep_segments: 8
These parameters are validated to ensure they are sane, or meet a minimum value.
There are some other Postgres parameters controlled by Patroni:
- listen_addresses - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- port - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- cluster_name - is set either from ``scope`` or from ``PATRRONI_SCOPE`` environment variable
- hot_standby: on
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
When applying the local or dynamic configuration options, the following actions are taken:
- The node first checks if there is a postgresql.base.conf or if the ``custom_conf`` parameter is set.
- If the `custom_conf` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
- If the `custom_conf` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
- If there is no `custom_conf` nor `postgresql.base.conf`, the original postgresql.conf is taken and renamed to postgresql.base.conf.
- The dynamic options (with the exceptions above) are dumped into the postgresql.conf and an include is set in
postgresql.conf to the used base configuration (either postgresql.base.conf or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
The parameters would be applied in the following order (run-time are given the highest priority):
1. load parameters from file `postgresql.base.conf` (or from a `custom_conf` file, if set)
2. load parameters from file `postgresql.conf`
3. load parameters from file `postgresql.auto.conf`
4. run-time parameter using `-o --name=value`
This allows configuration for all the nodes (2), configuration for a specific node using `ALTER SYSTEM` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
Also, the following Patroni configuration options can be changed only dynamically:
- ttl: 30
- loop_wait: 10
- retry_timeouts: 10
- maximum_lag_on_failover: 1048576
- postgresql.use_slots: true
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its
run-time values.
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the master is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
REST API
========
We provide a REST API endpoint for working with dynamic configuration.
GET /config
-----------
Get current version of dynamic configuration.
.. code-block:: bash
$ curl -s localhost:8008/config | jq .
{
"ttl": 30,
"loop_wait": 10,
"retry_timeout": 10,
"maximum_lag_on_failover": 1048576,
"postgresql": {
"use_slots": true,
"use_pg_rewind": true,
"parameters": {
"hot_standby": "on",
"wal_log_hints": "on",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"max_wal_senders": 5,
"max_replication_slots": 5,
"max_connections": "100"
}
}
}
PATCH /config
-------------
Change existing configuration.
.. code-block:: bash
$ curl -s -XPATCH -d \
'{"loop_wait":5,"ttl":20,"postgresql":{"parameters":{"max_connections":"101"}}}' \
http://localhost:8008/config | jq .
{
"ttl": 20,
"loop_wait": 5,
"maximum_lag_on_failover": 1048576,
"retry_timeout": 10,
"postgresql": {
"use_slots": true,
"use_pg_rewind": true,
"parameters": {
"hot_standby": "on",
"wal_log_hints": "on",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"max_wal_senders": 5,
"max_replication_slots": 5,
"max_connections": "101"
}
}
}
The above REST API call patches the existing configuration and returns the new configuration.
Let's check that the node processed this configuration. First of all it should start printing log lines every 5 seconds (loop_wait=5). The change of "max_connections" requires a restart, so the "restart_pending" flag should be exposed:
.. code-block:: bash
$ curl -s http://localhost:8008/patroni | jq .
{
"pending_restart": true,
"database_system_identifier": "6287881213849985952",
"postmaster_start_time": "2016-06-13 13:13:05.211 CEST",
"xlog": {
"location": 2197818976
},
"patroni": {
"scope": "batman",
"version": "1.0"
},
"state": "running",
"role": "master",
"server_version": 90503
}
Removing parameters:
If you want to remove (reset) some setting just patch it with ``null``:
.. code-block:: bash
$ curl -s -XPATCH -d \
'{"postgresql":{"parameters":{"max_connections":null}}}' \
http://localhost:8008/config | jq .
{
"ttl": 20,
"loop_wait": 5,
"retry_timeout": 10,
"maximum_lag_on_failover": 1048576,
"postgresql": {
"use_slots": true,
"use_pg_rewind": true,
"parameters": {
"hot_standby": "on",
"unix_socket_directories": ".",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5,
"max_replication_slots": 5
}
}
}
Above call removes ``postgresql.parameters.max_connections`` from the dynamic configuration.
PUT /config
-----------
It's also possible to perform the full rewrite of an existing dynamic configuration unconditionally:
.. code-block:: bash
$ curl -s -XPUT -d \
'{"maximum_lag_on_failover":1048576,"retry_timeout":10,"postgresql":{"use_slots":true,"use_pg_rewind":true,"parameters":{"hot_standby":"on","wal_log_hints":"on","wal_keep_segments":8,"wal_level":"hot_standby","unix_socket_directories":".","max_wal_senders":5}},"loop_wait":3,"ttl":20}' \
http://localhost:8008/config | jq .
{
"ttl": 20,
"maximum_lag_on_failover": 1048576,
"retry_timeout": 10,
"postgresql": {
"use_slots": true,
"parameters": {
"hot_standby": "on",
"unix_socket_directories": ".",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5
},
"use_pg_rewind": true
},
"loop_wait": 3
}
+33
View File
@@ -0,0 +1,33 @@
Pause/Resume mode for the cluster
=================================
The goal
--------
Under certain circumstances Patroni needs to temporary step down from managing the cluster, while still retaining the cluster state in DCS. Possible use cases are uncommon activities on the cluster, such as major version upgrades or corruption recovery. During those activities nodes are often started and stopped for the reason unknown to Patroni, some nodes can be even temporary promoted, violating the assumption of running only one master. Therefore, Patroni needs to be able to "detach" from the running cluster, implementing an equivalent of the maintenance mode in Pacemaker.
The implementation
------------------
When Patroni runs in a paused mode, it does not change the state of PostgreSQL, except for the following cases:
- For each node, the member key in DCS is updated with the current information about the cluster. This causes Patroni to run read-only queries on a member node if the member is running.
- For the Postgres master with the leader lock Patroni updates the lock. If the node with the leader lock stops being the master (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back.
- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running master node.
- If 'parallel' masters are detected by Patroni, it emits a warning, but does not demote the masters without the leader lock.
- If there is no leader lock in the cluster, the running master acquires the lock. If there is more than one master node, then the first master to acquire the lock wins. If there are no masters altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old master has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
- When Postgres is stopped, Patroni does not try to start it. When Patroni is stopped, it does not to stop Postgres instance it is managing.
User guide
----------
``patronictl`` supports ``pause`` and ``resume`` commands.
One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}``
+13
View File
@@ -0,0 +1,13 @@
### confd
`confd` directory contains haproxy template files for the [confd](https://github.com/kelseyhightower/confd) -- lightweight configuration management tool
You need to copy content of `confd` directory into /etcd/confd and run confd service:
```bash
$ confd -prefix=/service/$PATRONI_SCOPE -backend etcd -node $PATRONI_ETCD_HOST -interval=10
```
It will periodically update haproxy.cfg with the actual list of Patroni nodes from `etcd` and "reload" haproxy when it is necessary.
### startup-scripts
`startup-scripts` directory contains startup scripts for various OSes and management tools for Patroni.
+13
View File
@@ -0,0 +1,13 @@
[template]
#prefix = "/service/batman"
#owner = "haproxy"
#mode = "0644"
src = "haproxy.tmpl"
dest = "/etc/haproxy/haproxy.cfg"
check_cmd = "/usr/sbin/haproxy -c -f {{ .src }}"
reload_cmd = "haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D -sf $(cat /var/run/haproxy.pid)"
keys = [
"/members/",
]
+28
View File
@@ -0,0 +1,28 @@
global
maxconn 100
defaults
log global
mode tcp
retries 2
timeout client 30m
timeout connect 4s
timeout server 30m
timeout check 5s
frontend master_postgresql
bind *:5000
default_backend backend_master
frontend replicas_postgresql
bind *:5001
default_backend backend_replicas
backend backend_master
option httpchk OPTIONS /master
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
{{end}}
backend backend_replicas
option httpchk OPTIONS /replica
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
{{end}}
+13
View File
@@ -0,0 +1,13 @@
# 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.
### patroni.service
Systemd service file, to be copied to /etc/systemd/system/patroni.service, tested on Centos 7.1 with Patroni installed from pip.
+31
View File
@@ -0,0 +1,31 @@
# This is an example systemd config file for Patroni
# You can copy it to "/etc/systemd/system/patroni.service",
[Unit]
Description=Runners to orchestrate a high-availability PostgreSQL
After=syslog.target network.target
[Service]
Type=simple
User=postgres
Group=postgres
# Where to send early-startup messages from the server
# This is normally controlled by the global default set by systemd
# StandardOutput=syslog
ExecStart=/bin/patroni /etc/patroni.yml
# only kill the patroni process, not it's children, so it will gracefully stop postgres
KillMode=process
# Give a reasonable amount of time for the server to start up/shut down
TimeoutSec=10
# Do not restart the service if it crashes, we want to manually inspect database on failure
Restart=no
[Install]
WantedBy=multi-user.target
@@ -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
+17
View File
@@ -0,0 +1,17 @@
Feature: basic replication
We should check that the basic bootstrapping, replication and failover works.
Scenario: check replication of a single table
Given I start postgres0
And postgres0 is a leader after 10 seconds
And I start postgres1
When I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds
Scenario: check the basic failover
When I kill postgres0
Then postgres1 role is the primary after 32 seconds
When I start postgres0
Then postgres0 role is the secondary after 20 seconds
When I add the table bar to postgres1
Then table bar is present on postgres0 after 20 seconds
+13
View File
@@ -0,0 +1,13 @@
Feature: cascading replication
We should check that patroni can do base backup and streaming from the replica
Scenario: check a base backup and streaming replication from a replica
Given I start postgres0
And postgres0 is a leader after 10 seconds
And I configure and start postgres1 with a tag clonefrom true
And replication works from postgres0 to postgres1 after 20 seconds
And I create label with "postgres0" in postgres0 data directory
And I create label with "postgres1" in postgres1 data directory
And I configure and start postgres2 with a tag replicatefrom postgres1
Then replication works from postgres0 to postgres2 after 30 seconds
And there is a label with "postgres1" in postgres2 data directory
+413
View File
@@ -0,0 +1,413 @@
import abc
import consul
import etcd
import kazoo.client
import kazoo.exceptions
import os
import psycopg2
import shutil
import six
import subprocess
import tempfile
import time
import yaml
@six.add_metaclass(abc.ABCMeta)
class AbstractController(object):
def __init__(self, name, work_directory, output_dir):
self._name = name
self._work_directory = work_directory
self._output_dir = output_dir
self._handle = None
self._log = None
def _has_started(self):
return self._handle and self._handle.pid and self._handle.poll() is None
def _is_running(self):
return self._has_started()
@abc.abstractmethod
def _is_accessible(self):
"""process is accessible for queries"""
@abc.abstractmethod
def _start(self):
"""start process"""
def start(self, max_wait_limit=5):
if self._is_running():
return True
self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
self._handle = self._start()
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
for _ in range(max_wait_limit):
if self._is_accessible():
break
time.sleep(1)
else:
assert False,\
"{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit)
def stop(self, kill=False, timeout=15):
term = False
start_time = time.time()
while self._handle and self._is_running():
if kill:
self._handle.kill()
elif not term:
self._handle.terminate()
term = True
time.sleep(1)
if not kill and time.time() - start_time > timeout:
kill = True
if self._log:
self._log.close()
class PatroniController(AbstractController):
__PORT = 5440
PATRONI_CONFIG = '{}.yml'
""" starts and stops individual patronis"""
def __init__(self, dcs, name, work_directory, output_dir, tags=None):
super(PatroniController, self).__init__('patroni_' + name, work_directory, output_dir)
PatroniController.__PORT += 1
self._data_dir = os.path.join(work_directory, 'data', name)
self._connstring = None
self._config = self._make_patroni_test_config(name, dcs, tags)
self._conn = None
self._curs = None
def write_label(self, content):
with open(os.path.join(self._data_dir, 'label'), 'w') as f:
f.write(content)
def read_label(self):
try:
with open(os.path.join(self._data_dir, 'label'), 'r') as f:
return f.read().strip()
except IOError:
return None
def add_tag_to_config(self, tag, value):
with open(self._config) as r:
config = yaml.safe_load(r)
config['tags']['tag'] = value
with open(self._config, 'w') as w:
yaml.safe_dump(config, w, default_flow_style=False)
def _start(self):
return subprocess.Popen(['coverage', 'run', '--source=patroni', '-p', 'patroni.py', self._config],
stdout=self._log, stderr=subprocess.STDOUT, cwd=self._work_directory)
def _is_accessible(self):
return self.query("SELECT 1", fail_ok=True) is not None
def _make_patroni_test_config(self, name, dcs, tags):
patroni_config_name = self.PATRONI_CONFIG.format(name)
patroni_config_path = os.path.join(self._output_dir, patroni_config_name)
with open(patroni_config_name) as f:
config = yaml.safe_load(f)
config.pop('etcd')
host = config['postgresql']['listen'].split(':')[0]
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
user = config['postgresql'].get('authentication', config['postgresql']).get('superuser', {})
self._connkwargs = {k: user[n] for n, k in [('username', 'user'), ('password', 'password')] if n in user}
self._connkwargs.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
config['name'] = name
config['postgresql']['data_dir'] = self._data_dir
config['postgresql']['parameters'].update({
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
'log_filename': name + '.log', 'log_statement': 'all', 'log_min_messages': 'debug1'})
if 'bootstrap' in config and 'initdb' in config['bootstrap']:
config['bootstrap']['initdb'].extend([{'auth': 'md5'}, {'auth-host': 'md5'}])
if tags:
config['tags'] = tags
with open(patroni_config_path, 'w') as f:
yaml.safe_dump(config, f, default_flow_style=False)
return patroni_config_path
def _connection(self):
if not self._conn or self._conn.closed != 0:
self._conn = psycopg2.connect(**self._connkwargs)
self._conn.autocommit = True
return self._conn
def _cursor(self):
if not self._curs or self._curs.closed or self._curs.connection.closed != 0:
self._curs = self._connection().cursor()
return self._curs
def query(self, query, fail_ok=False):
try:
cursor = self._cursor()
cursor.execute(query)
return cursor
except psycopg2.Error:
if not fail_ok:
raise
def check_role_has_changed_to(self, new_role, timeout=10):
bound_time = time.time() + timeout
recovery_status = new_role != 'primary'
while time.time() < bound_time:
cur = self.query("SELECT pg_is_in_recovery()", fail_ok=True)
if cur:
row = cur.fetchone()
if row and row[0] == recovery_status:
return True
time.sleep(1)
return False
class AbstractDcsController(AbstractController):
_CLUSTER_NODE = '/service/batman'
def _is_accessible(self):
return self._is_running()
def stop_and_remove_work_directory(self, timeout=15):
""" terminate process and wipe out the temp work directory, but only if we actually started it"""
self.stop(timeout=timeout)
if self._work_directory:
shutil.rmtree(self._work_directory)
def path(self, key=None):
return self._CLUSTER_NODE + (key and '/' + key or '')
@abc.abstractmethod
def query(self, key):
""" query for a value of a given key """
@abc.abstractmethod
def set(self, key, value):
""" set a value to a given key """
@abc.abstractmethod
def cleanup_service_tree(self):
""" clean all contents stored in the tree used for the tests """
class ConsulController(AbstractDcsController):
def __init__(self, output_dir):
super(ConsulController, self).__init__('consul', tempfile.mkdtemp(), output_dir)
os.environ['PATRONI_CONSUL_HOST'] = 'localhost:8500'
self._client = consul.Consul()
def _start(self):
return subprocess.Popen(['consul', 'agent', '-server', '-bootstrap', '-advertise=127.0.0.1',
'-data-dir', self._work_directory], stdout=self._log, stderr=subprocess.STDOUT)
def _is_running(self):
try:
return bool(self._client.status.leader())
except Exception:
return False
def path(self, key=None):
return super(ConsulController, self).path(key)[1:]
def query(self, key):
_, value = self._client.kv.get(self.path(key))
return value and value['Value'].decode('utf-8')
def set(self, key, value):
self._client.kv.put(self.path(key), value)
def cleanup_service_tree(self):
self._client.kv.delete(self.path(), recurse=True)
class EtcdController(AbstractDcsController):
""" handles all etcd related tasks, used for the tests setup and cleanup """
def __init__(self, output_dir):
super(EtcdController, self).__init__('etcd', tempfile.mkdtemp(), output_dir)
os.environ['PATRONI_ETCD_HOST'] = 'localhost:4001'
self._client = etcd.Client()
def _start(self):
return subprocess.Popen(["etcd", "--debug", "--data-dir", self._work_directory],
stdout=self._log, stderr=subprocess.STDOUT)
def query(self, key):
try:
return self._client.get(self.path(key)).value
except etcd.EtcdKeyNotFound:
return None
def set(self, key, value):
self._client.set(self.path(key), value)
def cleanup_service_tree(self):
try:
self._client.delete(self.path(), recursive=True)
except (etcd.EtcdKeyNotFound, etcd.EtcdConnectionFailed):
return
except Exception as e:
assert False, "exception when cleaning up etcd contents: {0}".format(e)
def _is_running(self):
# if etcd is running, but we didn't start it
try:
return bool(self._client.machines)
except Exception:
return False
class ZooKeeperController(AbstractDcsController):
""" handles all zookeeper related tasks, used for the tests setup and cleanup """
def __init__(self, output_dir, export_env=True):
super(ZooKeeperController, self).__init__('zookeeper', None, output_dir)
if export_env:
os.environ['PATRONI_ZOOKEEPER_HOSTS'] = "'localhost:2181'"
self._client = kazoo.client.KazooClient()
def _start(self):
pass # TODO: implement later
def query(self, key):
try:
return self._client.get(self.path(key))[0].decode('utf-8')
except kazoo.exceptions.NoNodeError:
return None
def set(self, key, value):
self._client.set(self.path(key), value.encode('utf-8'))
def cleanup_service_tree(self):
try:
self._client.delete(self.path(), recursive=True)
except (kazoo.exceptions.NoNodeError):
return
except Exception as e:
assert False, "exception when cleaning up zookeeper contents: {0}".format(e)
def _is_running(self):
# if zookeeper is running, but we didn't start it
if self._client.connected:
return True
try:
return self._client.start(1) or True
except Exception:
return False
class ExhibitorController(ZooKeeperController):
def __init__(self, output_dir):
super(ExhibitorController, self).__init__(output_dir, False)
os.environ.update({'PATRONI_EXHIBITOR_HOSTS': 'localhost', 'PATRONI_EXHIBITOR_PORT': '8181'})
class PatroniPoolController(object):
KNOWN_DCS = {'consul': ConsulController, 'etcd': EtcdController,
'zookeeper': ZooKeeperController, 'exhibitor': ExhibitorController}
def __init__(self):
self._dcs = None
self._output_dir = None
self._patroni_path = None
self._processes = {}
self.create_and_set_output_directory('')
@property
def patroni_path(self):
if self._patroni_path is None:
cwd = os.path.realpath(__file__)
while True:
cwd, entry = os.path.split(cwd)
if entry == 'features' or cwd == '/':
break
self._patroni_path = cwd
return self._patroni_path
@property
def output_dir(self):
return self._output_dir
def start(self, pg_name, max_wait_limit=20, tags=None):
if pg_name not in self._processes:
self._processes[pg_name] = PatroniController(self.dcs, pg_name, self.patroni_path, self._output_dir, tags)
self._processes[pg_name].start(max_wait_limit)
def __getattr__(self, func):
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to', 'add_tag_to_config']:
raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
def wrapper(pg_name, *args, **kwargs):
return getattr(self._processes[pg_name], func)(*args, **kwargs)
return wrapper
def stop_all(self):
for ctl in self._processes.values():
ctl.stop()
self._processes.clear()
def create_and_set_output_directory(self, feature_name):
feature_dir = os.path.join(self.patroni_path, 'features/output', feature_name.replace(' ', '_'))
if os.path.exists(feature_dir):
shutil.rmtree(feature_dir)
os.makedirs(feature_dir)
self._output_dir = feature_dir
@property
def dcs(self):
if self._dcs is None:
self._dcs = os.environ.pop('DCS', 'etcd')
assert self._dcs in self.KNOWN_DCS, 'Unsupported dcs: ' + self._dcs
return self._dcs
# actions to execute on start/stop of the tests and before running invidual features
def before_all(context):
context.pctl = PatroniPoolController()
context.dcs_ctl = context.pctl.KNOWN_DCS[context.pctl.dcs](context.pctl.output_dir)
context.dcs_ctl.start()
try:
context.dcs_ctl.cleanup_service_tree()
except AssertionError: # after_all handlers won't be executed in before_all
context.dcs_ctl.stop_and_remove_work_directory()
raise
def after_all(context):
context.dcs_ctl.stop_and_remove_work_directory()
subprocess.call(['coverage', 'combine'])
subprocess.call(['coverage', 'report'])
def before_feature(context, feature):
""" create per-feature output directory to collect Patroni and PostgreSQL logs """
context.pctl.create_and_set_output_directory(feature.name)
def after_feature(context, feature):
""" stop all Patronis, remove their data directory and cleanup the keys in etcd """
context.pctl.stop_all()
shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data'))
context.dcs_ctl.cleanup_service_tree()
+99
View File
@@ -0,0 +1,99 @@
Feature: patroni api
We should check that patroni correctly responds to valid and not-valid API requests.
Scenario: check API requests on a stand-alone server
Given I start postgres0
And postgres0 is a leader after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200
And I receive a response state running
And I receive a response role master
When I issue a GET request to http://127.0.0.1:8008/replica
Then I receive a response code 503
When I run patronictl.py reinit batman postgres0 --force
Then I receive a response returncode 0
And I receive a response output "Failed: reinitialize for member postgres0, status code=503, (I am the leader, can not reinitialize)"
When I run patronictl.py failover batman --master postgres0 --force
Then I receive a response returncode 1
And I receive a response output "Error: No candidates found to failover to"
When I issue a POST request to http://127.0.0.1:8008/failover with {"leader": "postgres0"}
Then I receive a response code 500
And I receive a response text failover is not possible: cluster does not have members except leader
When I issue an empty POST request to http://127.0.0.1:8008/failover
Then I receive a response code 400
When I issue a POST request to http://127.0.0.1:8008/failover with {"foo": "bar"}
Then I receive a response code 400
And I receive a response text "No values given for required parameters leader and candidate"
Scenario: check local configuration reload
Given I issue an empty POST request to http://127.0.0.1:8008/reload
Then I receive a response code 200
And I receive a response text nothing changed
When I add tag new_tag new_value to postgres0 config
And I issue an empty POST request to http://127.0.0.1:8008/reload
Then I receive a response code 202
Scenario: check dynamic configuration change via DCS
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "loop_wait": 2, "postgresql": {"parameters": {"max_connections": 101}}}
Then I receive a response code 200
And I receive a response loop_wait 2
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds
When I issue a GET request to http://127.0.0.1:8008/config
Then I receive a response code 200
And I receive a response loop_wait 2
When I issue a GET request to http://127.0.0.1:8008/patroni
Then I receive a response code 200
And I receive a response tags {'tag': 'new_value'}
Scenario: check API requests for the primary-replica pair in the pause mode
Given I run patronictl.py pause batman
Then I receive a response returncode 0
When I start postgres1
Then replication works from postgres0 to postgres1 after 20 seconds
When I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 200
And I receive a response state running
And I receive a response role replica
When I run patronictl.py reinit batman postgres1 --force
Then I receive a response returncode 0
And I receive a response output "Success: reinitialize for member postgres1"
When I run patronictl.py restart batman postgres0 --force
Then I receive a response returncode 0
And I receive a response output "Success: restart on member postgres0"
And postgres0 role is the primary after 5 seconds
When I sleep for 10 seconds
Then postgres1 role is the secondary after 15 seconds
Scenario: check the failover via the API in the pause mode
Given I run patronictl.py failover batman --master postgres0 --candidate postgres1 --force
Then I receive a response returncode 0
And postgres1 is a leader after 5 seconds
And postgres1 role is the primary after 10 seconds
And postgres0 role is the secondary after 10 seconds
And replication works from postgres1 to postgres0 after 20 seconds
Scenario: check the scheduled failover
Given I issue a scheduled failover from postgres1 to postgres0 in 1 seconds
Then I receive a response returncode 1
And I receive a response output "Can't schedule failover in the paused state"
When I run patronictl.py resume batman
Then I receive a response returncode 0
Given I issue a scheduled failover from postgres1 to postgres0 in 1 seconds
Then I receive a response returncode 0
And postgres0 is a leader after 20 seconds
And postgres0 role is the primary after 10 seconds
And postgres1 role is the secondary after 10 seconds
And replication works from postgres0 to postgres1 after 25 seconds
Scenario: check the scheduled restart
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 1 seconds with {"role": "replica"}
Then I receive a response code 202
And I sleep for 2 seconds
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 1 seconds with {"restart_pending": "True"}
Then I receive a response code 202
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
+54
View File
@@ -0,0 +1,54 @@
import psycopg2 as pg
from behave import step, then
from time import sleep, time
@step('I start {name:w}')
def start_patroni(context, name):
return context.pctl.start(name)
@step('I shut down {name:w}')
def stop_patroni(context, name):
return context.pctl.stop(name)
@step('I kill {name:w}')
def kill_patroni(context, name):
return context.pctl.stop(name, kill=True)
@step('I add the table {table_name:w} to {pg_name:w}')
def add_table(context, table_name, pg_name):
# parse the configuration file and get the port
try:
context.pctl.query(pg_name, "CREATE TABLE {0}()".format(table_name))
except pg.Error as e:
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
@then('Table {table_name:w} is present on {pg_name:w} after {max_replication_delay:d} seconds')
def table_is_present_on(context, table_name, pg_name, max_replication_delay):
for _ in range(int(max_replication_delay)):
if context.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None:
break
sleep(1)
else:
assert False,\
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
@then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds')
def check_role(context, pg_name, pg_role, max_promotion_timeout):
assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)),\
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
@step('replication works from {master:w} to {replica:w} after {time_limit:d} seconds')
@then('replication works from {master:w} to {replica:w} after {time_limit:d} seconds')
def replication_works(context, master, replica, time_limit):
context.execute_steps(u"""
When I add the table test_{0} to {1}
Then table test_{0} is present on {2} after {3} seconds
""".format(int(time()), master, replica, time_limit))
+17
View File
@@ -0,0 +1,17 @@
from behave import step, then
@step('I configure and start {name:w} with a tag {tag_name:w} {tag_value:w}')
def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value):
return context.pctl.start(name, tags={tag_name: tag_value})
@then('There is a label with "{content:w}" in {name:w} data directory')
def check_label(context, content, name):
label = context.pctl.read_label(name)
assert label == content, "{0} is not equal to {1}".format(label, content)
@step('I create label with "{content:w}" in {name:w} data directory')
def write_label(context, content, name):
context.pctl.write_label(name, content)
+150
View File
@@ -0,0 +1,150 @@
import json
import parse
import pytz
import requests
import shlex
import subprocess
import time
import yaml
from behave import register_type, step, then
from datetime import datetime, timedelta
@parse.with_pattern(r'https?://(?:\w|\.|:|/)+')
def parse_url(text):
return text
register_type(url=parse_url)
# there is no way we can find out if the node has already
# started as a leader without checking the DCS. We cannot
# just rely on the database availability, since there is
# a short gap between the time PostgreSQL becomes available
# and Patroni assuming the leader role.
@step('{name:w} is a leader after {time_limit:d} seconds')
@then('{name:w} is a leader after {time_limit:d} seconds')
def is_a_leader(context, name, time_limit):
max_time = time.time() + int(time_limit)
while (context.dcs_ctl.query("leader") != name):
time.sleep(1)
assert time.time() < max_time, "{0} is not a leader in dcs after {1} seconds".format(name, time_limit)
@step('I sleep for {value:d} seconds')
def sleep_for_n_seconds(context, value):
time.sleep(int(value))
def _set_response(context, response):
context.status_code = response.status_code
data = response.content.decode('utf-8')
ct = response.headers.get('content-type', '')
if ct.startswith('application/json') or\
ct.startswith('text/yaml') or\
ct.startswith('text/x-yaml') or\
ct.startswith('application/yaml') or\
ct.startswith('application/x-yaml'):
try:
context.response = yaml.safe_load(data)
except ValueError:
context.response = data
else:
context.response = data
@step('I issue a GET request to {url:url}')
def do_get(context, url):
try:
r = requests.get(url)
except requests.exceptions.RequestException:
context.status_code = None
context.response = None
else:
_set_response(context, r)
@step('I issue an empty POST request to {url:url}')
def do_post_empty(context, url):
do_request(context, 'POST', url, None)
@step('I issue a {request_method:w} request to {url:url} with {data}')
def do_request(context, request_method, url, data):
data = data and json.loads(data) or {}
try:
if request_method == 'PATCH':
r = requests.patch(url, json=data)
else:
r = requests.post(url, json=data)
except requests.exceptions.RequestException:
context.status_code = None
context.response = None
else:
_set_response(context, r)
@step('I run {cmd}')
def do_run(context, cmd):
cmd = ['coverage', 'run', '--source=patroni', '-p'] + shlex.split(cmd)
try:
response = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
context.status_code = 0
except subprocess.CalledProcessError as e:
response = e.output
context.status_code = e.returncode
context.response = response.decode('utf-8').strip()
@then('I receive a response {component:w} {data}')
def check_response(context, component, data):
if component == 'code':
assert context.status_code == int(data),\
"status code {0} != {1}, response: {2}".format(context.status_code, data, context.response)
elif component == 'returncode':
assert context.status_code == int(data), "return code {0} != {1}".format(context.status_code, data)
elif component == 'text':
assert context.response == data.strip('"'), "response {0} does not contain {1}".format(context.response, data)
elif component == 'output':
assert data.strip('"') in context.response, "response {0} does not contain {1}".format(context.response, data)
else:
assert component in context.response, "{0} is not part of the response".format(component)
assert str(context.response[component]) == str(data), "{0} does not contain {1}".format(component, data)
@step('I issue a scheduled failover from {from_host:w} to {to_host:w} in {in_seconds:d} seconds')
def scheduled_failover(context, from_host, to_host, in_seconds):
context.execute_steps(u"""
Given I run patronictl.py failover batman --master {0} --candidate {1} --scheduled "{2}" --force
""".format(from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds))))
@step('I issue a scheduled restart at {url:url} in {in_seconds:d} seconds with {data}')
def scheduled_restart(context, url, in_seconds, data):
data = data and json.loads(data) or {}
data.update(schedule='{0}'.format((datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds))).isoformat()))
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
def add_tag_to_config(context, tag, value, pg_name):
context.pctl.add_tag_to_config(pg_name, tag, value)
@then('Response on GET {url} contains {value} after {timeout:d} seconds')
def check_http_response(context, url, value, timeout, negate=False):
for _ in range(int(timeout)):
r = requests.get(url)
if (value in r.content.decode('utf-8')) != negate:
break
time.sleep(1)
else:
assert False,\
"Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout)
@then('Response on GET {url} does not contain {value} after {timeout:d} seconds')
def check_not_in_http_response(context, url, value, timeout):
check_http_response(context, url, value, timeout, negate=True)
+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
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -e
pip install --ignore-installed setuptools==19.2 pyinstaller
pyinstaller --clean --onefile patroni.spec
+39
View File
@@ -0,0 +1,39 @@
# -*- mode: python -*-
block_cipher = None
def hiddenimports():
import sys
sys.path.insert(0, '.')
try:
import patroni.dcs
return patroni.dcs.dcs_modules()
finally:
sys.path.pop(0)
a = Analysis(['patroni/__main__.py'],
pathex=[],
binaries=None,
datas=None,
hiddenimports=hiddenimports(),
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name='patroni',
debug=False,
strip=False,
upx=True,
console=True)
+97 -92
View File
@@ -1,136 +1,141 @@
import logging import logging
import os import signal
import sys import sys
import time import time
import yaml
from patroni.api import RestApiServer from patroni.api import RestApiServer
from patroni.etcd import Etcd from patroni.config import Config
from patroni.dcs import get_dcs
from patroni.exceptions import DCSError from patroni.exceptions import DCSError
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 reap_children, sigchld_handler
from patroni.zookeeper import ZooKeeper from patroni.version import __version__
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Patroni: class Patroni(object):
def __init__(self, config): def __init__(self):
self.nap_time = config['loop_wait'] self.setup_signal_handlers()
self.postgresql = Postgresql(config['postgresql'])
self.ha = Ha(self.postgresql, self.get_dcs(self.postgresql.name, config)) self.version = __version__
host, port = config['restapi']['listen'].split(':') self.config = Config()
self.api = RestApiServer(self, config['restapi']) self.dcs = get_dcs(self.config)
self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql'])
self.api = RestApiServer(self, self.config['restapi'])
self.ha = Ha(self)
self.tags = self.get_tags()
self.next_run = time.time() self.next_run = time.time()
self.shutdown_member_ttl = 300 self.scheduled_restart = {}
@staticmethod def load_dynamic_configuration(self):
def get_dcs(name, config): while True:
if 'etcd' in config: try:
return Etcd(name, config['etcd']) cluster = self.dcs.get_cluster()
if 'zookeeper' in config: if cluster and cluster.config:
return ZooKeeper(name, config['zookeeper']) if self.config.set_dynamic_configuration(cluster.config):
raise Exception('Can not find sutable configuration of distributed configuration store') self.dcs.reload_config(self.config)
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
self.dcs.reload_config(self.config)
break
except DCSError:
logger.warning('Can not get cluster from dcs')
def touch_member(self, ttl=None): def get_tags(self):
connection_string = self.postgresql.connection_string + '?application_name=' + self.api.connection_string return {tag: value for tag, value in self.config.get('tags', {}).items()
if self.ha.cluster: if tag not in ('clonefrom', 'nofailover', 'noloadbalance') or value}
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 cleanup_on_failed_initialization(self): @property
""" cleanup the DCS if initialization was not successfull """ def nofailover(self):
logger.info("removing initialize key after failed attempt to initialize the cluster") return bool(self.tags.get('nofailover', False))
self.ha.dcs.cancel_initialization()
self.touch_member(self.shutdown_member_ttl)
self.postgresql.stop()
self.postgresql.move_data_directory()
def initialize(self): def reload_config(self):
# wait for etcd to be available try:
while not self.touch_member(): self.tags = self.get_tags()
logger.info('waiting on DCS') self.dcs.reload_config(self.config)
sleep(5) self.api.reload_config(self.config['restapi'])
self.postgresql.reload_config(self.config['postgresql'])
except Exception:
logger.exception('Failed to reload config_file=%s', self.config.config_file)
# is data directory empty? @property
if self.postgresql.data_directory_empty(): def replicatefrom(self):
while True: return self.tags.get('replicatefrom')
try:
cluster = self.ha.dcs.get_cluster() def sighup_handler(self, *args):
if not cluster.is_unlocked(): # the leader already exists self._received_sighup = True
if not cluster.initialize:
self.ha.dcs.initialize() def sigterm_handler(self, *args):
self.postgresql.bootstrap(cluster.leader) if not self._received_sigterm:
break self._received_sigterm = True
# racing to initialize sys.exit()
elif not cluster.initialize and self.ha.dcs.initialize():
try: @property
self.postgresql.bootstrap() def noloadbalance(self):
except: return bool(self.tags.get('noloadbalance', False))
# bail out and clean the initialize flag.
self.cleanup_on_failed_initialization()
raise
self.ha.dcs.take_leader()
break
except DCSError:
logger.info('waiting on DCS')
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 += self.dcs.loop_wait
self.next_run = time.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: # Release the GIL so we don't starve anyone waiting on async_executor lock
self.ha.dcs.watch(nap_time) time.sleep(0.001)
# Warn user that Patroni is not keeping up
logger.warning("Loop time exceeded, rescheduling immediately.")
elif self.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 not self._received_sigterm:
self.touch_member() if self._received_sighup:
self._received_sighup = False
if self.config.reload_local_configuration():
self.reload_config()
logger.info(self.ha.run_cycle()) logger.info(self.ha.run_cycle())
try:
if self.ha.state_handler.is_leader(): cluster = self.dcs.cluster
self.ha.cluster and self.ha.state_handler.create_replication_slots(self.ha.cluster) if cluster and cluster.config and self.config.set_dynamic_configuration(cluster.config):
else: self.reload_config()
self.ha.state_handler.drop_replication_slots()
except: if not self.postgresql.data_directory_empty():
logger.exception('Exception when changing replication slots') self.config.save_cache()
reap_children() reap_children()
self.schedule_next_run() self.schedule_next_run()
def setup_signal_handlers(self):
self._received_sighup = False
self._received_sigterm = False
signal.signal(signal.SIGHUP, self.sighup_handler)
signal.signal(signal.SIGTERM, self.sigterm_handler)
signal.signal(signal.SIGCHLD, sigchld_handler)
def main(): def main():
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO) logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
logging.getLogger('requests').setLevel(logging.WARNING) logging.getLogger('requests').setLevel(logging.WARNING)
setup_signal_handlers()
if len(sys.argv) < 2 or not os.path.isfile(sys.argv[1]): patroni = Patroni()
print('Usage: {} config.yml'.format(sys.argv[0]))
return
with open(sys.argv[1], 'r') as f:
config = yaml.load(f)
patroni = Patroni(config)
patroni.initialize()
try: try:
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() if patroni.ha.is_paused():
patroni.ha.dcs.delete_leader() logger.info('Leader key is not deleted and Postgresql is not stopped due paused state')
else:
patroni.postgresql.stop(checkpoint=False)
patroni.dcs.delete_leader()
+443 -33
View File
@@ -1,8 +1,15 @@
import base64
import fcntl import fcntl
import json import json
import logging import logging
import psycopg2 import psycopg2
import time
import dateutil.parser
import datetime
import pytz
from patroni.exceptions import PostgresConnectionException
from patroni.utils import deep_compare, patch_config, Retry, RetryFailedError, is_valid_pg_version
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,64 +17,467 @@ 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 _write_response(self, status_code, body, content_type='text/html', headers=None):
response = self.get_postgresql_status() self.send_response(status_code)
headers = headers or {}
if content_type:
headers['Content-Type'] = content_type
for name, value in headers.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body.encode('utf-8'))
def _write_json_response(self, status_code, response):
self._write_response(status_code, json.dumps(response), content_type='application/json')
def send_auth_request(self, body):
headers = {'WWW-Authenticate': 'Basic realm="' + self.server.patroni.__class__.__name__ + '"'}
self._write_response(401, body, headers=headers)
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 _write_status_response(self, status_code, response):
patroni = self.server.patroni
response.update({'tags': patroni.tags} if patroni.tags else {})
if patroni.postgresql.sysid:
response['database_system_identifier'] = patroni.postgresql.sysid
if patroni.postgresql.pending_restart:
response['pending_restart'] = True
response['patroni'] = {'version': patroni.version, 'scope': patroni.postgresql.scope}
if patroni.scheduled_restart and isinstance(patroni.scheduled_restart, dict):
response['scheduled_restart'] = patroni.scheduled_restart.copy()
del response['scheduled_restart']['postmaster_start_time']
response['scheduled_restart']['schedule'] = (response['scheduled_restart']['schedule']).isoformat()
self._write_json_response(status_code, response)
def do_GET(self, write_status_code_only=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()
self.send_response(status_code) patroni = self.server.patroni
self.send_header('Content-Type', 'application/json') cluster = patroni.dcs.cluster
self.end_headers() if cluster: # dcs available
self.wfile.write(json.dumps(response).encode('utf-8')) 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: # response['role'] != 'master'
status_code = 503 if patroni.noloadbalance else 200
else:
status_code = 503
elif 'role' in response and response['role'] in path:
status_code = 503 if response['role'] != 'master' and patroni.noloadbalance else 200
elif patroni.ha.restart_scheduled() and patroni.postgresql.role == 'master' and 'master' in path:
# exceptional case for master node when the postgres is being restarted via API
status_code = 200
else:
status_code = 503
def get_postgresql_status(self): if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more
message = self.responses[status_code][0]
self.wfile.write('{0} {1} {2}\r\n'.format(self.protocol_version, status_code, message).encode('utf-8'))
else:
self._write_status_response(status_code, response)
def do_OPTIONS(self):
self.do_GET(write_status_code_only=True)
def do_GET_patroni(self):
response = self.get_postgresql_status(True)
self._write_status_response(200, response)
def do_GET_config(self):
cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
if cluster.config:
self._write_json_response(200, cluster.config.data)
else:
self.send_error(502)
def _read_json_content(self, body_is_optional=False):
if 'content-length' not in self.headers:
return self.send_error(411) if not body_is_optional else {}
try: try:
row = self.server.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'), content_length = int(self.headers.get('content-length'))
pg_is_in_recovery(), if content_length == 0 and body_is_optional:
CASE WHEN pg_is_in_recovery() return {}
THEN null request = json.loads(self.rfile.read(content_length).decode('utf-8'))
ELSE pg_current_xlog_location() END, if isinstance(request, dict) and (request or body_is_optional):
pg_last_xlog_receive_location(), return request
pg_last_xlog_replay_location(), except Exception:
pg_is_in_recovery() AND pg_is_xlog_replay_paused()""")[0] logger.exception('Bad request')
return { self.send_error(400)
'running': True,
@check_auth
def do_PATCH_config(self):
request = self._read_json_content()
if request:
cluster = self.server.patroni.dcs.get_cluster()
data = cluster.config.data.copy()
if patch_config(data, request):
value = json.dumps(data, separators=(',', ':'))
if not self.server.patroni.dcs.set_config_value(value, cluster.config.index):
return self.send_error(409)
self._write_json_response(200, data)
@check_auth
def do_PUT_config(self):
request = self._read_json_content()
if request:
cluster = self.server.patroni.dcs.get_cluster()
if not deep_compare(request, cluster.config.data):
value = json.dumps(request, separators=(',', ':'))
if not self.server.patroni.dcs.set_config_value(value):
return self.send_error(502)
self._write_json_response(200, request)
@check_auth
def do_POST_reload(self):
try:
if self.server.patroni.config.reload_local_configuration(True):
status_code = 202
response = 'reload scheduled'
self.server.patroni.sighup_handler()
else:
status_code = 200
response = 'nothing changed'
except Exception as e:
status_code = 500
response = str(e)
self._write_response(status_code, response)
@staticmethod
def parse_schedule(schedule, action):
""" parses the given schedule and validates at """
error = None
scheduled_at = None
try:
scheduled_at = dateutil.parser.parse(schedule)
if scheduled_at.tzinfo is None:
error = 'Timezone information is mandatory for the scheduled {0}'.format(action)
status_code = 400
elif scheduled_at < datetime.datetime.now(pytz.utc):
error = 'Cannot schedule {0} in the past'.format(action)
status_code = 422
else:
status_code = None
except (ValueError, TypeError):
logger.exception('Invalid scheduled %s time: %s', action, schedule)
error = 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601'
status_code = 422
return (status_code, error, scheduled_at)
@check_auth
def do_POST_restart(self):
status_code = 500
data = 'restart failed'
request = self._read_json_content(body_is_optional=True)
cluster = self.server.patroni.dcs.get_cluster()
if request is None:
# failed to parse the json
return
if request:
logger.debug("received restart request: {0}".format(request))
if cluster.is_paused() and 'schedule' in request:
self._write_response(status_code, "Can't schedule restart in the paused state")
return
for k in request:
if k == 'schedule':
(_, data, request[k]) = self.parse_schedule(request[k], "restart")
if _:
status_code = _
break
elif k == 'role':
if request[k] not in ('master', 'replica'):
status_code = 400
data = "PostgreSQL role should be either master or replica"
break
elif k == 'postgres_version':
if not is_valid_pg_version(request[k]):
status_code = 400
data = "PostgreSQL version should be in the first.major.minor format"
break
elif k != 'restart_pending':
status_code = 400
data = "Unknown filter for the scheduled restart: {0}".format(k)
break
else:
if 'schedule' not in request:
try:
status, data = self.server.patroni.ha.restart(request)
status_code = 200 if status else 503
except Exception:
logger.exception('Exception during restart')
status_code = 400
else:
request['postmaster_start_time'] = self.server.patroni.ha.state_handler.postmaster_start_time()
if self.server.patroni.ha.schedule_future_restart(request):
data = "Restart scheduled"
status_code = 202
else:
data = "Another restart is already scheduled"
status_code = 409
self._write_response(status_code, data)
@check_auth
def do_DELETE_restart(self):
if self.server.patroni.ha.delete_future_restart():
data = "scheduled restart deleted"
code = 200
else:
data = "no restarts are scheduled"
code = 404
self._write_response(code, data)
@check_auth
def do_POST_reinitialize(self):
data = self.server.patroni.ha.reinitialize()
if data is None:
status_code = 200
data = 'reinitialize started'
else:
status_code = 503
self._write_response(status_code, data)
def poll_failover_result(self, leader, candidate):
timeout = max(10, self.server.patroni.dcs.loop_wait)
for _ in range(0, timeout*2):
time.sleep(1)
try:
cluster = self.server.patroni.dcs.get_cluster()
if cluster.leader and cluster.leader.name != leader:
if not candidate or candidate == cluster.leader.name:
return 200, 'Successfully failed over to "{0}"'.format(cluster.leader.name)
else:
return 200, 'Failed over to "{0}" instead of "{1}"'.format(cluster.leader.name, candidate)
if not cluster.failover:
return 503, 'Failover failed'
except Exception as e:
logger.debug('Exception occured during polling failover result: %s', e)
return 503, 'Failover status unknown'
def is_failover_possible(self, cluster, leader, candidate):
if leader and (not cluster.leader or cluster.leader.name != leader):
return 'leader name does not match'
if candidate:
members = [m for m in cluster.members if m.name == candidate]
if not members:
return 'candidate does not exists'
else:
members = [m for m in cluster.members if m.name != cluster.leader.name and m.api_url]
if not members:
return 'failover is not possible: cluster does not have members except leader'
for _, reachable, _, _, tags in self.server.patroni.ha.fetch_nodes_statuses(members):
if reachable and not tags.get('nofailover', False):
return None
return 'failover is not possible: no good candidates have been found'
@check_auth
def do_POST_failover(self):
request = self._read_json_content()
status_code = 500
if not request:
return
leader = request.get('leader')
candidate = request.get('candidate') or request.get('member')
scheduled_at = request.get('scheduled_at')
cluster = self.server.patroni.dcs.get_cluster()
if scheduled_at and cluster.is_paused():
self._write_response(status_code, "Can't schedule failover in the paused state")
logger.info("received failover request with leader=%s candidate=%s scheduled_at=%s",
leader, candidate, scheduled_at)
data = ''
if leader or candidate:
if scheduled_at:
(_, data, scheduled_at) = self.parse_schedule(scheduled_at, "failover")
if _:
status_code = _
elif self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
self.server.patroni.dcs.event.set()
data = 'Failover scheduled'
status_code = 202
else:
data = 'failed to write failover key into DCS'
status_code = 503
else:
data = self.is_failover_possible(cluster, leader, candidate)
if not data:
if self.server.patroni.dcs.manual_failover(leader, candidate):
self.server.patroni.dcs.event.set()
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name, candidate)
else:
data = 'failed to write failover key into DCS'
status_code = 503
else:
status_code = 400
data = 'No values given for required parameters leader and candidate'
self._write_response(status_code, data)
def parse_request(self):
"""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 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("""WITH replication_info AS (
SELECT usename, application_name, client_addr, state, sync_state, sync_priority
FROM pg_stat_replication
)
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,
to_char(pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
pg_is_in_recovery() AND pg_is_xlog_replay_paused(),
(SELECT json_agg(row_to_json(ri)) FROM replication_info ri)""", retry=retry)[0]
result = {
'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',
'server_version': self.server.patroni.postgresql.server_version,
'xlog': ({ 'xlog': ({
'received_location': row[3], 'received_location': row[3],
'replayed_location': row[4], 'replayed_location': row[4],
'paused': row[5]} if row[1] else { 'replayed_timestamp': row[5],
'paused': row[6]} if row[1] else {
'location': row[2] 'location': row[2]
}) })
} }
except (psycopg2.OperationalError, psycopg2.InterfaceError):
logger.exception('get_postgresql_status') if row[7]:
return {'running': self.server.patroni.postgresql.is_running()} result['replication'] = row[7]
return result
except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
state = self.server.patroni.postgresql.state
if state == 'running':
logger.exception('get_postgresql_status')
state = 'unknown'
return {'state': state, 'role': self.server.patroni.postgresql.role}
def log_message(self, fmt, *args):
logger.debug("API thread: %s - - [%s] %s", self.client_address[0], self.log_date_time_string(), fmt % args)
class RestApiServer(ThreadingMixIn, HTTPServer, Thread): class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
def __init__(self, patroni, config): def __init__(self, patroni, config):
self.connection_string = 'http://{}/patroni'.format(config.get('connect_address', None) or config['listen'])
host, port = config['listen'].split(':')
HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
Thread.__init__(self, target=self.serve_forever)
self._set_fd_cloexec(self.socket)
self.patroni = patroni self.patroni = patroni
self.__initialize(config)
self.__set_config_parameters(config)
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'
@staticmethod
def __get_ssl_options(config):
return {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
def __set_connection_string(self, connect_address):
self.connection_string = '{0}://{1}/patroni'.format(self.__protocol, connect_address or self.__listen)
def __set_config_parameters(self, config):
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
self.__set_connection_string(config.get('connect_address'))
def __initialize(self, config):
self.__ssl_options = self.__get_ssl_options(config)
self.__listen = config['listen']
host, port = config['listen'].split(':')
HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
Thread.__init__(self, target=self.serve_forever)
self._set_fd_cloexec(self.socket)
self.__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'.
if self.__ssl_options.get('certfile'):
import ssl
self.socket = ssl.wrap_socket(self.socket, server_side=True, **self.__ssl_options)
self.__protocol = 'https'
self.__set_connection_string(config.get('connect_address'))
def reload_config(self, config):
self.__set_config_parameters(config)
if self.__listen != config['listen'] or self.__ssl_options != self.__get_ssl_options(config):
self.shutdown()
self.__initialize(config)
self.start()
+50
View File
@@ -0,0 +1,50 @@
import logging
from threading import RLock, Thread
logger = logging.getLogger(__name__)
class AsyncExecutor(object):
def __init__(self):
self._thread_lock = RLock()
self._scheduled_action = None
self._scheduled_action_lock = RLock()
@property
def busy(self):
return self.scheduled_action is not None
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
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.reset_scheduled_action()
def run_async(self, func, args=()):
Thread(target=self.run, args=(func, args)).start()
def __enter__(self):
self._thread_lock.acquire()
def __exit__(self, *args):
self._thread_lock.release()
+312
View File
@@ -0,0 +1,312 @@
import json
import logging
import os
import sys
import tempfile
import yaml
from collections import defaultdict
from copy import deepcopy
from patroni.dcs import ClusterConfig
from patroni.postgresql import Postgresql
from patroni.utils import deep_compare, parse_int, patch_config
logger = logging.getLogger(__name__)
class Config(object):
"""
This class is responsible for:
1) Building and giving access to `effective_configuration` from:
* `Config.__DEFAULT_CONFIG` -- some sane default values
* `dynamic_configuration` -- configuration stored in DCS
* `local_configuration` -- configuration from `config.yml` or environment
2) Saving and loading `dynamic_configuration` into 'patroni.dynamic.json' file
located in local_configuration['postgresql']['data_dir'] directory.
This is necessary to be able to restore `dynamic_configuration`
if DCS was accidentally wiped
3) Loading of configuration file in the old format and converting it into new format
4) Mimicking some of the `dict` interfaces to make it possible
to work with it as with the old `config` object.
"""
PATRONI_ENV_PREFIX = 'PATRONI_'
PATRONI_CONFIG_VARIABLE = PATRONI_ENV_PREFIX + 'CONFIGURATION'
__CACHE_FILENAME = 'patroni.dynamic.json'
__DEFAULT_CONFIG = {
'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10,
'maximum_lag_on_failover': 1048576,
'postgresql': {
'bin_dir': '',
'use_slots': True,
'parameters': {p: v[0] for p, v in Postgresql.CMDLINE_OPTIONS.items()}
}
}
def __init__(self):
self._modify_index = -1
self._dynamic_configuration = {}
self.__environment_configuration = self._build_environment_configuration()
# Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment
self._config_file = len(sys.argv) >= 2 and os.path.isfile(sys.argv[1]) and sys.argv[1]
if self._config_file:
self._local_configuration = self._load_config_file()
else:
config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None)
self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration
if not self._local_configuration:
print('Usage: {0} config.yml'.format(sys.argv[0]))
print('\tPatroni may also read the configuration from the {0} environment variable'.
format(self.PATRONI_CONFIG_VARIABLE))
sys.exit(1)
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
self._data_dir = self.__effective_configuration['postgresql']['data_dir']
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
self._load_cache()
self._cache_needs_saving = False
@property
def config_file(self):
return self._config_file
@property
def dynamic_configuration(self):
return deepcopy(self._dynamic_configuration)
def _load_config_file(self):
"""Loads config.yaml from filesystem and applies some values which were set via ENV"""
with open(self._config_file) as f:
config = yaml.safe_load(f)
patch_config(config, self.__environment_configuration)
return config
def _load_cache(self):
if os.path.isfile(self._cache_file):
try:
with open(self._cache_file) as f:
self.set_dynamic_configuration(json.load(f))
except Exception:
logger.exception('Exception when loading file: %s', self._cache_file)
def save_cache(self):
if self._cache_needs_saving:
tmpfile = fd = None
try:
(fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir)
with os.fdopen(fd, 'w') as f:
fd = None
json.dump(self.dynamic_configuration, f)
tmpfile = os.rename(tmpfile, self._cache_file)
self._cache_needs_saving = False
except Exception:
logger.exception('Exception when saving file: %s', self._cache_file)
if fd:
try:
os.close(fd)
except Exception:
logger.error('Can not close temporary file %s', tmpfile)
if tmpfile and os.path.exists(tmpfile):
try:
os.remove(tmpfile)
except Exception:
logger.error('Can not remove temporary file %s', tmpfile)
# configuration could be either ClusterConfig or dict
def set_dynamic_configuration(self, configuration):
if isinstance(configuration, ClusterConfig):
if self._modify_index == configuration.modify_index:
return False # If the index didn't changed there is nothing to do
self._modify_index = configuration.modify_index
configuration = configuration.data
if not deep_compare(self._dynamic_configuration, configuration):
try:
self.__effective_configuration = self._build_effective_configuration(configuration,
self._local_configuration)
self._dynamic_configuration = configuration
self._cache_needs_saving = True
return True
except Exception:
logger.exception('Exception when setting dynamic_configuration')
def reload_local_configuration(self, dry_run=False):
if self.config_file:
try:
configuration = self._load_config_file()
if not deep_compare(self._local_configuration, configuration):
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
if dry_run:
return not deep_compare(new_configuration, self.__effective_configuration)
self._local_configuration = configuration
self.__effective_configuration = new_configuration
return True
except Exception:
logger.exception('Exception when reloading local configuration from %s', self.config_file)
if dry_run:
raise
@staticmethod
def _process_postgresql_parameters(parameters, is_local=False):
ret = {}
for name, value in (parameters or {}).items():
if name not in Postgresql.CMDLINE_OPTIONS or not is_local and Postgresql.CMDLINE_OPTIONS[name][1](value):
ret[name] = value
return ret
def _safe_copy_dynamic_configuration(self, dynamic_configuration):
config = deepcopy(self.__DEFAULT_CONFIG)
for name, value in dynamic_configuration.items():
if name == 'postgresql':
for name, value in (value or {}).items():
if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value))
elif name not in ('connect_address', 'listen', 'data_dir', 'pgpass', 'authentication'):
config['postgresql'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overriden from DCS
config[name] = int(value)
return config
@staticmethod
def _build_environment_configuration():
ret = defaultdict(dict)
def _popenv(name):
return os.environ.pop(Config.PATRONI_ENV_PREFIX + name.upper(), None)
for param in ('name', 'namespace', 'scope'):
value = _popenv(param)
if value:
ret[param] = value
def _set_section_values(section, params):
for param in params:
value = _popenv(section + '_' + param)
if value:
ret[section][param] = value
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile'])
_set_section_values('postgresql', ['listen', 'connect_address', 'data_dir', 'pgpass', 'bin_dir'])
def _get_auth(name):
ret = {}
for param in ('username', 'password'):
value = _popenv(name + '_' + param)
if value:
ret[param] = value
return ret
restapi_auth = _get_auth('restapi')
if restapi_auth:
ret['restapi']['authentication'] = restapi_auth
authentication = {}
for user_type in ('replication', 'superuser'):
entry = _get_auth(user_type)
if entry:
authentication[user_type] = entry
if authentication:
ret['postgresql']['authentication'] = authentication
users = {}
def _parse_list(value):
if not (value.strip().startswith('-') or '[' in value):
value = '[{0}]'.format(value)
try:
return yaml.safe_load(value)
except Exception:
logger.exception('Exception when parsing list %s', value)
return None
for param in list(os.environ.keys()):
if param.startswith(Config.PATRONI_ENV_PREFIX):
name, suffix = (param[8:].rsplit('_', 1) + [''])[:2]
if name and suffix:
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT)
if suffix in ('HOST', 'HOSTS', 'PORT') and '_' not in name:
value = os.environ.pop(param)
if suffix == 'PORT':
value = value and parse_int(value)
elif suffix == 'HOSTS':
value = value and _parse_list(value)
if value:
ret[name.lower()][suffix.lower()] = value
# PATRONI_<username>_PASSWORD=<password>, PATRONI_<username>_OPTIONS=<option1,option2,...>
# CREATE USER "<username>" WITH <OPTIONS> PASSWORD '<password>'
elif suffix == 'PASSWORD':
password = os.environ.pop(param)
if password:
users[name] = {'password': password}
options = os.environ.pop(param[:-9] + '_OPTIONS', None)
options = options and _parse_list(options)
if options:
users[name]['options'] = options
if users:
ret['bootstrap']['users'] = users
return ret
def _build_effective_configuration(self, dynamic_configuration, local_configuration):
config = self._safe_copy_dynamic_configuration(dynamic_configuration)
for name, value in local_configuration.items():
if name == 'postgresql':
for name, value in (value or {}).items():
if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value, True))
elif name != 'use_slots': # replication slots must be enabled/disabled globally
config['postgresql'][name] = deepcopy(value)
elif name not in config:
config[name] = deepcopy(value) if value else {}
# restapi server expects to get restapi.auth = 'username:password'
if 'authentication' in config['restapi']:
restapi = config['restapi']
auth = restapi['authentication']
restapi['auth'] = '{0}:{1}'.format(auth['username'], auth['password'])
# special treatment for old config
# 'exhibitor' inside 'zookeeper':
if 'zookeeper' in config and 'exhibitor' in config['zookeeper']:
config['exhibitor'] = config['zookeeper'].pop('exhibitor')
config.pop('zookeeper')
pg_config = config['postgresql']
# no 'authentication' in 'postgresql', but 'replication' and 'superuser'
if 'authentication' not in pg_config:
pg_config['use_pg_rewind'] = 'pg_rewind' in pg_config
pg_config['authentication'] = {u: pg_config[u] for u in ('replication', 'superuser') if u in pg_config}
# no 'superuser' in 'postgresql'.'authentication'
if 'superuser' not in pg_config['authentication'] and 'pg_rewind' in pg_config:
pg_config['authentication']['superuser'] = pg_config['pg_rewind']
# no 'name' in config
if 'name' not in config and 'name' in pg_config:
config['name'] = pg_config['name']
pg_config.update({p: config[p] for p in ('name', 'scope', 'retry_timeout',
'maximum_lag_on_failover') if p in config})
return config
def get(self, key, default=None):
return self.__effective_configuration.get(key, default)
def __contains__(self, key):
return key in self.__effective_configuration
def __getitem__(self, key):
return self.__effective_configuration[key]
def copy(self):
return deepcopy(self.__effective_configuration)
+839
View File
@@ -0,0 +1,839 @@
'''
Patroni Control
'''
import base64
import click
import datetime
import dateutil.parser
import json
import logging
import os
import psycopg2
import random
import requests
import sys
import time
import tzlocal
import yaml
from click import ClickException
from patroni.config import Config
from patroni.dcs import get_dcs as _get_dcs
from patroni.exceptions import PatroniException
from patroni.postgresql import Postgresql
from patroni.utils import is_valid_pg_version
from prettytable import PrettyTable
from six.moves.urllib_parse import urlparse
CONFIG_DIR_PATH = click.get_app_dir('patroni')
CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'patronictl.yaml')
LOGLEVEL = 'WARNING'
DCS_DEFAULTS = {'zookeeper': {'port': 2181, 'template': "zookeeper:\n hosts: ['{host}:{port}']"},
'exhibitor': {'port': 8181, 'template': "exhibitor:\n hosts: [{host}]\n port: {port}"},
'consul': {'port': 8500, 'template': "consul:\n host: '{host}:{port}'"},
'etcd': {'port': 4001, 'template': "etcd:\n host: '{host}:{port}'"}}
class PatroniCtlException(ClickException):
pass
def parse_dcs(dcs):
if dcs is None:
return None
parsed = urlparse(dcs)
scheme = parsed.scheme
if scheme == '' and parsed.netloc == '':
parsed = urlparse('//' + dcs)
port = int(parsed.port) if parsed.port else None
if scheme == '':
scheme = ([k for k, v in DCS_DEFAULTS.items() if v['port'] == port] or ['etcd'])[0]
elif scheme not in DCS_DEFAULTS:
raise PatroniCtlException('Unknown dcs scheme: {}'.format(scheme))
dcs_info = DCS_DEFAULTS[scheme]
return yaml.load(dcs_info['template'].format(host=parsed.hostname or 'localhost', port=port or dcs_info['port']))
def load_config(path, dcs):
logging.debug('Loading configuration from file %s', path)
config = {}
old_argv = list(sys.argv)
try:
sys.argv[1] = path
if Config.PATRONI_CONFIG_VARIABLE not in os.environ:
for p in ('PATRONI_RESTAPI_LISTEN', 'PATRONI_POSTGRESQL_DATA_DIR'):
if p not in os.environ:
os.environ[p] = '.'
config = Config().copy()
finally:
sys.argv = old_argv
dcs = parse_dcs(dcs) or parse_dcs(config.get('dcs_api')) or {}
if dcs:
for d in DCS_DEFAULTS:
config.pop(d, None)
config.update(dcs)
return config
def store_config(config, path):
dir_path = os.path.dirname(path)
if dir_path and not os.path.isdir(dir_path):
os.makedirs(dir_path)
with open(path, 'w') as fd:
yaml.dump(config, fd)
option_config_file = click.option('--config-file', '-c', help='Configuration file', default=CONFIG_FILE_PATH)
option_format = click.option('--format', '-f', 'fmt', help='Output format (pretty, json)', default='pretty')
option_dcs = click.option('--dcs', '-d', help='Use this DCS', envvar='DCS')
option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds')
option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds')
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
@click.group()
@click.pass_context
def ctl(ctx):
global LOGLEVEL
LOGLEVEL = os.environ.get('LOGLEVEL', LOGLEVEL)
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=LOGLEVEL)
def get_dcs(config, scope):
config['scope'] = scope
config.setdefault('name', scope)
try:
return _get_dcs(config)
except PatroniException as e:
raise PatroniCtlException(str(e))
def auth_header(config):
if config.get('restapi', {}).get('auth', ''):
return {'Authorization': 'Basic ' + base64.b64encode(config['restapi']['auth'].encode('utf-8')).decode('utf-8')}
def request_patroni(member, request_type, endpoint, content=None, headers=None):
headers = headers or {}
url_parts = urlparse(member.api_url)
logging.debug(url_parts)
if 'Content-Type' not in headers:
headers['Content-Type'] = 'application/json'
url = '{0}://{1}/{2}'.format(url_parts.scheme, url_parts.netloc, endpoint)
return getattr(requests, request_type)(url, headers=headers,
data=json.dumps(content) if content else None, timeout=60)
def print_output(columns, rows=None, alignment=None, fmt='pretty', header=True, delimiter='\t'):
rows = rows or []
if fmt == 'pretty':
t = PrettyTable(columns)
for k, v in (alignment or {}).items():
t.align[k] = v
for r in rows:
t.add_row(r)
click.echo(t)
return
if fmt == 'json':
elements = [dict(zip(columns, r)) for r in rows]
click.echo(json.dumps(elements))
if fmt == 'tsv':
if columns is not None and header:
click.echo(delimiter.join(columns) + '\n')
for r in rows:
c = [str(c) for c in r]
click.echo(delimiter.join(c))
def watching(w, watch, max_count=None, clear=True):
"""
>>> len(list(watching(True, 1, 0)))
1
>>> len(list(watching(True, 1, 1)))
2
>>> len(list(watching(True, None, 0)))
1
"""
if w and not watch:
watch = 2
if watch and clear:
click.clear()
yield 0
if max_count is not None and max_count < 1:
return
counter = 1
while watch and counter <= (max_count or counter):
time.sleep(watch)
counter += 1
if clear:
click.clear()
yield 0
def get_all_members(cluster, role='master'):
if role == 'master':
if cluster.leader is not None:
yield cluster.leader
return
leader_name = (cluster.leader.member.name if cluster.leader else None)
for m in cluster.members:
if role == 'any' or role == 'replica' and m.name != leader_name:
yield m
def get_any_member(cluster, role='master', member=None):
members = get_all_members(cluster, role)
for m in members:
if member is None or m.name == member:
return m
def get_cursor(cluster, connect_parameters, role='master', member=None):
member = get_any_member(cluster, role=role, member=member)
if member is None:
return None
params = member.conn_kwargs(connect_parameters)
params.update({'fallback_application_name': 'Patroni ctl', 'connect_timeout': '5'})
if 'database' in connect_parameters:
params['database'] = connect_parameters['database']
else:
params.pop('database')
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
def get_members(cluster, cluster_name, member_names, role, force, action):
candidates = {m.name: m for m in cluster.members}
if not force or role:
output_members(cluster, cluster_name)
if role:
role_names = [m.name for m in get_all_members(cluster, role)]
if member_names:
member_names = list(set(member_names) & set(role_names))
if not member_names:
raise PatroniCtlException('No {0} among provided members'.format(role))
else:
member_names = role_names
if not member_names and not force:
member_names = [click.prompt('Which member do you want to {0} [{1}]?'.format(action,
', '.join(candidates.keys())), type=str, default='')]
for mn in member_names:
if mn not in candidates:
raise PatroniCtlException('{0} is not a member of cluster'.format(mn))
if not force:
confirm = click.confirm('Are you sure you want to {0} members {1}?'.format(action, ', '.join(member_names)))
if not confirm:
raise PatroniCtlException('Aborted {0}'.format(action))
return [candidates[n] for n in member_names]
@ctl.command('dsn', help='Generate a dsn for the provided member, defaults to a dsn of the master')
@click.option('--role', '-r', help='Give a dsn of any member with this role', type=click.Choice(['master', 'replica',
'any']), default=None)
@click.option('--member', '-m', help='Generate a dsn for this member', type=str)
@option_dcs
@option_config_file
@click.argument('cluster_name')
def dsn(cluster_name, config_file, dcs, role, member):
if role is not None and member is not None:
raise PatroniCtlException('--role and --member are mutually exclusive options')
if member is None and role is None:
role = 'master'
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
m = get_any_member(cluster, role=role, member=member)
if m is None:
raise PatroniCtlException('Can not find a suitable member')
params = m.conn_kwargs()
click.echo('host={host} port={port}'.format(**params))
@ctl.command('query', help='Query a Patroni PostgreSQL member')
@click.argument('cluster_name')
@option_config_file
@option_format
@click.option('--format', 'fmt', help='Output format (pretty, json)', default='tsv')
@click.option('--file', '-f', 'p_file', help='Execute the SQL commands from this file', type=click.File('rb'))
@click.option('--password', help='force password prompt', is_flag=True)
@click.option('-U', '--username', help='database user name', type=str)
@option_dcs
@option_watch
@option_watchrefresh
@click.option('--role', '-r', help='The role of the query', type=click.Choice(['master', 'replica', 'any']),
default=None)
@click.option('--member', '-m', help='Query a specific member', type=str)
@click.option('--delimiter', help='The column delimiter', default='\t')
@click.option('--command', '-c', help='The SQL commands to execute')
@click.option('-d', '--dbname', help='database name to connect to', type=str)
def query(
cluster_name,
config_file,
dcs,
role,
member,
w,
watch,
delimiter,
command,
p_file,
password,
username,
dbname,
fmt='tsv',
):
if role is not None and member is not None:
raise PatroniCtlException('--role and --member are mutually exclusive options')
if member is None and role is None:
role = 'master'
if p_file is not None and command is not None:
raise PatroniCtlException('--file and --command are mutually exclusive options')
if p_file is None and command is None:
raise PatroniCtlException('You need to specify either --command or --file')
connect_parameters = dict()
if username:
connect_parameters['username'] = username
if password:
connect_parameters['password'] = click.prompt('Password', hide_input=True, type=str)
if dbname:
connect_parameters['database'] = dbname
if p_file is not None:
command = p_file.read()
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
cursor = None
for _ in watching(w, watch, clear=False):
output, cursor = query_member(cluster, cursor, member, role, command, connect_parameters)
print_output(None, output, fmt=fmt, delimiter=delimiter)
if cursor is None:
cluster = dcs.get_cluster()
def query_member(cluster, cursor, member, role, command, connect_parameters):
try:
if cursor is None:
cursor = get_cursor(cluster, connect_parameters, role=role, member=member)
if cursor is None:
if role is None:
message = 'No connection to member {0} is available'.format(member)
else:
message = 'No connection to role={0} is available'.format(role)
logging.debug(message)
return [[timestamp(0), message]], None
cursor.execute('SELECT pg_is_in_recovery()')
in_recovery = cursor.fetchone()[0]
if in_recovery and role == 'master' or not in_recovery and role == 'replica':
cursor.connection.close()
return None, None
cursor.execute(command)
return cursor.fetchall(), cursor
except (psycopg2.OperationalError, psycopg2.DatabaseError) as oe:
logging.debug(oe)
if cursor is not None and not cursor.connection.closed:
cursor.connection.close()
message = oe.pgcode or oe.pgerror or str(oe)
message = message.replace('\n', ' ')
return [[timestamp(0), 'ERROR, SQLSTATE: {0}'.format(message)]], None
@ctl.command('remove', help='Remove cluster from DCS')
@click.argument('cluster_name')
@option_config_file
@option_format
@option_dcs
def remove(config_file, cluster_name, fmt, dcs):
_, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
output_members(cluster, cluster_name, fmt=fmt)
confirm = click.prompt('Please confirm the cluster name to remove', type=str)
if confirm != cluster_name:
raise PatroniCtlException('Cluster names specified do not match')
message = 'Yes I am aware'
confirm = \
click.prompt('You are about to remove all information in DCS for {0}, please type: "{1}"'.format(cluster_name,
message), type=str)
if message != confirm:
raise PatroniCtlException('You did not exactly type "{0}"'.format(message))
if cluster.leader:
confirm = click.prompt('This cluster currently is healthy. Please specify the master name to continue')
if confirm != cluster.leader.name:
raise PatroniCtlException('You did not specify the current master of the cluster')
dcs.delete_cluster()
def wait_for_leader(dcs, timeout=30):
t_stop = time.time() + timeout
timeout /= 2
while time.time() < t_stop:
dcs.watch(timeout)
cluster = dcs.get_cluster()
if cluster.leader:
return cluster
raise PatroniCtlException('Timeout occured')
def 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
def check_response(response, member_name, action_name, silent_success=False):
if response.status_code >= 400:
click.echo('Failed: {0} for member {1}, status code={2}, ({3})'.format(
action_name, member_name, response.status_code, response.text
))
elif not silent_success:
click.echo('Success: {0} for member {1}'.format(action_name, member_name))
def parse_scheduled(scheduled):
if (scheduled or 'now') != 'now':
try:
scheduled_at = dateutil.parser.parse(scheduled)
if scheduled_at.tzinfo is None:
scheduled_at = tzlocal.get_localzone().localize(scheduled_at)
except (ValueError, TypeError):
message = 'Unable to parse scheduled timestamp ({0}). It should be in an unambiguous format (e.g. ISO 8601)'
raise PatroniCtlException(message.format(scheduled))
return scheduled_at
return None
@ctl.command('restart', help='Restart cluster member')
@click.argument('cluster_name')
@click.argument('member_names', nargs=-1)
@click.option('--role', '-r', help='Restart only members with this role', default='any',
type=click.Choice(['master', 'replica', 'any']))
@click.option('--any', 'p_any', help='Restart a single member only', is_flag=True)
@click.option('--scheduled', help='Timestamp of a scheduled restart in unambiguous format (e.g. ISO 8601)',
default=None)
@click.option('--pg-version', 'version', help='Restart if the PostgreSQL version is less than provided (e.g. 9.5.2)',
default=None)
@click.option('--pending', help='Restart if pending', is_flag=True)
@option_config_file
@option_force
@option_dcs
def restart(cluster_name, member_names, config_file, dcs, force, role, p_any, scheduled, version, pending):
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
members = get_members(cluster, cluster_name, member_names, role, force, 'restart')
if p_any:
random.shuffle(members)
members = members[:1]
if version is None and not force:
version = click.prompt('Restart if the PostgreSQL version is less than provided (e.g. 9.5.2) ',
type=str, default='')
content = {}
if pending:
content['restart_pending'] = True
if version:
if not is_valid_pg_version(version):
message = 'PostgreSQL version should be in the first.major.minor format'
raise PatroniCtlException(message)
else:
content['postgres_version'] = version
if scheduled is None and not force:
scheduled = click.prompt('When should the restart take place (e.g. 2015-10-01T14:30) ', type=str, default='now')
scheduled_at = parse_scheduled(scheduled)
if scheduled_at:
if cluster.is_paused():
raise PatroniCtlException("Can't schedule restart in the paused state")
content['schedule'] = scheduled_at.isoformat()
for member in members:
if 'schedule' in content:
if force and member.data.get('scheduled_restart'):
r = request_patroni(member, 'delete', 'restart', headers=auth_header(config))
check_response(r, member.name, 'flush scheduled restart', True)
r = request_patroni(member, 'post', 'restart', content, auth_header(config))
if r.status_code == 200:
click.echo('Success: restart on member {0}'.format(member.name))
elif r.status_code == 202:
click.echo('Success: restart scheduled on member {0}'.format(member.name))
elif r.status_code == 409:
click.echo('Failed: another restart is already scheduled on member {0}'.format(member.name))
else:
click.echo('Failed: restart for member {0}, status code={1}, ({2})'.format(
member.name, r.status_code, r.text)
)
@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)
members = get_members(cluster, cluster_name, member_names, None, force, 'reinitialize')
for member in members:
r = request_patroni(member, 'post', 'reinitialize', headers=auth_header(config))
check_response(r, member.name, 'reinitialize')
@ctl.command('failover', help='Failover to a replica')
@click.argument('cluster_name')
@click.option('--master', help='The name of the current master', default=None)
@click.option('--candidate', help='The name of the candidate', default=None)
@click.option('--scheduled', help='Timestamp of a scheduled failover in unambiguous format (e.g. ISO 8601)',
default=None)
@option_force
@option_config_file
@option_dcs
def failover(config_file, cluster_name, master, candidate, force, dcs, scheduled):
"""
We want to trigger a failover for the specified cluster name.
We verify that the cluster name, master name and candidate name are correct.
If so, we trigger a failover and keep the client up to date.
"""
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
if cluster.leader is None and not cluster.is_paused():
raise PatroniCtlException('This cluster has no master')
if master is None and (not cluster.is_paused() or cluster.leader):
if force:
master = cluster.leader.member.name
else:
master = click.prompt('Master', type=str, default=cluster.leader.member.name)
if master is not None and cluster.leader and cluster.leader.member.name != master:
raise PatroniCtlException('Member {0} is not the leader of cluster {1}'.format(master, cluster_name))
candidate_names = [str(m.name) for m in cluster.members if m.name != master]
# We sort the names for consistent output to the client
candidate_names.sort()
if not candidate_names:
raise PatroniCtlException('No candidates found to failover to')
if candidate is None and not force:
candidate = click.prompt('Candidate ' + str(candidate_names), type=str, default='')
if candidate == master:
raise PatroniCtlException('Failover target and source are the same.')
if candidate and candidate not in candidate_names:
raise PatroniCtlException('Member {0} does not exist in cluster {1}'.format(candidate, cluster_name))
if scheduled is None and not force:
scheduled = click.prompt('When should the failover take place (e.g. 2015-10-01T14:30) ', type=str,
default='now')
scheduled_at = parse_scheduled(scheduled)
if scheduled_at:
if cluster.is_paused():
raise PatroniCtlException("Can't schedule failover in the paused state")
scheduled_at = scheduled_at.isoformat()
failover_value = {'leader': master, 'candidate': candidate, 'scheduled_at': scheduled_at}
logging.debug(failover_value)
# By now we have established that the leader exists and the candidate exists
click.echo('Current cluster topology')
output_members(dcs.get_cluster(), cluster_name)
if not force:
a = \
click.confirm('Are you sure you want to failover cluster {0}, demoting current master {1}?'.format(
cluster_name, master))
if not a:
raise PatroniCtlException('Aborting failover')
r = None
try:
member = cluster.leader.member if cluster.leader else [m for m in cluster.members if m.name == candidate][0]
r = request_patroni(member, 'post', 'failover', failover_value, auth_header(config))
if r.status_code in (200, 202):
logging.debug(r)
cluster = dcs.get_cluster()
logging.debug(cluster)
click.echo('{0} {1}'.format(timestamp(), r.text))
else:
click.echo('Failover failed, details: {0}, {1}'.format(r.status_code, r.text))
return
except Exception:
logging.exception(r)
logging.warning('Failing over to DCS')
click.echo(timestamp() + ' Could not failover using Patroni api, falling back to DCS')
click.echo(timestamp() + ' Initializing failover from master {0}'.format(master))
dcs.manual_failover(master, candidate, scheduled_at=failover_value)
output_members(cluster, cluster_name)
def output_members(cluster, name, extended=False, fmt='pretty'):
rows = []
logging.debug(cluster)
leader_name = None
if cluster.leader:
leader_name = cluster.leader.member.name
xlog_location_cluster = cluster.last_leader_operation or 0
# Mainly for consistent pretty printing and watching we sort the output
cluster.members.sort(key=lambda x: x.name)
for m in cluster.members:
logging.debug(m)
leader = ''
if m.name == leader_name:
leader = '*'
host = m.conn_kwargs()['host']
xlog_location = m.data.get('xlog_location') or 0
lag = ''
if xlog_location_cluster >= xlog_location:
lag = round((xlog_location_cluster - xlog_location)/1024/1024)
row = [
name,
m.name,
host,
leader,
m.data.get('state', ''),
lag,
]
if extended:
value = ''
scheduled_restart = m.data.get('scheduled_restart')
if scheduled_restart:
value = scheduled_restart['schedule']
if 'postgres_version' in scheduled_restart:
value += ' if version < {0}'.format(scheduled_restart['postgres_version'])
row.append(value)
rows.append(row)
columns = [
'Cluster',
'Member',
'Host',
'Leader',
'State',
'Lag in MB',
]
alignment = {'Cluster': 'l', 'Member': 'l', 'Host': 'l', 'Lag in MB': 'r'}
if extended:
columns.append('Scheduled restart')
alignment['Scheduled restart'] = 'l'
print_output(columns, rows, alignment, fmt)
@ctl.command('list', help='List the Patroni members for a given Patroni')
@click.argument('cluster_names', nargs=-1)
@click.option('--extended', '-e', help='Show some extra information', is_flag=True)
@option_config_file
@option_format
@option_watch
@option_watchrefresh
@option_dcs
def members(config_file, cluster_names, fmt, watch, w, dcs, extended):
if not cluster_names:
logging.warning('Listing members: No cluster names were provided')
return
config = load_config(config_file, dcs)
for cluster_name in cluster_names:
dcs = get_dcs(config, cluster_name)
for _ in watching(w, watch):
cluster = dcs.get_cluster()
output_members(cluster, cluster_name, extended, fmt)
def timestamp(precision=6):
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:precision - 7]
@ctl.command('configure', help='Create configuration file')
@click.option('--config-file', '-c', help='Configuration file', prompt='Configuration file', default=CONFIG_FILE_PATH)
@click.option('--dcs', '-d', help='The DCS connect url', prompt='DCS connect url', default='etcd://localhost:4001')
@click.option('--namespace', '-n', help='The namespace', prompt='Namespace', default='/service/')
def configure(config_file, dcs, namespace):
config = dict()
config['dcs_api'] = str(dcs)
config['namespace'] = str(namespace)
store_config(config, config_file)
def touch_member(config, dcs):
''' Rip-off of the ha.touch_member without inter-class dependencies '''
p = Postgresql(config['postgresql'])
p.set_state('running')
p.set_role('master')
def restapi_connection_string(config):
protocol = 'https' if config.get('certfile') else 'http'
connect_address = config.get('connect_address')
listen = config['listen']
return '{0}://{1}/patroni'.format(protocol, connect_address or listen)
data = {
'conn_url': p.connection_string,
'api_url': restapi_connection_string(config['restapi']),
'state': p.state,
'role': p.role
}
return dcs.touch_member(json.dumps(data, separators=(',', ':')), permanent=True)
def set_defaults(config, cluster_name):
"""fill-in some basic configuration parameters if config file is not set """
config['postgresql'].setdefault('name', cluster_name)
config['postgresql'].setdefault('scope', cluster_name)
config['postgresql'].setdefault('listen', '127.0.0.1')
config['postgresql']['authentication'] = {'replication': None}
config['restapi']['listen'] = ':' in config['restapi']['listen'] and config['restapi']['listen'] or '127.0.0.1:8008'
@ctl.command('scaffold', help='Create a structure for the cluster in DCS')
@click.argument('cluster_name')
@click.option('--sysid', '-s', help='System ID of the cluster to put into the initialize key', default="")
@option_config_file
@option_dcs
def scaffold(cluster_name, config_file, dcs, sysid):
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
if cluster and cluster.initialize is not None:
raise PatroniCtlException("This cluster is already initialized")
if not dcs.initialize(create_new=True, sysid=sysid):
# initialize key already exists, don't touch this cluster
raise PatroniCtlException("Initialize key for cluster {0} already exists".format(cluster_name))
set_defaults(config, cluster_name)
# make sure the leader keys will never expire
if not (touch_member(config, dcs) and dcs.attempt_to_acquire_leader(permanent=True)):
# we did initialize this cluster, but failed to write the leader or member keys, wipe it down completely.
dcs.delete_cluster()
raise PatroniCtlException("Unable to install permanent leader for cluster {0}".format(cluster_name))
click.echo("Cluster {0} has been created successfully".format(cluster_name))
@ctl.command('flush', help='Flush scheduled events')
@click.argument('cluster_name')
@click.argument('member_names', nargs=-1)
@click.argument('target', type=click.Choice(['restart']))
@click.option('--role', '-r', help='Flush only members with this role', default='any',
type=click.Choice(['master', 'replica', 'any']))
@option_config_file
@option_force
@option_dcs
def flush(cluster_name, member_names, config_file, dcs, force, role, target):
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
members = get_members(cluster, cluster_name, member_names, role, force, 'flush')
for member in members:
if target == 'restart':
if member.data.get('scheduled_restart'):
r = request_patroni(member, 'delete', 'restart', None, auth_header(config))
check_response(r, member.name, 'flush scheduled restart')
else:
click.echo('No scheduled restart for member {0}'.format(member.name))
def toggle_pause(config_file, cluster_name, dcs, paused):
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
if cluster.is_paused() == paused:
raise PatroniCtlException('Cluster is {0} paused'.format(paused and 'already' or 'not'))
r = request_patroni(cluster.leader.member, 'patch', 'config', {'pause': paused or None}, auth_header(config))
if r.status_code == 200:
click.echo('Success: cluster management is {0}'.format(paused and 'paused' or 'resumed'))
else:
click.echo('Failed: {0} cluster management status code={1}, ({2})'.format(
paused and 'pause' or 'resume', r.status_code, r.text))
@ctl.command('pause', help='Disable auto failover')
@click.argument('cluster_name')
@option_config_file
@option_dcs
def pause(config_file, cluster_name, dcs):
return toggle_pause(config_file, cluster_name, dcs, True)
@ctl.command('resume', help='Resume auto failover')
@click.argument('cluster_name')
@option_config_file
@option_dcs
def resume(config_file, cluster_name, dcs):
return toggle_pause(config_file, cluster_name, dcs, False)
-185
View File
@@ -1,185 +0,0 @@
import abc
from collections import namedtuple
from patroni.exceptions import DCSError
from patroni.utils import calculate_ttl, sleep
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
def parse_connection_string(value):
"""Original Governor stores connection strings for each cluster members if a following format:
postgres://{username}:{password}@{connect_address}/postgres
Since each of our patroni instances provides own REST API endpoint it's good to store this information
in DCS among with postgresql connection string. In order to not introduce new keys and be compatible with
original Governor we decided to extend original connection string in a following way:
postgres://{username}:{password}@{connect_address}/postgres?application_name={api_url}
This way original Governor could use such connection string as it is, because of feature of `libpq` library.
This method is able to split connection string stored in DCS into two parts, `conn_url` and `api_url`"""
scheme, netloc, path, params, query, fragment = urlparse(value)
conn_url = urlunparse((scheme, netloc, path, params, '', fragment))
api_url = ([v for n, v in parse_qsl(query) if n == 'application_name'] or [None])[0]
return conn_url, api_url
class Member(namedtuple('Member', 'index,name,conn_url,api_url,expiration,ttl')):
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
Consists of the following fields:
:param index: modification index of a given member key in a Configuration Store
: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 api_url: REST API url of patroni instance
:param expiration: expiration time of given member key
:param ttl: ttl of given member key in seconds"""
def real_ttl(self):
return calculate_ttl(self.expiration) or -1
class Leader(namedtuple('Leader', 'index,expiration,ttl,member')):
"""Immutable object (namedtuple) which represents leader key.
Consists of the following fields:
:param index: modification index of a leader key in a Configuration Store
:param expiration: expiration time of the leader key
:param ttl: ttl of the leader key
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
@property
def name(self):
return self.member.name
@property
def conn_url(self):
return self.member.conn_url
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members')):
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
Consists of the following fields:
:param initialize: boolean, shows whether this cluster has initialization key stored in DC or not.
: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.
This value is stored in `/optime/leader` key
:param members: list of Member object, all PostgreSQL cluster members including leader"""
def is_unlocked(self):
return not (self.leader and self.leader.name)
class AbstractDCS:
__metaclass__ = abc.ABCMeta
_INITIALIZE = 'initialize'
_LEADER = 'leader'
_MEMBERS = 'members/'
_OPTIME = 'optime'
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
def __init__(self, name, config):
"""
:param name: name of current instance (the same value as `~Postgresql.name`)
:param config: dict, reference to config section of selected DCS.
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
"""
self._name = name
self._scope = config['scope']
self._base_path = '/service/' + self._scope
def client_path(self, 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 leader_optime_path(self):
return self.client_path(self._LEADER_OPTIME)
@abc.abstractmethod
def get_cluster(self):
""":returns: `Cluster` object which represent current state and topology of the cluster
raise `~DCSError` in case of communication or other problems with DCS. If current instance was
running as a master and exception raised instance would be demoted."""
@abc.abstractmethod
def update_leader(self, state_handler):
"""Update leader key (or session) ttl and `/optime/leader` key in DCS.
:param state_handler: reference to `Postgresql` object
:returns: `!True` if leader key (or session) has been updated successfully.
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,
for example for etcd `prevValue` parameter must be used."""
@abc.abstractmethod
def attempt_to_acquire_leader(self):
"""Attempt to acquire leader lock
This method should create `/leader` key with value=`~self._name`
:returns: `!True` if key has been created successfully.
Key must be created atomically. In case if key already exists it should not be
overwritten and `!False` must be returned"""
def current_leader(self):
try:
cluster = self.get_cluster()
return None if cluster.is_unlocked() else cluster.leader
except DCSError:
return None
@abc.abstractmethod
def touch_member(self, connection_string, ttl=None):
"""Update member key in DCS.
This method should create or update key with the name = '/members/' + `~self._name`
and value = connection_string in a given DCS.
:param connection_string: how this instance can be accessed by other instances
:param ttl: ttl for member key, optional parameter. If it is None `~self.member_ttl will be used`
:returns: `!True` on success otherwise `!False`
"""
@abc.abstractmethod
def take_leader(self):
"""This method should create leader key with value = `~self._name` and ttl=`~self.ttl`
Since it could be called only on initial cluster bootstrap it could create this key regardless,
overwriting the key if necessary."""
@abc.abstractmethod
def initialize(self):
"""Race for cluster initialization.
:returns: `!True` if key has been created successfully.
this method should create atomically initialize key and return `!True`
otherwise it should return `!False`"""
@abc.abstractmethod
def delete_leader(self):
"""Voluntarily remove leader key from DCS
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):
sleep(timeout)
+456
View File
@@ -0,0 +1,456 @@
import abc
import dateutil
import importlib
import inspect
import json
import os
import pkgutil
import six
import sys
from collections import namedtuple
from patroni.exceptions import PatroniException
from random import randint
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
from threading import Event, Lock
def parse_connection_string(value):
"""Original Governor stores connection strings for each cluster members if a following format:
postgres://{username}:{password}@{connect_address}/postgres
Since each of our patroni instances provides own REST API endpoint it's good to store this information
in DCS among with postgresql connection string. In order to not introduce new keys and be compatible with
original Governor we decided to extend original connection string in a following way:
postgres://{username}:{password}@{connect_address}/postgres?application_name={api_url}
This way original Governor could use such connection string as it is, because of feature of `libpq` library.
This method is able to split connection string stored in DCS into two parts, `conn_url` and `api_url`"""
scheme, netloc, path, params, query, fragment = urlparse(value)
conn_url = urlunparse((scheme, netloc, path, params, '', fragment))
api_url = ([v for n, v in parse_qsl(query) if n == 'application_name'] or [None])[0]
return conn_url, api_url
def dcs_modules():
"""Get names of DCS modules, depending on execution environment. If being packaged with PyInstaller,
modules aren't discoverable dynamically by scanning source directory because `FrozenImporter` doesn't
implement `iter_modules` method. But it is still possible to find all potential DCS modules by
iterating through `toc`, which contains list of all "frozen" resources."""
dcs_dirname = os.path.dirname(__file__)
module_prefix = __package__ + '.'
if getattr(sys, 'frozen', False):
importer = pkgutil.get_importer(dcs_dirname)
return [module for module in list(importer.toc) if module.startswith(module_prefix) and module.count('.') == 2]
else:
return [module_prefix + name for _, name, is_pkg in pkgutil.iter_modules([dcs_dirname]) if not is_pkg]
def get_dcs(config):
available_implementations = set()
for module_name in dcs_modules():
module = importlib.import_module(module_name)
for name in filter(lambda name: not name.startswith('__'), dir(module)): # iterate through module content
value = getattr(module, name)
name = name.lower()
# try to find implementation of AbstractDCS interface, class name must match with module_name
if inspect.isclass(value) and issubclass(value, AbstractDCS) and __package__ + '.' + name == module_name:
available_implementations.add(name)
if name in config: # which has configuration section in the config file
# propagate some parameters
config[name].update({p: config[p] for p in ('namespace', 'name', 'scope',
'loop_wait', 'ttl', 'retry_timeout') if p in config})
return value(config[name])
raise PatroniException("""Can not find suitable configuration of distributed configuration store
Available implementations: """ + ', '.join(available_implementations))
class Member(namedtuple('Member', 'index,name,session,data')):
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
Consists of the following fields:
:param index: modification index of a given member key in a Configuration Store
:param name: name of PostgreSQL cluster member
:param session: either session id or just ttl in seconds
:param data: arbitrary data i.e. conn_url, api_url, xlog location, state, role, tags, etc...
There are two mandatory keys in a data:
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 (TypeError, ValueError):
data = {}
return Member(index, name, session, data)
@property
def conn_url(self):
return self.data.get('conn_url')
def conn_kwargs(self, auth=None):
ret = self.data.get('conn_kwargs')
if ret:
ret = ret.copy()
else:
r = urlparse(self.conn_url)
ret = {
'host': r.hostname,
'port': r.port or 5432,
'database': r.path[1:]
}
self.data['conn_kwargs'] = ret.copy()
if auth and isinstance(auth, dict):
if 'username' in auth:
ret['user'] = auth['username']
if 'password' in auth:
ret['password'] = auth['password']
return ret
@property
def api_url(self):
return self.data.get('api_url')
@property
def tags(self):
return self.data.get('tags', {})
@property
def nofailover(self):
return self.tags.get('nofailover', False)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@property
def clonefrom(self):
return self.tags.get('clonefrom', False) and bool(self.conn_url)
class Leader(namedtuple('Leader', 'index,session,member')):
"""Immutable object (namedtuple) which represents leader key.
Consists of the following fields:
:param index: modification index of a leader key in a Configuration Store
:param session: either session id or just ttl in seconds
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
@property
def name(self):
return self.member.name
def conn_kwargs(self, auth=None):
return self.member.conn_kwargs(auth)
@property
def conn_url(self):
return self.member.conn_url
class Failover(namedtuple('Failover', 'index,leader,candidate,scheduled_at')):
"""
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader"}'))
True
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader", "member": "cluster_candidate"}'))
True
>>> Failover.from_node(1, 'null') is None
True
>>> n = '{"leader": "cluster_leader", "member": "cluster_candidate", "scheduled_at": "2016-01-14T10:09:57.1394Z"}'
>>> 'tzinfo=' in str(Failover.from_node(1, n))
True
>>> Failover.from_node(1, None) is None
True
>>> Failover.from_node(1, '{}') is None
True
>>> 'abc' in Failover.from_node(1, 'abc:def')
True
"""
@staticmethod
def from_node(index, value):
if not value:
return None
try:
data = json.loads(value)
if not data:
return None
except ValueError:
t = [a.strip() for a in value.split(':')]
leader = t[0]
candidate = t[1] if len(t) > 1 else None
return Failover(index, leader, candidate, None) if leader or candidate else None
if data.get('scheduled_at'):
data['scheduled_at'] = dateutil.parser.parse(data['scheduled_at'])
return Failover(index, data.get('leader'), data.get('member'), data.get('scheduled_at'))
def __len__(self):
return int(bool(self.leader)) + int(bool(self.candidate))
class ClusterConfig(namedtuple('ClusterConfig', 'index,data,modify_index')):
@staticmethod
def from_node(index, data, modify_index=None):
"""
>>> ClusterConfig.from_node(1, '{') is None
True
"""
try:
data = json.loads(data)
except (TypeError, ValueError):
return None
return ClusterConfig(index, data, modify_index or index)
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operation,members,failover')):
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
Consists of the following fields:
:param initialize: shows whether this cluster has initialization key stored in DC or not.
:param config: global dynamic configuration, reference to `ClusterConfig` object
: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.
This value is stored in `/optime/leader` key
:param members: list of Member object, all PostgreSQL cluster members including leader
:param failover: reference to `Failover` object"""
def is_unlocked(self):
return not (self.leader and self.leader.name)
def has_member(self, member_name):
return any(m for m in self.members if m.name == member_name)
def get_member(self, member_name, fallback_to_leader=True):
return ([m for m in self.members if m.name == member_name] or [self.leader if fallback_to_leader else None])[0]
def get_clone_member(self):
candidates = [m for m in self.members if m.clonefrom and (not self.leader or m.name != self.leader.name)]
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
def is_paused(self):
return self.config and self.config.data.get('pause', False) or False
@six.add_metaclass(abc.ABCMeta)
class AbstractDCS(object):
_INITIALIZE = 'initialize'
_CONFIG = 'config'
_LEADER = 'leader'
_FAILOVER = 'failover'
_MEMBERS = 'members/'
_OPTIME = 'optime'
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
def __init__(self, config):
"""
:param config: dict, reference to config section of selected DCS.
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
"""
self._name = config['name']
self._namespace = '/{0}'.format(config.get('namespace', '/service/').strip('/'))
self._base_path = '/'.join([self._namespace, config['scope']])
self._set_loop_wait(config.get('loop_wait', 10))
self._cluster = None
self._cluster_thread_lock = Lock()
self.event = Event()
def client_path(self, path):
return '/'.join([self._base_path, path.lstrip('/')])
@property
def initialize_path(self):
return self.client_path(self._INITIALIZE)
@property
def config_path(self):
return self.client_path(self._CONFIG)
@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
def set_ttl(self, ttl):
"""Set the new ttl value for leader key"""
@abc.abstractmethod
def set_retry_timeout(self, retry_timeout):
"""Set the new value for retry_timeout"""
def _set_loop_wait(self, loop_wait):
self._loop_wait = loop_wait
def reload_config(self, config):
self._set_loop_wait(config['loop_wait'])
self.set_ttl(config['ttl'])
self.set_retry_timeout(config['retry_timeout'])
@property
def loop_wait(self):
return self._loop_wait
@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):
with self._cluster_thread_lock:
try:
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
def write_leader_optime(self, last_operation):
"""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
:returns: `!True` if leader key (or session) has been updated successfully.
If not, `!False` must be returned and current instance would be demoted.
You have to use CAS (Compare And Swap) operation in order to update leader key,
for example for etcd `prevValue` parameter must be used."""
@abc.abstractmethod
def attempt_to_acquire_leader(self, permanent=False):
"""Attempt to acquire leader lock
This method should create `/leader` key with value=`~self._name`
:param permanent: if set to `!True`, the leader key will never expire.
Used in patronictl for the external master
:returns: `!True` if key has been created successfully.
Key must be created atomically. In case if key already exists it should not be
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, candidate, scheduled_at=None, index=None):
failover_value = {}
if leader:
failover_value['leader'] = leader
if candidate:
failover_value['member'] = candidate
if scheduled_at:
failover_value['scheduled_at'] = scheduled_at.isoformat()
return self.set_failover_value(json.dumps(failover_value, separators=(',', ':')), index)
@abc.abstractmethod
def set_config_value(self, value, index=None):
"""Create or update `/config` key"""
@abc.abstractmethod
def touch_member(self, data, ttl=None, permanent=False):
"""Update member key in DCS.
This method should create or update key with the name = '/members/' + `~self._name`
and value = data in a given DCS.
:param data: json serialized information about instance (including connection strings)
:param ttl: ttl for member key, optional parameter. If it is None `~self.member_ttl will be used`
:param permanent: if set to `!True`, the member key will never expire.
Used in patronictl for the external master.
:returns: `!True` on success otherwise `!False`
"""
@abc.abstractmethod
def take_leader(self):
"""This method should create leader key with value = `~self._name` and ttl=`~self.ttl`
Since it could be called only on initial cluster bootstrap it could create this key regardless,
overwriting the key if necessary."""
@abc.abstractmethod
def initialize(self, create_new=True, sysid=""):
"""Race for cluster initialization.
:param create_new: False if the key should already exist (in the case we are setting the system_id)
:param sysid: PostgreSQL cluster system identifier, if specified, is written to the key
:returns: `!True` if key has been created successfully.
this method should create atomically initialize key and return `!True`
otherwise it should return `!False`"""
@abc.abstractmethod
def delete_leader(self):
"""Voluntarily remove leader key from DCS
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 """
@abc.abstractmethod
def delete_cluster(self):
"""Delete cluster from DCS"""
def watch(self, 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()
+268
View File
@@ -0,0 +1,268 @@
from __future__ import absolute_import
import logging
import os
import time
import six
from consul import ConsulException, NotFound, base, std
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import sleep
from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
class ConsulError(DCSError):
pass
class HTTPClient(std.HTTPClient):
def __init__(self, *args, **kwargs):
super(HTTPClient, self).__init__(*args, **kwargs)
def patch_default_timeout(self, timeout):
# Set a default timeout for the `request.session.request` method, that is used
# internally by the methods request.session.get, request.session.post and
# others. We monkey-patch here to avoid reimplementing each individual method from
# `std.HTTPClient`. By default, the timeout is not set. It means that a new
# session may hang almost indefinitely waiting for the server to respond,
# which is not what we want in Patroni.
request_func = getattr(self.session.request, '__func__' if six.PY3 else 'im_func')
defaults_attr_name = '__defaults__' if six.PY3 else 'func_defaults'
defaults = list(getattr(request_func, defaults_attr_name))
code = request_func.__code__ if six.PY3 else request_func.func_code
defaults[code.co_varnames[code.co_argcount - len(defaults):code.co_argcount].index('timeout')] = timeout
setattr(request_func, defaults_attr_name, tuple(defaults)) # monkeypatching
def get(self, callback, path, params=None):
# The get function is overridden to handle a special case of it being called
# with an index and wait parameters. That form indicates that a user needs to
# wait for the given key to change its value, with a wait timeout supplied. We
# don't want our monkey-patched timeout to be less than the value of the wait
# parameter, therefore, we set it to either the value of wait or a default of 5 minutes.
if isinstance(params, dict) and 'index' in params:
timeout = (float(params['wait'][:-1]) if 'wait' in params else 300) + 1
else:
timeout = None
return callback(self.response(self.session.get(self.uri(path, params), verify=self.verify, timeout=timeout)))
class ConsulClient(base.Consul):
@staticmethod
def connect(host, port, scheme, verify=True):
return HTTPClient(host, port, scheme, verify)
def catch_consul_errors(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (ConsulException, RequestException):
return False
return wrapper
class Consul(AbstractDCS):
def __init__(self, config):
super(Consul, self).__init__(config)
self._ttl = None
self._session = None
self._my_member_data = None
self.set_ttl(config.get('ttl') or 30)
host, port = config.get('host', '127.0.0.1:8500').split(':')
self._client = ConsulClient(host=host, port=port)
self._client.http.patch_default_timeout(config['retry_timeout']/2.0)
self._scope = config['scope']
self.create_session()
self.__do_not_watch = False
def create_session(self):
while not self._session:
try:
self.refresh_session()
except ConsulError:
logger.info('waiting on consul')
sleep(5)
def set_ttl(self, ttl):
ttl = ttl/2.0 # My experiments have shown that session expires after 2*ttl time
if self._ttl != ttl:
self._session = None
self.__do_not_watch = True
self._ttl = ttl
def set_retry_timeout(self, retry_timeout):
self._client.http.patch_default_timeout(retry_timeout/2.0)
def refresh_session(self):
""":returns: `!True` if it had to create new session"""
if self._session:
try:
return self._client.session.renew(self._session) is None
except NotFound:
self._session = None
if not self._session:
name = self._scope + '-' + self._name
try:
self._session = self._client.session.create(name=name, lock_delay=0, behavior='delete', ttl=self._ttl)
except (ConsulException, RequestException):
logger.exception('session.create')
if not self._session:
raise ConsulError('Failed to renew/create session')
return True
def client_path(self, path):
return super(Consul, self).client_path(path)[1:]
@staticmethod
def member(node):
return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value'])
def _load_cluster(self):
try:
path = self.client_path('/')
_, results = self._client.kv.get(path, recurse=True)
if results is None:
raise NotFound
nodes = {}
for node in results:
node['Value'] = (node['Value'] or b'').decode('utf-8')
nodes[os.path.relpath(node['Key'], path)] = node
# get initialize flag
initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['Value']
# get global dynamic configuration
config = nodes.get(self._CONFIG)
config = config and ClusterConfig.from_node(config['ModifyIndex'], config['Value'])
# get last leader operation
last_leader_operation = nodes.get(self._LEADER_OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['Value'])
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader
leader = nodes.get(self._LEADER)
if leader and leader['Value'] == self._name and self._session != leader.get('Session', 'x'):
logger.info('I am leader but not owner of the session. Removing leader node')
self._client.kv.delete(self.leader_path, cas=leader['ModifyIndex'])
leader = None
if leader:
member = Member(-1, leader['Value'], None, {})
member = ([m for m in members if m.name == leader['Value']] or [member])[0]
leader = Leader(leader['ModifyIndex'], leader.get('Session'), member)
# failover key
failover = nodes.get(self._FAILOVER)
if failover:
failover = Failover.from_node(failover['ModifyIndex'], failover['Value'])
self._cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover)
except NotFound:
self._cluster = Cluster(None, None, None, None, [], None)
except:
logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly')
def touch_member(self, data, **kwargs):
cluster = self.cluster
member = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
create_member = self.refresh_session()
if member and (create_member or member.session != self._session):
try:
self._client.kv.delete(self.member_path)
create_member = True
except Exception:
return False
if not create_member and member and data == self._my_member_data:
return True
try:
args = {} if kwargs.get('permanent', False) else {'acquire': self._session}
self._client.kv.put(self.member_path, data, **args)
self._my_member_data = data
return True
except Exception:
logger.exception('touch_member')
return False
@catch_consul_errors
def attempt_to_acquire_leader(self, permanent=False):
args = {} if permanent else {'acquire': self._session}
ret = self._client.kv.put(self.leader_path, self._name, **args)
if not ret:
logger.info('Could not take out TTL lock')
return ret
def take_leader(self):
return self.attempt_to_acquire_leader()
@catch_consul_errors
def set_failover_value(self, value, index=None):
return self._client.kv.put(self.failover_path, value, cas=index)
@catch_consul_errors
def set_config_value(self, value, index=None):
return self._client.kv.put(self.config_path, value, cas=index)
@catch_consul_errors
def write_leader_optime(self, last_operation):
return self._client.kv.put(self.leader_optime_path, last_operation)
@staticmethod
def update_leader():
return True
@catch_consul_errors
def initialize(self, create_new=True, sysid=''):
kwargs = {'cas': 0} if create_new else {}
return self._client.kv.put(self.initialize_path, sysid, **kwargs)
@catch_consul_errors
def cancel_initialization(self):
return self._client.kv.delete(self.initialize_path)
@catch_consul_errors
def delete_cluster(self):
return self._client.kv.delete(self.client_path(''), recurse=True)
@catch_consul_errors
def delete_leader(self):
cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
return self._client.kv.delete(self.leader_path, cas=cluster.leader.index)
def watch(self, timeout):
if self.__do_not_watch:
self.__do_not_watch = False
return True
cluster = self.cluster
if cluster and cluster.leader and cluster.leader.name != self._name and cluster.leader.index:
end_time = time.time() + timeout
while timeout >= 1:
try:
idx, _ = self._client.kv.get(self.leader_path, index=cluster.leader.index, wait=str(timeout) + 's')
return str(idx) != str(cluster.leader.index)
except (ConsulException, RequestException):
logging.exception('watch')
timeout = end_time - time.time()
try:
return super(Consul, self).watch(timeout)
finally:
self.event.clear()
+390
View File
@@ -0,0 +1,390 @@
from __future__ import absolute_import
import etcd
import logging
import os
import random
import requests
import socket
import time
from dns.exception import DNSException
from dns import resolver
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import Retry, RetryFailedError, sleep
from urllib3.exceptions import HTTPError, ReadTimeoutError
from requests.exceptions import RequestException
from six.moves.http_client import HTTPException
logger = logging.getLogger(__name__)
class EtcdError(DCSError):
pass
class Client(etcd.Client):
def __init__(self, config):
super(Client, self).__init__(read_timeout=config['retry_timeout'])
self._config = config
self._load_machines_cache()
self._allow_reconnect = True
def _build_request_parameters(self):
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect}
# calculate the number of retries and timeout *per node*
# actual number of retries depends on the number of nodes
etcd_nodes = len(self._machines_cache) + 1
kwargs['retries'] = 0 if etcd_nodes > 3 else (1 if etcd_nodes > 1 else 2)
# if etcd_nodes > 3:
# kwargs.update({'retries': 0, 'timeout': float(self.read_timeout)/etcd_nodes})
# elif etcd_nodes > 1:
# kwargs.update({'retries': 1, 'timeout': self.read_timeout/2.0/etcd_nodes})
# else:
# kwargs.update({'retries': 2, 'timeout': self.read_timeout/3.0})
kwargs['timeout'] = self.read_timeout/float(kwargs['retries'] + 1)/etcd_nodes
return kwargs
@property
def machines(self):
"""Original `machines` method(property) of `etcd.Client` class raise exception
when it failed to get list of etcd cluster members. This method is being called
only when request failed on one of the etcd members during `api_execute` call.
For us it's more important to execute original request rather then get new topology
of etcd cluster. So we will catch this exception and return empty list of machines.
Later, during next `api_execute` call we will forcefully update machines_cache.
Also this method implements the same timeout-retry logic as `api_execute`, because
the original method was retrying 2 times with the `read_timeout` on each node."""
kwargs = self._build_request_parameters()
while True:
try:
response = self.http.request(self._MGET, self._base_uri + self.version_prefix + '/machines', **kwargs)
machines = [n.strip() for n in self._handle_server_response(response).data.decode('utf-8').split(',')]
logger.debug("Retrieved list of machines: %s", machines)
random.shuffle(machines)
return machines
except Exception as e:
# We can't get the list of machines, if one server is in the
# machines cache, try on it
logger.error("Failed to get list of machines from %s%s: %r", self._base_uri, self.version_prefix, e)
if self._machines_cache:
self._base_uri = self._machines_cache.pop(0)
logger.info("Retrying on %s", self._base_uri)
elif self._update_machines_cache:
raise etcd.EtcdException("Could not get the list of servers, "
"maybe you provided the wrong "
"host(s) to connect to?")
else:
return []
def set_read_timeout(self, timeout):
self._read_timeout = timeout
def _do_http_request(self, request_executor, method, url, fields=None, **kwargs):
try:
response = request_executor(method, url, fields=fields, **kwargs)
response.data.decode('utf-8')
self._check_cluster_id(response)
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
if (isinstance(fields, dict) and fields.get("wait") == "true" and
isinstance(e, ReadTimeoutError)):
logger.debug("Watch timed out.")
raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e)
logger.error("Request to server %s failed: %r", self._base_uri, e)
logger.info("Reconnection allowed, looking for another server.")
self._base_uri = self._next_server(cause=e)
response = False
return response
def api_execute(self, path, method, params=None, timeout=None):
if not path.startswith('/'):
raise ValueError('Path does not start with /')
kwargs = {'fields': params, 'preload_content': False}
if method in [self._MGET, self._MDELETE]:
request_executor = self.http.request
elif method in [self._MPUT, self._MPOST]:
request_executor = self.http.request_encode_body
kwargs['encode_multipart'] = False
else:
raise etcd.EtcdException('HTTP method {0} not supported'.format(method))
# Update machines_cache if previous attempt of update has failed
if self._update_machines_cache:
self._load_machines_cache()
kwargs.update(self._build_request_parameters())
if timeout is not None:
kwargs.update({'retries': 0, 'timeout': timeout})
response = False
try:
some_request_failed = False
while not response:
response = self._do_http_request(request_executor, method, self._base_uri + path, **kwargs)
if response is False:
some_request_failed = True
if some_request_failed and not self._use_proxies:
self._machines_cache = self.machines
if self._base_uri in self._machines_cache:
self._machines_cache.remove(self._base_uri)
except etcd.EtcdConnectionFailed:
self._update_machines_cache = True
if not response:
raise
return self._handle_server_response(response)
@staticmethod
def get_srv_record(host):
try:
return [(str(r.target).rstrip('.'), r.port) for r in resolver.query('_etcd-server._tcp.' + host, 'SRV')]
except DNSException:
logger.exception('Can not resolve SRV for %s', host)
return []
def _get_machines_cache_from_srv(self, discovery_srv):
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
This record should contain list of host and peer ports which could be used to run
'GET http://{host}:{port}/members' request (peer protocol)"""
ret = []
for host, port in self.get_srv_record(discovery_srv):
url = '{0}://{1}:{2}/members'.format(self._protocol, host, port)
try:
response = requests.get(url, timeout=self.read_timeout)
if response.ok:
for member in response.json():
ret.extend(member['clientURLs'])
break
except RequestException:
logger.exception('GET %s', url)
return list(set(ret))
def _get_machines_cache_from_dns(self, addr):
"""One host might be resolved into multiple ip addresses. We will make list out of it"""
ret = []
host, port = addr.split(':')
try:
for r in set(socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)):
ret.append('{0}://{1}:{2}'.format(self._protocol, r[4][0], r[4][1]))
except socket.error:
logger.exception('Can not resolve %s', host)
return list(set(ret)) if ret else ['{0}://{1}:{2}'.format(self._protocol, host, port)]
def _load_machines_cache(self):
"""This method should fill up `_machines_cache` from scratch.
It could happen only in two cases:
1. During class initialization
2. When all etcd members failed"""
self._update_machines_cache = True
if 'discovery_srv' not in self._config and 'host' not in self._config:
raise Exception('Neither discovery_srv nor host are defined in etcd section of config')
self._machines_cache = []
if 'discovery_srv' in self._config:
self._machines_cache = self._get_machines_cache_from_srv(self._config['discovery_srv'])
if not self._machines_cache and 'host' in self._config:
self._machines_cache = self._get_machines_cache_from_dns(self._config['host'])
# Can not bootstrap list of etcd-cluster members, giving up
if not self._machines_cache:
raise etcd.EtcdException
# After filling up initial list of machines_cache we should ask etcd-cluster about actual list
self._base_uri = self._machines_cache.pop(0)
self._machines_cache = self.machines
if self._base_uri in self._machines_cache:
self._machines_cache.remove(self._base_uri)
self._update_machines_cache = False
def catch_etcd_errors(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs) is not None
except (RetryFailedError, etcd.EtcdException):
return False
except:
logger.exception("")
raise EtcdError("unexpected error")
return wrapper
class Etcd(AbstractDCS):
def __init__(self, config):
super(Etcd, self).__init__(config)
self._ttl = int(config.get('ttl') or 30)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=(etcd.EtcdLeaderElectionInProgress,
etcd.EtcdWatcherCleared,
etcd.EtcdEventIndexCleared))
self._client = self.get_etcd_client(config)
self.__do_not_watch = False
def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs)
@staticmethod
def get_etcd_client(config):
client = None
while not client:
try:
client = Client(config)
except etcd.EtcdException:
logger.info('waiting on etcd')
sleep(5)
return client
def set_ttl(self, ttl):
ttl = int(ttl)
self.__do_not_watch = self._ttl != ttl
self._ttl = ttl
def set_retry_timeout(self, retry_timeout):
self._retry.deadline = retry_timeout
self._client.set_read_timeout(retry_timeout)
@staticmethod
def member(node):
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
def _load_cluster(self):
try:
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}
# get initialize flag
initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize.value
# get global dynamic configuration
config = nodes.get(self._CONFIG)
config = config and ClusterConfig.from_node(config.modifiedIndex, config.value)
# get last leader operation
last_leader_operation = nodes.get(self._LEADER_OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader
leader = nodes.get(self._LEADER)
if leader:
member = Member(-1, leader.value, None, {})
member = ([m for m in members if m.name == leader.value] or [member])[0]
index = result.etcd_index if result.etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1
leader = Leader(index, leader.ttl, member)
# failover key
failover = nodes.get(self._FAILOVER)
if failover:
failover = Failover.from_node(failover.modifiedIndex, failover.value)
self._cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover)
except etcd.EtcdKeyNotFound:
self._cluster = Cluster(None, None, None, None, [], None)
except:
logger.exception('get_cluster')
raise EtcdError('Etcd is not responding properly')
@catch_etcd_errors
def touch_member(self, data, ttl=None, permanent=False):
return self.retry(self._client.set, self.member_path, data, None if permanent else ttl or self._ttl)
@catch_etcd_errors
def take_leader(self):
return self.retry(self._client.set, self.leader_path, self._name, self._ttl)
def attempt_to_acquire_leader(self, permanent=False):
try:
return bool(self.retry(self._client.write,
self.leader_path,
self._name,
ttl=None if permanent else self._ttl,
prevExist=False))
except etcd.EtcdAlreadyExist:
logger.info('Could not take out TTL lock')
except (RetryFailedError, etcd.EtcdException):
pass
return False
@catch_etcd_errors
def set_failover_value(self, value, index=None):
return self._client.write(self.failover_path, value, prevIndex=index or 0)
@catch_etcd_errors
def set_config_value(self, value, index=None):
return self._client.write(self.config_path, value, prevIndex=index or 0)
@catch_etcd_errors
def write_leader_optime(self, last_operation):
return self._client.set(self.leader_optime_path, last_operation)
@catch_etcd_errors
def update_leader(self):
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
def delete_leader(self):
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)
@catch_etcd_errors
def delete_cluster(self):
return self.retry(self._client.delete, self.client_path(''), recursive=True)
def watch(self, timeout):
if self.__do_not_watch:
self.__do_not_watch = False
return True
cluster = self.cluster
# watch on leader key changes if it is defined and current node is not lock owner
if cluster and cluster.leader and cluster.leader.name != self._name and cluster.leader.index:
end_time = time.time() + timeout
while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try:
self._client.watch(self.leader_path, index=cluster.leader.index, timeout=timeout + 0.5)
# Synchronous work of all cluster members with etcd is less expensive
# than reestablishing http connection every time from every replica.
return True
except etcd.EtcdWatchTimedOut:
self._client.http.clear()
return False
except etcd.EtcdException:
logging.exception('watch')
timeout = end_time - time.time()
try:
return super(Etcd, self).watch(timeout)
finally:
self.event.clear()
+75
View File
@@ -0,0 +1,75 @@
import logging
import random
import requests
import time
from patroni.dcs.zookeeper import ZooKeeper
from patroni.utils import sleep
from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
class ExhibitorEnsembleProvider(object):
TIMEOUT = 3.1
def __init__(self, hosts, port, uri_path='/exhibitor/v1/cluster/list', poll_interval=300):
self._exhibitor_port = port
self._uri_path = uri_path
self._poll_interval = poll_interval
self._exhibitors = hosts
self._master_exhibitors = hosts
self._zookeeper_hosts = ''
self._next_poll = None
while not self.poll():
logger.info('waiting on exhibitor')
sleep(5)
def poll(self):
if self._next_poll and self._next_poll > time.time():
return False
json = self._query_exhibitors(self._exhibitors)
if not json:
json = self._query_exhibitors(self._master_exhibitors)
if isinstance(json, dict) and 'servers' in json and 'port' in json:
self._next_poll = time.time() + self._poll_interval
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(json['servers'])])
if self._zookeeper_hosts != zookeeper_hosts:
logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts)
self._zookeeper_hosts = zookeeper_hosts
self._exhibitors = json['servers']
return True
return False
def _query_exhibitors(self, exhibitors):
random.shuffle(exhibitors)
for host in exhibitors:
uri = 'http://{0}:{1}{2}'.format(host, self._exhibitor_port, self._uri_path)
try:
response = requests.get(uri, timeout=self.TIMEOUT)
return response.json()
except RequestException:
pass
return None
@property
def zookeeper_hosts(self):
return self._zookeeper_hosts
class Exhibitor(ZooKeeper):
def __init__(self, config):
interval = config.get('poll_interval', 300)
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
config = config.copy()
config['hosts'] = self._ensemble_provider.zookeeper_hosts
super(Exhibitor, self).__init__(config)
def _load_cluster(self):
if self._ensemble_provider.poll():
self._client.set_hosts(self._ensemble_provider.zookeeper_hosts)
return super(Exhibitor, self)._load_cluster()
+313
View File
@@ -0,0 +1,313 @@
import logging
from kazoo.client import KazooClient, KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError
from kazoo.handlers.threading import SequentialThreadingHandler
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
logger = logging.getLogger(__name__)
class ZooKeeperError(DCSError):
pass
class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
def __init__(self, connect_timeout):
super(PatroniSequentialThreadingHandler, self).__init__()
self.set_connect_timeout(connect_timeout)
def set_connect_timeout(self, connect_timeout):
self._connect_timeout = max(1.0, connect_timeout/2.0) # try to connect to zookeeper node during loop_wait/2
def create_connection(self, *args, **kwargs):
"""This method is trying to establish connection with one of the zookeeper nodes.
Somehow strategy "fail earlier and retry more often" works way better comparing to
the original strategy "try to connect with specified timeout".
Since we want to try connect to zookeeper more often (with the smaller connect_timeout),
he have to override `create_connection` method in the `SequentialThreadingHandler`
class (which is used by `kazoo.Client`).
:param args: always contains `tuple(host, port)` as the first element and could contain
`connect_timeout` (negotiated session timeout) as the second element."""
args = list(args)
if len(args) == 1:
args.append(self._connect_timeout)
else:
args[1] = max(self._connect_timeout, args[1]/10.0)
return super(PatroniSequentialThreadingHandler, self).create_connection(*args, **kwargs)
class ZooKeeper(AbstractDCS):
def __init__(self, config):
super(ZooKeeper, self).__init__(config)
hosts = config.get('hosts', [])
if isinstance(hosts, list):
hosts = ','.join(hosts)
self._client = KazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
timeout=config['ttl'], connection_retry={'max_delay': 1, 'max_tries': -1},
command_retry={'deadline': config['retry_timeout'], 'max_delay': 1, 'max_tries': -1})
self._client.add_listener(self.session_listener)
self._my_member_data = None
self._fetch_cluster = True
self._last_leader_operation = 0
self._orig_kazoo_connect = self._client._connection._connect
self._client._connection._connect = self._kazoo_connect
self._client.start()
def _kazoo_connect(self, host, port):
"""Kazoo is using Ping's to determine health of connection to zookeeper. If there is no
response on Ping after Ping interval (1/2 from read_timeout) it will consider current
connection dead and try to connect to another node. Without this "magic" it was taking
up to 2/3 from session timeout (ttl) to figure out that connection was dead and we had
only small time for reconnect and retry.
This method is needed to return different value of read_timeout, which is not calculated
from negotiated session timeout but from value of `loop_wait`. And it is 2 sec smaller
than loop_wait, because we can spend up to 2 seconds when calling `touch_member()` and
`write_leader_optime()` methods, which also may hang..."""
ret = self._orig_kazoo_connect(host, port)
return max(self.loop_wait - 2, 2)*1000, ret[1]
def session_listener(self, state):
if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None)
def cluster_watcher(self, event):
self._fetch_cluster = True
self.event.set()
def reload_config(self, config):
self.set_retry_timeout(config['retry_timeout'])
loop_wait = config['loop_wait']
loop_wait_changed = self._loop_wait != loop_wait
self._loop_wait = loop_wait
self._client.handler.set_connect_timeout(loop_wait)
# We need to reestablish connection to zookeeper if we want to change
# read_timeout (and Ping interval respectively), because read_timeout
# is calculated in `_kazoo_connect` method. If we are changing ttl at
# the same time, set_ttl method will reestablish connection and return
# `!True`, otherwise we will close existing connection and let kazoo
# open the new one.
if not self.set_ttl(int(config['ttl'] * 1000)) and loop_wait_changed:
self._client._connection._socket.close()
def set_ttl(self, ttl):
"""It is not possible to change ttl (session_timeout) in zookeeper without
destroying old session and creating the new one. This method returns `!True`
if session_timeout has been changed (`restart()` has been called)."""
if self._client._session_timeout != ttl:
self._client._session_timeout = ttl
self._client.restart()
return True
def set_retry_timeout(self, retry_timeout):
self._client._retry.deadline = retry_timeout
def get_node(self, key, watch=None):
try:
ret = self._client.get(key, watch)
return (ret[0].decode('utf-8'), ret[1])
except NoNodeError:
return None
@staticmethod
def member(name, value, znode):
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
def get_children(self, key, watch=None):
try:
return self._client.get_children(key, watch)
except NoNodeError:
return []
def load_members(self):
members = []
for member in self.get_children(self.members_path, self.cluster_watcher):
data = self.get_node(self.members_path + member)
if data is not None:
members.append(self.member(member, *data))
return members
def _inner_load_cluster(self):
self._fetch_cluster = False
self.event.clear()
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 global dynamic configuration
config = self.get_node(self.config_path, watch=self.cluster_watcher) if self._CONFIG in nodes else None
config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid)
# 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:
client_id = self._client.client_id
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')
self._client.delete(self.leader_path)
leader = None
if leader:
member = Member(-1, leader[0], None, {})
member = ([m for m in members if m.name == leader[0]] or [member])[0]
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self._fetch_cluster = member.index == -1
# failover key
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
failover = failover and Failover.from_node(failover[1].version, failover[0])
# 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, config, leader, self._last_leader_operation, members, failover)
def _load_cluster(self):
if self._fetch_cluster or self._cluster is None:
try:
self._client.retry(self._inner_load_cluster)
except Exception:
logger.exception('get_cluster')
self.cluster_watcher(None)
raise ZooKeeperError('ZooKeeper in not responding properly')
def _create(self, path, value, **kwargs):
try:
self._client.retry(self._client.create, path, value.encode('utf-8'), **kwargs)
return True
except:
return False
def attempt_to_acquire_leader(self, permanent=False):
ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=not permanent)
if not ret:
logger.info('Could not take out TTL lock')
return ret
def set_failover_value(self, value, index=None):
try:
self._client.retry(self._client.set, self.failover_path, value.encode('utf-8'), version=index or -1)
return True
except NoNodeError:
return value == '' or (index is None and self._create(self.failover_path, value))
except:
logging.exception('set_failover_value')
return False
def set_config_value(self, value, index=None):
try:
self._client.retry(self._client.set, self.config_path, value.encode('utf-8'), version=index or -1)
return True
except NoNodeError:
return index is None and self._create(self.config_path, value)
except Exception:
logging.exception('set_config_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, permanent=False):
cluster = self.cluster
member = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
data = data.encode('utf-8')
if member and self._client.client_id is not None and member.session != self._client.client_id[0]:
try:
self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError:
pass
except:
return False
member = None
if member:
if data == self._my_member_data:
return True
else:
try:
self._client.create_async(self.member_path, data, makepath=True, ephemeral=not permanent).get(timeout=1)
self._my_member_data = data
return True
except Exception as e:
if not isinstance(e, NodeExistsError):
logger.exception('touch_member')
return False
try:
self._client.set_async(self.member_path, data).get(timeout=1)
self._my_member_data = data
return True
except:
logger.exception('touch_member')
return False
def take_leader(self):
return self.attempt_to_acquire_leader()
def write_leader_optime(self, last_operation):
last_operation = last_operation.encode('utf-8')
if last_operation != self._last_leader_operation:
try:
self._client.set_async(self.leader_optime_path, last_operation).get(timeout=1)
self._last_leader_operation = last_operation
except NoNodeError:
try:
self._client.create_async(self.leader_optime_path, last_operation, makepath=True).get(timeout=1)
self._last_leader_operation = last_operation
except:
logger.exception('Failed to create %s', self.leader_optime_path)
except:
logger.exception('Failed to update %s', self.leader_optime_path)
def update_leader(self):
return True
def delete_leader(self):
self._client.restart()
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 delete_cluster(self):
try:
return self._client.retry(self._client.delete, self.client_path(''), recursive=True)
except NoNodeError:
return True
def watch(self, timeout):
if super(ZooKeeper, self).watch(timeout):
self._fetch_cluster = True
return self._fetch_cluster
-265
View File
@@ -1,265 +0,0 @@
from __future__ import absolute_import
import etcd
import logging
import os
import random
import requests
import socket
import time
import urllib3
from dns.exception import DNSException
from dns import resolver
from patroni.dcs import AbstractDCS, Cluster, DCSError, Leader, Member, parse_connection_string
from patroni.utils import Retry, RetryFailedError, sleep
from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
class EtcdError(DCSError):
pass
class Client(etcd.Client):
def __init__(self, config):
super(Client, self).__init__(read_timeout=5)
self._config = config
self._load_machines_cache()
self._allow_reconnect = True
@property
def machines(self):
"""Original `machines` method(property) of `etcd.Client` class raise exception
when it failed to get list of etcd cluster members. This method is being called
only when request failed on one of the etcd members during `api_execute` call.
For us it's more important to execute original request rather then get new
topology of etcd cluster. So we will catch this exception and return valid list
of machines with setting flag `self._update_machines_cache` to `!True`.
Later, during next `api_execute` call we will forcefully update machines_cache"""
try:
ret = super(Client, self).machines
random.shuffle(ret)
return ret
except etcd.EtcdException:
if self._update_machines_cache: # We are updating machines_cache
raise # This exception is fatal, we should re-raise it.
self._update_machines_cache = True
return [self._base_uri]
def api_execute(self, path, method, **kwargs):
# Update machines_cache if previous attempt of update has failed
self._update_machines_cache and self._load_machines_cache()
return super(Client, self).api_execute(path, method, **kwargs)
@staticmethod
def get_srv_record(host):
try:
return [(str(r.target).rstrip('.'), r.port) for r in resolver.query('_etcd-server._tcp.' + host, 'SRV')]
except DNSException:
logger.exception('Can not resolve SRV for %s', host)
return []
# try to workarond bug in python-etcd: https://github.com/jplana/python-etcd/issues/81
def _result_from_response(self, response):
try:
response.data.decode('utf-8')
except urllib3.exceptions.TimeoutError:
raise
except Exception as e:
raise etcd.EtcdException('Unable to decode server response: %s' % e)
return super(Client, self)._result_from_response(response)
def _get_machines_cache_from_srv(self, discovery_srv):
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
This record should contain list of host and peer ports which could be used to run
'GET http://{host}:{port}/members' request (peer protocol)"""
ret = []
for host, port in self.get_srv_record(discovery_srv):
url = '{}://{}:{}/members'.format(self._protocol, host, port)
try:
response = requests.get(url)
if response.ok:
for member in response.json():
ret.extend(member['clientURLs'])
break
except RequestException:
logger.exception('GET %s', url)
return list(set(ret))
def _get_machines_cache_from_dns(self, addr):
"""One host might be resolved into multiple ip addresses. We will make list out of it"""
ret = []
host, port = addr.split(':')
try:
for r in set(socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)):
ret.append('{}://{}:{}'.format(self._protocol, r[4][0], r[4][1]))
except socket.error:
logger.exception('Can not resolve %s', host)
return list(set(ret)) if ret else ['{}://{}:{}'.format(self._protocol, host, port)]
def _load_machines_cache(self):
"""This method should fill up `_machines_cache` from scratch.
It could happen only in two cases:
1. During class initialization
2. When all etcd members failed"""
self._update_machines_cache = True
if 'discovery_srv' not in self._config and 'host' not in self._config:
raise Exception('Neither discovery_srv nor host are defined in etcd section of config')
self._machines_cache = []
if 'discovery_srv' in self._config:
self._machines_cache = self._get_machines_cache_from_srv(self._config['discovery_srv'])
if not self._machines_cache and 'host' in self._config:
self._machines_cache = self._get_machines_cache_from_dns(self._config['host'])
# Can not bootstrap list of etcd-cluster members, giving up
if not self._machines_cache:
raise etcd.EtcdException
# After filling up initial list of machines_cache we should ask etcd-cluster about actual list
self._base_uri = self._machines_cache.pop(0)
self._machines_cache = self.machines
self._base_uri in self._machines_cache and self._machines_cache.remove(self._base_uri)
self._update_machines_cache = False
def catch_etcd_errors(func):
def wrapper(*args, **kwargs):
try:
return not func(*args, **kwargs) is None
except (RetryFailedError, etcd.EtcdException):
return False
return wrapper
class Etcd(AbstractDCS):
def __init__(self, name, config):
super(Etcd, self).__init__(name, config)
self.ttl = config['ttl']
self.member_ttl = config.get('member_ttl', 3600)
self._retry = Retry(deadline=10, max_delay=1, max_tries=-1,
retry_exceptions=(etcd.EtcdConnectionFailed,
etcd.EtcdLeaderElectionInProgress,
etcd.EtcdWatcherCleared,
etcd.EtcdEventIndexCleared))
self.client = self.get_etcd_client(config)
self.cluster = None
def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs)
def get_etcd_client(self, config):
client = None
while not client:
try:
client = Client(config)
except etcd.EtcdException:
logger.info('waiting on etcd')
sleep(5)
return client
@staticmethod
def member(node):
conn_url, api_url = parse_connection_string(node.value)
return Member(node.modifiedIndex, os.path.basename(node.key), conn_url, api_url, node.expiration, node.ttl)
def get_cluster(self):
try:
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}
# get initialize flag
initialize = bool(nodes.get(self._INITIALIZE, False))
# get last leader operation
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)
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader
leader = nodes.get(self._LEADER, None)
if leader:
member = Member(-1, leader.value, None, None, None, None)
member = ([m for m in members if m.name == leader.value] or [member])[0]
leader = Leader(leader.modifiedIndex, leader.expiration, leader.ttl, member)
self.cluster = Cluster(initialize, leader, last_leader_operation, members)
except etcd.EtcdKeyNotFound:
self.cluster = Cluster(False, None, None, [])
except:
self.cluster = None
logger.exception('get_cluster')
raise EtcdError('Etcd is not responding properly')
return self.cluster
@catch_etcd_errors
def touch_member(self, connection_string, ttl=None):
return self.retry(self.client.set, self.member_path, connection_string, ttl or self.member_ttl)
@catch_etcd_errors
def take_leader(self):
return self.retry(self.client.set, self.leader_path, self._name, self.ttl)
def attempt_to_acquire_leader(self):
try:
return bool(self.retry(self.client.write, self.leader_path, self._name, ttl=self.ttl, prevExist=False))
except etcd.EtcdAlreadyExist:
logger.info('Could not take out TTL lock')
except (RetryFailedError, etcd.EtcdException):
pass
return False
@catch_etcd_errors
def write_leader_optime(self, state_handler):
return self.client.set(self.leader_optime_path, state_handler.last_operation())
@catch_etcd_errors
def update_leader(self, state_handler):
ret = self.retry(self.client.test_and_set, self.leader_path, self._name, self._name, self.ttl)
ret and self.write_leader_optime(state_handler)
return ret
@catch_etcd_errors
def initialize(self):
return self.client.write(self.initialize_path, self._name, prevExist=False)
@catch_etcd_errors
def delete_leader(self):
return self.client.delete(self.leader_path, prevValue=self._name)
@catch_etcd_errors
def cancel_initialization(self):
return self.client.delete(self.initialize_path, prevValue=self._name)
def watch(self, timeout):
# 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:
end_time = time.time() + timeout
index = self.cluster.leader.index
while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try:
res = self.client.watch(self.leader_path, index=index + 1, timeout=timeout)
if res.action not in ['set', 'compareAndSwap'] or res.value != self.cluster.leader.name:
return
index = res.modifiedIndex
except urllib3.exceptions.TimeoutError:
self.client.http.clear()
return
except etcd.EtcdException:
index = None
timeout = end_time - time.time()
timeout > 0 and super(Etcd, self).watch(timeout)
+5 -1
View File
@@ -7,7 +7,7 @@ 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)
@@ -19,3 +19,7 @@ class PostgresException(PatroniException):
class DCSError(PatroniException): class DCSError(PatroniException):
pass pass
class PostgresConnectionException(PostgresException):
pass
+622 -65
View File
@@ -1,26 +1,39 @@
import json
import logging import logging
import psycopg2
import requests
import sys
import datetime
import pytz
from patroni.dcs import DCSError from multiprocessing.pool import ThreadPool
from psycopg2 import InterfaceError, OperationalError from patroni.async_executor import AsyncExecutor
from patroni.exceptions import DCSError, PostgresConnectionException
from patroni.postgresql import ACTION_ON_START
from patroni.utils import sleep
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Ha: class Ha(object):
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.recovering = False
self._async_executor = AsyncExecutor()
def is_paused(self):
return self.cluster and self.cluster.is_paused()
def load_cluster_from_dcs(self): def load_cluster_from_dcs(self):
cluster = self.dcs.get_cluster() cluster = self.dcs.get_cluster()
# We want to keep the state of cluster when it was healhy # We want to keep the state of cluster when it was healthy
if 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 +41,618 @@ 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 and not self._async_executor.busy:
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
}
if self.patroni.tags:
data['tags'] = self.patroni.tags
if self.state_handler.pending_restart:
data['pending_restart'] = True
if not self._async_executor.busy and data['state'] in ['running', 'restarting', 'starting']:
try:
data['xlog_location'] = self.state_handler.xlog_position()
except:
pass
if self.patroni.scheduled_restart:
scheduled_restart_data = self.patroni.scheduled_restart.copy()
scheduled_restart_data['schedule'] = scheduled_restart_data['schedule'].isoformat()
data['scheduled_restart'] = scheduled_restart_data
def follow_the_leader(self): self.dcs.touch_member(json.dumps(data, separators=(',', ':')))
return self.state_handler.follow_the_leader(self.cluster.leader)
def run_cycle(self): def clone(self, clone_member=None, msg='(without leader)'):
try: if self.state_handler.clone(clone_member):
self.load_cluster_from_dcs() logger.info('bootstrapped %s', msg)
if not self.state_handler.is_healthy(): cluster = self.dcs.get_cluster()
has_lock = self.has_lock() node_to_follow = self._get_node_to_follow(cluster)
self.state_handler.write_recovery_conf(None if has_lock else self.cluster.leader) self.state_handler.follow(node_to_follow, cluster.leader, True)
self.state_handler.start() else:
if not has_lock: logger.error('failed to bootstrap %s', msg)
return 'started as a secondary' self.state_handler.remove_data_directory()
logger.info('started as readonly because i had the session lock')
def bootstrap(self):
if not self.cluster.is_unlocked(): # cluster already has leader
clone_member = self.cluster.get_clone_member()
member_role = 'leader' if clone_member == self.cluster.leader else 'replica'
msg = "from {0} '{1}'".format(member_role, clone_member.name)
self._async_executor.schedule('bootstrap {0}'.format(msg))
self._async_executor.run_async(self.clone, args=(clone_member, msg))
return 'trying to bootstrap {0}'.format(msg)
# no initialize key and node is allowed to be master and has 'bootstrap' section in a configuration file
elif self.cluster.initialize is None and not self.patroni.nofailover and 'bootstrap' in self.patroni.config:
if self.dcs.initialize(create_new=True): # race for initialization
try:
self.state_handler.bootstrap(self.patroni.config['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.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
self.dcs.take_leader()
self.load_cluster_from_dcs() self.load_cluster_from_dcs()
return 'initialized a new cluster'
else:
return 'failed to acquire initialize lock'
else:
if self.state_handler.can_create_replica_without_replication_connection():
self._async_executor.run_async(self.clone)
return "trying to bootstrap (without leader)"
return 'waiting for leader to bootstrap'
def recover(self):
self.recovering = True
return self.follow("starting as readonly because i had the session lock", "starting as a secondary", True, True)
def _get_node_to_follow(self, cluster):
# determine the node to follow. If replicatefrom tag is set,
# try to follow the node mentioned there, otherwise, follow the leader.
if not self.patroni.replicatefrom or self.patroni.replicatefrom == self.state_handler.name:
node_to_follow = cluster.leader
else:
node_to_follow = cluster.get_member(self.patroni.replicatefrom)
return node_to_follow if node_to_follow and node_to_follow.name != self.state_handler.name else None
def follow(self, demote_reason, follow_reason, refresh=True, recovery=False, need_rewind=None):
if refresh:
self.load_cluster_from_dcs()
if recovery:
ret = demote_reason if self.has_lock() else follow_reason
else:
is_leader = self.state_handler.is_leader()
ret = demote_reason if is_leader else follow_reason
node_to_follow = self._get_node_to_follow(self.cluster)
if self.is_paused() and not self.state_handler.need_rewind:
self.state_handler.set_role('master' if is_leader else 'replica')
if is_leader:
return 'continue to run as master without lock'
elif not node_to_follow:
return 'no action'
self.state_handler.follow(node_to_follow, self.cluster.leader, recovery, self._async_executor, need_rewind)
return ret
def enforce_master_role(self, message, promote_message):
if self.state_handler.is_leader() or self.state_handler.role == 'master':
# Inform the state handler about its master role.
# It may be unaware of it if postgres is promoted manually.
self.state_handler.set_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 = None if is_master else json['xlog']['replayed_location']
return (member, True, not is_master, xlog_location, json.get('tags', {}))
except Exception as e:
logger.warning("request failed: GET %s (%s)", member.api_url, e)
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 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, _, _, 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.candidate: # manual failover to specific member
if failover.candidate == self.state_handler.name: # manual failover to me
return True
elif self.is_paused():
# Remove failover key if the node to failover has terminated to avoid waiting for it indefinitely
# In order to avoid attempts to delete this key from all nodes only the master is allowed to do it.
if (not self.cluster.get_member(failover.candidate, fallback_to_leader=False) and
self.state_handler.is_leader()):
logger.warning("manual failover: removing failover key because failover candidate is not running")
self.dcs.manual_failover('', '', index=self.cluster.failover.index)
return None
return False
# find specific node and check that it is healthy
member = self.cluster.get_member(failover.candidate, fallback_to_leader=False)
if member:
member, reachable, _, _, tags = self.fetch_node_status(member)
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.candidate is None
elif self.is_paused():
return False
# 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.candidate can be None)
members = [m for m in self.cluster.members if m.name not in (failover.candidate, failover.leader)]
if self.is_failover_possible(members): # check that there are healthy members
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.is_paused() and not self.patroni.nofailover and \
self.cluster.failover and not self.cluster.failover.scheduled_at:
ret = self.manual_failover_process_no_leader()
if ret is not None: # continue if we just deleted the stale failover key as a master
return ret
if self.state_handler.is_leader(): # leader is always the healthiest
return True
if self.is_paused():
return False
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.state_handler.set_role('demoted')
self.dcs.delete_leader()
self.touch_member()
self.dcs.reset_cluster()
sleep(2) # Give a time to somebody to take the leader lock
cluster = self.dcs.get_cluster()
node_to_follow = self._get_node_to_follow(cluster)
self.state_handler.follow(node_to_follow, cluster.leader, recovery=True, need_rewind=True)
else:
self.state_handler.follow(None, None)
def should_run_scheduled_action(self, action_name, scheduled_at, cleanup_fn):
if scheduled_at and not self.is_paused():
# If the scheduled action is in the far future, we shouldn't do anything and just return.
# If the scheduled action is in the past, we consider the value to be stale and we remove
# the value.
# If the value is close to now, we initiate the scheduled action
# Additionally, if the scheduled action cannot be executed altogether, i.e. there is an error
# or the action is in the past - we take care of cleaning it up.
now = datetime.datetime.now(pytz.utc)
try:
delta = (scheduled_at - now).total_seconds()
if delta > self.dcs.loop_wait:
logger.info('Awaiting %s at %s (in %.0f seconds)',
action_name, scheduled_at.isoformat(), delta)
return False
elif delta < - int(self.dcs.loop_wait * 1.5):
logger.warning('Found a stale %s value, cleaning up: %s',
action_name, scheduled_at.isoformat())
cleanup_fn()
return False
# The value is very close to now
sleep(max(delta, 0))
logger.info('Manual scheduled {0} at %s'.format(action_name), scheduled_at.isoformat())
return True
except TypeError:
logger.warning('Incorrect value of scheduled_at: %s', scheduled_at)
cleanup_fn()
return False
def process_manual_failover_from_leader(self):
failover = self.cluster.failover
if (failover.scheduled_at and not
self.should_run_scheduled_action("failover", failover.scheduled_at, lambda:
self.dcs.manual_failover('', '', index=failover.index))):
return
if not failover.leader or failover.leader == self.state_handler.name:
if not failover.candidate or failover.candidate != self.state_handler.name:
if not failover.candidate and self.is_paused():
logger.warning('Failover is possible only to a specific candidate in a paused state')
else:
members = [m for m in self.cluster.members
if not failover.candidate or m.name == failover.candidate]
if self.is_failover_possible(members): # check that there are healthy members
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',
failover.leader, self.state_handler.name)
logger.info('Cleaning up failover key')
self.dcs.manual_failover('', '', index=failover.index)
def process_unhealthy_cluster(self):
"""Cluster has no leader key"""
if self.is_healthiest_node():
if self.acquire_lock():
failover = self.cluster.failover
if failover:
if self.is_paused() and failover.leader and failover.candidate:
logger.info('Updating failover key after acquiring leader lock...')
self.dcs.manual_failover('', failover.candidate, failover.scheduled_at, failover.index)
else:
logger.info('Cleaning up failover key after acquiring leader lock...')
self.dcs.manual_failover('', '')
self.load_cluster_from_dcs()
return self.enforce_master_role('acquired session lock as a leader',
'promoted self to leader by acquiring session lock')
else:
return self.follow('demoted self after trying and failing to obtain lock',
'following new leader after trying and failing to obtain lock')
else:
# when we are doing manual failover there is no guaranty that new leader is ahead of any other node
# node tagged as nofailover can be ahead of the new leader either, but it is always excluded from elections
need_rewind = bool(self.cluster.failover) or self.patroni.nofailover
if need_rewind:
sleep(2) # Give a time to somebody to take the leader lock
if self.patroni.nofailover:
return self.follow('demoting self because I am not allowed to become master',
'following a different leader because I am not allowed to promote',
need_rewind=need_rewind)
return self.follow('demoting self because i am not the healthiest node',
'following a different leader because i am not the healthiest node',
need_rewind=need_rewind)
def process_healthy_cluster(self):
if self.has_lock():
if self.cluster.failover and (not self.is_paused() or self.state_handler.is_leader()):
msg = self.process_manual_failover_from_leader()
if msg is not None:
return msg
if self.is_paused() and not self.state_handler.is_leader():
if self.cluster.failover and self.cluster.failover.candidate == self.state_handler.name:
return 'waiting to become master after promote...'
self.dcs.delete_leader()
self.dcs.reset_cluster()
return 'removed leader lock because postgres is not running as master'
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('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 evaluate_scheduled_restart(self):
# restart if we need to
restart_data = self.future_restart_scheduled()
if restart_data:
recent_time = self.state_handler.postmaster_start_time()
request_time = restart_data['postmaster_start_time']
# check if postmaster start time has changed since the last restart
if recent_time and request_time and recent_time != request_time:
logger.info("Cancelling scheduled restart: postgres restart has already happened at %s", recent_time)
self.delete_future_restart()
return None
if (restart_data and
self.should_run_scheduled_action('restart', restart_data['schedule'], self.delete_future_restart)):
try:
ret, message = self.restart(restart_data, run_async=True)
if not ret:
logger.warning("Scheduled restart: %s", message)
return None
return message
finally:
self.delete_future_restart()
def restart_matches(self, role, postgres_version, pending_restart):
reason_to_cancel = ""
# checking the restart filters here seem to be less ugly than moving them into the
# run_scheduled_action.
if role and role != self.state_handler.role:
reason_to_cancel = "host role mismatch"
if (postgres_version and
self.state_handler.postgres_version_to_int(postgres_version) <= int(self.state_handler.server_version)):
reason_to_cancel = "postgres version mismatch"
if pending_restart and not self.state_handler.pending_restart:
reason_to_cancel = "pending restart flag is not set"
if not reason_to_cancel:
return True
else:
logger.info("not proceeding with the restart: %s", reason_to_cancel)
return False
def schedule_future_restart(self, restart_data):
with self._async_executor:
if not self.patroni.scheduled_restart:
self.patroni.scheduled_restart = restart_data
self.touch_member()
return True
return False
def delete_future_restart(self):
ret = False
with self._async_executor:
if self.patroni.scheduled_restart:
self.patroni.scheduled_restart = {}
self.touch_member()
ret = True
return ret
def future_restart_scheduled(self):
return self.patroni.scheduled_restart.copy() if (self.patroni.scheduled_restart and
isinstance(self.patroni.scheduled_restart, dict)) else None
def restart_scheduled(self):
return self._async_executor.scheduled_action == 'restart'
def restart(self, restart_data=None, run_async=False):
""" conditional and unconditional restart """
if (restart_data and isinstance(restart_data, dict) and
not self.restart_matches(restart_data.get('role'),
restart_data.get('postgres_version'),
('restart_pending' in restart_data))):
return (False, "restart conditions are not satisfied")
with self._async_executor:
prev = self._async_executor.schedule('restart')
if prev is not None:
return (False, prev + ' already in progress')
if run_async:
self._async_executor.run_async(self.state_handler.restart)
return (True, 'restart initiated')
elif self._async_executor.run(self.state_handler.restart):
return (True, 'restarted successfully')
else:
return (False, 'restart failed')
def _do_reinitialize(self, cluster):
self.state_handler.stop('immediate')
self.state_handler.remove_data_directory()
clone_member = self.cluster.get_clone_member()
member_role = 'leader' if clone_member == self.cluster.leader else 'replica'
self.clone(clone_member, "from {0} '{1}'".format(member_role, clone_member.name))
def reinitialize(self):
with self._async_executor:
self.load_cluster_from_dcs()
if self.cluster.is_unlocked(): if self.cluster.is_unlocked():
if self.state_handler.is_healthiest_node(self.old_cluster): return 'Cluster has no leader, can not reinitialize'
if self.acquire_lock():
if self.state_handler.is_leader() or self.state_handler.is_promoted: if self.cluster.leader.name == self.state_handler.name:
return 'acquired session lock as a leader' return 'I am the leader, can not reinitialize'
else:
self.state_handler.promote() action = self._async_executor.schedule('reinitialize', immediately=True)
return 'promoted self to leader by acquiring session lock' if action is not None:
else: return '{0} already in progress'.format(action)
self.load_cluster_from_dcs()
if self.state_handler.is_leader(): self._async_executor.run_async(self._do_reinitialize, args=(self.cluster, ))
self.demote()
return 'demoted self due after trying and failing to obtain lock' def handle_long_action_in_progress(self):
else: if self.has_lock():
self.follow_the_leader() if self.update_lock():
return 'following new leader after trying and failing to obtain lock' return 'updated leader lock during ' + self._async_executor.scheduled_action
else:
self.load_cluster_from_dcs()
if self.state_handler.is_leader():
self.demote()
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(): return 'failed to update leader lock during ' + self._async_executor.scheduled_action
if self.state_handler.is_leader() or self.state_handler.is_promoted: elif self.cluster.is_unlocked():
return 'no action. i am the leader with the lock' return 'not healthy enough for leader race'
else: else:
self.state_handler.promote() return self._async_executor.scheduled_action + ' in progress'
return 'promoted self to leader because i had the session lock'
@staticmethod
def sysid_valid(sysid):
# sysid does tv_sec << 32, where tv_sec is the number of seconds sine 1970,
# so even 1 << 32 would have 10 digits.
sysid = str(sysid)
return len(sysid) >= 10 and sysid.isdigit()
def post_recover(self):
if not self.state_handler.is_running():
if self.has_lock():
self.dcs.delete_leader()
self.dcs.reset_cluster()
return 'removed leader key after trying and failing to start postgres'
return 'failed to start postgres'
return None
def _run_cycle(self):
try:
self.load_cluster_from_dcs()
self.touch_member()
# cluster has leader key but not initialize key
if not (self.cluster.is_unlocked() or self.sysid_valid(self.cluster.initialize)) and self.has_lock():
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
if not (self.cluster.is_unlocked() or self.cluster.config and self.cluster.config.data) and self.has_lock():
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
if self._async_executor.busy:
return self.handle_long_action_in_progress()
# we've got here, so any async action has finished. Check if we tried to recover and failed
if self.recovering and not self.state_handler.need_rewind:
self.recovering = False
msg = self.post_recover()
if msg is not None:
return msg
# is data directory empty?
if self.state_handler.data_directory_empty():
return self.bootstrap() # new node
# "bootstrap", but data directory is not empty
elif not self.sysid_valid(self.cluster.initialize) and self.cluster.is_unlocked() and not self.is_paused():
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
else:
# check if we are allowed to join
if self.sysid_valid(self.cluster.initialize) and self.cluster.initialize != self.state_handler.sysid:
logger.fatal("system ID mismatch, node %s belongs to a different cluster: %s != %s",
self.state_handler.name, self.cluster.initialize, self.state_handler.sysid)
sys.exit(1)
if not self.state_handler.is_healthy():
if self.is_paused():
if self.has_lock():
self.dcs.delete_leader()
self.dcs.reset_cluster()
return 'removed leader lock because postgres is not running'
elif not self.state_handler.need_rewind:
return 'postgres is not running'
# try to start dead postgres
return self.recover()
try:
if self.cluster.is_unlocked():
return self.process_unhealthy_cluster()
else: else:
logger.info('does not have lock') msg = self.evaluate_scheduled_restart()
if self.state_handler.is_leader(): if msg is not None:
self.demote() return msg
return 'demoting self because i do not have the lock and i was a leader' return self.process_healthy_cluster()
else: finally:
self.follow_the_leader() # we might not have a valid PostgreSQL connection here if another thread
return 'no action. i am a secondary and i am following a leader' # stops PostgreSQL, therefore, we only reload replication slots if no
# asynchronous processes are running (should be always the case for the master)
if not self._async_executor.busy:
if not self.state_handler.cb_called:
self.state_handler.call_nowait(ACTION_ON_START)
self.state_handler.sync_replication_slots(self.cluster)
except DCSError: except DCSError:
logger.error('Error communicating with DCS') logger.error('Error communicating with DCS')
if self.state_handler.is_leader(): if not self.is_paused() and 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): return 'DCS is not accessible'
logger.error('Error communicating with Postgresql. Will try again') except (psycopg2.Error, PostgresConnectionException):
return 'Error communicating with PostgreSQL. Will try again later'
def run_cycle(self):
with self._async_executor:
info = self._run_cycle()
return (self.is_paused() and 'PAUSE: ' or '') + info
+924 -288
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,4 +1,4 @@
#!/usr/bin/python #!/usr/bin/env python
import logging import logging
import requests import requests
@@ -9,7 +9,7 @@ import boto.ec2
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AWSConnection: class AWSConnection(object):
def __init__(self, cluster_name): def __init__(self, cluster_name):
self.available = False self.available = False
self.cluster_name = cluster_name if cluster_name is not None else 'unknown' self.cluster_name = cluster_name if cluster_name is not None else 'unknown'
@@ -56,7 +56,7 @@ class AWSConnection:
conn = boto.ec2.connect_to_region(self.region) conn = boto.ec2.connect_to_region(self.region)
conn.create_tags([self.instance_id], tags) conn.create_tags([self.instance_id], tags)
except Exception as e: except Exception as e:
logger.info("could not set tags for EC2 instance {}: {}".format(self.instance_id, e)) logger.info("could not set tags for EC2 instance %s: %s", self.instance_id, e)
return False return False
return True return True
@@ -65,8 +65,12 @@ class AWSConnection:
return self._tag_ebs(new_role) and ret return self._tag_ebs(new_role) and ret
if __name__ == '__main__': def main():
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'): if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'):
AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2]) AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2])
else: else:
sys.exit("Usage: {0} action role name".format(sys.argv[0])) sys.exit("Usage: {0} action role name".format(sys.argv[0]))
if __name__ == '__main__':
main()
-216
View File
@@ -1,216 +0,0 @@
#!/usr/bin/python
# arguments are:
# - cluster scope
# - cluster role
# - master connection string
# for the AWS, the folliowing environment variables should be defined:
# - WALE_ENV_DIR: directory where WAL-E environment is kept
# - WAL_S3_BUCKET: a name of the S3 bucket for WAL-E
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
# latest backup size
from collections import namedtuple
import logging
import os
import psycopg2
import subprocess
import sys
if sys.hexversion >= 0x03000000:
long = int
logger = logging.getLogger(__name__)
class Restore(object):
def __init__(self, scope, role, datadir, connstring, env=None):
self.scope = scope
self.role = role
self.master_connection = Restore.parse_connstring(connstring)
self.data_dir = datadir
self.env = os.environ.copy() if not env else env
@staticmethod
def parse_connstring(connstring):
# the connection string is in the form host= port= user=
# return the dictionary with all components as separare keys
result = {}
if connstring:
for x in connstring.split():
if x and '=' in x:
key, val = x.split('=')
result[key.strip()] = val.strip()
return result
def setup(self):
pass
def replica_method(self):
return self.create_replica_with_pg_basebackup
def replica_fallback_method(self):
return None
def run(self):
""" creates a new replica using either pg_basebackup or WAL-E """
method_fn = self.replica_method()
ret = method_fn() if method_fn else 1
if ret != 0 and self.replica_fallback_method() is not None:
ret = (self.replica_fallback_method())()
return ret
def create_replica_with_pg_basebackup(self):
try:
ret = subprocess.call(['pg_basebackup', '-R', '-D',
self.data_dir, '--host=' + self.master_connection['host'],
'--port=' + str(self.master_connection['port']),
'-U', self.master_connection['user']],
env=self.env)
except Exception as e:
logger.error('Error when fetching backup with pg_basebackup: {0}'.format(e))
return 1
return ret
class WALERestore(Restore):
def __init__(self, scope, role, datadir, connstring, env=None):
super(WALERestore, self).__init__(scope, role, datadir, connstring, env)
# check the environment variables
self.init_error = False
def setup(self):
if (self.env.get('WAL_S3_BUCKET') and
self.env.get('WALE_BACKUP_THRESHOLD_PERCENTAGE') and
self.env.get('WALE_BACKUP_THRESHOLD_MEGABYTES')) is None:
self.init_error = True
else:
self.wal_e = namedtuple('WALE',
'threshold_megabytes threshold_backup_size_percentage s3_bucket cmd dir env_file')
self.wal_e.dir = self.env.get('WALE_ENV_DIR', '/home/postgres/etc/wal-e.d/env')
self.wal_e.env_file = os.path.join(self.wal_e.dir, 'WALE_S3_PREFIX')
self.wal_e.cmd = 'envdir {} wal-e --aws-instance-profile '.\
format(self.wal_e.dir)
self.wal_e.s3_bucket = self.env['WAL_S3_BUCKET']
self.wal_e.threshold_megabytes = self.env['WALE_BACKUP_THRESHOLD_MEGABYTES']
self.wal_e.threshold_backup_size_percentage = self.env['WALE_BACKUP_THRESHOLD_PERCENTAGE']
# check that the env file exists, create it otherwise
try:
if not os.path.exists(self.wal_e.dir):
os.makedirs(self.wal_e.dir)
# if this is a directory - make sure we have full access there
elif not (os.path.isdir(self.wal_e.dir) and os.access(self.wal_e.dir, os.R_OK | os.W_OK | os.X_OK)):
logger.error("Unable to access {} or not a directory".format(self.wal_e.dir))
self.init_error = True
# if WAL_S3_PREFIX is not there - create it and write the full path to bucket
if not self.init_error and not os.path.exists(self.wal_e.env_file):
with open(self.wal_e.env_file, 'w') as f:
f.write("s3://{0}/spilo/{1}/wal/\n".format(self.wal_e.s3_bucket, self.scope))
except (os.error, IOError) as e:
logger.error("{0}: WAL-e archiving is disabled".format(e))
self.init_error = True
def replica_method(self):
if self.should_use_s3_to_create_replica():
return self.create_replica_with_s3
return None
def replica_fallback_method(self):
return self.create_replica_with_pg_basebackup
def should_use_s3_to_create_replica(self):
""" determine whether it makes sense to use S3 and not pg_basebackup """
if self.init_error:
return False
threshold_megabytes = self.wal_e.threshold_megabytes
threshold_backup_size_percentage = self.wal_e.threshold_backup_size_percentage
try:
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'],
env=self.env)
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
# wal_segment_backup_stop wal_segment_offset_backup_stop
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
# 20310671 00000001000000000000007F 00000040
# 00000001000000000000007F 00000240
backup_strings = latest_backup.splitlines() if latest_backup else ()
if len(backup_strings) != 2:
return False
names = backup_strings[0].split()
vals = backup_strings[1].split()
if (len(names) != len(vals)) or (len(names) != 7):
return False
backup_info = dict(zip(names, vals))
except subprocess.CalledProcessError as e:
logger.error("could not query wal-e latest backup: {}".format(e))
return False
try:
backup_size = backup_info['expanded_size_bytes']
backup_start_segment = backup_info['wal_segment_backup_start']
backup_start_offset = backup_info['wal_segment_offset_backup_start']
except Exception as e:
logger.error("unable to get some of S3 backup parameters: {}".format(e))
return False
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
# that we have to convert to hex and 'prepend' to the high offset digits.
lsn_segment = backup_start_segment[8:16]
# first 2 characters of the result are 0x and the last one is L
lsn_offset = hex((long(backup_start_segment[16:32], 16) << 24) + long(backup_start_offset))[2:-1]
# construct the LSN from the segment and offset
backup_start_lsn = '{}/{}'.format(lsn_segment, lsn_offset)
conn = None
cursor = None
diff_in_bytes = long(backup_size)
try:
# get the difference in bytes between the current WAL location and the backup start offset
conn = psycopg2.connect(**(self.master_connection))
conn.autocommit = True
cursor = conn.cursor()
cursor.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)", (backup_start_lsn,))
diff_in_bytes = long(cursor.fetchone()[0])
except psycopg2.Error as e:
logger.error('could not determine difference with the master location: {}'.format(e))
return False
finally:
cursor and cursor.close()
conn and conn.close()
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\
(diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100)
def create_replica_with_s3(self):
if self.init_error:
return 1
try:
ret = subprocess.call(self.wal_e.cmd + ' backup-fetch {} LATEST'.format(self.data_dir), env=self.env)
except Exception as e:
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
return 1
return ret
if __name__ == '__main__':
if len(sys.argv) == 5:
# scope, role, datadir, connstring
restore = WALERestore(*(sys.argv[1:]))
restore.setup()
sys.exit(restore.run())
sys.exit("Usage: {0} scope role datadir connstring".format(sys.argv[0]))
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python
# sample script to clone new replicas using WAL-E restore
# falls back to pg_basebackup if WAL-E restore fails, or if
# WAL-E backup is too far behind
# note that pg_basebackup still expects to use restore from
# WAL-E for transaction logs
# theoretically should work with SWIFT, but not tested on it
# arguments are:
# - cluster scope
# - cluster role
# - master connection string
# - number of retries
# - envdir for the WALE env
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
# this script depends on an envdir defining the S3 bucket (or SWIFT dir),and login
# credentials per WALE Documentation.
# currently also requires that you configure the restore_command to use wal_e, example:
# recovery_conf:
# restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
from collections import namedtuple
import logging
import os
import psycopg2
import subprocess
import sys
import argparse
if sys.hexversion >= 0x0300000:
long = int
logger = logging.getLogger(__name__)
class WALERestore(object):
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb, threshold_pct, use_iam, no_master):
self.scope = scope
self.master_connection = connstring
self.data_dir = datadir
self.wal_e = namedtuple('wale', 'dir,threshold_mb,threshold_pct,iam_string,cmd')
self.wal_e.dir = env_dir
self.wal_e.threshold_mb = threshold_mb
self.wal_e.threshold_pct = threshold_pct
self.wal_e.iam_string = ' --aws-instance-profile ' if use_iam == 1 else ''
self.no_master = no_master
self.wal_e.cmd = 'envdir {0} wal-e {1} '.format(self.wal_e.dir, self.wal_e.iam_string)
self.init_error = (not os.path.exists(self.wal_e.dir))
def run(self):
""" creates a new replica using WAL-E """
if not self.init_error and self.should_use_s3_to_create_replica():
return self.create_replica_with_s3()
return 2
def should_use_s3_to_create_replica(self):
""" determine whether it makes sense to use S3 and not pg_basebackup """
threshold_megabytes = self.wal_e.threshold_mb
threshold_backup_size_percentage = self.wal_e.threshold_pct
try:
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'])
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
# wal_segment_backup_stop wal_segment_offset_backup_stop
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
# 20310671 00000001000000000000007F 00000040
# 00000001000000000000007F 00000240
backup_strings = latest_backup.decode('utf-8').splitlines() if latest_backup else ()
if len(backup_strings) != 2:
return False
names = backup_strings[0].split()
vals = backup_strings[1].split()
if (len(names) != len(vals)) or (len(names) != 7):
return False
backup_info = dict(zip(names, vals))
except subprocess.CalledProcessError as e:
logger.error("could not query wal-e latest backup: {}".format(e))
return False
try:
backup_size = backup_info['expanded_size_bytes']
backup_start_segment = backup_info['wal_segment_backup_start']
backup_start_offset = backup_info['wal_segment_offset_backup_start']
except Exception as e:
logger.error("unable to get some of WALE backup parameters: {}".format(e))
return False
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
# that we have to convert to hex and 'prepend' to the high offset digits.
lsn_segment = backup_start_segment[8:16]
# first 2 characters of the result are 0x and the last one is L
lsn_offset = hex((long(backup_start_segment[16:32], 16) << 24) + long(backup_start_offset))[2:-1]
# construct the LSN from the segment and offset
backup_start_lsn = '{0}/{1}'.format(lsn_segment, lsn_offset)
diff_in_bytes = long(backup_size)
if not self.no_master:
try:
# get the difference in bytes between the current WAL location and the backup start offset
with psycopg2.connect(self.master_connection) as con:
con.autocommit = True
with con.cursor() as cur:
cur.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)", (backup_start_lsn,))
diff_in_bytes = long(cur.fetchone()[0])
except psycopg2.Error as e:
logger.error('could not determine difference with the master location: %s', e)
return False
else:
# always try to use WAL-E if base backup is available
diff_in_bytes = 0
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\
(diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100)
def create_replica_with_s3(self):
# if we're set up, restore the replica using fetch latest
try:
ret = subprocess.call(self.wal_e.cmd.split() + ['backup-fetch', '{}'.format(self.data_dir), 'LATEST'])
except Exception as e:
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
return 1
return ret
def main():
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
parser = argparse.ArgumentParser(description='Script to image replicas using WAL-E')
parser.add_argument('--scope', required=True)
parser.add_argument('--role', required=False)
parser.add_argument('--datadir', required=True)
parser.add_argument('--connstring', required=True)
parser.add_argument('--retries', type=int, default=1)
parser.add_argument('--envdir', required=True)
parser.add_argument('--threshold_megabytes', type=int, default=10240)
parser.add_argument('--threshold_backup_size_percentage', type=int, default=30)
parser.add_argument('--use_iam', type=int, default=0)
parser.add_argument('--no_master', type=int, default=0)
args = parser.parse_args()
# retry cloning in a loop
for _ in range(0, args.retries + 1):
restore = WALERestore(scope=args.scope, datadir=args.datadir, connstring=args.connstring,
env_dir=args.envdir, threshold_mb=args.threshold_megabytes,
threshold_pct=args.threshold_backup_size_percentage, use_iam=args.use_iam,
no_master=args.no_master)
ret = restore.run()
if ret == 0:
break
sys.exit(ret)
if __name__ == '__main__':
main()
+197 -55
View File
@@ -1,80 +1,221 @@
import datetime
import os import os
import random import random
import re
import signal
import sys import sys
import time import time
import re
from patroni.exceptions import DCSError from patroni.exceptions import PatroniException
interrupted_sleep = False if sys.hexversion >= 0x0300000:
reap_children = False long = int
_DATE_TIME_RE = re.compile(r'''^ __interrupted_sleep = False
(?P<year>\d{4})\-(?P<month>\d{2})\-(?P<day>\d{2}) # date __reap_children = False
T
(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})\.(?P<microsecond>\d{6}) # time
\d*Z$''', re.X)
def parse_datetime(time_str): def deep_compare(obj1, obj2):
""" """
>>> parse_datetime('2015-06-10T12:56:30.552539016Z') >>> deep_compare({'1': None}, {})
datetime.datetime(2015, 6, 10, 12, 56, 30, 552539) False
>>> parse_datetime('2015-06-10 12:56:30.552539016Z') >>> deep_compare({'1': {}}, {'1': None})
False
>>> deep_compare({'1': [1]}, {'1': [2]})
False
>>> deep_compare({'1': 2}, {'1': '2'})
True
>>> deep_compare({'1': {'2': [3, 4]}}, {'1': {'2': [3, 4]}})
True
""" """
m = _DATE_TIME_RE.match(time_str)
if not m: if set(list(obj1.keys())) != set(list(obj2.keys())): # Objects have different sets of keys
return None return False
p = dict((n, int(m.group(n))) for n in 'year month day hour minute second microsecond'.split(' '))
return datetime.datetime(**p) for key, value in obj1.items():
if isinstance(value, dict):
if not (isinstance(obj2[key], dict) and deep_compare(value, obj2[key])):
return False
elif str(value) != str(obj2[key]):
return False
return True
def calculate_ttl(expiration): def patch_config(config, data):
"""recursively 'patch' `config` with `data`
:returns: `!True` if the `config` was changed"""
is_changed = False
for name, value in data.items():
if value is None:
if config.pop(name, None) is not None:
is_changed = True
elif name in config:
if isinstance(value, dict):
if isinstance(config[name], dict):
if patch_config(config[name], value):
is_changed = True
else:
config[name] = value
is_changed = True
elif str(config[name]) != str(value):
config[name] = value
is_changed = True
else:
config[name] = value
is_changed = True
return is_changed
def parse_bool(value):
""" """
>>> calculate_ttl(None) >>> parse_bool(1)
>>> calculate_ttl('2015-06-10 12:56:30.552539016Z') True
>>> parse_bool('off')
False
>>> parse_bool('foo')
""" """
if not expiration: value = str(value).lower()
return None if value in ('on', 'true', 'yes', '1'):
expiration = parse_datetime(expiration) return True
if not expiration: if value in ('off', 'false', 'no', '0'):
return None return False
now = datetime.datetime.utcnow()
return int((expiration - now).total_seconds())
def sigterm_handler(signo, stack_frame): def strtol(value, strict=True):
sys.exit() """As most as possible close equivalent of strtol(3) function (with base=0),
used by postgres to parse parameter values.
>>> strtol(0) == (0, '')
True
>>> strtol(1) == (1, '')
True
>>> strtol(9) == (9, '')
True
>>> strtol(' +0x400MB') == (1024, 'MB')
True
>>> strtol(' -070d') == (-56, 'd')
True
>>> strtol(' d ') == (None, 'd')
True
>>> strtol('9s', False) == (9, 's')
True
>>> strtol(' s ', False) == (1, 's')
True
"""
value = str(value).strip()
l = len(value)
i = 0
# skip sign:
if i < l and value[i] in ('-', '+'):
i += 1
# we always expect to get digit in the beginning
if i < l and value[i].isdigit():
if value[i] == '0':
i += 1
if i < l and value[i] in ('x', 'X'): # '0' followed by 'x': HEX
base = 16
i += 1
else: # just starts with '0': OCT
base = 8
else: # any other digit: DEC
base = 10
ret = None
while i <= l:
try: # try to find maximally long number
i += 1 # by giving to `int` longer and longer strings
ret = long(value[:i], base)
except ValueError: # until we will not get an exception or end of the string
i -= 1
break
if ret is not None: # yay! there is a number in the beginning of the string
return ret, value[i:].strip() # return the number and the "rest"
return (None if strict else 1), value.strip()
def parse_int(value, base_unit=None):
"""
>>> parse_int('1') == 1
True
>>> parse_int(' 0x400 MB ', '16384kB') == 64
True
>>> parse_int('1MB', 'kB') == 1024
True
>>> parse_int('1000 ms', 's') == 1
True
>>> parse_int('1GB', 'MB') is None
True
>>> parse_int(0) == 0
True
"""
convert = {
'kB': {'kB': 1, 'MB': 1024, 'GB': 1024 * 1024, 'TB': 1024 * 1024 * 1024},
'ms': {'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
's': {'ms': -1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
'min': {'ms': -1000 * 60, 's': -60, 'min': 1, 'h': 60, 'd': 60 * 24}
}
value, unit = strtol(value)
if value is not None:
if not unit:
return value
if base_unit and base_unit not in convert:
base_value, base_unit = strtol(base_unit, False)
else:
base_value = 1
if base_unit in convert and unit in convert[base_unit]:
multiplier = convert[base_unit][unit]
if multiplier < 0:
value /= -multiplier
else:
value *= multiplier
return int(value/base_value)
def compare_values(vartype, unit, old_value, new_value):
"""
>>> compare_values('enum', None, 'remote_write', 'REMOTE_WRITE')
True
>>> compare_values('real', None, '1.23', 1.23)
True
"""
# if the integer or bool new_value is not correct this function will return False
if vartype == 'bool':
old_value = parse_bool(old_value)
new_value = parse_bool(new_value)
elif vartype == 'integer':
old_value = parse_int(old_value)
new_value = parse_int(new_value, unit)
elif vartype == 'enum':
return str(old_value).lower() == str(new_value).lower()
else: # ('string', 'real')
return str(old_value) == str(new_value)
return old_value is not None and new_value is not None and old_value == new_value
def sigchld_handler(signo, stack_frame): def sigchld_handler(signo, stack_frame):
global interrupted_sleep, reap_children global __interrupted_sleep, __reap_children
reap_children = interrupted_sleep = True __reap_children = __interrupted_sleep = True
def sleep(interval): def sleep(interval):
global interrupted_sleep global __interrupted_sleep
current_time = time.time() current_time = time.time()
end_time = current_time + interval end_time = current_time + interval
while current_time < end_time: while current_time < end_time:
interrupted_sleep = False __interrupted_sleep = False
time.sleep(end_time - current_time) time.sleep(end_time - current_time)
if not interrupted_sleep: # we will ignore only sigchld if not __interrupted_sleep: # we will ignore only sigchld
break break
current_time = time.time() current_time = time.time()
interrupted_sleep = False __interrupted_sleep = False
def setup_signal_handlers():
signal.signal(signal.SIGTERM, sigterm_handler)
signal.signal(signal.SIGCHLD, sigchld_handler)
def reap_children(): def reap_children():
global reap_children global __reap_children
if reap_children: if __reap_children:
try: try:
while True: while True:
ret = os.waitpid(-1, os.WNOHANG) ret = os.waitpid(-1, os.WNOHANG)
@@ -83,20 +224,24 @@ def reap_children():
except OSError: except OSError:
pass pass
finally: finally:
reap_children = False __reap_children = False
class RetryFailedError(DCSError): def is_valid_pg_version(version):
return re.match(r'[1-9][0-9]?(\.(0|([1-9][0-9]?))){2}$', version)
class RetryFailedError(PatroniException):
"""Raised when retrying an operation ultimately failed, after retrying the maximum number of attempts.""" """Raised when retrying an operation ultimately failed, after retrying the maximum number of attempts."""
class Retry: class Retry(object):
"""Helper for retrying a method in the face of retry-able exceptions""" """Helper for retrying a method in the face of retry-able exceptions"""
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 +295,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__ = '1.1'
-245
View File
@@ -1,245 +0,0 @@
import logging
import random
import requests
import time
from kazoo.client import KazooClient, KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError
from patroni.dcs import AbstractDCS, Cluster, DCSError, Leader, Member, parse_connection_string
from patroni.utils import sleep
from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
class ZooKeeperError(DCSError):
pass
class ExhibitorEnsembleProvider:
TIMEOUT = 3.1
def __init__(self, hosts, port, uri_path='/exhibitor/v1/cluster/list', poll_interval=300):
self._exhibitor_port = port
self._uri_path = uri_path
self._poll_interval = poll_interval
self._exhibitors = hosts
self._master_exhibitors = hosts
self._zookeeper_hosts = ''
self._next_poll = None
while not self.poll():
logger.info('waiting on exhibitor')
sleep(5)
def poll(self):
if self._next_poll and self._next_poll > time.time():
return False
json = self._query_exhibitors(self._exhibitors)
if not json:
json = self._query_exhibitors(self._master_exhibitors)
if isinstance(json, dict) and 'servers' in json and 'port' in json:
self._next_poll = time.time() + self._poll_interval
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(json['servers'])])
if self._zookeeper_hosts != zookeeper_hosts:
logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts)
self._zookeeper_hosts = zookeeper_hosts
self._exhibitors = json['servers']
return True
return False
def _query_exhibitors(self, exhibitors):
random.shuffle(exhibitors)
for host in exhibitors:
uri = 'http://{}:{}{}'.format(host, self._exhibitor_port, self._uri_path)
try:
response = requests.get(uri, timeout=self.TIMEOUT)
return response.json()
except RequestException:
pass
return None
@property
def zookeeper_hosts(self):
return self._zookeeper_hosts
class ZooKeeper(AbstractDCS):
def __init__(self, name, config):
super(ZooKeeper, self).__init__(name, config)
hosts = config.get('hosts', [])
if isinstance(hosts, list):
hosts = ','.join(hosts)
self.exhibitor = None
if 'exhibitor' in config:
exhibitor = config['exhibitor']
interval = exhibitor.get('poll_interval', 300)
self.exhibitor = ExhibitorEnsembleProvider(exhibitor['hosts'], exhibitor['port'], poll_interval=interval)
hosts = self.exhibitor.zookeeper_hosts
self.client = KazooClient(hosts=hosts,
timeout=(config.get('session_timeout', None) or 30),
command_retry={
'deadline': (config.get('reconnect_timeout', None) or 10),
'max_delay': 1,
'max_tries': -1},
connection_retry={'max_delay': 1, 'max_tries': -1})
self.client.add_listener(self.session_listener)
self.cluster_event = self.client.handler.event_object()
self.cluster = None
self.fetch_cluster = True
self.last_leader_operation = 0
self.client.start(None)
def session_listener(self, state):
if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None)
def cluster_watcher(self, event):
self.fetch_cluster = True
self.cluster_event.set()
def get_node(self, key, watch=None):
try:
return self.client.get(key, watch)
except NoNodeError:
return None
@staticmethod
def member(name, value, znode):
conn_url, api_url = parse_connection_string(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):
members = []
for member in self.get_children(self.members_path, self.cluster_watcher):
data = self.get_node(self.members_path + member)
if data is not None:
members.append(self.member(member, *data))
return members
def _inner_load_cluster(self):
self.cluster_event.clear()
nodes = set(self.get_children(self.client_path('')))
# get initialize flag
initialize = self._INITIALIZE in nodes
# get list of members
members = self.load_members() if self._MEMBERS[:-1] in nodes else []
# get leader
leader = self.get_node(self.leader_path, self.cluster_watcher) if self._LEADER in nodes else None
if leader:
client_id = self.client.client_id
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')
self.client.delete(self.leader_path)
leader = None
if leader:
member = Member(-1, leader[0], None, None, None, None)
member = ([m for m in members if m.name == leader[0]] or [member])[0]
leader = Leader(leader[1].mzxid, None, None, member)
self.fetch_cluster = member.index == -1
# get last leader operation
self.last_leader_operation = self.get_node(self.leader_optime_path) if self.fetch_cluster else None
self.last_leader_operation = 0 if self.last_leader_operation is None else int(self.last_leader_operation[0])
self.cluster = Cluster(initialize, leader, self.last_leader_operation, members)
def get_cluster(self):
if self.exhibitor and self.exhibitor.poll():
self.client.set_hosts(self.exhibitor.zookeeper_hosts)
if self.fetch_cluster:
try:
self.client.retry(self._inner_load_cluster)
except:
logger.exception('get_cluster')
self.session_listener(KazooState.LOST)
raise ZooKeeperError('ZooKeeper in not responding properly')
return self.cluster
def _create(self, path, value, **kwargs):
try:
self.client.retry(self.client.create, path, value, **kwargs)
return True
except:
return False
def attempt_to_acquire_leader(self):
ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=True)
ret or logger.info('Could not take out TTL lock')
return ret
def initialize(self):
return self._create(self.initialize_path, self._name, makepath=True)
def touch_member(self, connection_string, ttl=None):
if self.cluster and any(m.name == self._name for m in self.cluster.members):
return True
path = self.member_path
try:
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True)
return True
except NodeExistsError:
try:
self.client.retry(self.client.delete, path)
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True)
return True
except:
logger.exception('touch_member')
return False
def take_leader(self):
return self.attempt_to_acquire_leader()
def update_leader(self, state_handler):
last_operation = state_handler.last_operation()
if last_operation != self.last_leader_operation:
self.last_leader_operation = last_operation
path = self.leader_optime_path
try:
self.client.retry(self.client.set, path, last_operation)
except NoNodeError:
try:
self.client.retry(self.client.create, path, last_operation, makepath=True)
except:
logger.exception('Failed to create %s', path)
except:
logger.exception('Failed to update %s', path)
return True
def delete_leader(self):
if isinstance(self.cluster, Cluster) and self.cluster.leader.name == self._name:
self.client.delete(self.leader_path, version=self.cluster.leader.index)
def _cancel_initialization(self):
node = self.get_node(self.initialize_path)
if node and node[0] == self._name:
self.client.delete(self.initialize_path, version=node[1].mzxid)
def cancel_initialization(self):
try:
self.client.retry(self._cancel_initialization)
except:
logger.exception("Unable to delete initialize key")
def watch(self, timeout):
self.cluster_event.wait(timeout)
if self.cluster_event.isSet():
self.fetch_cluster = True
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python
from patroni.ctl import ctl
if __name__ == '__main__':
ctl(None)
BIN
View File
Binary file not shown.
+67 -51
View File
@@ -1,61 +1,77 @@
ttl: &ttl 30 scope: batman
loop_wait: &loop_wait 10 #namespace: /service/
scope: &scope batman name: postgresql0
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
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
# authentication:
# username: username
# password: password
etcd: etcd:
scope: *scope
ttl: *ttl
host: 127.0.0.1:4001 host: 127.0.0.1:4001
#discovery_srv: my-etcd.domain
#zookeeper: bootstrap:
# scope: *scope # this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
# session_timeout: *ttl # and all other cluster members will use it as a `global configuration`
# reconnect_timeout: *loop_wait dcs:
# hosts: ttl: 30
# - 127.0.0.1:2181 loop_wait: 10
# - 127.0.0.2:2181 retry_timeout: 10
# exhibitor: maximum_lag_on_failover: 1048576
# poll_interval: 300 postgresql:
# port: 8181 use_pg_rewind: true
# hosts: # use_slots: true
# - host1 parameters:
# - host2 # wal_level: hot_standby
# - host3 # hot_standby: "on"
# wal_keep_segments: 8
# max_wal_senders: 5
# max_replication_slots: 5
# wal_log_hints: "on"
# archive_mode: "on"
# archive_timeout: 1800s
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
# recovery_conf:
# restore_command: cp ../wal_archive/%f %p
# some desired options for 'initdb'
initdb: # Note: It needs to be a list (some options need values, others are switches)
- encoding: UTF8
- data-checksums
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# Some additional users users which needs to be created after initializing new cluster
users:
admin:
password: admin
options:
- createrole
- createdb
postgresql: postgresql:
name: postgresql0
scope: *scope
listen: 127.0.0.1:5432 listen: 127.0.0.1:5432
connect_address: 127.0.0.1:5432 connect_address: 127.0.0.1:5432
data_dir: data/postgresql0 data_dir: data/postgresql0
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes # bin_dir:
use_slots: True pgpass: /tmp/pgpass0
pg_hba: authentication:
- host all all 0.0.0.0/0 md5 replication:
- hostssl all all 0.0.0.0/0 md5 username: replicator
replication: password: rep-pass
username: replicator superuser:
password: rep-pass username: postgres
network: 127.0.0.1/32 password: zalando
superuser:
password: zalando
admin:
username: admin
password: admin
wal_e:
env_dir: /home/postgres/etc/wal-e.d/env
threshold_megabytes: 10240
threshold_backup_size_percentage: 30
restore: patroni/scripts/restore.py
#recovery_conf:
#restore_command: cp ../wal_archive/%f %p
parameters: parameters:
archive_mode: "on" unix_socket_directories: '.'
wal_level: hot_standby tags:
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f nofailover: false
max_wal_senders: 5 noloadbalance: false
wal_keep_segments: 8 clonefrom: false
archive_timeout: 1800s
max_replication_slots: 5
hot_standby: "on"
+67 -51
View File
@@ -1,61 +1,77 @@
ttl: &ttl 30 scope: batman
loop_wait: &loop_wait 10 #namespace: /service/
scope: &scope batman name: postgresql1
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
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
# authentication:
# username: username
# password: password
etcd: etcd:
scope: *scope
ttl: *ttl
host: 127.0.0.1:4001 host: 127.0.0.1:4001
#discovery_srv: my-etcd.domain
#zookeeper: bootstrap:
# scope: *scope # this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
# session_timeout: *ttl # and all other cluster members will use it as a `global configuration`
# reconnect_timeout: *loop_wait dcs:
# hosts: ttl: 30
# - 127.0.0.1:2181 loop_wait: 10
# - 127.0.0.2:2181 retry_timeout: 10
# exhibitor: maximum_lag_on_failover: 1048576
# poll_interval: 300 postgresql:
# port: 8181 use_pg_rewind: true
# hosts: # use_slots: true
# - host1 parameters:
# - host2 # wal_level: hot_standby
# - host3 # hot_standby: "on"
# wal_keep_segments: 8
# max_wal_senders: 5
# max_replication_slots: 5
# wal_log_hints: "on"
# archive_mode: "on"
# archive_timeout: 1800s
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
# recovery_conf:
# restore_command: cp ../wal_archive/%f %p
# some desired options for 'initdb'
initdb: # Note: It needs to be a list (some options need values, others are switches)
- encoding: UTF8
- data-checksums
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# Some additional users users which needs to be created after initializing new cluster
users:
admin:
password: admin
options:
- createrole
- createdb
postgresql: postgresql:
name: postgresql1
scope: *scope
listen: 127.0.0.1:5433 listen: 127.0.0.1:5433
connect_address: 127.0.0.1:5433 connect_address: 127.0.0.1:5433
data_dir: data/postgresql1 data_dir: data/postgresql1
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes # bin_dir:
use_slots: True pgpass: /tmp/pgpass1
pg_hba: authentication:
- host all all 0.0.0.0/0 md5 replication:
- hostssl all all 0.0.0.0/0 md5 username: replicator
replication: password: rep-pass
username: replicator superuser:
password: rep-pass username: postgres
network: 127.0.0.1/32 password: zalando
superuser:
password: zalando
admin:
username: admin
password: admin
#recovery_conf:
#restore_command: cp ../wal_archive/%f %p
wal_e:
env_dir: /home/postgres/etc/wal-e.d/env
threshold_megabytes: 10240
threshold_backup_size_percentage: 30
restore: patroni/scripts/restore.py
parameters: parameters:
archive_mode: "on" unix_socket_directories: '.'
wal_level: hot_standby tags:
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f nofailover: false
max_wal_senders: 5 noloadbalance: false
wal_keep_segments: 8 clonefrom: false
archive_timeout: 1800s
max_replication_slots: 5
hot_standby: "on"
+78
View File
@@ -0,0 +1,78 @@
scope: batman
#namespace: /service/
name: postgresql2
restapi:
listen: 127.0.0.1:8010
connect_address: 127.0.0.1:8010
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
authentication:
username: username
password: password
etcd:
host: 127.0.0.1:4001
bootstrap:
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
# and all other cluster members will use it as a `global configuration`
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
# use_slots: true
parameters:
# wal_level: hot_standby
# hot_standby: "on"
# wal_keep_segments: 8
# max_wal_senders: 5
# max_replication_slots: 5
# wal_log_hints: "on"
# archive_mode: "on"
# archive_timeout: 1800s
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
# recovery_conf:
# restore_command: cp ../wal_archive/%f %p
# some desired options for 'initdb'
initdb: # Note: It needs to be a list (some options need values, others are switches)
- encoding: UTF8
- data-checksums
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# Some additional users users which needs to be created after initializing new cluster
users:
admin:
password: admin
options:
- createrole
- createdb
postgresql:
listen: 127.0.0.1:5434
connect_address: 127.0.0.1:5434
data_dir: data/postgresql2
# bin_dir:
pgpass: /tmp/pgpass2
authentication:
replication:
username: replicator
password: rep-pass
superuser:
username: postgres
password: zalando
parameters:
unix_socket_directories: '.'
tags:
nofailover: false
noloadbalance: false
clonefrom: false
replicatefrom: postgres1
-9
View File
@@ -1,9 +0,0 @@
boto
dnspython
mock
psycopg2
PyYAML
requests
six >= 1.7
kazoo>=2.2.1
python-etcd>=0.4.1
-9
View File
@@ -1,9 +0,0 @@
boto
mock
dnspython3
psycopg2
PyYAML
requests
six
kazoo>=2.2.1
python-etcd>=0.4.1
+12
View File
@@ -0,0 +1,12 @@
boto
psycopg2>=2.6.1
PyYAML
requests
six >= 1.7
kazoo==2.2.1
python-etcd==0.4.3
python-consul==0.6.0
click>=4.1
prettytable>=0.7
tzlocal
python-dateutil
+21 -16
View File
@@ -4,13 +4,12 @@
Setup file for patroni Setup file for patroni
""" """
import sys
import os
import inspect import inspect
import os
import sys
import setuptools
from setuptools.command.test import test as TestCommand from setuptools.command.test import test as TestCommand
from setuptools import setup from setuptools import find_packages, setup
if sys.version_info < (2, 7, 0): if sys.version_info < (2, 7, 0):
sys.stderr.write('FATAL: patroni needs to be run with Python 2.7+\n') sys.stderr.write('FATAL: patroni needs to be run with Python 2.7+\n')
@@ -32,9 +31,9 @@ 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 haproxy confd zookeeper exhibitor consul streaming replication'
COVERAGE_XML = True COVERAGE_XML = True
COVERAGE_HTML = False COVERAGE_HTML = False
@@ -51,12 +50,15 @@ CLASSIFIERS = [
'Operating System :: POSIX :: Linux', 'Operating System :: POSIX :: Linux',
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: CPython',
] ]
CONSOLE_SCRIPTS = ['patroni = patroni:main'] CONSOLE_SCRIPTS = ['patroni = patroni:main',
'patronictl = patroni.ctl:ctl',
"patroni_wale_restore = patroni.scripts.wale_restore:main",
"patroni_aws = patroni.scripts.aws:main"]
class PyTest(TestCommand): class PyTest(TestCommand):
@@ -73,7 +75,7 @@ class PyTest(TestCommand):
def finalize_options(self): def finalize_options(self):
TestCommand.finalize_options(self) TestCommand.finalize_options(self)
if self.cov_xml or self.cov_html: if self.cov_xml or self.cov_html:
self.cov = ['--cov', MAIN_PACKAGE, '--cov', MAIN_PACKAGE, '--cov-report', 'term-missing'] self.cov = ['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing']
if self.cov_xml: if self.cov_xml:
self.cov.extend(['--cov-report', 'xml']) self.cov.extend(['--cov-report', 'xml'])
if self.cov_html: if self.cov_html:
@@ -89,10 +91,14 @@ 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, '-vv']
import logging
silence = logging.WARNING
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
params['args'] += ['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd']
errno = pytest.main(**params) errno = pytest.main(**params)
sys.exit(errno) sys.exit(errno)
@@ -114,8 +120,7 @@ def setup_package():
# Some helper variables # Some helper variables
version = os.getenv('GO_PIPELINE_LABEL', VERSION) version = os.getenv('GO_PIPELINE_LABEL', VERSION)
requirements = 'requirements-py2.txt' if sys.version_info[0] == 2 else 'requirements-py3.txt' install_reqs = get_install_requirements('requirements.txt')
install_reqs = get_install_requirements(requirements)
command_options = {'test': {'test_suite': ('setup.py', 'tests')}} command_options = {'test': {'test_suite': ('setup.py', 'tests')}}
if JUNIT_XML: if JUNIT_XML:
@@ -137,12 +142,12 @@ def setup_package():
long_description=read('README.rst'), long_description=read('README.rst'),
classifiers=CLASSIFIERS, classifiers=CLASSIFIERS,
test_suite='tests', test_suite='tests',
packages=setuptools.find_packages(exclude=['tests', 'tests.*']), packages=find_packages(exclude=['tests', 'tests.*']),
package_data={MAIN_PACKAGE: ["*.json"]}, package_data={MAIN_PACKAGE: ["*.json"]},
install_requires=install_reqs, install_requires=install_reqs,
setup_requires=['six', 'flake8'], setup_requires=['flake8'],
cmdclass=cmdclass, cmdclass=cmdclass,
tests_require=['pytest-cov', 'pytest'], tests_require=['mock>=2.0.0', 'pytest-cov', 'pytest'],
command_options=command_options, command_options=command_options,
entry_points={'console_scripts': CONSOLE_SCRIPTS}, entry_points={'console_scripts': CONSOLE_SCRIPTS},
) )
+290 -22
View File
@@ -1,53 +1,321 @@
import datetime
import json
import psycopg2 import psycopg2
import pytz
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 ClusterConfig, 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 test_postgresql import psycopg2_connect, MockCursor
def throws(*args, **kwargs): future_restart_time = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=5)
raise psycopg2.OperationalError() postmaster_start_time = datetime.datetime.now(pytz.utc)
class MockPostgresql: class MockPostgresql(object):
def connection(self): name = 'test'
state = 'running'
role = 'master'
server_version = '999999'
sysid = 'dummysysid'
scope = 'dummy'
pending_restart = True
@staticmethod
def connection():
return psycopg2_connect() return psycopg2_connect()
def is_running(self): @staticmethod
def postmaster_start_time():
return str(postmaster_start_time)
class MockHa(object):
state_handler = MockPostgresql()
@staticmethod
def reinitialize():
return 'reinitialize'
@staticmethod
def restart():
return (True, '')
@staticmethod
def restart_scheduled():
return False
@staticmethod
def delete_future_restart():
return True
@staticmethod
def fetch_nodes_statuses(members):
return [[None, True, None, None, {}]]
@staticmethod
def schedule_future_restart(data):
return True return True
class MockPatroni: class MockPatroni(object):
def __init__(self): ha = MockHa()
self.postgresql = MockPostgresql() config = Mock()
postgresql = ha.state_handler
dcs = Mock()
tags = {}
version = '0.00'
noloadbalance = Mock(return_value=False)
scheduled_restart = {'schedule': future_restart_time,
'postmaster_start_time': postgresql.postmaster_start_time()}
@staticmethod
def sighup_handler():
pass
class MockRequest: class MockRequest(object):
def __init__(self, path): def __init__(self, request):
self.path = path self.request = request.encode('utf-8')
def makefile(self, *args, **kwargs): def makefile(self, *args, **kwargs):
return IO(self.path) return IO(self.request)
class MockRestApiServer(RestApiServer): class MockRestApiServer(RestApiServer):
def __init__(self, Handler, path, *args): def __init__(self, Handler, request):
self.patroni = MockPatroni() self.socket = 0
if len(args) > 0: self.serve_forever = Mock()
self.query = args[0] BaseHTTPServer.HTTPServer.__init__ = Mock()
Handler(MockRequest(path), ('0.0.0.0', 8080), self) MockRestApiServer._BaseServer__is_shut_down = Mock()
MockRestApiServer._BaseServer__shutdown_request = True
config = {'listen': '127.0.0.1:8008', 'auth': 'test:test'}
super(MockRestApiServer, self).__init__(MockPatroni(), config)
config['certfile'] = 'dumb'
self.reload_config(config)
Handler(MockRequest(request), ('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'): _authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
super(TestRestApiHandler, self).__init__(method_name)
def test_do_GET(self): def test_do_GET(self):
MockRestApiServer(RestApiHandler, b'GET /') MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, b'GET /', throws) with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={})):
MockRestApiServer(RestApiHandler, 'GET /replica')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /master')
MockPatroni.dcs.cluster.leader.name = MockPostgresql.name
MockRestApiServer(RestApiHandler, 'GET /replica')
MockPatroni.dcs.cluster = None
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, 'GET /master')
with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /master')
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /master'))
def test_do_OPTIONS(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
def test_do_GET_patroni(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
def test_basicauth(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /restart HTTP/1.0'))
MockRestApiServer(RestApiHandler, 'POST /restart HTTP/1.0\nAuthorization:')
@patch.object(MockPatroni, 'dcs')
def test_do_GET_config(self, mock_dcs):
mock_dcs.cluster.config.data = {}
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config'))
mock_dcs.cluster.config = None
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config'))
@patch.object(MockPatroni, 'dcs')
def test_do_PATCH_config(self, mock_dcs):
config = {'postgresql': {'use_slots': False, 'use_pg_rewind': True, 'parameters': {'wal_level': 'logical'}}}
mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, json.dumps(config))
request = 'PATCH /config HTTP/1.0' + self._authorization
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
request += '\nContent-Length: '
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '34\n\n{"postgresql":{"use_slots":false}}'))
config['ttl'] = 5
config['postgresql'].update({'use_slots': {'foo': True}, "parameters": None})
config = json.dumps(config)
request += str(len(config)) + '\n\n' + config
MockRestApiServer(RestApiHandler, request)
mock_dcs.set_config_value.return_value = False
MockRestApiServer(RestApiHandler, request)
@patch.object(MockPatroni, 'dcs')
def test_do_PUT_config(self, mock_dcs):
mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, '{}')
request = 'PUT /config HTTP/1.0' + self._authorization + '\nContent-Length: '
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2\n\n{}'))
config = '{"foo": "bar"}'
request += str(len(config)) + '\n\n' + config
MockRestApiServer(RestApiHandler, request)
mock_dcs.set_config_value.return_value = False
MockRestApiServer(RestApiHandler, request)
mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, config)
MockRestApiServer(RestApiHandler, request)
@patch.object(MockPatroni, 'sighup_handler', Mock(side_effect=Exception))
def test_do_POST_reload(self):
with patch.object(MockPatroni, 'config') as mock_config:
mock_config.reload_local_configuration.return_value = False
MockRestApiServer(RestApiHandler, 'POST /reload HTTP/1.0' + self._authorization)
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /reload HTTP/1.0' + self._authorization))
@patch.object(MockPatroni, 'dcs')
def test_do_POST_restart(self, mock_dcs):
mock_dcs.get_cluster.return_value.is_paused.return_value = False
request = 'POST /restart HTTP/1.0' + self._authorization
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
with patch.object(MockHa, 'restart', Mock(side_effect=Exception)):
MockRestApiServer(RestApiHandler, request)
post = request + '\nContent-Length: '
def make_request(request=None, **kwargs):
request = json.dumps(kwargs) if request is None else request
return '{0}{1}\n\n{2}'.format(post, len(request), request)
# empty request
request = make_request('')
MockRestApiServer(RestApiHandler, request)
# invalid request
request = make_request('foobar=baz')
MockRestApiServer(RestApiHandler, request)
# wrong role
request = make_request(schedule=future_restart_time.isoformat(), role='unknown', postgres_version='9.5.3')
MockRestApiServer(RestApiHandler, request)
# wrong version
request = make_request(schedule=future_restart_time.isoformat(), role='master', postgres_version='9.5.3.1')
MockRestApiServer(RestApiHandler, request)
# unknown filter
request = make_request(schedule=future_restart_time.isoformat(), batman='lives')
MockRestApiServer(RestApiHandler, request)
# incorrect schedule
request = make_request(schedule='2016-08-42 12:45TZ+1', role='master')
MockRestApiServer(RestApiHandler, request)
# everything fine, but the schedule is missing
request = make_request(role='master', postgres_version='9.5.2')
MockRestApiServer(RestApiHandler, request)
for retval in (True, False):
with patch.object(MockHa, 'schedule_future_restart', Mock(return_value=retval)):
request = make_request(schedule=future_restart_time.isoformat())
MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'restart', Mock(return_value=(retval, "foo"))):
request = make_request(role='master', postgres_version='9.5.2')
MockRestApiServer(RestApiHandler, request)
mock_dcs.get_cluster.return_value.is_paused.return_value = True
MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='master'))
def test_do_DELETE_restart(self):
for retval in (True, False):
with patch.object(MockHa, 'delete_future_restart', Mock(return_value=retval)):
request = 'DELETE /restart HTTP/1.0' + self._authorization
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
@patch.object(MockPatroni, 'dcs')
def test_do_POST_reinitialize(self, mock_dcs):
cluster = mock_dcs.get_cluster.return_value
cluster.is_paused.return_value = False
request = 'POST /reinitialize HTTP/1.0' + self._authorization
MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'reinitialize', Mock(return_value=None)):
MockRestApiServer(RestApiHandler, request)
@patch('time.sleep', Mock())
def test_RestApiServer_query(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
@patch('time.sleep', Mock())
@patch.object(MockPatroni, 'dcs')
def test_do_POST_failover(self, dcs):
dcs.loop_wait = 10
cluster = dcs.get_cluster.return_value
post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: '
MockRestApiServer(RestApiHandler, post + '7\n\n{"1":2}')
request = post + '0\n\n'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql1'
MockRestApiServer(RestApiHandler, request)
MockRestApiServer(RestApiHandler, post + '25\n\n{"leader": "postgresql1"}')
cluster.leader.name = 'postgresql2'
request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}'
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql1'
MockRestApiServer(RestApiHandler, request)
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}),
Member(0, 'postgresql2', 30, {'api_url': 'http'})]
MockRestApiServer(RestApiHandler, request)
cluster.failover = None
MockRestApiServer(RestApiHandler, request)
dcs.get_cluster.side_effect = [cluster]
MockRestApiServer(RestApiHandler, request)
cluster2 = cluster.copy()
cluster2.leader.name = 'postgresql0'
dcs.get_cluster.side_effect = [cluster, cluster2]
MockRestApiServer(RestApiHandler, request)
cluster2.leader.name = 'postgresql2'
dcs.get_cluster.side_effect = [cluster, cluster2]
MockRestApiServer(RestApiHandler, request)
dcs.get_cluster.side_effect = None
dcs.manual_failover.return_value = False
MockRestApiServer(RestApiHandler, request)
dcs.manual_failover.return_value = True
with patch.object(MockHa, 'fetch_nodes_statuses', Mock(return_value=[])):
MockRestApiServer(RestApiHandler, request)
# Valid future date
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\
' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}'
MockRestApiServer(RestApiHandler, request)
with patch.object(MockPatroni, 'dcs') as d:
d.manual_failover.return_value = False
MockRestApiServer(RestApiHandler, request)
# Exception: No timezone specified
request = post + '97\n\n{"leader": "postgresql1", "member": "postgresql2",' +\
' "scheduled_at": "6016-02-15T18:13:30.568224"}'
MockRestApiServer(RestApiHandler, request)
# Exception: Scheduled in the past
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2", "scheduled_at": "'
MockRestApiServer(RestApiHandler, request + '1016-02-15T18:13:30.568224+01:00"}')
# Invalid date
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}'))
+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()))
+18 -18
View File
@@ -1,12 +1,15 @@
import unittest
import requests
import boto.ec2 import boto.ec2
import requests
import sys
import unittest
from mock import Mock, patch
from collections import namedtuple from collections import namedtuple
from patroni.scripts.aws import AWSConnection from patroni.scripts.aws import AWSConnection, main as _main
from requests.exceptions import RequestException from requests.exceptions import RequestException
class MockEc2Connection: class MockEc2Connection(object):
def __init__(self, error=False): def __init__(self, error=False):
self.error = error self.error = error
@@ -23,7 +26,7 @@ class MockEc2Connection:
return True return True
class MockResponse: class MockResponse(object):
def __init__(self, content): def __init__(self, content):
self.content = content self.content = content
@@ -35,15 +38,6 @@ class MockResponse:
class TestAWSConnection(unittest.TestCase): class TestAWSConnection(unittest.TestCase):
def __init__(self, method_name='runTest'):
super(TestAWSConnection, self).__init__(method_name)
def set_error(self):
self.error = True
def set_json_error(self):
self.json_error = True
def boto_ec2_connect_to_region(self, region): def boto_ec2_connect_to_region(self, region):
return MockEc2Connection(self.error) return MockEc2Connection(self.error)
@@ -74,21 +68,27 @@ class TestAWSConnection(unittest.TestCase):
self.assertTrue(self.conn.on_role_change('master')) self.assertTrue(self.conn.on_role_change('master'))
def test_non_aws(self): def test_non_aws(self):
self.set_error() self.error = True
conn = AWSConnection('test') conn = AWSConnection('test')
self.assertFalse(conn.aws_available()) self.assertFalse(conn.aws_available())
self.assertFalse(conn._tag_ebs('master')) self.assertFalse(conn._tag_ebs('master'))
self.assertFalse(conn._tag_ec2('master')) self.assertFalse(conn._tag_ec2('master'))
def test_aws_bizare_response(self): def test_aws_bizare_response(self):
self.set_json_error() self.json_error = True
conn = AWSConnection('test') conn = AWSConnection('test')
self.assertFalse(conn.aws_available()) self.assertFalse(conn.aws_available())
def test_aws_tag_ebs_error(self): def test_aws_tag_ebs_error(self):
self.set_error() self.error = True
self.assertFalse(self.conn._tag_ebs("master")) self.assertFalse(self.conn._tag_ebs("master"))
def test_aws_tag_ec2_error(self): def test_aws_tag_ec2_error(self):
self.set_error() self.error = True
self.assertFalse(self.conn._tag_ec2("master")) self.assertFalse(self.conn._tag_ec2("master"))
@patch('sys.exit', Mock())
def test_main(self):
self.assertIsNone(_main())
sys.argv = ['aws.py', 'on_start', 'replica', 'foo']
self.assertIsNone(_main())
+74
View File
@@ -0,0 +1,74 @@
import os
import unittest
import sys
from mock import MagicMock, Mock, patch
from patroni.config import Config
from six.moves import builtins
class TestConfig(unittest.TestCase):
@patch('os.path.isfile', Mock(return_value=True))
@patch('json.load', Mock(side_effect=Exception))
@patch.object(builtins, 'open', MagicMock())
def setUp(self):
sys.argv = ['patroni.py']
os.environ[Config.PATRONI_CONFIG_VARIABLE] = 'restapi: {}\npostgresql: {data_dir: foo}'
self.config = Config()
def test_no_config(self):
self.assertRaises(SystemExit, Config)
@patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception))
def test_set_dynamic_configuration(self):
self.assertIsNone(self.config.set_dynamic_configuration({'foo': 'bar'}))
def test_reload_local_configuration(self):
os.environ.update({
'PATRONI_NAME': 'postgres0',
'PATRONI_NAMESPACE': '/patroni/',
'PATRONI_SCOPE': 'batman2',
'PATRONI_RESTAPI_USERNAME': 'username',
'PATRONI_RESTAPI_PASSWORD': 'password',
'PATRONI_RESTAPI_LISTEN': '0.0.0.0:8008',
'PATRONI_RESTAPI_CONNECT_ADDRESS': '127.0.0.1:8008',
'PATRONI_RESTAPI_CERTFILE': '/certfile',
'PATRONI_RESTAPI_KEYFILE': '/keyfile',
'PATRONI_POSTGRESQL_LISTEN': '0.0.0.0:5432',
'PATRONI_POSTGRESQL_CONNECT_ADDRESS': '127.0.0.1:5432',
'PATRONI_POSTGRESQL_DATA_DIR': 'data/postgres0',
'PATRONI_POSTGRESQL_PGPASS': '/tmp/pgpass0',
'PATRONI_ETCD_HOST': '127.0.0.1:2379',
'PATRONI_CONSUL_HOST': '127.0.0.1:8500',
'PATRONI_ZOOKEEPER_HOSTS': "'host1:2181','host2:2181'",
'PATRONI_EXHIBITOR_HOSTS': 'host1,host2',
'PATRONI_EXHIBITOR_PORT': '8181',
'PATRONI_foo_HOSTS': '[host1,host2', # Exception in parse_list
'PATRONI_SUPERUSER_USERNAME': 'postgres',
'PATRONI_SUPERUSER_PASSWORD': 'zalando',
'PATRONI_REPLICATION_USERNAME': 'replicator',
'PATRONI_REPLICATION_PASSWORD': 'rep-pass',
'PATRONI_admin_PASSWORD': 'admin',
'PATRONI_admin_OPTIONS': 'createrole,createdb'
})
sys.argv = ['patroni.py', 'postgres0.yml']
config = Config()
with patch.object(Config, '_load_config_file', Mock(return_value={'restapi': {}})):
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
self.assertRaises(Exception, config.reload_local_configuration, True)
self.assertTrue(config.reload_local_configuration(True))
self.assertTrue(config.reload_local_configuration())
@patch('tempfile.mkstemp', Mock(return_value=[3000, 'blabla']))
@patch('os.path.exists', Mock(return_value=True))
@patch('os.remove', Mock(side_effect=IOError))
@patch('os.close', Mock(side_effect=IOError))
@patch('os.rename', Mock(return_value=None))
@patch('json.dump', Mock())
def test_save_cache(self):
self.config.set_dynamic_configuration({'ttl': 30, 'postgresql': {'foo': 'bar'}})
with patch('os.fdopen', Mock(side_effect=IOError)):
self.config.save_cache()
with patch('os.fdopen', MagicMock()):
self.config.save_cache()
+140
View File
@@ -0,0 +1,140 @@
import consul
import unittest
from mock import Mock, patch
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulError, ConsulException, HTTPClient, NotFound
from test_etcd import SleepException
def kv_get(self, key, **kwargs):
if key == 'service/test/members/postgresql1':
return '1', {'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7'}
if key == 'service/test/':
return None, None
if key == 'service/good/leader':
return '1', None
if key == 'service/good/':
return ('6429',
[{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'failover', 'LockIndex': 0,
'ModifyIndex': 1334, 'Value': b''},
{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'initialize', 'LockIndex': 0,
'ModifyIndex': 1334, 'Value': b'postgresql0'},
{'CreateIndex': 2621, 'Flags': 0, 'Key': key + 'leader', 'LockIndex': 1,
'ModifyIndex': 2621, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7', 'Value': b'postgresql1'},
{'CreateIndex': 6156, 'Flags': 0, 'Key': key + 'members/postgresql0', 'LockIndex': 1,
'ModifyIndex': 6156, 'Session': '782e6da4-ed02-3aef-7963-99a90ed94b53',
'Value': ('postgres://replicator:[email protected]:5432/postgres' +
'?application_name=http://127.0.0.1:8008/patroni').encode('utf-8')},
{'CreateIndex': 2630, 'Flags': 0, 'Key': key + 'members/postgresql1', 'LockIndex': 1,
'ModifyIndex': 2630, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7',
'Value': ('postgres://replicator:[email protected]:5433/postgres' +
'?application_name=http://127.0.0.1:8009/patroni').encode('utf-8')},
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'optime/leader', 'LockIndex': 0,
'ModifyIndex': 6429, 'Value': b'4496294792'}])
raise ConsulException
class TestHTTPClient(unittest.TestCase):
def test_get(self):
self.client = HTTPClient('127.0.0.1', '8500', 'http', False)
self.client.session.get = Mock()
self.client.get(Mock(), '')
self.client.get(Mock(), '', {'wait': '1s', 'index': 1})
@patch.object(consul.Consul.KV, 'get', kv_get)
class TestConsul(unittest.TestCase):
@patch.object(consul.Consul.Session, 'create', Mock(return_value='fd4f44fe-2cac-bba5-a60b-304b51ff39b7'))
@patch.object(consul.Consul.Session, 'renew', Mock(side_effect=NotFound))
@patch.object(consul.Consul.KV, 'get', kv_get)
@patch.object(consul.Consul.KV, 'delete', Mock())
def setUp(self):
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10})
self.c._base_path = '/service/good'
self.c._load_cluster()
@patch('time.sleep', Mock(side_effect=SleepException))
@patch.object(consul.Consul.Session, 'create', Mock(side_effect=ConsulException))
def test_create_session(self):
self.c._session = None
self.assertRaises(SleepException, self.c.create_session)
@patch.object(consul.Consul.Session, 'renew', Mock(side_effect=NotFound))
@patch.object(consul.Consul.Session, 'create', Mock(side_effect=ConsulException))
def test_referesh_session(self):
self.c._session = '1'
self.c._name = ''
self.assertRaises(ConsulError, self.c.refresh_session)
@patch.object(consul.Consul.KV, 'delete', Mock())
def test_get_cluster(self):
self.c._base_path = '/service/test'
self.assertIsInstance(self.c.get_cluster(), Cluster)
self.assertIsInstance(self.c.get_cluster(), Cluster)
self.c._base_path = '/service/fail'
self.assertRaises(ConsulError, self.c.get_cluster)
self.c._base_path = '/service/good'
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
self.assertIsInstance(self.c.get_cluster(), Cluster)
@patch.object(consul.Consul.KV, 'delete', Mock(side_effect=[ConsulException, True, True]))
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException]))
def test_touch_member(self):
self.c.refresh_session = Mock(return_value=True)
self.c.touch_member('balbla')
self.c.touch_member('balbla')
self.c.touch_member('balbla')
self.c.refresh_session = Mock(return_value=False)
self.c.touch_member('balbla')
@patch.object(consul.Consul.KV, 'put', Mock(return_value=False))
def test_take_leader(self):
self.c.take_leader()
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_set_failover_value(self):
self.c.set_failover_value('')
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_set_config_value(self):
self.c.set_config_value('')
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
def test_write_leader_optime(self):
self.c.write_leader_optime('')
def test_update_leader(self):
self.c.update_leader()
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
def test_delete_leader(self):
self.c.delete_leader()
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_initialize(self):
self.c.initialize()
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
def test_cancel_initialization(self):
self.c.cancel_initialization()
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
def test_delete_cluster(self):
self.c.delete_cluster()
@patch.object(AbstractDCS, 'watch', Mock())
def test_watch(self):
self.c._name = ''
self.c.watch(1)
with patch.object(consul.Consul.KV, 'get', Mock(side_effect=ConsulException)):
self.c.watch(1)
@patch.object(consul.Consul.Session, 'destroy', Mock(side_effect=ConsulException))
def test_set_ttl(self):
self.c.set_ttl(20)
self.assertTrue(self.c.watch(1))
def test_set_retry_timeout(self):
self.c.set_retry_timeout(10)
+442
View File
@@ -0,0 +1,442 @@
import etcd
import os
import requests
import sys
import unittest
from click.testing import CliRunner
from mock import patch, Mock
from patroni.ctl import ctl, members, store_config, load_config, output_members, request_patroni, get_dcs, parse_dcs, \
wait_for_leader, get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException
from patroni.dcs.etcd import Client
from psycopg2 import OperationalError
from test_etcd import etcd_read, requests_get, socket_getaddrinfo, MockResponse
from test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader
from test_postgresql import MockConnect, psycopg2_connect
CONFIG_FILE_PATH = './test-ctl.yaml'
def test_rw_config():
runner = CliRunner()
with runner.isolated_filesystem():
store_config({'etcd': {'host': 'localhost:2379'}}, CONFIG_FILE_PATH + '/dummy')
sys.argv = ['patronictl.py', '']
load_config(CONFIG_FILE_PATH + '/dummy', None)
load_config(CONFIG_FILE_PATH + '/dummy', '0.0.0.0')
os.remove(CONFIG_FILE_PATH + '/dummy')
os.rmdir(CONFIG_FILE_PATH)
@patch('patroni.ctl.load_config',
Mock(return_value={'postgresql': {'data_dir': '.', 'parameters': {}, 'retry_timeout': 5},
'restapi': {'auth': 'u:p', 'listen': ''}, 'etcd': {'host': 'localhost:4001'}}))
class TestCtl(unittest.TestCase):
@patch('socket.getaddrinfo', socket_getaddrinfo)
def setUp(self):
with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.runner = CliRunner()
self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'retry_timeout': 10}}, 'foo')
@patch('psycopg2.connect', psycopg2_connect)
def test_get_cursor(self):
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), {}, role='master'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='master'))
# MockCursor returns pg_is_in_recovery as false
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='replica'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'database': 'foo'}, role='any'))
def test_parse_dcs(self):
assert parse_dcs(None) is None
assert parse_dcs('localhost') == {'etcd': {'host': 'localhost:4001'}}
assert parse_dcs('') == {'etcd': {'host': 'localhost:4001'}}
assert parse_dcs('localhost:8500') == {'consul': {'host': 'localhost:8500'}}
assert parse_dcs('zookeeper://localhost') == {'zookeeper': {'hosts': ['localhost:2181']}}
assert parse_dcs('exhibitor://dummy') == {'exhibitor': {'hosts': ['dummy'], 'port': 8181}}
assert parse_dcs('consul://localhost') == {'consul': {'host': 'localhost:8500'}}
self.assertRaises(PatroniCtlException, parse_dcs, 'invalid://test')
def test_output_members(self):
cluster = get_cluster_initialized_with_leader()
self.assertIsNone(output_members(cluster, name='abc', fmt='pretty'))
self.assertIsNone(output_members(cluster, name='abc', fmt='json'))
self.assertIsNone(output_members(cluster, name='abc', fmt='tsv'))
@patch('patroni.ctl.get_dcs')
@patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse()))
def test_failover(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
assert 'leader' in result.output
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n2100-01-01T12:23:00\ny')
assert result.exit_code == 0
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n2030-01-01T12:23:00\ny')
assert result.exit_code == 0
with patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
result = self.runner.invoke(ctl,
['failover', 'dummy', '--force', '--scheduled', '2015-01-01T12:00:00+01:00'])
assert result.exit_code == 1
# Aborting failover,as we anser NO to the confirmation
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\nN')
assert result.exit_code == 1
# Target and source are equal
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nleader\n\ny')
assert result.exit_code == 1
# Reality is not part of this cluster
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nReality\n\ny')
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force'])
assert 'Member' in result.output
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', '2015-01-01T12:00:00+01:00'])
assert result.exit_code == 0
# Invalid timestamp
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', 'invalid'])
assert result.exit_code != 0
# Invalid timestamp
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', '2115-02-30T12:00:00+01:00'])
assert result.exit_code != 0
# Specifying wrong leader
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='dummy')
assert result.exit_code == 1
with patch('patroni.ctl.request_patroni', Mock(side_effect=Exception)):
# Non-responding patroni
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
assert 'falling back to DCS' in result.output
with patch('patroni.ctl.request_patroni') as mocked:
mocked.return_value.status_code = 500
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
assert 'Failover failed' in result.output
# No members available
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
assert result.exit_code == 1
# No master available
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_without_leader
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
assert result.exit_code == 1
def test_get_dcs(self):
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy')
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
@patch('patroni.ctl.get_dcs')
@patch.object(etcd.Client, 'read', etcd_read)
def test_query(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
# Mutually exclusive
result = self.runner.invoke(ctl, ['query', 'alpha', '--member', 'abc', '--role', 'master'])
assert result.exit_code == 1
with self.runner.isolated_filesystem():
with open('dummy', 'w') as dummy_file:
dummy_file.write('SELECT 1')
# Mutually exclusive
result = self.runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy', '--command', 'dummy'])
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy'])
assert result.exit_code == 0
os.remove('dummy')
result = self.runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1'])
assert 'mock column' in result.output
# --command or --file is mandatory
result = self.runner.invoke(ctl, ['query', 'alpha'])
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1', '--username', 'root',
'--password', '--dbname', 'postgres'], input='ab\nab')
assert 'mock column' in result.output
def test_query_member(self):
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
rows = query_member(None, None, None, 'master', 'SELECT pg_is_in_recovery()', {})
self.assertTrue('False' in str(rows))
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
self.assertEquals(rows, (None, None))
with patch('test_postgresql.MockCursor.execute', Mock(side_effect=OperationalError('bla'))):
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
rows = query_member(None, None, None, None, 'SELECT pg_is_in_recovery()', {})
self.assertTrue('No connection to' in str(rows))
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
self.assertTrue('No connection to' in str(rows))
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
@patch('patroni.ctl.get_dcs')
def test_dsn(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['dsn', 'alpha'])
assert 'host=127.0.0.1 port=5435' in result.output
# Mutually exclusive options
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--role', 'master', '--member', 'dummy'])
assert result.exit_code == 1
# Non-existing member
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
assert result.exit_code == 1
@patch('requests.post', requests_get)
@patch('patroni.ctl.get_dcs')
def test_restart_reinit(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='y\n\nnow')
assert 'Failed: restart for' in result.output
assert result.exit_code == 0
result = self.runner.invoke(ctl, ['reinit', 'alpha'], input='y')
assert result.exit_code == 1
# successful reinit
result = self.runner.invoke(ctl, ['reinit', 'alpha', 'other'], input='y')
assert result.exit_code == 0
# Aborted restart
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='N')
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pending', '--force'])
assert result.exit_code == 0
# Not a member
result = self.runner.invoke(ctl, ['restart', 'alpha', 'dummy', '--any'], input='y')
assert result.exit_code == 1
# Wrong pg version
result = self.runner.invoke(ctl, ['restart', 'alpha', '--any', '--pg-version', '9.1'], input='y')
assert 'Error: PostgreSQL version' in result.output
assert result.exit_code == 1
with patch('requests.delete', Mock(return_value=MockResponse(500))):
# normal restart, the schedule is actually parsed, but not validated in patronictl
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force',
'--scheduled', '2300-10-01T14:30'])
assert 'Failed: flush scheduled restart' in result.output
with patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
result = self.runner.invoke(ctl,
['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
assert result.exit_code == 1
with patch('requests.post', Mock(return_value=MockResponse())):
# normal restart, the schedule is actually parsed, but not validated in patronictl
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pg-version', '42.0.0',
'--scheduled', '2300-10-01T14:30'], input='y')
assert result.exit_code == 0
with patch('requests.post', Mock(return_value=MockResponse(204))):
# get restart with the non-200 return code
# normal restart, the schedule is actually parsed, but not validated in patronictl
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pg-version', '42.0.0',
'--scheduled', '2300-10-01T14:30'], input='y')
assert result.exit_code == 0
# force restart with restart already present
with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(204))):
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force',
'--scheduled', '2300-10-01T14:30'])
assert result.exit_code == 0
with patch('requests.post', Mock(return_value=MockResponse(202))):
# get restart with the non-200 return code
# normal restart, the schedule is actually parsed, but not validated in patronictl
result = self.runner.invoke(
ctl, ['restart', 'alpha', '--pg-version', '99.0.0', '--scheduled', '2300-10-01T14:30'], input='y'
)
assert 'Success: restart scheduled' in result.output
assert result.exit_code == 0
with patch('requests.post', Mock(return_value=MockResponse(409))):
# get restart with the non-200 return code
# normal restart, the schedule is actually parsed, but not validated in patronictl
result = self.runner.invoke(
ctl, ['restart', 'alpha', '--pg-version', '99.0.0', '--scheduled', '2300-10-01T14:30'], input='y'
)
assert 'Failed: another restart is already' in result.output
assert result.exit_code == 0
@patch('patroni.ctl.get_dcs')
def test_remove(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='alpha\nslave')
assert 'Please confirm' in result.output
assert 'You are about to remove all' in result.output
# Not typing an exact confirmation
assert result.exit_code == 1
# master specified does not match master of cluster
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='alpha\nYes I am aware\nslave')
assert result.exit_code == 1
# cluster specified on cmdline does not match verification prompt
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='beta\nleader')
assert result.exit_code == 1
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='alpha\nYes I am aware\nleader')
assert result.exit_code == 0
@patch('patroni.dcs.AbstractDCS.watch', Mock(return_value=None))
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
def test_wait_for_leader(self):
self.assertRaises(PatroniCtlException, wait_for_leader, self.e, 0)
cluster = wait_for_leader(self.e, timeout=2)
assert cluster.leader.member.name == 'leader'
@patch('requests.post', Mock(side_effect=requests.exceptions.ConnectionError('foo')))
def test_request_patroni(self):
member = get_cluster_initialized_with_leader().leader.member
self.assertRaises(requests.exceptions.ConnectionError, request_patroni, member, 'post', 'dummy', {})
def test_ctl(self):
self.runner.invoke(ctl, ['list'])
result = self.runner.invoke(ctl, ['--help'])
assert 'Usage:' in result.output
def test_get_any_member(self):
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), role='master'))
m = get_any_member(get_cluster_initialized_with_leader(), role='master')
self.assertEquals(m.name, 'leader')
def test_get_all_members(self):
self.assertEquals(list(get_all_members(get_cluster_initialized_without_leader(), role='master')), [])
r = list(get_all_members(get_cluster_initialized_with_leader(), role='master'))
self.assertEquals(len(r), 1)
self.assertEquals(r[0].name, 'leader')
r = list(get_all_members(get_cluster_initialized_with_leader(), role='replica'))
self.assertEquals(len(r), 1)
self.assertEquals(r[0].name, 'other')
self.assertEquals(len(list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))), 2)
@patch('patroni.ctl.get_dcs')
def test_members(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(members, ['alpha'])
assert '127.0.0.1' in result.output
assert result.exit_code == 0
def test_configure(self):
result = self.runner.invoke(configure, ['--dcs', 'abc', '-c', 'dummy', '-n', 'bla'])
assert result.exit_code == 0
@patch('patroni.ctl.get_dcs')
def test_scaffold(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_not_initialized_without_leader
mock_get_dcs.return_value.initialize = Mock(return_value=True)
mock_get_dcs.return_value.touch_member = Mock(return_value=True)
mock_get_dcs.return_value.attempt_to_acquire_leader = Mock(return_value=True)
with patch.object(self.e, 'initialize', return_value=False):
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
assert result.exception
with patch.object(mock_get_dcs.return_value, 'touch_member', Mock(return_value=False)):
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
assert result.exception
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
assert result.exit_code == 0
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
assert result.exception
@patch('patroni.ctl.get_dcs')
def test_list_extended(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['list', 'dummy', '--extended'])
assert '2100' in result.output
assert 'Scheduled restart' in result.output
@patch('patroni.ctl.get_dcs')
@patch('requests.delete', Mock(return_value=MockResponse()))
def test_flush(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '-r', 'master'], input='y')
assert 'No scheduled restart' in result.output
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
assert 'Success: flush scheduled restart' in result.output
with patch.object(requests, 'delete', return_value=MockResponse(404)):
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
assert 'Failed: flush scheduled restart' in result.output
@patch('patroni.ctl.get_dcs')
def test_pause_cluster(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
with patch('requests.patch', Mock(return_value=MockResponse(200))):
result = self.runner.invoke(ctl, ['pause', 'dummy'])
assert 'Success' in result.output
with patch('requests.patch', Mock(return_value=MockResponse(500))):
result = self.runner.invoke(ctl, ['pause', 'dummy'])
assert 'Failed' in result.output
with patch('requests.patch', Mock(return_value=MockResponse(200))),\
patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
result = self.runner.invoke(ctl, ['pause', 'dummy'])
assert 'Cluster is already paused' in result.output
@patch('patroni.ctl.get_dcs')
def test_resume_cluster(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
with patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
with patch('requests.patch', Mock(return_value=MockResponse(200))):
result = self.runner.invoke(ctl, ['resume', 'dummy'])
assert 'Success' in result.output
with patch('requests.patch', Mock(return_value=MockResponse(500))):
result = self.runner.invoke(ctl, ['resume', 'dummy'])
assert 'Failed' in result.output
with patch('requests.patch', Mock(return_value=MockResponse(200))),\
patch('patroni.dcs.Cluster.is_paused', Mock(return_value=False)):
result = self.runner.invoke(ctl, ['resume', 'dummy'])
assert 'Cluster is not paused' in result.output
+119 -121
View File
@@ -1,63 +1,50 @@
import datetime
import dns.resolver
import etcd import etcd
import json import json
import requests import requests
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.etcd import AbstractDCS, Client, Cluster, Etcd, EtcdError
from patroni.etcd import Client, Etcd from patroni.exceptions import DCSError
from urllib3.exceptions import ReadTimeoutError
class MockResponse: class MockResponse(object):
def __init__(self): def __init__(self, status_code=200):
self.status_code = 200 self.status_code = status_code
self.content = '{}' self.content = '{}'
self.ok = True self.ok = True
self.text = ''
def json(self): def json(self):
return json.loads(self.content) return json.loads(self.content)
@property @property
def data(self): def data(self):
if self.content == 'TimeoutError': return self.content.encode('utf-8')
raise urllib3.exceptions.TimeoutError
if self.content == 'Exception':
raise Exception
return self.content
@property @property
def status(self): def status(self):
return self.status_code return self.status_code
@staticmethod
def getheader(*args): def getheader(*args):
return '' return ''
class MockPostgresql:
name = ''
def last_operation(self):
return 0
def requests_get(url, **kwargs): def requests_get(url, **kwargs):
members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\ members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\
'"name":"default","clientURLs":["http://localhost:2379","http://localhost:4001"]}]' '"name":"default","clientURLs":["http://localhost:2379","http://localhost:4001"]}]'
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'): response.content = '[{}]' if url.startswith('http://error') else members
response.content = '[{}]'
else:
response.content = members
elif url.startswith('http://exhibitor'): elif url.startswith('http://exhibitor'):
response.content = '{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}' response.content = '{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}'
else: else:
@@ -66,39 +53,35 @@ def requests_get(url, **kwargs):
return response return response
def etcd_watch(key, index=None, timeout=None, recursive=None): def etcd_watch(self, key, index=None, timeout=None, recursive=None):
if timeout == 1: if timeout == 2.0:
raise urllib3.exceptions.TimeoutError raise etcd.EtcdWatchTimedOut
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:
return etcd.EtcdResult('set', {'value': 'postgresql1', 'modifiedIndex': index + 1})
elif index == 20731:
return etcd.EtcdResult('set', {'value': 'postgresql2', 'modifiedIndex': index + 1})
def etcd_write(key, value, **kwargs): def etcd_write(self, key, value, **kwargs):
if key == '/service/exists/leader': if key == '/service/exists/leader':
raise etcd.EtcdAlreadyExist raise etcd.EtcdAlreadyExist
if key == '/service/test/leader': if key in ['/service/test/leader', '/patroni/test/leader'] and \
if kwargs.get('prevValue', None) == 'foo' or not kwargs.get('prevExist', True): (kwargs.get('prevValue') == 'foo' or not kwargs.get('prevExist', True)):
return True return True
raise etcd.EtcdException raise etcd.EtcdException
def etcd_delete(key, **kwargs): def etcd_read(self, key, **kwargs):
raise etcd.EtcdException
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/config", "value": '{"foo": "bar"}',
"modifiedIndex": 1582, "createdIndex": 1582},
{"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,37 +93,31 @@ 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) result = etcd.EtcdResult(**response)
result.etcd_index = 0
return result
def time_sleep(_):
pass
class SleepException(Exception): class SleepException(Exception):
pass pass
def time_sleep_exception(_): class MockSRV(object):
raise SleepException()
class MockSRV:
port = 2380 port = 2380
target = '127.0.0.1' target = '127.0.0.1'
def dns_query(name, type): def dns_query(name, _):
if name == '_etcd-server._tcp.blabla': if name == '_etcd-server._tcp.blabla':
return [] return []
elif name == '_etcd-server._tcp.exception': elif name == '_etcd-server._tcp.exception':
@@ -151,93 +128,105 @@ 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):
if url == 'http://localhost:2379/timeout':
raise ReadTimeoutError(None, None, None)
if url == 'http://localhost:2379/v2/machines':
ret = MockResponse()
ret.content = 'http://localhost:2379,http://localhost:4001'
return ret
if url == 'http://localhost:2379/': if url == 'http://localhost:2379/':
return MockResponse() return MockResponse()
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):
with patch.object(Client, 'machines') as mock_machines:
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:
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', 'retry_timeout': 3})
self.client.http.request = http_request self.client.http.request = http_request
self.client.http.request_encode_body = http_request
def test_api_execute(self): def test_machines(self):
self.client._base_uri = 'http://localhost:4001' self.client._base_uri = 'http://localhost:4001'
self.client._machines_cache = ['http://localhost:2379'] self.client._machines_cache = ['http://localhost:2379']
self.client.api_execute('/', 'GET') self.assertIsNotNone(self.client.machines)
self.client._base_uri = 'http://localhost:4001'
self.client._machines_cache = []
self.assertIsNotNone(self.client.machines)
self.client._update_machines_cache = True
machines = None
try:
machines = self.client.machines
self.assertFail()
except Exception:
self.assertIsNone(machines)
@patch.object(Client, 'machines')
def test_api_execute(self, mock_machines):
mock_machines.__get__ = Mock(return_value=['http://localhost:2379'])
self.assertRaises(ValueError, self.client.api_execute, '', '')
self.client._base_uri = 'http://localhost:4001'
self.client._machines_cache = ['http://localhost:2379']
self.client.api_execute('/', 'POST', timeout=0)
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', '')
self.client._update_machines_cache = True
with patch.object(Client, '_load_machines_cache', Mock(side_effect=etcd.EtcdException)):
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', 'GET')
def test_get_srv_record(self): def test_get_srv_record(self):
self.assertEquals(self.client.get_srv_record('blabla'), []) self.assertEquals(self.client.get_srv_record('blabla'), [])
self.assertEquals(self.client.get_srv_record('exception'), []) self.assertEquals(self.client.get_srv_record('exception'), [])
def test__result_from_response(self):
response = MockResponse()
response.content = 'TimeoutError'
self.assertRaises(urllib3.exceptions.TimeoutError, self.client._result_from_response, response)
response.content = 'Exception'
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
response.content = b'{}'
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
def test__get_machines_cache_from_srv(self): def test__get_machines_cache_from_srv(self):
self.client.get_srv_record = 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): @patch.object(Client, 'machines')
def test__load_machines_cache(self, mock_machines):
mock_machines.__get__ = Mock(return_value=['http://localhost:2379'])
self.client._config = {} self.client._config = {}
self.assertRaises(Exception, self.client._load_machines_cache) self.assertRaises(Exception, self.client._load_machines_cache)
self.client._config = {'discovery_srv': 'blabla'} self.client._config = {'discovery_srv': 'blabla'}
self.assertRaises(etcd.EtcdException, self.client._load_machines_cache) self.assertRaises(etcd.EtcdException, self.client._load_machines_cache)
@patch('requests.get', requests_get)
@patch.object(etcd.Client, 'write', etcd_write)
@patch.object(etcd.Client, 'read', etcd_read)
@patch.object(etcd.Client, 'delete', Mock(side_effect=etcd.EtcdException))
class TestEtcd(unittest.TestCase): class TestEtcd(unittest.TestCase):
def __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({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
self.etcd.client.write = etcd_write 'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
self.etcd.client.read = etcd_read
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(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', 'retry_timeout': 10})
def test_get_cluster(self): def test_get_cluster(self):
self.assertIsInstance(self.etcd.get_cluster(), Cluster) self.assertIsInstance(self.etcd.get_cluster(), Cluster)
@@ -245,11 +234,8 @@ class TestEtcd(unittest.TestCase):
cluster = self.etcd.get_cluster() cluster = self.etcd.get_cluster()
self.assertIsInstance(cluster, Cluster) self.assertIsInstance(cluster, Cluster)
self.assertIsNone(cluster.leader) self.assertIsNone(cluster.leader)
def test_current_leader(self):
self.assertIsInstance(self.etcd.current_leader(), Leader)
self.etcd._base_path = '/service/noleader' self.etcd._base_path = '/service/noleader'
self.assertIsNone(self.etcd.current_leader()) self.assertRaises(EtcdError, self.etcd.get_cluster)
def test_touch_member(self): def test_touch_member(self):
self.assertFalse(self.etcd.touch_member('', '')) self.assertFalse(self.etcd.touch_member('', ''))
@@ -257,31 +243,43 @@ 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_write_leader_optime(self):
self.etcd.write_leader_optime('0')
def test_update_leader(self): def test_update_leader(self):
self.assertTrue(self.etcd.update_leader(MockPostgresql())) self.assertTrue(self.etcd.update_leader())
def test_initialize(self): def test_initialize(self):
self.assertFalse(self.etcd.initialize()) self.assertFalse(self.etcd.initialize())
def test_cancel_initializion(self): def test_cancel_initializion(self):
self.etcd.client.delete = etcd_delete
self.assertFalse(self.etcd.cancel_initialization()) 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_delete_cluster(self):
self.assertFalse(self.etcd.delete_cluster())
@patch.object(etcd.Client, 'watch', etcd_watch)
def test_watch(self): def test_watch(self):
self.etcd.client.watch = etcd_watch self.etcd.watch(0)
self.etcd.watch(100)
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) with patch.object(AbstractDCS, 'watch', Mock()):
self.etcd.watch(100) self.etcd.watch(9.5)
def test_other_exceptions(self):
self.etcd.retry = Mock(side_effect=AttributeError('foo'))
self.assertRaises(EtcdError, self.etcd.cancel_initialization)
def test_set_ttl(self):
self.etcd.set_ttl(20)
self.assertTrue(self.etcd.watch(1))
+31
View File
@@ -0,0 +1,31 @@
import unittest
from mock import Mock, patch
from patroni.dcs.exhibitor import ExhibitorEnsembleProvider, Exhibitor
from patroni.dcs.zookeeper import ZooKeeperError
from test_etcd import SleepException, requests_get
from test_zookeeper import MockKazooClient
@patch('requests.get', requests_get)
@patch('time.sleep', Mock(side_effect=SleepException))
class TestExhibitorEnsembleProvider(unittest.TestCase):
def test_init(self):
self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181)
def test_poll(self):
self.assertFalse(ExhibitorEnsembleProvider(['exhibitor'], 8181).poll())
class TestExhibitor(unittest.TestCase):
@patch('requests.get', requests_get)
@patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient)
def setUp(self):
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
@patch.object(ExhibitorEnsembleProvider, 'poll', Mock(return_value=True))
def test_get_cluster(self):
self.assertRaises(ZooKeeperError, self.e.get_cluster)
+433 -72
View File
@@ -1,10 +1,17 @@
import datetime
import etcd
import os
import pytz
import unittest import unittest
from mock import Mock, patch from mock import Mock, MagicMock, PropertyMock, patch
from patroni.dcs import Cluster, DCSError from patroni.config import Config
from patroni.etcd import Client, Etcd from patroni.dcs import Cluster, Failover, Leader, Member, get_dcs
from patroni.dcs.etcd import Client
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 patroni.postgresql import Postgresql
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
def true(*args, **kwargs): def true(*args, **kwargs):
@@ -15,86 +22,164 @@ def false(*args, **kwargs):
return False return False
class MockPostgresql: def get_cluster(initialize, leader, members, failover):
return Cluster(initialize, None, leader, 10, members, failover)
def __init__(self):
self.name = 'postgresql0'
self.is_promoted = False
def is_healthy(self):
return True
def write_recovery_conf(self, _):
return True
def start(self):
return True
def is_healthiest_node(self, members):
return True
def is_leader(self):
return True
def promote(self):
return True
def demote(self, _):
return True
def follow_the_leader(self, _):
return True
def create_replication_slots(self, _):
return True
def last_operation(self):
return 0
def nop(*args, **kwargs): def get_cluster_not_initialized_without_leader():
pass return get_cluster(None, None, [], None)
def dead_etcd(): def get_cluster_initialized_without_leader(leader=False, failover=None):
raise DCSError('Etcd is not responding properly') m1 = 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, m1) if leader else None
m2 = Member(0, 'other', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
'api_url': 'http://127.0.0.1:8011/patroni',
'tags': {'clonefrom': True},
'scheduled_restart': {'schedule': "2100-01-01 10:53:07.560445+00:00",
'postgres_version': '99.0.0'}})
return get_cluster(True, l, [m1, m2], failover)
def get_unlocked_cluster(): def get_cluster_initialized_with_leader(failover=None):
return Cluster(False, None, 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)
future_restart_time = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=5)
postmaster_start_time = datetime.datetime.now(pytz.utc)
class MockPatroni(object):
def __init__(self, p, d):
os.environ[Config.PATRONI_CONFIG_VARIABLE] = """
restapi:
listen: 0.0.0.0:8008
bootstrap:
users:
replicator:
password: rep-pass
options:
- replication
postgresql:
name: foo
data_dir: data/postgresql0
pg_rewind:
username: postgres
password: postgres
zookeeper:
exhibitor:
hosts: [localhost]
port: 8181
"""
self.config = Config()
self.postgresql = p
self.dcs = d
self.api = Mock()
self.tags = {'foo': 'bar'}
self.nofailover = None
self.replicatefrom = None
self.api.connection_string = 'http://127.0.0.1:8008'
self.clonefrom = None
self.scheduled_restart = {'schedule': future_restart_time,
'postmaster_start_time': str(postmaster_start_time)}
def run_async(self, func, args=()):
return func(*args) if args else func()
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
@patch.object(Postgresql, 'is_leader', Mock(return_value=True))
@patch.object(Postgresql, 'xlog_position', Mock(return_value=0))
@patch.object(Postgresql, 'call_nowait', Mock(return_value=True))
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database system identifier': '1234567890'}))
@patch.object(Postgresql, 'sync_replication_slots', Mock())
@patch.object(Postgresql, 'write_pg_hba', Mock())
@patch.object(Postgresql, 'write_pgpass', Mock())
@patch.object(Postgresql, 'write_recovery_conf', Mock())
@patch.object(Postgresql, 'query', Mock())
@patch.object(Postgresql, 'checkpoint', Mock())
@patch.object(etcd.Client, 'write', etcd_write)
@patch.object(etcd.Client, 'read', etcd_read)
@patch.object(etcd.Client, 'delete', Mock(side_effect=etcd.EtcdException))
@patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=False))
@patch('patroni.async_executor.AsyncExecutor.run_async', run_async)
@patch('subprocess.call', Mock(return_value=0))
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(etcd.Client, 'read', etcd_read)
super(TestHa, self).__init__(method_name) def setUp(self):
def set_up(self):
self.p = MockPostgresql()
with patch.object(Client, 'machines') as mock_machines: with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379']) mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.e = Etcd('foo', {'ttl': 30, 'host': 'remotehost:2379', 'scope': 'test'}) self.p = Postgresql({'name': 'postgresql0', 'scope': 'dummy', 'listen': '127.0.0.1:5432',
self.e.client.read = etcd_read 'data_dir': 'data/postgresql0', 'retry_timeout': 10,
self.e.client.write = etcd_write 'authentication': {'superuser': {'username': 'foo', 'password': 'bar'},
self.ha = Ha(self.p, self.e) 'replication': {'username': '', 'password': ''}},
self.ha.load_cluster_from_dcs() 'parameters': {'wal_level': 'hot_standby', 'max_replication_slots': 5, 'foo': 'bar',
self.ha.cluster = get_unlocked_cluster() 'hot_standby': 'on', 'max_wal_senders': 5, 'wal_keep_segments': 8}})
self.ha.load_cluster_from_dcs = nop self.p.set_state('running')
self.p.set_role('replica')
self.p.postmaster_start_time = MagicMock(return_value=str(postmaster_start_time))
self.p.check_replication_lag = true
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=False)
self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'scope': 'test',
'name': 'foo', 'retry_timeout': 10}})
self.ha = Ha(MockPatroni(self.p, self.e))
self.ha.old_cluster = self.e.get_cluster()
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.ha.load_cluster_from_dcs = Mock()
def test_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(), 'starting 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.is_running = false
self.p.follow = false
self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
self.assertEquals(self.ha.run_cycle(), 'failed to start postgres')
def test_recover_master_failed(self):
self.p.follow = false
self.p.is_healthy = false
self.p.is_running = false
self.p.name = 'leader'
self.p.set_role('master')
self.p.controldata = lambda: {'Database cluster state': 'in production'}
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEquals(self.ha.run_cycle(), 'starting as readonly because i had the session lock')
def test_do_not_recover_in_pause(self):
pass
@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 = false
self.p.is_leader = self.p.is_healthy = false self.p.is_healthy = true
self.ha.has_lock = true self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock') self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
@@ -102,24 +187,26 @@ 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')
def test_demote_after_failing_to_obtain_lock(self): def test_demote_after_failing_to_obtain_lock(self):
self.ha.acquire_lock = false self.ha.acquire_lock = false
self.assertEquals(self.ha.run_cycle(), 'demoted self due after trying and failing to obtain lock') self.assertEquals(self.ha.run_cycle(), 'demoted self after trying and failing to obtain lock')
def test_follow_new_leader_after_failing_to_obtain_lock(self): def test_follow_new_leader_after_failing_to_obtain_lock(self):
self.ha.is_healthiest_node = true
self.ha.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 +225,285 @@ 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_follow_the_leader(self): 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(self):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.p.is_leader = false self.p.is_leader = false
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader') self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
self.ha.patroni.replicatefrom = "foo"
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
def test_follow_in_pause(self):
self.ha.cluster.is_unlocked = false
self.ha.is_paused = true
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
self.p.is_leader = false
self.assertEquals(self.ha.run_cycle(), 'PAUSE: no action')
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_another_member(self):
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from replica \'other\'')
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_without_leader(self):
self.ha.cluster = get_cluster_initialized_without_leader()
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap (without leader)')
def test_bootstrap_initialize_lock_failed(self):
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.assertIsNotNone(self.ha.reinitialize())
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertIsNone(self.ha.reinitialize())
self.assertIsNotNone(self.ha.reinitialize())
self.ha.state_handler.name = self.ha.cluster.leader.name
self.assertIsNotNone(self.ha.reinitialize())
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.cluster = get_cluster_initialized_with_leader()
self.ha.reinitialize()
self.assertEquals(self.ha.restart(), (False, 'reinitialize already in progress'))
with patch.object(self.ha, "restart_matches", return_value=False):
self.assertEquals(self.ha.restart({'foo': 'bar'}), (False, "restart conditions are not satisfied"))
def test_restart_in_progress(self):
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
self.ha.restart(run_async=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)
@patch('time.sleep', Mock())
def test_manual_failover_from_leader(self):
self.ha.has_lock = true
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
f = Failover(0, self.p.name, '', None)
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', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
# Failover scheduled time must include timezone
scheduled = datetime.datetime.now()
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.ha.run_cycle()
scheduled = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=30)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=-600)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
scheduled = None
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
@patch('requests.get', requests_get)
def test_manual_failover_from_leader_in_pause(self):
self.ha.has_lock = true
self.ha.is_paused = true
scheduled = datetime.datetime.now()
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEquals('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
self.assertEquals('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
@patch('requests.get', requests_get)
@patch('time.sleep', Mock())
def test_manual_failover_process_no_leader(self):
self.p.is_leader = false
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader', None))
self.p.set_role('replica')
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
self.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, self.p.name, '', None))
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.p.set_role('replica')
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
# set failover flag to True for all members of the cluster
# this should elect the current member, as we are not going to call the API for it.
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'}) # accessible, in_recovery
self.p.set_role('replica')
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
# same as previous, but set the current member to nofailover. In no case it should be elected as a leader
self.ha.patroni.nofailover = True
self.assertEquals(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
@patch('time.sleep', Mock())
def test_manual_failover_process_no_leader_in_pause(self):
self.ha.is_paused = true
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None))
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'blabla', None))
self.assertEquals('PAUSE: acquired session lock as a leader', self.ha.run_cycle())
self.p.is_leader = false
self.p.set_role('replica')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'PAUSE: promoted self to leader by acquiring session lock')
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())
self.ha.is_paused = true
self.assertFalse(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)
def test_post_recover(self):
self.p.is_running = false
self.ha.has_lock = true
self.assertEqual(self.ha.post_recover(), 'removed leader key after trying and failing to start postgres')
self.ha.has_lock = false
self.assertEqual(self.ha.post_recover(), 'failed to start postgres')
self.p.is_running = true
self.assertIsNone(self.ha.post_recover())
def test_schedule_future_restart(self):
self.ha.patroni.scheduled_restart = {}
# do the restart 2 times. The first one should succeed, the second one should fail
self.assertTrue(self.ha.schedule_future_restart({'schedule': future_restart_time}))
self.assertFalse(self.ha.schedule_future_restart({'schedule': future_restart_time}))
def test_delete_future_restarts(self):
self.ha.delete_future_restart()
def test_evaluate_scheduled_restart(self):
self.p.postmaster_start_time = Mock(return_value=str(postmaster_start_time))
# restart while the postmaster has been already restarted, fails
with patch.object(self.ha,
'future_restart_scheduled',
Mock(return_value={'postmaster_start_time':
str(postmaster_start_time - datetime.timedelta(days=1)),
'schedule': str(future_restart_time)})):
self.assertIsNone(self.ha.evaluate_scheduled_restart())
with patch.object(self.ha,
'future_restart_scheduled',
Mock(return_value={'postmaster_start_time': str(postmaster_start_time),
'schedule': str(future_restart_time)})):
with patch.object(self.ha,
'should_run_scheduled_action', Mock(return_value=True)):
# restart in the future, ok
self.assertIsNotNone(self.ha.evaluate_scheduled_restart())
with patch.object(self.ha, 'restart', Mock(return_value=(False, "Test"))):
# restart in the future, bit the actual restart failed
self.assertIsNone(self.ha.evaluate_scheduled_restart())
def test_scheduled_restart(self):
self.ha.cluster = get_cluster_initialized_with_leader()
with patch.object(self.ha, "evaluate_scheduled_restart", Mock(return_value="restart scheduled")):
self.assertEquals(self.ha.run_cycle(), "restart scheduled")
def test_restart_matches(self):
self.p._role = 'replica'
self.p.server_version = 90500
self.p._pending_restart = True
self.assertFalse(self.ha.restart_matches("master", "9.5.0", True))
self.assertFalse(self.ha.restart_matches("replica", "9.4.3", True))
self.p._pending_restart = False
self.assertFalse(self.ha.restart_matches("replica", "9.5.2", True))
self.assertTrue(self.ha.restart_matches("replica", "9.5.2", False))
def test_process_healthy_cluster_in_pause(self):
self.p.is_leader = false
self.ha.is_paused = true
self.p.name = 'leader'
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEquals(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running as master')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
self.assertEquals(self.ha.run_cycle(), 'PAUSE: waiting to become master after promote...')
def test_postgres_unhealthy_in_pause(self):
self.ha.is_paused = true
self.p.is_healthy = false
self.assertEquals(self.ha.run_cycle(), 'PAUSE: postgres is not running')
self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running')
def test_no_etcd_connection_in_pause(self):
self.ha.is_paused = true
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
self.assertEquals(self.ha.run_cycle(), 'PAUSE: DCS is not accessible')
+77 -170
View File
@@ -1,200 +1,107 @@
import datetime import etcd
import patroni.zookeeper
import psycopg2
import subprocess
import sys import sys
import time import time
import unittest import unittest
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, Leader from patroni.async_executor import AsyncExecutor
from patroni.etcd import Etcd from patroni.dcs.etcd import Client
from patroni.exceptions import PostgresException from patroni.exceptions import DCSError
from patroni import Patroni, main from patroni import Patroni, main as _main
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 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
def nop(*args, **kwargs): class MockFrozenImporter(object):
pass
toc = set(['patroni.dcs.etcd'])
class SleepException(Exception):
pass
def time_sleep(*args):
raise SleepException()
class Mock_BaseServer__is_shut_down:
def set(self):
pass
def clear(self):
pass
def get_cluster(initialize, leader):
return Cluster(initialize, leader, None, None)
def get_cluster_not_initialized_without_leader():
return get_cluster(None, None)
def get_cluster_initialized_without_leader():
return get_cluster(True, None)
def get_cluster_not_initialized_with_leader():
return get_cluster(False, Leader(0, 0, 0,
Member(0, 'leader', 'postgres://replicator:[email protected]:5435/postgres',
None, None, 28)))
def get_cluster_initialized_with_leader():
return get_cluster(True, Leader(0, 0, 0,
Member(0, 'leader', 'postgres://replicator:[email protected]:5435/postgres',
None, None, 28)))
@patch('time.sleep', Mock())
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
@patch.object(Postgresql, 'write_pg_hba', Mock())
@patch.object(Postgresql, '_write_postgresql_conf', Mock())
@patch.object(Postgresql, 'write_recovery_conf', Mock())
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
@patch.object(AsyncExecutor, 'run', Mock())
@patch.object(etcd.Client, 'write', etcd_write)
@patch.object(etcd.Client, 'read', etcd_read)
class TestPatroni(unittest.TestCase): class TestPatroni(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch('pkgutil.get_importer', Mock(return_value=MockFrozenImporter()))
self.setUp = self.set_up @patch('sys.frozen', Mock(return_value=True), create=True)
self.tearDown = self.tear_down @patch.object(etcd.Client, 'read', etcd_read)
super(TestPatroni, self).__init__(method_name) def setUp(self):
RestApiServer._BaseServer__is_shut_down = Mock()
def set_up(self):
self.touched = False
self.init_cancelled = False
subprocess.call = subprocess_call
psycopg2.connect = psycopg2_connect
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:
config = yaml.load(f)
with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.p = Patroni(config)
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
def test_get_dcs(self):
patroni.zookeeper.KazooClient = MockKazooClient
self.assertIsInstance(self.p.get_dcs('', {'zookeeper': {'scope': '', 'hosts': ''}}), ZooKeeper)
self.assertRaises(Exception, self.p.get_dcs, '', {})
def test_patroni_main(self):
main()
sys.argv = ['patroni.py', 'postgres0.yml']
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://remotehost:2379']) mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
Patroni.initialize = nop sys.argv = ['patroni.py', 'postgres0.yml']
touch_member = Patroni.touch_member self.p = Patroni()
run = Patroni.run
Patroni.touch_member = self.touch_member @patch('patroni.dcs.AbstractDCS.get_cluster', Mock(side_effect=[None, DCSError('foo'), None]))
Patroni.run = time_sleep def test_load_dynamic_configuration(self):
self.p.config._dynamic_configuration = {}
self.p.load_dynamic_configuration()
self.p.load_dynamic_configuration()
Etcd.delete_leader = nop @patch('time.sleep', Mock(side_effect=SleepException))
@patch.object(etcd.Client, 'delete', Mock())
@patch.object(Client, 'machines')
def test_patroni_main(self, mock_machines):
with patch('subprocess.call', Mock(return_value=1)):
sys.argv = ['patroni.py', 'postgres0.yml']
self.assertRaises(SleepException, main) mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
with patch.object(Patroni, 'run', Mock(side_effect=SleepException)):
self.assertRaises(SleepException, _main)
with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())):
with patch('patroni.ha.Ha.is_paused', Mock(return_value=True)):
_main()
Patroni.run = run @patch('patroni.config.Config.save_cache', Mock())
Patroni.touch_member = touch_member @patch('patroni.config.Config.reload_local_configuration', Mock(return_value=True))
def test_run(self):
def test_patroni_run(self): self.p.sighup_handler()
time.sleep = time_sleep self.p.ha.dcs.watch = Mock(side_effect=SleepException)
self.p.touch_member = self.touch_member self.p.api.start = Mock()
self.p.ha.state_handler.sync_replication_slots = time_sleep self.p.config._dynamic_configuration = {}
self.p.ha.dcs.client.read = etcd_read
self.p.ha.dcs.watch = time_sleep
self.assertRaises(SleepException, self.p.run)
self.p.ha.state_handler.is_leader = lambda: False
self.p.api.start = nop
self.assertRaises(SleepException, self.p.run) self.assertRaises(SleepException, self.p.run)
with patch('patroni.config.Config.set_dynamic_configuration', Mock(return_value=True)):
self.assertRaises(SleepException, self.p.run)
with patch('patroni.postgresql.Postgresql.data_directory_empty', Mock(return_value=False)):
self.assertRaises(SleepException, self.p.run)
def touch_member(self, ttl=None): def test_sigterm_handler(self):
if not self.touched: self.assertRaises(SystemExit, self.p.sigterm_handler)
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.ha.dcs.client.write = etcd_write
self.p.ha.dcs.client.read = etcd_read
self.p.touch_member = self.touch_member
self.p.postgresql.data_directory_empty = true
self.p.ha.dcs.initialize = true
self.p.postgresql.initialize = true
self.p.postgresql.start = true
self.p.ha.dcs.get_cluster = get_cluster_not_initialized_without_leader
self.p.initialize()
self.p.ha.dcs.initialize = false
self.p.ha.dcs.get_cluster = get_cluster_initialized_with_leader
time.sleep = time_sleep
self.p.ha.dcs.client.read = etcd_read
self.p.initialize()
self.p.ha.dcs.get_cluster = get_cluster_initialized_without_leader
self.assertRaises(SleepException, self.p.initialize)
self.p.postgresql.data_directory_empty = false
self.p.initialize()
self.p.ha.dcs.get_cluster = get_cluster_not_initialized_with_leader
self.p.postgresql.data_directory_empty = true
self.p.initialize()
def test_schedule_next_run(self): def test_schedule_next_run(self):
self.p.next_run = time.time() - self.p.nap_time - 1 self.p.ha.dcs.watch = Mock(return_value=True)
self.p.schedule_next_run()
self.p.next_run = time.time() - self.p.dcs.loop_wait - 1
self.p.schedule_next_run() self.p.schedule_next_run()
def cancel_initialization(self): def test_noloadbalance(self):
self.init_cancelled = True self.p.tags['noloadbalance'] = True
self.assertTrue(self.p.noloadbalance)
def test_cleanup_on_initialization(self): def test_nofailover(self):
self.p.ha.dcs.client.write = etcd_write self.p.tags['nofailover'] = True
self.p.ha.dcs.client.read = etcd_read self.assertTrue(self.p.nofailover)
self.p.ha.dcs.get_cluster = get_cluster_not_initialized_without_leader self.p.tags['nofailover'] = None
self.p.touch_member = self.touch_member self.assertFalse(self.p.nofailover)
self.p.postgresql.data_directory_empty = true
self.p.ha.dcs.initialize = true
self.p.postgresql.initialize = true
self.p.postgresql.start = false
self.p.ha.dcs.cancel_initialization = self.cancel_initialization def test_replicatefrom(self):
self.assertRaises(PostgresException, self.p.initialize) self.assertIsNone(self.p.replicatefrom)
self.assertTrue(self.init_cancelled) self.p.tags['replicatefrom'] = 'foo'
self.assertEqual(self.p.replicatefrom, 'foo')
def test_reload_config(self):
self.p.reload_config()
self.p.get_tags = Mock(side_effect=Exception)
self.p.reload_config()
+460 -136
View File
@@ -1,226 +1,550 @@
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 subprocess
import unittest import unittest
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 test_ha import true, false from patroni.utils import RetryFailedError
from six.moves import builtins
from test_ha import false
def nop(*args, **kwargs): class MockCursor(object):
pass
def __init__(self, connection):
def subprocess_call(cmd, shell=False, env=None): self.connection = connection
return 0
def false(*args, **kwargs):
return False
class MockCursor:
def __init__(self):
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('WITH replication_info AS ('):
self.results = [('', True, '', '', '', False)] replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
'"state":"streaming","sync_state":"async","sync_priority":0}]'
self.results = [('', True, '', '', '', '', False, replication_info)]
elif sql.startswith('SELECT name, setting'):
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
('search_path', 'public', None, 'string', 'user'),
('port', '5433', None, 'integer', 'postmaster'),
('listen_addresses', '*', None, 'string', 'postmaster'),
('autovacuum', 'on', None, 'bool', 'sighup')]
else: else:
self.results = [( self.results = [(None, None, None, None, None, None, None, None, None, None)]
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)]
def fetchone(self): def fetchone(self):
return self.results[0] return self.results[0]
def close(self): def fetchall(self):
pass return self.results
def __iter__(self): def __iter__(self):
for i in self.results: for i in self.results:
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(object):
server_version = '99999'
autocommit = False
closed = 0
def cursor(self):
return MockCursor(self)
def __enter__(self):
return self
def __exit__(self, *args):
pass
@staticmethod
def close():
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(): def fake_listdir(path):
return False return ["a", "b", "c"] if path.endswith('pg_xlog/archive_status') else []
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
class TestPostgresql(unittest.TestCase): class TestPostgresql(unittest.TestCase):
_PARAMETERS = {'wal_level': 'hot_standby', 'max_replication_slots': 5, 'f.oo': 'bar',
'search_path': 'public', 'hot_standby': 'on', 'max_wal_senders': 5,
'wal_keep_segments': 8, 'wal_log_hints': 'on', 'max_locks_per_transaction': 64,
'max_worker_processes': 8, 'max_connections': 100, 'max_prepared_transactions': 0}
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 @patch('os.rename', Mock())
super(TestPostgresql, self).__init__(method_name) @patch.object(Postgresql, 'get_major_version', Mock(return_value=9.4))
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
def set_up(self): def setUp(self):
subprocess.call = subprocess_call self.data_dir = 'data/test0'
shutil.copy = nop if not os.path.exists(self.data_dir):
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0', os.makedirs(self.data_dir)
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': self.data_dir, 'retry_timeout': 10,
'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'], 'authentication': {'superuser': {'username': 'test', 'password': 'test'},
'superuser': {'password': ''}, 'replication': {'username': 'replicator', 'password': 'rep-pass'}},
'admin': {'username': 'admin', 'password': 'admin'}, 'remove_data_directory_on_rewind_failure': True,
'replication': {'username': 'replicator', 'use_pg_rewind': True, 'pg_ctl_timeout': 'bla',
'password': 'rep-pass', 'parameters': self._PARAMETERS,
'network': '127.0.0.1/32'}, 'recovery_conf': {'foo': 'bar'},
'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'},
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'callbacks': {'on_start': 'true', 'on_stop': 'true',
'on_restart': 'true', 'on_role_change': 'true', 'on_restart': 'true', 'on_role_change': 'true',
'on_reload': 'true' 'on_reload': 'true'
}, },
'restore': 'true'}) 'restore': 'true'})
psycopg2.connect = psycopg2_connect self.leadermem = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
if not os.path.exists(self.p.data_dir): self.leader = Leader(-1, 28, self.leadermem)
os.makedirs(self.p.data_dir) self.other = Member(0, 'test-1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
self.leadermem = Member(0, 'leader', 'postgres://replicator:[email protected]:5435/postgres', None, None, 28) 'tags': {'replicatefrom': 'leader'}})
self.leader = Leader(-1, None, 28, self.leadermem) self.me = Member(0, 'test0', 28, {'conn_url': 'postgres://replicator:[email protected]:5434/postgres'})
self.other = Member(0, 'test1', 'postgres://replicator:[email protected]:5433/postgres', None, None, 28)
self.me = Member(0, 'test0', 'postgres://replicator:[email protected]:5434/postgres', None, None, 28)
def tear_down(self): def tearDown(self):
shutil.rmtree('data') shutil.rmtree('data')
def mock_query(self, p): def test_get_initdb_options(self):
raise psycopg2.OperationalError("not supported") self.assertEquals(self.p.get_initdb_options([{'encoding': 'UTF8'}, 'data-checksums']),
['--encoding=UTF8', '--data-checksums'])
self.assertRaises(Exception, self.p.get_initdb_options, [{'pgdata': 'bar'}])
self.assertRaises(Exception, self.p.get_initdb_options, [{'foo': 'bar', 1: 2}])
self.assertRaises(Exception, self.p.get_initdb_options, [1])
def test_data_directory_empty(self): @patch('os.path.exists', Mock(return_value=True))
self.assertTrue(self.p.data_directory_empty()) @patch('os.unlink', Mock())
def test_delete_trigger_file(self):
self.p.delete_trigger_file()
def test_initialize(self): @patch.object(Postgresql, 'is_running')
self.assertTrue(self.p.initialize()) def test_start(self, mock_is_running):
self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf'))) mock_is_running.return_value = True
def test_start_stop(self):
self.assertFalse(self.p.start())
self.p.is_running = is_running
with open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w'):
pass
self.assertTrue(self.p.start()) self.assertTrue(self.p.start())
mock_is_running.return_value = False
open(os.path.join(self.data_dir, 'postmaster.pid'), 'w').close()
pg_conf = os.path.join(self.data_dir, 'postgresql.conf')
open(pg_conf, 'w').close()
self.assertTrue(self.p.start())
with open(pg_conf) as f:
lines = f.readlines()
self.assertTrue("f.oo = 'bar'\n" in lines)
@patch.object(Postgresql, 'is_running')
def test_stop(self, mock_is_running):
mock_is_running.return_value = True
self.assertTrue(self.p.stop()) self.assertTrue(self.p.stop())
with patch('subprocess.call', Mock(return_value=1)):
mock_is_running.return_value = False
self.assertTrue(self.p.stop())
def test_sync_from_leader(self): def test_restart(self):
self.assertTrue(self.p.sync_from_leader(self.leader)) self.p.start = false
self.assertFalse(self.p.restart())
self.assertEquals(self.p.state, 'restart failed (restarting)')
def test_follow_the_leader(self): @patch.object(builtins, 'open', MagicMock())
self.p.demote(self.leader) def test_write_pgpass(self):
self.p.follow_the_leader(None) self.p.write_pgpass({'host': 'localhost', 'port': '5432', 'user': 'foo', 'password': 'bar'})
self.p.demote(self.leader)
self.p.follow_the_leader(self.leader)
self.p.follow_the_leader(Leader(-1, None, 28, self.other))
def test_create_connection_users(self): def test_checkpoint(self):
cfg = self.p.config with patch.object(MockCursor, 'fetchone', Mock(return_value=(True, ))):
cfg['superuser']['username'] = 'test' self.assertEquals(self.p.checkpoint({'user': 'postgres'}), 'is_in_recovery=true')
p = Postgresql(cfg) with patch.object(MockCursor, 'execute', Mock(return_value=None)):
p.create_connection_users() self.assertIsNone(self.p.checkpoint())
self.assertEquals(self.p.checkpoint(), 'not accessible or not healty')
def test_create_replication_slots(self): @patch('subprocess.call', side_effect=OSError)
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
def test_pg_rewind(self, mock_call):
r = {'user': '', 'host': '', 'port': '', 'database': '', 'password': ''}
self.assertTrue(self.p.rewind(r))
subprocess.call = mock_call
self.assertFalse(self.p.rewind(r))
@patch('os.unlink', Mock(return_value=True))
@patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
@patch.object(Postgresql, 'remove_data_directory', Mock(return_value=True))
@patch.object(Postgresql, 'single_user_mode', Mock(return_value=1))
@patch.object(Postgresql, 'write_pgpass', Mock(return_value={}))
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
@patch.object(Postgresql, 'can_rewind', PropertyMock(return_value=True))
@patch.object(Postgresql, 'rewind', return_value=False)
def test_follow(self, mock_pg_rewind):
with patch.object(Postgresql, 'check_recovery_conf', Mock(return_value=True)):
self.assertTrue(self.p.follow(None, None)) # nothing to do, recovery.conf has good primary_conninfo
self.p.follow(self.me, self.me) # follow is called when the node is holding leader lock
with patch.object(Postgresql, 'restart', Mock(return_value=False)):
self.p.set_role('replica')
self.p.follow(None, None) # restart without rewind
with patch.object(Postgresql, 'stop', Mock(return_value=False)):
self.p.follow(self.leader, self.leader, need_rewind=True) # failed to stop postgres
self.p.follow(self.leader, self.leader) # "leader" is not accessible or is_in_recovery
with patch.object(Postgresql, 'checkpoint', Mock(return_value=None)):
self.p.follow(self.leader, self.leader)
mock_pg_rewind.return_value = True
self.p.follow(self.leader, self.leader, need_rewind=True)
self.p.follow(None, None) # check_recovery_conf...
@patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
def test_can_rewind(self):
with patch('subprocess.call', MagicMock(return_value=1)):
self.assertFalse(self.p.can_rewind)
with patch('subprocess.call', side_effect=OSError):
self.assertFalse(self.p.can_rewind)
with patch.object(Postgresql, 'controldata', Mock(return_value={'wal_log_hints setting': 'on'})):
self.assertTrue(self.p.can_rewind)
self.p.config['use_pg_rewind'] = False
self.assertFalse(self.p.can_rewind)
@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)
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
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, None, self.leader, 0, [self.me, self.other, self.leadermem], None)
self.p.create_replication_slots(cluster) with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
self.p.sync_replication_slots(cluster)
self.p.sync_replication_slots(cluster)
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
self.p.sync_replication_slots(cluster)
with mock.patch('patroni.postgresql.logger.error', new_callable=Mock()) as errorlog_mock:
self.p.query = Mock()
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
cluster.members.extend([alias1, alias2])
self.p.sync_replication_slots(cluster)
errorlog_mock.assert_called_once()
assert "test-3" in errorlog_mock.call_args[0][1]
assert "test.3" in errorlog_mock.call_args[0][1]
@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): @patch.object(Postgresql, 'is_running')
def test_is_healthy(self, mock_is_running):
mock_is_running.return_value = True
self.assertTrue(self.p.is_healthy()) self.assertTrue(self.p.is_healthy())
self.p.is_running = is_running mock_is_running.return_value = 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('os.path.isfile', Mock(return_value=True))
@patch('os.kill', Mock(side_effect=Exception))
@patch('os.getpid', Mock(return_value=2))
@patch('os.getppid', Mock(return_value=2))
@patch.object(builtins, 'open', mock_open(read_data='-1'))
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
def test_is_running(self):
self.assertFalse(self.p.is_running())
@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'))
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
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): def test_move_data_directory(self):
self.p.is_running = is_running
os.rename = nop
os.path.isdir = true
self.p.move_data_directory() self.p.move_data_directory()
with patch('os.rename', Mock(side_effect=OSError)):
self.p.move_data_directory()
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
def test_bootstrap(self):
with patch('subprocess.call', Mock(return_value=1)):
self.assertRaises(PostgresException, self.p.bootstrap, {})
self.p.bootstrap({'users': {'replicator': {'password': 'rep-pass', 'options': ['replication']}},
'pg_hba': ['host replication replicator 127.0.0.1/32 md5',
'hostssl all all 0.0.0.0/0 md5',
'host all all 0.0.0.0/0 md5']})
with open(os.path.join(self.data_dir, 'pg_hba.conf')) as f:
lines = f.readlines()
assert 'host replication replicator 127.0.0.1/32 md5\n' in lines
assert 'host all all 0.0.0.0/0 md5\n' in lines
@patch('patroni.postgresql.Postgresql.create_replica', Mock(return_value=0))
def test_clone(self):
self.p.clone(self.leader)
@patch('os.listdir', Mock(return_value=['recovery.conf']))
@patch('os.path.exists', Mock(return_value=True))
def test_get_postgres_role_from_data_directory(self):
self.assertEquals(self.p.get_postgres_role_from_data_directory(), 'replica')
def test_remove_data_directory(self):
self.p.remove_data_directory()
open(self.data_dir, 'w').close()
self.p.remove_data_directory()
os.symlink('unexisting', self.data_dir)
with patch('os.unlink', Mock(side_effect=OSError)):
self.p.remove_data_directory()
self.p.remove_data_directory()
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
def test_controldata(self):
with patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string)):
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)
with patch('subprocess.check_output', Mock(side_effect=subprocess.CalledProcessError(1, ''))):
self.assertEquals(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
data = self.p.read_postmaster_opts()
self.assertEqual(data, dict())
@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.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.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)
@patch('os.listdir', MagicMock(side_effect=fake_listdir))
@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.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 = OSError
mock_link.side_effect = OSError
self.p.cleanup_archive_status()
mock_unlink.assert_not_called()
mock_remove.assert_not_called()
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
def test_sysid(self):
self.assertEqual(self.p.sysid, "6200971513092291716")
@patch('os.path.isfile', Mock(return_value=True))
@patch('shutil.copy', Mock(side_effect=IOError))
def test_save_configuration_files(self):
self.p.save_configuration_files()
@patch('os.path.isfile', Mock(side_effect=[False, True]))
@patch('shutil.copy', Mock(side_effect=IOError))
def test_restore_configuration_files(self):
self.p.restore_configuration_files()
def test_can_create_replica_without_replication_connection(self):
self.p.config['create_replica_method'] = []
self.assertFalse(self.p.can_create_replica_without_replication_connection())
self.p.config['create_replica_method'] = ['wale', 'basebackup']
self.p.config['wale'] = {'command': 'foo', 'no_master': 1}
self.assertTrue(self.p.can_create_replica_without_replication_connection())
def test_replica_method_can_work_without_replication_connection(self):
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('basebackup'))
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('foobar'))
self.p.config['foo'] = {'command': 'bar', 'no_master': 1}
self.assertTrue(self.p.replica_method_can_work_without_replication_connection('foo'))
self.p.config['foo'] = {'command': 'bar'}
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('foo'))
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
def test_reload_config(self):
parameters = self._PARAMETERS.copy()
parameters.pop('f.oo')
self.p.reload_config({'retry_timeout': 10, 'listen': '*', 'parameters': parameters})
parameters['b.ar'] = 'bar'
self.p.reload_config({'retry_timeout': 10, 'listen': '*', 'parameters': parameters})
parameters['autovacuum'] = 'on'
self.p.reload_config({'retry_timeout': 10, 'listen': '*', 'parameters': parameters})
parameters['autovacuum'] = 'off'
parameters.pop('search_path')
self.p.reload_config({'retry_timeout': 10, 'listen': '*:5433', 'parameters': parameters})
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
def test_get_major_version(self):
with patch.object(builtins, 'open', mock_open(read_data='9.4')):
self.assertEquals(self.p.get_major_version(), 9.4)
with patch.object(builtins, 'open', Mock(side_effect=Exception)):
self.assertEquals(self.p.get_major_version(), 0.0)
def test_postmaster_start_time(self):
with patch.object(MockCursor, "fetchone", Mock(return_value=('foo', True, '', '', '', '', False))):
self.assertEqual(self.p.postmaster_start_time(), 'foo')
with patch.object(MockCursor, "execute", side_effect=psycopg2.Error):
self.assertIsNone(self.p.postmaster_start_time())
-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)
+22 -53
View File
@@ -1,17 +1,8 @@
import os
import time
import unittest import unittest
from patroni.exceptions import DCSError from mock import Mock, patch
from patroni.utils import Retry, RetryFailedError, reap_children, sigchld_handler, sigterm_handler, sleep from patroni.exceptions import PatroniException
from patroni.utils import reap_children, Retry, RetryFailedError, sigchld_handler, sleep
def nop(*args, **kwargs):
pass
def os_waitpid(a, b):
return (0, 0)
def time_sleep(_): def time_sleep(_):
@@ -20,38 +11,23 @@ def time_sleep(_):
class TestUtils(unittest.TestCase): class TestUtils(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch('time.sleep', Mock())
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):
self.assertRaises(SystemExit, sigterm_handler, None, None)
def test_reap_children(self): def test_reap_children(self):
reap_children() self.assertIsNone(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() self.assertIsNone(reap_children())
@patch('time.sleep', time_sleep)
def test_sleep(self): def test_sleep(self):
time.sleep = time_sleep self.assertIsNone(sleep(0.01))
sleep(0.01)
@patch('time.sleep', Mock())
class TestRetrySleeper(unittest.TestCase): class TestRetrySleeper(unittest.TestCase):
def _pass(self): @staticmethod
pass def _fail(times=1):
def _fail(self, times=1):
scope = dict(times=0) scope = dict(times=0)
def inner(): def inner():
@@ -59,43 +35,36 @@ 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):
return Retry(*args, **kwargs)
def test_reset(self): def test_reset(self):
retry = self._makeOne(delay=0, max_tries=2) retry = Retry(delay=0, max_tries=2)
retry(self._fail()) retry(self._fail())
self.assertEquals(retry._attempts, 1) self.assertEquals(retry._attempts, 1)
retry.reset() retry.reset()
self.assertEquals(retry._attempts, 0) self.assertEquals(retry._attempts, 0)
def test_too_many_tries(self): def test_too_many_tries(self):
retry = self._makeOne(delay=0) retry = Retry(delay=0)
self.assertRaises(RetryFailedError, retry, self._fail(times=999)) self.assertRaises(RetryFailedError, retry, self._fail(times=999))
self.assertEquals(retry._attempts, 1) self.assertEquals(retry._attempts, 1)
def test_maximum_delay(self): def test_maximum_delay(self):
def sleep_func(_time): retry = Retry(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 = Retry(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):
retry = self._makeOne(sleep_func=_sleep) pass
retry = Retry(sleep_func=_sleep)
rcopy = retry.copy() rcopy = retry.copy()
self.assertTrue(rcopy.sleep_func is _sleep) self.assertTrue(rcopy.sleep_func is _sleep)
+62
View File
@@ -0,0 +1,62 @@
import psycopg2
import subprocess
import unittest
from mock import MagicMock, patch, PropertyMock
from patroni.scripts.wale_restore import WALERestore, main as _main
wale_output = b'name last_modified expanded_size_bytes wal_segment_backup_start ' +\
b'wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop\n' +\
b'base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 ' +\
b'00000001000000000000007F 00000040 00000001000000000000007F 00000240\n'
@patch('os.access', MagicMock(return_value=True))
@patch('os.makedirs', MagicMock(return_value=True))
@patch('os.path.exists', MagicMock(return_value=True))
@patch('os.path.isdir', MagicMock(return_value=True))
@patch('psycopg2.extensions.cursor', MagicMock(autospec=True))
@patch('psycopg2.extensions.connection', MagicMock(autospec=True))
@patch('psycopg2.connect', MagicMock(autospec=True))
@patch('subprocess.check_output', MagicMock(return_value=wale_output))
class TestWALERestore(unittest.TestCase):
def setUp(self):
self.wale_restore = WALERestore("batman", "/data", "host=batman port=5432 user=batman", "/etc", 100, 100, 1, 0)
def test_should_use_s3_to_create_replica(self):
with patch('psycopg2.connect', MagicMock(side_effect=psycopg2.Error("foo"))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output', MagicMock(side_effect=subprocess.CalledProcessError(1, "cmd", "foo"))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output', MagicMock(return_value=wale_output.split(b'\n')[0])):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output',
MagicMock(return_value=wale_output.replace(b' wal_segment_offset_backup_stop', b''))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('subprocess.check_output',
MagicMock(return_value=wale_output.replace(b'expanded_size_bytes', b'expanded_size_foo'))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
self.wale_restore.should_use_s3_to_create_replica()
self.wale_restore.no_master = 1
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
def test_create_replica_with_s3(self):
with patch('subprocess.call', MagicMock(return_value=0)):
self.assertEqual(self.wale_restore.create_replica_with_s3(), 0)
with patch('subprocess.call', MagicMock(side_effect=Exception("foo"))):
self.assertEqual(self.wale_restore.create_replica_with_s3(), 1)
def test_run(self):
with patch.object(self.wale_restore, 'init_error', PropertyMock(return_value=True)):
self.assertEqual(self.wale_restore.run(), 2)
with patch.object(self.wale_restore, 'should_use_s3_to_create_replica', MagicMock(return_value=True)):
with patch.object(self.wale_restore, 'create_replica_with_s3', MagicMock(return_value=0)):
self.assertEqual(self.wale_restore.run(), 0)
@patch('sys.exit', MagicMock())
@patch.object(WALERestore, 'run', MagicMock(return_value=0))
def test_main(self):
self.assertEqual(_main(), None)
+122 -88
View File
@@ -1,141 +1,134 @@
import patroni.zookeeper import six
import requests
import unittest import unittest
from patroni.dcs import Leader
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.handlers.threading import SequentialThreadingHandler
from kazoo.protocol.states import ZnodeStat from kazoo.protocol.states import ZnodeStat
from test_etcd import MockPostgresql, requests_get from mock import Mock, patch
from patroni.dcs.zookeeper import Leader, PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
class MockEvent: class MockKazooClient(Mock):
def clear(self): leader = False
pass exists = True
def set(self): def __init__(self, *args, **kwargs):
pass super(MockKazooClient, self).__init__()
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): @staticmethod
pass def retry(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 not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)")
if path == '/no_node': if path == '/no_node':
raise NoNodeError raise NoNodeError
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'):
return '1'
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'): elif path.endswith('/initialize'):
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))
return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
def get_children(self, path, watch=None, include_data=False): @staticmethod
if path == '/no_node': def get_children(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 raise NoNodeError
elif path in ['/service/bla/', '/service/test/']: elif path in ['/service/bla/', '/service/test/']:
return ['initialize', 'leader', 'members', 'optime'] 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 value == b'Exception':
raise Exception
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 create_async(self, path, value=b"", acl=None, ephemeral=False, sequence=False, makepath=False):
return self.create(path, value, acl, ephemeral, sequence, makepath) or Mock()
@staticmethod
def set(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' and value == b'retry':
return
if path in ('/service/test/failover', '/service/test/config'):
if value == b'Exception':
raise Exception
elif value == b'ok':
return
raise NoNodeError raise NoNodeError
def set_async(self, path, value, version=-1):
return self.set(path, value, version) or Mock()
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:
return
self.leader = True self.leader = True
raise Exception raise Exception
elif path.endswith('/initialize'): elif path == '/service/test/members/buzz':
raise Exception
elif path.endswith('/') or path.endswith('/initialize') or path == '/service/test/members/bar':
raise NoNodeError raise NoNodeError
def set_hosts(self, hosts, randomize_hosts=None): def delete_async(self, path, version=-1, recursive=False):
pass return self.delete(path, version, recursive) or Mock()
def exhibitor_sleep(_): class TestPatroniSequentialThreadingHandler(unittest.TestCase):
raise SleepException
def setUp(self):
self.handler = PatroniSequentialThreadingHandler(10)
class TestExhibitorEnsembleProvider(unittest.TestCase): @patch.object(SequentialThreadingHandler, 'create_connection', Mock())
def test_create_connection(self):
def __init__(self, method_name='runTest'): self.assertIsNotNone(self.handler.create_connection(()))
self.setUp = self.set_up self.assertIsNotNone(self.handler.create_connection((), 40))
super(TestExhibitorEnsembleProvider, self).__init__(method_name)
def set_up(self):
requests.get = requests_get
patroni.zookeeper.sleep = exhibitor_sleep
def test_init(self):
self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181)
class TestZooKeeper(unittest.TestCase): class TestZooKeeper(unittest.TestCase):
def __init__(self, method_name='runTest'): @patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient)
self.setUp = self.set_up def setUp(self):
super(TestZooKeeper, self).__init__(method_name) self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10})
def set_up(self):
requests.get = requests_get
patroni.zookeeper.KazooClient = MockKazooClient
self.zk = ZooKeeper('foo', {'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181}, 'scope': 'test'})
def test_session_listener(self): def test_session_listener(self):
self.zk.session_listener(KazooState.SUSPENDED) self.zk.session_listener(KazooState.SUSPENDED)
def test_reload_config(self):
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 10})
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 5})
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'))
@@ -145,14 +138,27 @@ class TestZooKeeper(unittest.TestCase):
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)
self.zk.exhibitor.poll = lambda: True
cluster = self.zk.get_cluster() cluster = self.zk.get_cluster()
self.assertIsInstance(cluster.leader, Leader) self.assertIsInstance(cluster.leader, Leader)
self.zk.touch_member('foo') self.zk.touch_member('foo')
self.zk.delete_leader()
def test_delete_leader(self):
self.assertTrue(self.zk.delete_leader())
def test_set_failover_value(self):
self.zk.set_failover_value('')
self.zk.set_failover_value('ok')
self.zk.set_failover_value('Exception')
def test_set_config_value(self):
self.zk.set_config_value('')
self.zk.set_config_value('ok')
self.zk.set_config_value('Exception')
def test_initialize(self): def test_initialize(self):
self.assertFalse(self.zk.initialize()) self.assertFalse(self.zk.initialize())
@@ -161,19 +167,47 @@ class TestZooKeeper(unittest.TestCase):
self.zk.cancel_initialization() 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.touch_member('exists') self.zk._name = 'bar'
self.zk.touch_member('new')
self.zk._name = 'na'
self.zk._client.exists = 1
self.zk.touch_member('Exception')
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()
with patch.object(MockKazooClient, 'create', Mock(side_effect=Exception)):
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')
with patch.object(MockKazooClient, 'create_async', Mock()):
self.zk.write_leader_optime('1')
with patch.object(MockKazooClient, 'set_async', Mock()):
self.zk.write_leader_optime('2')
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('3')
self.assertTrue(self.zk.update_leader(MockPostgresql()))
def test_delete_cluster(self):
self.assertTrue(self.zk.delete_cluster())
def test_watch(self): def test_watch(self):
self.zk.watch(0) self.zk.watch(0)
self.zk.event.isSet = lambda: True
self.zk.watch(0)
def test__kazoo_connect(self):
self.zk._client._retry.deadline = 1
self.zk._orig_kazoo_connect = Mock(return_value=(0, 0))
self.zk._kazoo_connect(None, None)