Compare commits

..
759 Commits
Author SHA1 Message Date
Alexander KukushkinandGitHub 08d6e5e50e BUGFIX: don't leak password when running pg_rewind (#1321)
In addition to that:
* enforce security settings from `postgresql.authention`
* update release notes
* bump version
* close https://github.com/zalando/patroni/issues/1320
2019-12-05 18:19:38 +01:00
Alexander KukushkinandGitHub b542e4b5f0 Release 1.6.2 (#1319)
* update release notes
* bump version
2019-12-05 11:36:17 +01:00
Alexander KukushkinandGitHub 0693fe7dd0 Housekeeping (#1315)
* Reduce memory usage by patroni init process
* More cleanup in setup.py
* Implement missing tests
2019-12-04 11:28:46 +01:00
Igor YanchenkoandAlexander Kukushkin 49d3968c23 Make it possible to configure log level for exception tracebacks (#1311)
If you set `log.traceback_level=DEBUG`, the tracebacks will be visible only when `log.level=DEBUG`. The default behavior remains the same.
2019-12-03 15:13:42 +01:00
Alexander KukushkinandGitHub f1819443ef Avoid spawning semaphore tracker process (#1299)
We are not using semaphores, therefore we don't need to track them.
2019-12-02 12:16:18 +01:00
Igor YanchenkoandAlexander Kukushkin cf0c0f8e7c Make the error more helpful if restapi cannot bind (#1300)
Giving the user a hint if we couldn't start the restapi service.
2019-12-02 12:15:45 +01:00
Alexander KukushkinandGitHub e1d569ad75 Inherit CaseInsensitiveDict from urllib3 HTTPHeaderDict (#1302)
It might look like a hack, but the API is stable enough and didn't change in the past 3+ years.
2019-12-02 12:14:59 +01:00
Igor YanchenkoandAlexander Kukushkin 726ee46111 Implemented patroni --version (#1291)
That required a refactoring of `Config` and `Patroni` classes. Now one has to explicitely create the instance of `Config` before creating `Patroni`.

The Config file can optionally call the validate function.
2019-12-02 12:14:19 +01:00
Alexander KukushkinandGitHub cc0df4900b Set User-Agent for all http requests (#1312)
Example: `Patroni/1.6.1 Python/3.6.8 Linux`
2019-12-02 10:46:20 +01:00
Igor YanchenkoandAlexander Kukushkin 638aa63023 Don't make user to choose from an empty list (#1305)
If a user provides a wrong cluster name, we will raise an exception rather than ask to choose a member from an empty list.
2019-12-02 10:38:35 +01:00
Alexander KukushkinandGitHub a5ff38a034 Improve behave tests (#1313)
Hopefully, make them less flaky
2019-12-02 10:33:44 +01:00
Alexander KukushkinandGitHub a3be2958a7 Tidy up setup.py (#1308)
1. Stop using `setuptools.command.test`, it is being deprecated
2. Remove junit integration
2019-11-27 15:56:50 +01:00
Alexander KukushkinandGitHub 85341ff78b Use passfile in primary_conninfo only on 10+ (#1301)
somehow passfile happened to work on ubuntu with older postgres versions, but it is not always the case for other distros.
2019-11-27 14:58:23 +01:00
Alexander KukushkinandGitHub 7793887ea7 Fix tests on windows (#1303)
and disable junit, it produces a deprecation warning
2019-11-27 14:57:33 +01:00
Alexander KukushkinandGitHub 525a26fab5 Solve the problem of cyclic imports (#1306)
Move `PATRONI_ENV_PREFIX` into the `patroni/__init__.py`
2019-11-26 17:03:34 +01:00
Alexander KukushkinandGitHub 90a4208390 Get rid from requests module (#1296)
It wasn't used for anything critical anyway, so it doesn't make a lot of sense to keep it as an explicit dependency.
2019-11-22 15:31:55 +01:00
Alexander KukushkinandGitHub f03b85f5b0 Fix calculation of wal_buffes (#1297)
Close https://github.com/zalando/patroni/issues/1288
2019-11-22 15:30:28 +01:00
Alexander KukushkinandGitHub 474ac3cc11 Move multiprocessing.set_start_method() back to main (#1295)
It is not possible to call it from forked process
2019-11-21 17:28:14 +01:00
Alexander KukushkinandGitHub 412c720d3a Avoid importing all DCS modules (#1286)
We will try to import only the module which has a configuration section.
I.e. if there is only zookeeper section in the config, Patroni will try to import only `patroni.dcs.zookeeper` and skip `etcd`, `consul`, and `kubernetes`.
This approach has two benefits:
1. When there are no dependencies installed Patroni was showing INFO messages `Failed to import smth`, which looks scary.
2. It reduces memory usage, because sometimes dependencies are heavy.
2019-11-21 14:39:37 +01:00
Igor YanchenkoandAlexander Kukushkin cd96a10dd2 Provide an example of using multiple etcd endpoints in yaml files (#1289) 2019-11-21 13:29:05 +01:00
Alexander KukushkinandGitHub 183adb7848 Housekeeping (#1284)
* Implement proper tests for `multiprocessing.set_start_method()`
* Exclude some watchdog code from coverage (it is used only for behave tests)
* properly use os.path.join for windows compatibility
* import DCS modules in `features/environment.py` on demand. It allows to run behave tests against chosen DCS without installing all dependencies.
* remove some unused behave code
* fix some minor issues in the dcs.kubernetes module
2019-11-21 13:27:55 +01:00
Igor YanchenkoandAlexander Kukushkin 8b26733f6a striping extra spaces if etcd.hosts is written as comma separated string (#1290)
Patroni was failing to connect to the etcd server if config looks like following:
```yaml
etcd:
    hosts: host1:port1, host2:port2
```
2019-11-21 10:50:14 +01:00
Alexander KukushkinandGitHub 35a2ccf8a8 A couple of small fixes in docs (#1285)
* fix formatting in release notes
* fix patronictl reinit command name
2019-11-21 10:39:28 +01:00
Alexander KukushkinandGitHub 2f9a48fae4 Release 1.6.1 (#1281)
* Bump version to 1.6.1
* Update release notes
2019-11-15 12:48:00 +01:00
Maciej KowalczykandAlexander Kukushkin efcd05ace2 Use "spawn" multiprocessing start method (#1279)
workaround https://bugs.python.org/issue6721

Fixes #1278
2019-11-15 10:56:18 +01:00
Alexander KukushkinandGitHub 66d77697ae Use LIST + WATCH when working with K8s API (#1276)
There is an opinion that LIST requests with labelSelector to K8s API are expensive and Patroni was doing two such requests per HA loop (LIST pods and LIST endpoints/configmaps).
To efficiently detect object changes we will switch to the LIST+WATCH approach.
The initial LIST request populates the ObjectCache and events from the WATCH request update it.

In addition to that, the ObjectCache will be updated after performing the UPDATE operations on the K8s objects. To avoid race conditions, all operations on ObjectCache are performed after comparing the resource_version of the old and the new objects and rejected if the new resource_version value is smaller than the old one.

The disadvantage of such an approach is that it will require keeping three connections to the K8s API from each Patroni Pod (previously it was two).

Yesterday I deployed this feature branch on our biggest K8s cluster, with ~300 Patroni pods.
The CPU Utilization on K8s master nodes immediately dropped from ~20% to ~10% (two times), and the incoming traffic on master nodes dropped ~7-8 times!

Last, but not least, we get more or less the same impact on etcd cluster behind K8s master nodes, the CPU Utilization dropped nearly twice and outgoing traffic ~7-8 times.
2019-11-14 14:54:57 +01:00
Alexander KukushkinandGitHub c1adbafbc5 Improve documentation (#1244)
* document tags
* move dynamic configuration out of `bootstrap.dcs`
* document REST API endpoints
2019-11-13 16:10:28 +01:00
Alexander KukushkinandGitHub 252a1b78ed Make it possible to change use_slots online (#1261)
Previously it required restarting Patroni and removing slots manually
Fixes https://github.com/zalando/patroni/issues/1158
2019-11-11 16:18:53 +01:00
Alexander KukushkinandGitHub 5ea73d50ed Make it possible to apply some recovery params without restart (#1260)
Starting from PostgreSQL 12 the following recovery parameters could be changed without restart, but Patroni didn't yet support it:
* archive_cleanup_command
* promote_trigger_file
* recovery_end_command
* recovery_min_apply_delay

In future postgres releases this list will be extended and Patroni will support it automatically.
2019-11-11 16:18:23 +01:00
Alexander KukushkinandGitHub 09a7cf265d Fix 'start failed' issue (#1262)
The start of postgres happens in two stages:
1. First Patroni is waiting for postgres port to be open
2. After that, it is waiting for postgres starts to accept connections

There is a default timeout 60 seconds for both stages (in total).

When the port isn't open, pg_isready exits with code=2.
If postgres is rejecting connections due to recovery, exit code=1.

In most cases postgres quickly opens the port and pg_isready starts returning 1, but in rare cases the whole timeout could spend in `1.`
After that, the HA loop is still waiting for postgres to start, but executing only the check from `2.`. Since pg_isready exit code is still = 2, Patroni was falsely assuming that 'start failed' without taking into consideration the fact that the postmaster process is up and running.

Fixes https://github.com/zalando/patroni/issues/1160
2019-11-11 09:37:06 +01:00
Feike SteenbergenandHenning Jacobs d2d49907ad Correctly document PATRONI_KUBERNETES_PORTS (#1266)
The previous documentation was wrong and will throw the following error
when used:

        Exception when parsing list {[{"name": "postgresql", "port": 5432}]}

When removing the surrounding braces, the error goes away and the
endpoint is updated with the correct Port name.
2019-11-05 10:09:24 +01:00
Alexander KukushkinandGitHub 94b7ff656e Don't give up on retry too early (#1245)
Fixes https://github.com/zalando/patroni/issues/1195
2019-10-31 09:33:16 +01:00
Alexander KukushkinandGitHub 29ac77b6e7 Compare all recovery parameters (#1208)
Previously check_recovery_conf() function was only checking whether primary_conninfo has changed and never taking into account all other recovery parameters.

Fixes https://github.com/zalando/patroni/issues/1201
2019-10-30 12:30:09 +01:00
Alexander KukushkinandGitHub 9e87b00d36 Kill callback child processes when it is necessary (#1242)
Not doing so makes it hard to implement callbacks in bash and eventually can lead to the situation when two callbacks are running at the same time. In case if we failed to kill the child process we will still wait for it to finish.

The same problem could happen with custom bootstrap, therefore if we happen to kill the custom bootstrap process we also kill all child subprocesses.

Closes https://github.com/zalando/patroni/issues/1238
2019-10-29 12:44:18 +01:00
Alexander KukushkinandGitHub 3f711650a7 Fix compatibility with python 3.4&3.5 (#1248)
Close https://github.com/zalando/patroni/issues/1247
2019-10-25 15:01:49 +02:00
Alexander KukushkinandGitHub 2a9ef418d6 Return the real member name when picking the sync standby (#1253)
Before we returned it in the lower case, what was preventing such a standby from promoting due to the name comparison mismatch.

Fixes https://github.com/zalando/patroni/issues/1252
2019-10-25 14:53:05 +02:00
Alexander KukushkinandGitHub 6fe482a4c8 Avoid calling expensive os.listdir() (#1254)
When the system is under IO stress, `os.listdir()` could take a few seconds (or even minutes) to execute what is badly affecting the HA loop of Patroni and could even cause the leader key to disappear from DCS due to the lack of updates.

There is a better and less expensive way to check that the PGDATA is not empty. Instead of doing the `os.listdir` we simply check the presence of the `global/pg_control` file in it.
2019-10-25 14:52:13 +02:00
Alexander KukushkinandGitHub 828585079f Improve workflow when PGDATA is not empty during bootstrap (#1217)
Recently it has happened two times when people tried to deploy the new cluster but postgres data directory wasn't empty and also wasn't valid. In this case Patroni was still creating initialize key in DCS and trying to start the postgres up.
Now it will complain about non-empty invalid postgres data directory and exit.

Close https://github.com/zalando/patroni/issues/1216
2019-10-25 14:09:44 +02:00
Alexander KukushkinandGitHub 0947ac1e43 Fix race condition in postmaster_start_time() (#1243)
when it is executed not from the main thread we need to create a new cursor object.
2019-10-24 11:23:34 +02:00
Cody CoonsandAlexander Kukushkin d770c910fd Remove only PATRONI_ prefixed environment variables (#1224)
it will solve a lot of problems with running different FDW
2019-10-24 08:39:21 +02:00
cobolbabyandAlexander Kukushkin 732d33812f Add net-tools and iputils-ping to the docker image (#1230)
they might be useful.
2019-10-24 08:36:50 +02:00
Alexander KukushkinandGitHub 78a3848e73 Retry on raft internal error (#1241)
Fixes https://github.com/zalando/patroni/issues/1237
2019-10-22 17:20:06 +02:00
Alexander KukushkinandGitHub 367d787ff9 Implement /history and /cluster endpoints (#1191)
The /history endpoint shows the content of the `history` key in DCS
The /cluster endpoint show all cluster members and some service info like pending and scheduled restarts or switchovers.

In addition to that implement `patronictl history`

Close #586
Close #675
Close #1133
2019-10-22 17:19:02 +02:00
Alexander KukushkinandGitHub f4623c4e8e Build recovery params in a separate method (#1219)
In addition to that try to protect from the case when some recovery parameters are set in one of included files by explicitly setting their value to an empty string on postgres 12.

Simplifies https://github.com/zalando/patroni/pull/1208
2019-10-11 20:18:06 +02:00
Alexander KukushkinandGitHub 863aed314b Fix race conditions in async actions (#1215)
Specifically, there was a chance that `patronictl reinit --force` was overwritten by recover and we end up in a situation when Patroni was trying to start the postgres while basebackup still running.
2019-10-11 10:17:02 +02:00
Alexander KukushkinandGitHub b666f5e4ed Refactor Patroni REST API communication (#1197)
* make it possible to use client certificates with REST API
* define a separate PatroniRequest class which handles all communication
* refactor patronictl to use the new class
* make Ha to use the new class instead of calling requests.get. The old call wasn't taking into account certificates and basic-auth

Close #898
2019-10-11 10:16:33 +02:00
Alexander KukushkinandGitHub 21ed8e2d09 A few small fixes (#1221)
* fix some warnings when running unit-tests
* allow python-kubernetes up to 10.0.1
* python-consul>=0.7.1 is required due to #802
2019-10-11 10:15:22 +02:00
Alexander KukushkinandGitHub c95275665f Functions for better parsing of primary_conninfo and recovery.conf (#1218)
Needed to simplify https://github.com/zalando/patroni/pull/1208
2019-10-10 16:00:55 +02:00
Alexander KukushkinandGitHub 3d29cb7e50 Perform pg_ctl reload regardless of config changes (#1204)
It is possible that some config files are not controlled by Patroni and when somebody is doing reload via REST API or by sending SIGHUP to Patroni process the usual expectation is that postgres will also be reloaded, but it didn't happen when there were no changes in the postgresql section of Patroni config.

For example one might replace ssl_cert_file and ssl_key_file on the filesystem and starting from PostgreSQL 10 it just requires a reload, but Patroni wasn't doing it.

In addition to that fix the issue with handling of `wal_buffers`. The default value depends on `shared_buffers` and `wal_segment_size` and therefore Patroni was exposing pending_restart when the new value in the config was explicitly set to -1 (default).

Close https://github.com/zalando/patroni/issues/1198
2019-10-10 14:49:30 +02:00
Alexander KukushkinandGitHub 1572c02ced Use passfile in the primary_conninfo instead of password (#1194)
Fixed a few minor issues related to the #1134 and #1122
Close https://github.com/zalando/patroni/issues/1185
2019-10-09 18:04:14 +02:00
Alexander KukushkinandGitHub 86ee22efab Switch to a streaming watcher (#1189)
Watch requests to K8s API either streaming the data or close connection by timeout. In any case it requires a second connection open, but opening a new connection every 10 seconds is more expensive for both, Patroni and K8s API.

Switching to the streaming model also brings other benefits: we can watch not only on leader object, but also on config and wake up Patroni main thread if the config was changed.
2019-10-07 15:16:35 +02:00
Alexander KukushkinandGitHub facee0186d Explicitly start logger Thread (#1186)
The PatroniLogger object is instantiated in the Patroni constructor and down the road there might be a fatal error causing Patroni process to exit, but live thread prevents the normal shutdown.
In order to mitigate the issue and don't loose ability to use the logging infrastructure we will switch to QueueLogger only when the thread was explicitly started from the Patroni.run() method.

Continuation of https://github.com/zalando/patroni/pull/1178
2019-10-07 11:00:38 +02:00
Alexander KukushkinandGitHub 686b2c5432 Fix memory leak on python 3.7 (#1200)
Close https://github.com/zalando/patroni/issues/1167
2019-10-07 10:55:26 +02:00
wilfriedrosetandAlexander Kukushkin ee678f61d7 Fix typos in documentation (#1202) 2019-10-07 10:34:43 +02:00
JechoandAlexander Kukushkin a8c32a4032 Fix minor typo in documentation #1212
Close #1211
2019-10-07 10:14:15 +02:00
geokalaandAlexander Kukushkin 178e565fe4 Update cacert documentation for use with REST API (#1190)
Fixes #1188
2019-09-24 13:04:07 +02:00
Alexander KukushkinandGitHub fa7eef3d7c Fix logger shutdown behavior (#1178)
Since it is based on Thread with daemon set to True, the shutdown of logger was very likely to happen too early, what was causing some lines not to appear at the destination.

Close https://github.com/zalando/patroni/issues/1173
2019-09-17 12:27:09 +02:00
Jonathan S. KatzandAlexander Kukushkin a88704e792 Allow for certificate-based authentication from Patroni PostgreSQL accounts (#1134)
The two principal features this introduces:

1. Provide the Patroni PostgreSQL management accounts (superuser, replication, rewind) to be able to authenticate using certificate-based authentication
2. Allow the user to specify the `sslmode` they wish to connect as.

### References
- [PostgreSQL Certificate Based Authentication](https://www.postgresql.org/docs/current/auth-cert.html)
- [libpq connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html) which are used by psycopg2
- [SSL Modes](https://www.postgresql.org/docs/current/libpq-ssl.html)
2019-09-17 12:14:49 +02:00
anikin-aaandAlexander Kukushkin 3937a8d4fc Fix status code for GET /replica, when replica is starting (#1152)
Close #772, #1128
2019-08-26 11:18:13 +02:00
SoulouandAlexander Kukushkin 53d32f1457 Allow lower values for postgresql configuration (#1148)
* Default values have not been changed
* These minimal values still work properly to boot a (small) cluster

Fixes #1142
2019-08-26 10:48:36 +02:00
Alexander KukushkinandGitHub 0a1d9b0a25 Get rid from distutils module dependency (#1146)
We are using only one function from there, `find_executable()` and it is better to implement a similar function in Patroni rather than add `distutils` module into requirements.txt
2019-08-26 09:38:47 +02:00
Alexander KukushkinandGitHub 3aa3bc3237 Pass statement_timeout=0 in PGOPTIONS when doing pg_rewind (#1155)
It might happen that statement_timeout on the server is set to some small value and one of the statements executed by pg_rewind is canceled.

I already proposed a patch fixing the pg_rewind itself, but it also would be good to have a workaround in Patroni.
2019-08-26 08:43:05 +02:00
msvechlaandAlexander Kukushkin 0d0c4c0a30 Add PATRONICTL_CONFIG_FILE Environment Variable (#1150)
add a `PATRONICTL_CONFIG_FILE` environment variable, which allows configuring the --config-file flag from the environment.
2019-08-26 08:42:24 +02:00
AlexanderandAlexander Kukushkin e9a5d25ef3 Synchronous commit is disabled for rewind user GRANTs (#1145)
SET local synchronous_commit = 'local' before running GRANT
2019-08-23 17:03:02 +02:00
Will ColtonandAlexander Kukushkin 0f7c8b7b09 Fix a command in the docker readme. (#1138)
Fixes #1139
2019-08-06 15:49:32 +02:00
Alexander KukushkinandGitHub 278bf9852b Release 1.6.0 (#1131)
* Implement missing tests and do a few minor fixes
* Bump version to 1.6.0
* Update release notes
2019-08-05 15:08:04 +02:00
Alexander KukushkinandGitHub 0b1b1e3b54 Compatibility with postgresql 12 (#1068)
* use `SHOW primary_conninfo` instead of parsing config file on pg12
* strip out standby and recovery parameters from postgresql.auto.conf before starting the postgres 12

Patroni config remains backward compatible.
Despite for example `restore_command` converted to a GUC starting from postgresql 12, in the Patroni configuration you can still keep it in the `postgresql.recovery_conf` section.
If you put it into `postgresql.parameters.restore_command`, that will also work, but it is important not to mix both ways:
```yaml
# is OK
postgresql:
  parameters:
    restore_command: my_restore_command
    archive_cleanup_command: my_archive_cleanup_command

# is OK
postgresql:
  recovery_conf:
    restore_command: my_restore_command
    archive_cleanup_command: my_archive_cleanup_command

# is NOT ok
postgresql:
  parameters:
    restore_command: my_restore_command
  recovery_conf:
    archive_cleanup_command: my_archive_cleanup_command
```
2019-08-02 16:00:55 +02:00
Alexander KukushkinandGitHub 4a24b79b73 IPv6 support (#1122)
fixes https://github.com/zalando/patroni/issues/1121
2019-08-02 11:34:29 +02:00
Rafia SabihandGitHub 5cc3afc037 Enhance dialogues for scheduled switchover and restart (#1119)
Enhance dialogue for switchover and restart

In case of schedule switchover or restart, mention time if any, when confirming.
2019-08-02 11:21:26 +02:00
Don SeilerandAlexander Kukushkin 5cb7d1bdc1 Grammar fixes for SETTINGS.rst (#1106) 2019-07-26 09:34:42 +02:00
Alexander KukushkinandGitHub 9a94c54cb0 Do a smart comparison of actual and desired primary_conninfo (#1089)
On every replica Patroni periodically opens the recovery.conf file and checks that it contains the correct primary_conninfo.
So far the correctness check was pretty dumb, it was basically doing a full match of strings. That could lead to the restart of the replica when Patroni is "joining" already running postgres process.
Instead of using string comparison we parse the actual primary_conninfo value from the file and check that all parameters are matching with the desired value.

In addition to that we stop reading and parsing recovery.conf of every iteration if the modification time didn't change.
2019-07-01 13:01:45 +02:00
Jan TomsaandAlexander Kukushkin 7d1a5cad03 Allow to specify consul consistency mode (#1094)
Allow users to specify consul consistency mode.
This option will be passed to the Consul client as kwargs https://github.com/zalando/patroni/blob/master/patroni/dcs/consul.py#L213.
The library will then enforce the selected consistency level https://python-consul.readthedocs.io/en/latest/#consul

More about consistency mode here https://www.consul.io/api/features/consistency.html
2019-07-01 11:02:26 +02:00
Jan TomsaandAlexander Kukushkin 1d3fd3ac0b Enable debug logging for GET/OPTIONS API calls together with latency 2019-07-01 10:50:39 +02:00
wilfriedrosetandAlexander Kukushkin 47c3dc2352 patronictl checks if --config value exists (#736) (#1092)
Be verbose about configuration file when the given filename does not exists instead of ignoring silently (which can lead to misunderstanding)
2019-06-27 14:58:07 +02:00
wilfriedrosetandAlexander Kukushkin 78999a2d62 Add fallback value for editor_cmd (#532) (#1091)
Fixes #532
2019-06-27 14:56:57 +02:00
Alexander KukushkinandGitHub 1a6db4f5af Reverse logic around checkpoint_after_promote (#1084)
It will be set to false in the JSON only until the checkpoint actually happened.

The next improvement of https://github.com/zalando/patroni/commit/bba9066315f8dda23e0a28909790a5a3d48d1842
2019-06-17 10:42:31 +02:00
Alexander KukushkinandGitHub fad6d26a3a Small refactoring of postgresql/bootstrap (#1086)
the main purpose of this PR is simplifying #1068

It is mostly necessary for future support of pg12, where there will be no recovery.conf anymore, but `keep_existing_recovery_conf` parameter still needs to be supported due to backward compatibility.
2019-06-17 10:41:13 +02:00
Alexander KukushkinandGitHub 37f03790cc Implement two-step logging (#1080)
A few times we observed that Patroni HA loop was blocked for a few minutes due to not being able to write logs to stderr. This is a very rare condition which we hit so far only on k8s. This commit makes Patroni resilient to such kind of problems. All log messages first are written into the in-memory queue and later they are asynchronously flushed into the stderr or file from a separate thread.

The maximum queue size is configurable and the default value is 1000. This should be enough to keep more than one hour of log messages with default settings and when Patroni cluster operates normally (without big issues).

In case if we hit the maximum size of the queue further logs will be discarded until the queue size will be reduced. The number of discarded messages will be reported into the log later.

In addition to that, the number of non-flushed and discarded messages (if there are any), will be reported via Patroni REST API as:
```json
"logger_queue_size": X,
"logger_records_lost": Y`
```
2019-06-13 14:18:49 +02:00
Alexander KukushkinandGitHub 83e62c2723 Simplify postgresql.reload_config (and fix some bugs) (#1087)
`compare_values` function is already smart enough to handle all tricky cases when the unit is not in `B` or `kB`, but also in blocks. Therefore we don't need to query `wal_segment_size` from pg_settings.
2019-06-13 14:01:37 +02:00
Daniel KuceraandAlexander Kukushkin 638b560cf8 log exceptions caught in Retry (#1081)
Log the final exception when either the number of attempts or the timeout were reached, it will hopefully help to debug some issues when communication to DCS fails
2019-06-11 15:27:14 +02:00
wilfriedrosetandAlexander Kukushkin 2384d9e735 Add API route /health (#1079)
close #119
2019-06-11 15:22:52 +02:00
Kostiantyn NemchenkoandAlexander Kukushkin dcd605ebc8 Update existing_data.rst (#1071) 2019-06-11 15:15:48 +02:00
Alexander KukushkinandGitHub 75926b442e Open trust in pg_hba.conf during bootstrap to localhost (#1075)
previously it was open by mistake to unix_socket only

Fixes https://github.com/zalando/patroni/issues/1072
2019-06-11 15:14:57 +02:00
Alexander KukushkinandGitHub 3b90cc1931 Fix some small issues in the Dockerfile (#1074)
* symlink ~/.config/patroni/patronictl.yaml
* comment out rewind section
2019-05-28 14:47:46 +02:00
Alexander KukushkinandGitHub b291291ff6 Make compare_values compatible with pg12 (#1061)
* integer variables could be specified as floats: work_mem = '30.1GB'
* memory-based units could be in B and MB
* time-based variables now allow microseconds (us)
* vacuum_cost_delay and autovacuum_vacuum_cost_delay converted to real with base unit ms
2019-05-21 16:20:22 +02:00
Alexander KukushkinandGitHub a4bd6a9b4b Refactor postgresql class (#1060)
* Convert postgresql.py into a package
* Factor out cancellable process into a separate class
* Factor out connection handler into a separate class
* Move postmaster into postgresql package
* Factor out pg_rewind into a separate class
* Factor out bootstrap into a separate class
* Factor out slots handler into a separate class
* Factor out postgresql config handler into a separate class
* Move callback_executor into postgresql package

This is just a careful refactoring, without code changes.
2019-05-21 16:02:47 +02:00
Alexander KukushkinandGitHub f1f2389146 A couple of small improvements in acceptance tests (#1057)
* Keep basebackup and wal_archive next to PGDATA in the data directory
* Test bootstrap of standby cluster nodes with custom scripts
2019-05-13 16:33:19 +02:00
Alexander KukushkinandGitHub e54dfa508d Consider sync node as a healthy even when the former leader is ahead (#1059)
Fixes https://github.com/zalando/patroni/issues/1054
2019-05-13 16:32:53 +02:00
Alexander KukushkinandGitHub 4b48653d09 More standby cluster bugfixes (#1053)
1. use the default port is 5432 when only standby_cluster.host is defined
2. check that standby_cluster replica can be bootstrapped without connection to the standby_cluster leader against `create_replica_methods` defined in the `standby_cluster` config instead of the `postgresql` section.
3. Don't fallback to the create_replica_methods defined in the `postgresql` section when bootstrapping a member of the standby cluster.
4. Make sure we specify the database when connecting to the leader.
2019-05-13 14:19:22 +02:00
Alexander KukushkinandGitHub c7db134a20 Make pyscopg2 version parsing more robust (#1052)
non-released version can have unparsable suffixes, for example 2.8.3.dev0
2019-05-13 14:18:56 +02:00
Alexander KukushkinandGitHub bba9066315 Make it possible to run pg_rewind without superuser on pg11+ (#1035)
* expose the current patroni version in DCS
* expose `checkpoint_after_promote` flag in DCS as an indicator that pg_rewind could be safely executed
* other nodes will wait until this flag is set instead of connecting as superuser and issuing the CHECKPOINT
* define `postgresql.authention.rewind` with credentials for pg_rewind in patroni configuration files.
* create user for pg_rewind if postgres is 11+
* grant execute on functions required for pg_rewind to rewind user
2019-05-02 14:07:26 +02:00
vilajitandAlexander Kukushkin f6d29081c9 Enabling kerberos support (#1015)
* make it possible to create users without passwords
* put `krbsrvname` into the connection string if it is specified in the config
* update postgres?.yml example files to mention `krbsrvname`
2019-04-29 09:02:04 +02:00
Alexander KukushkinandGitHub f0b784fe7f Manage pg_ident.conf with Patroni (#1037)
This functionality works similarly to the `pg_hba`:
If the `postgresql.pg_ident` is defined in the config file or DCS, Patroni will write its value to pg_ident.conf, however, if `postgresql.parameters.ident_file` is defined, Patroni will assume that pg_ident is managed from outside and not update the file.
2019-04-23 16:16:53 +02:00
Julien RiouandAlexander Kukushkin 663026c34c Use SSLContext to wrap REST API socket (#1039)
Using `ssl.wrap_socket` is deprecated and was still allowing soon-to-be-deprecated protocols like TLS 1.1.
Now using `SSLContext.create_default_context()` to produce a secure SSL context to wrap the REST API server's socket.
2019-04-23 11:23:22 +02:00
Alexander KukushkinandGitHub 51b085a76d Don't wait until the previous callback finish is kill failed (#1036)
Such wait was happening in the main thread and blocking HA loop.
After all the executor thread was doing absolutely the same.
2019-04-15 15:49:06 +02:00
Cameron DanielandAlexander Kukushkin 74893e7b84 Reload Consul config on SIGHUP (#1030)
It is especially useful when somebody is changing the value of `token`
2019-04-15 15:00:38 +02:00
Julien RiouandAlexander Kukushkin 0e0b364302 Add read-only and read-write API routes (#1038)
Patroni is great behind a load balancer like HAProxy. API routes are handy to direct the traffic to a valid node depending on its role. We could have one route for writes directed to the primary node and one route for reads directed to the replicas. In a two nodes setup, when we lose a node, there's one host left: the primary. Then, the read traffic will be dropped, even if the primary can handle it. This commit adds read-only and read-write routes. Read-only route enables reads on the primary. Read-write route is an alias for '/master', '/primary' or '/leader' route.
2019-04-15 14:49:54 +02:00
Alexander KukushkinandGitHub 51b02e65a5 Don't set archive_mode to off during the custom bootstrap (#1025)
As @ants complained on Slack, it breaks the workflow when the same location is used for bootstrap and further archiving.
For the pg_upgrade we can achieve the same results by modifying postgres config in the upgrade script, which is part of spilo.
2019-04-15 14:31:02 +02:00
Alexander KukushkinandGitHub 7c0c9599fc Remove psycopg2 from requirements (#1023)
Recently released psycopg2 split into two different packages, psycopg2, and psycopg2-binary which could be installed at the same time into the same place on the filesystem. In order to decrease dependency hell problem, we let a user choose how to install psycopg2. There are a few options available and it is reflected in the documentation.

This PR also changes the following behavior:
* `pip install patroni` will fail if psycopg2 is not installed
* Patroni will check psycopg2 upon start and fail if it can't be found or outdated.

Closes https://github.com/zalando/patroni/issues/1021
2019-04-15 14:30:16 +02:00
joe-tssandAlexander Kukushkin cf0d712450 Grant deletecollection to patroni serviceaccount (#1033)
Closes #1032
2019-04-11 15:17:06 +02:00
Sharoon ThomasandAlexander Kukushkin d418a9449e scheduled_at needs a value for manual_failover (#1024)
The variable scheduled_at may be undefined for a manual failover where there is an exception raised trying to switchover with REST API.
2019-04-08 12:35:15 +02:00
Alexander KukushkinandGitHub 6909ce0c7a Release 1.5.6 (#1020)
* Update release notes
* Bump version
2019-04-03 14:44:31 +02:00
Alexander KukushkinandGitHub cd9d9ca0c3 Make sure we don't enforce ssl_version (#1010)
Fixes https://github.com/zalando/patroni/issues/1009
2019-04-02 16:49:32 +02:00
Alexander KukushkinandGitHub a0a2da238e Couple of minor improvements (#1019)
1. Fix race condition on shutdown. It is very annoying when you cancel behave tests but postgres remains running.
2. Dump pg_controldata output to logs when "recovering" stopped postgres. It will help to investigate some annoying issues.
2019-04-02 16:49:21 +02:00
Pavlo GolubandAlexander Kukushkin b53a29c022 Fix unit-tests for Windows (#1014)
Closes #1013
2019-04-02 13:58:17 +02:00
Alexander KukushkinandGitHub e38fe78b56 Fix callbacks behavior (mostly for standby cluster) (#998)
First of all, this patch changes the behavior of `on_start`/`on_restart` callbacks, they will be called only when postgres is started or restarted without role changes. In case if the member is promoted or demoted only the `on_role_change` callback will be executed. `on_role_change` was never called for standby leader, only `on_start`/`on_restart` and with a wrong role argument.
Before that `on_role_change` was never called for standby leader, only `on_start`/`on_restart` and with a wrong role argument.

In addition to that, the REST API will return standby_leader role for the leader of the standby cluster.

Closes https://github.com/zalando/patroni/issues/988
2019-03-29 10:28:07 +01:00
Lukas VogelandAlexander Kukushkin e059e30560 Ingore VSCode files (#1007)
Fixes #1008
2019-03-22 16:26:37 -04:00
Alexander KukushkinandGitHub 680444ae13 Reduce lock time taken by dcs.get_cluster() (#989)
`dcs.cluster` and `dcs.get_cluster()` are using the same lock resource and therefore when get_cluster call is slow due to the slowness of DCS it was also affecting the `dcs.cluster` call, which in return was making health-check requests slow.
2019-03-12 22:37:11 +01:00
Alexander KukushkinandGitHub 92720882aa Reset is_leader flag for every removal of leader key (#990)
This is the next improvement of #777
2019-03-12 22:10:46 +01:00
Alexander KukushkinandGitHub 13c88e8b7a Replace self-execute with multiprocessing.Process (#994)
In addition to that transfer postmaster pid to Patroni process with the help of multiprocessing.Pipe instead of using stdin-stdout pipes.

Closes https://github.com/zalando/patroni/issues/992
2019-03-12 10:40:37 +01:00
Alexander KukushkinandGitHub 4a4258fc3f Mock external resources (#995)
unit tests should not accidentally hit running Postgres, DCS or filesystem unless we want it explicitly.
2019-03-12 10:39:42 +01:00
Ants AasmaandAlexander Kukushkin 2204454094 Remove unnecessary usage of relpath (#1002)
`os.path.relpath` depends on being able to resolve the working directory.
This will fail if Patroni is started in a directory that is later unlinked from the filesystem, creating an unnecessary exception when loading from DCS.
2019-03-11 12:31:06 +01:00
Alexander KukushkinandGitHub 9e19b43869 Rename cluster name to demo (#1000)
* Assign hostname to haproxy container
* Tune vim config
2019-03-11 10:57:26 +01:00
Julien TachoiresandAlexander Kukushkin 13f7aede61 Wait for callback end if it could not be killed (#985)
that could happen if the script is running under a sudo
2019-03-11 10:35:36 +01:00
Julien TachoiresandAlexander Kukushkin 8a7ba57457 Clean target directory when pg_wal|pg_xlog is a symlink (#997) 2019-03-11 10:33:46 +01:00
Alexander KukushkinandGitHub c64d51f79c Better support for static etcd cluster (#986)
if the `etcd.use_proxies` is set to true, Patroni will stick to the list of hosts specified in the `etcd.hosts` and avoid doing topology discovery. Such mode might be useful when you know that you connect to the etcd cluster via the set of proxies or when th etcd cluster has static topology.
2019-03-07 11:36:36 +01:00
Alexander KukushkinandGitHub f0990532dc Update docker-compose demo cluster (#980)
1. Multi-stage build with an extensive cleanup of useless files and optional image compression
2. Start three-node etcd cluster
3. Start three-node Patroni cluster
4. One container with haproxy
5. All container names are prefixed with "demo-" and don't have suffixes
6. Decommission dev_patroni_cluster.sh script, docker-compose is now standard de-facto.
7. Provide more examples in the docker/README.md
2019-03-07 11:32:03 +01:00
Alexander KukushkinandGitHub e670122c80 Use create_replica_methods from standby_cluster for replica bootstrap (#981)
It might happen that the standby cluster is configured to be created and replay WAL files from the different source than when it is running not in standby mode.  This is necessary to avoid writing WAL files and backups into the old place after promotion.

The easiest way to achieve such behavior is passing RemoteMember object to `Postgresql.clone` method instead of the usual Member object.
2019-02-21 11:37:50 +01:00
Alexander KukushkinandGitHub c6e70a9910 Release 1.5.5 (#979)
* Bump version
* Update release notes
2019-02-15 16:14:39 +01:00
Alexander KukushkinandGitHub 0ec1760397 Don't write primary_conninfo into recovery.conf for wal only standby cluster (#971)
It is useless and makes postgres to generate a lot of errors
2019-02-15 13:35:34 +01:00
Alexander KukushkinandGitHub 317721991b Fix handling of PATRONI_*_PASSWORD environment variables (#970)
Bug was introduced in https://github.com/zalando/patroni/pull/947 and https://github.com/zalando/patroni/pull/500
2019-02-15 13:35:22 +01:00
Alexander KukushkinandGitHub 0c516de147 Create headless service associated with $SCOPE-config endpoint (#958)
if there is no service defined k8s assumes that endpoint is orphaned and removes it.
Patroni tries to create the service only in case if use_endpoints is enabled if the following cases:
1. Upon start
2. When it tries to (re-)create the config endpoint

If for some reason creation of the service has failed, Patroni will retry it on every cycle of HA loop. Usually it fails due to lack of permissions and if you don't want to give such permissions to the service account used by Patroni, you can create the service explicitly in the deployment manifest.
2019-02-15 13:35:04 +01:00
Michael BanckandAlexander Kukushkin 073074f83e Run coverage as python -m coverage (#968)
Depending on the platform the coverage binary might not always be available under the standard name.
2019-02-13 16:02:12 +01:00
Michael BanckandAlexander Kukushkin 345e6d3131 Copy away output directories of failed acceptance tests. (#967)
And dump logs on travis from only failed features
2019-02-13 16:00:15 +01:00
Michael BanckandAlexander Kukushkin d01a9bdcd5 Change base port for acceptance tests from 5440 to 5360 (#966) 2019-02-13 15:59:13 +01:00
Alexander KukushkinandGitHub 10bbf0c3c5 Always use replication=1, otherwise it is considered a logical walsender (#952)
Fixes: https://github.com/zalando/patroni/issues/894
Fixes: https://github.com/zalando/patroni/issues/951
2019-01-30 12:38:51 +01:00
Alexander KukushkinandGitHub 4304560ce2 Adjust read timeout for leader watch blocking query (#950)
According to the Consul documentation the actual response timeout is increased by a small random amount of additional wait time added to the supplied maximum wait time to spread out the wake up time of any concurrent requests. It adds up to wait / 16 additional time to the maximum duration.
In our case we will add wait/15 or 1 second depending on what is bigger.

Fixes: https://github.com/zalando/patroni/issues/945
2019-01-30 12:38:24 +01:00
Alexander KukushkinandGitHub 254ee2acfc Show information about timelines in patronictl list (#949)
This information will help to detect stale replicas.

In addition to that Host will include ':{port}' if the port value isn't default or more than one member running on the same host.

Fixes: https://github.com/zalando/patroni/issues/942
2019-01-30 12:37:51 +01:00
Alexander KukushkinandGitHub 739329b590 Make it possible to automatically reinit the former master (#948)
If the pg_rewind is disabled or can't be used, the former master could fail to start as a new replica due to diverged timelines. In this case, the only way to fix it is wiping the data directory and reinitializing.

So far Patroni was able to remove the data directory only after failed attempt to run pg_rewind. This commit fixes it.
If the `postgresql.remove_data_directory_on_diverged_timelines` is set, Patroni will wipe the data directory and reinitialize the former master automatically.

Fixes: https://github.com/zalando/patroni/issues/941
2019-01-30 12:37:21 +01:00
Étienne MandAlexander Kukushkin bd2c54581a Add ETCD_(PROTOCOL|USERNAME|PASSWORD) env variables (#947)
Fix #944
2019-01-30 12:36:50 +01:00
Maxim IvanovandAlexander Kukushkin f0b12b7e2e Document create_replicas_methods in standby_cluster section (#939)
Fixes https://github.com/zalando/patroni/issues/935
2019-01-30 12:36:24 +01:00
Étienne MandAlexander Kukushkin 93d157dea3 Document how to start Patroni with an existing data directory (#918) 2019-01-30 12:35:57 +01:00
Alexander KukushkinandGitHub 2c128520cf Python34 compatibility (#933)
and some other minor fixes.

Closes https://github.com/zalando/patroni/issues/932
2019-01-16 14:40:05 +01:00
Alexander KukushkinandGitHub 381a5b80d2 Release 1.5.4 (#931)
* Bump version
* Update release notes
* Make it possible to configure registration of Service in Consul via env variables
2019-01-15 12:14:19 +01:00
Alexander KukushkinandGitHub 71dae6a905 Optionally consider node not healthy if it is not on the latest timeline (#892)
The latest timeline is calculated from the `/history` key in DCS. In case there is no such key or it contains some garbage we consider the node healthy.
Closes https://github.com/zalando/patroni/issues/890
2019-01-15 11:16:30 +01:00
Alexander KukushkinandGitHub cf34fb3934 Relax requirements on superuser credentials (#930)
libpq allows opening connection without explicitly specifying neither username nor password. Depending on situation it would rely either on `pgpass` file or trust authentication method in pg_hba.conf.

Since pg_rewind is also using libpq, it could work the same way.

Fixes https://github.com/zalando/patroni/issues/928
2019-01-15 11:15:35 +01:00
Alexander KukushkinandGitHub e080ded44b Make logging configurable via YAML file (#927)
It allows changing logging settings in runtime by updating config and doing reload or sending `SIGHUP` to the Patroni process.
Important! Environment configuration names related to logging were renamed and documentation accordingly updated. For compatibility reasons Patroni still accepts `PATRONI_LOGLEVEL` and `PATRONI_FORMAT`, but some other variables related to logging, which were introduced only
recently (between releases), will stop working. I think it is ok, since we didn't release the new version yet and therefore it is very unlikely that somebody is using them except authors of corresponding PRs.

Example of log section in the config file:
```yaml
log:
  dir: /where/to/write/patroni/logs  # if not specified, write logs to stderr
  file_size: 50000000  # 50MB
  file_num: 10  # keep history of 10 files
  dateformat: '%Y-%m-%d %H:%M:%S'
  loggers:  # increase log verbosity for etcd.client and urllib3
    etcd.client: DEBUG
    urllib3: DEBUG
```
2019-01-15 08:42:13 +01:00
jouirandAlexander Kukushkin dec3656f6e Redirect HTTPServer exceptions to logger (#900) (#925)
By default they were written to stdout
2019-01-15 08:37:06 +01:00
Alexander KukushkinandGitHub 1e2d89fa58 Apply 5 second backoff when loading global config up on start (#922)
It doesn't make much sense to hammer DCS when we just starting up.
Fixes https://github.com/zalando/patroni/issues/919
2019-01-14 14:55:56 +01:00
Alexander KukushkinandGitHub 994863c18d Refactor wait_for_user_backends_to_close method (#917)
1. Log only debug level messages on any kind of error
2. Update regexp for matching postgres aux processes to make it compatible with postgres 11

Fixes https://github.com/zalando/patroni/issues/914
2019-01-14 14:55:45 +01:00
Alexander KukushkinandGitHub 3fce982909 Set archive_mode to off during the custom bootstrap (#911)
We want to avoid archiving WALs and history files until the cluster is fully functional. It should really help if the custom bootstrap involves pg_upgrade.
2019-01-14 14:55:23 +01:00
Lucas CapistrantandAlexander Kukushkin d306092cbc Explicitly secure rw perms for recovery.conf at creation time (#910)
We don't want anybody except patroni/postgres user reading this file, it contains replication user and password.
2019-01-14 14:22:04 +01:00
Dmitry DolgovandAlexander Kukushkin 11f7ceb521 Do not check types of standby_cluster configuration (#924)
Simply allow valid keys
2019-01-14 14:16:15 +01:00
bradnicholsonandAlexander Kukushkin 05a13839aa Update replica_bootstrap.rst (#915)
Add some docs about replication slots for standby clusters
2019-01-14 12:57:21 +01:00
Étienne MandAlexander Kukushkin 04ac199fc8 Single quotes are mandatory around each host in PATRONI_ETCD_HOSTS (#926)
Otherwise YAML parser fails
2019-01-14 11:56:15 +01:00
anikin-aaandAlexander Kukushkin 0be8a9527b possibility to set logdatefmt via env. (#904)
The value could be configured via `PATRONI_LOGDATEFMT` environment variable. The default value is `%Y-%m-%d %H:%M:%S`
2019-01-14 08:46:38 +01:00
Pavel KirillovandAlexander Kukushkin 929ff08bfd Service deregister timeout must be in Go time format (#893) 2018-12-21 15:42:15 +01:00
Cody CoonsandAlexander Kukushkin 7bc8d0aac9 Removed stderr pipe to stdout on pg_ctl process (#896)
Inheriting stderr from the main Patroni process allows all Postgres logs to be seen along with all patroni logs. This is very useful in a container environment as Patroni and Postgres logs may be consumed using standard tools (docker logs, kubectl, etc).

In addition to that, this change fixes a bug with Patroni not being able to catch postmaster pid when postgres writing some warnings into stderr
2018-12-21 15:41:34 +01:00
Lucas CapistrantandAlexander Kukushkin f3da6de129 Add ability to configure app logs to be written to a file (#903)
It gives users the option to send Patroni application logs to a File instead of Standard Out. There are three environment variables that can be set to enable and configure file logging.
1. `PATRONI_FILE_LOG_DIR`: Path to a directory that is writeable by the executing user. Having this variable set is what activates file logging.
2. `PATRONI_FILE_LOG_NUM`: This is a rolling file logger. This variable dictates how many log files are retained.
3. `PATRONI_FILE_LOG_SIZE`: This variable dictates the size at which the logs will roll.

If `PATRONI_FILE_LOG_DIR` is not set than Patroni will log to stderr (default behavior does not change)

Closes https://github.com/zalando/patroni/issues/902
2018-12-21 15:38:29 +01:00
Alexander KukushkinandGitHub 491f230711 Release 1.5.3 (#889)
* Bump version
* Update release notes
2018-12-03 17:12:53 +01:00
Alexander KukushkinandGitHub f1d7ccf36e Make sure we refresh session at least once per HA loop (#880)
Fixes https://github.com/zalando/patroni/issues/879
2018-12-03 16:35:14 +01:00
Kostiantyn NemchenkoandAlexander Kukushkin 96ea01bee4 Fix kubernetes demo files (#885)
- Update postgres docker image to the latest 11 version.

- Remove empty lines inside the `RUN` command to make the Dockerfile compatible with future docker versions.

- Set the `PATRONI_KUBERNETES_POD_IP` environment variable, which is required when _use_endpoints_ is enabled. Otherwise, the `KeyError` is raised [here](https://github.com/zalando/patroni/blob/master/patroni/dcs/kubernetes.py#L95).

- Set `EDITOR` environment variable to make configuration changes via `patronictl edit-config`.
2018-12-03 15:46:25 +01:00
Alexander KukushkinandGitHub e684ca66e5 Compatibility with postgres 9.3 (#882)
use replication=1 instead of replication='database' when opening replication connection, 9.3 doesn't understand it
2018-11-30 15:46:05 +01:00
Alexander KukushkinandGitHub 1a0876e5ca Refactor acceptance tests to improve stability (#884)
Hope it will crash less often when executed on travis against k8s
2018-11-30 12:40:56 +01:00
Alexander KukushkinandGitHub 9bf074acfb Compatibility with python3 (#883)
Change of `loop_wait` was causing Patroni to disconnect from zookeeper and never reconnect back. The error was happening only with python3 due to a difference in implementation of `select.select` function.
2018-11-30 11:40:34 +01:00
Alexander KukushkinandGitHub f8f928420d Release 1.5.2 (#875)
* Update release notes
* Bump version
2018-11-26 10:31:14 +01:00
Shea StewartandAlexander Kukushkin 6519a192b1 add openshift customizations, templates, and test (#871)
- It modifies the Dockerfile and entrypoint slightly to allow for OpenShift SCCs to operate correctly
- It adds 2 template examples that can be easily modified by changing parameters

Fixes #572
2018-11-21 18:01:39 +01:00
Alexander KukushkinandGitHub 8c7e1892ee touch_member method should not raise exceptions (#859)
Fixes https://github.com/zalando/patroni/issues/853
2018-11-21 12:51:52 +01:00
Lardière SébastienandAlexander Kukushkin a1ba2cdca7 Add confd template for pgbouncer (#844) 2018-11-21 12:03:22 +01:00
Kostiantyn NemchenkoandAlexander Kukushkin ce9c7bdadc Describe Consul registration parameters (#870)
Mention Consul related parameters introduced by #802
2018-11-21 12:00:40 +01:00
Alexander KukushkinandGitHub fb01aaebc5 Compatibility with kazoo-2.6.0 (#872)
Recently 2.6.0 was release which changes the way how create_connection method is called. Before it was passing two arguments, and in the new version all argument names are specified explicitly.
2018-11-19 14:26:20 +01:00
alago197andAlexander Kukushkin a13cc8b847 Update SETTINGS.rst with connect_address clarifications (#858) 2018-11-12 16:56:51 +01:00
lwsboxandAlexander Kukushkin bd9f30372e fix bug: change pip to pip3 (#851)
should use python3 to install in postgres10 image.
2018-11-05 14:17:22 +01:00
Josh BerkusandAlexander Kukushkin d247b5ae17 Fix several build problems with the Docker image for testing Kubernet… (#735)
1. Update to Postgres 10
2. Install most of the python modules from deb packages
3. Do *not* upgrade pip
2018-11-04 08:31:11 +01:00
Alexander KukushkinandGitHub b4f35ecca0 Release 1.5.1 (#846)
* Bump version
* Update release notes
2018-11-01 16:18:41 +01:00
Alexander KukushkinandGitHub 0f666e69f3 Prefix system tables, views and functions with pg_catalog (#845)
and implement missing unit tests
2018-11-01 16:17:40 +01:00
Alexander KukushkinandGitHub 2efd97baab Permanent replication slots (#819)
Permanent replication slots are preserved on failover/switchover, that is Patroni on the new primary will create configured replication slots right after doing promote.

Slots could be configured with the help of `patronictl edit-config`.
The initial configuration could be also done in the `bootstrap.dcs`

```yaml
slots:
  permanent_physical_1:
    type: physical
  permanent_logical_1:
    type: logical
    database: foo
    plugin: pgoutput
```

It is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.

Closes https://github.com/zalando/patroni/issues/656
2018-10-31 11:37:42 +01:00
Alexander KukushkinandGitHub c65c4f1ffe Round-robing across all masters in pause mode if DCS is not accessible (#842)
Regression was introduced in https://github.com/zalando/patroni/commit/90cf930036a9d5249265af15d2b787ec7517cf57

Fixes https://github.com/zalando/patroni/issues/841
2018-10-30 16:48:53 +01:00
SrikanthandAlexander Kukushkin 2264190e79 Add pre-command to start watchdog (#835)
Add `ExecStartPre` commands to systemd unit to help start watchdog devices.
2018-10-26 07:55:01 +01:00
Alexander KukushkinandGitHub f70edefc65 A few bugfixes in the "standby cluster" workflow (#823)
* Always run `pg_rewind` against the remote master
* Always use the remote master as the source when "recovering" stopped standby leader
* Use remote master as the source when "recovering" the node in the unhealthy cluster
* Use the local dbname as the fallback when doing `pg_rewind` from the remote master
*  `no_replication_slot` is the allowed key in the `RemoteMember` object
* Make it possible to "bootstrap" the new `standby_cluster` with existing (and valid) data directory. There is one prerequisite though, there should be no `patroni.dynamic.json` file in it!
2018-10-09 13:30:48 +02:00
Yogesh SharmaandAlexander Kukushkin 6567f509b1 Add pgbackrest support (#1) (#822)
* pgbackrest support

pgBackrest can restore in existing $PGDATA folder, this allows speedy restore as files which have not changed since last backup are skipped, to support this feature new param keep_data has been introduced. When keep_data=True, cleanup of $PGDATA will be skipped.

Patroni passes some extra parameters to custom_replica_methods when calling, this causes an error due to pgbackrest strict parameter checking. New param no_params=True can be set to skip parameters passing.

Fixes https://github.com/zalando/patroni/issues/625
2018-10-08 19:00:30 +02:00
Alexander KukushkinandGitHub 534829d617 Release 1.5.0 (#809)
Update release notes and bump version
2018-09-20 16:29:00 +02:00
Alexander KukushkinandGitHub 76d1b4cfd8 Minor fixes (#808)
* Use `shutil.move` instead of `os.replace`, which is available only from 3.3
*  Introduce standby-leader health-check and consul service
* Improve unit tests, some lines were not covered
* rename `assertEquals` -> `assertEqual`, due to deprecation warning
2018-09-19 16:32:33 +02:00
Pavel GolubandAlexander Kukushkin 3d76a013a7 Support for Windows (#799)
Postgres on Windows is using different signals, backslashes as file separators and some of the functions and syscalls are not available there.
2018-09-19 13:50:36 +02:00
Pavel KirillovandAlexander Kukushkin 2e9cb412e4 Register service in consul (#802)
Кegister service 'scope_name' with tag 'master' or 'replica'

example with scope 'pgsql-pgpi'
```[root@pgpi1 ~]# host -t SRV pgsql-pgpi.service.consul. 127.0.0.1
Using domain server:
Name: 127.0.0.1
Address: 127.0.0.1#53
Aliases:

pgsql-pgpi.service.consul has SRV record 1 1 5432 pgpi1.node.dc.consul.
pgsql-pgpi.service.consul has SRV record 1 1 5432 pgpi2.node.dc.consul.
[root@pgpi1 ~]# host -t SRV master.pgsql-pgpi.service.consul. 127.0.0.1
Using domain server:
Name: 127.0.0.1
Address: 127.0.0.1#53
Aliases:

master.pgsql-pgpi.service.consul has SRV record 1 1 5432 pgpi2.node.dc.consul.
[root@pgpi1 ~]# host -t SRV replica.pgsql-pgpi.service.consul. 127.0.0.1
Using domain server:
Name: 127.0.0.1
Address: 127.0.0.1#53
Aliases:

replica.pgsql-pgpi.service.consul has SRV record 1 1 5432 pgpi1.node.dc.consul.```

Fixes: https://github.com/zalando/patroni/issues/771
2018-09-07 15:17:56 +02:00
Dmitry DolgovandGitHub dd7c3c349f [WIP] Standby cluster implementation (#679)
Implementation of "standby cluster" described in #657. Standby cluster consists
of a "standby leader", that replicates from a "remote master" (which is not a
part of current patroni cluster and can be anywhere), and cascade replicas,
that replicate from the corresponding standby leader. "Standby leader" behaves
pretty much like a regular leader, which means that it holds a leader lock in
DSC, in case if disappears there will be an election of a new "standby
leader".
One can define such a cluster using the section "standby_cluster" in patroni
config file. This section provides parameters for standby cluster, that will be
applied only once during bootstrap and can be changed only through DSC.
2018-09-07 10:10:56 +02:00
Alexander KukushkinandGitHub 4ca8a6e506 Make retries of calls to DCS consistent across implementations (#805)
in addition to that do a small refactoring of zookeeper and consul and try to improve the stability of AT
2018-09-06 08:37:26 +02:00
wilfriedrosetandAlexander Kukushkin 0136f252ab Add patronictl -k/--insecure flag and suport for restapi cert (#790)
Fixes https://github.com/zalando/patroni/issues/785
2018-08-29 16:08:13 +02:00
anikin-aaandAlexander Kukushkin 0e13677880 exclude members with nofailover tag (#798)
Exclude members with nofailover tag from `patronictl switchover/failover` output.
Fixes https://github.com/zalando/patroni/issues/769
2018-08-29 16:07:01 +02:00
anikin-aaandAlexander Kukushkin 68c0d87d42 check if output lines of controldata are possible to split (#797)
Otherwise it fails with scary stacktrace
2018-08-29 16:06:06 +02:00
Alexander KukushkinandGitHub 90cf930036 Refactor REST API health-checks (#779)
Make it more readable and easy to understand.
Mostly it is needed to implement https://github.com/zalando/patroni/issues/772
2018-08-29 11:35:22 +02:00
Jan MusslerandAlexander Kukushkin 2b87ae0cd0 Add member name to error message. (#792)
Analog to success message.
2018-08-29 11:30:51 +02:00
Alexander KukushkinandGitHub 5ca5dacaa9 Immediately reserve LSN on upon creation of replication slot (#783)
This feature is available starting from 9.6
2018-08-29 11:30:01 +02:00
Alexander KukushkinandGitHub 87e9aab04c Improve tests (#778)
* Implement missing unit-tests
* Add acceptance tests for ISSUE #776
* Update list of classifiers, keywords and authors
2018-08-29 11:29:37 +02:00
Alexander KukushkinandGitHub 518df2bc49 Search new sync candidate amoung potential and async standbys (#794)
In synchronos_mode_strict we put '*' into synchronos_standby_names, what makes one connection 'sync' and other connections 'potential'.
The code picking up the correct sync standby didn't consider 'potential' as a good candidate.

Fixes: https://github.com/zalando/patroni/issues/789
2018-08-29 11:28:46 +02:00
Alexander KukushkinandGitHub a513a7bb68 Improve stability of acceptance tests (#780)
last time tests were failing due to postgres/patroni slowness in picking sync standby
2018-08-29 11:13:18 +02:00
Alexander KukushkinandGitHub 715caaddf3 Remove .zappr.yaml (#795)
and switch to github approvals
2018-08-29 11:09:35 +02:00
Oleksii KliukinandAlexander Kukushkin b165183503 Reset is_leader status on demote (#777)
Make sure demoted cluster member stops responding with code 200 on the  /master API call.

Issue a new minor release.

Fixes https://github.com/zalando/patroni/issues/776
2018-08-14 17:08:08 +02:00
Dmitry DolgovandGitHub b282a0f254 Add "cluster_unlocked" field (#764)
Add a field to an api to figure out if a master is there from patroni point
of view. It can be useful, when you have an alert, based on Auto Scaling
Groups, and then ASG decided to shutdown the current master, spin up a
new instance but the current master shutdown is stuck. In this situation
the current master is no longer a part of ASG, but patroni and Postgres
are still alive on the instance, which means a new replica will not be
promoted yet - this will lead to a false alert, saying that your cluster
doesn't have any master node.
2018-08-13 14:02:01 +02:00
Oleksii KliukinandAlexander Kukushkin 5e7345a2ca Release notes 1.4.5 (#762)
bump version update release notes
2018-08-03 17:02:11 +02:00
Don SeilerandAlexander Kukushkin 502094ee79 Log config change or not (#731)
This adds INFO log messages that clearly state if configuration values were seen as changed by Patroni after SIGHUP/reload and warrant reloading (or if nothing was changed an no reloading is necessary).

This ended up being a lot simpler than I had imagined once I found postgresql.py:reload_config().

I add a log line in config.py:reload_local_configuration() since that function will short-circuit the process early if the local config wasn't changed. But the final determination of whether or not values have changed and need reloading is in postgresql.py:reload_config().
2018-08-03 17:00:57 +02:00
Alexander KukushkinandGitHub 0c1ae6fbeb Respond 200 to master health-check only if update_lock was successful (#713)
If Patroni gets partitioned it starts receiving stale information from DCS.
We can't use this information to determine that we have the leader key.
Instead, we will record in Ha object the actual state of acquire/update lock and report as a leader only if it was successful.

P.S. despite responding with 200 on `GET /master` postgres was still running read-only.
2018-08-03 17:00:01 +02:00
Alexander KukushkinandGitHub 2fd2556050 Set role to demoted if postgres isn't running and no recovery.conf (#757)
In really rare cases it was causing following behavior:
```
2018-07-31 10:35:30,302 INFO: starting as a secondary
2018-07-31 10:35:30,309 INFO: Lock owner: postgresql0; I am postgresql1
2018-07-31 10:35:30,310 INFO: Demoting master during restarting after failure
2018-07-31 10:35:30,381 INFO: postmaster pid=17709
2018-07-31 10:35:30,386 INFO: lost leader lock during restarting after failure
2018-07-31 10:35:30,388 ERROR: Exception during CHECKPOINT
```
2018-08-03 16:59:04 +02:00
Oleksii KliukinandAlexander Kukushkin d47049ce0e Fix condition for the replica start due to pg_rewind in paused state. (#754)
Avoid starting the replica that had already executed pg_rewind before.

Fixes in #753
2018-08-03 16:45:33 +02:00
Henning JacobsandGitHub 2f7c53031c Python 3.6 and 3.7 are now supported, too (#752) 2018-07-24 10:51:25 +02:00
Christoph BergandHenning Jacobs a2c6ed5504 async is a keyword in python3.7 (#751)
* async is a keyword in python3.7

Setting up patroni (1.4.4-1) ...
    File "/usr/lib/python3/dist-packages/patroni/ha.py", line 610
      'offline':          dict(stop='fast', checkpoint=False, release=False, offline=True, async=False),
                                                                                               ^
  SyntaxError: invalid syntax

Fix #750 by replacing dict member "async" with "async_req".

* requirements.txt: Update for new kubernetes version compatible with 3.7
2018-07-23 20:42:33 +02:00
Oleksii KliukinandAlexander Kukushkin 00c2e1c2d0 Grant delete on endpoints and configmaps in RBAC. (#749)
'patronictl remove' deletes the cluster configuration (stored either in configmaps or endpoints) and cannot be run from the postgres pod w/o 'delete' on those objects being granted to the pod service account.
2018-07-23 20:39:46 +03:00
Don SeilerandAlexander Kukushkin f5927bad70 Add EnvironmentFile directive (#746)
Add an EnvironmentFile directive to read in a configuration file with environment variables. The "-" prefix means it can proceed if the file doesn't exist.

This would allow users to keep sensitive information like the SUPERUSER/REPLICATION passwords in the config file separate from a YAML file that might be deployed from source control.
2018-07-23 20:31:47 +03:00
Alexander KukushkinandOleksii Kliukin 26466237b9 Update docker-compose example to postgres 10 (#737)
Some other changes are related to the new version of confd, which now
requires specifying etcd url instead of etcd host.
2018-07-23 16:41:17 +02:00
Tony SorrentinoandOleksii Kliukin c8f9199988 Added setting state to "stopped" when a member is stopped in Ha.shutdown (#733)
Changes by @tonys66, review by @CyberDem0n
2018-07-23 14:59:39 +02:00
Alexander KukushkinandOleksii Kliukin 2356af679b Convert query params from list to dict (#744)
Patroni is relying on params to determinte timeout and amount of retries when executing api requests to consul. Starting from v1.1.0 python-consul changed internal API and started
using `list` instead of `dict` to pass query parameters. Such change broke "watch" functionality.

Fixes https://github.com/zalando/patroni/issues/742 and
https://github.com/zalando/patroni/issues/734
2018-07-23 14:56:51 +02:00
Ants AasmaandOleksii Kliukin 3b633abd91 Improve logging when stale postmaster.pid matches running process (#738)
Currently the informational message logged is beyond confusing. This
improves the logging so there is some indication what this message is
about and that it is somewhat normal. Changes by @ants
2018-07-17 16:46:22 +02:00
alago197andOleksii Kliukin 936a4238fb Update some descriptions for the REST API endpoints (#729)
* Update some descriptions for the REST API endpoints

By @alago197
2018-07-10 15:40:53 +02:00
Don SeilerandOleksii Kliukin 50a8114d0b Use enforced minimums in postgresX.yml files (#730)
Fix the discrepancy for the values of max_wal_senders and max_replication_slots between the sample postgres.yml files and hard-coded defaults in Patroni, bumping the former to 10.
Contributed by @dtseiler
2018-07-04 10:08:54 +02:00
Don SeilerandAlexander Kukushkin 4e8709b266 Adding reload functionality (#726)
This allows the config to be reloaded via `systemctl reload patroni`, sending SIGHUP to the patroni process. Tested on CentOS.
2018-06-30 23:16:42 +02:00
Alexander KukushkinandGitHub 4128cba628 max_worker_processes parameter was introduced only in 9.4 (#724)
exclude it from the list on 9.3 when building effective configuration
2018-06-26 13:48:16 +01:00
Don SeilerandAlexander Kukushkin 959f254bfb Adding patronictl reload functionality to reload from yaml config file (#716)
Fixes https://github.com/zalando/patroni/issues/715
2018-06-20 10:09:10 +02:00
Alexander KukushkinandGitHub 8a3b78ca7b Rest api thread can raise an exception during shutdown (#711)
catch it and report
2018-06-14 13:17:50 +02:00
Oleksii KliukinandGitHub 41e5f58f2b Describe synchronous_mode_strict (#710)
* Describe synchronous_mode_strict

Per https://github.com/zalando/patroni/issues/709
2018-06-13 11:12:22 +02:00
Dmitry DolgovandGitHub f0d23b0b14 Merge pull request #706 from zalando/feature/rename-create-replica-method
Rename create_replica_method to create_replica_methods
2018-06-12 14:16:54 +02:00
Alexander KukushkinandGitHub cbd0a759c0 Relax kubernetes module version (#701)
Patroni is proven to work with 2.0.0, 3.0.0 and 6.0.0
2018-06-12 14:11:00 +02:00
Alexander KukushkinandGitHub aadd39b0a4 Do crash recovery only when we sure that postgres was running as master (#707)
pg_controldata reports in this case:
* 'in production'
* 'shutting down'
* 'in crash recovery'
2018-06-12 14:09:09 +02:00
Henning JacobsandAlexander Kukushkin 2537147810 #694 handle configuration error (#695)
It is possible to change a lot of parameters in runtime (including `restapi.listen`) by updating Patroni config file and sending SIGHUP to Patroni process.

If something was misconfigured it was throwing a weird exception and breaking `restapi` thread.

This PR improves friendliness of error message and avoids breaking of `restapi`.
2018-06-12 14:08:38 +02:00
Alexander KukushkinandGitHub e939304001 Take and apply some parameters from controldata when starting as replica (#703)
* Take and apply some parameters from controldata when starting as replica

https://www.postgresql.org/docs/10/static/hot-standby.html#HOT-STANDBY-ADMIN
There is set of parameters which value on the replica must be not smaller than on the primary, otherwise replica will refuse to start:
* max_connections
* max_prepared_transactions
* max_locks_per_transaction
* max_worker_processes

It might happen that values of these parameters in the global configuration are not set high enough, what makes impossible to start a replica without human intervention. Usually it happens when we bootstrap a new cluster from the basebackup.

As a solution to this problem we will take values of above parameters from the pg_controldata output and in case if the values in the global configuration are not high enough, apply values taken from pg_controldata and set `pending_restart` flag.
2018-06-12 14:04:32 +02:00
Chris FraserandAlexander Kukushkin aa18f70466 If set, use LD_LIBRARY_PATH when starting postgres (#698)
Fixes #697
2018-06-12 14:00:48 +02:00
Alexander KukushkinandGitHub e405e4e03c Workaround to sporadic unit-test failures (#696)
Fixes https://github.com/zalando/patroni/issues/691
2018-06-12 14:00:10 +02:00
erthalion 3d80e49b38 Rename also in settings docs 2018-06-12 13:28:30 +02:00
erthalion d037aa8afd Rename create_replica_method to create_replica_methods
To make it clear that it's actually an array
2018-06-12 11:33:13 +02:00
Björn AlbersandAlexander Kukushkin e5f2511764 Add WorkingDirectory to systemd sample config. (#686)
Otherwise `initdb` fails because it tries to create the data directory in the root directory where the postgres user has no permissions.
2018-06-04 16:36:41 +02:00
Alexander KukushkinandGitHub 1de7c78c04 Release 1.4.4 (#683)
bump version and update release notes
2018-05-22 14:46:19 +02:00
Alexander KukushkinandGitHub 041015037e Sync replication slots when we noticed a new postmaster process (#677)
Fixes: https://github.com/zalando/patroni/issues/674
2018-05-18 16:32:06 +02:00
Alexander KukushkinandGitHub 856552bd61 Sync replication slots and verify sysid after coming out of pause (#678)
Fixes https://github.com/zalando/patroni/issues/568
and https://github.com/zalando/patroni/issues/674
2018-05-18 12:18:49 +02:00
Oleksii KliukinandAlexander Kukushkin 4ce539ba1b Allow options to the basebackup built-in method. (#604)
Options should be specified in the basebackup section, which is optional.
2018-05-18 12:18:35 +02:00
Oleksii KliukinandAlexander Kukushkin 1043376e6b Do not exit when encountering invalid system ID. (#669)
Do not exit when the cluster system ID is empty or the one that doesn't pass the validation check. In that case, the cluster most likely needs a reinit; mention it in the result message.
Avoid terminating Patroni, as otherwise reinit cannot happen.
2018-05-18 11:48:15 +02:00
Alexander KukushkinandGitHub ed479fe585 Don't demote master if failed to update leader key in pause (#668)
Fixes https://github.com/zalando/patroni/issues/659
2018-05-18 11:19:56 +02:00
Alexander KukushkinandGitHub 5ce18a8045 Improve protection of DCS being accidentally wiped (#680)
We already have a lot of logic in place to prevent failover in such case and restore all keys, but an accidental removal of `/config` key was effectively switching off pause mode for 1 cycle of HA loop.
2018-05-18 11:18:58 +02:00
Alexander KukushkinandGitHub 5296336f4a BUGFIX: postmaster start can fail if pid from postmaster.pid is alive (#681)
Upon start postmaster process performs various safety checks if there is a postmaster.pid file in the data directory. Although Patroni already detected that the running process corresponding to the postmaster.pid is not a postmaster, the new postmaster might fail to start, because it thinks that postmaster.pid is already locked.
Important!!! Unlink of postmaster.pid isn't an option in this case, because it has a lot of nasty race conditions.
Luckily there is a workaround to this problem, we can pass the pid from postmaster.pid in the `PG_GRANDPARENT_PID` environment variable and postmaster will ignore it.

More likely to hit such problem if you run Patroni and postgres in the docker container.
2018-05-18 11:18:27 +02:00
Cody CoonsandAlexander Kukushkin 3eeb4ed979 Added check for empty subsets (#670)
On Kubernetes 1.10.0 I experienced an issue where calls to `patch_or_create` were failing when bootstraping a cluster. The call was failing because `self._leader_observed_subsets` was `None` instead of `[]`.
2018-04-26 16:38:19 +02:00
Alexander KukushkinandGitHub 84f29caf92 Fix race condition in poll_failover_result (#658)
It didn't affect directly neither failover nor switchover, but in some rare cases it was reporting it as a success too early, when the former leader released the lock: `Failed over to "None" instead of "desired-node"`

In addition to that this commit improves logs and status messages by differentiating between failover and switchover.
2018-04-16 17:45:05 +02:00
Alexander KukushkinandGitHub d78790b194 Abort start if attaching to running postgres and cluster not initiazlied (#661)
Patroni can attach itself to an already running PostgreSQL instance. If that is the first instance "seen" in the given cluster, Patroni for that instance will create the initialize key, grab the leader key and, if the instance is running a replica, promote.

Because of this behavior, when a cluster with a master and one or more replicas gets Patroni for each node, it is imperative to start running Patroni on the master node before getting to the replicas.

This commit changes such weird behavior and will abort Patroni start if there is no initialize key in DCS and postgres is running as a replica.

Closes https://github.com/zalando/patroni/issues/655
2018-04-16 17:32:26 +02:00
Kostiantyn NemchenkoandAlexander Kukushkin 3110090154 Minor corrections to the documentation. (#654) 2018-04-16 15:46:46 +02:00
Reinhard NägeleandAlexander Kukushkin 20138af37a Link to official Helm chart (#660)
Changes the link from my outdated fork to the official Helm chart which is now up to date.
2018-04-16 15:45:53 +02:00
Dave CramerandAlexander Kukushkin 38ad394308 Use the word primary in favour of master (#663)
Primary is a better alternative.
2018-04-16 01:29:51 +02:00
Alexander KukushkinandGitHub e375fac273 Treat postgres settings parameter names as case insensitive (#650)
Because they are indeed case insensitive.
Most of the parameters have snake_case_name, but there are three exceptions from this rule: DateStyle, IntervalStyle and TimeZone.
In fact, if you specify timezone = 'some/tzn' it still works, but Patroni wasn't able to find 'timezone' in pg_settings and stripping this parameter out.

We will use CaseInsensitiveDict to keep postgresql.parameters. This change affects only "final" configuration. That means if you put some"duplicates" (work_mem vs WORK_MEM) into patroni yaml or into cluster config, it would be resolved only at the last stage and for example you will be able to see both values if you use `patronictl edit-config`.

Fixes https://github.com/zalando/patroni/issues/649
2018-04-04 14:23:53 +02:00
Alexander KukushkinandGitHub 8c795ff0cf Pass dict object to touch_member instead of json encoded string (#651)
DCS implementation will take care about encoding it.
Fixes https://github.com/zalando/patroni/issues/642
2018-04-04 13:45:44 +02:00
Don SeilerandOleksii Kliukin 140618abd2 Missing a word (#647)
In re Issue #639
2018-04-04 13:40:46 +02:00
Josh BerkusandOleksii Kliukin 3c05e2e984 Added references to the Slack channel in Readme and in contributing.rst. (#653) 2018-04-04 13:39:43 +02:00
bradnicholsonandAlexander Kukushkin ca679a93b8 Make deleting recovery.conf optional. (#638)
pgBackRest's restore command generates the appropriate recovery.conf based
on the parameters you provide to pgBackRest.  When calling pgBackRest's restore command
via Patroni's custom bootstrap, it deletes that recovery.conf.  Specifying the recovery.conf
information in the patroni.yml is less than ideal.  It prevent's leveraging pgBackRests
work to ensure recovery.conf files are properly generated.  It also can lead to transient
config data in the patroni.yml under certain restore cases, such as a PITR restore
of Cluster B to  Cluster A, where the restore_commnand in A needs to reference B.

The parameter is optional.  The default behavior is to delete the recovery.conf.

Fixes https://github.com/zalando/patroni/issues/637
2018-03-09 15:35:29 +01:00
Alexander KukushkinandGitHub f500dbb0ff Release 1.4.3 (#635)
Bump version and update release notes
2018-03-05 10:10:17 +01:00
Andy NewtonandAlexander Kukushkin f748de3b29 Make log level configurable from environment variables (#622)
* `PATRONI_LOGLEVEL` - sets the general logging level
* `PATRONI_REQUESTS_LOGLEVEL` - sets the logging level for all HTTP requests e.g. Kubernetes API calls
2018-03-05 09:50:45 +01:00
Alexander KukushkinandGitHub 3afd26101b Single user mode was waiting for user input and never finish (#634)
Regression was introduced in https://github.com/zalando/patroni/pull/576
2018-03-02 22:22:43 +01:00
Alexander KukushkinandGitHub c04e7a1798 Write bootstrap.pg_hba into a pg_hba.conf after custom bootstrap (#632)
Fixes https://github.com/zalando/patroni/issues/631
2018-02-26 18:48:56 +01:00
Alexander KukushkinandGitHub 89a11fed07 Don't rediscover etcd cluster topology when watch timed out (#630)
but switch to the next node if it is possible.

Fixes https://github.com/zalando/patroni/issues/628
2018-02-26 18:48:30 +01:00
Alexander KukushkinandGitHub c95dd990cc Release 1.4.2 (#619)
* Bump version to 1.4.2
* Update release notes
2018-01-30 16:44:42 +01:00
Alexander KukushkinandGitHub dd1500b4dc Handle exceptions raised from psutil (#610)
Process.cmdline can raise `NoSuchProcess` or `AccessDenied`
Process.children can raise `AccessDenied`

Fixes https://github.com/zalando/patroni/issues/609
2018-01-30 16:26:39 +01:00
Alexander KukushkinandGitHub 0db788bb95 Make show-config work with cluster_name from config file (#618)
similar to edit-config, list and so on
2018-01-30 14:24:54 +01:00
Maciej SzulikandAlexander Kukushkin a4aaf53212 Add proper rbac to run patroni on k8s (#616)
Adds 3 resources that will properly setup the RBAC:

1. service account, which is also assigned to the pods of the cluster, so that they use those particular permissions 
2. a role, which holds only the necessary permissions that patroni members need to interact with k8s cluster
3. a rolebinding, which connects two two former things together to use.

The role and rolebinding was created using this tool https://github.com/liggitt/audit2rbac which looks at [audit logs](https://kubernetes.io/docs/tasks/debug-application-cluster/audit/#advanced-audit) provided  by the api server.
2018-01-30 12:00:49 +01:00
Maciej SzulikandOleksii Kliukin 3d293ac087 Change the pip definition in Dockerfile to use master now (#617) 2018-01-30 10:58:08 +01:00
Alexander KukushkinandGitHub a0c8491abb Don't swallow silently all errors from k8s API (#611)
Output exception trace to the logs when http status code == 403, something is wrong with permissions.

When http status code == 409 -- such error could be ignored, because object probably was created or updated by another process.

For all other http status codes it will also produce stack traces.

I hope it will help to debug issues similar to the https://github.com/zalando/patroni/issues/606
2018-01-26 09:57:17 +01:00
Alexander KukushkinandGitHub f8b6b21297 Avoid calling pg_controldata during bootstrap (#612)
Fixes https://github.com/zalando/patroni/issues/608
2018-01-26 09:56:17 +01:00
Alexander KukushkinandGitHub 9825b8a584 Improve patronictl list UX (#613)
* rename scheduled failover to scheduled switchover
* show information about pending_restarts
2018-01-26 09:55:54 +01:00
Alexander KukushkinandGitHub 8da05ad785 Update haproxy.tmpl (#614)
connection to postgres should be closed forcibly when health-check fails
2018-01-25 15:31:02 +01:00
Alexander KukushkinandGitHub a1e5c8e1cb A few iprovements in patronictl (#601)
* make switchover work with an old patroni
* exclude leader from candidates when interactively running failover
2018-01-17 15:33:08 +01:00
Oleksii KliukinandGitHub 4202ad853a Minor corrections to the documentation. (#599) 2018-01-10 16:10:12 +01:00
Alexander KukushkinandGitHub 93ac309b38 Fix link to the Kubernetes documentation (#598)
blog => blob
2018-01-10 13:19:23 +01:00
Oleksii KliukinandGitHub 84d804e579 Release notes 1.4 (#597)
Document  Kubernetes parameters, environment variables. Describe how Patroni uses Kubernetes.
2018-01-10 11:17:08 +01:00
Alexander KukushkinandGitHub d1312a7ce4 Do not try to load history file when timeline=1 (#596)
00000001.history doesn't exists
2018-01-09 12:01:14 +01:00
Oleksii KliukinandGitHub d14d9f669a Document pip-related installation options. (#595)
* Remove redundant requirements of Mac OS.

* Clarify how to run the example in getting started.
2018-01-08 13:59:31 +01:00
Alexander KukushkinandGitHub 5668367181 Implement '/sync' and /async endpoints (#578)
They will respond with http status code 200 only when the node is running as a synchronous or asynchronous replica.

Fixes https://github.com/zalando/patroni/issues/189
Fixes https://github.com/zalando/patroni/issues/415
2018-01-05 15:28:40 +01:00
Alexander KukushkinandGitHub 03c2a85d23 Expose current timeline in DCS and via API (#591)
It is very easy to get current timeline on the master by executing
```sql
SELECT ('x' || SUBSTR(pg_walfile_name(pg_current_wal_lsn()), 1, 8))::bit(32)::int
```

Unfortunately the same method doesn't work when postgres is_in_recovery. Therefore we will use replication connection for that on the replicas. In order to avoid opening and closing replication connection on every HA loop we will cache the result if its value matches with the timeline of the master.

Also this PR introduces a new key in DCS: `/history`. It will contain a json serialized object with timeline history in a format similar to the usual history files. The differences are:
* Second column is the absolute wal position in bytes, instead of LSN
* Optionally there might be a fourth column - timestamp, (mtime of history file)
2018-01-05 15:25:56 +01:00
Alexander KukushkinandGitHub 18786464a1 Rename failover to switchover and make new failover work without leader (#588)
In addition to that implement /switchover endpoint as an alias to /failover endpoint and implement more checks like:
* candidate must be provided for a failover
* switchover can't be scheduled in a pause state
* and so on

Fixes https://github.com/zalando/patroni/issues/585
Fixes https://github.com/zalando/patroni/issues/520
2018-01-05 15:17:56 +01:00
Alexander KukushkinandGitHub 3a96ffa718 Expose pause state of every member to DCS and via REST (#592)
and implement patronictl pause|resume --wait on top of that

Fixes https://github.com/zalando/patroni/issues/349
2018-01-05 15:16:45 +01:00
Alexander KukushkinandGitHub 6b01d2787f More improvements in patronictl (#590)
Make specifying cluster_name optional for some more commands.
If it is not specified, it's value would be taken from config file.
2018-01-04 12:26:13 +01:00
Alexander KukushkinandGitHub 2b8618b027 Minimize amount of SELECTS issued by Patroni on every loop (#584)
Every iteration of HA loop Patroni needs to call pg_is_in_recovery() and calcualte absolute wal_position. It was doing two separate SELECT statements for that. In case of master it was doing even three queries (wal_position two times).
We will issue one SELECT for every HA loop and cache the results.
2018-01-04 11:17:43 +01:00
Ants AasmaandAlexander Kukushkin 15d1767402 Some improvements to patronictl (#571)
* Use scope from config file when listing members

* Add version command to patronictl

* Only delete leader on shutdown when we have the lock to avoid exceptions when leader key does not exist

* Add a timestamp option to list command.

* YAML format for patronictl output

* Fix API request to get version
2018-01-04 10:35:22 +01:00
Alexander KukushkinandGitHub 0e01bb33bb Improve patronictl reinit (#576)
Make it possible to cancel a running task if you want to reinitialize replica.
There are two possible ways to trigger it:
1. patronictl will ask whether you want to cancel already running task if an attempt to trigger reinitialize has failed
2. if you are using `--force` argument with `patronictl reinit`
2018-01-04 10:31:44 +01:00
Alexander KukushkinandGitHub b6425cab85 Allow to specify multiple hosts for etcd (#589)
This list will be used for initial discovery of etcd cluster members.
If for some reason during work this list of hosts has been exhausted (during work), Patroni will return to initial list.

In addition to that improve ipv6 compatibility by using a special function for splitting host and port.

Fixes https://github.com/zalando/patroni/issues/523
2018-01-04 10:25:06 +01:00
Alexander KukushkinandGitHub 84de53603f Update travis settings (#581)
* Add master branch and release tags to safelist
* Update build matrix: don't install python3.5 if running acceptance tests
2017-12-20 16:28:09 +01:00
Alexander KukushkinandGitHub 062c55f99c Update readthedocs config (#580)
* Get Patroni version from patroni/version.py
* Update copyright to match with the LICENSE file

Fixes https://github.com/zalando/patroni/issues/519
2017-12-20 14:28:12 +01:00
Alexander KukushkinandGitHub fa5769468a Update python versions list (#577)
new travis image has 2.7 and 3.6 preinstalled by default
2017-12-19 15:35:13 +01:00
Alexander KukushkinandGitHub 7e72d1a75f Bump zookeeper version (#573)
3.4.9 can't be downloaded anymore and acceptance test with zookeeper/exhibitor fails
2017-12-08 18:40:11 +01:00
Alexander KukushkinandGitHub 4328c15010 Make Patroni Kubernetes native (#500)
* Use ConfigMaps or Endpoins for leader elections and to keep cluster state
* Label pods with a postgres role
* change behavior of pip install. From now on it will not install all dependencies, you have to specify explicitly DCS you want to use Patroni with: `pip install patroni[etcd,zookeeper,kubernetes]`
2017-12-08 16:55:00 +01:00
Alexander KukushkinandGitHub bd847fd2cc Patronictl extended info (#567)
* Show information about scheduled failover and maintenance mode when showing list of cluster members. Fixes https://github.com/zalando/patroni/issues/557

* Fix postgres version check functions (postgres 10 and above compatibility) and apply pep8 formatting to the tests.
* Bump some configuration parameters to match with postgres 10 defaults.
* Fix name of contributor in release notes.
2017-11-28 12:10:05 +01:00
Ants AasmaandAlexander Kukushkin 5da0e12353 Factor out postmaster process (#561)
Introduces a PostmasterProcess object that identifies a running process via pid and start time.
When pid file is parsed and the correct process identified this object is passed around.
When the process goes away we try to find a new one in case somebody restarted postgres behind our back.
2017-11-23 14:36:23 +01:00
Alexander KukushkinandGitHub a89a902f4a Bump version and write release notes (#560)
and implement missing unit-tests
2017-11-10 11:48:50 +01:00
Alexander KukushkinandGitHub 2e86fe5991 Consul dc (#559)
Make it possible to specify dc for consul as PATRONI_CONSUL_DC environment variable and update documentation accordingly.
2017-11-10 11:21:47 +01:00
Ants AasmaandAlexander Kukushkin 7367b7c74a Verify process start time when checking if postgres is running. (#549)
After a crash that doesn't clean up postmaster.pid there could be a new process with the same pid resulting in a false positive for is_running(), which will lead to all kinds of bad behavior.

Fixes #548
2017-11-09 15:36:05 +01:00
ainlolcatandAlexander Kukushkin cfa957eb96 shutdown postgresql before bootstrap when we lost data directory (#553)
Tries to kill postgresql before bootstrap to prevent old process from interfering.
Fixes https://github.com/zalando/patroni/issues/542
2017-11-09 15:20:51 +01:00
V AitvarasandAlexander Kukushkin ad7a1b8a16 Make it possible to provide datacenter configuration for Consul (#558)
```yaml
consul:
  url: http://consul.host:8500
  token: long-token-here
  dc: dev1-d1
```
2017-11-06 16:44:30 +01:00
Alexander KukushkinandGitHub 4daaf2beb0 Perform crash recovery in a single user mode if postgres died as master (#554)
But do it only if pg_rewind is enabled or there is no master at the moment.
Such "crash recovery" procedure was advised by Heikki Linnakangas
2017-11-03 16:22:39 +01:00
Alexander KukushkinandGitHub 8d926cbc86 Always send token in X-Consul-Token http header (#555)
Fixes https://github.com/zalando/patroni/issues/552
2017-11-03 16:22:07 +01:00
Alexander KukushkinandGitHub 823a4d6b8e Adjust session ttl if supplied value is smaller than minimum possible (#556)
It could happen that ttl provided in Patroni configuration is smaller
than minimum supported by Consul. In such case Consul agent fails to
create a new session and responds with 500 Internal Server Error and
http body contains something like: "Invalid Session TTL '3000000000',
must be between [10s=24h0m0s]". Without session Patroni is not able to
create member and leader keys in the Consul KV store and it means that
cluster becomes completely unhealthy.

As a workaround we will handle such exception, adjust ttl to the minimum
possible and retry session creation.

In addition to that make it possible to define custom log format via environment variable `PATRONI_LOGFORMAT`
2017-11-03 16:21:53 +01:00
Alexander KukushkinandGitHub 8e3511ca6b Different minor fixes (#551)
* Use unix line endings
* Make flake8 happy
2017-11-02 16:24:17 +01:00
Alexander KukushkinandOleksii Kliukin 7c000f1519 Update releases.rst 2017-10-12 15:03:13 +02:00
Alexander KukushkinandOleksii Kliukin 1e856e4ec6 Update release notes 2017-10-12 15:03:13 +02:00
Alexander KukushkinandOleksii Kliukin ae1a8f8942 Update release notes 2017-10-12 15:03:13 +02:00
Alexander KukushkinandOleksii Kliukin 31d4d7878e Bump verions to 1.3.5 2017-10-12 15:03:13 +02:00
Alexander KukushkinandOleksii Kliukin 34db670331 Improve test coverage 2017-10-12 15:03:13 +02:00
Alexander KukushkinandOleksii Kliukin 94c52991e0 Set role to uninitialized if data directory was removed in runtime
Fixes https://github.com/zalando/patroni/issues/542
2017-10-12 15:03:13 +02:00
Alexander KukushkinandGitHub 8e9c62d002 Make it possible to change Consul session checks (#543)
If list of checks is not specified, Consul will use "serfHealth" in addition to TTL based created by Patroni.
There are some cases when people want to sacrifice fast detection of network partitioning in favor of ability to tolerate network lags.

Fixes https://github.com/zalando/patroni/issues/522
2017-10-12 15:01:31 +02:00
Alexander KukushkinandGitHub cfdda23e27 Fix pg_rewind behaviour (#524)
When Patroni does calculation whether it should run pg_rewind or not, it relies on pg_controldata output or gets necessary information from replication connection.
On some cases (when for example postgres running as a master was killed), we can't use pg_controldata output immediately, but trying to start postgres. Such start could fail with the following errror:
```
LOG,00000,"ending log output to stderr",,"Future log output will go to log destination ""csvlog"".",,,,,,,""
LOG,00000,"database system was interrupted; last known up at 2017-09-16 22:35:22 UTC",,,,,,,,,""
LOG,00000,"restored log file ""00000006.history"" from archive",,,,,,,,,""
LOG,00000,"entering standby mode",,,,,,,,,"" 2017-09-18 08:00:39.433 UTC,,,57,,59bf7d26.39,4,,2017-09-18 08:00:38 UTC,,0,LOG,00000,"restored log file ""00000006.history"" from archive",,,,,,,,,""
FATAL,XX000,"requested timeline 6 is not a child of this server's history","Latest checkpoint is at 29/1A000178 on timeline 5, but in the history of the requested timeline, the server forked off from that timeline at 29/1A000140.",,,,,,,,""
LOG,00000,"startup process (PID 57) exited with exit code 1",,,,,,,,,""
LOG,00000,"aborting startup due to startup process failure",,,,,,,,,""
LOG,00000,"database system is shut down",,,,,,,,,""
```
In this case controldata will still have `Database cluster state: in production`
All further attempts to start postgres will fail. Such situation could be fixed only if we start not in recovery. For safety we will do it in a single user mode.

The second problems is: if postgres was running as master, but later we started it and stopped, than pg_controldata will report:
```
Database cluster state:               shut down in recovery
Minimum recovery ending location:     0/0
Min recovery ending loc's timeline:   0
```

And this info can't be used for calculations. In this case we should use
`Latest checkpoint location` and `Latest checkpoint's TimeLineID`
2017-09-29 14:21:19 +02:00
Ants AasmaandAlexander Kukushkin 32b0768631 Fix watchdog on Python 3 (#531)
A misunderstanding of the ioctl() call interface. If mutable=False then fcntl.ioctl() actually returns the arg buffer back.
This accidentally worked on Python2 because int and str comparison did not return an error.
Error reporting is actually done by raising IOError on Python2 and OSError on Python3.

* Properly handle errors in set_timeout(), have them result in only a warning if watchdog support is not required.

* Improve watchdog device driver name display on Python3

* Eliminate race condition in watchdog feature tests.
  The pinged/closed states were not getting reset properly if the checks ran too quickly.
  Add explicit reset points in feature test so the check is unambiguous.
2017-09-29 10:27:10 +02:00
Alexander KukushkinandGitHub 8a584f7a61 Set pgpass explicitly to /tmp/pgpass0 when running unit-tests (#518)
If $HOME is set to a non-existing directory (which would e.g. be the case on an official Debian package autobuilder) some tests were failing
2017-09-12 16:07:20 +02:00
Alexander KukushkinandGitHub 3919b322f4 Release 1.3.4 (#515)
Fix documentation and update release notes
2017-09-08 10:56:09 +02:00
Andrew Colin KissaandAlexander Kukushkin 53715e689a Pass the consul token as a header (#513)
Headers are now the prefered way to pass the token to the consul API - https://www.consul.io/api/index.html#authentication
2017-09-07 16:59:49 +02:00
Alexander KukushkinandGitHub 5ef01cfdfa Advanced configuration for Consul (#506)
* possibility to specify client certs and cacert
* possibility to specify token
* compatibility with python-consul-0.7.1
2017-08-24 07:56:12 +02:00
Alexander KukushkinandGitHub 4f87ea96ca "Could not take out TTL lock" message was never logged (#502)
This is not a critical bug, because `attempt_to_acquire_leader` method was still returning False in this case.
2017-08-24 07:55:30 +02:00
Alexander KukushkinandGitHub 23152a7fc4 synchronous_standby_names must be quoted with quote_ident (#505)
in addition to that implement additional checks around manual failover and recover when synchronous_mode is enabled

* Comparison must be case insensitive
2017-08-24 07:55:02 +02:00
Alexander KukushkinandGitHub 77aea03df9 Different bugfixes around pause state, mostly related to watchdog (#507)
* Do not send keepalives if watchdog is not active
* Avoid activating watchdog in a pause mode
* Set correct postgres state in pause mode
* Don't try to run queries from API if postgres is stopped
2017-08-24 07:53:32 +02:00
Alexander KukushkinandGitHub 4faec82380 Small bugfixes (#499)
* Short after promote synchronous replication was disabled even is synchronous_mode_strict is set
* Create empty pg_ident.conf if it is missing after restoring from backup
* Bump version
2017-08-04 10:56:33 +02:00
francobellagambaandAlexander Kukushkin d374882356 Fixes #494 - Custom Bootrap Temp hba.conf (#496)
* Fixes #494
2017-08-01 13:56:40 +02:00
Alexander KukushkinandOleksii Kliukin 25aa49b240 Run one manual failover test via rest API instead of patronictl
and bump Patroni version
2017-07-31 11:18:01 +02:00
Alexander KukushkinandGitHub 322aa45e09 BUGFIX: patronictl edit-config didn't worked with zookeeper (#492)
When updating config key we should use `ClusterConfig.index` instead of
`ClusterConfig.modify_index`. The second one should be used by Patroni
internally to check that key was really changed, because when key is
deleted and recreated it's version always starts from the same value: 0

In addition to that use patronictl instead of http PATCH in some of
acceptance tests to change cluster config.

Fixes https://github.com/zalando/patroni/issues/491
2017-07-31 11:07:00 +02:00
Oleksii Kliukin 9f9acb6a55 Fix a watchdog unit test on OS X. 2017-07-28 16:45:29 +02:00
Alexander KukushkinandGitHub f8b3703d6e Bugfix: failover via API didn't work due to change in _MemberStatus (#489)
Originally fetch_nodes_statuses was returning a tuple, later it was
wrapped into namedtuple _MemberStatus and recently _MemberStatus was
extened with watchdog_failed field, but api.py was still relying on
usual tuple and checking failover limitations on it's own instead of
calling `failover_limitation` method.
2017-07-28 15:38:55 +02:00
Oleksii KliukinandGitHub 895e46885a Patroni 1.3
- add release notes
- update the version
2017-07-27 15:58:31 +02:00
Alexander KukushkinandGitHub 6300ec4dbf Implement missing tests for watchdog (#487)
and fix one bug
2017-07-27 12:41:46 +02:00
Ants AasmaandAlexander Kukushkin 70d718a058 Simplify watchdog code (#452)
* Only activate watchdog while master and not paused

We don't really need the protections while we are not master. This way
we only need to tickle the watchdog when we are updating leader key or
while demotion is happening.

As implemented we might fail to notice to shut down the watchdog if
someone demotes postgres and removes leader key behind Patroni's back.
There are probably other similar cases. Basically if the administrator
if being actively stupid they might get unexpected restarts. That seems
fine.

* Add configuration change support. Change MODE_REQUIRED to disable leader eligibility instead of closing Patroni.

Changes watchdog timeout during the next keepalive when ttl is changed. Watchdog driver and requirement can also be switched online.

When watchdog mode is `required` and watchdog setup does not work then the effect is similar to nofailover. Add watchdog_failed to status API to signify this. This is True only when watchdog does not work **AND** it is required.

* Reset implementation when config changed while active.

* Add watchdog safety margin configuration

Defaults to 5 seconds. Basically this is the maximum amount of time
that can pass between the calls to odcs.update_leader()` and
`watchdog.keepalive()`, which are called right after each other. Should
be safe for pretty much any sane scenario and allows the default
settings to not trigger watchdog when DCS is not responding.

* Cancel bootstrap if watchdog activation fails

The system would have demoted itself anyway the next HA loop. Doing it
in bootstrap gives at least some other node chance to try bootstrapping
in the hope that it is configured correctly.

If all nodes are unable to activate they will continue to try until the
disk is filled with moved datadirs. Perhaps not ideal behavior, but as
the situation is unlikely to resolve itself without administrator
intervention it doesn't seem too bad.
2017-07-27 12:16:11 +02:00
Alexander KukushkinandGitHub e2feac87bc Block callbacks during bootstrap (#483)
It wasn't a big issue when on_start was called during normal boostrap
with initdb, because usually such process is very fast. But situation is
changing when we run custom bootstrap, becuase it might be a long time
between cluster become connectable and end of recovery and promote.

Actually situation was even worse than that, on_start was called with
the `replica` argument and later on_role_changes was never called,
because promote wasn't performed by Patroni.

As a solution for this problem we will block any callbacks during
bootstrap and explicitly call on_start after leader lock was taken.
2017-07-24 14:19:19 +02:00
Alexander KukushkinandGitHub cb360f089c Restart postgres after custom bootstrap if hba_file is defined in configuration (#482)
In addition to that always use absolute paths to config files.

Fixes https://github.com/zalando/patroni/issues/481
2017-07-22 09:46:05 +02:00
Alexander KukushkinandGitHub 7e066a18cb Install locales package and define default locale as en_US.UTF-8 (#480)
otherwise initdb fails
2017-07-22 09:45:28 +02:00
Oleksii KliukinandGitHub 895eefaa51 Document bootstrapping and replica creation (#478)
Describe parameters around custom replica creation and bootstrap
2017-07-19 12:25:50 +02:00
Alexander KukushkinandGitHub d5b3d94377 Custom bootstrap (#454)
Task of restoring a cluster from backup or cloning existing cluster into a new one was floating around for some time. It was kind of possible to achieve it by doing a lot of manual actions and very error prone. So I come up with the idea of making the way how we bootstrap a new cluster configurable.

In short - we want to run a custom script instead of running initdb.
2017-07-18 15:12:58 +02:00
Alexander KukushkinandGitHub e2cda83496 DEBIAN_FRONTEND=noninteractive to stop apt-get upgrade asking questions (#476)
+ Don't install unnecessary python modules
+ Bump etcd version
+ Fix etcd arguments and add missing python modules
2017-07-17 15:15:55 +02:00
Andrey MartyanovandAlexander Kukushkin 33d3cc433d Update python-etcd dependency (#472)
* Update python-etcd dependency to use acceptable version range.
2017-07-13 12:11:02 +02:00
Andrey MartyanovandAlexander Kukushkin 2877f75288 Improve haproxy.cfg (#473)
* Use more readable 'listen' section
* Explicitely defined check options
* Enable stats dashboard
* Close HAProxy connections to a backend server when it's marked as down.
2017-07-13 12:09:28 +02:00
Alexander KukushkinandGitHub acc6d7c2c2 Watchdog unit-tests, bugfixes and questions (#449)
Implement missing unit-tests for and drop unused code
2017-07-11 10:00:30 +02:00
Nick StottandAlexander Kukushkin 0b2134aba3 truncate the fqdn to 64 chars, or NAMEDATALEN-1 (#470)
as described here, https://github.com/postgres/postgres/blob/master/src/backend/replication/slot.c#L164-L210 the slot name should be truncated to 63 chars,

getting an error related to a slot_name
```
FATAL:  replication slot name "c_formationid4_main_m_1_c_formationid4_main_m_nicksaccount_svc_c" is too long
```
the leader has the following slot names

```
postgres=# select * from pg_replication_slots;
                            slot_name                            | plugin | slot_type | datoid | database | active | active_pid | xmin | catalog_xmin | restart_ls
n | confirmed_flush_lsn
-----------------------------------------------------------------+--------+-----------+--------+----------+--------+------------+------+--------------+-----------
--+---------------------
 c_formationid4_main_m_1_c_formationid4_main_m_nicksaccount_svc_ |        | physical  |        |          | f      |            |      |              |
  |
 c_formationid4_main_m_2_c_formationid4_main_m_nicksaccount_svc_ |        | physical  |        |          | f      |            |      |              |
  |
(2 rows)
```
2017-07-05 09:56:14 +02:00
jouirandAlexander Kukushkin 4ca94a5dab Add config_dir option for configuration files location (#466)
On debian, the configuration files (postgresql.conf, pg_hba.conf, etc) are not stored in the data directory. It would be great to be able to configure the location of this separate directory. Patroni could override existing configuration files where they are used to be.

The default is to store configuration files in the data directory. This setting is targeting custom installations like debian and any others moving configuration files out of the data directory.

Fixes #465
2017-07-04 16:14:17 +02:00
jouirandAlexander Kukushkin b60f65a2ca Compatibility with old kazoo (#468)
```python
AttributeError: 'KazooClient' object has no attribute '_retry'
```

Fixes #467
2017-07-04 16:13:18 +02:00
Alexander KukushkinandGitHub 23e6f65156 Don't wal_keep_segments as command line argument to postgres (#460)
It make it not possible to change it without restart.
Fixes https://github.com/zalando/patroni/issues/459
2017-07-03 12:07:24 +02:00
Alexander KukushkinandGitHub 10f2321334 Don't fail if one of DCS implementation can't be loaded (#463)
It might be that it's not required by configuration.
2017-07-03 12:07:19 +02:00
jouirandAlexander Kukushkin fa68eba33e Bugfix sleep func missing in Kazoo client (#464)
This patch is adding the sleep function needed in the connection_retry and command_retry parameters. The KazooClient is trying to compare them with the ones included in the handler at instantiation.
2017-07-03 12:07:09 +02:00
Alexander KukushkinandGitHub b576e69362 Manage pg_hba.conf via patroni config or dynamic_configuration (#458)
So far Patroni was populating pg_hba.conf only when running bootstrap code and after that it was not very handy to manage it's content, because it was necessary to login to every node, change pg_hba.conf manually and run pg_ctl reload.

This commit intends to fix it and give Patroni control over pg_hba.conf. It is possible to define pg_hba.conf content via `postgresql.pg_hba` in the patroni configuration file or in the `DCS/config` (dynamic configuration).

If the `hba_file` is defined in the `postgresql.parameters`, Patroni will ignore `postgresql.pg_hba`.
2017-06-23 12:38:25 +02:00
Alexander KukushkinandGitHub 681b6b507b Support unix sockets when connecting to a local postgres cluster (#457)
For backward compatibility this feature is not enabled by default. To enable it you have to set `postgresql.use_unix_socket: true`.
If feature is enable, and `unix_socket_directories` is defined and non empty, Patroni will use the first suitable value from it to connect to the local postgres cluster.
If the `unix_socket_directories` is not defined, Patroni will assume that default value should be used and will not pass `host` to command line arguments and omit it from connection url.

Solves: https://github.com/zalando/patroni/issues/61

In addition to mentioned above, this commit solves couple of bugs:
* manual failover with pg_rewind in a pause state was broken
* psycopg2 (or libpq, I am not really sure what exactly) doesn't mark cursors connection as closed when we use unix socket and there is an `OperationalError` occurs. We will close such connection on our own.
2017-06-22 11:47:57 +02:00
Alexander KukushkinandGitHub 3fee62c39b BUGFIX: retry on boto exceptions never worked (#450)
because `boto.exception` is not an excpetion, but a python module.

+ increase retry timeout to 5 minutes
+ refactor unit-tests to cover the case with retries.
2017-06-16 10:27:03 +02:00
Alexander KukushkinandGitHub 5bd9aa7547 BUGFIX: pg_rewind wasn't working when data page checksum is not enabled (#456)
pg_controldata output depends on postgres major version and in some cases some of the parameters are prefixed by 'Current ' for old postgres versions.

Bug was introduced by commit 37c1552.
Fixes https://github.com/zalando/patroni/issues/455
2017-06-16 10:25:54 +02:00
Ants AasmaandAlexander Kukushkin a70b46ef13 Add watchdog support on Linux (#343)
Ensures that system gets rebooted before TTL runs out.

Initial version. Open questions:

    Do we want to disable watchdog while we are not master?
2017-06-01 16:53:46 +02:00
Alexander KukushkinandGitHub e3a01727a9 Implement missing tests and add pg-10 support to wale_restore(#446)
in addition to that get rid from two modules and fix formatting of tests
2017-05-22 12:01:02 +02:00
Alexander KukushkinandGitHub cd84dc82b6 Implement postgresql-10 support (#444)
Mainly it handles rename of xlog to wal.
In the API and inside DCS it is still named xlog (for compatibility).

* Address feedback
2017-05-19 17:04:53 +02:00
Alexander KukushkinandGitHub 7633b19213 Support change of superuser and replication credentials on reload (#445)
Fixes: https://github.com/zalando/patroni/issues/353
and: https://github.com/zalando/patroni/issues/443
2017-05-19 16:32:35 +02:00
Alexander KukushkinandGitHub 37c1552c0a Smart pg_rewind (#417)
Previously we were running pg_rewind only in limited amount of cases:
 * when we knew postgres was a master (no recovery.conf in data dir)
 * when we were doing a manual switchover to a specific node (no
   guaranty that this node is the most up-to-date)
 * when a given node has nofailover tag (it could be ahead of new master)

This approach was kind of working in most of the cases, but sometimes we
were executing pg_rewind when it was not necessary and in some other
cases we were not executing it although it was needed.

The main idea of this PR is first try to figure out that we really need
to run pg_rewind by analyzing timelineid, LSN and history file on master
and replica and run it only if it's needed.
2017-05-19 16:32:06 +02:00
PhilippandAlexander Kukushkin fed7a24889 Init.d script for Debian (#441) 2017-05-19 16:29:10 +02:00
Joar WandborgandAlexander Kukushkin 3725b4bbcb Add wal-e threshold logging (#440)
Add error log for threshold, so that you can see if thresholds were the reason for ExitCode.FAIL.
2017-05-19 16:28:29 +02:00
Ants AasmaandAlexander Kukushkin 644b741969 Add config editing to patronictl (#428)
Current UI to change cluster configuration is somewhat unfriendly, involving a curl command, knowing the REST API endpoint, knowing the specific syntax to call it with and writing a JSON document. I added two commands in this branch to make this a bit easier, `show-config` and `edit-config` (names are merely placeholders, any opinions on better ones?).

* `patronictl show-config clustername` fetches the config from DCS, formats it as YAML and outputs it.

* `patronictl edit-config clustername` fetches the config, formats it as YAML, invokes $EDITOR on it, then shows user the diff and after confirmation applies the changed config to DCS, guarding for concurrent modifications.

* `patronictl edit-config clustername --set synchronous_mode=true --set postgresql.use_slots=true` will set the specific key-value pairs.

There are also some UI capabilities I'm less sure of, but included them here as I already implemented them.

* If output is a tty then the diffs are colored. I'm not sure if this feature is cool enough to pull the weight of adding a dependency on cdiff. Or maybe someone knows of another more task focused diff coloring library?
* `patronictl edit-config clustername --pg work_mem=100MB` - Shorthand for `--set postgresql.parameters.work_mem=100MB`
* `patronictl edit-config clustername --apply changes.yaml` - apply changes from a yaml file.
* `patronictl edit-config clustername --replace new-config.yaml` - replace config with new version.
2017-05-19 16:25:21 +02:00
Alexander KukushkinandGitHub 2263d68490 bugfix: async executor was not set to busy when bootstrap without leader (#439)
This was causing patroni to exit with following exception:
2017-04-27 08:54:25,000 CRITICAL: system ID mismatch, node foo-0 belongs to a different cluster: 6347967071236960319 !=
2017-04-27 08:54:25,221 ERROR:
Traceback (most recent call last):
  File "/usr/local/lib/python3.5/dist-packages/patroni/__init__.py", line 134, in patroni_main
    patroni.run()
  File "/usr/local/lib/python3.5/dist-packages/patroni/__init__.py", line 110, in run
    logger.info(self.ha.run_cycle())
  File "/usr/local/lib/python3.5/dist-packages/patroni/ha.py", line 946, in run_cycle
    info = self._run_cycle()
  File "/usr/local/lib/python3.5/dist-packages/patroni/ha.py", line 903, in _run_cycle
    sys.exit(1)
SystemExit: 1
2017-04-27 14:36:35 +02:00
Oleksii KliukinandAlexander Kukushkin 728a521c1d Export locale variables when forking a postmaster. (#437)
Avoid "postmaster became multithreaded during startup" error on OS X built with --enable-nls (default for petere/homebrew).

The issue is that on OS X the libintl that replaces setlocale() spawns a thread when it needs to detect the current locale. Setting the LC_ and LANG variables prevents this, but we don't propagate them to the fork.

See: https://www.postgresql.org/message-id/flat/20140902013458.GB906981%40tornado.leadboat.com#[email protected] for an additional explanation of the original Postgres behavior.
2017-04-27 14:35:01 +02:00
Joar WandborgandAlexander Kukushkin 3241ec2504 Use csv.DictReader when parsing wal-e backup-list (#436)
wal-e outputs in CSV format using the 'excel-tab' dialect: https://github.com/wal-e/wal-e/blob/3164de68527e6ace269a2112291344b18b9ca6c5/wal_e/operator/backup.py#L63

The ISO date may be written with a space instead of'T' as delimiter between date
and time, this causes the old parsing to fail.
2017-04-27 14:33:22 +02:00
Alexander KukushkinandGitHub 44a7142a9d Synchronous mode strict (#435)
If synchronous_mode_strict==true then '*' will be written as synchronous_standby_names when that last replication host dies.
2017-04-27 14:32:15 +02:00
jamessewellandAlexander Kukushkin 208c7abaab Update the startup script timeouts (#399)
* Update the startup script shutdown / startup timeout to allow the current sync node time to release the role in synchronous_mode.

Without this change Patroni will be killed while waiting, and will leave
PostgreSQL up rather than stopping it.

* Updates to support strict sync mode.

If synchronous_mode_strict==true then 'patroni_dummy_host' will be written as synchronous_standby_names
when that last replication host dies.

* Oops, mixed two pull requests - backing out one
2017-04-27 14:30:54 +02:00
Oleksii KliukinandAlexander Kukushkin 8553e120e4 Bump up to 1.2.5 (#434) 2017-04-20 12:45:26 +02:00
Ants AasmaandOleksii Kliukin 856a13e24c Remove error spinning on etcd failure and reduce log spam (#429)
When all etcd servers refuse connections during watch the call will fail with an exception and will be immediately retried. This creates a huge amount of log spam potentially creating additional issues on top of losing the DCS. This patch takes note if etcd failures are repeating and starting from the second failure will sleep for a second before retrying. It additionally omits the stack trace after the first failure in a streak of failures.
2017-04-20 12:40:15 +02:00
Alex KerneyandAlexander Kukushkin 1d513e7e04 Release the leader key when the leader restarts with an empty data dir (#420)
* If the leader has an empty data directory it must have been recreated, so release the leader key
2017-04-18 12:45:48 +02:00
Alexander KukushkinandGitHub 1c5d5f1dae BUGFIX: pg_drop_replication_slot may not be called if slot is active (#427)
Default value of wal_sender_timeout is 60 seconds while we are trying to remove replication slot after 30 seconds (ttl=30). That means postgres might think that slot is still active and does nothing. Patroni at the same time was thinking that it was removed successfully.

If the drop replication slot query didn't return any single row we must fetch list of existing physical replication slots from postgres on the next iteration of HA loop.

Fixes: issue #425
2017-04-18 12:45:24 +02:00
Oleksii KliukinandAlexander Kukushkin d39f895082 Fix unit tests for Python 3.6 (#431)
Python 3.6 complains about 'AttributeError: 'MockRequest' object has no attribute 'sendall'
2017-04-18 12:44:42 +02:00
Alexander KukushkinandGitHub dea8f22a37 Fix race condition when opening connection to cluster (#433)
`Postgresql.connection` method could be called from different threads at the same time resulting in more than one connection open but only one used afterwards.
2017-04-18 12:44:27 +02:00
Lauri AppleandGitHub 8b3114a390 Merge pull request #432 from zalando/kubecon-berlin-2017-talk
add link to recent KubeCon Berlin 2017 talk
2017-04-14 16:04:02 +02:00
Henning JacobsandGitHub b327882289 add link to recent KubeCon Berlin 2017 talk
mooore talk videos!
2017-04-14 16:02:34 +02:00
Oleksii KliukinandGitHub 875e450ff8 Retry when tagging EC2 and EBS with Postgres roles. (#418)
Retry if an error happens when setting Role or Name tags for the
EC2 instances or EBS volumes. The maximum retry interval is 15 seconds.
2017-03-24 16:51:24 +01:00
Alexander KukushkinandGitHub 5bd7d043ae Use postgres:9.6 as a base image (#414)
* Expose 5000 and 5001 on corresponding ports
* Use latest version of etcd
2017-03-20 12:22:09 +01:00
Oleksii KliukinandGitHub 720d08d1b4 Bump up to 1.2.4 (#409) 2017-03-06 12:48:56 +01:00
Dr Nic WilliamsandOleksii Kliukin d39bd4363d shorter, more correct spelling of navigate [fixes #394] (#407) 2017-03-04 13:42:10 +01:00
Lauri AppleandGitHub 48e1b976b3 Merge pull request #398 from zalando/LappleApple-patch-1
Update README.rst
2017-02-16 18:23:56 +01:00
Lauri AppleandGitHub fb17eeaf94 Update README.rst
adjusted the Helm chart language to be more precise, and fixed a broken link at the end
2017-02-16 17:43:45 +01:00
Alexander KukushkinandGitHub 0443844f65 Make it possible to compare xlog location against replica (#396) 2017-02-16 17:07:34 +01:00
Alexander KukushkinandGitHub 3ece35c0a6 Reassemble postgresql parameters when major version became known (#395)
* Reassemble postgresql parameters when major version became known

Otherwise we were writing some "unknown" parameters into postgresql.conf
and postgres was refusing to start. Only 9.3 was affected.

In addition to that move rename of wal_level from hot_standby to replica
into get_server_parameters method. Now this rename is handled in a
single place.

* Bump etcd and consul versions
2017-02-16 17:07:21 +01:00
Alexander KukushkinandGitHub 1ed91a93c6 Handle EtcdEventIndexCleared and EtcdWatcherCleared exceptions (#387)
If this case it doesn't make sense to retry, because it brings nothing
but produces a log of exceptions in the log...
2017-02-16 17:07:09 +01:00
Oleksii KliukinandGitHub 8aa173e79a Bump up to 1.2.3 (#386) 2017-01-18 14:31:03 +01:00
Alexander KukushkinandGitHub c6252bc004 Don't resolve url hostnames manualy but mokey patch urllib3 (#385)
Change hostnames by ip addresses was causing certificate verification to
fail. Instead of doing it we will better monkey patch urllib3
functionality which does name resolution. It should work without
problems even for https connection.
2017-01-18 13:46:02 +01:00
Alexander KukushkinandGitHub 2b5c08d17d Merge pull request #383 from zalando/bugfix/etcd-machines
Call self._load_machines_cache() method on timeout is causing switch to a new server every 5 minutes
2017-01-18 10:46:33 +01:00
Alexander Kukushkin 68606c645d Merge branch 'master' of github.com:zalando/patroni into bugfix/etcd-machines 2017-01-13 11:41:32 +01:00
Alexander KukushkinandGitHub 4a91558bf6 Merge pull request #382 from zalando/bugfix/at-timeouts
Scheduled failovers in 1 second don't work reliably with loop_wait=2
2017-01-13 11:40:10 +01:00
Alexander Kukushkin 39f5f7982c Scheduled failovers in 1 second don't work reliably with loop_wait=2 2017-01-13 11:25:07 +01:00
Alexander KukushkinandGitHub 56c736a912 Merge pull request #381 from zalando/travis/trusty
Switch to trusty and run acceptance tests with postgres 9.6
2017-01-13 10:26:44 +01:00
Alexander Kukushkin 1f829a4b34 Switch to trusty and run acceptance tests with postgres 9.6 2017-01-13 09:32:38 +01:00
Alexander Kukushkin 711d53980f Call self._load_machines_cache() method on timeout is causing switch to
a new server every 5 minutes
2017-01-12 17:30:18 +01:00
Alexander KukushkinandGitHub bbc67b2460 Merge pull request #378 from zalando/travis/caching
Optimize travis cache usage
2017-01-12 14:11:31 +01:00
Oleksii KliukinandGitHub 2559ba8ca2 Release notes for version 1.2
In addition to writing the nodes, modify the way changes are presented, adding some custom CSS.
2017-01-05 11:31:26 +01:00
Alexander Kukushkin 4c34136d44 Optimize travis cache usage 2017-01-02 17:10:13 +01:00
Oleksii KliukinandGitHub ee0ba74a84 Keep the version number up-to-date. (#376) 2016-12-30 12:15:19 +01:00
Alexander KukushkinandGitHub 874b5fbe8d Merge pull request #374 from zalando/feature/mention_readthedocs_in_readme
Mention readthedocs in README
2016-12-21 12:44:43 +01:00
Oleksii KliukinandGitHub 576351a807 Mention readthedocs 2016-12-20 18:46:09 +01:00
Oleksii KliukinandGitHub fb89e75ce4 Make patroni documentation available on patroni.readthedocs.io. (#373)
Run sphnix-quickstart and some workarounds.
Sphinx is a logical choice because our docs is already in .rst.
2016-12-20 18:22:57 +01:00
Alexander KukushkinandGitHub c293d3d7ec Ha loop diagram (bootstrap and automatic failover) 2016-12-20 16:41:51 +01:00
Alexander KukushkinandGitHub 8fda957804 Restart former master in readonly only once when partitioned (#370) 2016-12-20 16:41:18 +01:00
Alexander Kukushkin 574e1dba04 Update diagram 2016-12-20 16:32:00 +01:00
Alejandro MartínezandAlexander Kukushkin 27dd9b4c6e Add HA loop diagram (#352) 2016-12-20 16:26:45 +01:00
Alexander KukushkinandOleksii Kliukin 663bbb1048 Do not hide environment variables
* Serialize callback execution.

If the previous callback is still running - kill it
Also it will fix a problem of zombie processes when executing callbacks
from the main thread.

* bugfix: pass an arguments to a callback
2016-12-16 17:15:04 +01:00
Alexander KukushkinandGitHub a5e79bce9d * bugfix: pass an arguments to a callback 2016-12-16 15:44:04 +01:00
Alexander KukushkinandGitHub 8c0712047e Serialize callback execution (#366)
If the previous callback is still running - kill it
Also it will fix a problem of zombie processes when executing callbacks from the main thread.
2016-12-16 14:29:53 +01:00
Ants AasmaandAlexander Kukushkin 82176765e9 Fix rewind behavior when paused (#365)
* Check if we can rewind when deciding to call Postgresql.follow during pause.

Without this the following sequence of events occurs:

1. Manual failover from node, demotion sets need_rewind flag. Rewind is not done because can_rewind is False.
2. Cluster is put into paused mode. Rewind is not attempted because recovery.conf matches.
3. PostgreSQL goes down (pg_ctl stop).
4. Because need_rewind is set PostgreSQL is restarted.
5. Sysadmin is unhappy because Patroni is doing stuff behind his back during pause.

* Allow superuser user to be missing from config for rewind.

In other places a missing superuser authentication section is allowed and we default to libpq's use default OS user with no password. This makes rewind work with a missing superuser configuration.
2016-12-16 14:29:38 +01:00
Oleksii KliukinandGitHub cffc7d8dc5 Fix flake8 warnings, release 1.2 (#364) 2016-12-13 11:07:37 +01:00
Alexander KukushkinandGitHub 1e984c3f00 Take a max from xlog_receive and xlog_replay (#363) 2016-12-12 16:27:36 +01:00
Alexander KukushkinandGitHub 47cc572a3d Show only 1 warning if srv discovery failed (#362) 2016-12-12 10:58:48 +01:00
Alexander KukushkinandGitHub d138a8db17 AT for master_start_timeout + minor fixes (#361) 2016-12-09 12:02:41 +01:00
Ants AasmaandOleksii Kliukin 1290b30b84 Introduce starting state and master start timeout. (#295)
Previously pg_ctl waited for a timeout and then happily trodded on considering PostgreSQL to be running. This caused PostgreSQL to show up in listings as running when it was actually not and caused a race condition that resulted in either a failover or a crash recovery or a crash recovery interrupted by failover and a missed rewind.

This change adds a master_start_timeout parameter and introduces a new state for the main run_cycle loop: starting. When master_start_timeout is zero we will fail over as soon as there is a failover candidate. Otherwise PostgreSQL will be started, but once master_start_timeout expires we will stop and release leader lock if failover is possible. Once failover succeeds or fails (no leader and no one to take the role) we continue with normal processing. While we are waiting for the master timeout we handle manual failover requests.

* Introduce timeout parameter to restart.

When restart timeout is set master becomes eligible for failover after that timeout expires regardless of master_start_time. Immediate restart calls will wait for this timeout to pass, even when node is a standby.
2016-12-08 14:44:27 +01:00
Alexander KukushkinandGitHub ec78777778 Implement simple asynchronos dns-resolve cache (#360) 2016-12-07 13:16:26 +01:00
Oleksii KliukinandGitHub b38d98a6a3 Fix the WAL-E restore (#359)
* Fix broken WAL directory symlinks after WAL-E restore.

* Add unit-tests for wale_restore.

* Reduce the amount of MagicMock to the one (for psycopg2.connect)

* Make WAL-E restore process more robuts.

Allow retries only on WAL-E failures.
Sleep after each attempt

* Update the tests.

* Change WAL-E behavior when master is absent, tests.

- Challenge the use of WAL-E even when 'no_master' flag is set. This flag in
  fact does not indicate that the master is absent. In order to check the master
  absense the script looks whether the connection string is not empty.

- Retry on a failure to fetch current xlog position from the master. The reason
  it has to be separate from retries in the main loop is that we don't just
  retry the connection attempt, but also make a decision when either it was
  successfull or all attempts are exhausted.

- Remove wrong usages of ProperyMocks from the tests.

* Avoid redundant output of the exception message in logger.exception

* Address issues uncovered by flake8
2016-12-06 17:01:40 +01:00
Alexander KukushkinandGitHub b299b12f58 Varios configuration parameters for etcd (#358)
* Add https and auth support for etcd

Also implement support of PATRONI_ETCD_URL and PATRONI_ETCD_SRV
environment variables

* Implement etcd.proxy etcd.cacert, etcd.cert and etcd.key support

Now it should be possible to set up fully encrypted connection to etcd
with authorization.
2016-12-06 16:40:21 +01:00
Alexander KukushkinandGitHub c6417b2558 Add postgres-9.6 support (#357)
starting from 9.6 we need wal_level = 'replica' which is alias for 'hot_standby'. It was working before without problems, but if somebody change wal_level to replica, Patroni will expose pending_restart flag, although restart in this case is not necessary.

* bump versions of consul and etcd to the latest for travis integration-tests
2016-11-25 12:35:01 +01:00
Alexander KukushkinandGitHub 28b00dea16 Solve issue of handling sigchld when dunning in a docker (#355)
If Patroni was started in a docker with pid=1 it will execute itself
with the same arguments. The original process will take care about init
process duties, i.e. handle sigchld and reap dead orphan processes.
Also it will forward SIGINT, SIGHUP, SIGTERM and some other signals to
the real Patroni process.
2016-11-22 16:22:47 +01:00
Alexander KukushkinandGitHub 038b5aed72 Improve leader watch functionality (#356)
Previously replicas were always watching for leader key (even if the
postgres was not in the running there). It was not a big issue, but it
was not possible to interrupt such watch in cases if the postgres
started up or stopped successfully. Also it was delaying update_member
call and we had kind of stale information in DCS up to `loop_wait`
seconds. This commit changes such behavior. If the async_executor is
busy by starting/stopping or restarting postgres we will not watch for
leader key but waiting for event from async_executor up to `loop_wait`
seconds. Async executor will fire such event only in case if the
function it was calling returned something what could be evaluated to
boolean True.

Such functionality is really needed to change the way how we are making
decision about necessity of pg_rewind. It will require to have a local
postgres running and for us it is really important to get such
notification as soon as possible.
2016-11-22 16:22:30 +01:00
Alexander KukushkinandGitHub 66543f41a3 BUGFIX: Try all cluster members when doing pause/resume (#351)
Previously it was not possible to pause/resume unhealthy cluster.
2016-11-04 18:43:19 +02:00
Alexander KukushkinandGitHub 37b020e7a3 Various bugfixes and improvements: (#346)
* Replace pytz.UTC with dateutil.tz.tzutc, it helps to reduce memory by more than 4Mb...

* fix check of python version: 0x0300000 => 0x3000000

* Update leader key before restart and demote
2016-11-04 18:42:56 +02:00
Lauri at ZalandoandOleksii Kliukin e9a834739b updated README with link to releases info (#338) 2016-10-19 16:13:37 +02:00
Ants AasmaandOleksii Kliukin 7e53a604d4 Add synchronous replication support. (#314)
Adds a new configuration variable synchronous_mode. When enabled Patroni will manage synchronous_standby_names to enable synchronous replication whenever there are healthy standbys available. With synchronous mode enabled Patroni will automatically fail over only to a standby that was synchronously replicating at the time of master failure. This effectively means zero lost user visible transactions.

To enforce the synchronous failover guarantee Patroni stores current synchronous replication state in the DCS, using strict ordering, first enable synchronous replication, then publish the information. Standby can use this to verify that it was indeed a synchronous standby before master failed and is allowed to fail over.

We can't enable multiple standbys as synchronous, allowing PostreSQL to pick one because we can't know which one was actually set to be synchronous on the master when it failed. This means that on standby failure commits will be blocked on the master until next run_cycle iteration. TODO: figure out a way to poke Patroni to run sooner or allow for PostgreSQL to pick one without the possibility of lost transactions.

On graceful shutdown standbys will disable themselves by setting a nosync tag for themselves and waiting for the master to notice and pick another standby. This adds a new mechanism for Ha to publish dynamic tags to the DCS.

When the synchronous standby goes away or disconnects a new one is picked and Patroni switches master over to the new one. If no synchronous standby exists Patroni disables synchronous replication (synchronous_standby_names=''), but not synchronous_mode. In this case, only the node that was previously master is allowed to acquire the leader lock.

Added acceptance tests and documentation.

Implementation by @ants with extensive review by @CyberDem0n.
2016-10-19 16:12:51 +02:00
Lauri at ZalandoandGitHub 8cc3d91021 Merge pull request #339 from zalando/LappleApple-patch-2
Added Helm chart link to README
2016-10-11 13:28:19 +02:00
Lauri at ZalandoandGitHub 46cb62b0cc Added Helm chart link to README
Take a look and when you've approved I'll start promoting this externally :)
2016-10-11 12:00:30 +02:00
Alexander KukushkinandGitHub 1e573aec8f Do session/renew call to Consul when update_leader is called (#336) 2016-10-10 10:05:55 +02:00
Kirill PushkinandAlexander Kukushkin 6c9a870f09 Make 'users' hash of 'bootstrap' stanza optional (#334)
fix by @sm97
2016-10-07 10:57:53 +02:00
Alejandro MartínezandAlexander Kukushkin 48a6af6994 Add post_init configuration parameter on bootstrap (#296)
* Add bootstrap post_init configuration parameter
* Add documentation

By @zenitraM
2016-09-28 15:42:23 +02:00
Alexander KukushkinandGitHub 4594bc98da Increase timeouts when running AT on travis (#324)
* Increase timeouts two times when running AT on travis
* Make up to 3 attempts to download DCS
* Get rid from hard-coded names
2016-09-28 15:13:09 +02:00
Alexander KukushkinandGitHub bb07076343 Write all postgres parameters into postgresql.conf (#323)
It will make possible to start postgres easily with pg_ctl (without
Patroni). Previously if you tried to start such postgres manually
it was usually failing because some of the parameters does not much
with values written into xlog (for example max_connections).
2016-09-27 16:25:53 +02:00
Alexander KukushkinandGitHub 67c4b6b105 Make --dcs and --config-file options global (#322) 2016-09-27 16:25:24 +02:00
Alexander KukushkinandGitHub e38dfaf1ba Call touch_member at the end of HA loop (#321)
To make sure that we have up-to-date state of member in DCS after HA
loop has changed something.
2016-09-27 16:25:11 +02:00
Alexander KukushkinandGitHub 298357c099 Implement retry and timeout strategy for consul (#305)
..the same way as for etcd

Change HTTPClient implementation from using `requests.session` to
`urllib3.PoolManager`, because reference implementation from python-consul
didn't really worked with timeouts and was blocking HA loop...
2016-09-27 16:24:30 +02:00
SulionandOleksii Kliukin e7329fb903 Cover case when failover is now and via DCS (#320)
Fix the bug with setting failover directly in DCS via patronictl. Fix by @Sulion
2016-09-21 17:01:27 +02:00
Alexander KukushkinandGitHub 5265e71fc2 Don't write leader optime into DCS if it didn't changed (#319) 2016-09-21 14:22:22 +02:00
Alexander KukushkinandGitHub 10c7fa41f3 Exclude unhealthy nodes when choosing where to clone from (#313)
Node MUST have tag clonefrom: true, be in the 'running' state and also
we should not try to clone from itself.
2016-09-21 09:42:48 +02:00
Alexander KukushkinandGitHub 7ca55359de Demote immediately if failed to update leader lock (#316)
If the Etcd node partitioned from rest of the cluster it is still
possible to read from it (though it returns some stale information),
but it is not possible to write into it.
Previously Patroni was trying to fetch the new cluster view from DCS in
order to figure out is it still the leader or not and Etcd is always
returning stale info where the node still owns the leader key, but with
negative TTL.
This weird bug clearly shows how dangerous premature optimization is.
2016-09-20 15:45:21 +02:00
lothar schulzandOleksii Kliukin 1100136d8d contribution details moved to contribution.md (#311)
* adapted contribution hints and created contribution.md
2016-09-20 14:35:29 +02:00
Alexander KukushkinandGitHub 453e68637a Don't try to remove leader key when running ctl on the leader node (#302) 2016-09-19 13:33:24 +02:00
Alexander KukushkinandGitHub 0b1bfeca5b Make sure that we are running and testing latest versions of everything (#303) 2016-09-19 13:32:53 +02:00
Alexander KukushkinandGitHub 5c8399e4fa Make sure data directory is empty before trying to restore backup (#307)
We are doing number of attempts when trying to initialize replica using
different methods. Any of this attemp may create and put something into
data directory, what causes next attempts fail.
In addition to that improve logging when creating replica.
2016-09-19 13:32:27 +02:00
Alexander KukushkinandGitHub 540ee2b3c7 Bugfix/fast recover (#300)
* reap children before and after running HA loop

When the Patroni is running in a docker container with the pid=1 it is
also responsible for reaping of all dead processes. We can't call
os.waitpid immediately after receiving SIGCHLD because it breaks
subprocess module. It simply stops receiving exit codes of the processes
it executes because these processes. That's why we just registering the
fact of receiving SIGCHLD and reaping children only after execution of
HA loop.
If the postmaster was dying for some reason, Patroni was able to detect
this fact only on the next iteration of HA loop, because zombie
processes where still there and it was possible to send 0 signal to it.
To avoid such situation we should also reap all dead processes before
executing HA loop.

* Don't rely on _cursor_holder when closing connection

it could happen that connection has been opened but not cursor...

* Don't "retry" when fetching current xlog location and it fails

On every iteration of HA loop we are updaing member key in DCS and among
other data there is current xlog location stored in the value.
If the postgres has died for some reason it is not possible to fetch
xlog position and we are just wasting retry_timeout/2 = 5 seconds there.
If this information will be missing from DCS during period of one HA
loop nothing should break. Patroni is not relying on this information
anyway. When it is doing manual or automatic failover it aways
communicates with other nodes directly to get the most fresh
infomation.

* Don't try to update leader optime when postgres is not 100% healthy

`update_lock` method is not only doing update of the leader lock but
also writes the most recent value of xlog position into optime/leader
key. If you know that postgres can be not 100% healthy because it is in
process of restart or recover we should not try to fetch current xlog
position and update 'optime/leader'. Previously we were using
`AsyncExecutor.busy` property for avoiding of such action, but I think
we should be more excpilicit and do the update only if we know that
postgres is 100% healty.
2016-09-14 15:13:01 +02:00
Feike SteenbergenandGitHub dc259298dc Merge pull request #297 from ender74/python-wheel
added explicit dependency for python-wheel
2016-09-09 15:28:31 +02:00
Heiko Hüter 41cdbc0304 added explicit dependency for python-wheel 2016-09-09 14:56:33 +02:00
Feike SteenbergenandGitHub 0d70199941 Merge pull request #293 from zalando/release1.1
Bumped version to 1.1
2016-09-07 15:01:56 +02:00
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
133 changed files with 21450 additions and 4974 deletions
+54 -6
View File
@@ -1,11 +1,59 @@
data/*
*.pyc
*.egg/
*.egg-info/
*.py[cod]
# 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
.eggs/
build/
.tox
nosetests.xml
coverage.xml
htmlcov
junit.xml
features/output
dummy
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
pgpass
scm-source.json
# Sphinx-generated documentation
docs/build/
docs/source/_static/
docs/source/_templates/
# Pycharm IDE
.idea/
#VSCode IDE
.vscode/
+121 -46
View File
@@ -1,82 +1,157 @@
sudo: false
sudo: true
dist: trusty
language: python
python:
- "3.5"
addons:
apt:
packages:
- postgresql-contrib-9.5
postgresql: "9.5"
- expect-dev # for unbuffer
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"
- ETCDVERSION=3.0.17 ZKVERSION=3.4.11 CONSULVERSION=0.7.4
- PYVERSIONS="2.7 3.5 3.6"
- EXCLUDE_BEHAVE="3.5"
- BOTO_CONFIG=/doesnotexist
matrix:
include:
- python: "3.5"
env: TEST_SUITE="python setup.py"
- python: "3.6"
env: DCS="etcd" TEST_SUITE="behave"
- python: "3.6"
env: DCS="exhibitor" TEST_SUITE="behave"
- python: "3.6"
env: DCS="consul" TEST_SUITE="behave"
- python: "3.6"
env: DCS="kubernetes" TEST_SUITE="behave"
branches:
only:
- master
- /^v\d+\.\d+(\.\d+)?$/
cache:
directories:
- $HOME/virtualenv/python2.7.9
- $HOME/virtualenv/python3.4.2
- $HOME/virtualenv/python3.5.0
- $HOME/mycache
before_cache:
- |
rm -fr $HOME/mycache/python*
for pv in $PYVERSIONS; do
if [[ $TEST_SUITE != "behave" || $pv != $EXCLUDE_BEHAVE ]]; then
fpv=$(basename $(readlink $HOME/virtualenv/python${pv}))
mv $HOME/virtualenv/${fpv} $HOME/mycache/${fpv}
fi
done
install:
- |
set -e
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
function get_consul() {
CC=~/mycache/consul_${CONSULVERSION}
if [[ ! -x $CC ]]; then
curl -L https://releases.hashicorp.com/consul/${CONSULVERSION}/consul_${CONSULVERSION}_linux_amd64.zip \
| gunzip > $CC
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
chmod +x $CC
fi
ln -s $CC consul
}
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
function get_etcd() {
EC=~/mycache/etcd_${ETCDVERSION}
if [[ ! -x $EC ]]; 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
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
mv etcd $EC
fi
ln -s $EC etcd
}
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
function get_kubernetes() {
wget -O localkube "https://storage.googleapis.com/minikube/k8sReleases/v1.7.0/localkube-linux-amd64"
chmod +x localkube
sudo nohup ./localkube --logtostderr=true --enable-dns=false > localkube.log 2>&1 &
echo "Waiting for localkube to start..."
if ! timeout 120 sh -c "while ! curl -ks http://127.0.0.1:8080/ >/dev/null; do sleep 1; done"; then
sudo cat localkube.log
echo "localkube did not start"
exit 1
fi
echo "Check certificate permissions"
sudo chmod 644 /var/lib/localkube/certs/*
sudo ls -altr /var/lib/localkube/certs/
echo "Set up .kube/config"
mkdir ~/.kube
echo -e "apiVersion: v1\nclusters:\n- cluster:\n certificate-authority: /var/lib/localkube/certs/ca.crt\n server: https://127.0.0.1:8443\n name: local\ncontexts:\n- context:\n cluster: local\n user: myself\n name: local\ncurrent-context: local\nkind: Config\npreferences: {}\nusers:\n- name: myself\n user:\n client-certificate: /var/lib/localkube/certs/apiserver.crt\n client-key: /var/lib/localkube/certs/apiserver.key\n" > ~/.kube/config
}
function get_exhibitor() {
ZC=~/mycache/zookeeper-${ZKVERSION}
if [[ ! -d $ZC ]]; then
curl -L http://www.apache.org/dist/zookeeper/zookeeper-${ZKVERSION}/zookeeper-${ZKVERSION}.tar.gz | tar xz
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
mv zookeeper-${ZKVERSION}/conf/zoo_sample.cfg zookeeper-${ZKVERSION}/conf/zoo.cfg
mv zookeeper-${ZKVERSION} $ZC
fi
$ZC/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
}
attempt_num=1
until get_${DCS}; do
[[ $attempt_num -ge 3 ]] && exit 1
echo "Attempt $attempt_num failed! Trying again in $attempt_num seconds..."
sleep $(( attempt_num++ ))
done
fi
for pv in "2.7" "3.4" "3.5"; do
source ~/virtualenv/python${pv}/bin/activate
# explicitly install all needed python modules to cache them
for p in '-r requirements.txt' 'behave codacy-coverage coverage coveralls flake8 mock pytest-cov pytest'; do
pip install $p
done
for pv in $PYVERSIONS; do
if [[ $TEST_SUITE != "behave" || $pv != $EXCLUDE_BEHAVE ]]; then
fpv=$(basename $(readlink $HOME/virtualenv/python$pv))
if [[ -d ~/mycache/${fpv} ]]; then
mv ~/virtualenv/${fpv} ~/virtualenv/${fpv}.bckp
mv ~/mycache/${fpv} ~/virtualenv/${fpv}
fi
source ~/virtualenv/python${pv}/bin/activate
# explicitly install all needed python modules to cache them
for p in '-r requirements.txt' 'psycopg2-binary behave codacy-coverage coverage coveralls flake8 mock pytest-cov pytest setuptools'; do
pip install $p --upgrade
done
fi
done
script:
- |
for pv in "2.7" "3.4" "3.5"; do
for pv in $PYVERSIONS; do
if [[ $TEST_SUITE == "behave" && $pv == $EXCLUDE_BEHAVE ]]; then
continue
fi
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
if [[ $TEST_SUITE != "behave" ]]; then
echo Running unit tests using python${pv}
$TEST_SUITE test
unbuffer $TEST_SUITE test
$TEST_SUITE flake8
elif [[ $pv != $EXCLUDE_BEHAVE ]]; then
echo Running acceptance tests using python${pv}
if ! PATH=.:/usr/lib/postgresql/9.6/bin:$PATH unbuffer $TEST_SUITE; then
# output all log files when tests are failing
grep . features/output/*_failed/*postgres?.*
exit 1
fi
fi
done
set +e
after_success:
# before_cache is executed earlier than after_success, so we need to restore one of virtualenv directories
- fpv=$(basename $(readlink $HOME/virtualenv/python3.6)) && mv $HOME/mycache/${fpv} $HOME/virtualenv/${fpv}
- coveralls
- if [[ $TEST_SUITE != "behave" ]]; then python-codacy-coverage -r coverage.xml; fi
- if [[ $DCS == "exhibitor" ]]; then ~/mycache/zookeeper-${ZKVERSION}/bin/zkServer.sh stop; fi
- sudo kill $(jobs -p)
-12
View File
@@ -1,12 +0,0 @@
approvals:
# PR needs at least 4 approvals
minimum: 1
# approval = comment that matches this regex
pattern: "^:?\\+1:?$"
from:
# commenter must be either one of:
# a public zalando org member
orgs:
- zalando
# a collaborator of the repo
collaborators: true
+145 -28
View File
@@ -1,41 +1,158 @@
## 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
FROM ubuntu:14.04
MAINTAINER Feike Steenbergen <[email protected]>
ARG PG_MAJOR=10
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
ARG LC_ALL=C.UTF-8
ARG LANG=C.UTF-8
# We need curl
RUN apt-get update -y && apt-get install curl -y
FROM postgres:$PG_MAJOR as builder
# 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
ARG PGHOME
ARG PGDATA
ARG LC_ALL
ARG LANG
ENV PGVERSION 9.5
RUN apt-get install postgresql-${PGVERSION} postgresql-server-dev-${PGVERSION} -y
RUN apt-get install python python-dev python-pip -y
ADD requirements-py2.txt /requirements-py2.txt
RUN pip install -r /requirements-py2.txt
ENV ETCDVERSION=2.3.8 CONFDVERSION=0.16.0
ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH
RUN set -ex \
&& export DEBIAN_FRONTEND=noninteractive \
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
&& apt-get update -y \
# postgres:10 is based on debian, which has the patroni package. We will install all required dependencies
&& apt-cache depends patroni | sed -n -e 's/.*Depends: \(python3-.\+\)$/\1/p' \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim curl less jq locales haproxy sudo \
python3-etcd python3-kazoo python3-pip busybox \
net-tools iputils-ping --fix-missing \
&& pip3 install dumb-init \
\
# Cleanup all locales but en_US.UTF-8
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
&& find /usr/share/i18n/locales/ -type f ! -name en_US ! -name en_GB ! -name i18n ! -name iso14651_t1 ! -name iso14651_t1_common ! -name 'translit_*' -delete \
&& echo 'en_US.UTF-8 UTF-8' > /usr/share/i18n/SUPPORTED \
\
# Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
\
# haproxy dummy config
&& echo 'global\n stats socket /run/haproxy/admin.sock mode 660 level admin' > /etc/haproxy/haproxy.cfg \
\
# vim config
&& echo 'syntax on\nfiletype plugin indent on\nset mouse-=a\nautocmd FileType yaml setlocal ts=2 sts=2 sw=2 expandtab' > /etc/vim/vimrc.local \
\
# Prepare postgres/patroni/haproxy environment
&& mkdir -p $PGHOME/.config/patroni /patroni /run/haproxy \
&& ln -s ../../postgres0.yml $PGHOME/.config/patroni/patronictl.yaml \
&& ln -s /patronictl.py /usr/local/bin/patronictl \
&& sed -i "s|/var/lib/postgresql.*|$PGHOME:/bin/bash|" /etc/passwd \
&& chown -R postgres:postgres /var/log \
\
# Download etcd
&& curl -sL 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 \
\
# Download confd
&& curl -sL https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-amd64 \
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \
\
# Clean up all useless packages and some files
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \
exim4-config gnupg-agent dirmngr libpython2.7-stdlib libpython2.7-minimal \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \
/root/.cache \
/var/cache/debconf/* \
/etc/rc?.d \
/etc/systemd \
/docker-entrypoint* \
/sbin/pam* \
/sbin/swap* \
/sbin/unix* \
/usr/local/bin/gosu \
/usr/sbin/[acgipr]* \
/usr/sbin/*user* \
/usr/share/doc* \
/usr/share/man \
/usr/share/info \
/usr/share/i18n/locales/translit_hangul \
/usr/share/locale/?? \
/usr/share/locale/??_?? \
/usr/share/postgresql/*/man \
/usr/share/postgresql-common/pg_wrapper \
/usr/share/vim/vim80/doc \
/usr/share/vim/vim80/lang \
/usr/share/vim/vim80/tutor \
# /var/lib/dpkg/info/* \
&& find /usr/bin -xtype l -delete \
&& find /var/log -type f -exec truncate --size 0 {} \; \
&& find /usr/lib/python3/dist-packages -name '*test*' | xargs rm -fr \
&& find /lib/x86_64-linux-gnu/security -type f ! -name pam_env.so ! -name pam_permit.so ! -name pam_unix.so -delete
ADD patroni.py /patroni.py
ADD patronictl.py /patronictl.py
ADD patroni/ /patroni
# perform compression if it is necessary
ARG COMPRESS
RUN if [ "$COMPRESS" = "true" ]; then \
set -ex \
# Allow certain sudo commands from postgres
&& echo 'postgres ALL=(ALL) NOPASSWD: /bin/tar xpJf /a.tar.xz -C /, /bin/rm /a.tar.xz, /bin/ln -snf dash /bin/sh' >> /etc/sudoers \
&& ln -snf busybox /bin/sh \
&& files="/bin/sh /usr/bin/sudo /usr/lib/sudo/sudoers.so /lib/x86_64-linux-gnu/security/pam_*.so" \
&& libs="$(ldd $files | awk '{print $3;}' | grep '^/' | sort -u) /lib/x86_64-linux-gnu/ld-linux-x86-64.so.* /lib/x86_64-linux-gnu/libnsl.so.* /lib/x86_64-linux-gnu/libnss_compat.so.*" \
&& (echo /var/run $files $libs | tr ' ' '\n' && realpath $files $libs) | sort -u | sed 's/^\///' > /exclude \
&& find /etc/alternatives -xtype l -delete \
&& save_dirs="usr lib var bin sbin etc/ssl etc/init.d etc/alternatives etc/apt" \
&& XZ_OPT=-e9v tar -X /exclude -cpJf a.tar.xz $save_dirs \
# we call "cat /exclude" to avoid including files from the $save_dirs that are also among
# the exceptions listed in the /exclude, as "uniq -u" eliminates all non-unique lines.
# By calling "cat /exclude" a second time we guarantee that there will be at least two lines
# for each exception and therefore they will be excluded from the output passed to 'rm'.
&& /bin/busybox sh -c "(find $save_dirs -not -type d && cat /exclude /exclude && echo exclude) | sort | uniq -u | xargs /bin/busybox rm" \
&& /bin/busybox --install -s \
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
fi
RUN ln -s /patroni.py /usr/local/bin/patroni
RUN ln -s /patronictl.py /usr/local/bin/patronictl
FROM scratch
COPY --from=builder / /
ENV ETCDVERSION 2.2.5
RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C /bin --strip=1 --wildcards --no-anchored etcd etcdctl
LABEL maintainer="Alexander Kukushkin <[email protected]>"
### 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 chown postgres:postgres -R /patroni/ /data/ /pgpass /var/log/etcd.* /patroni/postgres.yml
ADD docker/entrypoint.sh /entrypoint.sh
ARG PG_MAJOR
ARG COMPRESS
ARG PGHOME
ARG PGDATA
ARG LC_ALL
ARG LANG
EXPOSE 4001 5432 2380
ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
COPY patroni /patroni/
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
COPY extras/confd/templates/haproxy.tmpl /etc/confd/templates/
COPY patroni*.py docker/entrypoint.sh /
COPY postgres?.yml $PGHOME/
WORKDIR $PGHOME
RUN sed -i 's/env python/&3/' /patroni*.py \
# "fix" patroni configs
&& sed -i 's/^\( connect_address:\| - host\)/#&/' postgres?.yml \
&& sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \
&& sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \
&& sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \
&& sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \
&& sed -i 's/^\(scope\|name\|etcd\| host\| authentication\| pg_hba\| parameters\):/#&/' postgres?.yml \
&& sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \
&& sed -i 's/^ parameters:/ pg_hba:\n - local all all trust\n - host replication all all md5\n - host all all all md5\n&\n max_connections: 100/' postgres?.yml \
&& if [ "$COMPRESS" = "true" ]; then chmod u+s /usr/bin/sudo; fi \
&& chmod +s /bin/ping \
&& chown -R postgres:postgres $PGHOME /run /etc/haproxy
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
USER postgres
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
+131 -165
View File
@@ -1,34 +1,138 @@
|Build Status| |Coverage Status|
Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul
------------------------------------------------------------
--------------------------------------------------------------------
Patroni was previously known as Governor.
You can find a version of this documentation that is searchable and also easier to navigate at `patroni.readthedocs.io <https://patroni.readthedocs.io>`__.
*There are many ways to run high availability with PostgreSQL. Here, we
present a template for you to create your own customized, high-availability
solution using Python and — for maximum accessibility — a distributed
configuration store like ZooKeeper, etcd or Consul.*
Getting Started
---------------
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 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>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. 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.
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.
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
.. 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:
* `Elephants on Automatic: HA Clustered PostgreSQL with Helm <https://www.youtube.com/watch?v=CftcVhFMGSY>`_, talk by Josh Berkus and Oleksii Kliukin at KubeCon Berlin 2017
* `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/docs/CONTRIBUTING.rst>`__ section below for more details.
We report new releases information `here <https://github.com/zalando/patroni/releases>`__.
=========
Community
=========
There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel #patroni in the `PostgreSQL Slack <https://postgres-slack.herokuapp.com/>`__. If you're using Patroni, or just interested, please join us.
===================================
Technical Requirements/Installation
===================================
**Pre-requirements for Mac OS**
To install requirements on a Mac, run the following:
::
brew install postgresql etcd haproxy libyaml python
**Psycopg2**
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
There are a few options available:
1. Use the package manager from your distro
::
sudo apt-get install python-psycopg2 # install python2 psycopg2 module on Debian/Ubuntu
sudo apt-get install python3-psycopg2 # install python3 psycopg2 module on Debian/Ubuntu
sudo yum install python-psycopg2 # install python2 psycopg2 on RedHat/Fedora/CentOS
2. Install psycopg2 from the binary package
::
pip install psycopg2-binary
3. Install psycopg2 from source
::
pip install psycopg2>=2.5.4
**General installation for pip**
Patroni can be installed with pip:
::
pip install patroni[dependencies]
where dependencies can be either empty, or consist of one or more of the following:
etcd
`python-etcd` module in order to use Etcd as DCS
consul
`python-consul` module in order to use Consul as DCS
zookeeper
`kazoo` module in order to use Zookeeper as DCS
exhibitor
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
kubernetes
`kubernetes` module in order to use Kubernetes as DCS in Patroni
aws
`boto` in order to use AWS callbacks
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
::
pip install patroni[etcd,aws]
Note that external tools to call in the replica creation or custom bootstap scripts (i.e. WAL-E) should be installed independently of Patroni.
=======================
Running and Configuring
=======================
To get started, do the following from different terminals:
::
> etcd --data-dir=data/etcd
> ./patroni.py postgres0.yml
> ./patroni.py postgres1.yml
From there, you will see a high-availability cluster start up. Test
different settings in the YAML files to see how its behavior changes. Kill
some of the components to see how the system behaves.
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.
Add more ``postgres*.yml`` files to create an even larger cluster.
We provide a haproxy configuration, which will give your application a
single endpoint for connecting to the cluster's leader. To configure,
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,
run:
::
@@ -39,167 +143,29 @@ run:
> psql --host 127.0.0.1 --port 5000 postgres
How Patroni Works
-----------------
For a diagram of the high availability decision loop, review this PDF:
`postgres-ha.pdf <https://github.com/zalando/patroni/blob/master/postgres-ha.pdf>`__
==================
YAML Configuration
------------------
==================
For an example file, see ``postgres0.yml``. Regarding settings:
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>`__.
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *loop\_wait*: the number of seconds the loop will sleep
=========================
Environment Configuration
=========================
- *restapi*:
- *listen*: IP address + port that Patroni will listen to, to provide health-check information for haproxy.
- *connect\_address*: IP address + port through which restapi is accessible.
- *auth*: (optional) 'username:password' to protect dangerous REST API endpoints.
- *certfile*: (optional) Specifies a file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- *keyfile*: (optional) Specifies a file with the secret key in the PEM format.
- *etcd*:
- *scope*: the relative path used on etcd's HTTP API for this deployment; makes it possible to run multiple HA deployments from a single etcd cluster.
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *host*: the host:port for the etcd endpoint.
- *consul*:
- *scope*: the relative path used on Consul's HTTP API for this deployment; makes it possible to run multiple HA deployments from a single Consul cluster.
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *host*: the host:port for the Consul endpoint.
- *zookeeper*:
- *scope*: the relative path used on ZooKeeper for this deployment; makes it possible to run multiple HA deployments from a single ZooKeeper cluster.
- *session\_timeout*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
- *reconnect\_timeout*: how long we should try to reconnect to ZooKeeper after a connection loss. After this timeout, assume that you no longer have a lock and restart in read-only mode.
- *hosts*: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...']
- *exhibitor*: if you are running a ZooKeeper cluster under the Exhibitor supervisory, the following section might interest you:
- *poll\_interval*: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor
- *port*: Exhibitor port.
- *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: ['host1', 'host2', 'etc...' ]. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- *postgresql*:
- *name*: the name of the Postgres host. Must be unique for the cluster.
- *listen*: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. The first address from this list will be used by Patroni to establish local connections to the PostgreSQL node.
- *connect\_address*: IP address + port through which Postgres is accessible from other nodes and applications.
- *data\_dir*: file path to initialize and store Postgres data files.
- *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag.
- *use\_slots*: whether or not to use replication_slots. Must be False for PostgreSQL 9.3. You should comment out max_replication_slots before it becomes ineligible for leader status.
- *initdb*: List options to be passed on to initdb
- *encoding*: default encoding for new databases
- *locale*: default locale for new databases
- *data-checksums* # When pg_rewind is needed on 9.3, this needs to be enabled
- *pg\_hba*: list of lines which should be added to pg\_hba.conf.
- *- host all all 0.0.0.0/0 md5*.
- *- host replication replicator 127.0.0.1/32 md5* # A line like this is required for replication
- *replication*:
- *username*: replication username; user will be created during initialization.
- *password*: replication password; 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 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 the Postgres user, set during initialization.
- *admin*:
- *username*: admin username; user is created during initialization. It will have CREATEDB and CREATEROLE privileges.
- *password*: admin password; user is 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.
- *create\_replica\_methods*: an ordered list of the create methods for turning a patroni node into a new replica.
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured
as its own config item.
- *replica\_method* for each create_replica_method other than basebackup, you would add a configuration section
of the same name. At a minimum, this should include "command" with a full path to the actual script to be
executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
Go `here <https://github.com/zalando/patroni/blob/master/docs/ENVIRONMENT.rst>`__ for comprehensive information about configuring(overriding) settings via environment variables.
===================
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 optimal 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: 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 significantly increases the variability of write
performance. If followers become inaccessible from the leader, the
leader effectively becomes readonly.
To enable a simple synchronous replication test, add the follow lines to
the ``parameters`` section of your YAML configuration files:
.. code:: YAML
synchronous_commit: "on"
synchronous_standby_names: "*"
When using synchronous replication, use at least three Postgres data nodes
to ensure write availability if one host fails.
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.
Patroni uses Postgres' streaming replication, which is asynchronous by default. 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 leader. This setting should be increased or decreased based on business requirements. It's also possible to use synchronous replication for better durability guarantees. See `replication modes documentation <https://github.com/zalando/patroni/blob/master/docs/replication_modes.rst>`__ for details.
======================================
Applications Should Not Use Superusers
--------------------------------------
======================================
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
undesireable.
Requirements on a Mac
---------------------
Run the following on a Mac to install requirements:
::
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 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.
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.
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
:target: https://travis-ci.org/zalando/patroni
+1 -5
View File
@@ -1,15 +1,11 @@
Failover
========
- 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 patroni from changing the cluster state.
Configuration
==============
- Provide a way to change postgresql.conf and pg_hba.conf of a running cluster on the Patroni level, without changing individual nodes.
- Provide 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.
- Implement patronictl command to create initial configuration of the cluster with leader and member keys fixed to the user-supplied values in order to simplify migrations.
- Implement support for consul in addtion to etcd and zookeeper
- Complete zookeeper support in patronictl
Documentation
==============
+69
View File
@@ -0,0 +1,69 @@
# docker compose file for running a 3-node PostgreSQL cluster
# with 3-node etcd cluster as the DCS and one haproxy node
version: "2"
networks:
demo:
services:
etcd1:
image: patroni
networks: [ demo ]
env_file: docker/etcd.env
container_name: demo-etcd1
hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
etcd2:
image: patroni
networks: [ demo ]
env_file: docker/etcd.env
container_name: demo-etcd2
hostname: etcd2
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
etcd3:
image: patroni
networks: [ demo ]
env_file: docker/etcd.env
container_name: demo-etcd3
hostname: etcd3
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
patroni1:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni1
container_name: demo-patroni1
environment:
PATRONI_NAME: patroni1
patroni2:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni2
container_name: demo-patroni2
environment:
PATRONI_NAME: patroni2
patroni3:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni3
container_name: demo-patroni3
environment:
PATRONI_NAME: patroni3
haproxy:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: haproxy
container_name: demo-haproxy
ports:
- "5000:5000"
- "5001:5001"
command: haproxy
+103 -33
View File
@@ -1,47 +1,117 @@
# Patroni Dockerfile
You can run Patroni in a docker container using this Dockerfile, or by using one of the Docker image at
https://registry.opensource.zalan.do/v1/repositories/acid/patroni/tags
You can run Patroni in a docker container using this Dockerfile
This Dockerfile is meant in aiding development of Patroni and quick testing of features. It is not a production-worthy
Dockerfile
docker build -t patroni .
# Examples
## Standalone Patroni
docker run -d registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT
docker run -d patroni
## Multiple Patroni's communicating with a standalone etcd inside Docker
Basically what you would do would be:
* Run 1 container which provides etcd
docker run -d <IMAGE> --etcd-only
* Run n containers running Patroni, passing the `--etcd` option to the `docker run` command
docker run -d <IMAGE> --etcd=<IP FROM etcd CONTAINER:PORT>
To automate this you can run the following script:
dev_patroni_cluster.sh [OPTIONS]
Options:
--image IMAGE The Docker image to use for the cluster
--members INT The number of members for the cluster
--name NAME The name of the new cluster
## Three-node Patroni cluster with three-node etcd cluster and one haproxy container using docker-compose
Example session:
$ ./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
Started Patroni container 67e611f2eca7c40f9e6e0e24a4a8f2cba7e3e56d22a420e15ab9240a37a9d7a4, ip=172.17.1.25
Started Patroni container 47dd12ae635ab83b039f5889e250048b606ed5e48e3650b69e365e7e1d4acbcf, ip=172.17.1.26
$ docker-compose up -d
Creating demo-haproxy ...
Creating demo-patroni2 ...
Creating demo-patroni1 ...
Creating demo-patroni3 ...
Creating demo-etcd2 ...
Creating demo-etcd1 ...
Creating demo-etcd3 ...
Creating demo-haproxy
Creating demo-patroni2
Creating demo-patroni1
Creating demo-patroni3
Creating demo-etcd1
Creating demo-etcd2
Creating demo-etcd2 ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
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 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 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
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5b7a90b4cfbf patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd2
e30eea5222f2 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd1
83bcf3cb208f patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd3
922532c56e7d patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni3
14f875e445f3 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni2
110d1073b383 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni1
5af5e6e36028 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy
$ docker logs demo-patroni1
2019-02-20 08:19:32,714 INFO: Failed to import patroni.dcs.consul
2019-02-20 08:19:32,737 INFO: Selected new etcd server http://etcd3:2379
2019-02-20 08:19:35,140 INFO: Lock owner: None; I am patroni1
2019-02-20 08:19:35,174 INFO: trying to bootstrap a new cluster
...
2019-02-20 08:19:39,310 INFO: postmaster pid=37
2019-02-20 08:19:39.314 UTC [37] LOG: listening on IPv4 address "0.0.0.0", port 5432
2019-02-20 08:19:39.321 UTC [37] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2019-02-20 08:19:39.353 UTC [39] LOG: database system was shut down at 2019-02-20 08:19:36 UTC
2019-02-20 08:19:39.354 UTC [40] FATAL: the database system is starting up
localhost:5432 - rejecting connections
2019-02-20 08:19:39.369 UTC [37] LOG: database system is ready to accept connections
localhost:5432 - accepting connections
2019-02-20 08:19:39,383 INFO: establishing a new patroni connection to the postgres cluster
2019-02-20 08:19:39,408 INFO: running post_bootstrap
2019-02-20 08:19:39,432 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'"
2019-02-20 08:19:39,515 INFO: initialized a new cluster
2019-02-20 08:19:49,424 INFO: Lock owner: patroni1; I am patroni1
2019-02-20 08:19:49,447 INFO: Lock owner: patroni1; I am patroni1
2019-02-20 08:19:49,480 INFO: no action. i am the leader with the lock
2019-02-20 08:19:59,422 INFO: Lock owner: patroni1; I am patroni1
$ docker exec -ti demo-patroni1 bash
postgres@patroni1:~$ patronictl list
+---------+----------+------------+--------+---------+----+-----------+
| Cluster | Member | Host | Role | State | TL | Lag in MB |
+---------+----------+------------+--------+---------+----+-----------+
| demo | patroni1 | 172.22.0.3 | Leader | running | 1 | 0 |
| demo | patroni2 | 172.22.0.7 | | running | 1 | 0 |
| demo | patroni3 | 172.22.0.4 | | running | 1 | 0 |
+---------+----------+------------+--------+---------+----+-----------+
postgres@patroni1:~$ etcdctl ls --recursive --sort -p /service/demo
/service/demo/config
/service/demo/initialize
/service/demo/leader
/service/demo/members/
/service/demo/members/patroni1
/service/demo/members/patroni2
/service/demo/members/patroni3
/service/demo/optime/
/service/demo/optime/leader
postgres@patroni1:~$ etcdctl member list
1bab629f01fa9065: name=etcd3 peerURLs=http://etcd3:2380 clientURLs=http://etcd3:2379 isLeader=false
8ecb6af518d241cc: name=etcd2 peerURLs=http://etcd2:2380 clientURLs=http://etcd2:2379 isLeader=true
b2e169fcb8a34028: name=etcd1 peerURLs=http://etcd1:2380 clientURLs=http://etcd1:2379 isLeader=false
postgres@patroni1:~$ exit
$ psql -h localhost -p 5000 -U postgres -W
Password: postgres
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
Type "help" for help.
localhost/postgres=# select pg_is_in_recovery();
pg_is_in_recovery
───────────────────
f
(1 row)
localhost/postgres=# \q
$ psql -h localhost -p 5001 -U postgres -W
Password: postgres
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
Type "help" for help.
localhost/postgres=# select pg_is_in_recovery();
pg_is_in_recovery
───────────────────
t
(1 row)
-90
View File
@@ -1,90 +0,0 @@
#!/bin/bash
DOCKER_IMAGE="registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT"
MEMBERS=3
function usage()
{
cat <<__EOF__
Usage: $0
Options:
--image IMAGE The Docker image to use for the cluster
--members INT The number of members for the cluster
--name NAME The name of the new cluster
Examples:
$0 --image ${DOCKER_IMAGE}
$0
$0 --image ${DOCKER_IMAGE} --members=2
__EOF__
}
optspec=":-:"
while getopts "$optspec" optchar; do
case "${optchar}" in
-)
case "${OPTARG}" in
help)
usage
exit 0
;;
name)
PATRONI_SCOPE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
;;
name=*)
PATRONI_SCOPE="${OPTARG#*=}"
;;
image)
DOCKER_IMAGE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
;;
image=*)
DOCKER_IMAGE="${OPTARG#*=}"
;;
members)
MEMBERS="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
;;
members=*)
MEMBERS="${OPTARG#*=}"
;;
*)
if [ "$OPTERR" = 1 ] && [ "${optspec:0:1}" != ":" ]; then
echo "Unknown option --${OPTARG}" >&2
fi
;;
esac;;
*)
if [ "$OPTERR" != 1 ] || [ "${optspec:0:1}" = ":" ]; then
echo "Non-option argument: '-${OPTARG}'" >&2
usage
exit 1
fi
;;
esac
done
function random_name()
{
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
etcd_container=$(docker run -P -d --name="${PATRONI_SCOPE}_etcd" "${DOCKER_IMAGE}" --etcd-only)
etcd_container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${etcd_container})
echo "The etcd container is ${etcd_container}, ip=${etcd_container_ip}"
for i in $(seq 1 "${MEMBERS}")
do
container_name=$(random_name)
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})
echo "Started Patroni container ${patroni_container}, ip=${patroni_container_ip}"
done
+54 -139
View File
@@ -1,145 +1,60 @@
#!/bin/bash
#!/bin/sh
function usage()
{
cat <<__EOF__
Usage: $0
Options:
--etcd ETCD Provide an external etcd to connect to
--name NAME Give the cluster a specific name
--etcd-only Do not run Patroni, run a standalone etcd
Examples:
$0 --etcd=127.17.0.84:4001
$0 --etcd-only
$0
$0 --name=true_scotsman
__EOF__
}
DOCKER_IP=$(hostname --ip-address)
PATRONI_SCOPE=${PATRONI_SCOPE:-batman}
optspec=":vh-:"
while getopts "$optspec" optchar; do
case "${optchar}" in
-)
case "${OPTARG}" in
etcd-only)
exec etcd --data-dir /tmp/etcd.data \
-advertise-client-urls=http://${DOCKER_IP}:4001 \
-listen-client-urls=http://0.0.0.0:4001 \
-listen-peer-urls=http://0.0.0.0:2380
exit 0
;;
cheat)
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)
usage
exit 0
;;
*)
if [ "$OPTERR" = 1 ] && [ "${optspec:0:1}" != ":" ]; then
echo "Unknown option --${OPTARG}" >&2
fi
;;
esac;;
*)
if [ "$OPTERR" != 1 ] || [ "${optspec:0:1}" = ":" ]; then
echo "Non-option argument: '-${OPTARG}'" >&2
usage
exit 1
fi
;;
esac
done
if [ -z ${ETCD_CLUSTER} ]
then
etcd --data-dir /tmp/etcd.data \
-advertise-client-urls=http://${DOCKER_IP}:4001 \
-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"
if [ -f /a.tar.xz ]; then
echo "decompressing image..."
sudo tar xpJf /a.tar.xz -C / > /dev/null 2>&1
sudo rm /a.tar.xz
sudo ln -snf dash /bin/sh
fi
mkdir -p ~postgres/.config/patroni
cat > ~postgres/.config/patroni/patronictl.yaml <<__EOF__
{dcs_api: 'etcd://${ETCD_CLUSTER}', namespace: /service/}
__EOF__
readonly PATRONI_SCOPE=${PATRONI_SCOPE:-batman}
PATRONI_NAMESPACE=${PATRONI_NAMESPACE:-/service}
readonly PATRONI_NAMESPACE=${PATRONI_NAMESPACE%/}
readonly DOCKER_IP=$(hostname --ip-address)
cat > /patroni/postgres.yaml <<__EOF__
case "$1" in
haproxy)
haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D
CONFD="confd -prefix=$PATRONI_NAMESPACE/$PATRONI_SCOPE -interval=10 -backend"
if [ ! -z "$PATRONI_ZOOKEEPER_HOSTS" ]; then
while ! /usr/share/zookeeper/bin/zkCli.sh -server $PATRONI_ZOOKEEPER_HOSTS ls /; do
sleep 1
done
exec dumb-init $CONFD zookeeper -node $PATRONI_ZOOKEEPER_HOSTS
else
while ! etcdctl cluster-health 2> /dev/null; do
sleep 1
done
exec dumb-init $CONFD etcd -node $(echo $ETCDCTL_ENDPOINTS | sed 's/,/ -node /g')
fi
;;
etcd)
exec "$@" -advertise-client-urls http://$DOCKER_IP:2379
;;
zookeeper)
exec /usr/share/zookeeper/bin/zkServer.sh start-foreground
;;
esac
ttl: &ttl 30
loop_wait: &loop_wait 10
scope: &scope '${PATRONI_SCOPE}'
namespace: 'patroni'
restapi:
listen: 0.0.0.0:8008
connect_address: ${DOCKER_IP}:8008
etcd:
scope: *scope
ttl: *ttl
host: ${ETCD_CLUSTER}
postgresql:
name: ${HOSTNAME}
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:
- 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
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: 'true'
max_wal_senders: 20
listen_addresses: 0.0.0.0
max_wal_size: 1GB
min_wal_size: 128MB
wal_keep_segments: 64
archive_timeout: 1800s
max_replication_slots: 20
hot_standby: "on"
__EOF__
cat /patroni/postgres.yaml
if [ ! -z $CHEAT ]
then
while :
do
sleep 60
done
else
exec python /patroni.py /patroni/postgres.yaml
## We start an etcd
if [ -z "$PATRONI_ETCD_HOSTS" ] && [ -z "$PATRONI_ZOOKEEPER_HOSTS" ]; then
export PATRONI_ETCD_URL="http://127.0.0.1:2379"
etcd --data-dir /tmp/etcd.data -advertise-client-urls=$PATRONI_ETCD_URL -listen-client-urls=http://0.0.0.0:2379 > /var/log/etcd.log 2> /var/log/etcd.err &
fi
export PATRONI_SCOPE
export PATRONI_NAMESPACE
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="${PATRONI_POSTGRESQL_DATA_DIR:-$PGDATA}"
export PATRONI_REPLICATION_USERNAME="${PATRONI_REPLICATION_USERNAME:-replicator}"
export PATRONI_REPLICATION_PASSWORD="${PATRONI_REPLICATION_PASSWORD:-replicate}"
export PATRONI_SUPERUSER_USERNAME="${PATRONI_SUPERUSER_USERNAME:-postgres}"
export PATRONI_SUPERUSER_PASSWORD="${PATRONI_SUPERUSER_PASSWORD:-postgres}"
exec python3 /patroni.py postgres0.yml
+5
View File
@@ -0,0 +1,5 @@
ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE=new
ETCD_INITIAL_CLUSTER_TOKEN=tutorial
+14
View File
@@ -0,0 +1,14 @@
PATRONI_SCOPE=demo
PATRONI_ETCD_HOSTS='etcd1:2379','etcd2:2379','etcd3:2379'
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
# for etcdctl
ETCDCTL_ENDPOINTS=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
+29
View File
@@ -0,0 +1,29 @@
.. _contributing:
Contributing guidelines
=======================
Wanna contribute to Patroni? Yay - here is how!
Chatting
--------
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel #patroni in the `PostgreSQL Slack <https://postgres-slack.herokuapp.com/>`__.
Reporting issues
----------------
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
Contributing a pull request
---------------------------
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
2) Do a fork, develop and test your code changes.
3) Include documentation
4) Submit a pull request.
You'll get feedback about your pull request as soon as possible.
Happy Patroni hacking ;-)
+136
View File
@@ -0,0 +1,136 @@
.. _environment:
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
Log
---
- **PATRONI\_LOG\_LEVEL**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **PATRONI\_LOG\_TRACEBACK\_LEVEL**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **PATRONI\_LOG\_LEVEL=DEBUG**.
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **PATRONI\_LOG\_DATEFORMAT**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **PATRONI\_LOG\_MAX\_QUEUE\_SIZE**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **PATRONI\_LOG\_DIR**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this env variable, the application will retain 4 25MB logs by default. You can tune those retention values with `PATRONI_LOG_FILE_NUM` and `PATRONI_LOG_FILE_SIZE` (see below).
- **PATRONI\_LOG\_FILE\_NUM**: The number of application logs to retain.
- **PATRONI\_LOG\_FILE\_SIZE**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **PATRONI\_LOG\_LOGGERS**: Redefine logging level per python module. Example ``PATRONI_LOG_LOGGERS="{patroni.postmaster: WARNING, urllib3: DEBUG}"``
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.
- **PATRONI\_CONSUL\_URL**: url for the Consul, in format: http(s)://host:port
- **PATRONI\_CONSUL\_PORT**: (optional) Consul port
- **PATRONI\_CONSUL\_SCHEME**: (optional) **http** or **https**, defaults to **http**
- **PATRONI\_CONSUL\_TOKEN**: (optional) ACL token
- **PATRONI\_CONSUL\_VERIFY**: (optional) whether to verify the SSL certificate for HTTPS requests
- **PATRONI\_CONSUL\_CACERT**: (optional) The ca certificate. If present it will enable validation.
- **PATRONI\_CONSUL\_CERT**: (optional) File with the client certificate
- **PATRONI\_CONSUL\_KEY**: (optional) File with the client key. Can be empty if the key is part of certificate.
- **PATRONI\_CONSUL\_DC**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
- **PATRONI\_CONSUL\_CONSISTENCY**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
- **PATRONI\_CONSUL\_CHECKS**: (optional) list of Consul health checks used for the session. If not specified Consul will use "serfHealth" in additional to the TTL based check created by Patroni. Additional checks, in particular the "serfHealth", may cause the leader lock to expire faster than in `ttl` seconds when the leader instance becomes unavailable.
- **PATRONI\_CONSUL\_REGISTER\_SERVICE**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, replica or standby-leader depending on the node's role. Defaults to **false**
- **PATRONI\_CONSUL\_SERVICE\_CHECK\_INTERVAL**: (optional) how often to perform health check against registered url
Etcd
----
- **PATRONI\_ETCD\_PROXY**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **PATRONI\_ETCD\_URL**
- **PATRONI\_ETCD\_URL**: url for the etcd, in format: http(s)://(username:password@)host:port
- **PATRONI\_ETCD\_HOSTS**: list of etcd endpoints in format 'host1:port1','host2:port2',etc...
- **PATRONI\_ETCD\_USE\_PROXIES**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster but stick to a fixed list of **hosts**.
- **PATRONI\_ETCD\_PROTOCOL**: http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
- **PATRONI\_ETCD\_HOST**: the host:port for the etcd endpoint.
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery.
- **PATRONI\_ETCD\_USERNAME**: username for etcd authentication.
- **PATRONI\_ETCD\_PASSWORD**: password for etcd authentication.
- **PATRONI\_ETCD\_CACERT**: The ca certificate. If present it will enable validation.
- **PATRONI\_ETCD\_CERT**: File with the client certificate.
- **PATRONI\_ETCD\_KEY**: File with the client key. Can be empty if the key is part of certificate.
ZooKeeper
---------
- **PATRONI\_ZOOKEEPER\_HOSTS**: comma separated list of ZooKeeper cluster members: "'host1:port1','host2:port2','etc...'". It is important to quote every single entity!
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.
.. _kubernetes_environment:
Kubernetes
----------
- **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`.
- **PATRONI\_KUBERNETES\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing Postgres role (`master` or `replica`). Patroni will set this label on the pod it is running in. Default value is `role`.
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
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\_CONFIG\_DIR**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable 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\_REPLICATION\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **PATRONI\_REPLICATION\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **PATRONI\_REPLICATION\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REPLICATION\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REPLICATION\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **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).
- **PATRONI\_SUPERUSER\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **PATRONI\_SUPERUSER\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **PATRONI\_SUPERUSER\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_SUPERUSER\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_SUPERUSER\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **PATRONI\_REWIND\_PASSWORD**: password for the user for ``pg_rewind``; the user will be created during initialization.
- **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **PATRONI\_REWIND\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REWIND\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REWIND\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
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.
- **PATRONI\_RESTAPI\_CAFILE**: Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **PATRONI\_RESTAPI\_VERIFY\_CLIENT**: ``none``, ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. If ``verify_client`` is set to ``optional`` or ``required`` basic-auth is not checked.
CTL
---
- **PATRONI\_CTL\_INSECURE**: Allow connections to REST API without verifying SSL certs.
- **PATRONI\_CTL\_CACERT**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_CERTFILE**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
- **PATRONI\_CTL\_KEYFILE**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter.
+20
View File
@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = Patroni
SOURCEDIR = .
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+156
View File
@@ -0,0 +1,156 @@
.. _readme:
============
Introduction
============
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 :ref:`Contributing <contributing>` section below for more details.
We report new releases information :ref:`here <releases>`.
Technical Requirements/Installation
-----------------------------------
**Pre-requirements for Mac OS**
To install requirements on a Mac, run the following:
::
brew install postgresql etcd haproxy libyaml python
.. _psycopg2_install_options:
**Psycopg2**
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
There are a few options available:
1. Use the package manager from your distro
::
sudo apt-get install python-psycopg2 # install python2 psycopg2 module on Debian/Ubuntu
sudo apt-get install python3-psycopg2 # install python3 psycopg2 module on Debian/Ubuntu
sudo yum install python-psycopg2 # install python2 psycopg2 on RedHat/Fedora/CentOS
2. Install psycopg2 from the binary package
::
pip install psycopg2-binary
3. Install psycopg2 from source
::
pip install psycopg2>=2.5.4
**General installation for pip**
Patroni can be installed with pip:
::
pip install patroni[dependencies]
where dependencies can be either empty, or consist of one or more of the following:
etcd
`python-etcd` module in order to use Etcd as DCS
consul
`python-consul` module in order to use Consul as DCS
zookeeper
`kazoo` module in order to use Zookeeper as DCS
exhibitor
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
kubernetes
`kubernetes` module in order to use Kubernetes as DCS in Patroni
aws
`boto` in order to use AWS callbacks
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
::
pip install patroni[etcd,aws]
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
independently of Patroni.
.. _running_configuring:
Running and Configuring
-----------------------
The following section assumes Patroni repository as being cloned from https://github.com/zalando/patroni. Namely, you
will need example configuration files `postgres0.yml` and `postgres1.yml`. If you installed Patroni with pip, you can
obtain those files from the git repository and replace `./patroni.py` below with `patroni` command.
To get started, do the following from different terminals:
::
> etcd --data-dir=data/etcd
> ./patroni.py postgres0.yml
> ./patroni.py postgres1.yml
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.
Add more ``postgres*.yml`` files to create an even larger cluster.
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,
run:
::
> haproxy -f haproxy.cfg
::
> psql --host 127.0.0.1 --port 5000 postgres
YAML Configuration
------------------
Go :ref:`here <settings>` 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>`__.
Environment Configuration
-------------------------
Go :ref:`here <environment>` for comprehensive information about configuring(overriding) settings via environment variables.
Replication Choices
-------------------
Patroni uses Postgres' streaming replication, which is asynchronous by default. 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 leader. This setting should be increased or decreased based on business requirements. It's also possible to use synchronous replication for better durability guarantees. See :ref:`replication modes documentation <replication_modes>` for details.
Applications Should Not Use Superusers
--------------------------------------
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.
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
:target: https://travis-ci.org/zalando/patroni
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
:target: https://coveralls.io/r/zalando/patroni?branch=master
+242
View File
@@ -0,0 +1,242 @@
.. _settings:
===========================
YAML Configuration Settings
===========================
.. _dynamic_configuration_settings:
Dynamic configuration settings
------------------------------
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes. Some parameters, like **loop_wait**, **ttl**, **postgresql.parameters.max_connections**, **postgresql.parameters.max_worker_processes** and so on could be set only in the dynamic configuration. Some other parameters like **postgresql.listen**, **postgresql.data_dir** could be set only locally, i.e. in the Patroni config file or via :ref:`configuration <environment>` variable. In most cases the local configuration will override the dynamic configuration. In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **master\_start\_timeout**: the amount of time a master is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for master failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the master. See :ref:`replication modes documentation <replication_modes>` for details.
- **postgresql**:
- **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
- **use\_slots**: whether or not to use replication_slots. Defaults to `true` on PostgreSQL 9.4+.
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
- **parameters**: list of configuration settings for Postgres.
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
- **host**: an address of remote master
- **port**: a port of remote master
- **primary\_slot\_name**: which slot on the remote master to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote master, can be different from the list defined in :ref:`postgresql_settings`
- **restore\_command**: command to restore WAL records from the remote master to standby leader, can be different from the list defined in :ref:`postgresql_settings`
- **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Patroni will try to create slots before opening connections to the cluster.
- **my_slot_name**: the name of replication slot. It is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
- **database**: the database name where logical slots should be created.
- **plugin**: the plugin name for the logical slot.
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
Log
---
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
- **file\_num**: The number of application logs to retain.
- **file\_size**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **loggers**: This section allows redefining logging level per python module
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
.. _bootstrap_settings:
Bootstrap configuration
-----------------------
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing of new cluster. The global dynamic configuration for the cluster. Under the ``bootstrap.dcs`` you can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration_settings>` and after Patroni initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store. All later changes of ``bootstrap.dcs`` will not take any effect! If you want to change them please use either ``patronictl edit-config`` or Patroni :ref:`REST API <rest_api>`.
- **method**: custom script to use for bootstrapping this cluster.
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
parameter is present in the configuration file.
- **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 which need to be created after initializing new cluster
- **admin**: the name of user
- **password: zalando**:
- **options**: list of options for CREATE USER statement
- **- createrole**
- **- createdb**
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
.. _consul_settings:
Consul
------
Most of the parameters are optional, but you have to specify one of the **host** or **url**
- **host**: the host:port for the Consul endpoint, in format: http(s)://host:port
- **url**: url for the Consul endpoint
- **port**: (optional) Consul port
- **scheme**: (optional) **http** or **https**, defaults to **http**
- **token**: (optional) ACL token
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
- **checks**: (optional) list of Consul health checks used for the session. If not specified Consul will use "serfHealth" in additional to the TTL based check created by Patroni. Additional checks, in particular the "serfHealth", may cause the leader lock to expire faster than in `ttl` seconds when the leader instance becomes unavailable
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, replica or standby-leader depending on the node's role. Defaults to **false**
- **service\_check\_interval**: (optional) how often to perform health check against registered url
Etcd
----
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
- **host**: the host:port for the etcd endpoint.
- **hosts**: list of etcd endpoint in format host1:port1,host2:port2,etc... Could be a comma separated string or an actual yaml list.
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
- **url**: url for the etcd
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery.
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
- **username**: (optional) username for etcd authentication.
- **password**: (optional) password for etcd authentication.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
ZooKeeper
----------
- **hosts**: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
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.
.. _kubernetes_settings:
Kubernetes
----------
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **role\_label**: (optional) name of the label containing role (master or replica). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
.. _postgresql_settings:
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).
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **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.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **rewind**:
- **username**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **password**: password for the user for ``pg_rewind``; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **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 postgres restarts (without changing role).
- **on\_role\_change**: run this script when the postgres is being promoted or demoted.
- **on\_start**: run this script when the postgres starts.
- **on\_stop**: run this script when the postgres 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. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
- **data\_dir**: The location of the Postgres data directory, either :ref:`existing <existing_data>` or to be initialized by Patroni.
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable 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.
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
- **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, the post_init script 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 overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details.
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. This parameter has higher priority than ``bootstrap.pg_hba``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``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.
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_ident.conf``.
- **- mapname1 systemname1 pguser1**.
- **- mapname1 systemname2 pguser2**.
- **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 the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former master can not start streaming from the new master. This option is useful when ``pg_rewind`` can not be used. Default value is **false**.
- **replica\_method**: for each create_replica_methods 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 (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the master is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
- **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.
- **cafile**: Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **verify\_client**: ``none``, ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. If ``verify_client`` is set to ``optional`` or ``required`` basic-auth is not checked.
.. _patronictl_settings:
CTL
---
- **Optional**:
- **insecure**: Allow connections to REST API without verifying SSL certs.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
- **keyfile**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter.
Watchdog
--------
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
Tags
----
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
+3
View File
@@ -0,0 +1,3 @@
li {
margin-bottom: 0.5em
}
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Patroni documentation build configuration file, created by
# sphinx-quickstart on Mon Dec 19 16:54:09 2016.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
from patroni.version import __version__
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ['sphinx.ext.intersphinx',
'sphinx.ext.todo',
'sphinx.ext.mathjax',
'sphinx.ext.ifconfig',
'sphinx.ext.viewcode']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'Patroni'
copyright = '2015 Compose, Zalando SE'
author = 'Zalando SE'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = __version__[:__version__.rfind('.')]
# The full version, including alpha/beta/rc tags.
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = []
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
if not on_rtd: # only import and set the theme if we're building docs locally
import sphinx_rtd_theme
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'Patronidoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'Patroni.tex', 'Patroni Documentation',
'Zalando SE', 'manual'),
]
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'patroni', 'Patroni Documentation',
[author], 1)
]
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'Patroni', 'Patroni Documentation',
author, 'Patroni', 'One line description of project.',
'Miscellaneous'),
]
# -- Options for Epub output ----------------------------------------------
# Bibliographic Dublin Core info.
epub_title = project
epub_author = author
epub_publisher = author
epub_copyright = copyright
# The unique identifier of the text. This can be a ISBN number
# or the project homepage.
#
# epub_identifier = ''
# A unique identification for the text.
#
# epub_uid = ''
# A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html']
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'https://docs.python.org/': None}
# A possibility to have an own stylesheet, to add new rules or override existing ones
# For the latter case, the CSS specificity of the rules should be higher than the default ones
def setup(app):
app.add_stylesheet("custom.css")
+85
View File
@@ -0,0 +1,85 @@
.. _dynamic_configuration:
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 :ref:`configuration <settings>` (patroni.yml).
These options are defined in the configuration file and take precedence over dynamic configuration.
patroni.yml could be changed and reloaded in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``.
- Environment :ref:`configuration <environment>`.
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 your 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 ``PATRONI_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
- check_timeline: false
- 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.
+36
View File
@@ -0,0 +1,36 @@
.. _existing_data:
Convert a Standalone to a Patroni Cluster
=========================================
This section describes the process for converting a standalone PostgreSQL instance into a Patroni cluster.
To deploy a Patroni cluster without using a pre-existing PostgreSQL instance, see :ref:`Running and Configuring <running_configuring>` instead.
Procedure
---------
A Patroni cluster can be started with a data directory from a single-node PostgreSQL database. This is achieved by following closely these steps:
1. Manually start PostgreSQL daemon
2. Create Patroni superuser and replication users as defined in the :ref:`authentication <postgresql_settings>` section of the Patroni configuration. If this user is created in SQL, the following queries achieve this:
.. code-block:: sql
CREATE USER $PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD '$PATRONI_SUPERUSER_PASSWORD';
CREATE USER $PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD '$PATRONI_REPLICATION_PASSWORD';
3. Start Patroni (e.g. ``patroni /etc/patroni/patroni.yml``). It automatically detects that PostgreSQL daemon is already running but its configuration might be out-of-date.
4. Ask Patroni to restart the node with ``patronictl restart cluster-name node-name``. This step is only required if PostgreSQL configuration is out-of-date.
FAQ
---
- During Patroni startup, Patroni complains that it cannot bind to the PostgreSQL port.
You need to verify ``listen_addresses`` and ``port`` in ``postgresql.conf`` and ``postgresql.listen`` in ``patroni.yml``. Don't forget that ``pg_hba.conf`` should allow such access.
- After asking Patroni to restart the node, PostgreSQL displays the error message ``could not open configuration file "/etc/postgresql/10/main/pg_hba.conf": No such file or directory``
It can mean various things depending on how you manage PostgreSQL configuration. If you specified `postgresql.config_dir`, Patroni generates the ``pg_hba.conf`` based on the settings in the :ref:`bootstrap <bootstrap_settings>` section only when it bootstraps a new cluster. In this scenario the ``PGDATA`` was not empty, therefore no bootstrap happened. This file must exist beforehand.
+148
View File
@@ -0,0 +1,148 @@
// Graphviz source for ha_loop_diagram.png
// recompile with:
// dot -Tpng ha_loop_diagram.dot -o ha_loop_diagram.png
digraph G {
rankdir=TB;
fontname="sans-serif";
penwidth="0.3";
layout="dot";
newrank=true;
edge [fontname="sans-serif",
fontsize=12,
color=black,
fontcolor=black];
node [fontname=serif,
fontsize=12,
fillcolor=white,
color=black,
fontcolor=black,
style=filled];
"start" [label=Start, shape="rectangle", fillcolor="green"]
"start" -> "load_cluster_from_dcs";
"update_member" [label="Persist node state in DCS"]
"update_member" -> "start"
subgraph cluster_run_cycle {
label="run_cycle"
"load_cluster_from_dcs" [label="Load cluster from DCS"];
"touch_member" [label="Persist node in DCS"];
"cluster.has_member" [shape="diamond", label="Is node registered on DCS?"]
"cluster.has_member" -> "touch_member" [label="no" color="red"]
"long_action_in_progress?" [shape="diamond" label="Is the PostgreSQL currently being\nstopping/starting/restarting/reinitializing?"]
"load_cluster_from_dcs" -> "cluster.has_member";
"touch_member" -> "long_action_in_progress?";
"cluster.has_member" -> "long_action_in_progress?" [label="yes" color="green"];
"long_action_in_progress?" -> "recovering?" [label="no" color="red"]
"recovering?" [label="Was cluster recovering and failed?", shape="diamond"];
"recovering?" -> "post_recover" [label="yes" color="green"];
"recovering?" -> "data_directory_empty" [label="no" color="red"];
"post_recover" [label="Remove leader key (if I was the leader)"];
"data_directory_empty" [label="Is data folder empty?", shape="diamond"];
"data_directory_empty" -> "cluster_initialize" [label="no" color="red"];
"data_belongs_to_cluster" [label="Does data dir belong to cluster?", shape="diamond"];
"data_belongs_to_cluster" -> "exit" [label="no" color="red"];
"data_belongs_to_cluster" -> "is_healthy" [label="yes" color="green"]
"exit" [label="Fail and exit", fillcolor=red];
"cluster_initialize" [label="Is cluster initialized on DCS?" shape="diamond"]
"cluster_initialize" -> "cluster.has_leader" [label="no" color="red"]
"cluster.has_leader" [label="Does the cluster has leader?", shape="diamond"]
"cluster.has_leader" -> "dcs.initialize" [label="no", color="red"]
"cluster.has_leader" -> "is_healthy" [label="yes", color="green"]
"cluster_initialize" -> "data_belongs_to_cluster" [label="yes" color="green"]
"dcs.initialize" [label="Initialize new cluster"];
"dcs.initialize" -> "is_healthy"
"is_healthy" [label="Is node healthy?\n(running Postgres)", shape="diamond"];
"recover" [label="Start as read-only\nand set Recover flag"]
"is_healthy" -> "recover" [label="no" color="red"];
"is_healthy" -> "cluster.is_unlocked" [label="yes" color="green"];
"cluster.is_unlocked" [label="Does the cluster has a leader?", shape="diamond"]
}
"post_recover" -> "update_member"
"recover" -> "update_member"
"long_action_in_progress?" -> "async_has_lock?" [label="yes" color="green"];
"cluster.is_unlocked" -> "unhealthy_is_healthiest" [label="no" color="red"]
"cluster.is_unlocked" -> "healthy_has_lock" [label="yes" color="green"]
"data_directory_empty" -> "bootstrap.is_unlocked" [label="yes" color="green"]
subgraph cluster_async {
label = "Long action in progress\n(Start/Stop/Restart/Reinitialize)"
"async_has_lock?" [label="Do I have the leader lock?", shape="diamond"]
"async_update_lock" [label="Renew leader lock"]
"async_has_lock?" -> "async_update_lock" [label="yes" color="green"]
}
"async_update_lock" -> "update_member"
"async_has_lock?" -> "update_member" [label="no" color="red"]
subgraph cluster_bootstrap {
label = "Node bootstrap";
"bootstrap.is_unlocked" [label="Does the cluster has a leader?", shape="diamond"]
"bootstrap.is_initialized" [label="Does the cluster has an initialize key?", shape="diamond"]
"bootstrap.is_unlocked" -> "bootstrap.is_initialized" [label="no" color="red"]
"bootstrap.is_unlocked" -> "bootstrap.select_node" [label="yes" color="green"]
"bootstrap.select_node" [label="Select a node to take a backup from"]
"bootstrap.do_bootstrap" [label="Run pg_basebackup\n(async)"]
"bootstrap.select_node" -> "bootstrap.do_bootstrap"
"bootstrap.is_initialized" -> "bootstrap.initialization_race" [label="no" color="red"]
"bootstrap.is_initialized" -> "bootstrap.wait_for_leader" [label="yes" color="green"]
"bootstrap.initialization_race" [label="Race for initialize key"]
"bootstrap.initialization_race" -> "bootstrap.won_initialize_race?"
"bootstrap.won_initialize_race?" [label="Do I won initialize race?", shape="diamond"]
"bootstrap.won_initialize_race?" -> "bootstrap.initdb_and_start" [label="yes" color="green"]
"bootstrap.won_initialize_race?" -> "bootstrap.wait_for_leader" [label="no" color="red"]
"bootstrap.wait_for_leader" [label="Need to wait for leader key"]
"bootstrap.initdb_and_start" [label="Run initdb, start postgres and create roles"]
"bootstrap.initdb_and_start" -> "bootstrap.success?"
"bootstrap.success?" [label="Success", shape="diamond"]
"bootstrap.success?" -> "bootstrap.take_leader_key" [label="yes" color="green"]
"bootstrap.success?" -> "bootstrap.clean" [label="no" color="red"]
"bootstrap.clean" [label="Remove initialize key from DCS\nand data directory from filesystem"]
"bootstrap.take_leader_key" [label="Take a leader key in DCS"]
}
"bootstrap.do_bootstrap" -> "update_member"
"bootstrap.wait_for_leader" -> "update_member"
"bootstrap.clean" -> "update_member"
"bootstrap.take_leader_key" -> "update_member"
subgraph cluster_process_healthy_cluster {
label = "process_healthy_cluster"
"healthy_has_lock" [label="Am I the owner of the leader lock?", shape=diamond]
"healthy_is_leader" [label="Is Postgres running as master?", shape=diamond]
"healthy_no_lock" [label="Follow the leader (async,\ncreate/update recovery.conf and restart if necessary)"]
"healthy_has_lock" -> "healthy_no_lock" [label="no" color="red"]
"healthy_has_lock" -> "healthy_update_leader_lock" [label="yes" color="green"]
"healthy_update_leader_lock" [label="Try to update leader lock"]
"healthy_update_leader_lock" -> "healthy_update_success"
"healthy_update_success" [label="Success?", shape=diamond]
"healthy_update_success" -> "healthy_is_leader" [label="yes" color="green"]
"healthy_update_success" -> "healthy_demote" [label="no" color="red"]
"healthy_demote" [label="Demote (async,\nrestart in read-only)"]
"healthy_failover" [label="Promote Postgres to master"]
"healthy_is_leader" -> "healthy_failover" [label="no" color="red"]
}
"healthy_demote" -> "update_member"
"healthy_is_leader" -> "update_member" [label="yes" color="green"]
"healthy_failover" -> "update_member"
"healthy_no_lock" -> "update_member"
subgraph cluster_process_unhealthy_cluster {
label = "process_unhealthy_cluster"
"unhealthy_is_healthiest" [label="Am I the healthiest node?", shape="diamond"]
"unhealthy_is_healthiest" -> "unhealthy_leader_race" [label="yes", color="green"]
"unhealthy_leader_race" [label="Try to create leader key"]
"unhealthy_leader_race" -> "unhealthy_acquire_lock"
"unhealthy_acquire_lock" [label="Was I able to get the lock?", shape="diamond"]
"unhealthy_is_leader" [label="Is Postgres running as master?", shape=diamond]
"unhealthy_acquire_lock" -> "unhealthy_is_leader" [label="yes" color="green"]
"unhealthy_is_leader" -> "unhealthy_promote" [label="no" color="red"]
"unhealthy_promote" [label="Promote to master"]
"unhealthy_is_healthiest" -> "unhealthy_follow" [label="no" color="red"]
"unhealthy_follow" [label="try to follow somebody else()"]
"unhealthy_acquire_lock" -> "unhealthy_follow" [label="no" color="red"]
}
"unhealthy_follow" -> "update_member"
"unhealthy_promote" -> "update_member"
"unhealthy_is_leader" -> "update_member" [label="yes" color="green"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

+38
View File
@@ -0,0 +1,38 @@
.. Patroni documentation master file, created by
sphinx-quickstart on Mon Dec 19 16:54:09 2016.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Introduction
============
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>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. 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.
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. 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>`__.
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the :ref:`Kubernetes <kubernetes>` chapter of the Patroni documentation.
.. toctree::
:maxdepth: 2
:caption: Contents:
README
dynamic_configuration
rest_api
ENVIRONMENT
SETTINGS
replica_bootstrap
replication_modes
pause
kubernetes
watchdog
releases
CONTRIBUTING
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
+53
View File
@@ -0,0 +1,53 @@
.. _kubernetes:
Using Patroni with Kubernetes
=============================
Patroni can use Kubernetes objects in order to store the state of the cluster and manage the leader key. That makes it
capable of operating Postgres in Kubernetes environment without any consistency store, namely, one doesn't
need to run an extra Etcd deployment. There are two different type of Kubernetes objects Patroni can use to store the
leader and the configuration keys, they are configured with the `kubernetes.use_endpoints` or `PATRONI_KUBERNETES_USE_ENDPOINTS`
environment variable.
Use Endpoints
-------------
Despite the fact that this is the recommended mode, it is turned off by default for compatibility reasons. When it is on, Patroni stores
the cluster configuration and the leader key in the `metadata: annotations` fields of the respective `Endpoints` it creates.
Changing the leader is safer than when using `ConfigMaps`, since both the annotations, containing the leader information, and the actual addresses
pointing to the running leader pod are updated simultaneously in one go.
Use ConfigMaps
--------------
In this mode, Patroni will create ConfigMaps instead of Endpoints and store keys inside meta-data of those ConfigMaps.
Changing the leader takes at least two updates, one to the leader ConfigMap and another to the respective Endpoint.
There are two ways to direct the traffic to the Postgres master:
- use the `callback script <https://github.com/zalando/patroni/blob/master/kubernetes/callback.py>`_ provided by Patroni
- configure the Kubernetes Postgres service to use the label selector with the `role_label` (configured in patroni configuration).
Note that in some cases, for instance, when running on OpenShift, there is no alternative to using ConfigMaps.
Configuration
-------------
Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation.
Examples
--------
- The `kubernetes <https://github.com/zalando/patroni/tree/master/kubernetes>`__ folder of the Patroni repository contains
examples of the Docker image, the Kubernetes manifest and the callback script in order to test Patroni Kubernetes setup.
Note that in the current state it will not be able to use PersistentVolumes because of permission issues.
- You can find the full-featured Docker image that can use Persistent Volumes in the
`Spilo Project <https://github.com/zalando/spilo>`_.
- There is also a `Helm chart <https://github.com/kubernetes/charts/tree/master/incubator/patroni>`_
to deploy the Spilo image configured with Patroni running using Kubernetes.
- In order to run your database clusters at scale using Patroni and Spilo, take a look at the
`postgres-operator <https://github.com/zalando-incubator/postgres-operator>`_ project. It implements the operator pattern
to manage Spilo clusters.
+35
View File
@@ -0,0 +1,35 @@
.. _pause:
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 try to stop the 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}``
+1519
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
Replica imaging and bootstrap
=============================
Patroni allows customizing creation of a new replica. It also supports defining what happens when the new empty cluster
is being bootstrapped. The distinction between two is well defined: Patroni creates replicas only if the ``initialize``
key is present in DCS for the cluster. If there is no ``initialize`` key - Patroni calls bootstrap exclusively on the
first node that takes the initialize key lock.
.. _custom_bootstrap:
Bootstrap
---------
PostgreSQL provides ``initdb`` command to initialize a new cluster and Patroni calls it by default. In certain cases,
particularly when creating a new cluster as a copy of an existing one, it is necessary to replace a built-in method with
custom actions. Patroni supports executing user-defined scripts to bootstrap new clusters, supplying some required
arguments to them, i.e. the name of the cluster and the path to the data directory. This is configured in the
``bootstrap`` section of the Patroni configuration. For example:
.. code:: YAML
bootstrap:
method: <custom_bootstrap_method_name>
<custom_bootstrap_method_name>:
command: <path_to_custom_bootstrap_script> [param1 [, ...]]
keep_existing_recovery_conf: False
recovery_conf:
recovery_target_action: promote
recovery_target_timeline: latest
restore_command: <method_specific_restore_command>
Each bootstrap method must define at least a ``name`` and a ``command``. A special ``initdb`` method is available to trigger
the default behavior, in which case ``method`` parameter can be omitted altogether. The ``command`` can be specified using either
an absolute path, or the one relative to the ``patroni`` command location. In addition to the fixed parameters defined
in the configuration files, Patroni supplies two cluster-specific ones:
--scope
Name of the cluster to be bootstrapped
--datadir
Path to the data directory of the cluster instance to be bootstrapped
If the bootstrap script returns 0, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
of the intermediate steps fail, or the script returns a non-zero value, Patroni assumes that the bootstrap has failed,
cleans up after itself and releases the initialize lock to give another node the opportunity to bootstrap.
If a ``recovery_conf`` block is defined in the same section as the custom bootstrap method, Patroni will generate a
``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists.
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you.
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
.. _custom_replica_creation:
Building replicas
-----------------
Patroni uses tried and proven ``pg_basebackup`` in order to create new replicas. One downside of it is that it requires
a running master node. Another one is the lack of 'on-the-fly' compression for the backup data and no built-in cleanup
for outdated backup files. Some people prefer other backup solutions, such as ``WAL-E``, ``pgBackRest``, ``Barman`` and
others, or simply roll their own scripts. In order to accommodate all those use-cases Patroni supports running custom
scripts to clone a new replica. Those are configured in the ``postgresql`` configuration block:
.. code:: YAML
postgresql:
create_replica_methods:
- <method name>
<method name>:
command: <command name>
keep_data: True
no_params: True
no_master: 1
example: wal_e
.. code:: YAML
postgresql:
create_replica_methods:
- wal_e
- basebackup
wal_e:
command: patroni_wale_restore
no_master: 1
envdir: {{WALE_ENV_DIR}}
use_iam: 1
basebackup:
max-rate: '100M'
example: pgbackrest
.. code:: YAML
postgresql:
create_replica_methods:
- pgbackrest
- basebackup
pgbackrest:
command: /usr/bin/pgbackrest --stanza=<scope> --delta restore
keep_data: True
no_params: True
basebackup:
max-rate: '100M'
The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
to execute and any custom parameters that should be passed to that command. All parameters will be passed in a
``--name=value`` format. Besides user-defined parameters, Patroni supplies a couple of cluster-specific ones:
--scope
Which cluster this replica belongs to
--datadir
Path to the data directory of the replica
--role
Always 'replica'
--connstring
Connection string to connect to the cluster member to clone from (master or other replica). The user in the
connection string can execute SQL and replication protocol commands.
A special ``no_master`` parameter, if defined, allows Patroni to call the replica creation method even if there is no
running master or replicas. In that case, an empty string will be passed in a connection string. This is useful for
restoring the formerly running cluster from the binary backup.
A special ``keep_data`` parameter, if defined, will instruct Patroni to not clean PGDATA folder before calling restore.
A special ``no_params`` parameter, if defined, restricts passing parameters to custom command.
A ``basebackup`` method is a special case: it will be used if
``create_replica_methods`` is empty, although it is possible
to list it explicitly among the ``create_replica_methods`` methods. This method initializes a new replica with the
``pg_basebackup``, the base backup is taken from the master unless there are replicas with ``clonefrom`` tag, in which case one
of such replicas will be used as the origin for pg_basebackup. It works without any configuration; however, it is
possible to specify a ``basebackup`` configuration section. Same rules as with the other method configuration apply,
namely, only long (with --) options should be specified there. Not all parameters make sense, if you override a connection
string or provide an option to created tar-ed or compressed base backups, patroni won't be able to make a replica out
of it. There is no validation performed on the names or values of the parameters passed to the ``basebackup`` section.
You can specify basebackup parameters as either a map (key-value pairs) or a list of elements, where each element
could be either a key-value pair or a single key (for options that does not receive any values, for instance, ``--verbose``).
Consider those 2 examples:
.. code:: YAML
postgresql:
basebackup:
max-rate: '100M'
checkpoint: 'fast'
and
.. code:: YAML
postgresql:
basebackup:
- verbose
- max-rate: '100M'
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
.. _standby_cluster:
Standby cluster
---------------
Another available option is to run a "standby cluster", that contains only of
standby nodes replicating from some remote master. This type of clusters has:
* "standby leader", that behaves pretty much like a regular cluster leader,
except it replicates from a remote master.
* cascade replicas, that are replicating from standby leader.
Standby leader holds and updates a leader lock in DCS. If the leader lock
expires, cascade replicas will perform an election to choose another leader
from the standbys.
For the sake of flexibility, you can specify methods of creating a replica and
recovery WAL records when a cluster is in the "standby mode" by providing
`create_replica_methods` key in `standby_cluster` section. It is distinct from
creating replicas, when cluster is detached and functions as a normal cluster,
which is controlled by `create_replica_methods` in `postgresql` section. Both
"standby" and "normal" `create_replica_methods` reference keys in `postgresql`
section.
To configure such cluster you need to specify the section ``standby_cluster``
in a patroni configuration:
.. code:: YAML
bootstrap:
dcs:
standby_cluster:
host: 1.2.3.4
port: 5432
primary_slot_name: patroni
create_replica_methods:
- basebackup
Note, that these options will be applied only once during cluster bootstrap,
and the only way to change them afterwards is through DCS.
If you use replication slots on the standby cluster, you must also create the corresponding replication slot on the primary cluster. It will not be done automatically by the standby cluster implementation. You can use Patroni's permanent replication slots feature on the primary cluster to maintain a replication slot with the same name as ``primary_slot_name``, or its default value if ``primary_slot_name`` is not provided.
+79
View File
@@ -0,0 +1,79 @@
.. _replication_modes:
=================
Replication modes
=================
Patroni uses PostgreSQL streaming replication. For more information about streaming replication, see the `Postgres documentation <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__. By default Patroni configures PostgreSQL for asynchronous replication. 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.
Asynchronous mode durability
----------------------------
In asynchronous mode the cluster is allowed to lose some committed transactions to ensure availability. When the primary server fails or becomes unavailable for any other reason Patroni will automatically promote a sufficiently healthy standby to primary. Any transactions that have not been replicated to that standby remain in a "forked timeline" on the primary, and are effectively unrecoverable [1]_.
The amount of transactions that can be lost is controlled via ``maximum_lag_on_failover`` parameter. Because the primary transaction log position is not sampled in real time, in reality the amount of lost data on failover is worst case bounded by ``maximum_lag_on_failover`` bytes of transaction log plus the amount that is written in the last ``ttl`` seconds (``loop_wait``/2 seconds in the average case). However typical steady state replication delay is well under a second.
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former master become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
PostgreSQL synchronous replication
----------------------------------
You can use Postgres's `synchronous replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__ with Patroni. 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.
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.
To enable a simple synchronous replication test, add the following lines to the ``parameters`` section of your YAML configuration files:
.. code:: YAML
synchronous_commit: "on"
synchronous_standby_names: "*"
When using PostgreSQL synchronous replication, use at least three Postgres data nodes to ensure write availability if one host fails.
Using PostgreSQL synchronous replication does not guarantee zero lost transactions under all circumstances. When the primary and the secondary that is currently acting as a synchronous replica fail simultaneously a third node that might not contain all transactions will be promoted.
.. _synchronous_mode:
Synchronous mode
----------------
For use cases where losing committed transactions is not permissible you can turn on Patroni's ``synchronous_mode``. When ``synchronous_mode`` is turned on Patroni will not promote a standby unless it is certain that the standby contains all transactions that may have returned a successful commit status to client [2]_. This means that the system may be unavailable for writes even though some servers are available. System administrators can still use manual failover commands to promote a standby even if it results in transaction loss.
Turning on ``synchronous_mode`` does not guarantee multi node durability of commits under all circumstances. When no suitable standby is available, primary server will still accept writes, but does not guarantee their replication. When the primary fails in this mode no standby will be promoted. When the host that used to be the primary comes back it will get promoted automatically, unless system administrator performed a manual failover. This behavior makes synchronous mode usable with 2 node clusters.
When ``synchronous_mode`` is on and a standby crashes, commits will block until next iteration of Patroni runs and switches the primary to standalone mode (worst case delay for writes ``ttl`` seconds, average case ``loop_wait``/2 seconds). Manually shutting down or restarting a standby will not cause a commit service interruption. Standby will signal the primary to release itself from synchronous standby duties before PostgreSQL shutdown is initiated.
When it is absolutely necessary to guarantee that each write is stored durably
on at least two nodes, enable ``synchronous_mode_strict`` in addition to the
``synchronous_mode``. This parameter prevents Patroni from switching off the
synchronous replication on the primary when no synchronous standby candidates
are available. As a downside, the primary is not be available for writes
(unless the Postgres transaction explicitly turns of ``synchronous_mode``),
blocking all client write requests until at least one synchronous replica comes
up.
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
Synchronous mode can be switched on and off via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
Synchronous mode implementation
-------------------------------
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest primary and current synchronous standby. This state is updated with strict ordering constraints to ensure the following invariants:
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
- A node must be set as the synchronous standby in PostgreSQL as long as it is published as the synchronous standby.
- A node that is not the leader or current synchronous standby is not allowed to promote itself automatically.
Patroni will only ever assign one standby to ``synchronous_standby_names`` because with multiple candidates it is not possible to know which node was acting as synchronous during the failure.
On each HA loop iteration Patroni re-evaluates synchronous standby choice. If the current synchronous standby is connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster member available for sync that is furthest ahead in replication is picked.
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed primary with the cluster.
.. [2] Clients can change the behavior per transaction using PostgreSQL's ``synchronous_commit`` setting. Transactions with ``synchronous_commit`` values of ``off`` and ``local`` may be lost on fail over, but will not be blocked by replication delays.
+336
View File
@@ -0,0 +1,336 @@
.. _rest_api:
Patroni REST API
================
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
Health check endpoints
----------------------
For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``OPTIONS`` method instead of ``GET``.
- The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the leader:
- ``GET /``
- ``GET /master``
- ``GET /leader``
- ``GET /primary``
- ``GET /read-write``
- ``GET /replica``: replica health check endpoint. It returns HTTP status code **200** only when the Patroni node is in the state ``running``, the role is ``replica`` and ``noloadbalance`` tag is not set.
- ``GET /read-only``: like the above endpoint, but also includes the primary.
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`.
- ``GET /synchronous`` or ``GET /sync``: returns HTTP status code **200** only when the Patroni node is running as a synchronous standby.
- ``GET /asynchronous`` or ``GET /async``: returns HTTP status code **200** only when the Patroni node is running as an asynchronous standby.
- ``GET /health``: returns HTTP status code **200** only when PostgreSQL is up and running.
Monitoring endpoint
-------------------
The ``GET /patroni`` is used by Patroni during the leader race. It also could be used by your monitoring system. The JSON document produced by this endpoint has the same structure as the JSON produced by the health check endpoints.
.. code-block:: bash
$ curl -s http://localhost:8008/patroni | jq .
{
"state": "running",
"postmaster_start_time": "2019-09-24 09:22:32.555 CEST",
"role": "master",
"server_version": 110005,
"cluster_unlocked": false,
"xlog": {
"location": 25624640
},
"timeline": 3,
"database_system_identifier": "6739877027151648096",
"patroni": {
"version": "1.6.0",
"scope": "batman"
}
}
Cluster status endpoints
------------------------
- The ``GET /cluster`` endpoint generates a JSON document describing the current cluster topology and state:
.. code-block:: bash
$ curl -s http://localhost:8008/cluster | jq .
{
"members": [
{
"name": "postgresql0",
"host": "127.0.0.1",
"port": 5432,
"role": "leader",
"state": "running",
"api_url": "http://127.0.0.1:8008/patroni",
"timeline": 5,
"tags": {
"clonefrom": true
}
},
{
"name": "postgresql1",
"host": "127.0.0.1",
"port": 5433,
"role": "replica",
"state": "running",
"api_url": "http://127.0.0.1:8009/patroni",
"timeline": 5,
"tags": {
"clonefrom": true
},
"lag": 0
}
],
"scheduled_switchover": {
"at": "2019-09-24T10:36:00+02:00",
"from": "postgresql0"
}
}
- The ``GET /history`` endpoint provides a view on the history of cluster switchovers/failovers. The format is very similar to the content of history files in the ``pg_wal`` directory. The only difference is the timestamp field showing when the new timeline was created.
.. code-block:: bash
$ curl -s http://localhost:8008/history | jq .
[
[
1,
25623960,
"no recovery target specified",
"2019-09-23T16:57:57+02:00"
],
[
2,
25624344,
"no recovery target specified",
"2019-09-24T09:22:33+02:00"
],
[
3,
25624752,
"no recovery target specified",
"2019-09-24T09:26:15+02:00"
],
[
4,
50331856,
"no recovery target specified",
"2019-09-24T09:35:52+02:00"
]
]
Config endpoint
---------------
``GET /config``: Get the current version of the 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 the 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 "pending_restart" 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
}
}
}
The 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
}
Switchover and failover endpoints
---------------------------------
``POST /switchover`` or ``POST /failover``. These endpoints are very similar to each other. There are a couple of minor differences though:
1. The failover endpoint allows to perform a manual failover when there are no healthy nodes, but at the same time it will not allow you to schedule a switchover.
2. The switchover endpoint is the opposite. It works only when the cluster is healthy (there is a leader) and allows to schedule a switchover at a given time.
In the JSON body of the ``POST`` request you must specify at least the ``leader`` or ``candidate`` fields and optionally the ``scheduled_at`` field if you want to schedule a switchover at a specific time.
Example: perform a failover to the specific node:
.. code-block:: bash
$ curl -s http://localhost:8009/failover -XPOST -d '{"candidate":"postgresql1"}'
Successfully failed over to "postgresql1"
Example: schedule a switchover from the leader to any other healthy replica in the cluster at a specific time:
.. code-block:: bash
$ curl -s http://localhost:8008/switchover -XPOST -d \
'{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}'
Switchover scheduled
Depending on the situation the request might finish with a different HTTP status code and body. The status code **200** is returned when the switchover or failover successfully completed. If the switchover was successfully scheduled, Patroni will return HTTP status code **202**. In case something went wrong, the error status code (one of **400**, **412** or **503**) will be returned with some details in the response body. For more information please check the source code of ``patroni/api.py:do_POST_failover()`` method.
The switchover and failover endpoints are used by ``patronictl switchover`` and ``patronictl failover``, respectively.
Restart endpoint
----------------
- ``POST /restart``: You can restart Postgres on the specific node by performing the ``POST /restart`` call. In the JSON body of ``POST`` request it is possible to optionally specify some restart conditions:
- **restart_pending**: boolean, if set to ``true`` Patroni will restart PostgreSQL only when restart is pending in order to apply some changes in the PostgreSQL config.
- **role**: perform restart only if the current role of the node matches with the role from the POST request.
- **postgres_version**: perform restart only if the current version of postgres is smaller than specified in the POST request.
- **timeout**: how long we should wait before PostgreSQL starts accepting connections. Overrides ``master_start_timeout``.
- **schedule**: timestamp with time zone, schedule the restart somewhere in the future.
- ``DELETE /restart``: delete the scheduled restart
``POST /restart`` and ``DELETE /restart`` endpoints are used by ``patronictl restart`` and ``patronictl flush`` respectively.
Reload endpoint
---------------
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of ``patronictl restart``.
The reload endpoint is used by ``patronictl reload``.
Reinitialize endpoint
---------------------
``POST /reinitialize``: reinitialize the PostgreSQL data directory on the specified node. It is allowed to be executed only on replicas. Once called, it will remove the data directory and start ``pg_basebackup`` or some alternative :ref:`replica creation method <custom_replica_creation>`.
The call might fail if Patroni is in a loop trying to recover (restart) a failed Postgres. In order to overcome this problem one can specify ``{"force":true}`` in the request body.
The reinitialize endpoint is used by ``patronictl reinit``.
+39
View File
@@ -0,0 +1,39 @@
.. _watchdog:
Watchdog support
================
Having multiple PostgreSQL servers running as master can result in transactions lost due to diverging timelines. This situation is also called a split-brain problem. To avoid split-brain Patroni needs to ensure PostgreSQL will not accept any transaction commits after leader key expires in the DCS. Under normal circumstances Patroni will try to achieve this by stopping PostgreSQL when leader lock update fails for any reason. However, this may fail to happen due to various reasons:
- Patroni has crashed due to a bug, out-of-memory condition or by being accidentally killed by a system administrator.
- Shutting down PostgreSQL is too slow.
- Patroni does not get to run due to high load on the system, the VM being paused by the hypervisor, or other infrastructure issues.
To guarantee correct behavior under these conditions Patroni supports watchdog devices. Watchdog devices are software or hardware mechanisms that will reset the whole system when they do not get a keepalive heartbeat within a specified timeframe. This adds an additional layer of fail safe in case usual Patroni split-brain protection mechanisms fail.
Patroni will try to activate the watchdog before promoting PostgreSQL to master. If watchdog activation fails and watchdog mode is ``required`` then the node will refuse to become master. When deciding to participate in leader election Patroni will also check that watchdog configuration will allow it to become leader at all. After demoting PostgreSQL (for example due to a manual failover) Patroni will disable the watchdog again. Watchdog will also be disabled while Patroni is in paused state.
By default Patroni will set up the watchdog to expire 5 seconds before TTL expires. With the default setup of ``loop_wait=10`` and ``ttl=30`` this gives HA loop at least 15 seconds (``ttl`` - ``safety_margin`` - ``loop_wait``) to complete before the system gets forcefully reset. By default accessing DCS is configured to time out after 10 seconds. This means that when DCS is unavailable, for example due to network issues, Patroni and PostgreSQL will have at least 5 seconds (``ttl`` - ``safety_margin`` - ``loop_wait`` - ``retry_timeout``) to come to a state where all client connections are terminated.
Safety margin is the amount of time that Patroni reserves for time between leader key update and watchdog keepalive. Patroni will try to send a keepalive immediately after confirmation of leader key update. If Patroni process is suspended for extended amount of time at exactly the right moment the keepalive may be delayed for more than the safety margin without triggering the watchdog. This results in a window of time where watchdog will not trigger before leader key expiration, invalidating the guarantee. To be absolutely sure that watchdog will trigger under all circumstances set up the watchdog to expire after half of TTL by setting ``safety_margin`` to -1 to set watchdog timeout to ``ttl // 2``. If you need this guarantee you probably should increase ``ttl`` and/or reduce ``loop_wait`` and ``retry_timeout``.
Currently watchdogs are only supported using Linux watchdog device interface.
Setting up software watchdog on Linux
-------------------------------------
Default Patroni configuration will try to use ``/dev/watchdog`` on Linux if it is accessible to Patroni. For most use cases using software watchdog built into the Linux kernel is secure enough.
To enable software watchdog issue the following commands as root before starting Patroni:
.. code-block:: bash
modprobe softdog
# Replace postgres with the user you will be running patroni under
chown postgres /dev/watchdog
For testing it may be helpful to disable rebooting by adding ``soft_noboot=1`` to the modprobe command line. In this case the watchdog will just log a line in kernel ring buffer, visible via `dmesg`.
Patroni will log information about the watchdog when it is successfully enabled.
+13
View File
@@ -0,0 +1,13 @@
### confd
`confd` directory contains haproxy and pgbouncer 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_URL -interval=10
```
It will periodically update haproxy.cfg and pgbouncer.ini with the actual list of Patroni nodes from `etcd` and "reload" haproxy and pgbouncer.ini 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/",
]
+12
View File
@@ -0,0 +1,12 @@
[template]
prefix = "/service/batman"
owner = "postgres"
mode = "0644"
src = "pgbouncer.tmpl"
dest = "/etc/pgbouncer/pgbouncer.ini"
reload_cmd = "systemctl reload pgbouncer"
keys = [
"/members/","/leader"
]
+32
View File
@@ -0,0 +1,32 @@
global
maxconn 100
defaults
log global
mode tcp
retries 2
timeout client 30m
timeout connect 4s
timeout server 30m
timeout check 5s
listen stats
mode http
bind *:7000
stats enable
stats uri /
listen master
bind *:5000
option httpchk OPTIONS /master
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
{{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}}
listen replicas
bind *:5001
option httpchk OPTIONS /replica
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
{{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}}
+17
View File
@@ -0,0 +1,17 @@
[databases]
{{with get "/leader"}}{{$leader := .Value}}{{$leadkey := printf "/members/%s" $leader}}{{with get $leadkey}}{{$data := json .Value}}{{$hostport := base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}}{{ $host := base (index (split $hostport ":") 0)}}{{ $port := base (index (split $hostport ":") 1)}}* = host={{ $host }} port={{ $port }} pool_size=10{{end}}{{end}}
[pgbouncer]
logfile = /var/log/postgresql/pgbouncer.log
pidfile = /var/run/postgresql/pgbouncer.pid
listen_addr = *
listen_port = 6432
unix_socket_dir = /var/run/postgresql
auth_type = trust
auth_file = /etc/pgbouncer/userlist.txt
auth_hba_file = /etc/pgbouncer/pg_hba.txt
admin_users = pgbouncer
stats_users = pgbouncer
pool_mode = session
max_client_conn = 100
default_pool_size = 20
+9
View File
@@ -11,3 +11,12 @@ Upstart job for Ubuntu 12.04 or 14.04. Requires Upstart > 1.4. Intended for sys
### patroni.service
Systemd service file, to be copied to /etc/systemd/system/patroni.service, tested on Centos 7.1 with Patroni installed from pip.
### patroni
Init.d service file for Debian-like distributions. Copy it to /etc/init.d/, make executable:
```chmod 755 /etc/init.d/patroni``` and run with ```service patroni start```, or make it starting on boot with ```update-rc.d patroni defaults```. Also you might edit some configuration variables in it:
PATRONI for patroni.py location
CONF for configuration file
LOGFILE for log (script creates it if does not exist)
Note. If you have several versions of Postgres installed, please add to POSTGRES_VERSION the release number which you wish to run. Script uses this value to append PATH environment with correct path to Postgres bin.
+144
View File
@@ -0,0 +1,144 @@
#!/bin/sh
#
### BEGIN INIT INFO
# Provides: patroni
# Required-Start: $remote_fs $syslog
# Required-Stop: $remote_fs $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Patroni init script
# Description: Runners to orchestrate a high-availability PostgreSQL
### END INIT INFO
### BEGIN USER CONFIGURATION
CONF="/etc/patroni/postgres.yml"
LOGFILE="/var/log/patroni.log"
USER="postgres"
GROUP="postgres"
NAME=patroni
PATRONI="/opt/patroni/$NAME.py"
PIDFILE="/var/run/$NAME.pid"
# Set this parameter, if you have several Postgres versions installed
# POSTGRES_VERSION="9.4"
POSTGRES_VERSION=""
### END USER CONFIGURATION
. /lib/lsb/init-functions
# Loading this library for get_versions() function
if test ! -e /usr/share/postgresql-common/init.d-functions; then
log_failure_msg "Probably postgresql-common does not installed."
exit 1
else
. /usr/share/postgresql-common/init.d-functions
fi
# Is there Patroni executable?
if test ! -e $PATRONI; then
log_failure_msg "Patroni executable $PATRONI does not exist."
exit 1
fi
# Is there Patroni configuration file?
if test ! -e $CONF; then
log_failure_msg "Patroni configuration file $CONF does not exist."
exit 1
fi
# Create logfile if doesn't exist
if test ! -e $LOGFILE; then
log_action_msg "Creating logfile for Patroni..."
touch $LOGFILE
chown $USER:$GROUP $LOGFILE
fi
prepare_pgpath() {
if [ "$POSTGRES_VERSION" != "" ]; then
if [ -x /usr/lib/postgresql/$POSTGRES_VERSION/bin/pg_ctl ]; then
PGPATH="/usr/lib/postgresql/$POSTGRES_VERSION/bin"
else
log_failure_msg "Postgres version incorrect, check POSTGRES_VERSION variable."
exit 0
fi
else
get_versions
if echo $versions | grep -q -e "\s"; then
log_warning_msg "You have several Postgres versions installed. Please, use POSTGRES_VERSION to define correct environment."
else
versions=`echo $versions | sed -e 's/^[ \t]*//'`
PGPATH="/usr/lib/postgresql/$versions/bin"
fi
fi
}
get_pid() {
if test -e $PIDFILE; then
PID=`cat $PIDFILE`
CHILDPID=`ps --ppid $PID -o %p --no-headers`
else
log_failure_msg "Could not find PID file. Patroni probably down."
exit 1
fi
}
case "$1" in
start)
prepare_pgpath
PGPATH=$PATH:$PGPATH
log_success_msg "Starting Patroni\n"
exec start-stop-daemon --start --quiet \
--background \
--pidfile $PIDFILE --make-pidfile \
--chuid $USER:$GROUP \
--chdir `eval echo ~$USER` \
--exec $PATRONI \
--startas /bin/sh -- \
-c "/usr/bin/env PATH=$PGPATH /usr/bin/python $PATRONI $CONF >> $LOGFILE 2>&1"
;;
stop)
log_success_msg "Stopping Patroni"
get_pid
start-stop-daemon --stop --pid $CHILDPID
start-stop-daemon --stop --pidfile $PIDFILE --remove-pidfile --quiet
;;
reload)
log_success_msg "Reloading Patroni configuration"
get_pid
kill -HUP $CHILDPID
;;
status)
get_pid
if start-stop-daemon -T --pid $CHILDPID; then
log_success_msg "Patroni is running\n"
exit 0
else
log_warning_msg "Patroni in not running\n"
fi
;;
restart)
$0 stop
$0 start
;;
*)
echo "Usage: /etc/init.d/$NAME {start|stop|restart|reload|status}"
exit 1
;;
esac
if [ $? -eq 0 ]; then
echo .
exit 0
else
echo " failed"
exit 1
fi
+16 -2
View File
@@ -11,17 +11,31 @@ Type=simple
User=postgres
Group=postgres
# Read in configuration file if it exists, otherwise proceed
EnvironmentFile=-/etc/patroni_env.conf
WorkingDirectory=~
# Where to send early-startup messages from the server
# This is normally controlled by the global default set by systemd
# StandardOutput=syslog
#StandardOutput=syslog
# Pre-commands to start watchdog device
# Uncomment if watchdog is part of your patroni setup
#ExecStartPre=-/usr/bin/sudo /sbin/modprobe softdog
#ExecStartPre=-/usr/bin/sudo /bin/chown postgres /dev/watchdog
# Start the patroni process
ExecStart=/bin/patroni /etc/patroni.yml
# Send HUP to reload from patroni.yml
ExecReload=/bin/kill -s HUP $MAINPID
# 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
TimeoutSec=30
# Do not restart the service if it crashes, we want to manually inspect database on failure
Restart=no
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
while getopts ":-:" optchar; do
[[ "${optchar}" == "-" ]] || continue
case "${OPTARG}" in
datadir=* )
PGDATA=${OPTARG#*=}
;;
dbname=* )
DBNAME=${OPTARG#*=}
;;
walmethod=* )
WALMETHOD=${OPTARG#*=}
;;
esac
done
[[ -z $PGDATA || -z $DBNAME || -z $WALMETHOD ]] && exit 1
[[ $WALMETHOD != "none" ]] && WALMETHOD="-X $WALMETHOD" || WALMETHOD=""
exec pg_basebackup -D $PGDATA $WALMETHOD -c fast -d $DBNAME
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -x
while getopts ":-:" optchar; do
[[ "${optchar}" == "-" ]] || continue
case "${OPTARG}" in
datadir=* )
PGDATA=${OPTARG#*=}
;;
sourcedir=* )
SOURCE=${OPTARG#*=}
;;
esac
done
[[ -z $PGDATA || -z $SOURCE ]] && exit 1
mkdir -p $(dirname $PGDATA)
exec cp -af $SOURCE $PGDATA
+48 -9
View File
@@ -3,15 +3,54 @@ Feature: basic replication
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 postgres0 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "loop_wait": 2, "synchronous_mode": true}
Then I receive a response code 200
When I start postgres1
And I configure and start postgres2 with a tag replicatefrom postgres0
And "sync" key in DCS has leader=postgres0 after 20 seconds
And I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds
Then table foo is present on postgres2 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
Scenario: check restart of sync replica
Given I shut down postgres2
Then "sync" key in DCS has sync_standby=postgres1 after 5 seconds
When I start postgres2
And I shut down postgres1
Then "sync" key in DCS has sync_standby=postgres2 after 10 seconds
When I start postgres1
And "members/postgres1" key in DCS has state=running after 10 seconds
And I sleep for 2 seconds
When I issue a GET request to http://127.0.0.1:8010/sync
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8009/async
Then I receive a response code 200
Scenario: check the basic failover in synchronous mode
Given I run patronictl.py pause batman
Then I receive a response returncode 0
When I sleep for 2 seconds
And I shut down postgres0
And I run patronictl.py resume batman
Then I receive a response returncode 0
And postgres2 role is the primary after 24 seconds
And Response on GET http://127.0.0.1:8010/history contains recovery after 10 seconds
When I issue a PATCH request to http://127.0.0.1:8010/config with {"synchronous_mode": null, "master_start_timeout": 0}
Then I receive a response code 200
When I add the table bar to postgres2
Then table bar is present on postgres1 after 20 seconds
And Response on GET http://127.0.0.1:8010/config contains master_start_timeout after 10 seconds
Scenario: check immediate failover when master_start_timeout=0
Given I kill postmaster on postgres2
Then postgres1 is a leader after 10 seconds
And postgres1 role is the primary after 10 seconds
Scenario: check rejoin of the former master with pg_rewind
Given I add the table splitbrain to postgres0
And 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
When I add the table buz to postgres1
Then table buz is present on postgres0 after 20 seconds
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
[[ "$3" == "master" ]] || exit
PGPASSWORD=zalando psql -h localhost -U postgres -p $1 -w -tAc "SELECT slot_name FROM pg_replication_slots WHERE slot_type = 'logical'" >> data/postgres0/label
+1
View File
@@ -8,6 +8,7 @@ Scenario: check a base backup and streaming replication from a replica
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 "members/postgres1" key in DCS has state=running after 12 seconds
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
+17
View File
@@ -0,0 +1,17 @@
Feature: custom bootstrap
We should check that patroni can bootstrap a new cluster from a backup
Scenario: clone existing cluster using pg_basebackup
Given I start postgres0
Then postgres0 is a leader after 10 seconds
When I add the table foo to postgres0
And I start postgres1 in a cluster batman1 as a clone of postgres0
Then postgres1 is a leader of batman1 after 10 seconds
Then table foo is present on postgres1 after 10 seconds
Scenario: make a backup and do a restore into a new cluster
Given I add the table bar to postgres1
And I do a backup of postgres1
When I start postgres2 in a cluster batman2 from backup
Then postgres2 is a leader of batman2 after 30 seconds
And table bar is present on postgres2 after 10 seconds
+498 -83
View File
@@ -1,14 +1,15 @@
import abc
import consul
import etcd
import kazoo.client
import kazoo.exceptions
import datetime
import os
import psycopg2
import json
import shutil
import signal
import six
import subprocess
import sys
import tempfile
import threading
import time
import yaml
@@ -16,7 +17,8 @@ import yaml
@six.add_metaclass(abc.ABCMeta)
class AbstractController(object):
def __init__(self, name, work_directory, output_dir):
def __init__(self, context, name, work_directory, output_dir):
self._context = context
self._name = name
self._work_directory = work_directory
self._output_dir = output_dir
@@ -46,6 +48,7 @@ class AbstractController(object):
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
max_wait_limit *= self._context.timeout_multiplier
for _ in range(max_wait_limit):
if self._is_accessible():
break
@@ -54,10 +57,11 @@ class AbstractController(object):
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):
def stop(self, kill=False, timeout=15, _=False):
term = False
start_time = time.time()
timeout *= self._context.timeout_multiplier
while self._handle and self._is_running():
if kill:
self._handle.kill()
@@ -71,18 +75,29 @@ class AbstractController(object):
if self._log:
self._log.close()
def cancel_background(self):
pass
class PatroniController(AbstractController):
__PORT = 5440
__PORT = 5360
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)
def __init__(self, context, name, work_directory, output_dir, custom_config=None):
super(PatroniController, self).__init__(context, '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)
if custom_config and 'watchdog' in custom_config:
self.watchdog = WatchdogMonitor(name, work_directory, output_dir)
custom_config['watchdog'] = {'driver': 'testing', 'device': self.watchdog.fifo_path, 'mode': 'required'}
else:
self.watchdog = None
self._scope = (custom_config or {}).get('scope', 'batman')
self._config = self._make_patroni_test_config(name, custom_config)
self._closables = []
self._conn = None
self._curs = None
@@ -91,59 +106,97 @@ class PatroniController(AbstractController):
with open(os.path.join(self._data_dir, 'label'), 'w') as f:
f.write(content)
def read_label(self):
def read_label(self, label):
try:
with open(os.path.join(self._data_dir, 'label'), 'r') as f:
with open(os.path.join(self._data_dir, label), 'r') as f:
return f.read().strip()
except IOError:
return None
@staticmethod
def recursive_update(dst, src):
for k, v in src.items():
if k in dst and isinstance(dst[k], dict):
PatroniController.recursive_update(dst[k], v)
else:
dst[k] = v
def update_config(self, custom_config):
with open(self._config) as r:
config = yaml.safe_load(r)
self.recursive_update(config, custom_config)
with open(self._config, 'w') as w:
yaml.safe_dump(config, w, default_flow_style=False)
self._scope = config.get('scope', 'batman')
def add_tag_to_config(self, tag, value):
self.update_config({'tags': {tag: value}})
def _start(self):
return subprocess.Popen(['coverage', 'run', '--source=patroni', '-p', 'patroni.py', self._config],
if self.watchdog:
self.watchdog.start()
if isinstance(self._context.dcs_ctl, KubernetesController):
self._context.dcs_ctl.create_pod(self._name[8:], self._scope)
os.environ['PATRONI_KUBERNETES_POD_IP'] = '10.0.0.' + self._name[-1]
return subprocess.Popen([sys.executable, '-m', '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 stop(self, kill=False, timeout=15, postgres=False):
if postgres:
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-mi', '-w'])
super(PatroniController, self).stop(kill, timeout)
if isinstance(self._context.dcs_ctl, KubernetesController):
self._context.dcs_ctl.delete_pod(self._name[8:])
if self.watchdog:
self.watchdog.stop()
def _make_patroni_test_config(self, name, dcs, tags):
def _is_accessible(self):
cursor = self.query("SELECT 1", fail_ok=True)
if cursor is not None:
cursor.execute("SET synchronous_commit TO 'local'")
return True
def _make_patroni_test_config(self, name, custom_config):
patroni_config_name = self.PATRONI_CONFIG.format(name)
patroni_config_path = os.path.join(self._output_dir, patroni_config_name)
with open(patroni_config_name) as f:
config = yaml.load(f)
config = yaml.safe_load(f)
config.pop('etcd', None)
host = config['postgresql']['listen'].split(':')[0]
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
user = config['postgresql'].get('superuser', {})
config['name'] = name
config['postgresql']['data_dir'] = self._data_dir
config['postgresql']['use_unix_socket'] = True
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',
'unix_socket_directories': self._data_dir})
if 'bootstrap' in config:
config['bootstrap']['post_bootstrap'] = 'psql -w -c "SELECT 1"'
if 'initdb' in config['bootstrap']:
config['bootstrap']['initdb'].extend([{'auth': 'md5'}, {'auth-host': 'md5'}])
if custom_config is not None:
self.recursive_update(config, custom_config)
if config['postgresql'].get('callbacks', {}).get('on_role_change'):
config['postgresql']['callbacks']['on_role_change'] += ' ' + str(self.__PORT)
with open(patroni_config_path, 'w') as f:
yaml.safe_dump(config, f, default_flow_style=False)
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['postgresql'].update({'name': name, 'data_dir': self._data_dir})
config['postgresql']['parameters'].update({
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
'log_filename': name + '.log', 'log_statement': 'all', 'log_min_messages': 'debug1'})
if tags:
config['tags'] = tags
if dcs != 'etcd':
dcs_config = config.pop('etcd')
dcs_config.pop('host')
if dcs == 'consul':
config[dcs] = dcs_config
else:
dcs_config.update({'session_timeout': dcs_config.pop('ttl'), 'reconnect_timeout': config['loop_wait']})
if dcs == 'exhibitor':
dcs_config['exhibitor'] = {'hosts': ['127.0.0.1'], 'port': 8181}
else:
dcs_config['hosts'] = ['127.0.0.1:2181']
config['zookeeper'] = dcs_config
with open(patroni_config_path, 'w') as f:
yaml.dump(config, f, default_flow_style=False)
self._replication = config['postgresql'].get('authentication', config['postgresql']).get('replication', {})
self._replication.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
return patroni_config_path
@@ -179,38 +232,125 @@ class PatroniController(AbstractController):
time.sleep(1)
return False
def get_watchdog(self):
return self.watchdog
def _get_pid(self):
try:
pidfile = os.path.join(self._data_dir, 'postmaster.pid')
if not os.path.exists(pidfile):
return None
return int(open(pidfile).readline().strip())
except Exception:
return None
def patroni_hang(self, timeout):
hang = ProcessHang(self._handle.pid, timeout)
self._closables.append(hang)
hang.start()
def cancel_background(self):
for obj in self._closables:
obj.close()
self._closables = []
@property
def backup_source(self):
return 'postgres://{username}:{password}@{host}:{port}/{database}'.format(**self._replication)
def backup(self, dest='data/basebackup'):
subprocess.call([PatroniPoolController.BACKUP_SCRIPT, '--walmethod=none',
'--datadir=' + os.path.join(self._work_directory, dest),
'--dbname=' + self.backup_source])
class ProcessHang(object):
"""A background thread implementing a cancelable process hang via SIGSTOP."""
def __init__(self, pid, timeout):
self._cancelled = threading.Event()
self._thread = threading.Thread(target=self.run)
self.pid = pid
self.timeout = timeout
def start(self):
self._thread.start()
def run(self):
os.kill(self.pid, signal.SIGSTOP)
try:
self._cancelled.wait(self.timeout)
finally:
os.kill(self.pid, signal.SIGCONT)
def close(self):
self._cancelled.set()
self._thread.join()
class AbstractDcsController(AbstractController):
_CLUSTER_NODE = 'service/batman'
_CLUSTER_NODE = '/service/{0}'
def __init__(self, context, mktemp=True):
work_directory = mktemp and tempfile.mkdtemp() or None
super(AbstractDcsController, self).__init__(context, self.name(), work_directory, context.pctl.output_dir)
def _is_accessible(self):
return self._is_running()
def stop_and_remove_work_directory(self, timeout=15):
def stop(self, kill=False, timeout=15):
""" terminate process and wipe out the temp work directory, but only if we actually started it"""
self.stop(timeout=timeout)
super(AbstractDcsController, self).stop(kill=kill, timeout=timeout)
if self._work_directory:
shutil.rmtree(self._work_directory)
def path(self, key=None, scope='batman'):
return self._CLUSTER_NODE.format(scope) + (key and '/' + key or '')
@abc.abstractmethod
def query(self, key):
def query(self, key, scope='batman'):
""" query for a value of a given key """
@abc.abstractmethod
def cleanup_service_tree(self):
""" clean all contents stored in the tree used for the tests """
@classmethod
def get_subclasses(cls):
for subclass in cls.__subclasses__():
for subsubclass in subclass.get_subclasses():
yield subsubclass
yield subclass
@classmethod
def name(cls):
return cls.__name__[:-10].lower()
class ConsulController(AbstractDcsController):
def __init__(self, output_dir):
super(ConsulController, self).__init__('consul', tempfile.mkdtemp(), output_dir)
def __init__(self, context):
super(ConsulController, self).__init__(context)
os.environ['PATRONI_CONSUL_HOST'] = 'localhost:8500'
os.environ['PATRONI_CONSUL_REGISTER_SERVICE'] = 'on'
self._config_file = None
import consul
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)
self._config_file = self._work_directory + '.json'
with open(self._config_file, 'wb') as f:
f.write(b'{"session_ttl_min":"5s","server":true,"bootstrap":true,"advertise_addr":"127.0.0.1"}')
return subprocess.Popen(['consul', 'agent', '-config-file', self._config_file, '-data-dir',
self._work_directory], stdout=self._log, stderr=subprocess.STDOUT)
def stop(self, kill=False, timeout=15):
super(ConsulController, self).stop(kill=kill, timeout=timeout)
if self._config_file:
os.unlink(self._config_file)
def _is_running(self):
try:
@@ -218,35 +358,46 @@ class ConsulController(AbstractDcsController):
except Exception:
return False
def query(self, key):
_, value = self._client.kv.get('{0}/{1}'.format(self._CLUSTER_NODE, key))
def path(self, key=None, scope='batman'):
return super(ConsulController, self).path(key, scope)[1:]
def query(self, key, scope='batman'):
_, value = self._client.kv.get(self.path(key, scope))
return value and value['Value'].decode('utf-8')
def cleanup_service_tree(self):
self._client.kv.delete(self._CLUSTER_NODE, recurse=True)
self._client.kv.delete(self.path(scope=''), recurse=True)
def start(self, max_wait_limit=15):
super(ConsulController, self).start(max_wait_limit)
class EtcdController(AbstractDcsController):
""" handles all etcd related tasks, used for the tests setup and cleanup """
def __init__(self, output_dir):
super(EtcdController, self).__init__('etcd', tempfile.mkdtemp(), output_dir)
self._client = etcd.Client()
def __init__(self, context):
super(EtcdController, self).__init__(context)
os.environ['PATRONI_ETCD_HOST'] = 'localhost:2379'
import etcd
self._client = etcd.Client(port=2379)
def _start(self):
return subprocess.Popen(["etcd", "--debug", "--data-dir", self._work_directory],
stdout=self._log, stderr=subprocess.STDOUT)
def query(self, key):
def query(self, key, scope='batman'):
import etcd
try:
return self._client.get('/{0}/{1}'.format(self._CLUSTER_NODE, key)).value
return self._client.get(self.path(key, scope)).value
except etcd.EtcdKeyNotFound:
return None
def cleanup_service_tree(self):
import etcd
try:
self._client.delete('/' + self._CLUSTER_NODE, recursive=True)
self._client.delete(self.path(scope=''), recursive=True)
except (etcd.EtcdKeyNotFound, etcd.EtcdConnectionFailed):
return
except Exception as e:
@@ -260,26 +411,103 @@ class EtcdController(AbstractDcsController):
return False
class KubernetesController(AbstractDcsController):
def __init__(self, context):
super(KubernetesController, self).__init__(context)
self._namespace = 'default'
self._labels = {"application": "patroni"}
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
os.environ['PATRONI_KUBERNETES_LABELS'] = json.dumps(self._labels)
os.environ['PATRONI_KUBERNETES_USE_ENDPOINTS'] = 'true'
from kubernetes import client as k8s_client, config as k8s_config
k8s_config.load_kube_config(context='local')
self._client = k8s_client
self._api = self._client.CoreV1Api()
def _start(self):
pass
def create_pod(self, name, scope):
labels = self._labels.copy()
labels['cluster-name'] = scope
metadata = self._client.V1ObjectMeta(namespace=self._namespace, name=name, labels=labels)
spec = self._client.V1PodSpec(containers=[self._client.V1Container(name=name, image='empty')])
body = self._client.V1Pod(metadata=metadata, spec=spec)
self._api.create_namespaced_pod(self._namespace, body)
def delete_pod(self, name):
try:
self._api.delete_namespaced_pod(name, self._namespace, body=self._client.V1DeleteOptions())
except Exception:
pass
while True:
try:
self._api.read_namespaced_pod(name, self._namespace)
except Exception:
break
def query(self, key, scope='batman'):
if key.startswith('members/'):
pod = self._api.read_namespaced_pod(key[8:], self._namespace)
return (pod.metadata.annotations or {}).get('status', '')
else:
try:
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(key, '-' + key)
e = self._api.read_namespaced_endpoints(ep, self._namespace)
if key != 'sync':
return e.metadata.annotations[key]
else:
return json.dumps(e.metadata.annotations)
except Exception:
return None
def cleanup_service_tree(self):
try:
self._api.delete_collection_namespaced_pod(self._namespace, label_selector=self._label_selector)
except Exception:
pass
try:
self._api.delete_collection_namespaced_endpoints(self._namespace, label_selector=self._label_selector)
except Exception:
pass
while True:
result = self._api.list_namespaced_pod(self._namespace, label_selector=self._label_selector)
if len(result.items) < 1:
break
def _is_running(self):
return True
class ZooKeeperController(AbstractDcsController):
""" handles all zookeeper related tasks, used for the tests setup and cleanup """
def __init__(self, output_dir):
super(ZooKeeperController, self).__init__('zookeeper', None, output_dir)
def __init__(self, context, export_env=True):
super(ZooKeeperController, self).__init__(context, False)
if export_env:
os.environ['PATRONI_ZOOKEEPER_HOSTS'] = "'localhost:2181'"
import kazoo.client
self._client = kazoo.client.KazooClient()
def _start(self):
pass # TODO: implement later
def query(self, key):
def query(self, key, scope='batman'):
import kazoo.exceptions
try:
return self._client.get('/{0}/{1}'.format(self._CLUSTER_NODE, key))[0].decode('utf-8')
return self._client.get(self.path(key, scope))[0].decode('utf-8')
except kazoo.exceptions.NoNodeError:
return None
def cleanup_service_tree(self):
import kazoo.exceptions
try:
self._client.delete('/' + self._CLUSTER_NODE, recursive=True)
self._client.delete(self.path(scope=''), recursive=True)
except (kazoo.exceptions.NoNodeError):
return
except Exception as e:
@@ -295,17 +523,25 @@ class ZooKeeperController(AbstractDcsController):
return False
class ExhibitorController(ZooKeeperController):
def __init__(self, context):
super(ExhibitorController, self).__init__(context, False)
os.environ.update({'PATRONI_EXHIBITOR_HOSTS': 'localhost', 'PATRONI_EXHIBITOR_PORT': '8181'})
class PatroniPoolController(object):
KNOWN_DCS = {'consul': ConsulController, 'etcd': EtcdController,
'zookeeper': ZooKeeperController, 'exhibitor': ZooKeeperController}
BACKUP_SCRIPT = 'features/backup_create.sh'
def __init__(self):
def __init__(self, context):
self._context = context
self._dcs = None
self._output_dir = None
self._patroni_path = None
self._processes = {}
self.create_and_set_output_directory('')
self.known_dcs = {subclass.name(): subclass for subclass in AbstractDcsController.get_subclasses()}
@property
def patroni_path(self):
@@ -322,55 +558,232 @@ class PatroniPoolController(object):
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 start(self, name, max_wait_limit=20, custom_config=None):
if name not in self._processes:
self._processes[name] = PatroniController(self._context, name, self.patroni_path,
self._output_dir, custom_config)
self._processes[name].start(max_wait_limit)
def __getattr__(self, func):
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to']:
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to',
'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup']:
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)
def wrapper(name, *args, **kwargs):
return getattr(self._processes[name], func)(*args, **kwargs)
return wrapper
def stop_all(self):
for ctl in self._processes.values():
ctl.cancel_background()
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(' ', '_'))
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
def clone(self, from_name, cluster_name, to_name):
f = self._processes[from_name]
custom_config = {
'scope': cluster_name,
'bootstrap': {
'method': 'pg_basebackup',
'pg_basebackup': {
'command': self.BACKUP_SCRIPT + ' --walmethod=stream --dbname=' + f.backup_source
},
'dcs': {
'postgresql': {
'parameters': {
'max_connections': 101
}
}
}
},
'postgresql': {
'parameters': {
'archive_mode': 'on',
'archive_command': 'mkdir -p {0} && test ! -f {0}/%f && cp %p {0}/%f'.format(
os.path.join(self.patroni_path, 'data', 'wal_archive'))
},
'authentication': {
'superuser': {'password': 'zalando1'},
'replication': {'password': 'rep-pass1'}
}
}
}
self.start(to_name, custom_config=custom_config)
def bootstrap_from_backup(self, name, cluster_name):
custom_config = {
'scope': cluster_name,
'bootstrap': {
'method': 'backup_restore',
'backup_restore': {
'command': 'features/backup_restore.sh --sourcedir=' + os.path.join(self.patroni_path,
'data', 'basebackup'),
'recovery_conf': {
'recovery_target_action': 'promote',
'recovery_target_timeline': 'latest',
'restore_command': 'cp {0}/data/wal_archive/%f %p'.format(self.patroni_path)
}
}
},
'postgresql': {
'authentication': {
'superuser': {'password': 'zalando2'},
'replication': {'password': 'rep-pass2'}
}
}
}
self.start(name, custom_config=custom_config)
@property
def dcs(self):
if self._dcs is None:
self._dcs = os.environ.get('DCS', 'etcd')
assert self._dcs in self.KNOWN_DCS, 'Unsupported dcs: ' + self.dcs
self._dcs = os.environ.pop('DCS', 'etcd')
assert self._dcs in self.known_dcs, 'Unsupported dcs: ' + self._dcs
return self._dcs
class WatchdogMonitor(object):
"""Testing harness for emulating a watchdog device as a named pipe. Because we can't easily emulate ioctl's we
require a custom driver on Patroni side. The device takes no action, only notes if it was pinged and/or triggered.
"""
def __init__(self, name, work_directory, output_dir):
self.fifo_path = os.path.join(work_directory, 'data', 'watchdog.{0}.fifo'.format(name))
self.fifo_file = None
self._stop_requested = False # Relying on bool setting being atomic
self._thread = None
self.last_ping = None
self.was_pinged = False
self.was_closed = False
self._was_triggered = False
self.timeout = 60
self._log_file = open(os.path.join(output_dir, 'watchdog.{0}.log'.format(name)), 'w')
self._log("watchdog {0} initialized".format(name))
def _log(self, msg):
tstamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S,%f")
self._log_file.write("{0}: {1}\n".format(tstamp, msg))
def start(self):
assert self._thread is None
self._stop_requested = False
self._log("starting fifo {0}".format(self.fifo_path))
fifo_dir = os.path.dirname(self.fifo_path)
if os.path.exists(self.fifo_path):
os.unlink(self.fifo_path)
elif not os.path.exists(fifo_dir):
os.mkdir(fifo_dir)
os.mkfifo(self.fifo_path)
self.last_ping = time.time()
self._thread = threading.Thread(target=self.run)
self._thread.start()
def run(self):
try:
while not self._stop_requested:
self._log("opening")
self.fifo_file = os.open(self.fifo_path, os.O_RDONLY)
try:
self._log("Fifo {0} connected".format(self.fifo_path))
self.was_closed = False
while not self._stop_requested:
c = os.read(self.fifo_file, 1)
if c == b'X':
self._log("Stop requested")
return
elif c == b'':
self._log("Pipe closed")
break
elif c == b'C':
command = b''
c = os.read(self.fifo_file, 1)
while c != b'\n' and c != b'':
command += c
c = os.read(self.fifo_file, 1)
command = command.decode('utf8')
if command.startswith('timeout='):
self.timeout = int(command.split('=')[1])
self._log("timeout={0}".format(self.timeout))
elif c in [b'V', b'1']:
cur_time = time.time()
if cur_time - self.last_ping > self.timeout:
self._log("Triggered")
self._was_triggered = True
if c == b'V':
self._log("magic close")
self.was_closed = True
elif c == b'1':
self.was_pinged = True
self._log("ping after {0} seconds".format(cur_time - (self.last_ping or cur_time)))
self.last_ping = cur_time
else:
self._log('Unknown command {0} received from fifo'.format(c))
finally:
self.was_closed = True
self._log("closing")
os.close(self.fifo_file)
except Exception as e:
self._log("Error {0}".format(e))
finally:
self._log("stopping")
self._log_file.flush()
if os.path.exists(self.fifo_path):
os.unlink(self.fifo_path)
def stop(self):
self._log("Monitor stop")
self._stop_requested = True
try:
if os.path.exists(self.fifo_path):
fd = os.open(self.fifo_path, os.O_WRONLY)
os.write(fd, b'X')
os.close(fd)
except Exception as e:
self._log("err while closing: {0}".format(str(e)))
if self._thread:
self._thread.join()
self._thread = None
def reset(self):
self._log("reset")
self.was_pinged = self.was_closed = self._was_triggered = False
@property
def was_triggered(self):
delta = time.time() - self.last_ping
triggered = self._was_triggered or not self.was_closed and delta > self.timeout
self._log("triggered={0}, {1}s left".format(triggered, self.timeout - delta))
return triggered
# 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)
os.environ.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
context.ci = 'TRAVIS_BUILD_NUMBER' in os.environ or 'BUILD_NUMBER' in os.environ
context.timeout_multiplier = 2 if context.ci else 1
context.pctl = PatroniPoolController(context)
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
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()
context.dcs_ctl.stop()
raise
def after_all(context):
context.dcs_ctl.stop_and_remove_work_directory()
subprocess.call(['coverage', 'combine'])
subprocess.call(['coverage', 'report'])
context.dcs_ctl.stop()
subprocess.call([sys.executable, '-m', 'coverage', 'combine'])
subprocess.call([sys.executable, '-m', 'coverage', 'report'])
def before_feature(context, feature):
@@ -383,3 +796,5 @@ def after_feature(context, feature):
context.pctl.stop_all()
shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data'))
context.dcs_ctl.cleanup_service_tree()
if feature.status == 'failed':
shutil.copytree(context.pctl.output_dir, context.pctl.output_dir + '_failed')
+92 -28
View File
@@ -8,46 +8,110 @@ Scenario: check API requests on a stand-alone server
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/health
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8008/replica
Then I receive a response code 503
When I issue an empty POST request to http://127.0.0.1:8008/reinitialize
Then I receive a response code 503
And I receive a response text "I am the leader, can not reinitialize"
When I issue a POST request to http://127.0.0.1:8008/failover with leader=postgres0
Then I receive a response code 500
And I receive a response text "failover is not possible: cluster does not have members except leader"
When I 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 switchover batman --master postgres0 --force
Then I receive a response returncode 1
And I receive a response output "Error: No candidates found to switchover to"
When I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0"}
Then I receive a response code 412
And I receive a response text switchover is not possible: cluster does not have members except leader
When I issue an empty POST request to http://127.0.0.1:8008/failover
Then I receive a response code 400
And I receive a response text "No values given for required parameters leader and candidate"
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 "Failover could be performed only to a specific candidate"
Scenario: check API requests for the primary-replica pair
Given I start postgres1
And replication works from postgres0 to postgres1 after 20 seconds
Scenario: check local configuration reload
Given 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 run patronictl.py edit-config -s 'ttl=10' -s 'loop_wait=2' -p 'max_connections=101' --force batman
Then I receive a response returncode 0
And I receive a response output "+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 {'new_tag': 'new_value'}
And I sleep for 4 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 3 seconds with {"role": "replica"}
Then I receive a response code 202
And I sleep for 4 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 3 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
And postgres0 role is the primary after 10 seconds
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 issue an empty POST request to http://127.0.0.1:8009/reinitialize
Then I receive a response code 200
When I issue an empty POST request to http://127.0.0.1:8008/restart
Then I receive a response code 200
And postgres0 role is the primary after 5 seconds
When I sleep for 10 seconds
Then postgres1 role is the secondary after 15 seconds
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
Given I issue a POST request to http://127.0.0.1:8008/failover with leader=postgres0,candidate=postgres1
Scenario: check the switchover via the API in the pause mode
Given I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0", "candidate": "postgres1"}
Then I receive a response code 200
And postgres1 is a leader after 5 seconds
And postgres1 role is the primary after 5 seconds
And postgres0 role is the secondary after 10 seconds
And 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 at http://127.0.0.1:8009 from postgres1 to postgres0 in 1 seconds
And "members/postgres0" key in DCS has state=running after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/master
Then I receive a response code 503
When I issue a GET request to http://127.0.0.1:8008/replica
Then I receive a response code 200
And postgres0 is a leader after 20 seconds
And postgres0 role is the primary after 5 seconds
And postgres1 role is the secondary after 10 seconds
And replication works from postgres0 to postgres1 after 25 seconds
When I issue a GET request to http://127.0.0.1:8009/master
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 503
Scenario: check the scheduled switchover
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds
Then I receive a response returncode 1
And I receive a response output "Can't schedule switchover in the paused state"
When I run patronictl.py resume batman
Then I receive a response returncode 0
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 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
And "members/postgres1" key in DCS has state=running after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/master
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8008/replica
Then I receive a response code 503
When I issue a GET request to http://127.0.0.1:8009/master
Then I receive a response code 503
When I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 200
+48
View File
@@ -0,0 +1,48 @@
Feature: standby cluster
Scenario: check permanent logical slots are preserved on failover/switchover
Given I start postgres1
Then postgres1 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I issue a PATCH request to http://127.0.0.1:8009/config with {"loop_wait": 2, "slots": {"pm_1": {"type": "physical"}}, "postgresql": {"parameters": {"wal_level": "logical"}}}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8009/config contains slots after 10 seconds
And I sleep for 3 seconds
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"test_logical": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
Then I receive a response code 200
And I do a backup of postgres1
When I start postgres0 with callback configured
Then "members/postgres0" key in DCS has state=running after 10 seconds
And replication works from postgres1 to postgres0 after 15 seconds
When I shut down postgres1
Then postgres0 is a leader after 10 seconds
And "members/postgres0" key in DCS has role=master after 3 seconds
When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200
And there is a label with "test_logical" in postgres0 data directory
Scenario: check replication of a single table in a standby cluster
Given I start postgres1 in a standby cluster batman1 as a clone of postgres0
Then postgres1 is a leader of batman1 after 10 seconds
When I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds
When I issue a GET request to http://127.0.0.1:8009/master
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8009/standby_leader
Then I receive a response code 200
And I receive a response role standby_leader
And there is a postgres1_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres1 data directory
When I start postgres2 in a cluster batman1
Then postgres2 role is the replica after 24 seconds
And table foo is present on postgres2 after 20 seconds
Scenario: check failover
When I kill postgres1
And I kill postmaster on postgres1
Then postgres2 is replicating from postgres0 after 32 seconds
When I issue a GET request to http://127.0.0.1:8010/master
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8010/standby_leader
Then I receive a response code 200
And I receive a response role standby_leader
And replication works from postgres0 to postgres2 after 15 seconds
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
+8 -1
View File
@@ -11,7 +11,7 @@ def start_patroni(context, name):
@step('I shut down {name:w}')
def stop_patroni(context, name):
return context.pctl.stop(name)
return context.pctl.stop(name, timeout=60)
@step('I kill {name:w}')
@@ -19,6 +19,11 @@ def kill_patroni(context, name):
return context.pctl.stop(name, kill=True)
@step('I kill postmaster on {name:w}')
def stop_postgres(context, name):
return context.pctl.stop(name, postgres=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
@@ -30,6 +35,7 @@ def add_table(context, table_name, pg_name):
@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):
max_replication_delay *= context.timeout_multiplier
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
@@ -41,6 +47,7 @@ def table_is_present_on(context, 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):
max_promotion_timeout *= context.timeout_multiplier
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)
+37 -4
View File
@@ -1,17 +1,50 @@
import json
import time
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})
return context.pctl.start(name, custom_config={'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)
@then('There is a {label} with "{content}" in {name:w} data directory')
def check_label(context, label, content, name):
label = context.pctl.read_label(name, label)
label = label.replace('\n', '\\n')
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)
@step('"{name}" key in DCS has {key:w}={value:w} after {time_limit:d} seconds')
def check_member(context, name, key, value, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while time.time() < max_time:
try:
response = json.loads(context.dcs_ctl.query(name))
if response.get(key) == value:
return
except Exception:
pass
time.sleep(1)
assert False, "{0} does not have {1}={2} in dcs after {3} seconds".format(name, key, value, time_limit)
@step('there is a non empty {key:w} key in DCS after {time_limit:d} seconds')
def check_initialize(context, key, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while time.time() < max_time:
try:
if context.dcs_ctl.query(key):
return
except Exception:
pass
time.sleep(1)
assert False, "There is no {0} in dcs after {1} seconds".format(key, time_limit)
+27
View File
@@ -0,0 +1,27 @@
import time
from behave import step, then
@step('I start {name:w} in a cluster {cluster_name:w} as a clone of {name2:w}')
def start_cluster_clone(context, name, cluster_name, name2):
context.pctl.clone(name2, cluster_name, name)
@step('I start {name:w} in a cluster {cluster_name:w} from backup')
def start_cluster_from_backup(context, name, cluster_name):
context.pctl.bootstrap_from_backup(name, cluster_name)
@then('{name:w} is a leader of {cluster_name:w} after {time_limit:d} seconds')
def is_a_leader(context, name, cluster_name, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while (context.dcs_ctl.query("leader", scope=cluster_name) != 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 do a backup of {name:w}')
def do_backup(context, name):
context.pctl.backup(name)
+95 -44
View File
@@ -1,10 +1,19 @@
import json
import os
import parse
import pytz
import requests
import shlex
import subprocess
import sys
import time
import yaml
from behave import register_type, step, then
from dateutil import tz
from datetime import datetime, timedelta
from patroni.request import PatroniRequest
tzutc = tz.tzutc()
request_executor = PatroniRequest({'ctl': {'auth': 'username:password'}})
@parse.with_pattern(r'https?://(?:\w|\.|:|/)+')
@@ -12,12 +21,7 @@ def parse_url(text):
return text
@parse.with_pattern(r'(?:\w+=(?:\w|\.|:|-|\+|\s)+,?)+')
def parse_data(text):
return text
register_type(url=parse_url, data=parse_data)
register_type(url=parse_url)
# there is no way we can find out if the node has already
@@ -28,6 +32,7 @@ register_type(url=parse_url, data=parse_data)
@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):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while (context.dcs_ctl.query("leader") != name):
time.sleep(1)
@@ -39,62 +44,108 @@ def sleep_for_n_seconds(context, value):
time.sleep(int(value))
def _set_response(context, response):
context.status_code = response.status
data = response.data.decode('utf-8')
ct = response.getheader('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:
context.status_code = r.status_code
try:
context.response = r.json()
except ValueError:
context.response = r.content.decode('utf-8')
do_request(context, 'GET', url, None)
@step('I issue an empty POST request to {url:url}')
def do_post_empty(context, url):
do_post(context, url, None)
do_request(context, 'POST', url, None)
@step('I issue a POST request to {url:url} with {data:data}')
def do_post(context, url, data):
post_data = {}
if data:
post_components = data.split(',')
for pc in post_components:
if '=' in pc:
k, v = pc.split('=', 2)
post_data[k.strip()] = v.strip()
@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)
try:
r = requests.post(url, json=post_data)
except requests.exceptions.RequestException:
context.status_code = None
context.response = None
r = request_executor.request(request_method, url, data)
except Exception:
context.status_code = context.response = None
else:
context.status_code = r.status_code
try:
context.response = r.json()
except ValueError:
context.response = r.content.decode('utf-8')
_set_response(context, r)
@step('I run {cmd}')
def do_run(context, cmd):
cmd = [sys.executable, '-m', 'coverage', 'run', '--source=patroni', '-p'] + shlex.split(cmd)
try:
# XXX: Dirty hack! We need to take name/passwd from the config!
env = os.environ.copy()
env.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
response = subprocess.check_output(cmd, stderr=subprocess.STDOUT, env=env)
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, int(data), context.response)
"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}, {2}".format(context.status_code,
data, context.response)
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 context.response[component] == data, "{0} does not contain {1}".format(component, data)
assert str(context.response[component]) == str(data), "{0} does not contain {1}".format(component, data)
@step('I issue a scheduled failover at {at_url:url} from {from_host:w} to {to_host:w} in {in_seconds:d} seconds')
def scheduled_failover(context, at_url, from_host, to_host, in_seconds):
@step('I issue a scheduled switchover from {from_host:w} to {to_host:w} in {in_seconds:d} seconds')
def scheduled_switchover(context, from_host, to_host, in_seconds):
context.execute_steps(u"""
Given I issue a POST request to {0}/failover with leader={1},candidate={2},scheduled_at={3}
""".format(at_url, from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds))))
Given I run patronictl.py switchover batman --master {0} --candidate {1} --scheduled "{2}" --force
""".format(from_host, to_host, datetime.now(tzutc) + 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(tzutc) + 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):
timeout *= context.timeout_multiplier
for _ in range(int(timeout)):
r = request_executor.request('GET', url)
if (value in r.data.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)
+82
View File
@@ -0,0 +1,82 @@
import os
import time
from behave import step
select_replication_query = """
SELECT * FROM pg_catalog.pg_stat_replication
WHERE application_name = '{0}'
"""
callback = "bash -c 'echo \"${*: -3:1} ${*: -2:1} ${*: -1:1}\" >> data/$1/$1_cb.log' -- "
@step('I start {name:w} with callback configured')
def start_patroni_with_callbacks(context, name):
return context.pctl.start(name, custom_config={
"postgresql": {
"callbacks": {
"on_role_change": "features/callback.sh"
}
}
})
@step('I start {name:w} in a cluster {cluster_name:w}')
def start_patroni(context, name, cluster_name):
return context.pctl.start(name, custom_config={
"scope": cluster_name,
"postgresql": {
"callbacks": {c: callback + name for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')},
"backup_restore": {
"command": "features/backup_restore.sh --sourcedir=" + os.path.join(context.pctl.patroni_path,
'data', 'basebackup')}
}
})
@step('I start {name:w} in a standby cluster {cluster_name:w} as a clone of {name2:w}')
def start_patroni_standby_cluster(context, name, cluster_name, name2):
# we need to remove patroni.dynamic.json in order to "bootstrap" standby cluster with existing PGDATA
os.unlink(os.path.join(context.pctl._processes[name]._data_dir, 'patroni.dynamic.json'))
port = context.pctl._processes[name2]._connkwargs.get('port')
context.pctl._processes[name].update_config({
"scope": cluster_name,
"bootstrap": {
"dcs": {
"ttl": 20,
"loop_wait": 2,
"retry_timeout": 5,
"standby_cluster": {
"host": "localhost",
"port": port,
"primary_slot_name": "pm_1",
"create_replica_methods": ["backup_restore", "basebackup"]
}
}
},
"postgresql": {
"callbacks": {c: callback + name for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')}
}
})
return context.pctl.start(name)
@step('{pg_name1:w} is replicating from {pg_name2:w} after {timeout:d} seconds')
def check_replication_status(context, pg_name1, pg_name2, timeout):
bound_time = time.time() + timeout
while time.time() < bound_time:
cur = context.pctl.query(
pg_name2,
select_replication_query.format(pg_name1),
fail_ok=True
)
if cur and len(cur.fetchall()) != 0:
break
time.sleep(1)
else:
assert False, "{0} is not replicating from {1} after {2} seconds".format(pg_name1, pg_name2, timeout)
+49
View File
@@ -0,0 +1,49 @@
from behave import step, then
import time
def polling_loop(timeout, interval=1):
"""Returns an iterator that returns values until timeout has passed. Timeout is measured from start of iteration."""
start_time = time.time()
iteration = 0
end_time = start_time + timeout
while time.time() < end_time:
yield iteration
iteration += 1
time.sleep(interval)
@step('I start {name:w} with watchdog')
def start_patroni_with_watchdog(context, name):
return context.pctl.start(name, custom_config={'watchdog': True})
@step('{name:w} watchdog has been pinged after {timeout:d} seconds')
def watchdog_was_pinged(context, name, timeout):
for _ in polling_loop(timeout):
if context.pctl.get_watchdog(name).was_pinged:
return True
return False
@then('{name:w} watchdog has been closed')
def watchdog_was_closed(context, name):
assert context.pctl.get_watchdog(name).was_closed
@step('I reset {name:w} watchdog state')
def watchdog_reset_pinged(context, name):
context.pctl.get_watchdog(name).reset()
@then('{name:w} watchdog is triggered after {timeout:d} seconds')
def watchdog_was_triggered(context, name, timeout):
for _ in polling_loop(timeout):
if context.pctl.get_watchdog(name).was_triggered:
return True
assert False
@step('{name:w} hangs for {timeout:d} seconds')
def patroni_hang(context, name, timeout):
return context.pctl.patroni_hang(name, timeout)
+31
View File
@@ -0,0 +1,31 @@
Feature: watchdog
Verify that watchdog gets pinged and triggered under appropriate circumstances.
Scenario: watchdog is opened and pinged
Given I start postgres0 with watchdog
Then postgres0 is a leader after 10 seconds
And postgres0 role is the primary after 10 seconds
And postgres0 watchdog has been pinged after 10 seconds
Scenario: watchdog is disabled during pause
Given I run patronictl.py pause batman
Then I receive a response returncode 0
When I sleep for 2 seconds
Then postgres0 watchdog has been closed
Scenario: watchdog is opened and pinged after resume
Given I reset postgres0 watchdog state
And I run patronictl.py resume batman
Then I receive a response returncode 0
And postgres0 watchdog has been pinged after 10 seconds
Scenario: watchdog is disabled when shutting down
Given I shut down postgres0
Then postgres0 watchdog has been closed
Scenario: watchdog is triggered if patroni stops responding
Given I reset postgres0 watchdog state
And I start postgres0 with watchdog
Then postgres0 role is the primary after 10 seconds
When postgres0 hangs for 30 seconds
Then postgres0 watchdog is triggered after 30 seconds
+18 -14
View File
@@ -1,21 +1,25 @@
global
maxconn 100
maxconn 100
defaults
log global
mode tcp
retries 2
timeout client 30m
timeout connect 4s
timeout server 30m
timeout check 5s
log global
mode tcp
retries 2
timeout client 30m
timeout connect 4s
timeout server 30m
timeout check 5s
frontend ft_postgresql
bind *:5000
default_backend bk_db
backend bk_db
option httpchk
listen stats
mode http
bind *:7000
stats enable
stats uri /
listen batman
bind *:5000
option httpchk
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
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
+34
View File
@@ -0,0 +1,34 @@
FROM postgres:11
MAINTAINER Alexander Kukushkin <[email protected]>
RUN export DEBIAN_FRONTEND=noninteractive \
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
&& apt-get update -y \
&& apt-get upgrade -y \
&& apt-cache depends patroni | sed -n -e 's/.* Depends: \(python3-.\+\)$/\1/p' \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim-tiny curl jq locales git python3-pip python3-wheel \
## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& pip3 install setuptools \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& PGHOME=/home/postgres \
&& mkdir -p $PGHOME \
&& chown postgres $PGHOME \
&& sed -i "s|/var/lib/postgresql.*|$PGHOME:/bin/bash|" /etc/passwd \
# Set permissions for OpenShift
&& chmod 775 $PGHOME \
&& chmod 664 /etc/passwd \
# Clean up
&& apt-get remove -y git python3-pip python3-wheel \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* /root/.cache
ADD entrypoint.sh /
EXPOSE 5432 8008
ENV LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 EDITOR=/usr/bin/editor
USER postgres
WORKDIR /home/postgres
CMD ["/bin/bash", "/entrypoint.sh"]
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
if [[ $UID -ge 10000 ]]; then
GID=$(id -g)
sed -e "s/^postgres:x:[^:]*:[^:]*:/postgres:x:$UID:$GID:/" /etc/passwd > /tmp/passwd
cat /tmp/passwd > /etc/passwd
rm /tmp/passwd
fi
cat > /home/postgres/patroni.yml <<__EOF__
bootstrap:
dcs:
postgresql:
use_pg_rewind: true
initdb:
- auth-host: md5
- auth-local: trust
- encoding: UTF8
- locale: en_US.UTF-8
- data-checksums
pg_hba:
- host all all 0.0.0.0/0 md5
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
restapi:
connect_address: '${PATRONI_KUBERNETES_POD_IP}:8008'
postgresql:
connect_address: '${PATRONI_KUBERNETES_POD_IP}:5432'
authentication:
superuser:
password: '${PATRONI_SUPERUSER_PASSWORD}'
replication:
password: '${PATRONI_REPLICATION_PASSWORD}'
__EOF__
unset PATRONI_SUPERUSER_PASSWORD PATRONI_REPLICATION_PASSWORD
export KUBERNETES_NAMESPACE=$PATRONI_KUBERNETES_NAMESPACE
export POD_NAME=$PATRONI_NAME
exec /usr/bin/python3 /usr/local/bin/patroni /home/postgres/patroni.yml
+49
View File
@@ -0,0 +1,49 @@
# Patroni OpenShift Configuration
Patroni can be run in OpenShift. Based on the kubernetes configuration, the Dockerfile and Entrypoint has been modified to support the dynamic UID/GID configuration that is applied in OpenShift. This can be run under the standard `restricted` SCC.
# Examples
## Create test project
```
oc new-project patroni-test
```
## Build the image
Note: Update the references when merged upstream.
Note: If deploying as a template for multiple users, the following commands should be performed in a shared namespace like `openshift`.
```
oc import-image postgres:10 --confirm -n openshift
oc new-build https://github.com/zalando/patroni --context-dir=kubernetes -n openshift
```
## Deploy the Image
Two configuration templates exist in [templates](templates) directory:
- Patroni Ephemeral
- Patroni Persistent
The only difference is whether or not the statefulset requests persistent storage.
## Create the Template
Install the template into the `openshift` namespace if this should be shared across projects:
```
oc create -f templates/template_patroni_ephemeral.yml -n openshift
```
Then, from your own project:
```
oc new-app patroni-pgsql-ephemeral
```
Once the pods are running, two configmaps should be available:
```
$ oc get configmap
NAME DATA AGE
patroniocp-config 0 1m
patroniocp-leader 0 1m
```
@@ -0,0 +1,287 @@
apiVersion: v1
kind: Template
metadata:
name: patroni-pgsql-ephemeral
annotations:
description: |-
Patroni Postgresql database cluster, without persistent storage.
WARNING: Any data stored will be lost upon pod destruction. Only use this template for testing.
iconClass: icon-postgresql
openshift.io/display-name: Patroni Postgresql (Ephemeral)
openshift.io/long-description: This template deploys a a patroni postgresql HA cluster without persistent storage.
tags: postgresql
objects:
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${PATRONI_CLUSTER_NAME}
spec:
ports:
- port: 5432
protocol: TCP
targetPort: 5432
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${PATRONI_MASTER_SERVICE_NAME}
spec:
ports:
- port: 5432
protocol: TCP
targetPort: 5432
selector:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
role: master
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: v1
kind: Secret
metadata:
name: ${PATRONI_CLUSTER_NAME}
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
stringData:
superuser-password: ${PATRONI_SUPERUSER_PASSWORD}
replication-password: ${PATRONI_REPLICATION_PASSWORD}
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${PATRONI_REPLICA_SERVICE_NAME}
spec:
ports:
- port: 5432
protocol: TCP
targetPort: 5432
selector:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
role: replica
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: apps/v1
kind: StatefulSet
metadata:
creationTimestamp: null
generation: 3
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${APPLICATION_NAME}
spec:
podManagementPolicy: OrderedReady
replicas: 3
revisionHistoryLimit: 10
selector:
matchLabels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
serviceName: ${APPLICATION_NAME}
template:
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
spec:
containers:
- env:
- name: PATRONI_KUBERNETES_POD_IP
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: status.podIP
- name: PATRONI_KUBERNETES_NAMESPACE
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
- name: PATRONI_KUBERNETES_LABELS
value: '{application: ${APPLICATION_NAME}, cluster-name: ${PATRONI_CLUSTER_NAME}}'
- name: PATRONI_SUPERUSER_USERNAME
value: ${PATRONI_SUPERUSER_USERNAME}
- name: PATRONI_SUPERUSER_PASSWORD
valueFrom:
secretKeyRef:
key: superuser-password
name: ${PATRONI_CLUSTER_NAME}
- name: PATRONI_REPLICATION_USERNAME
value: ${PATRONI_REPLICATION_USERNAME}
- name: PATRONI_REPLICATION_PASSWORD
valueFrom:
secretKeyRef:
key: replication-password
name: ${PATRONI_CLUSTER_NAME}
- name: PATRONI_SCOPE
value: ${PATRONI_CLUSTER_NAME}
- name: PATRONI_NAME
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.name
- name: PATRONI_POSTGRESQL_DATA_DIR
value: /home/postgres/pgdata/pgroot/data
- name: PATRONI_POSTGRESQL_PGPASS
value: /tmp/pgpass
- name: PATRONI_POSTGRESQL_LISTEN
value: 0.0.0.0:5432
- name: PATRONI_RESTAPI_LISTEN
value: 0.0.0.0:8008
image: docker-registry.default.svc:5000/${NAMESPACE}/patroni:latest
imagePullPolicy: IfNotPresent
name: ${APPLICATION_NAME}
ports:
- containerPort: 8008
protocol: TCP
- containerPort: 5432
protocol: TCP
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /home/postgres/pgdata
name: pgdata
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
serviceAccount: ${SERVICE_ACCOUNT}
serviceAccountName: ${SERVICE_ACCOUNT}
terminationGracePeriodSeconds: 0
volumes:
- name: pgdata
emptyDir: {}
updateStrategy:
type: OnDelete
- apiVersion: v1
kind: Endpoints
metadata:
name: ${APPLICATION_NAME}
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
subsets: []
- apiVersion: v1
kind: ServiceAccount
metadata:
name: ${SERVICE_ACCOUNT}
- apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ${SERVICE_ACCOUNT}
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- create
- get
- list
- patch
- update
- watch
# delete is required only for 'patronictl remove'
- delete
- apiGroups:
- ""
resources:
- endpoints
verbs:
- get
- patch
- update
# the following three privileges are necessary only when using endpoints
- create
- list
- watch
# delete is required only for for 'patronictl remove'
- delete
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- patch
- update
- watch
- apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ${SERVICE_ACCOUNT}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ${SERVICE_ACCOUNT}
subjects:
- kind: ServiceAccount
name: ${SERVICE_ACCOUNT}
parameters:
- description: The name of the application for labelling all artifacts.
displayName: Application Name
name: APPLICATION_NAME
value: patroni-ephemeral
- description: The name of the patroni-pgsql cluster.
displayName: Cluster Name
name: PATRONI_CLUSTER_NAME
value: patroni-ephemeral
- description: The name of the OpenShift Service exposed for the patroni-ephemeral-master container.
displayName: Master service name.
name: PATRONI_MASTER_SERVICE_NAME
value: patroni-ephemeral-master
- description: The name of the OpenShift Service exposed for the patroni-ephemeral-replica containers.
displayName: Replica service name.
name: PATRONI_REPLICA_SERVICE_NAME
value: patroni-ephemeral-replica
- description: Maximum amount of memory the container can use.
displayName: Memory Limit
name: MEMORY_LIMIT
value: 512Mi
- description: The OpenShift Namespace where the patroni and postgresql ImageStream resides.
displayName: ImageStream Namespace
name: NAMESPACE
value: openshift
- description: Username of the superuser account for initialization.
displayName: Superuser Username
name: PATRONI_SUPERUSER_USERNAME
value: postgres
- description: Password of the superuser account for initialization.
displayName: Superuser Passsword
name: PATRONI_SUPERUSER_PASSWORD
value: postgres
- description: Username of the replication account for initialization.
displayName: Replication Username
name: PATRONI_REPLICATION_USERNAME
value: postgres
- description: Password of the replication account for initialization.
displayName: Repication Passsword
name: PATRONI_REPLICATION_PASSWORD
value: postgres
- description: Service account name used for pods and rolebindings to form a cluster in the project.
displayName: Service Account
name: SERVICE_ACCOUNT
value: patroniocp
@@ -0,0 +1,303 @@
apiVersion: v1
kind: Template
metadata:
name: patroni-pgsql-persistent
annotations:
description: |-
Patroni Postgresql database cluster, with persistent storage.
WARNING: Any data stored will be lost upon pod destruction. Only use this template for testing.
iconClass: icon-postgresql
openshift.io/display-name: Patroni Postgresql (Persistent)
openshift.io/long-description: This template deploys a a patroni postgresql HA cluster without persistent storage.
tags: postgresql
objects:
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${PATRONI_CLUSTER_NAME}
spec:
ports:
- port: 5432
protocol: TCP
targetPort: 5432
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${PATRONI_MASTER_SERVICE_NAME}
spec:
ports:
- port: 5432
protocol: TCP
targetPort: 5432
selector:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
role: master
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: v1
kind: Secret
metadata:
name: ${PATRONI_CLUSTER_NAME}
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
stringData:
superuser-password: ${PATRONI_SUPERUSER_PASSWORD}
replication-password: ${PATRONI_REPLICATION_PASSWORD}
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${PATRONI_REPLICA_SERVICE_NAME}
spec:
ports:
- port: 5432
protocol: TCP
targetPort: 5432
selector:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
role: replica
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: apps/v1
kind: StatefulSet
metadata:
creationTimestamp: null
generation: 3
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
name: ${APPLICATION_NAME}
spec:
podManagementPolicy: OrderedReady
replicas: 3
revisionHistoryLimit: 10
selector:
matchLabels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
serviceName: ${APPLICATION_NAME}
template:
metadata:
creationTimestamp: null
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
spec:
containers:
- env:
- name: PATRONI_KUBERNETES_POD_IP
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: status.podIP
- name: PATRONI_KUBERNETES_NAMESPACE
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
- name: PATRONI_KUBERNETES_LABELS
value: '{application: ${APPLICATION_NAME}, cluster-name: ${PATRONI_CLUSTER_NAME}}'
- name: PATRONI_SUPERUSER_USERNAME
value: ${PATRONI_SUPERUSER_USERNAME}
- name: PATRONI_SUPERUSER_PASSWORD
valueFrom:
secretKeyRef:
key: superuser-password
name: ${PATRONI_CLUSTER_NAME}
- name: PATRONI_REPLICATION_USERNAME
value: ${PATRONI_REPLICATION_USERNAME}
- name: PATRONI_REPLICATION_PASSWORD
valueFrom:
secretKeyRef:
key: replication-password
name: ${PATRONI_CLUSTER_NAME}
- name: PATRONI_SCOPE
value: ${PATRONI_CLUSTER_NAME}
- name: PATRONI_NAME
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.name
- name: PATRONI_POSTGRESQL_DATA_DIR
value: /home/postgres/pgdata/pgroot/data
- name: PATRONI_POSTGRESQL_PGPASS
value: /tmp/pgpass
- name: PATRONI_POSTGRESQL_LISTEN
value: 0.0.0.0:5432
- name: PATRONI_RESTAPI_LISTEN
value: 0.0.0.0:8008
image: docker-registry.default.svc:5000/${NAMESPACE}/patroni:latest
imagePullPolicy: IfNotPresent
name: ${APPLICATION_NAME}
ports:
- containerPort: 8008
protocol: TCP
- containerPort: 5432
protocol: TCP
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /home/postgres/pgdata
name: ${APPLICATION_NAME}
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
serviceAccount: ${SERVICE_ACCOUNT}
serviceAccountName: ${SERVICE_ACCOUNT}
terminationGracePeriodSeconds: 0
volumes:
- name: ${APPLICATION_NAME}
persistentVolumeClaim:
claimName: ${APPLICATION_NAME}
volumeClaimTemplates:
- metadata:
labels:
application: ${APPLICATION_NAME}
name: ${APPLICATION_NAME}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: ${PVC_SIZE}
updateStrategy:
type: OnDelete
- apiVersion: v1
kind: Endpoints
metadata:
name: ${APPLICATION_NAME}
labels:
application: ${APPLICATION_NAME}
cluster-name: ${PATRONI_CLUSTER_NAME}
subsets: []
- apiVersion: v1
kind: ServiceAccount
metadata:
name: ${SERVICE_ACCOUNT}
- apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ${SERVICE_ACCOUNT}
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- create
- get
- list
- patch
- update
- watch
# delete is required only for 'patronictl remove'
- delete
- apiGroups:
- ""
resources:
- endpoints
verbs:
- get
- patch
- update
# the following three privileges are necessary only when using endpoints
- create
- list
- watch
# delete is required only for for 'patronictl remove'
- delete
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- patch
- update
- watch
- apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ${SERVICE_ACCOUNT}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ${SERVICE_ACCOUNT}
subjects:
- kind: ServiceAccount
name: ${SERVICE_ACCOUNT}
parameters:
- description: The name of the application for labelling all artifacts.
displayName: Application Name
name: APPLICATION_NAME
value: patroni-persistent
- description: The name of the patroni-pgsql cluster.
displayName: Cluster Name
name: PATRONI_CLUSTER_NAME
value: patroni-persistent
- description: The name of the OpenShift Service exposed for the patroni-persistent-master container.
displayName: Master service name.
name: PATRONI_MASTER_SERVICE_NAME
value: patroni-persistent-master
- description: The name of the OpenShift Service exposed for the patroni-persistent-replica containers.
displayName: Replica service name.
name: PATRONI_REPLICA_SERVICE_NAME
value: patroni-persistent-replica
- description: Maximum amount of memory the container can use.
displayName: Memory Limit
name: MEMORY_LIMIT
value: 512Mi
- description: The OpenShift Namespace where the patroni and postgresql ImageStream resides.
displayName: ImageStream Namespace
name: NAMESPACE
value: openshift
- description: Username of the superuser account for initialization.
displayName: Superuser Username
name: PATRONI_SUPERUSER_USERNAME
value: postgres
- description: Password of the superuser account for initialization.
displayName: Superuser Passsword
name: PATRONI_SUPERUSER_PASSWORD
value: postgres
- description: Username of the replication account for initialization.
displayName: Replication Username
name: PATRONI_REPLICATION_USERNAME
value: postgres
- description: Password of the replication account for initialization.
displayName: Repication Passsword
name: PATRONI_REPLICATION_PASSWORD
value: postgres
- description: Service account name used for pods and rolebindings to form a cluster in the project.
displayName: Service Account
name: SERVICE_ACCOUNT
value: patroni-persistent
- description: The size of the persistent volume to create.
displayName: Persistent Volume Size
name: PVC_SIZE
value: 5Gi
+43
View File
@@ -0,0 +1,43 @@
pipeline {
agent any
stages {
stage ('Deploy test pod'){
when {
expression {
openshift.withCluster() {
openshift.withProject() {
return !openshift.selector( "dc", "pgbench" ).exists()
}
}
}
}
steps {
script {
openshift.withCluster() {
openshift.withProject() {
def pgbench = openshift.newApp( "https://github.com/stewartshea/docker-pgbench/", "--name=pgbench", "-e PGPASSWORD=postgres", "-e PGUSER=postgres", "-e PGHOST=patroni-persistent-master", "-e PGDATABASE=postgres", "-e TEST_CLIENT_COUNT=20", "-e TEST_DURATION=120" )
def pgbenchdc = openshift.selector( "dc", "pgbench" )
timeout(5) {
pgbenchdc.rollout().status()
}
}
}
}
}
}
stage ('Run benchmark Test'){
steps {
sh '''
oc exec $(oc get pods -l app=pgbench | grep Running | awk '{print $1}') ./test.sh
'''
}
}
stage ('Clean up pgtest pod'){
steps {
sh '''
oc delete all -l app=pgbench
'''
}
}
}
}
@@ -0,0 +1,2 @@
# Jenkins Test
This pipeline test will create a separate deployment config for a pgbench pod and execute a test against the patroni cluster. This is a sample and should be customized.
+212
View File
@@ -0,0 +1,212 @@
# headless service to avoid deletion of patronidemo-config endpoint
apiVersion: v1
kind: Service
metadata:
name: patronidemo-config
labels:
application: patroni
cluster-name: patronidemo
spec:
clusterIP: None
---
apiVersion: apps/v1beta1
kind: StatefulSet
metadata:
name: &cluster_name patronidemo
labels:
application: patroni
cluster-name: *cluster_name
spec:
replicas: 3
serviceName: *cluster_name
template:
metadata:
labels:
application: patroni
cluster-name: *cluster_name
spec:
serviceAccountName: patronidemo
containers:
- name: *cluster_name
image: patroni # docker build -t patroni .
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8008
protocol: TCP
- containerPort: 5432
protocol: TCP
volumeMounts:
- mountPath: /home/postgres/pgdata
name: pgdata
env:
- name: PATRONI_KUBERNETES_POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: PATRONI_KUBERNETES_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: PATRONI_KUBERNETES_USE_ENDPOINTS
value: 'true'
- name: PATRONI_KUBERNETES_LABELS
value: '{application: patroni, cluster-name: patronidemo}'
- name: PATRONI_SUPERUSER_USERNAME
value: postgres
- name: PATRONI_SUPERUSER_PASSWORD
valueFrom:
secretKeyRef:
name: *cluster_name
key: superuser-password
- name: PATRONI_REPLICATION_USERNAME
value: standby
- name: PATRONI_REPLICATION_PASSWORD
valueFrom:
secretKeyRef:
name: *cluster_name
key: replication-password
- name: PATRONI_SCOPE
value: *cluster_name
- name: PATRONI_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: PATRONI_POSTGRESQL_DATA_DIR
value: /home/postgres/pgdata/pgroot/data
- name: PATRONI_POSTGRESQL_PGPASS
value: /tmp/pgpass
- name: PATRONI_POSTGRESQL_LISTEN
value: '0.0.0.0:5432'
- name: PATRONI_RESTAPI_LISTEN
value: '0.0.0.0:8008'
terminationGracePeriodSeconds: 0
volumes:
- name: pgdata
emptyDir: {}
# volumeClaimTemplates:
# - metadata:
# labels:
# application: spilo
# spilo-cluster: *cluster_name
# annotations:
# volume.alpha.kubernetes.io/storage-class: anything
# name: pgdata
# spec:
# accessModes:
# - ReadWriteOnce
# resources:
# requests:
# storage: 5Gi
---
apiVersion: v1
kind: Endpoints
metadata:
name: &cluster_name patronidemo
labels:
application: patroni
cluster-name: *cluster_name
subsets: []
---
apiVersion: v1
kind: Service
metadata:
name: &cluster_name patronidemo
labels:
application: patroni
cluster-name: *cluster_name
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Secret
metadata:
name: &cluster_name patronidemo
labels:
application: patroni
cluster-name: *cluster_name
type: Opaque
data:
superuser-password: emFsYW5kbw==
replication-password: cmVwLXBhc3M=
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: patronidemo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: patronidemo
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- create
- get
- list
- patch
- update
- watch
# delete and deletecollection are required only for 'patronictl remove'
- delete
- deletecollection
- apiGroups:
- ""
resources:
- endpoints
verbs:
- get
- patch
- update
# the following three privileges are necessary only when using endpoints
- create
- list
- watch
# delete and deletecollection are required only for for 'patronictl remove'
- delete
- deletecollection
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- patch
- update
- watch
# The following privilege is only necessary for creation of headless service
# for patronidemo-config endpoint, in order to prevent cleaning it up by the
# k8s master. You can avoid giving this privilege by explicitly creating the
# service like it is done in this manifest (lines 2..10)
- apiGroups:
- ""
resources:
- services
verbs:
- create
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: patronidemo
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: patronidemo
subjects:
- kind: ServiceAccount
name: patronidemo
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)
+211 -61
View File
@@ -1,106 +1,256 @@
import logging
import os
import signal
import sys
import time
import yaml
from patroni.api import RestApiServer
from patroni.exceptions import PatroniException
from patroni.ha import Ha
from patroni.postgresql import Postgresql
from patroni.utils import reap_children, set_ignore_sigterm, setup_signal_handlers
from patroni.version import __version__
logger = logging.getLogger(__name__)
PATRONI_ENV_PREFIX = 'PATRONI_'
class Patroni(object):
PATRONI_CONFIG_VARIABLE = 'PATRONI_CONFIGURATION'
def __init__(self, config):
self.nap_time = config['loop_wait']
self.tags = {tag: value for tag, value in config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance') or value}
self.postgresql = Postgresql(config['postgresql'])
self.dcs = self.get_dcs(self.postgresql.name, config)
def __init__(self, conf):
from patroni.api import RestApiServer
from patroni.dcs import get_dcs
from patroni.ha import Ha
from patroni.log import PatroniLogger
from patroni.postgresql import Postgresql
from patroni.request import PatroniRequest
from patroni.watchdog import Watchdog
self.setup_signal_handlers()
self.version = __version__
self.api = RestApiServer(self, config['restapi'])
self.ha = Ha(self)
self.next_run = time.time()
self.logger = PatroniLogger()
self.config = conf
self.logger.reload_config(self.config.get('log', {}))
self.dcs = get_dcs(self.config)
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
@property
def noloadbalance(self):
return self.tags.get('noloadbalance', False)
self.postgresql = Postgresql(self.config['postgresql'])
self.api = RestApiServer(self, self.config['restapi'])
self.request = PatroniRequest(self.config, True)
self.ha = Ha(self)
self.tags = self.get_tags()
self.next_run = time.time()
self.scheduled_restart = {}
def load_dynamic_configuration(self):
from patroni.exceptions import DCSError
while True:
try:
cluster = self.dcs.get_cluster()
if cluster and cluster.config and cluster.config.data:
if self.config.set_dynamic_configuration(cluster.config):
self.dcs.reload_config(self.config)
self.watchdog.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')
time.sleep(5)
def get_tags(self):
return {tag: value for tag, value in self.config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
@property
def nofailover(self):
return self.tags.get('nofailover', False)
return bool(self.tags.get('nofailover', False))
@property
def nosync(self):
return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False):
try:
self.tags = self.get_tags()
self.logger.reload_config(self.config.get('log', {}))
self.watchdog.reload_config(self.config)
if sighup:
self.request.reload_config(self.config)
self.api.reload_config(self.config['restapi'])
self.postgresql.reload_config(self.config['postgresql'], sighup)
self.dcs.reload_config(self.config)
except Exception:
logger.exception('Failed to reload config_file=%s', self.config.config_file)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@staticmethod
def get_dcs(name, config):
if 'etcd' in config:
from patroni.etcd import Etcd
return Etcd(name, config['etcd'])
if 'zookeeper' in config:
from patroni.zookeeper import ZooKeeper
return ZooKeeper(name, config['zookeeper'])
if 'consul' in config:
from patroni.consul import Consul
return Consul(name, config['consul'])
raise PatroniException('Can not find suitable configuration of distributed configuration store')
def sighup_handler(self, *args):
self._received_sighup = True
def sigterm_handler(self, *args):
with self._sigterm_lock:
if not self._received_sigterm:
self._received_sigterm = True
sys.exit()
@property
def noloadbalance(self):
return bool(self.tags.get('noloadbalance', False))
def schedule_next_run(self):
self.next_run += self.nap_time
self.next_run += self.dcs.loop_wait
current_time = time.time()
nap_time = self.next_run - current_time
if nap_time <= 0:
self.next_run = current_time
elif self.dcs.watch(nap_time):
# Release the GIL so we don't starve anyone waiting on async_executor lock
time.sleep(0.001)
# Warn user that Patroni is not keeping up
logger.warning("Loop time exceeded, rescheduling immediately.")
elif self.ha.watch(nap_time):
self.next_run = time.time()
@property
def received_sigterm(self):
with self._sigterm_lock:
return self._received_sigterm
def run(self):
self.api.start()
self.logger.start()
self.next_run = time.time()
while True:
while not self.received_sigterm:
if self._received_sighup:
self._received_sighup = False
if self.config.reload_local_configuration():
self.reload_config(True)
else:
self.postgresql.config.reload_config(self.config['postgresql'], True)
logger.info(self.ha.run_cycle())
reap_children()
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
self.reload_config()
if self.postgresql.role != 'uninitialized':
self.config.save_cache()
self.schedule_next_run()
def setup_signal_handlers(self):
from threading import Lock
def main():
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
logging.getLogger('requests').setLevel(logging.WARNING)
setup_signal_handlers()
self._received_sighup = False
self._sigterm_lock = Lock()
self._received_sigterm = False
if os.name != 'nt':
signal.signal(signal.SIGHUP, self.sighup_handler)
signal.signal(signal.SIGTERM, self.sigterm_handler)
# Patroni reads the configuration from the command-line argument if it exists, and from the environment otherwise.
use_env = False
use_file = (len(sys.argv) >= 2 and os.path.isfile(sys.argv[1]))
if not use_file:
config_env = os.environ.get(Patroni.PATRONI_CONFIG_VARIABLE)
use_env = config_env is not None
if not use_env:
print('Usage: {0} config.yml'.format(sys.argv[0]))
print('\tPatroni may also read the configuration from the {} environment variable'.
format(Patroni.PATRONI_CONFIG_VARIABLE))
return
def shutdown(self):
with self._sigterm_lock:
self._received_sigterm = True
try:
self.api.shutdown()
except Exception:
logger.exception('Exception during RestApi.shutdown')
self.ha.shutdown()
self.logger.shutdown()
if use_file:
with open(sys.argv[1], 'r') as f:
config = yaml.load(f)
elif use_env:
config = yaml.load(config_env)
patroni = Patroni(config)
def patroni_main():
import argparse
from patroni.config import Config, ConfigParseError
parser = argparse.ArgumentParser()
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
parser.add_argument('configfile', nargs='?', default='',
help='Patroni may also read the configuration from the {0} environment variable'
.format(Config.PATRONI_CONFIG_VARIABLE))
args = parser.parse_args()
try:
conf = Config(args.configfile)
except ConfigParseError as e:
if e.value:
print(e.value)
parser.print_help()
sys.exit(1)
patroni = Patroni(conf)
try:
patroni.run()
except KeyboardInterrupt:
set_ignore_sigterm()
pass
finally:
patroni.api.shutdown()
patroni.postgresql.stop(checkpoint=False)
patroni.dcs.delete_leader()
patroni.shutdown()
def fatal(string, *args):
sys.stderr.write('FATAL: ' + string.format(*args) + '\n')
sys.exit(1)
def check_psycopg2():
min_psycopg2 = (2, 5, 4)
min_psycopg2_str = '.'.join(map(str, min_psycopg2))
def parse_version(version):
for e in version.split('.'):
try:
yield int(e)
except ValueError:
break
try:
import psycopg2
version_str = psycopg2.__version__.split(' ')[0]
version = tuple(parse_version(version_str))
if version < min_psycopg2:
fatal('Patroni requires psycopg2>={0}, but only {1} is available', min_psycopg2_str, version_str)
except ImportError:
fatal('Patroni requires psycopg2>={0} or psycopg2-binary', min_psycopg2_str)
def main():
if os.getpid() != 1:
check_psycopg2()
return patroni_main()
# Patroni started with PID=1, it looks like we are in the container
pid = 0
# Looks like we are in a docker, so we will act like init
def sigchld_handler(signo, stack_frame):
try:
while True:
ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0):
break
elif ret[0] != pid:
logger.info('Reaped pid=%s, exit status=%s', *ret)
except OSError:
pass
def passtochild(signo, stack_frame):
if pid:
os.kill(pid, signo)
if os.name != 'nt':
signal.signal(signal.SIGCHLD, sigchld_handler)
signal.signal(signal.SIGHUP, passtochild)
signal.signal(signal.SIGQUIT, passtochild)
signal.signal(signal.SIGUSR1, passtochild)
signal.signal(signal.SIGUSR2, passtochild)
signal.signal(signal.SIGINT, passtochild)
signal.signal(signal.SIGABRT, passtochild)
signal.signal(signal.SIGTERM, passtochild)
import multiprocessing
patroni = multiprocessing.Process(target=patroni_main)
patroni.start()
pid = patroni.pid
patroni.join()
+472 -192
View File
@@ -1,16 +1,19 @@
import base64
import fcntl
import json
import logging
import psycopg2
import socket
import time
import dateutil
import traceback
import dateutil.parser
import datetime
import pytz
import os
import six
import socket
from patroni.exceptions import PostgresConnectionException
from patroni.utils import Retry, RetryFailedError
from patroni.exceptions import PostgresConnectionException, PostgresException
from patroni.postgresql.misc import postgres_version_to_int
from patroni.utils import deep_compare, parse_bool, patch_config, Retry, \
RetryFailedError, parse_int, split_host_port, tzutc, uri, cluster_as_json
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from six.moves.socketserver import ThreadingMixIn
from threading import Thread
@@ -18,212 +21,378 @@ from threading import Thread
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):
def _write_response(self, status_code, body, headers=None):
def _write_response(self, status_code, body, content_type='text/html', headers=None):
self.send_response(status_code)
if body is not None:
headers = headers or {}
if 'Content-Type' not in headers:
headers['Content-Type'] = 'text/html'
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body.encode('utf-8'))
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 send_auth_request(self, body):
self._write_response(401, body, {'WWW-Authenticate': 'Basic realm=\"Patroni\"'})
def _write_json_response(self, status_code, response):
self._write_response(status_code, json.dumps(response), content_type='application/json')
def finish(self, *args, **kwargs):
try:
if not self.wfile.closed:
self.wfile.flush()
self.wfile.close()
except socket.error:
def check_auth(func):
"""Decorator function to check authorization header or client certificates
Usage example:
@check_auth
def do_PUT_foo():
pass
self.rfile.close()
"""
def check_auth_header(self):
auth_header = self.headers.get('Authorization')
status = self.server.check_auth_header(auth_header)
return not status or self.send_auth_request(status)
def wrapper(self, *args, **kwargs):
if self.server.check_auth(self):
return func(self, *args, **kwargs)
def _write_status_response(self, status_code, response, options=False):
if options:
body = None
else:
patroni = self.server.patroni
response.update({'tags': patroni.tags} if patroni.tags else {})
if patroni.postgresql.sysid:
response['database_system_identifier'] = patroni.postgresql.sysid
response['patroni'] = {'version': patroni.version, 'scope': patroni.postgresql.scope}
body = json.dumps(response)
self._write_response(status_code, body, {'Content-Type': 'application/json'})
return wrapper
def do_GET(self, options=False):
def _write_status_response(self, status_code, response):
patroni = self.server.patroni
tags = patroni.ha.get_effective_tags()
if tags:
response['tags'] = tags
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()
if not patroni.ha.watchdog.is_healthy:
response['watchdog_failed'] = True
if patroni.ha.is_paused():
response['pause'] = True
qsize = patroni.logger.queue_size
if qsize > patroni.logger.NORMAL_LOG_QUEUE_SIZE:
response['logger_queue_size'] = qsize
lost = patroni.logger.records_lost
if lost:
response['logger_records_lost'] = lost
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"""
time_start = time.time()
request_type = 'OPTIONS' if write_status_code_only else 'GET'
path = '/master' if self.path == '/' else self.path
response = self.get_postgresql_status()
patroni = self.server.patroni
cluster = patroni.dcs.cluster
if cluster: # dcs available
if cluster.leader and cluster.leader.name == patroni.postgresql.name: # is_leader
status_code = 200 if 'master' in path else 503
elif 'role' not in response:
status_code = 503
elif response['role'] == 'master': # running as master but without leader lock!!!!
status_code = 503
elif response['role'] in path: # 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
if not cluster and patroni.ha.is_paused():
primary_status_code = 200 if response['role'] == 'master' else 503
else:
status_code = 503
self._write_status_response(status_code, response, options)
primary_status_code = 200 if patroni.ha.is_leader() else 503
replica_status_code = 200 if not patroni.noloadbalance and \
response.get('role') == 'replica' and response.get('state') == 'running' else 503
status_code = 503
if patroni.ha.is_standby_cluster() and ('standby_leader' in path or 'standby-leader' in path):
status_code = 200 if patroni.ha.is_leader() else 503
elif 'master' in path or 'leader' in path or 'primary' in path or 'read-write' in path:
status_code = primary_status_code
elif 'replica' in path:
status_code = replica_status_code
elif 'read-only' in path:
status_code = 200 if primary_status_code == 200 else replica_status_code
elif 'health' in path:
status_code = 200 if response.get('state') == 'running' else 503
elif cluster: # dcs is available
is_synchronous = cluster.is_synchronous_mode() and cluster.sync \
and cluster.sync.sync_standby == patroni.postgresql.name
if path in ('/sync', '/synchronous') and is_synchronous:
status_code = replica_status_code
elif path in ('/async', '/asynchronous') and not is_synchronous:
status_code = replica_status_code
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)
time_end = time.time()
self.log_message('%s %s %s latency: %s ms', request_type, path,
status_code, (time_end - time_start) * 1000)
def do_OPTIONS(self):
self.do_GET(options=True)
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_cluster(self):
cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
self._write_json_response(200, cluster_as_json(cluster))
def do_GET_history(self):
cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
self._write_json_response(200, cluster.history and cluster.history.lines or [])
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:
content_length = int(self.headers.get('content-length'))
if content_length == 0 and body_is_optional:
return {}
request = json.loads(self.rfile.read(content_length).decode('utf-8'))
if isinstance(request, dict) and (request or body_is_optional):
return request
except Exception:
logger.exception('Bad request')
self.send_error(400)
@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.server.patroni.ha.wakeup()
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):
self.server.patroni.sighup_handler()
self._write_response(202, 'reload scheduled')
@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(tzutc):
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'
try:
status, data = self.server.patroni.ha.restart()
status_code = 200 if status else 503
except Exception:
logger.exception('Exception during restart')
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':
try:
postgres_version_to_int(request[k])
except PostgresException as e:
status_code = 400
data = e.value
break
elif k == 'timeout':
request[k] = parse_int(request[k], 's')
if request[k] is None or request[k] <= 0:
status_code = 400
data = "Timeout should be a positive number of seconds"
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:
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):
ha = self.server.patroni.ha
cluster = ha.dcs.get_cluster()
if cluster.is_unlocked():
status_code = 503
data = 'Cluster has no leader, can not reinitialize'
elif cluster.leader.name == ha.state_handler.name:
status_code = 503
data = 'I am the leader, can not reinitialize'
request = self._read_json_content(body_is_optional=True)
if request:
logger.debug('received reinitialize request: %s', request)
force = isinstance(request, dict) and parse_bool(request.get('force')) or False
data = self.server.patroni.ha.reinitialize(force)
if data is None:
status_code = 200
data = 'reinitialize started'
else:
action = ha.schedule_reinitialize()
if action is not None:
status_code = 503
data = action + ' already in progress'
else:
status_code = 200
data = 'reinitialize scheduled'
status_code = 503
self._write_response(status_code, data)
def poll_failover_result(self, leader, candidate):
for _ in range(0, 15):
def poll_failover_result(self, leader, candidate, action):
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 cluster.is_unlocked() and cluster.leader.name != leader:
if not candidate or candidate == cluster.leader.name:
return 200, 'Successfully failed over to "{0}"'.format(cluster.leader.name)
return 200, 'Successfully {0}ed over to "{1}"'.format(action[:-4], cluster.leader.name)
else:
return 200, 'Failed over to "{0}" instead of "{1}"'.format(cluster.leader.name, candidate)
return 200, '{0}ed over to "{1}" instead of "{2}"'.format(action[:-4].title(),
cluster.leader.name, candidate)
if not cluster.failover:
return 503, 'Failover failed'
return 503, action.title() + ' failed'
except Exception as e:
logger.debug('Exception occured during polling failover result: %s', e)
return 503, 'Failover status unknown'
logger.debug('Exception occured during polling %s result: %s', action, e)
return 503, action.title() + ' status unknown'
def is_failover_possible(self, cluster, leader, candidate):
if leader and not cluster.leader or cluster.leader.name != leader:
def is_failover_possible(self, cluster, leader, candidate, action):
if leader and (not cluster.leader or cluster.leader.name != leader):
return 'leader name does not match'
if candidate:
if action == 'switchover' and cluster.is_synchronous_mode() and cluster.sync.sync_standby != candidate:
return 'candidate name does not match with sync_standby'
members = [m for m in cluster.members if m.name == candidate]
if not members:
return 'candidate does not exists'
elif cluster.is_synchronous_mode():
members = [m for m in cluster.members if m.name == cluster.sync.sync_standby]
if not members:
return action + ' is not possible: can not find sync_standby'
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 action + ' is not possible: cluster does not have members except leader'
for st in self.server.patroni.ha.fetch_nodes_statuses(members):
if st.failover_limitation() is None:
return None
return 'failover is not possible: no good candidates have been found'
return action + ' is not possible: no good candidates have been found'
@check_auth
def do_POST_failover(self):
content_length = int(self.headers.get('content-length', 0))
try:
request = json.loads(self.rfile.read(content_length).decode('utf-8'))
except ValueError:
request = {}
def do_POST_failover(self, action='failover'):
request = self._read_json_content()
(status_code, data) = (400, '')
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.ha.dcs.get_cluster()
status_code = 500
cluster = self.server.patroni.dcs.get_cluster()
logger.info("received failover request with leader=%s candidate=%s scheduled_at=%s",
leader, candidate, scheduled_at)
logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s",
action, leader, candidate, scheduled_at)
data = ''
if leader or candidate:
if scheduled_at:
try:
scheduled_at = dateutil.parser.parse(scheduled_at)
if scheduled_at.tzinfo is None:
data = 'Timezone information is mandatory for scheduled_at'
status_code = 400
elif scheduled_at < datetime.datetime.now(pytz.utc):
data = 'Cannot schedule failover in the past'
status_code = 422
elif self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
self.server.patroni.dcs.event.set()
data = 'Failover scheduled'
status_code = 200
else:
data = 'failed to write failover key into DCS'
status_code = 503
except (ValueError, TypeError):
logger.exception('Invalid scheduled failover time: %s', request['scheduled_at'])
data = 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601'
status_code = 422
if action == 'failover' and not candidate:
data = 'Failover could be performed only to a specific candidate'
elif action == 'switchover' and not leader:
data = 'Switchover could be performed only from a specific leader'
if not data and scheduled_at:
if not leader:
data = 'Scheduled {0} is possible only from a specific leader'.format(action)
if not data and cluster.is_paused():
data = "Can't schedule {0} in the paused state".format(action)
if not data:
(status_code, data, scheduled_at) = self.parse_schedule(scheduled_at, action)
if not data and cluster.is_paused() and not candidate:
data = action.title() + ' is possible only to a specific candidate in a paused state'
if not data and not scheduled_at:
data = self.is_failover_possible(cluster, leader, candidate, action)
if data:
status_code = 412
if not data:
if self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
self.server.patroni.ha.wakeup()
if scheduled_at:
data = action.title() + ' scheduled'
status_code = 202
else:
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name,
candidate, action)
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'
data = 'failed to write {0} key into DCS'.format(action)
status_code = 503
self._write_response(status_code, data)
def do_POST_switchover(self):
self.do_POST_failover(action='switchover')
def parse_request(self):
"""Override parse_request method to enrich basic functionality of `BaseHTTPRequestHandler` class
@@ -242,12 +411,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.command = mname
return ret
def handle_one_request(self):
try:
BaseHTTPRequestHandler.handle_one_request(self)
except socket.error:
pass
def query(self, sql, *params, **kwargs):
if not kwargs.get('retry', False):
return self.server.query(sql, *params)
@@ -256,62 +419,77 @@ class RestApiHandler(BaseHTTPRequestHandler):
def get_postgresql_status(self, retry=False):
try:
row = self.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
pg_is_in_recovery(),
CASE WHEN pg_is_in_recovery()
THEN 0
ELSE pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::bigint
END,
pg_xlog_location_diff(pg_last_xlog_receive_location(), '0/0')::bigint,
pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')::bigint,
to_char(pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
pg_is_in_recovery() AND pg_is_xlog_replay_paused()""", retry=retry)[0]
return {
cluster = self.server.patroni.dcs.cluster
if self.server.patroni.postgresql.state not in ('running', 'restarting', 'starting'):
raise RetryFailedError('')
stmt = ("SELECT pg_catalog.to_char(pg_catalog.pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
" CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0"
" ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END,"
" CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0"
" ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END,"
" pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(),"
" pg_catalog.pg_last_{0}_replay_{1}()), '0/0')::bigint,"
" pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint,"
" pg_catalog.to_char(pg_catalog.pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
" pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused(), "
" pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) "
"FROM (SELECT (SELECT rolname FROM pg_authid WHERE oid = usesysid) AS usename,"
" application_name, client_addr, w.state, sync_state, sync_priority"
" FROM pg_catalog.pg_stat_get_wal_senders() w, pg_catalog.pg_stat_get_activity(pid)) AS ri")
row = self.query(stmt.format(self.server.patroni.postgresql.wal_name,
self.server.patroni.postgresql.lsn_name), retry=retry)[0]
result = {
'state': self.server.patroni.postgresql.state,
'postmaster_start_time': row[0],
'role': 'replica' if row[1] else 'master',
'role': 'replica' if row[1] == 0 else 'master',
'server_version': self.server.patroni.postgresql.server_version,
'cluster_unlocked': bool(not cluster or cluster.is_unlocked()),
'xlog': ({
'received_location': row[3],
'replayed_location': row[4],
'replayed_timestamp': row[5],
'paused': row[6]} if row[1] else {
'paused': row[6]} if row[1] == 0 else {
'location': row[2]
})
}
if result['role'] == 'replica' and self.server.patroni.ha.is_standby_cluster():
result['role'] = self.server.patroni.postgresql.role
if row[1] > 0:
result['timeline'] = row[1]
else:
leader_timeline = None if not cluster or cluster.is_unlocked() else cluster.leader.timeline
result['timeline'] = self.server.patroni.postgresql.replica_cached_timeline(leader_timeline)
if row[7]:
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}
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):
# On 3.7+ the `ThreadingMixIn` gathers all non-daemon worker threads in order to join on them at server close.
daemon_threads = True # Make worker threads "fire and forget" to prevent a memory leak.
def __init__(self, patroni, config):
self._auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
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)
protocol = 'http'
# wrap socket with ssl if 'certfile' is defined in a config.yaml
# Sometime it's also needed to pass reference to a 'keyfile'.
options = {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
if options.get('certfile'):
import ssl
self.socket = ssl.wrap_socket(self.socket, server_side=True, **options)
protocol = 'https'
self.connection_string = '{0}://{1}/patroni'.format(protocol, config.get('connect_address', config['listen']))
self.patroni = patroni
self.__listen = None
self.__ssl_options = None
self.reload_config(config)
self.daemon = True
def query(self, sql, *params):
@@ -327,15 +505,117 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
@staticmethod
def _set_fd_cloexec(fd):
flags = fcntl.fcntl(fd, fcntl.F_GETFD)
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
if os.name != 'nt':
import fcntl
flags = fcntl.fcntl(fd, fcntl.F_GETFD)
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
def check_basic_auth_key(self, key):
return self._auth_key == key
return self.__auth_key == key
def check_auth_header(self, auth_header):
if self._auth_key:
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'
def check_auth(self, rh):
if not hasattr(rh.request, 'getpeercert') or not rh.request.getpeercert(): # valid client cert isn't present
if self.__protocol == 'https' and self.__ssl_options.get('verify_client') in ('required', 'optional'):
return rh._write_response(403, 'client certificate required')
reason = self.check_auth_header(rh.headers.get('Authorization'))
if reason:
headers = {'WWW-Authenticate': 'Basic realm="' + self.patroni.__class__.__name__ + '"'}
return rh._write_response(401, reason, headers=headers)
return True
@staticmethod
def __has_dual_stack():
if hasattr(socket, 'AF_INET6') and hasattr(socket, 'IPPROTO_IPV6') and hasattr(socket, 'IPV6_V6ONLY'):
sock = None
try:
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False)
return True
except socket.error as e:
logger.debug('Error when working with ipv6 socket: %s', e)
finally:
if sock:
sock.close()
return False
def __httpserver_init(self, host, port):
dual_stack = self.__has_dual_stack()
if host in ('', '*'):
host = None
info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
# in case dual stack is not supported we want IPv4 to be preferred over IPv6
info.sort(key=lambda x: x[0] == socket.AF_INET, reverse=not dual_stack)
self.address_family = info[0][0]
try:
HTTPServer.__init__(self, info[0][-1][:2], RestApiHandler)
except socket.error:
logger.error(
"Couldn't start a service on '%s:%s', please check your `restapi.listen` configuration", host, port)
raise
def __initialize(self, listen, ssl_options):
try:
host, port = split_host_port(listen, None)
except Exception:
raise ValueError('Invalid "restapi" config: expected <HOST>:<PORT> for "listen", but got "{0}"'
.format(listen))
reloading_config = self.__listen is not None # changing config in runtime
if reloading_config:
self.shutdown()
self.__listen = listen
self.__ssl_options = ssl_options
self.__httpserver_init(host, port)
Thread.__init__(self, target=self.serve_forever)
self._set_fd_cloexec(self.socket)
# wrap socket with ssl if 'certfile' is defined in a config.yaml
# Sometime it's also needed to pass reference to a 'keyfile'.
self.__protocol = 'https' if ssl_options.get('certfile') else 'http'
if self.__protocol == 'https':
import ssl
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=ssl_options.get('cafile'))
ctx.load_cert_chain(certfile=ssl_options['certfile'], keyfile=ssl_options.get('keyfile'))
verify_client = ssl_options.get('verify_client')
if verify_client:
modes = {'none': ssl.CERT_NONE, 'optional': ssl.CERT_OPTIONAL, 'required': ssl.CERT_REQUIRED}
if verify_client in modes:
ctx.verify_mode = modes[verify_client]
else:
logger.error('Bad value in the "restapi.verify_client": %s', verify_client)
self.socket = ctx.wrap_socket(self.socket, server_side=True)
if reloading_config:
self.start()
def reload_config(self, config):
if 'listen' not in config: # changing config in runtime
raise ValueError('Can not find "restapi.listen" config')
ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'cafile') if n in config}
if isinstance(config.get('verify_client'), six.string_types):
ssl_options['verify_client'] = config['verify_client'].lower()
if self.__listen != config['listen'] or self.__ssl_options != ssl_options:
self.__initialize(config['listen'], ssl_options)
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
self.connection_string = uri(self.__protocol, config.get('connect_address') or self.__listen, 'patroni')
@staticmethod
def handle_error(request, client_address):
address, port = client_address
logger.warning('Exception happened during processing of request from {}:{}'.format(address, port))
logger.warning(traceback.format_exc())
+95 -12
View File
@@ -1,27 +1,78 @@
import logging
from threading import Lock, Thread
from threading import Event, Lock, RLock, Thread
logger = logging.getLogger(__name__)
class CriticalTask(object):
"""Represents a critical task in a background process that we either need to cancel or get the result of.
Fields of this object may be accessed only when holding a lock on it. To perform the critical task the background
thread must, while holding lock on this object, check `is_cancelled` flag, run the task and mark the task as
complete using `complete()`.
The main thread must hold async lock to prevent the task from completing, hold lock on critical task object,
call cancel. If the task has completed `cancel()` will return False and `result` field will contain the result of
the task. When cancel returns True it is guaranteed that the background task will notice the `is_cancelled` flag.
"""
def __init__(self):
self._lock = Lock()
self.is_cancelled = False
self.result = None
def reset(self):
"""Must be called every time the background task is finished.
Must be called from async thread. Caller must hold lock on async executor when calling."""
self.is_cancelled = False
self.result = None
def cancel(self):
"""Tries to cancel the task, returns True if the task has already run.
Caller must hold lock on async executor and the task when calling."""
if self.result is not None:
return False
self.is_cancelled = True
return True
def complete(self, result):
"""Mark task as completed along with a result.
Must be called from async thread. Caller must hold lock on task when calling."""
self.result = result
def __enter__(self):
self._lock.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._lock.release()
class AsyncExecutor(object):
def __init__(self):
self._busy = False
self._thread_lock = Lock()
def __init__(self, cancellable, ha_wakeup):
self._cancellable = cancellable
self._ha_wakeup = ha_wakeup
self._thread_lock = RLock()
self._scheduled_action = None
self._scheduled_action_lock = Lock()
self._scheduled_action_lock = RLock()
self._is_cancelled = False
self._finish_event = Event()
self.critical_task = CriticalTask()
@property
def busy(self):
return self._busy
return self.scheduled_action is not None
def schedule(self, action, immediately=False):
def schedule(self, action):
with self._scheduled_action_lock:
if self._scheduled_action is not None:
return self._scheduled_action
self._scheduled_action = action
self._busy = immediately
self._is_cancelled = False
self._finish_event.set()
return None
@property
@@ -34,19 +85,51 @@ class AsyncExecutor(object):
self._scheduled_action = None
def run(self, func, args=()):
wakeup = False
try:
return func(*args) if args else func()
except:
with self:
if self._is_cancelled:
return
self._finish_event.clear()
self._cancellable.reset_is_cancelled()
# if the func returned something (not None) - wake up main HA loop
wakeup = func(*args) if args else func()
return wakeup
except Exception:
logger.exception('Exception during execution of long running task %s', self.scheduled_action)
finally:
with self:
self._busy = False
self.reset_scheduled_action()
self._finish_event.set()
with self.critical_task:
self.critical_task.reset()
if wakeup is not None:
self._ha_wakeup()
def run_async(self, func, args=()):
self._busy = True
Thread(target=self.run, args=(func, args)).start()
def try_run_async(self, action, func, args=()):
prev = self.schedule(action)
if prev is None:
return self.run_async(func, args)
return 'Failed to run {0}, {1} is already in progress'.format(action, prev)
def cancel(self):
with self:
with self._scheduled_action_lock:
if self._scheduled_action is None:
return
logger.warning('Cancelling long running task %s', self._scheduled_action)
self._is_cancelled = True
self._cancellable.cancel()
self._finish_event.wait()
with self:
self.reset_scheduled_action()
def __enter__(self):
self._thread_lock.acquire()
+397
View File
@@ -0,0 +1,397 @@
import json
import logging
import os
import shutil
import tempfile
import yaml
from collections import defaultdict
from copy import deepcopy
from patroni import PATRONI_ENV_PREFIX
from patroni.exceptions import ConfigParseError
from patroni.dcs import ClusterConfig
from patroni.postgresql.config import CaseInsensitiveDict, ConfigHandler
from patroni.utils import deep_compare, parse_bool, parse_int, patch_config
logger = logging.getLogger(__name__)
_AUTH_ALLOWED_PARAMETERS = (
'username',
'password',
'sslmode',
'sslcert',
'sslkey',
'sslrootcert',
'sslcrl'
)
def default_validator(conf):
if not conf:
return "Config is empty."
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_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,
'check_timeline': False,
'master_start_timeout': 300,
'synchronous_mode': False,
'synchronous_mode_strict': False,
'standby_cluster': {
'create_replica_methods': '',
'host': '',
'port': '',
'primary_slot_name': '',
'restore_command': '',
'archive_cleanup_command': '',
'recovery_min_apply_delay': ''
},
'postgresql': {
'bin_dir': '',
'use_slots': True,
'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items()})
},
'watchdog': {
'mode': 'automatic',
}
}
def __init__(self, configfile, validator=default_validator):
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 = configfile and os.path.isfile(configfile) and configfile
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 validator:
error = validator(self._local_configuration)
if error:
raise ConfigParseError(error)
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
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 check_mode(self, mode):
return bool(parse_bool(self._dynamic_configuration.get(mode)))
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 = shutil.move(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):
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)
self._local_configuration = configuration
self.__effective_configuration = new_configuration
return True
else:
logger.info('No local configuration items changed.')
except Exception:
logger.exception('Exception when reloading local configuration from %s', self.config_file)
@staticmethod
def _process_postgresql_parameters(parameters, is_local=False):
return {name: value for name, value in (parameters or {}).items()
if name not in ConfigHandler.CMDLINE_OPTIONS or
not is_local and ConfigHandler.CMDLINE_OPTIONS[name][1](value)}
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 == 'standby_cluster':
for name, value in (value or {}).items():
if name in self.__DEFAULT_CONFIG['standby_cluster']:
config['standby_cluster'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overriden from DCS
if name in ('synchronous_mode', 'synchronous_mode_strict'):
config[name] = value
else:
config[name] = int(value)
return config
@staticmethod
def _build_environment_configuration():
ret = defaultdict(dict)
def _popenv(name):
return os.environ.pop(PATRONI_ENV_PREFIX + name.upper(), None)
for param in ('name', 'namespace', 'scope'):
value = _popenv(param)
if value:
ret[param] = value
def _fix_log_env(name, oldname):
value = _popenv(oldname)
name = PATRONI_ENV_PREFIX + 'LOG_' + name.upper()
if value and name not in os.environ:
os.environ[name] = value
for name, oldname in (('level', 'loglevel'), ('format', 'logformat'), ('dateformat', 'log_datefmt')):
_fix_log_env(name, oldname)
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', 'cafile', 'verify_client'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile'])
_set_section_values('postgresql', ['listen', 'connect_address', 'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
'dir', 'file_size', 'file_num', 'loggers'])
def _parse_dict(value):
if not value.strip().startswith('{'):
value = '{{{0}}}'.format(value)
try:
return yaml.safe_load(value)
except Exception:
logger.exception('Exception when parsing dict %s', value)
return None
value = ret.get('log', {}).pop('loggers', None)
if value:
value = _parse_dict(value)
if value:
ret['log']['loggers'] = value
def _get_auth(name, params=None):
ret = {}
for param in params or _AUTH_ALLOWED_PARAMETERS[:2]:
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', 'rewind'):
entry = _get_auth(user_type, _AUTH_ALLOWED_PARAMETERS)
if entry:
authentication[user_type] = entry
if authentication:
ret['postgresql']['authentication'] = authentication
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(PATRONI_ENV_PREFIX):
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
name, suffix = (param[8:].split('_', 1) + [''])[:2]
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'URL', 'PROXY',
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'NAMESPACE', 'CONTEXT',
'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS') and name:
value = os.environ.pop(param)
if suffix == 'PORT':
value = value and parse_int(value)
elif suffix in ('HOSTS', 'PORTS', 'CHECKS'):
value = value and _parse_list(value)
elif suffix == 'LABELS':
value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE'):
value = parse_bool(value)
if value:
ret[name.lower()][suffix.lower()] = value
if 'etcd' in ret:
ret['etcd'].update(_get_auth('etcd'))
users = {}
for param in list(os.environ.keys()):
if param.startswith(PATRONI_ENV_PREFIX):
name, suffix = (param[8:].rsplit('_', 1) + [''])[:2]
# PATRONI_<username>_PASSWORD=<password>, PATRONI_<username>_OPTIONS=<option1,option2,...>
# CREATE USER "<username>" WITH <OPTIONS> PASSWORD '<password>'
if name and 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 or name in ['watchdog']:
config[name] = deepcopy(value) if value else {}
# restapi server expects to get restapi.auth = 'username:password'
if 'restapi' in config and 'authentication' in config['restapi']:
config['restapi']['auth'] = '{username}:{password}'.format(**config['restapi']['authentication'])
# 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']
# handle setting additional connection parameters that may be available
# in the configuration file, such as SSL connection parameters
for name, value in pg_config['authentication'].items():
pg_config['authentication'][name] = {n: v for n, v in value.items() if n in _AUTH_ALLOWED_PARAMETERS}
# no 'name' in config
if 'name' not in config and 'name' in pg_config:
config['name'] = pg_config['name']
updated_fields = (
'name',
'scope',
'retry_timeout',
'synchronous_mode',
'synchronous_mode_strict',
)
pg_config.update({p: config[p] for p in updated_fields 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)
-244
View File
@@ -1,244 +0,0 @@
from __future__ import absolute_import
import logging
import os
import time
import six
from consul import ConsulException, NotFound, base, std
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import sleep
from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
class ConsulError(DCSError):
pass
class HTTPClient(std.HTTPClient):
def __init__(self, *args, **kwargs):
super(HTTPClient, self).__init__(*args, **kwargs)
self._patch_default_timeout()
def _patch_default_timeout(self):
# Set a default timeout for the `request.session.request` method, that is used
# internally by the methods request.session.get, request.session.post and
# others. We monkey-patch here to avoid reimplementing each individual method from
# `std.HTTPClient`. By default, the timeout is not set. It means that a new
# session may hang almost indefinitely waiting for the server to respond,
# which is not what we want in Patroni.
request_func = getattr(self.session.request, '__func__' if six.PY3 else 'im_func')
defaults_attr_name = '__defaults__' if six.PY3 else 'func_defaults'
defaults = list(getattr(request_func, defaults_attr_name))
code = request_func.__code__ if six.PY3 else request_func.func_code
defaults[code.co_varnames[code.co_argcount - len(defaults):code.co_argcount].index('timeout')] = 5
setattr(request_func, defaults_attr_name, tuple(defaults)) # monkeypatching
def get(self, callback, path, params=None):
# The get function is overridden to handle a special case of it being called
# with an index and wait parameters. That form indicates that a user needs to
# wait for the given key to change its value, with a wait timeout supplied. We
# don't want our monkey-patched timeout to be less than the value of the wait
# parameter, therefore, we set it to either the value of wait or a default of 5 minutes.
if isinstance(params, dict) and 'index' in params:
timeout = (float(params['wait'][:-1]) if 'wait' in params else 300) + 1
else:
timeout = None
return callback(self.response(self.session.get(self.uri(path, params), verify=self.verify, timeout=timeout)))
class ConsulClient(base.Consul):
@staticmethod
def connect(host, port, scheme, verify=True):
return HTTPClient(host, port, scheme, verify)
def catch_consul_errors(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (ConsulException, RequestException):
return False
return wrapper
class Consul(AbstractDCS):
def __init__(self, name, config):
super(Consul, self).__init__(name, config)
self.ttl = int((config.get('ttl') or 30)/2) # My experiments have shown that session expires after 2*ttl time
host, port = config.get('host', '127.0.0.1:8500').split(':')
self._client = ConsulClient(host=host, port=port)
self._scope = config['scope']
self._session = None
self._my_member_data = None
self.create_or_restore_session()
def create_or_restore_session(self):
while not self._session:
try:
_, member = self._client.kv.get(self.member_path)
self._session = (member or {}).get('Session')
if self.refresh_session():
self._client.kv.delete(self.member_path)
except (ConsulException, RequestException):
logger.info('waiting on consul')
sleep(5)
def refresh_session(self):
""":returns: `!True` if it had to create new session"""
if self._session:
try:
return self._client.session.renew(self._session) is None
except NotFound:
self._session = None
if not self._session:
name = self._scope + '-' + self._name
try:
self._session = self._client.session.create(name=name, lock_delay=0, behavior='delete', ttl=self.ttl)
except (ConsulException, RequestException):
logger.exception('session.create')
if not self._session:
raise ConsulError('Failed to renew/create session')
return True
def client_path(self, path):
return super(Consul, self).client_path(path)[1:]
@staticmethod
def member(node):
return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value'])
def _load_cluster(self):
try:
path = self.client_path('/')
_, results = self._client.kv.get(path, recurse=True)
if results is None:
raise NotFound
nodes = {}
for node in results:
node['Value'] = (node['Value'] or b'').decode('utf-8')
nodes[os.path.relpath(node['Key'], path)] = node
# get initialize flag
initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['Value']
# get last leader operation
last_leader_operation = nodes.get(self._LEADER_OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['Value'])
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader
leader = nodes.get(self._LEADER)
if leader and leader['Value'] == self._name and self._session != leader.get('Session', 'x'):
logger.info('I am leader but not owner of the session. Removing leader node')
self._client.kv.delete(self.leader_path, cas=leader['ModifyIndex'])
leader = None
if leader:
member = Member(-1, leader['Value'], None, {})
member = ([m for m in members if m.name == leader['Value']] or [member])[0]
leader = Leader(leader['ModifyIndex'], leader.get('Session'), member)
# failover key
failover = nodes.get(self._FAILOVER)
if failover:
failover = Failover.from_node(failover['ModifyIndex'], failover['Value'])
self._cluster = Cluster(initialize, leader, last_leader_operation, members, failover)
except NotFound:
self._cluster = Cluster(False, None, None, [], None)
except:
logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly')
def touch_member(self, data, **kwargs):
create_member = self.refresh_session()
cluster = self.cluster
member = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
if create_member and member:
try:
self._client.kv.delete(self.member_path)
except Exception:
return False
if not create_member and member and data == self._my_member_data:
return True
try:
self._client.kv.put(self.member_path, data, acquire=self._session)
self._my_member_data = data
return True
except Exception:
logger.exception('touch_member')
return False
@catch_consul_errors
def attempt_to_acquire_leader(self):
ret = self._client.kv.put(self.leader_path, self._name, acquire=self._session)
if not ret:
logger.info('Could not take out TTL lock')
return ret
def take_leader(self):
return self.attempt_to_acquire_leader()
@catch_consul_errors
def set_failover_value(self, value, index=None):
return self._client.kv.put(self.failover_path, value, cas=index)
@catch_consul_errors
def write_leader_optime(self, last_operation):
return self._client.kv.put(self.leader_optime_path, last_operation)
@staticmethod
def update_leader():
return True
@catch_consul_errors
def initialize(self, create_new=True, sysid=''):
kwargs = {'cas': 0} if create_new else {}
return self._client.kv.put(self.initialize_path, sysid, **kwargs)
@catch_consul_errors
def cancel_initialization(self):
return self._client.kv.delete(self.initialize_path)
@catch_consul_errors
def delete_cluster(self):
return self._client.kv.delete(self.client_path(''), recurse=True)
@catch_consul_errors
def delete_leader(self):
cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
return self._client.kv.delete(self.leader_path, cas=cluster.leader.index)
def watch(self, timeout):
cluster = self.cluster
if cluster and cluster.leader and cluster.leader.name != self._name and cluster.leader.index:
end_time = time.time() + timeout
while timeout >= 1:
try:
idx, _ = self._client.kv.get(self.leader_path, index=cluster.leader.index, wait=str(timeout) + 's')
return str(idx) != str(cluster.leader.index)
except (ConsulException, RequestException):
logging.exception('watch')
timeout = end_time - time.time()
try:
return super(Consul, self).watch(timeout)
finally:
self.event.clear()
+807 -233
View File
File diff suppressed because it is too large Load Diff
-336
View File
@@ -1,336 +0,0 @@
import abc
import dateutil
import json
import six
from collections import namedtuple
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
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')
@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)
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
@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'))
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members,failover')):
"""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
: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
@six.add_metaclass(abc.ABCMeta)
class AbstractDCS(object):
_INITIALIZE = 'initialize'
_LEADER = 'leader'
_FAILOVER = 'failover'
_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._namespace = '/{0}'.format(config.get('namespace', '/service/').strip('/'))
self._base_path = '/'.join([self._namespace, config['scope']])
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 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 _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):
"""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"""
@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), index)
@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, 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()
+790
View File
@@ -0,0 +1,790 @@
import abc
import dateutil
import importlib
import inspect
import json
import logging
import os
import pkgutil
import re
import six
import sys
import time
from collections import defaultdict, namedtuple
from copy import deepcopy
from patroni.exceptions import PatroniException
from patroni.utils import parse_bool, uri
from random import randint
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
from threading import Event, Lock
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
logger = logging.getLogger(__name__)
def slot_name_from_member_name(member_name):
"""Translate member name to valid PostgreSQL slot name.
PostgreSQL replication slot names must be valid PostgreSQL names. This function maps the wider space of
member names to valid PostgreSQL names. Names are lowercased, dashes and periods common in hostnames
are replaced with underscores, other characters are encoded as their unicode codepoint. Name is truncated
to 64 characters. Multiple different member names may map to a single slot name."""
def replace_char(match):
c = match.group(0)
return '_' if c in '-.' else "u{:04d}".format(ord(c))
slot_name = re.sub('[^a-z0-9_]', replace_char, member_name.lower())
return slot_name[0:63]
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):
modules = dcs_modules()
for module_name in modules:
name = module_name.split('.')[-1]
if name in config: # we will try to import only modules which have configuration section in the config file
try:
module = importlib.import_module(module_name)
for key, item in module.__dict__.items(): # iterate through the module content
# try to find implementation of AbstractDCS interface, class name must match with module_name
if key.lower() == name and inspect.isclass(item) and issubclass(item, AbstractDCS):
# propagate some parameters
config[name].update({p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
'patronictl', 'ttl', 'retry_timeout') if p in config})
return item(config[name])
except ImportError:
logger.debug('Failed to import %s', module_name)
available_implementations = []
for module_name in modules:
name = module_name.split('.')[-1]
try:
module = importlib.import_module(module_name)
available_implementations.extend(name for key, item in module.__dict__.items() if key.lower() == name
and inspect.isclass(item) and issubclass(item, AbstractDCS))
except ImportError:
logger.info('Failed to import %s', module_name)
raise PatroniException("""Can not find suitable configuration of distributed configuration store
Available implementations: """ + ', '.join(sorted(set(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):
conn_url = self.data.get('conn_url')
conn_kwargs = self.data.get('conn_kwargs')
if conn_url:
return conn_url
if conn_kwargs:
conn_url = uri('postgresql', (conn_kwargs.get('host'), conn_kwargs.get('port', 5432)))
self.data['conn_url'] = conn_url
return conn_url
def conn_kwargs(self, auth=None):
defaults = {
"host": "",
"port": "",
"database": ""
}
ret = self.data.get('conn_kwargs')
if ret:
defaults.update(ret)
ret = defaults
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()
# apply any remaining authentication parameters
if auth and isinstance(auth, dict):
ret.update(auth)
if 'username' in auth:
ret['user'] = ret.pop('username')
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)
@property
def state(self):
return self.data.get('state', 'unknown')
@property
def is_running(self):
return self.state == 'running'
class RemoteMember(Member):
""" Represents a remote master for a standby cluster
"""
def __new__(cls, name, data):
return super(RemoteMember, cls).__new__(cls, None, name, None, data)
@staticmethod
def allowed_keys():
return ('primary_slot_name',
'create_replica_methods',
'restore_command',
'archive_cleanup_command',
'recovery_min_apply_delay',
'no_replication_slot')
def __getattr__(self, name):
if name in RemoteMember.allowed_keys():
return self.data.get(name)
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
@property
def timeline(self):
return self.member.data.get('timeline')
@property
def checkpoint_after_promote(self):
"""
>>> Leader(1, '', Member.from_node(1, '', '', '{"version":"z"}')).checkpoint_after_promote
"""
version = self.member.data.get('version')
if version:
try:
# 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false
if tuple(map(int, version.split('.'))) > (1, 5, 6):
return self.member.data['role'] == 'master' and 'checkpoint_after_promote' not in self.member.data
except Exception:
logger.debug('Failed to parse Patroni version %s', version)
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"}))
True
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader", "member": "cluster_candidate"}'))
True
>>> Failover.from_node(1, 'null') is None
False
>>> 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
False
>>> Failover.from_node(1, '{}') is None
False
>>> 'abc' in Failover.from_node(1, 'abc:def')
True
"""
@staticmethod
def from_node(index, value):
if isinstance(value, dict):
data = value
elif value:
try:
data = json.loads(value)
if not isinstance(data, dict):
data = {}
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
else:
data = {}
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
False
"""
try:
data = json.loads(data)
except (TypeError, ValueError):
data = None
modify_index = 0
if not isinstance(data, dict):
data = {}
return ClusterConfig(index, data, index if modify_index is None else modify_index)
@property
def permanent_slots(self):
return isinstance(self.data, dict) and (
self.data.get('permanent_replication_slots') or
self.data.get('permanent_slots') or self.data.get('slots')
) or {}
class SyncState(namedtuple('SyncState', 'index,leader,sync_standby')):
"""Immutable object (namedtuple) which represents last observed synhcronous replication state
:param index: modification index of a synchronization key in a Configuration Store
:param leader: reference to member that was leader
:param sync_standby: standby that was last synchronized to leader
"""
@staticmethod
def from_node(index, value):
"""
>>> SyncState.from_node(1, None).leader is None
True
>>> SyncState.from_node(1, '{}').leader is None
True
>>> SyncState.from_node(1, '{').leader is None
True
>>> SyncState.from_node(1, '[]').leader is None
True
>>> SyncState.from_node(1, '{"leader": "leader"}').leader == "leader"
True
>>> SyncState.from_node(1, {"leader": "leader"}).leader == "leader"
True
"""
if isinstance(value, dict):
data = value
elif value:
try:
data = json.loads(value)
if not isinstance(data, dict):
data = {}
except (TypeError, ValueError):
data = {}
else:
data = {}
return SyncState(index, data.get('leader'), data.get('sync_standby'))
def matches(self, name):
"""
Returns if a node name matches one of the nodes in the sync state
>>> s = SyncState(1, 'foo', 'bar')
>>> s.matches('foo')
True
>>> s.matches('bar')
True
>>> s.matches('baz')
False
>>> s.matches(None)
False
>>> SyncState(1, None, None).matches('foo')
False
"""
return name is not None and name in (self.leader, self.sync_standby)
class TimelineHistory(namedtuple('TimelineHistory', 'index,value,lines')):
"""Object representing timeline history file"""
@staticmethod
def from_node(index, value):
"""
>>> h = TimelineHistory.from_node(1, 2)
>>> h.lines
[]
"""
try:
lines = json.loads(value)
except (TypeError, ValueError):
lines = None
if not isinstance(lines, list):
lines = []
return TimelineHistory(index, value, lines)
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operation,members,failover,sync,history')):
"""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
:param sync: reference to `SyncState` object, last observed synchronous replication state.
:param history: reference to `TimelineHistory` 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, exclude):
exclude = [exclude] + [self.leader.name] if self.leader else []
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
def check_mode(self, mode):
return bool(self.config and parse_bool(self.config.data.get(mode)))
def is_paused(self):
return self.check_mode('pause')
def is_synchronous_mode(self):
return self.check_mode('synchronous_mode')
def get_replication_slots(self, name, role):
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
# the current master, because that member would replicate from elsewhere. We still create the slot if
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
# master), or if replicatefrom destination member happens to be the current master
use_slots = self.config and self.config.data.get('postgresql', {}).get('use_slots', True)
if role in ('master', 'standby_leader'):
slot_members = [m.name for m in self.members if use_slots and m.name != name and
(m.replicatefrom is None or m.replicatefrom == name or
not self.has_member(m.replicatefrom))]
permanent_slots = (self.config and self.config.permanent_slots or {}).copy()
else:
# only manage slots for replicas that replicate from this one, except for the leader among them
slot_members = [m.name for m in self.members if use_slots and
m.replicatefrom == name and m.name != self.leader.name]
permanent_slots = {}
slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
if len(slots) < len(slot_members):
# Find which names are conflicting for a nicer error message
slot_conflicts = defaultdict(list)
for name in slot_members:
slot_conflicts[slot_name_from_member_name(name)].append(name)
logger.error("Following cluster members share a replication slot name: %s",
"; ".join("{} map to {}".format(", ".join(v), k)
for k, v in slot_conflicts.items() if len(v) > 1))
# "merge" replication slots for members with permanent_replication_slots
for name, value in permanent_slots.items():
if not slot_name_re.match(name):
logger.error("Invalid permanent replication slot name '%s'", name)
logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars")
continue
if name in slots:
logger.error("Permanent replication slot {'%s': %s} is conflicting with" +
" physical replication slot for cluster member", name, value)
continue
value = deepcopy(value)
if not value:
value = {'type': 'physical'}
if isinstance(value, dict):
if 'type' not in value:
value['type'] = 'logical' if value.get('database') and value.get('plugin') else 'physical'
if value['type'] == 'physical' or value['type'] == 'logical' \
and value.get('database') and value.get('plugin'):
slots[name] = value
continue
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
return slots
def has_permanent_logical_slots(self, name):
slots = self.get_replication_slots(name, 'master').values()
return any(v for v in slots if v.get("type") == "logical")
@property
def timeline(self):
"""
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0).timeline
0
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]')).timeline
1
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]')).timeline
0
"""
if self.history:
if self.history.lines:
try:
return int(self.history.lines[-1][0]) + 1
except Exception:
logger.error('Failed to parse cluster history from DCS: %s', self.history.lines)
elif self.history.value == '[]':
return 1
return 0
@six.add_metaclass(abc.ABCMeta)
class AbstractDCS(object):
_INITIALIZE = 'initialize'
_CONFIG = 'config'
_LEADER = 'leader'
_FAILOVER = 'failover'
_HISTORY = 'history'
_MEMBERS = 'members/'
_OPTIME = 'optime'
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
_SYNC = 'sync'
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._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
self._set_loop_wait(config.get('loop_wait', 10))
self._ctl = bool(config.get('patronictl', False))
self._cluster = None
self._cluster_valid_till = 0
self._cluster_thread_lock = Lock()
self._last_leader_operation = ''
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 history_path(self):
return self.client_path(self._HISTORY)
@property
def leader_optime_path(self):
return self.client_path(self._LEADER_OPTIME)
@property
def sync_path(self):
return self.client_path(self._SYNC)
@abc.abstractmethod
def set_ttl(self, ttl):
"""Set the new ttl value for leader key"""
@abc.abstractmethod
def ttl(self):
"""Get new ttl value"""
@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):
try:
cluster = self._load_cluster()
except Exception:
self.reset_cluster()
raise
with self._cluster_thread_lock:
self._cluster = cluster
self._cluster_valid_till = time.time() + self.ttl
return cluster
@property
def cluster(self):
with self._cluster_thread_lock:
return self._cluster if self._cluster_valid_till > time.time() else None
def reset_cluster(self):
with self._cluster_thread_lock:
self._cluster = None
self._cluster_valid_till = 0
@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
:returns: `!True` on success."""
def write_leader_optime(self, last_operation):
if self._last_leader_operation != last_operation and self._write_leader_optime(last_operation):
self._last_leader_operation = last_operation
@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."""
def update_leader(self, last_operation, access_is_restricted=False):
"""Update leader key (or session) ttl and optime/leader
:param last_operation: absolute xlog location in bytes
:returns: `!True` if leader key (or session) has been updated successfully.
If not, `!False` must be returned and current instance would be demoted."""
ret = self._update_leader()
if ret and last_operation:
self.write_leader_optime(last_operation)
return ret
@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, 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: 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"""
@staticmethod
def sync_state(leader, sync_standby):
"""Build sync_state dict"""
return {'leader': leader, 'sync_standby': sync_standby}
def write_sync_state(self, leader, sync_standby, index=None):
sync_value = self.sync_state(leader, sync_standby)
return self.set_sync_state_value(json.dumps(sync_value, separators=(',', ':')), index)
@abc.abstractmethod
def set_history_value(self, value):
""""""
@abc.abstractmethod
def set_sync_state_value(self, value, index=None):
""""""
@abc.abstractmethod
def delete_sync_state(self, index=None):
""""""
def watch(self, leader_index, 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 leader_index: index of a leader key
: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()
+540
View File
@@ -0,0 +1,540 @@
from __future__ import absolute_import
import json
import logging
import os
import re
import socket
import ssl
import time
import urllib3
from consul import ConsulException, NotFound, base
from urllib3.exceptions import HTTPError
from six.moves.urllib.parse import urlencode, urlparse, quote
from six.moves.http_client import HTTPException
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
logger = logging.getLogger(__name__)
class ConsulError(DCSError):
pass
class ConsulInternalError(ConsulException):
"""An internal Consul server error occurred"""
class InvalidSessionTTL(ConsulException):
"""Session TTL is too small or too big"""
class InvalidSession(ConsulException):
"""invalid session"""
class HTTPClient(object):
def __init__(self, host='127.0.0.1', port=8500, token=None, scheme='http', verify=True, cert=None, ca_cert=None):
self.token = token
self._read_timeout = 10
self.base_uri = uri(scheme, (host, port))
kwargs = {}
if cert:
if isinstance(cert, tuple):
# Key and cert are separate
kwargs['cert_file'] = cert[0]
kwargs['key_file'] = cert[1]
else:
# combined certificate
kwargs['cert_file'] = cert
if ca_cert:
kwargs['ca_certs'] = ca_cert
if verify or ca_cert:
kwargs['cert_reqs'] = ssl.CERT_REQUIRED
self.http = urllib3.PoolManager(num_pools=10, **kwargs)
self._ttl = None
def set_read_timeout(self, timeout):
self._read_timeout = timeout/3.0
@property
def ttl(self):
return self._ttl
def set_ttl(self, ttl):
ret = self._ttl != ttl
self._ttl = ttl
return ret
@staticmethod
def response(response):
data = response.data.decode('utf-8')
if response.status == 500:
msg = '{0} {1}'.format(response.status, data)
if data.startswith('Invalid Session TTL'):
raise InvalidSessionTTL(msg)
elif data.startswith('invalid session'):
raise InvalidSession(msg)
else:
raise ConsulInternalError(msg)
return base.Response(response.status, response.headers, data)
def uri(self, path, params=None):
return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '')
def __getattr__(self, method):
if method not in ('get', 'post', 'put', 'delete'):
raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method))
def wrapper(callback, path, params=None, data=''):
# python-consul doesn't allow to specify ttl smaller then 10 seconds
# because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack...
if method == 'put' and path == '/v1/session/create':
ttl = '"ttl": "{0}s"'.format(self._ttl)
if not data or data == '{}':
data = '{' + ttl + '}'
else:
data = data[:-1] + ', ' + ttl + '}'
if isinstance(params, list): # starting from v1.1.0 python-consul switched from `dict` to `list` for params
params = {k: v for k, v in params}
kwargs = {'retries': 0, 'preload_content': False, 'body': data}
if method == 'get' and isinstance(params, dict) and 'index' in params:
timeout = float(params['wait'][:-1]) if 'wait' in params else 300
# According to the documentation a small random amount of additional wait time is added to the
# supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds
# up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a
# response rather read timeout we will add to the timeout a sligtly bigger value.
kwargs['timeout'] = timeout + max(timeout/15.0, 1)
else:
kwargs['timeout'] = self._read_timeout
token = params.pop('token', self.token) if isinstance(params, dict) else self.token
kwargs['headers'] = urllib3.make_headers(user_agent=USER_AGENT)
if token:
kwargs['headers']['X-Consul-Token'] = token
return callback(self.response(self.http.request(method.upper(), self.uri(path, params), **kwargs)))
return wrapper
class ConsulClient(base.Consul):
def __init__(self, *args, **kwargs):
self._cert = kwargs.pop('cert', None)
self._ca_cert = kwargs.pop('ca_cert', None)
self.token = kwargs.get('token')
super(ConsulClient, self).__init__(*args, **kwargs)
def connect(self, *args, **kwargs):
kwargs.update(dict(zip(['host', 'port', 'scheme', 'verify'], args)))
if self._cert:
kwargs['cert'] = self._cert
if self._ca_cert:
kwargs['ca_cert'] = self._ca_cert
if self.token:
kwargs['token'] = self.token
return HTTPClient(**kwargs)
def reload_config(self, config):
self.http.token = self.token = config.get('token')
self.consistency = config.get('consistency', 'default')
self.dc = config.get('dc')
def catch_consul_errors(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (RetryFailedError, ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
return False
return wrapper
def force_if_last_failed(func):
def wrapper(*args, **kwargs):
if wrapper.last_result is False:
kwargs['force'] = True
wrapper.last_result = func(*args, **kwargs)
return wrapper.last_result
wrapper.last_result = None
return wrapper
def service_name_from_scope_name(scope_name):
"""Translate scope name to service name which can be used in dns.
230 = 253 - len('replica.') - len('.service.consul')
"""
def replace_char(match):
c = match.group(0)
return '-' if c in '. _' else "u{:04d}".format(ord(c))
service_name = re.sub(r'[^a-z0-9\-]', replace_char, scope_name.lower())
return service_name[0:230]
class Consul(AbstractDCS):
def __init__(self, config):
super(Consul, self).__init__(config)
self._scope = config['scope']
self._session = None
self.__do_not_watch = False
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=(ConsulInternalError, HTTPException,
HTTPError, socket.error, socket.timeout))
kwargs = {}
if 'url' in config:
r = urlparse(config['url'])
config.update({'scheme': r.scheme, 'host': r.hostname, 'port': r.port or 8500})
elif 'host' in config:
host, port = split_host_port(config.get('host', '127.0.0.1:8500'), 8500)
config['host'] = host
if 'port' not in config:
config['port'] = int(port)
if config.get('cacert'):
config['ca_cert'] = config.pop('cacert')
if config.get('key') and config.get('cert'):
config['cert'] = (config['cert'], config['key'])
config_keys = ('host', 'port', 'token', 'scheme', 'cert', 'ca_cert', 'dc', 'consistency')
kwargs = {p: config.get(p) for p in config_keys if config.get(p)}
verify = config.get('verify')
if not isinstance(verify, bool):
verify = parse_bool(verify)
if isinstance(verify, bool):
kwargs['verify'] = verify
self._client = ConsulClient(**kwargs)
self.set_retry_timeout(config['retry_timeout'])
self.set_ttl(config.get('ttl') or 30)
self._last_session_refresh = 0
self.__session_checks = config.get('checks')
self._register_service = config.get('register_service', False)
if self._register_service:
self._service_name = service_name_from_scope_name(self._scope)
if self._scope != self._service_name:
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name,
self._scope)
self._service_check_interval = config.get('service_check_interval', '5s')
if not self._ctl:
self.create_session()
def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs)
def create_session(self):
while not self._session:
try:
self.refresh_session()
except ConsulError:
logger.info('waiting on consul')
time.sleep(5)
def reload_config(self, config):
super(Consul, self).reload_config(config)
self._client.reload_config(config.get('consul', {}))
def set_ttl(self, ttl):
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x
self._session = None
self.__do_not_watch = True
@property
def ttl(self):
return self._client.http.ttl
def set_retry_timeout(self, retry_timeout):
self._retry.deadline = retry_timeout
self._client.http.set_read_timeout(retry_timeout)
def adjust_ttl(self):
try:
settings = self._client.agent.self()
min_ttl = (settings['Config']['SessionTTLMin'] or 10000000000)/1000000000.0
logger.warning('Changing Session TTL from %s to %s', self._client.http.ttl, min_ttl)
self._client.http.set_ttl(min_ttl)
except Exception:
logger.exception('adjust_ttl')
def _do_refresh_session(self):
""":returns: `!True` if it had to create new session"""
if self._session and self._last_session_refresh + self._loop_wait > time.time():
return False
if self._session:
try:
self._client.session.renew(self._session)
except NotFound:
self._session = None
ret = not self._session
if ret:
try:
self._session = self._client.session.create(name=self._scope + '-' + self._name,
checks=self.__session_checks,
lock_delay=0.001, behavior='delete')
except InvalidSessionTTL:
logger.exception('session.create')
self.adjust_ttl()
raise
self._last_session_refresh = time.time()
return ret
def refresh_session(self):
try:
return self.retry(self._do_refresh_session)
except (ConsulException, RetryFailedError):
logger.exception('refresh_session')
raise ConsulError('Failed to renew/create session')
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.retry(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[node['Key'][len(path):].lstrip('/')] = 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 timeline history
history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['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 not self._ctl and 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'])
# get synchronization state
sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync['ModifyIndex'], sync and sync['Value'])
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
except NotFound:
return Cluster(None, None, None, None, [], None, None, None)
except Exception:
logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly')
@catch_consul_errors
def touch_member(self, data, permanent=False):
cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
create_member = not permanent and self.refresh_session()
if member and (create_member or member.session != self._session):
self._client.kv.delete(self.member_path)
create_member = True
if not create_member and member and deep_compare(data, member.data):
return True
try:
args = {} if permanent else {'acquire': self._session}
self._client.kv.put(self.member_path, json.dumps(data, separators=(',', ':')), **args)
if self._register_service:
self.update_service(not create_member and member and member.data or {}, data)
return True
except InvalidSession:
self._session = None
logger.error('Our session disappeared from Consul, can not "touch_member"')
except Exception:
logger.exception('touch_member')
return False
@catch_consul_errors
def register_service(self, service_name, **kwargs):
logger.info('Register service %s, params %s', service_name, kwargs)
return self._client.agent.service.register(service_name, **kwargs)
@catch_consul_errors
def deregister_service(self, service_id):
logger.info('Deregister service %s', service_id)
# service_id can contain special characters, but is used as part of uri in deregister request
service_id = quote(service_id)
return self._client.agent.service.deregister(service_id)
def _update_service(self, data):
service_name = self._service_name
role = data['role'].replace('_', '-')
state = data['state']
api_parts = urlparse(data['api_url'])
api_parts = api_parts._replace(path='/{0}'.format(role))
conn_parts = urlparse(data['conn_url'])
check = base.Check.http(api_parts.geturl(), self._service_check_interval,
deregister='{0}s'.format(self._client.http.ttl * 10))
params = {
'service_id': '{0}/{1}'.format(self._scope, self._name),
'address': conn_parts.hostname,
'port': conn_parts.port,
'check': check,
'tags': [role]
}
if state == 'stopped':
return self.deregister_service(params['service_id'])
if role in ['master', 'replica', 'standby-leader']:
if state != 'running':
return
return self.register_service(service_name, **params)
logger.warning('Could not register service: unknown role type %s', role)
@force_if_last_failed
def update_service(self, old_data, new_data, force=False):
update = False
for key in ['role', 'api_url', 'conn_url', 'state']:
if key not in new_data:
logger.warning('Could not register service: not enough params in member data')
return
if old_data.get(key) != new_data[key]:
update = True
if force or update:
return self._update_service(new_data)
@catch_consul_errors
def _do_attempt_to_acquire_leader(self, permanent):
try:
kwargs = {} if permanent else {'acquire': self._session}
return self.retry(self._client.kv.put, self.leader_path, self._name, **kwargs)
except InvalidSession:
self._session = None
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
self.refresh_session()
return self.retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
def attempt_to_acquire_leader(self, permanent=False):
if not self._session and not permanent:
self.refresh_session()
ret = self._do_attempt_to_acquire_leader(permanent)
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)
@catch_consul_errors
def _update_leader(self):
if self._session:
self.retry(self._client.session.renew, self._session)
self._last_session_refresh = time.time()
return bool(self._session)
@catch_consul_errors
def initialize(self, create_new=True, sysid=''):
kwargs = {'cas': 0} if create_new else {}
return self.retry(self._client.kv.put, self.initialize_path, sysid, **kwargs)
@catch_consul_errors
def cancel_initialization(self):
return self.retry(self._client.kv.delete, self.initialize_path)
@catch_consul_errors
def delete_cluster(self):
return self.retry(self._client.kv.delete, self.client_path(''), recurse=True)
@catch_consul_errors
def set_history_value(self, value):
return self._client.kv.put(self.history_path, value)
@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)
@catch_consul_errors
def set_sync_state_value(self, value, index=None):
return self.retry(self._client.kv.put, self.sync_path, value, cas=index)
@catch_consul_errors
def delete_sync_state(self, index=None):
return self.retry(self._client.kv.delete, self.sync_path, cas=index)
def watch(self, leader_index, timeout):
self._last_session_refresh = 0
if self.__do_not_watch:
self.__do_not_watch = False
return True
if leader_index:
end_time = time.time() + timeout
while timeout >= 1:
try:
idx, _ = self._client.kv.get(self.leader_path, index=leader_index, wait=str(timeout) + 's')
return str(idx) != str(leader_index)
except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
logger.exception('watch')
timeout = end_time - time.time()
try:
return super(Consul, self).watch(None, timeout)
finally:
self.event.clear()
+653
View File
@@ -0,0 +1,653 @@
from __future__ import absolute_import
import etcd
import json
import logging
import os
import urllib3.util.connection
import random
import six
import socket
import time
from dns.exception import DNSException
from dns import resolver
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
from six.moves.queue import Queue
from six.moves.http_client import HTTPException
from six.moves.urllib_parse import urlparse
from threading import Thread
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..request import get as requests_get
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
logger = logging.getLogger(__name__)
class EtcdRaftInternal(etcd.EtcdException):
"""Raft Internal Error"""
class EtcdError(DCSError):
pass
class DnsCachingResolver(Thread):
def __init__(self, cache_time=600.0, cache_fail_time=30.0):
super(DnsCachingResolver, self).__init__()
self._cache = {}
self._cache_time = cache_time
self._cache_fail_time = cache_fail_time
self._resolve_queue = Queue()
self.daemon = True
self.start()
def run(self):
while True:
(host, port), attempt = self._resolve_queue.get()
response = self._do_resolve(host, port)
if response:
self._cache[(host, port)] = (time.time(), response)
else:
if attempt < 10:
self.resolve_async(host, port, attempt + 1)
time.sleep(1)
def resolve(self, host, port):
current_time = time.time()
cached_time, response = self._cache.get((host, port), (0, []))
time_passed = current_time - cached_time
if time_passed > self._cache_time or (not response and time_passed > self._cache_fail_time):
new_response = self._do_resolve(host, port)
if new_response:
self._cache[(host, port)] = (current_time, new_response)
response = new_response
return response
def resolve_async(self, host, port, attempt=0):
self._resolve_queue.put(((host, port), attempt))
@staticmethod
def _do_resolve(host, port):
try:
return socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
except socket.gaierror:
logger.warning('failed to resolve host %s', host)
return []
class Client(etcd.Client):
def __init__(self, config, dns_resolver, cache_ttl=300):
self._dns_resolver = dns_resolver
self.set_machines_cache_ttl(cache_ttl)
self._machines_cache_updated = 0
args = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'username', 'password',
'cert', 'ca_cert') if config.get(p)}
super(Client, self).__init__(read_timeout=config['retry_timeout'], **args)
# For some reason python3-etcd on debian and ubuntu are not based on the latest version
# Workaround for the case when https://github.com/jplana/python-etcd/pull/196 is not applied
self.http.connection_pool_kw.pop('ssl_version', None)
self._config = config
self._load_machines_cache()
self._allow_reconnect = True
# allow passing retry argument to api_execute in params
self._comparison_conditions.add('retry')
self._read_options.add('retry')
self._del_conditions.add('retry')
def _calculate_timeouts(self, etcd_nodes=None, timeout=None):
"""Calculate a request timeout and number of retries per single etcd node.
In case if the timeout per node is too small (less than one second) we will reduce the number of nodes.
For the cluster with only one node we will try to do 2 retries.
For clusters with 2 nodes we will try to do 1 retry for every node.
No retries for clusters with 3 or more nodes. We better rely on switching to a different node."""
etcd_nodes = etcd_nodes or len(self._machines_cache) + 1
per_node_timeout = timeout = float(timeout or self.read_timeout)
max_retries = 4 - min(etcd_nodes, 3)
per_node_retries = 1
min_timeout = 1.0
while etcd_nodes > 0:
per_node_timeout = float(timeout) / etcd_nodes
if per_node_timeout >= min_timeout:
# for small clusters we will try to do more than on try on every node
while per_node_retries < max_retries and per_node_timeout / (per_node_retries + 1) >= min_timeout:
per_node_retries += 1
per_node_timeout /= per_node_retries
break
# if the timeout per one node is to small try to reduce number of nodes
etcd_nodes -= 1
max_retries = 1
return etcd_nodes, per_node_timeout, per_node_retries - 1
def _get_headers(self):
basic_auth = ':'.join((self.username, self.password)) if self.username and self.password else None
return urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
def _build_request_parameters(self, timeout=None):
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect}
if timeout is not None:
kwargs.update(retries=0, timeout=timeout)
else:
_, per_node_timeout, per_node_retries = self._calculate_timeouts()
kwargs.update(timeout=per_node_timeout, retries=per_node_retries)
return kwargs
def set_machines_cache_ttl(self, cache_ttl):
self._machines_cache_ttl = cache_ttl
@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)
data = self._handle_server_response(response).data.decode('utf-8')
machines = [m.strip() for m in data.split(',') if m.strip()]
logger.debug("Retrieved list of machines: %s", machines)
if not machines:
raise etcd.EtcdException
random.shuffle(machines)
for url in machines:
r = urlparse(url)
port = r.port or (443 if r.scheme == 'https' else 80)
self._dns_resolver.resolve_async(r.hostname, port)
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, ProtocolError))):
logger.debug("Watch timed out.")
# switch to the next etcd node because we don't know exactly what happened,
# whether the key didn't received an update or there is a network problem.
self._machines_cache.insert(0, self._base_uri)
self._base_uri = self._next_server()
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 /')
retry = params.pop('retry', None) if isinstance(params, dict) else None
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()
elif not self._use_proxies and time.time() - self._machines_cache_updated > self._machines_cache_ttl:
self._refresh_machines_cache()
kwargs.update(self._build_request_parameters(timeout))
if retry:
machines_cache = [self._base_uri] + self._machines_cache
response = False
while True:
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:
if not retry:
raise etcd.EtcdException('{0} {1} request failed'.format(method, path))
some_request_failed = True
if some_request_failed:
self._refresh_machines_cache()
if response:
break
except etcd.EtcdConnectionFailed:
if not retry:
raise
sleeptime = retry.sleeptime
remaining_time = retry.stoptime - sleeptime - time.time()
nodes, timeout, retries = self._calculate_timeouts(len(machines_cache), remaining_time)
if nodes == 0:
self._update_machines_cache = True
raise
retry.sleep_func(sleeptime)
retry.update_delay()
# We still have some time left. Partially restore `_machines_cache` and retry request
kwargs.update(timeout=timeout, retries=retries)
self._base_uri = machines_cache[0]
self._machines_cache = machines_cache[1:nodes]
return self._handle_server_response(response)
@staticmethod
def get_srv_record(host):
try:
return [(r.target.to_text(True), r.port) for r in resolver.query(host, 'SRV')]
except DNSException:
return []
def _get_machines_cache_from_srv(self, 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 r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
protocol = 'https' if '-ssl' in r else 'http'
endpoint = '/members' if '-server' in r else ''
for host, port in self.get_srv_record('_etcd{0}._tcp.{1}'.format(r, srv)):
url = uri(protocol, (host, port), endpoint)
if endpoint:
try:
response = requests_get(url, timeout=self.read_timeout, verify=False)
if response.status < 400:
for member in json.loads(response.data.decode('utf-8')):
ret.extend(member['clientURLs'])
break
except Exception:
logger.exception('GET %s', url)
else:
ret.append(url)
if ret:
self._protocol = protocol
break
else:
logger.warning('Can not resolve SRV for %s', srv)
return list(set(ret))
def _get_machines_cache_from_dns(self, host, port):
"""One host might be resolved into multiple ip addresses. We will make list out of it"""
if self.protocol == 'http':
ret = map(lambda res: uri(self.protocol, res[-1][:2]), self._dns_resolver.resolve(host, port))
if ret:
return list(set(ret))
return [uri(self.protocol, (host, port))]
def _get_machines_cache_from_config(self):
if 'proxy' in self._config:
return [uri(self.protocol, (self._config['host'], self._config['port']))]
machines_cache = []
if 'srv' in self._config:
machines_cache = self._get_machines_cache_from_srv(self._config['srv'])
if not machines_cache and 'hosts' in self._config:
machines_cache = list(self._config['hosts'])
if not machines_cache and 'host' in self._config:
machines_cache = self._get_machines_cache_from_dns(self._config['host'], self._config['port'])
return machines_cache
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 'srv' not in self._config and 'host' not in self._config and 'hosts' not in self._config:
raise Exception('Neither srv, hosts, host nor url are defined in etcd section of config')
self._machines_cache = self._get_machines_cache_from_config()
# 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._next_server()
self._refresh_machines_cache()
self._update_machines_cache = False
def _refresh_machines_cache(self):
self._machines_cache = self._get_machines_cache_from_config() if self._use_proxies else self.machines
if self._base_uri in self._machines_cache:
self._machines_cache.remove(self._base_uri)
elif self._machines_cache:
self._base_uri = self._next_server()
self._machines_cache_updated = time.time()
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, EtcdRaftInternal))
self._client = self.get_etcd_client(config)
self.__do_not_watch = False
self._has_failed = False
def retry(self, *args, **kwargs):
retry = self._retry.copy()
kwargs['retry'] = retry
return retry(*args, **kwargs)
def _handle_exception(self, e, name='', do_sleep=False, raise_ex=None):
if not self._has_failed:
logger.exception(name)
else:
logger.error(e)
if do_sleep:
time.sleep(1)
self._has_failed = True
if isinstance(raise_ex, Exception):
raise raise_ex
def catch_etcd_errors(func):
def wrapper(self, *args, **kwargs):
try:
retval = func(self, *args, **kwargs) is not None
self._has_failed = False
return retval
except (RetryFailedError, etcd.EtcdException) as e:
self._handle_exception(e)
return False
except Exception as e:
self._handle_exception(e, raise_ex=EtcdError('unexpected error'))
return wrapper
@staticmethod
def get_etcd_client(config):
if 'proxy' in config:
config['use_proxies'] = True
config['url'] = config['proxy']
if 'url' in config:
r = urlparse(config['url'])
config.update({'protocol': r.scheme, 'host': r.hostname, 'port': r.port or 2379,
'username': r.username, 'password': r.password})
elif 'hosts' in config:
hosts = config.pop('hosts')
default_port = config.pop('port', 2379)
protocol = config.get('protocol', 'http')
if isinstance(hosts, six.string_types):
hosts = hosts.split(',')
config['hosts'] = []
for value in hosts:
if isinstance(value, six.string_types):
config['hosts'].append(uri(protocol, split_host_port(value.strip(), default_port)))
elif 'host' in config:
host, port = split_host_port(config['host'], 2379)
config['host'] = host
if 'port' not in config:
config['port'] = int(port)
if config.get('cacert'):
config['ca_cert'] = config.pop('cacert')
if config.get('key') and config.get('cert'):
config['cert'] = (config['cert'], config['key'])
for p in ('discovery_srv', 'srv_domain'):
if p in config:
config['srv'] = config.pop(p)
dns_resolver = DnsCachingResolver()
def create_connection_patched(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
source_address=None, socket_options=None):
host, port = address
if host.startswith('['):
host = host.strip('[]')
err = None
for af, socktype, proto, _, sa in dns_resolver.resolve(host, port):
sock = None
try:
sock = socket.socket(af, socktype, proto)
if socket_options:
for opt in socket_options:
sock.setsockopt(*opt)
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT:
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sa)
return sock
except socket.error as e:
err = e
if sock is not None:
sock.close()
sock = None
if err is not None:
raise err
raise socket.error("getaddrinfo returns an empty list")
urllib3.util.connection.create_connection = create_connection_patched
client = None
while not client:
try:
client = Client(config, dns_resolver)
if 'use_proxies' in config and not client.machines:
raise etcd.EtcdException
except etcd.EtcdException:
logger.info('waiting on etcd')
time.sleep(5)
return client
def set_ttl(self, ttl):
ttl = int(ttl)
self.__do_not_watch = self._ttl != ttl
self._ttl = ttl
self._client.set_machines_cache_ttl(ttl*10)
@property
def ttl(self):
return self._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):
cluster = None
try:
result = self.retry(self._client.read, self.client_path(''), recursive=True)
nodes = {node.key[len(result.key):].lstrip('/'): 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 timeline history
history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history.modifiedIndex, history.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)
# get synchronization state
sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync.modifiedIndex, sync and sync.value)
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
except etcd.EtcdKeyNotFound:
cluster = Cluster(None, None, None, None, [], None, None, None)
except Exception as e:
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
self._has_failed = False
return cluster
@catch_etcd_errors
def touch_member(self, data, permanent=False):
data = json.dumps(data, separators=(',', ':'))
return self._client.set(self.member_path, data, None if permanent else self._ttl)
@catch_etcd_errors
def take_leader(self):
return self.retry(self._client.write, self.leader_path, self._name, ttl=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.write, self.leader_path, self._name, prevValue=self._name, ttl=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)
@catch_etcd_errors
def set_history_value(self, value):
return self._client.write(self.history_path, value)
@catch_etcd_errors
def set_sync_state_value(self, value, index=None):
return self.retry(self._client.write, self.sync_path, value, prevIndex=index or 0)
@catch_etcd_errors
def delete_sync_state(self, index=None):
return self.retry(self._client.delete, self.sync_path, prevIndex=index or 0)
def watch(self, leader_index, timeout):
if self.__do_not_watch:
self.__do_not_watch = False
return True
if 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=leader_index, timeout=timeout + 0.5)
self._has_failed = False
# 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()
self._has_failed = False
return False
except (etcd.EtcdEventIndexCleared, etcd.EtcdWatcherCleared): # Watch failed
self._has_failed = False
return True # leave the loop, because watch with the same parameters will fail anyway
except etcd.EtcdException as e:
self._handle_exception(e, 'watch', True)
timeout = end_time - time.time()
try:
return super(Etcd, self).watch(None, timeout)
finally:
self.event.clear()
etcd.EtcdError.error_exceptions[300] = EtcdRaftInternal
+74
View File
@@ -0,0 +1,74 @@
import json
import logging
import random
import time
from patroni.dcs.zookeeper import ZooKeeper
from patroni.request import get as requests_get
from patroni.utils import uri
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')
time.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:
try:
response = requests_get(uri('http', (host, self._exhibitor_port), self._uri_path), timeout=self.TIMEOUT)
return json.loads(response.data.decode('utf-8'))
except Exception:
logging.debug('Request to %s failed', host)
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()
+554
View File
@@ -0,0 +1,554 @@
from __future__ import absolute_import
import datetime
import functools
import json
import logging
import socket
import sys
import time
from kubernetes import client as k8s_client, config as k8s_config, watch as k8s_watch
from urllib3 import Timeout
from urllib3.exceptions import HTTPError
from six.moves.http_client import HTTPException
from threading import Condition, Lock, Thread
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..utils import deep_compare, Retry, RetryFailedError, tzutc, USER_AGENT
logger = logging.getLogger(__name__)
class KubernetesError(DCSError):
pass
class KubernetesRetriableException(k8s_client.rest.ApiException):
def __init__(self, orig):
super(KubernetesRetriableException, self).__init__(orig.status, orig.reason)
self.body = orig.body
self.headers = orig.headers
class CoreV1ApiProxy(object):
def __init__(self, use_endpoints=False):
self._api = k8s_client.CoreV1Api()
self._api.api_client.user_agent = USER_AGENT
self._api.api_client.rest_client.pool_manager.connection_pool_kw['maxsize'] = 10
self._request_timeout = None
self._use_endpoints = use_endpoints
def set_timeout(self, timeout):
self._request_timeout = (1, timeout / 3.0)
def __getattr__(self, func):
if func.endswith('_kind'):
func = func[:-4] + ('endpoints' if self._use_endpoints else 'config_map')
def wrapper(*args, **kwargs):
if '_request_timeout' not in kwargs:
kwargs['_request_timeout'] = self._request_timeout
try:
return getattr(self._api, func)(*args, **kwargs)
except k8s_client.rest.ApiException as e:
if e.status in (502, 503, 504): # XXX
raise KubernetesRetriableException(e)
raise
return wrapper
def catch_kubernetes_errors(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except k8s_client.rest.ApiException as e:
if e.status == 403:
logger.exception('Permission denied')
elif e.status != 409: # Object exists or conflict in resource_version
logger.exception('Unexpected error from Kubernetes API')
return False
except (RetryFailedError, HTTPException, HTTPError, socket.error, socket.timeout):
return False
return wrapper
class ObjectCache(Thread):
def __init__(self, dcs, func, retry, condition):
Thread.__init__(self)
self.daemon = True
self._api_client = k8s_client.ApiClient()
self._dcs = dcs
self._func = func
self._retry = retry
self._condition = condition
self._is_ready = False
self._object_cache = {}
self._object_cache_lock = Lock()
self._annotations_map = {self._dcs.leader_path: self._dcs._LEADER, self._dcs.config_path: self._dcs._CONFIG}
self.start()
def _list(self):
return self._func(_request_timeout=(self._retry.deadline, Timeout.DEFAULT_TIMEOUT))
def _watch(self, resource_version):
return self._func(_request_timeout=(self._retry.deadline, Timeout.DEFAULT_TIMEOUT),
_preload_content=False, watch=True, resource_version=resource_version)
def set(self, name, value):
with self._object_cache_lock:
old_value = self._object_cache.get(name)
ret = not old_value or int(old_value.metadata.resource_version) < int(value.metadata.resource_version)
if ret:
self._object_cache[name] = value
return ret, old_value
def delete(self, name, resource_version):
with self._object_cache_lock:
old_value = self._object_cache.get(name)
ret = old_value and int(old_value.metadata.resource_version) < int(resource_version)
if ret:
del self._object_cache[name]
return not old_value or ret, old_value
def copy(self):
with self._object_cache_lock:
return self._object_cache.copy()
def _build_cache(self):
objects = self._list()
return_type = 'V1' + objects.kind[:-4]
with self._object_cache_lock:
self._object_cache = {item.metadata.name: item for item in objects.items}
with self._condition:
self._is_ready = True
self._condition.notify()
response = self._watch(objects.metadata.resource_version)
try:
for line in k8s_watch.watch.iter_resp_lines(response):
event = json.loads(line)
obj = event['object']
if obj.get('code') == 410:
break
ev_type = event['type']
name = obj['metadata']['name']
if ev_type in ('ADDED', 'MODIFIED'):
obj = k8s_watch.watch.SimpleNamespace(data=json.dumps(obj))
obj = self._api_client.deserialize(obj, return_type)
success, old_value = self.set(name, obj)
if success:
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
elif ev_type == 'DELETED':
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
new_value = None
else:
logger.warning('Unexpected event type: %s', ev_type)
continue
if success and return_type != 'V1Pod':
if old_value:
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
if old_value != new_value and \
(name != self._dcs.config_path or old_value is not None and new_value is not None):
logger.debug('%s changed from %s to %s', name, old_value, new_value)
self._dcs.event.set()
finally:
with self._condition:
self._is_ready = False
response.close()
response.release_conn()
def run(self):
while True:
try:
self._build_cache()
except Exception as e:
with self._condition:
self._is_ready = False
logger.error('ObjectCache.run %r', e)
def is_ready(self):
"""Must be called only when holding the lock on `_condition`"""
return self._is_ready
class Kubernetes(AbstractDCS):
def __init__(self, config):
self._labels = config['labels']
self._labels[config.get('scope_label', 'cluster-name')] = config['scope']
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
self._namespace = config.get('namespace') or 'default'
self._role_label = config.get('role_label', 'role')
config['namespace'] = ''
super(Kubernetes, self).__init__(config)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=(KubernetesRetriableException, HTTPException,
HTTPError, socket.error, socket.timeout))
self._ttl = None
try:
k8s_config.load_incluster_config()
except k8s_config.ConfigException:
k8s_config.load_kube_config(context=config.get('context', 'local'))
self.__subsets = None
use_endpoints = config.get('use_endpoints') and (config.get('patronictl') or 'pod_ip' in config)
if use_endpoints:
addresses = [k8s_client.V1EndpointAddress(ip='127.0.0.1' if config.get('patronictl') else config['pod_ip'])]
ports = []
for p in config.get('ports', [{}]):
port = {'port': int(p.get('port', '5432'))}
port.update({n: p[n] for n in ('name', 'protocol') if p.get(n)})
ports.append(k8s_client.V1EndpointPort(**port))
self.__subsets = [k8s_client.V1EndpointSubset(addresses=addresses, ports=ports)]
self._should_create_config_service = True
self._api = CoreV1ApiProxy(use_endpoints)
self.set_retry_timeout(config['retry_timeout'])
self.set_ttl(config.get('ttl') or 30)
self._leader_observed_record = {}
self._leader_observed_time = None
self._leader_resource_version = None
self._leader_observed_subsets = []
self._config_resource_version = None
self.__do_not_watch = False
self._condition = Condition()
pods_func = functools.partial(self._api.list_namespaced_pod, self._namespace,
label_selector=self._label_selector)
self._pods = ObjectCache(self, pods_func, self._retry, self._condition)
kinds_func = functools.partial(self._api.list_namespaced_kind, self._namespace,
label_selector=self._label_selector)
self._kinds = ObjectCache(self, kinds_func, self._retry, self._condition)
def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs)
def client_path(self, path):
return super(Kubernetes, self).client_path(path)[1:].replace('/', '-')
@property
def leader_path(self):
return self._base_path[1:] if self.__subsets else super(Kubernetes, self).leader_path
def set_ttl(self, ttl):
ttl = int(ttl)
self.__do_not_watch = self._ttl != ttl
self._ttl = ttl
@property
def ttl(self):
return self._ttl
def set_retry_timeout(self, retry_timeout):
self._retry.deadline = retry_timeout
self._api.set_timeout(retry_timeout)
@staticmethod
def member(pod):
annotations = pod.metadata.annotations or {}
member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', ''))
member.data['pod_labels'] = pod.metadata.labels
return member
def _wait_caches(self):
stop_time = time.time() + self._retry.deadline
while not (self._pods.is_ready() and self._kinds.is_ready()):
timeout = stop_time - time.time()
if timeout <= 0:
raise RetryFailedError('Exceeded retry deadline')
self._condition.wait(timeout)
def _load_cluster(self):
try:
with self._condition:
self._wait_caches()
members = [self.member(pod) for pod in self._pods.copy().values()]
nodes = self._kinds.copy()
config = nodes.get(self.config_path)
metadata = config and config.metadata
self._config_resource_version = metadata.resource_version if metadata else None
annotations = metadata and metadata.annotations or {}
# get initialize flag
initialize = annotations.get(self._INITIALIZE)
# get global dynamic configuration
config = ClusterConfig.from_node(metadata and metadata.resource_version,
annotations.get(self._CONFIG) or '{}',
metadata.resource_version if self._CONFIG in annotations else 0)
# get timeline history
history = TimelineHistory.from_node(metadata and metadata.resource_version,
annotations.get(self._HISTORY) or '[]')
leader = nodes.get(self.leader_path)
metadata = leader and leader.metadata
self._leader_resource_version = metadata.resource_version if metadata else None
self._leader_observed_subsets = leader.subsets if self.__subsets and leader and leader.subsets else []
annotations = metadata and metadata.annotations or {}
# get last leader operation
last_leader_operation = annotations.get(self._OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation)
# get leader
leader_record = {n: annotations.get(n) for n in (self._LEADER, 'acquireTime',
'ttl', 'renewTime', 'transitions') if n in annotations}
if (leader_record or self._leader_observed_record) and leader_record != self._leader_observed_record:
self._leader_observed_record = leader_record
self._leader_observed_time = time.time()
leader = leader_record.get(self._LEADER)
try:
ttl = int(leader_record.get('ttl')) or self._ttl
except (TypeError, ValueError):
ttl = self._ttl
if not metadata or not self._leader_observed_time or self._leader_observed_time + ttl < time.time():
leader = None
if metadata:
member = Member(-1, leader, None, {})
member = ([m for m in members if m.name == leader] or [member])[0]
leader = Leader(metadata.resource_version, None, member)
# failover key
failover = nodes.get(self.failover_path)
metadata = failover and failover.metadata
failover = Failover.from_node(metadata and metadata.resource_version,
metadata and (metadata.annotations or {}).copy())
# get synchronization state
sync = nodes.get(self.sync_path)
metadata = sync and sync.metadata
sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
except Exception:
logger.exception('get_cluster')
raise KubernetesError('Kubernetes API is not responding properly')
@staticmethod
def compare_ports(p1, p2):
return p1.name == p2.name and p1.port == p2.port and (p1.protocol or 'TCP') == (p2.protocol or 'TCP')
@staticmethod
def subsets_changed(last_observed_subsets, subsets):
"""
>>> Kubernetes.subsets_changed([], [])
False
>>> Kubernetes.subsets_changed([], [k8s_client.V1EndpointSubset()])
True
>>> s1 = [k8s_client.V1EndpointSubset(addresses=[k8s_client.V1EndpointAddress(ip='1.2.3.4')])]
>>> s2 = [k8s_client.V1EndpointSubset(addresses=[k8s_client.V1EndpointAddress(ip='1.2.3.5')])]
>>> Kubernetes.subsets_changed(s1, s2)
True
>>> a = [k8s_client.V1EndpointAddress(ip='1.2.3.4')]
>>> s1 = [k8s_client.V1EndpointSubset(addresses=a, ports=[k8s_client.V1EndpointPort(protocol='TCP', port=1)])]
>>> s2 = [k8s_client.V1EndpointSubset(addresses=a, ports=[k8s_client.V1EndpointPort(port=5432)])]
>>> Kubernetes.subsets_changed(s1, s2)
True
>>> p1 = k8s_client.V1EndpointPort(name='port1', port=1)
>>> p2 = k8s_client.V1EndpointPort(name='port2', port=2)
>>> p3 = k8s_client.V1EndpointPort(name='port3', port=3)
>>> s1 = [k8s_client.V1EndpointSubset(addresses=a, ports=[p1, p2])]
>>> s2 = [k8s_client.V1EndpointSubset(addresses=a, ports=[p2, p3])]
>>> Kubernetes.subsets_changed(s1, s2)
True
>>> s2 = [k8s_client.V1EndpointSubset(addresses=a, ports=[p2, p1])]
>>> Kubernetes.subsets_changed(s1, s2)
False
"""
if len(last_observed_subsets) != len(subsets):
return True
if subsets == []:
return False
if len(last_observed_subsets[0].addresses or []) != 1 or \
last_observed_subsets[0].addresses[0].ip != subsets[0].addresses[0].ip or \
len(last_observed_subsets[0].ports) != len(subsets[0].ports):
return True
if len(subsets[0].ports) == 1:
return not Kubernetes.compare_ports(last_observed_subsets[0].ports[0], subsets[0].ports[0])
observed_ports = {p.name: p for p in last_observed_subsets[0].ports}
for p in subsets[0].ports:
if p.name not in observed_ports or not Kubernetes.compare_ports(p, observed_ports.pop(p.name)):
return True
return False
@catch_kubernetes_errors
def patch_or_create(self, name, annotations, resource_version=None, patch=False, retry=True, subsets=None):
metadata = {'namespace': self._namespace, 'name': name, 'labels': self._labels, 'annotations': annotations}
if patch or resource_version:
if resource_version is not None:
metadata['resource_version'] = resource_version
func = functools.partial(self._api.patch_namespaced_kind, name)
else:
func = functools.partial(self._api.create_namespaced_kind)
# skip annotations with null values
metadata['annotations'] = {k: v for k, v in metadata['annotations'].items() if v is not None}
metadata = k8s_client.V1ObjectMeta(**metadata)
if subsets is not None and self.__subsets:
endpoints = {'metadata': metadata}
if self.subsets_changed(self._leader_observed_subsets, subsets):
endpoints['subsets'] = subsets
body = k8s_client.V1Endpoints(**endpoints)
else:
body = k8s_client.V1ConfigMap(metadata=metadata)
ret = self.retry(func, self._namespace, body) if retry else func(self._namespace, body)
if ret:
self._kinds.set(name, ret)
return ret
def patch_or_create_config(self, annotations, resource_version=None, patch=False, retry=True):
# SCOPE-config endpoint requires corresponding service otherwise it might be "cleaned" by k8s master
if self.__subsets and not patch and not resource_version:
self._should_create_config_service = True
self._create_config_service()
ret = self.patch_or_create(self.config_path, annotations, resource_version, patch, retry)
if ret:
self._config_resource_version = ret.metadata.resource_version
return ret
def _create_config_service(self):
metadata = k8s_client.V1ObjectMeta(namespace=self._namespace, name=self.config_path, labels=self._labels)
body = k8s_client.V1Service(metadata=metadata, spec=k8s_client.V1ServiceSpec(cluster_ip='None'))
try:
if not self._api.create_namespaced_service(self._namespace, body):
return
except Exception as e:
if not isinstance(e, k8s_client.rest.ApiException) or e.status != 409: # Service already exists
return logger.exception('create_config_service failed')
self._should_create_config_service = False
def _write_leader_optime(self, last_operation):
"""Unused"""
def _update_leader(self):
"""Unused"""
def update_leader(self, last_operation, access_is_restricted=False):
now = datetime.datetime.now(tzutc).isoformat()
annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now,
'acquireTime': self._leader_observed_record.get('acquireTime') or now,
'transitions': self._leader_observed_record.get('transitions') or '0'}
if last_operation:
annotations[self._OPTIME] = last_operation
subsets = [] if access_is_restricted else self.__subsets
ret = self.patch_or_create(self.leader_path, annotations, self._leader_resource_version, subsets=subsets)
if ret:
self._leader_resource_version = ret.metadata.resource_version
return ret
def attempt_to_acquire_leader(self, permanent=False):
now = datetime.datetime.now(tzutc).isoformat()
annotations = {self._LEADER: self._name, 'ttl': str(sys.maxsize if permanent else self._ttl),
'renewTime': now, 'acquireTime': now, 'transitions': '0'}
if self._leader_observed_record:
try:
transitions = int(self._leader_observed_record.get('transitions'))
except (TypeError, ValueError):
transitions = 0
if self._leader_observed_record.get(self._LEADER) != self._name:
transitions += 1
else:
annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
annotations['transitions'] = str(transitions)
subsets = [] if self.__subsets else None
ret = self.patch_or_create(self.leader_path, annotations, self._leader_resource_version, subsets=subsets)
if ret:
self._leader_resource_version = ret.metadata.resource_version
else:
logger.info('Could not take out TTL lock')
return ret
def take_leader(self):
return self.attempt_to_acquire_leader()
def set_failover_value(self, value, index=None):
"""Unused"""
def manual_failover(self, leader, candidate, scheduled_at=None, index=None):
annotations = {'leader': leader or None, 'member': candidate or None,
'scheduled_at': scheduled_at and scheduled_at.isoformat()}
patch = bool(self.cluster and isinstance(self.cluster.failover, Failover) and self.cluster.failover.index)
return self.patch_or_create(self.failover_path, annotations, index, bool(index or patch), False)
def set_config_value(self, value, index=None):
return self.patch_or_create_config({self._CONFIG: value}, index, bool(self._config_resource_version), False)
@catch_kubernetes_errors
def touch_member(self, data, permanent=False):
cluster = self.cluster
if cluster and cluster.leader and cluster.leader.name == self._name:
role = 'promoted' if data['role'] in ('replica', 'promoted') else 'master'
elif data['state'] == 'running' and data['role'] != 'master':
role = data['role']
else:
role = None
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
pod_labels = member and member.data.pop('pod_labels', None)
ret = pod_labels is not None and pod_labels.get(self._role_label) == role and deep_compare(data, member.data)
if not ret:
metadata = {'namespace': self._namespace, 'name': self._name, 'labels': {self._role_label: role},
'annotations': {'status': json.dumps(data, separators=(',', ':'))}}
body = k8s_client.V1Pod(metadata=k8s_client.V1ObjectMeta(**metadata))
ret = self._api.patch_namespaced_pod(self._name, self._namespace, body)
if self.__subsets and self._should_create_config_service:
self._create_config_service()
return ret
def initialize(self, create_new=True, sysid=""):
cluster = self.cluster
resource_version = cluster.config.index if cluster and cluster.config and cluster.config.index else None
return self.patch_or_create_config({self._INITIALIZE: sysid}, resource_version)
def delete_leader(self):
if self.cluster and isinstance(self.cluster.leader, Leader) and self.cluster.leader.name == self._name:
self.patch_or_create(self.leader_path, {self._LEADER: None}, self._leader_resource_version, True, False, [])
self.reset_cluster()
def cancel_initialization(self):
self.patch_or_create_config({self._INITIALIZE: None}, self._config_resource_version, True)
@catch_kubernetes_errors
def delete_cluster(self):
self.retry(self._api.delete_collection_namespaced_kind, self._namespace, label_selector=self._label_selector)
def set_history_value(self, value):
return self.patch_or_create_config({self._HISTORY: value}, None, bool(self._config_resource_version), False)
def set_sync_state_value(self, value, index=None):
"""Unused"""
def write_sync_state(self, leader, sync_standby, index=None):
return self.patch_or_create(self.sync_path, self.sync_state(leader, sync_standby), index, False)
def delete_sync_state(self, index=None):
return self.write_sync_state(None, None, index)
def watch(self, leader_index, timeout):
if self.__do_not_watch:
self.__do_not_watch = False
return True
try:
return super(Kubernetes, self).watch(None, timeout)
finally:
self.event.clear()
+351
View File
@@ -0,0 +1,351 @@
import json
import logging
import select
import time
from kazoo.client import KazooClient, KazooState, KazooRetry
from kazoo.exceptions import NoNodeError, NodeExistsError
from kazoo.handlers.threading import SequentialThreadingHandler
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from patroni.exceptions import DCSError
from patroni.utils import deep_compare
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) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method
kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout*10)/10.0)
elif 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)
def select(self, *args, **kwargs):
"""Python3 raises `ValueError` if socket is closed, because fd == -1"""
try:
return super(PatroniSequentialThreadingHandler, self).select(*args, **kwargs)
except ValueError as e:
raise select.error(9, str(e))
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=KazooRetry(max_delay=1, max_tries=-1,
sleep_func=time.sleep), command_retry=KazooRetry(deadline=config['retry_timeout'],
max_delay=1, max_tries=-1, sleep_func=time.sleep))
self._client.add_listener(self.session_listener)
self._fetch_cluster = True
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
@property
def ttl(self):
return self._client._session_timeout
def set_retry_timeout(self, retry_timeout):
retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry
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, sync_standby):
members = []
for member in self.get_children(self.members_path, self.cluster_watcher):
watch = member == sync_standby and self.cluster_watcher or None
data = self.get_node(self.members_path + member, watch)
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 timeline history
history = self.get_node(self.history_path, watch=self.cluster_watcher) if self._HISTORY in nodes else None
history = history and TimelineHistory.from_node(history[1].mzxid, history[0])
# get last leader operation
last_leader_operation = self._OPTIME in nodes and self._fetch_cluster and self.get_node(self.leader_optime_path)
last_leader_operation = last_leader_operation and int(last_leader_operation[0]) or 0
# get synchronization state
sync = self.get_node(self.sync_path, watch=self.cluster_watcher) if self._SYNC in nodes else None
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
# get list of members
sync_standby = sync.leader == self._name and sync.sync_standby or None
members = self.load_members(sync_standby) 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 not self._ctl and 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])
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
def _load_cluster(self):
cluster = self.cluster
if self._fetch_cluster or cluster is None:
try:
cluster = self._client.retry(self._inner_load_cluster)
except Exception:
logger.exception('get_cluster')
self.cluster_watcher(None)
raise ZooKeeperError('ZooKeeper in not responding properly')
return cluster
def _create(self, path, value, retry=False, ephemeral=False):
try:
if retry:
self._client.retry(self._client.create, path, value, makepath=True, ephemeral=ephemeral)
else:
self._client.create_async(path, value, makepath=True, ephemeral=ephemeral).get(timeout=1)
return True
except Exception:
logger.exception('Failed to create %s', path)
return False
def attempt_to_acquire_leader(self, permanent=False):
ret = self._create(self.leader_path, self._name.encode('utf-8'), retry=True, ephemeral=not permanent)
if not ret:
logger.info('Could not take out TTL lock')
return ret
def _set_or_create(self, key, value, index=None, retry=False, do_not_create_empty=False):
value = value.encode('utf-8')
try:
if retry:
self._client.retry(self._client.set, key, value, version=index or -1)
else:
self._client.set_async(key, value, version=index or -1).get(timeout=1)
return True
except NoNodeError:
if do_not_create_empty and not value:
return True
elif index is None:
return self._create(key, value, retry)
else:
return False
except Exception:
logger.exception('Failed to update %s', key)
return False
def set_failover_value(self, value, index=None):
return self._set_or_create(self.failover_path, value, index)
def set_config_value(self, value, index=None):
return self._set_or_create(self.config_path, value, index, retry=True)
def initialize(self, create_new=True, sysid=""):
sysid = sysid.encode('utf-8')
return self._create(self.initialize_path, sysid, retry=True) if create_new \
else self._client.retry(self._client.set, self.initialize_path, sysid)
def touch_member(self, data, permanent=False):
cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or
not (deep_compare(member.data.get('tags', {}), data.get('tags', {})) and
member.data.get('version') == data.get('version') and
member.data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))):
try:
self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError:
pass
except Exception:
return False
member = None
if member:
if deep_compare(data, member.data):
return True
else:
try:
self._client.create_async(self.member_path, encoded_data, makepath=True,
ephemeral=not permanent).get(timeout=1)
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, encoded_data).get(timeout=1)
return True
except Exception:
logger.exception('touch_member')
return False
def take_leader(self):
return self.attempt_to_acquire_leader()
def _write_leader_optime(self, last_operation):
return self._set_or_create(self.leader_optime_path, last_operation)
def _update_leader(self):
return True
def delete_leader(self):
self._client.restart()
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 Exception:
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 set_history_value(self, value):
return self._set_or_create(self.history_path, value)
def set_sync_state_value(self, value, index=None):
return self._set_or_create(self.sync_path, value, index, retry=True, do_not_create_empty=True)
def delete_sync_state(self, index=None):
return self.set_sync_state_value("{}", index)
def watch(self, leader_index, timeout):
if super(ZooKeeper, self).watch(leader_index, timeout):
self._fetch_cluster = True
return self._fetch_cluster
-325
View File
@@ -1,325 +0,0 @@
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, 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=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 _do_http_request(self, request_executor, method, url, fields=None, **kwargs):
try:
response = request_executor(method, url, fields=fields, **kwargs)
response.data.decode('utf-8')
self._check_cluster_id(response)
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
if (isinstance(fields, dict) and fields.get("wait") == "true" and
isinstance(e, ReadTimeoutError)):
logger.debug("Watch timed out.")
raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e)
logger.error("Request to server %s failed: %r", self._base_uri, e)
logger.info("Reconnection allowed, looking for another server.")
self._base_uri = self._next_server(cause=e)
response = False
return response
def api_execute(self, path, method, params=None, timeout=None):
if not path.startswith('/'):
raise ValueError('Path does not start with /')
if timeout is None:
timeout = self.read_timeout
if timeout == 0:
timeout = None
kwargs = {'timeout': timeout, 'fields': params, 'redirect': self.allow_redirect,
'headers': self._get_headers(), 'preload_content': False}
if method in [self._MGET, self._MDELETE]:
request_executor = self.http.request
elif method in [self._MPUT, self._MPOST]:
request_executor = self.http.request_encode_body
kwargs['encode_multipart'] = False
else:
raise etcd.EtcdException('HTTP method {0} not supported'.format(method))
# Update machines_cache if previous attempt of update has failed
if self._update_machines_cache:
self._load_machines_cache()
response = False
try:
while not response:
response = self._do_http_request(request_executor, method, self._base_uri + path, **kwargs)
if response is False and not self._use_proxies:
self._machines_cache = self.machines
self._machines_cache.remove(self._base_uri)
return self._handle_server_response(response)
except etcd.EtcdConnectionFailed:
self._update_machines_cache = True
raise
@staticmethod
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=5)
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, name, config):
super(Etcd, self).__init__(name, config)
self.ttl = config.get('ttl', 30)
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)
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
@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 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]
leader = Leader(leader.modifiedIndex, leader.ttl, member)
# failover key
failover = nodes.get(self._FAILOVER)
if failover:
failover = Failover.from_node(failover.modifiedIndex, failover.value)
self._cluster = Cluster(initialize, leader, last_leader_operation, members, failover)
except etcd.EtcdKeyNotFound:
self._cluster = Cluster(False, None, None, [], None)
except:
logger.exception('get_cluster')
raise EtcdError('Etcd is not responding properly')
@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.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 set_failover_value(self, value, index=None):
return self._client.write(self.failover_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):
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 + 1, 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()
+8
View File
@@ -23,3 +23,11 @@ class DCSError(PatroniException):
class PostgresConnectionException(PostgresException):
pass
class WatchdogError(PatroniException):
pass
class ConfigParseError(PatroniException):
pass
+1143 -239
View File
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
import logging
import os
import sys
from copy import deepcopy
from logging.handlers import RotatingFileHandler
from patroni.utils import deep_compare
from six.moves.queue import Queue, Full
from threading import Lock, Thread
_LOGGER = logging.getLogger(__name__)
def debug_exception(logger_obj, msg, *args, **kwargs):
kwargs.pop("exc_info", False)
if logger_obj.isEnabledFor(logging.DEBUG):
logger_obj.debug(msg, *args, exc_info=True, **kwargs)
else:
msg = "{0}, DETAIL: '{1}'".format(msg, sys.exc_info()[1])
logger_obj.error(msg, *args, exc_info=False, **kwargs)
def error_exception(logger_obj, msg, *args, **kwargs):
exc_info = kwargs.pop("exc_info", True)
logger_obj.error(msg, *args, exc_info=exc_info, **kwargs)
class QueueHandler(logging.Handler):
def __init__(self):
logging.Handler.__init__(self)
self.queue = Queue()
self._records_lost = 0
def _put_record(self, record):
self.format(record)
record.msg = record.message
record.args = None
record.exc_info = None
self.queue.put_nowait(record)
def _try_to_report_lost_records(self):
if self._records_lost:
try:
record = _LOGGER.makeRecord(_LOGGER.name, logging.WARNING, __file__, 0,
'QueueHandler has lost %s log records',
(self._records_lost,), None, 'emit')
self._put_record(record)
self._records_lost = 0
except Exception:
pass
def emit(self, record):
try:
self._put_record(record)
self._try_to_report_lost_records()
except Exception:
self._records_lost += 1
@property
def records_lost(self):
return self._records_lost
class ProxyHandler(logging.Handler):
def __init__(self, patroni_logger):
logging.Handler.__init__(self)
self.patroni_logger = patroni_logger
def emit(self, record):
self.patroni_logger.log_handler.handle(record)
class PatroniLogger(Thread):
DEFAULT_LEVEL = 'INFO'
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
NORMAL_LOG_QUEUE_SIZE = 2 # When everything goes normal Patroni writes only 2 messages per HA loop
DEFAULT_MAX_QUEUE_SIZE = 1000
LOGGING_BROKEN_EXIT_CODE = 5
def __init__(self):
super(PatroniLogger, self).__init__()
self._queue_handler = QueueHandler()
self._root_logger = logging.getLogger()
self._config = None
self.log_handler = None
self.log_handler_lock = Lock()
self._old_handlers = []
self.reload_config({'level': 'DEBUG'})
# We will switch to the QueueHandler only when thread was started.
# This is necessary to protect from the cases when Patroni constructor
# failed and PatroniLogger thread remain running and prevent shutdown.
self._proxy_handler = ProxyHandler(self)
self._root_logger.addHandler(self._proxy_handler)
def update_loggers(self):
loggers = deepcopy(self._config.get('loggers') or {})
for name, logger in self._root_logger.manager.loggerDict.items():
if not isinstance(logger, logging.PlaceHolder):
level = loggers.pop(name, logging.NOTSET)
logger.setLevel(level)
for name, level in loggers.items():
logger = self._root_logger.manager.getLogger(name)
logger.setLevel(level)
def reload_config(self, config):
if self._config is None or not deep_compare(self._config, config):
with self._queue_handler.queue.mutex:
self._queue_handler.queue.maxsize = config.get('max_queue_size', self.DEFAULT_MAX_QUEUE_SIZE)
self._root_logger.setLevel(config.get('level', PatroniLogger.DEFAULT_LEVEL))
if config.get('traceback_level', PatroniLogger.DEFAULT_TRACEBACK_LEVEL).lower() == 'debug':
logging.Logger.exception = debug_exception
else:
logging.Logger.exception = error_exception
new_handler = None
if 'dir' in config:
if not isinstance(self.log_handler, RotatingFileHandler):
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
handler = new_handler or self.log_handler
handler.maxBytes = int(config.get('file_size', 25000000))
handler.backupCount = int(config.get('file_num', 4))
else:
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
new_handler = logging.StreamHandler()
handler = new_handler or self.log_handler
oldlogformat = (self._config or {}).get('format', PatroniLogger.DEFAULT_FORMAT)
logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
olddateformat = (self._config or {}).get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None`
if oldlogformat != logformat or olddateformat != dateformat or new_handler:
handler.setFormatter(logging.Formatter(logformat, dateformat))
if new_handler:
with self.log_handler_lock:
if self.log_handler:
self._old_handlers.append(self.log_handler)
self.log_handler = new_handler
self._config = config.copy()
self.update_loggers()
def _close_old_handlers(self):
while True:
with self.log_handler_lock:
if not self._old_handlers:
break
handler = self._old_handlers.pop()
try:
handler.close()
except Exception:
_LOGGER.exception('Failed to close the old log handler %s', handler)
def run(self):
# switch to QueueHandler only when the thread was started
with self.log_handler_lock:
self._root_logger.addHandler(self._queue_handler)
self._root_logger.removeHandler(self._proxy_handler)
while True:
self._close_old_handlers()
record = self._queue_handler.queue.get(True)
if record is None:
break
self.log_handler.handle(record)
self._queue_handler.queue.task_done()
def shutdown(self):
try:
self._queue_handler.queue.put_nowait(None)
except Full: # Queue is full.
# It seems that logging is not working, exiting with non-standard exit-code is the best we can do.
sys.exit(self.LOGGING_BROKEN_EXIT_CODE)
self.join()
logging.shutdown()
@property
def queue_size(self):
return self._queue_handler.queue.qsize()
@property
def records_lost(self):
return self._queue_handler.records_lost
-778
View File
@@ -1,778 +0,0 @@
import logging
import os
import psycopg2
import shlex
import shutil
import subprocess
import tempfile
import time
from patroni.exceptions import PostgresConnectionException, PostgresException
from patroni.utils import Retry, RetryFailedError
from six import string_types
from six.moves.urllib_parse import urlparse
from threading import Lock
logger = logging.getLogger(__name__)
ACTION_ON_START = "on_start"
ACTION_ON_STOP = "on_stop"
ACTION_ON_RESTART = "on_restart"
ACTION_ON_RELOAD = "on_reload"
ACTION_ON_ROLE_CHANGE = "on_role_change"
def parseurl(url):
r = urlparse(url)
ret = {
'host': r.hostname,
'port': r.port or 5432,
'database': r.path[1:],
'fallback_application_name': 'Patroni',
'connect_timeout': 3,
'options': '-c statement_timeout=2000',
}
if r.username:
ret['user'] = r.username
if r.password:
ret['password'] = r.password
return ret
class Postgresql(object):
def __init__(self, config):
self.config = config
self.name = config['name']
self.server_parameters = config.get('parameters') or {}
self.scope = config['scope']
self.listen_addresses, self.port = config['listen'].split(':')
self.data_dir = config['data_dir']
self.replication = config['replication']
self.superuser = config.get('superuser') or {}
self.admin = config.get('admin') or {}
self.initdb_options = config.get('initdb') or []
self.pgpass = config.get('pgpass') or os.path.join(os.path.expanduser('~'), 'pgpass')
self.pg_rewind = config.get('pg_rewind') or {}
self.callback = config.get('callbacks') or {}
self.use_slots = config.get('use_slots', True)
self.schedule_load_slots = self.use_slots
self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf')
self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'),
os.path.join(self.data_dir, 'postgresql.conf'))
self.postmaster_pid = os.path.join(self.data_dir, 'postmaster.pid')
self.trigger_file = config.get('recovery_conf', {}).get('trigger_file') or 'promote'
self.trigger_file = os.path.abspath(os.path.join(self.data_dir, self.trigger_file))
self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir]
self.local_address = self.get_local_address()
connect_address = config.get('connect_address') or self.local_address
self.connection_string = 'postgres://{username}:{password}@{connect_address}/postgres'.format(
connect_address=connect_address, **self.replication)
self._connection = None
self._cursor_holder = None
self._sysid = None
self.replication_slots = [] # list of already existing replication slots
self.retry = Retry(max_tries=-1, deadline=5, max_delay=1, retry_exceptions=PostgresConnectionException)
self._state = 'stopped'
self._state_lock = Lock()
self._role = self.get_postgres_role_from_data_directory()
self._role_lock = Lock()
if self.is_running():
self._state = 'running'
self._role = 'master' if self.is_leader() else 'replica'
@property
def can_rewind(self):
""" check if pg_rewind executable is there and that pg_controldata indicates
we have either wal_log_hints or checksums turned on
"""
# low-hanging fruit: check if pg_rewind configuration is there
if not self.pg_rewind or\
not (self.pg_rewind.get('username', '') and self.pg_rewind.get('password', '')):
return False
cmd = ['pg_rewind', '--help']
try:
ret = subprocess.call(cmd, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
if ret != 0: # pg_rewind is not there, close up the shop and go home
return False
except OSError:
return False
# check if the cluster's configuration permits pg_rewind
data = self.controldata()
return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
@property
def sysid(self):
if not self._sysid:
data = self.controldata()
self._sysid = data.get('Database system identifier', "")
return self._sysid
def get_local_address(self):
listen_addresses = self.listen_addresses.split(',')
local_address = listen_addresses[0].strip() # take first address from listen_addresses
for la in listen_addresses:
if la.strip() in ['*', '0.0.0.0']: # we are listening on *
local_address = 'localhost' # connection via localhost is preferred
break
return local_address + ':' + self.port
def get_postgres_role_from_data_directory(self):
return 'replica' if os.path.exists(self.recovery_conf) else 'master'
@property
def _connect_kwargs(self):
r = parseurl('postgres://{0}/postgres'.format(self.local_address))
if 'username' in self.superuser:
r['user'] = self.superuser['username']
if 'password' in self.superuser:
r['password'] = self.superuser['password']
return r
def connection(self):
if not self._connection or self._connection.closed != 0:
self._connection = psycopg2.connect(**self._connect_kwargs)
self._connection.autocommit = True
self.server_version = self._connection.server_version
return self._connection
def _cursor(self):
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
logger.info("establishing a new patroni connection to the postgres cluster")
self._cursor_holder = self.connection().cursor()
return self._cursor_holder
def close_connection(self):
if self._cursor_holder and self._cursor_holder.connection and self._cursor_holder.connection.closed == 0:
self._cursor_holder.connection.close()
logger.info("closed patroni connection to the postgresql cluster")
def _query(self, sql, *params):
cursor = None
try:
cursor = self._cursor()
cursor.execute(sql, params)
return cursor
except psycopg2.Error as e:
if cursor and cursor.connection.closed == 0:
raise e
if self.state == 'restarting':
raise RetryFailedError('cluster is being restarted')
raise PostgresConnectionException('connection problems')
def query(self, sql, *params):
try:
return self.retry(self._query, sql, *params)
except RetryFailedError as e:
raise PostgresConnectionException(str(e))
def data_directory_empty(self):
return not os.path.exists(self.data_dir) or os.listdir(self.data_dir) == []
@staticmethod
def initdb_allowed_option(name):
if name in ['pgdata', 'nosync', 'pwfile', 'sync-only']:
raise Exception('{0} option for initdb is not allowed'.format(name))
return True
def get_initdb_options(self):
options = []
for o in self.initdb_options:
if isinstance(o, string_types) and self.initdb_allowed_option(o):
options.append('--{0}'.format(o))
elif isinstance(o, dict):
keys = list(o.keys())
if len(keys) != 1 or not isinstance(keys[0], string_types) or not self.initdb_allowed_option(keys[0]):
raise Exception('Invalid option: {0}'.format(o))
options.append('--{0}={1}'.format(keys[0], o[keys[0]]))
else:
raise Exception('Unknown type of initdb option: {0}'.format(o))
return options
def initialize(self):
self.set_state('initalizing new cluster')
options = self.get_initdb_options()
pwfile = None
if self.superuser:
if 'username' in self.superuser:
options.append('--username={0}'.format(self.superuser['username']))
if 'password' in self.superuser:
(fd, pwfile) = tempfile.mkstemp()
os.write(fd, self.superuser['password'].encode('utf-8'))
os.close(fd)
options.append('--pwfile={0}'.format(pwfile))
ret = subprocess.call(self._pg_ctl + ['initdb'] + (['-o', ' '.join(options)] if options else [])) == 0
if pwfile:
os.remove(pwfile)
if ret:
self.write_pg_hba()
else:
self.set_state('initdb failed')
return ret
def delete_trigger_file(self):
if os.path.exists(self.trigger_file):
os.unlink(self.trigger_file)
def write_pgpass(self, record):
with open(self.pgpass, 'w') as f:
os.fchmod(f.fileno(), 0o600)
f.write('{host}:{port}:*:{user}:{password}\n'.format(**record))
env = os.environ.copy()
env['PGPASSFILE'] = self.pgpass
return env
def sync_replica(self, clone_member):
# add the credentials to connect to the replica origin to pgpass.
env = self.write_pgpass(parseurl(clone_member.conn_url)) if clone_member else os.environ.copy()
if self.create_replica(clone_member, env) == 0:
self.delete_trigger_file()
return True
return False
@staticmethod
def build_connstring(conn):
"""
>>> Postgresql.build_connstring({'host': '127.0.0.1', 'port': '5432'}) == 'host=127.0.0.1 port=5432'
True
"""
return ' '.join('{0}={1}'.format(param, val) for param, val in sorted(conn.items()))
def replica_method_can_work_without_replication_connection(self, method):
return method != 'basebackup' and self.config and self.config.get(method, {}).get('no_master')
def can_create_replica_without_replication_connection(self):
""" go through the replication methods to see if there are ones
that does not require a working replication connection.
"""
replica_methods = self.config.get('create_replica_method', [])
return any(self.replica_method_can_work_without_replication_connection(replica_method)
for replica_method in replica_methods)
def create_replica(self, clone_member, env):
# create the replica according to the replica_method
# defined by the user. this is a list, so we need to
# loop through all methods the user supplies
connstring = clone_member.conn_url if clone_member else ""
# get list of replica methods from config.
# If there is no configuration key, or no value is specified, use basebackup
replica_methods = self.config.get('create_replica_method') or ['basebackup']
# if we don't have any source, leave only replica methods that work without it
replica_methods = replica_methods if clone_member else \
[r for r in replica_methods if self.replica_method_can_work_without_replication_connection(r)]
# go through them in priority order
ret = 1
for replica_method in replica_methods:
# if the method is basebackup, then use the built-in
if replica_method == "basebackup":
ret = self.basebackup(clone_member, env)
if ret == 0:
logger.info("replica has been created using basebackup")
# if basebackup succeeds, exit with success
break
else:
cmd = replica_method
method_config = {}
# user-defined method; check for configuration
# not required, actually
if replica_method in self.config:
method_config = self.config[replica_method].copy()
# look to see if the user has supplied a full command path
# if not, use the method name as the command
cmd = method_config.pop('command', cmd)
# add the default parameters
try:
method_config.update({"scope": self.scope,
"role": "replica",
"datadir": self.data_dir,
"connstring": connstring})
params = ["--{0}={1}".format(arg, val) for arg, val in method_config.items()]
# call script with the full set of parameters
ret = subprocess.call(shlex.split(cmd) + params, env=env)
# if we succeeded, stop
if ret == 0:
logger.info("replica has been created using {0}".format(replica_method))
break
except Exception as e:
logger.exception('Error creating replica using method {0}: {1}'.format(replica_method, str(e)))
ret = 1
return ret
def is_leader(self):
return not self.query('SELECT pg_is_in_recovery()').fetchone()[0]
def is_running(self):
return subprocess.call(' '.join(self._pg_ctl) + ' status > /dev/null 2>&1', shell=True) == 0
def call_nowait(self, cb_name):
""" pick a callback command and call it without waiting for it to finish """
if not self.callback or cb_name not in self.callback:
return False
cmd = self.callback[cb_name]
try:
subprocess.Popen(shlex.split(cmd) + [cb_name, self.role, self.scope])
except OSError:
logger.exception('callback %s %s %s %s failed', cmd, cb_name, self.role, self.scope)
return False
return True
@property
def role(self):
with self._role_lock:
return self._role
def set_role(self, value):
with self._role_lock:
self._role = value
@property
def state(self):
with self._state_lock:
return self._state
def set_state(self, value):
with self._state_lock:
self._state = value
def start(self, block_callbacks=False):
if self.is_running():
logger.error('Cannot start PostgreSQL because one is already running.')
return True
self.set_role(self.get_postgres_role_from_data_directory())
if os.path.exists(self.postmaster_pid):
os.remove(self.postmaster_pid)
logger.info('Removed %s', self.postmaster_pid)
if not block_callbacks:
self.set_state('starting')
env = {'PATH': os.environ.get('PATH')}
# pg_ctl will write a FATAL if the username is incorrect. exporting PGUSER if necessary
if 'username' in self.superuser and self.superuser['username'] != os.environ.get('USER'):
env['PGUSER'] = self.superuser['username']
ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()], env=env, preexec_fn=os.setsid) == 0
self.set_state('running' if ret else 'start failed')
self.schedule_load_slots = ret and self.use_slots
self.save_configuration_files()
# block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones
if ret and not block_callbacks:
self.call_nowait(ACTION_ON_START)
return ret
def checkpoint(self, connect_kwargs=None):
connect_kwargs = connect_kwargs or self._connect_kwargs
for p in ['connect_timeout', 'options']:
connect_kwargs.pop(p, None)
try:
with psycopg2.connect(**connect_kwargs) as conn:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute("SET statement_timeout = 0")
cur.execute('CHECKPOINT')
except psycopg2.Error:
logging.exception('Exception during CHECKPOINT')
def stop(self, mode='fast', block_callbacks=False, checkpoint=True):
# make sure we close all connections established against
# the former node, otherwise, we might get a stalled one
# after kill -9, which would report incorrect data to
# patroni.
self.close_connection()
if not self.is_running():
if not block_callbacks:
self.set_state('stopped')
return True
if checkpoint:
self.checkpoint()
if not block_callbacks:
self.set_state('stopping')
ret = subprocess.call(self._pg_ctl + ['stop', '-m', mode]) == 0
# block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones
if not ret:
self.set_state('stop failed')
elif not block_callbacks:
self.set_state('stopped')
self.call_nowait(ACTION_ON_STOP)
return ret
def reload(self):
ret = subprocess.call(self._pg_ctl + ['reload']) == 0
if ret:
self.call_nowait(ACTION_ON_RELOAD)
return ret
def restart(self):
self.set_state('restarting')
ret = self.stop(block_callbacks=True) and self.start(block_callbacks=True)
if ret:
self.call_nowait(ACTION_ON_RESTART)
else:
self.set_state('restart failed ({0})'.format(self.state))
return ret
def server_options(self):
options = "--listen_addresses='{0}' --port={1}".format(self.listen_addresses, self.port)
for setting, value in self.server_parameters.items():
options += " --{0}='{1}'".format(setting, value)
return options
def is_healthy(self):
if not self.is_running():
logger.warning('Postgresql is not running.')
return False
return True
def check_replication_lag(self, last_leader_operation):
return (last_leader_operation or 0) - self.xlog_position() <= self.config.get('maximum_lag_on_failover', 0)
def write_pg_hba(self):
with open(os.path.join(self.data_dir, 'pg_hba.conf'), 'a') as f:
f.write('\n{}\n'.format('\n'.join(self.config.get('pg_hba', []))))
@staticmethod
def primary_conninfo(leader_url):
r = parseurl(leader_url)
return 'user={user} password={password} host={host} port={port} sslmode=prefer sslcompression=1'.format(**r)
def check_recovery_conf(self, leader):
if not os.path.isfile(self.recovery_conf):
return False
pattern = leader and leader.conn_url and self.primary_conninfo(leader.conn_url)
with open(self.recovery_conf, 'r') as f:
for line in f:
if line.startswith('primary_conninfo'):
return pattern and (pattern in line)
return not pattern
def write_recovery_conf(self, leader):
with open(self.recovery_conf, 'w') as f:
f.write("standby_mode = 'on'\nrecovery_target_timeline = 'latest'\n")
if leader and leader.conn_url:
f.write("primary_conninfo = '{0}'\n".format(self.primary_conninfo(leader.conn_url)))
if self.use_slots:
f.write("primary_slot_name = '{0}'\n".format(self.name))
for name, value in self.config.get('recovery_conf', {}).items():
if name not in ('standby_mode', 'recovery_target_timeline', 'primary_conninfo', 'primary_slot_name'):
f.write("{0} = '{1}'\n".format(name, value))
def rewind(self, leader):
# prepare pg_rewind connection
r = parseurl(leader.conn_url)
r.update(self.pg_rewind)
r['user'] = r.pop('username')
env = self.write_pgpass(r)
pc = "user={user} host={host} port={port} dbname=postgres sslmode=prefer sslcompression=1".format(**r)
# first run a checkpoint on a promoted master in order
# to make it store the new timeline ([email protected])
self.checkpoint(r)
logger.info("running pg_rewind from %s", pc)
pg_rewind = ['pg_rewind', '-D', self.data_dir, '--source-server', pc]
try:
ret = subprocess.call(pg_rewind, env=env) == 0
except OSError:
ret = False
if ret:
self.write_recovery_conf(leader)
return ret
def controldata(self):
""" return the contents of pg_controldata, or non-True value if pg_controldata call failed """
result = {}
try:
data = subprocess.check_output(['pg_controldata', self.data_dir])
if data:
data = data.decode('utf-8').splitlines()
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':')[1].strip() for l in data if l}
except subprocess.CalledProcessError:
logger.exception("Error when calling pg_controldata")
return result
def read_postmaster_opts(self):
""" returns the list of option names/values from postgres.opts, Empty dict if read failed or no file """
result = {}
try:
with open(os.path.join(self.data_dir, "postmaster.opts")) as f:
data = f.read()
opts = [opt.strip('"\n') for opt in data.split(' "')]
for opt in opts:
if '=' in opt and opt.startswith('--'):
name, val = opt.split('=', 1)
name = name.strip('-')
result[name] = val
except IOError:
logger.exception('Error when reading postmaster.opts')
return result
def single_user_mode(self, command=None, options=None):
""" run a given command in a single-user mode. If the command is empty - then just start and stop """
cmd = ['postgres', '--single', '-D', self.data_dir]
for opt, val in sorted((options or {}).items()):
cmd.extend(['-c', '{0}={1}'.format(opt, val)])
# need a database name to connect
cmd.append('postgres')
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
if p:
if command:
p.communicate('{0}\n'.format(command))
p.stdin.close()
return p.wait()
return 1
def cleanup_archive_status(self):
status_dir = os.path.join(self.data_dir, 'pg_xlog', 'archive_status')
try:
for f in os.listdir(status_dir):
path = os.path.join(status_dir, f)
try:
if os.path.islink(path):
os.unlink(path)
elif os.path.isfile(path):
os.remove(path)
except OSError:
logger.exception("Unable to remove %s", path)
except OSError:
logger.exception("Unable to list %s", status_dir)
def follow(self, leader, recovery=False):
if self.check_recovery_conf(leader) and not recovery:
return True
change_role = self.role == 'master'
need_rewind = change_role and self.can_rewind
if need_rewind:
logger.info("set the rewind flag after demote")
self.write_recovery_conf(leader)
if leader and need_rewind: # we have a leader and need to rewind
if self.is_running():
self.stop()
# at present, pg_rewind only runs when the cluster is shut down cleanly
# and not shutdown in recovery. We have to remove the recovery.conf if present
# and start/shutdown in a single user mode to emulate this.
# XXX: if recovery.conf is linked, it will be written anew as a normal file.
if os.path.islink(self.recovery_conf):
os.unlink(self.recovery_conf)
else:
os.remove(self.recovery_conf)
# Archived segments might be useful to pg_rewind,
# clean the flags that tell we should remove them.
self.cleanup_archive_status()
# Start in a single user mode and stop to produce a clean shutdown
opts = self.read_postmaster_opts()
opts.update({'archive_mode': 'on', 'archive_command': 'false'})
self.single_user_mode(options=opts)
if self.rewind(leader):
ret = self.start()
else:
logger.error("unable to rewind the former master")
self.remove_data_directory()
ret = True
else: # do not rewind until the leader becomes available
ret = self.restart()
if change_role and ret:
self.call_nowait(ACTION_ON_ROLE_CHANGE)
return ret
def save_configuration_files(self):
"""
copy postgresql.conf to postgresql.conf.backup to be able to retrive configuration files
- originally stored as symlinks, those are normally skipped by pg_basebackup
- in case of WAL-E basebackup (see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239)
"""
try:
for f in self.configuration_to_save:
if os.path.isfile(f):
shutil.copy(f, f + '.backup')
except IOError:
logger.exception('unable to create backup copies of configuration files')
def restore_configuration_files(self):
""" restore a previously saved postgresql.conf """
try:
for f in self.configuration_to_save:
if not os.path.isfile(f) and os.path.isfile(f + '.backup'):
shutil.copy(f + '.backup', f)
except IOError:
logger.exception('unable to restore configuration files from backup')
def promote(self):
if self.role == 'master':
return True
ret = subprocess.call(self._pg_ctl + ['promote']) == 0
if ret:
self.set_role('master')
logger.info("cleared rewind flag after becoming the leader")
self.call_nowait(ACTION_ON_ROLE_CHANGE)
return ret
def create_or_update_role(self, name, password, options):
self.query("""DO $$
BEGIN
SET local synchronous_commit = 'local';
PERFORM * FROM pg_authid WHERE rolname = %s;
IF FOUND THEN
ALTER ROLE "{0}" WITH LOGIN {1} PASSWORD %s;
ELSE
CREATE ROLE "{0}" WITH LOGIN {1} PASSWORD %s;
END IF;
END;
$$""".format(name, options), name, password, password)
def create_replication_user(self):
self.create_or_update_role(self.replication['username'], self.replication['password'], 'REPLICATION')
def create_connection_user(self):
if self.admin:
self.create_or_update_role(self.admin['username'], self.admin['password'], 'CREATEDB CREATEROLE')
def xlog_position(self):
return self.query("""SELECT pg_xlog_location_diff(CASE WHEN pg_is_in_recovery()
THEN pg_last_xlog_replay_location()
ELSE pg_current_xlog_location()
END, '0/0')::bigint""").fetchone()[0]
def load_replication_slots(self):
if self.use_slots and self.schedule_load_slots:
cursor = self.query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'")
self.replication_slots = [r[0] for r in cursor]
self.schedule_load_slots = False
def sync_replication_slots(self, cluster):
if self.use_slots:
try:
self.load_replication_slots()
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
# the current master, because that member would replicate from elsewhere. We still create the slot if
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
# master), or if replicatefrom destination member happens to be the current master
if self.role == 'master':
slots = [m.name for m in cluster.members if m.name != self.name and
(m.replicatefrom is None or m.replicatefrom == self.name or
not cluster.has_member(m.replicatefrom))]
else:
# only manage slots for replicas that replicate from this one, except for the leader among them
slots = [m.name for m in cluster.members if m.replicatefrom == self.name and
m.name != cluster.leader.name]
# drop unused slots
for slot in set(self.replication_slots) - set(slots):
self.query("""SELECT pg_drop_replication_slot(%s)
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot)
# create new slots
for slot in set(slots) - set(self.replication_slots):
self.query("""SELECT pg_create_physical_replication_slot(%s)
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot)
self.replication_slots = slots
except psycopg2.Error:
logger.exception('Exception when changing replication slots')
def last_operation(self):
return str(self.xlog_position())
def bootstrap(self, cluster_initialized=False, clone_member=None):
"""
Populate PostgreSQL data directory by doing one of the following:
- create with initdb if there is no master.
- initialize the replica from an existing member (master or replica)
- initialize the replica using the replica creation method that
works without the replication connection (i.e. restore from on-disk
base backup)
The choice between the last 2 is triggered by the initialize flag.
We should never try to initdb an already initialized cluster, nor
try to bootstrap the cluster that lacks the initialize key using the
master-less replica creation method (in the latter case, there is
no clear inidicator of the moment we should abandon our attempts and
swich to initdb).
Failure during initdb always leads to an exception, since there is
no point in continuing if initdb fails. For the rest of the cases,
the function returns False in order to inidicate a failed attempt
that should be retried in the future.
"""
ret = False
if not (cluster_initialized or clone_member):
ret = self.initialize() and self.start()
if ret:
self.create_replication_user()
self.create_connection_user()
else:
raise PostgresException("Could not bootstrap master PostgreSQL")
else:
if self.sync_replica(clone_member):
self.restore_configuration_files()
self.write_recovery_conf(clone_member)
ret = self.start()
return ret
def move_data_directory(self):
if os.path.isdir(self.data_dir) and not self.is_running():
try:
new_name = '{0}_{1}'.format(self.data_dir, time.strftime('%Y-%m-%d-%H-%M-%S'))
logger.info('renaming data directory to %s', new_name)
os.rename(self.data_dir, new_name)
except OSError:
logger.exception("Could not rename data directory %s", self.data_dir)
def remove_data_directory(self):
logger.info('Removing data directory: %s', self.data_dir)
try:
if os.path.islink(self.data_dir):
os.unlink(self.data_dir)
elif not os.path.exists(self.data_dir):
return
elif os.path.isfile(self.data_dir):
os.remove(self.data_dir)
elif os.path.isdir(self.data_dir):
shutil.rmtree(self.data_dir)
except (IOError, OSError):
logger.exception('Could not remove data directory %s', self.data_dir)
self.move_data_directory()
def basebackup(self, clone_member, env):
# creates a replica data dir using pg_basebackup.
# this is the default, built-in create_replica_method
# tries twice, then returns failure (as 1)
# uses "stream" as the xlog-method to avoid sync issues
master_connection = clone_member.conn_url
maxfailures = 2
ret = 1
for bbfailures in range(0, maxfailures):
try:
ret = subprocess.call(['pg_basebackup', '--pgdata=' + self.data_dir,
'--xlog-method=stream', "--dbname=" + master_connection], env=env)
if ret == 0:
break
except Exception as e:
logger.error('Error when fetching backup with pg_basebackup: {0}'.format(e))
if bbfailures < maxfailures - 1:
logger.error('Trying again in 5 seconds')
time.sleep(5)
return ret
+883
View File
@@ -0,0 +1,883 @@
import logging
import os
import psycopg2
import shlex
import shutil
import subprocess
import time
from contextlib import contextmanager
from copy import deepcopy
from patroni.postgresql.callback_executor import CallbackExecutor
from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.config import ConfigHandler
from patroni.postgresql.connection import Connection, get_connection_cursor
from patroni.postgresql.misc import parse_history, postgres_major_version_to_int
from patroni.postgresql.postmaster import PostmasterProcess
from patroni.postgresql.slots import SlotsHandler
from patroni.exceptions import PostgresConnectionException
from patroni.utils import Retry, RetryFailedError, polling_loop
from threading import current_thread, Lock
logger = logging.getLogger(__name__)
ACTION_ON_START = "on_start"
ACTION_ON_STOP = "on_stop"
ACTION_ON_RESTART = "on_restart"
ACTION_ON_RELOAD = "on_reload"
ACTION_ON_ROLE_CHANGE = "on_role_change"
ACTION_NOOP = "noop"
STATE_RUNNING = 'running'
STATE_REJECT = 'rejecting connections'
STATE_NO_RESPONSE = 'not responding'
STATE_UNKNOWN = 'unknown'
STOP_POLLING_INTERVAL = 1
@contextmanager
def null_context():
yield
class Postgresql(object):
def __init__(self, config):
self.name = config['name']
self.scope = config['scope']
self._data_dir = config['data_dir']
self._database = config.get('database', 'postgres')
self._version_file = os.path.join(self._data_dir, 'PG_VERSION')
self._pg_control = os.path.join(self._data_dir, 'global', 'pg_control')
self._major_version = self.get_major_version()
self._state_lock = Lock()
self.set_state('stopped')
self._pending_restart = False
self._connection = Connection()
self.config = ConfigHandler(self, config)
self._bin_dir = config.get('bin_dir') or ''
self.bootstrap = Bootstrap(self)
self.bootstrapping = False
self.__thread_ident = current_thread().ident
self.slots_handler = SlotsHandler(self)
self._callback_executor = CallbackExecutor()
self.__cb_called = False
self.__cb_pending = None
self.cancellable = CancellableSubprocess()
self._sysid = None
self.retry = Retry(max_tries=-1, deadline=config['retry_timeout']/2.0, max_delay=1,
retry_exceptions=PostgresConnectionException)
# Retry 'pg_is_in_recovery()' only once
self._is_leader_retry = Retry(max_tries=1, deadline=config['retry_timeout']/2.0, max_delay=1,
retry_exceptions=PostgresConnectionException)
self._role_lock = Lock()
self.set_role(self.get_postgres_role_from_data_directory())
self._state_entry_timestamp = None
self._cluster_info_state = {}
self._cached_replica_timeline = None
# Last known running process
self._postmaster_proc = None
if self.is_running():
self.set_state('running')
self.set_role('master' if self.is_leader() else 'replica')
self.config.write_postgresql_conf() # we are "joining" already running postgres
hba_saved = self.config.replace_pg_hba()
ident_saved = self.config.replace_pg_ident()
if hba_saved or ident_saved:
self.reload()
elif self.role == 'master':
self.set_role('demoted')
@property
def create_replica_methods(self):
return self.config.get('create_replica_methods', []) or self.config.get('create_replica_method', [])
@property
def major_version(self):
return self._major_version
@property
def database(self):
return self._database
@property
def data_dir(self):
return self._data_dir
@property
def callback(self):
return self.config.get('callbacks') or {}
@property
def wal_name(self):
return 'wal' if self._major_version >= 100000 else 'xlog'
@property
def lsn_name(self):
return 'lsn' if self._major_version >= 100000 else 'location'
@property
def cluster_info_query(self):
return ("SELECT CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
"ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, "
"CASE WHEN pg_catalog.pg_is_in_recovery() THEN GREATEST("
" pg_catalog.pg_{0}_{1}_diff(COALESCE("
"pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint,"
" pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint)"
"ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint "
"END").format(self.wal_name, self.lsn_name)
def _version_file_exists(self):
return not self.data_directory_empty() and os.path.isfile(self._version_file)
def get_major_version(self):
if self._version_file_exists():
try:
with open(self._version_file) as f:
return postgres_major_version_to_int(f.read().strip())
except Exception:
logger.exception('Failed to read PG_VERSION from %s', self._data_dir)
return 0
def pgcommand(self, cmd):
"""Returns path to the specified PostgreSQL command"""
return os.path.join(self._bin_dir, cmd)
def pg_ctl(self, cmd, *args, **kwargs):
"""Builds and executes pg_ctl command
:returns: `!True` when return_code == 0, otherwise `!False`"""
pg_ctl = [self.pgcommand('pg_ctl'), cmd]
return subprocess.call(pg_ctl + ['-D', self._data_dir] + list(args), **kwargs) == 0
def pg_isready(self):
"""Runs pg_isready to see if PostgreSQL is accepting connections.
:returns: 'ok' if PostgreSQL is up, 'reject' if starting up, 'no_resopnse' if not up."""
r = self.config.local_connect_kwargs
cmd = [self.pgcommand('pg_isready'), '-p', r['port'], '-d', self._database]
# Host is not set if we are connecting via default unix socket
if 'host' in r:
cmd.extend(['-h', r['host']])
# We only need the username because pg_isready does not try to authenticate
if 'user' in r:
cmd.extend(['-U', r['user']])
ret = subprocess.call(cmd)
return_codes = {0: STATE_RUNNING,
1: STATE_REJECT,
2: STATE_NO_RESPONSE,
3: STATE_UNKNOWN}
return return_codes.get(ret, STATE_UNKNOWN)
def reload_config(self, config, sighup=False):
self.config.reload_config(config, sighup)
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout']/2.0
@property
def pending_restart(self):
return self._pending_restart
def set_pending_restart(self, value):
self._pending_restart = value
@property
def sysid(self):
if not self._sysid and not self.bootstrapping:
data = self.controldata()
self._sysid = data.get('Database system identifier', "")
return self._sysid
def get_postgres_role_from_data_directory(self):
if self.data_directory_empty() or not self.controldata():
return 'uninitialized'
elif self.config.recovery_conf_exists():
return 'replica'
else:
return 'master'
@property
def server_version(self):
return self._connection.server_version
def connection(self):
return self._connection.get()
def set_connection_kwargs(self, kwargs):
self._connection.set_conn_kwargs(kwargs)
def _query(self, sql, *params):
"""We are always using the same cursor, therefore this method is not thread-safe!!!
You can call it from different threads only if you are holding explicit `AsyncExecutor` lock,
because the main thread is always holding this lock when running HA cycle."""
cursor = None
try:
cursor = self._connection.cursor()
cursor.execute(sql, params)
return cursor
except psycopg2.Error as e:
if cursor and cursor.connection.closed == 0:
# When connected via unix socket, psycopg2 can't recoginze 'connection lost'
# and leaves `_cursor_holder.connection.closed == 0`, but psycopg2.OperationalError
# is still raised (what is correct). It doesn't make sense to continiue with existing
# connection and we will close it, to avoid its reuse by the `cursor` method.
if isinstance(e, psycopg2.OperationalError):
self._connection.close()
else:
raise e
if self.state == 'restarting':
raise RetryFailedError('cluster is being restarted')
raise PostgresConnectionException('connection problems')
def query(self, sql, *args, **kwargs):
if not kwargs.get('retry', True):
return self._query(sql, *args)
try:
return self.retry(self._query, sql, *args)
except RetryFailedError as e:
raise PostgresConnectionException(str(e))
def pg_control_exists(self):
return os.path.isfile(self._pg_control)
def data_directory_empty(self):
if self.pg_control_exists():
return False
if not os.path.exists(self._data_dir):
return True
return all(os.name != 'nt' and (n.startswith('.') or n == 'lost+found') for n in os.listdir(self._data_dir))
def replica_method_options(self, method):
return deepcopy(self.config.get(method, {}))
def replica_method_can_work_without_replication_connection(self, method):
return method != 'basebackup' and self.replica_method_options(method).get('no_master')
def can_create_replica_without_replication_connection(self, replica_methods=None):
""" go through the replication methods to see if there are ones
that does not require a working replication connection.
"""
if replica_methods is None:
replica_methods = self.create_replica_methods
return any(self.replica_method_can_work_without_replication_connection(m) for m in replica_methods)
def reset_cluster_info_state(self):
self._cluster_info_state = {}
def _cluster_info_state_get(self, name):
if not self._cluster_info_state:
try:
result = self._is_leader_retry(self._query, self.cluster_info_query).fetchone()
self._cluster_info_state = dict(zip(['timeline', 'wal_position'], result))
except RetryFailedError as e: # SELECT failed two times
self._cluster_info_state = {'error': str(e)}
if not self.is_starting() and self.pg_isready() == STATE_REJECT:
self.set_state('starting')
if 'error' in self._cluster_info_state:
raise PostgresConnectionException(self._cluster_info_state['error'])
return self._cluster_info_state.get(name)
def is_leader(self):
return bool(self._cluster_info_state_get('timeline'))
def is_running(self):
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
is running updates the cached process based on pid file."""
if self._postmaster_proc:
if self._postmaster_proc.is_running():
return self._postmaster_proc
self._postmaster_proc = None
# we noticed that postgres was restarted, force syncing of replication
self.slots_handler.schedule()
self._postmaster_proc = PostmasterProcess.from_pidfile(self._data_dir)
return self._postmaster_proc
@property
def cb_called(self):
return self.__cb_called
def call_nowait(self, cb_name):
""" pick a callback command and call it without waiting for it to finish """
if self.bootstrapping:
return
if cb_name in (ACTION_ON_START, ACTION_ON_STOP, ACTION_ON_RESTART, ACTION_ON_ROLE_CHANGE):
self.__cb_called = True
if self.callback and cb_name in self.callback:
cmd = self.callback[cb_name]
try:
cmd = shlex.split(self.callback[cb_name]) + [cb_name, self.role, self.scope]
self._callback_executor.call(cmd)
except Exception:
logger.exception('callback %s %s %s %s failed', cmd, cb_name, self.role, self.scope)
@property
def role(self):
with self._role_lock:
return self._role
def set_role(self, value):
with self._role_lock:
self._role = value
@property
def state(self):
with self._state_lock:
return self._state
def set_state(self, value):
with self._state_lock:
self._state = value
self._state_entry_timestamp = time.time()
def time_in_state(self):
return time.time() - self._state_entry_timestamp
def is_starting(self):
return self.state == 'starting'
def wait_for_port_open(self, postmaster, timeout):
"""Waits until PostgreSQL opens ports."""
for _ in polling_loop(timeout):
if self.cancellable.is_cancelled:
return False
if not postmaster.is_running():
logger.error('postmaster is not running')
self.set_state('start failed')
return False
isready = self.pg_isready()
if isready != STATE_NO_RESPONSE:
if isready not in [STATE_REJECT, STATE_RUNNING]:
logger.warning("Can't determine PostgreSQL startup status, assuming running")
return True
logger.warning("Timed out waiting for PostgreSQL to start")
return False
def start(self, timeout=None, task=None, block_callbacks=False, role=None):
"""Start PostgreSQL
Waits for postmaster to open ports or terminate so pg_isready can be used to check startup completion
or failure.
:returns: True if start was initiated and postmaster ports are open, False if start failed"""
# make sure we close all connections established against
# the former node, otherwise, we might get a stalled one
# after kill -9, which would report incorrect data to
# patroni.
self._connection.close()
if self.is_running():
logger.error('Cannot start PostgreSQL because one is already running.')
self.set_state('starting')
return True
if not block_callbacks:
self.__cb_pending = ACTION_ON_START
self.set_role(role or self.get_postgres_role_from_data_directory())
self.set_state('starting')
self._pending_restart = False
configuration = self.config.effective_configuration
self.config.write_postgresql_conf(configuration)
self.config.resolve_connection_addresses()
self.config.replace_pg_hba()
self.config.replace_pg_ident()
options = ['--{0}={1}'.format(p, configuration[p]) for p in self.config.CMDLINE_OPTIONS
if p in configuration and p != 'wal_keep_segments']
if self.cancellable.is_cancelled:
return False
with task or null_context():
if task and task.is_cancelled:
logger.info("PostgreSQL start cancelled.")
return False
self._postmaster_proc = PostmasterProcess.start(self.pgcommand('postgres'),
self._data_dir,
self.config.postgresql_conf,
options)
if task:
task.complete(self._postmaster_proc)
start_timeout = timeout
if not start_timeout:
try:
start_timeout = float(self.config.get('pg_ctl_timeout', 60))
except ValueError:
start_timeout = 60
# We want postmaster to open ports before we continue
if not self._postmaster_proc or not self.wait_for_port_open(self._postmaster_proc, start_timeout):
return False
ret = self.wait_for_startup(start_timeout)
if ret is not None:
return ret
elif timeout is not None:
return False
else:
return None
def checkpoint(self, connect_kwargs=None):
check_not_is_in_recovery = connect_kwargs is not None
connect_kwargs = connect_kwargs or self.config.local_connect_kwargs
for p in ['connect_timeout', 'options']:
connect_kwargs.pop(p, None)
try:
with get_connection_cursor(**connect_kwargs) as cur:
cur.execute("SET statement_timeout = 0")
if check_not_is_in_recovery:
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
if cur.fetchone()[0]:
return 'is_in_recovery=true'
return cur.execute('CHECKPOINT')
except psycopg2.Error:
logger.exception('Exception during CHECKPOINT')
return 'not accessible or not healty'
def stop(self, mode='fast', block_callbacks=False, checkpoint=None, on_safepoint=None):
"""Stop PostgreSQL
Supports a callback when a safepoint is reached. A safepoint is when no user backend can return a successful
commit to users. Currently this means we wait for user backends to close. But in the future alternate mechanisms
could be added.
:param on_safepoint: This callback is called when no user backends are running.
"""
if checkpoint is None:
checkpoint = False if mode == 'immediate' else True
success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint)
if success:
# block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones
if not block_callbacks:
self.set_state('stopped')
if pg_signaled:
self.call_nowait(ACTION_ON_STOP)
else:
logger.warning('pg_ctl stop failed')
self.set_state('stop failed')
return success
def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint):
postmaster = self.is_running()
if not postmaster:
if on_safepoint:
on_safepoint()
return True, False
if checkpoint and not self.is_starting():
self.checkpoint()
if not block_callbacks:
self.set_state('stopping')
# Send signal to postmaster to stop
success = postmaster.signal_stop(mode)
if success is not None:
if success and on_safepoint:
on_safepoint()
return success, True
# We can skip safepoint detection if we don't have a callback
if on_safepoint:
# Wait for our connection to terminate so we can be sure that no new connections are being initiated
self._wait_for_connection_close(postmaster)
postmaster.wait_for_user_backends_to_close()
on_safepoint()
postmaster.wait()
return True, True
@staticmethod
def terminate_starting_postmaster(postmaster):
"""Terminates a postmaster that has not yet opened ports or possibly even written a pid file. Blocks
until the process goes away."""
postmaster.signal_stop('immediate')
postmaster.wait()
def _wait_for_connection_close(self, postmaster):
try:
with self.connection().cursor() as cur:
while postmaster.is_running(): # Need a timeout here?
cur.execute("SELECT 1")
time.sleep(STOP_POLLING_INTERVAL)
except psycopg2.Error:
pass
def reload(self):
ret = self.pg_ctl('reload')
if ret:
self.call_nowait(ACTION_ON_RELOAD)
return ret
def check_for_startup(self):
"""Checks PostgreSQL status and returns if PostgreSQL is in the middle of startup."""
return self.is_starting() and not self.check_startup_state_changed()
def check_startup_state_changed(self):
"""Checks if PostgreSQL has completed starting up or failed or still starting.
Should only be called when state == 'starting'
:returns: True if state was changed from 'starting'
"""
ready = self.pg_isready()
if ready == STATE_REJECT:
return False
elif ready == STATE_NO_RESPONSE:
ret = not self.is_running()
if ret:
self.set_state('start failed')
self.slots_handler.schedule(False) # TODO: can remove this?
self.config.save_configuration_files(True) # TODO: maybe remove this?
return ret
else:
if ready != STATE_RUNNING:
# Bad configuration or unexpected OS error. No idea of PostgreSQL status.
# Let the main loop of run cycle clean up the mess.
logger.warning("%s status returned from pg_isready",
"Unknown" if ready == STATE_UNKNOWN else "Invalid")
self.set_state('running')
self.slots_handler.schedule()
self.config.save_configuration_files(True)
# TODO: __cb_pending can be None here after PostgreSQL restarts on its own. Do we want to call the callback?
# Previously we didn't even notice.
action = self.__cb_pending or ACTION_ON_START
self.call_nowait(action)
self.__cb_pending = None
return True
def wait_for_startup(self, timeout=None):
"""Waits for PostgreSQL startup to complete or fail.
:returns: True if start was successful, False otherwise"""
if not self.is_starting():
# Should not happen
logger.warning("wait_for_startup() called when not in starting state")
while not self.check_startup_state_changed():
if self.cancellable.is_cancelled or timeout and self.time_in_state() > timeout:
return None
time.sleep(1)
return self.state == 'running'
def restart(self, timeout=None, task=None, block_callbacks=False, role=None):
"""Restarts PostgreSQL.
When timeout parameter is set the call will block either until PostgreSQL has started, failed to start or
timeout arrives.
:returns: True when restart was successful and timeout did not expire when waiting.
"""
self.set_state('restarting')
if not block_callbacks:
self.__cb_pending = ACTION_ON_RESTART
ret = self.stop(block_callbacks=True) and self.start(timeout, task, True, role)
if not ret and not self.is_starting():
self.set_state('restart failed ({0})'.format(self.state))
return ret
def is_healthy(self):
if not self.is_running():
logger.warning('Postgresql is not running.')
return False
return True
def controldata(self):
""" return the contents of pg_controldata, or non-True value if pg_controldata call failed """
result = {}
# Don't try to call pg_controldata during backup restore
if self._version_file_exists() and self.state != 'creating replica':
try:
env = os.environ.copy()
env.update(LANG='C', LC_ALL='C')
data = subprocess.check_output([self.pgcommand('pg_controldata'), self._data_dir], env=env)
if data:
data = data.decode('utf-8').splitlines()
# pg_controldata output depends on major verion. Some of parameters are prefixed by 'Current '
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':', 1)[1].strip() for l in data
if l and ':' in l}
except subprocess.CalledProcessError:
logger.exception("Error when calling pg_controldata")
return result
@contextmanager
def get_replication_connection_cursor(self, host='localhost', port=5432, database=None, **kwargs):
conn_kwargs = self.config.replication.copy()
conn_kwargs.update(host=host, port=int(port), database=database or self._database, connect_timeout=3,
user=conn_kwargs.pop('username'), replication=1, options='-c statement_timeout=2000')
with get_connection_cursor(**conn_kwargs) as cur:
yield cur
def get_local_timeline_lsn_from_replication_connection(self):
timeline = lsn = None
try:
with self.get_replication_connection_cursor(**self.config.local_replication_address) as cur:
cur.execute('IDENTIFY_SYSTEM')
timeline, lsn = cur.fetchone()[1:3]
except Exception:
logger.exception('Can not fetch local timeline and lsn from replication connection')
return timeline, lsn
def get_replica_timeline(self):
return self.get_local_timeline_lsn_from_replication_connection()[0]
def replica_cached_timeline(self, master_timeline):
if not self._cached_replica_timeline or not master_timeline or self._cached_replica_timeline != master_timeline:
self._cached_replica_timeline = self.get_replica_timeline()
return self._cached_replica_timeline
def get_master_timeline(self):
return self._cluster_info_state_get('timeline')
def get_history(self, timeline):
history_path = 'pg_{0}/{1:08X}.history'.format(self.wal_name, timeline)
try:
cursor = self._connection.cursor()
cursor.execute('SELECT isdir, modification FROM pg_catalog.pg_stat_file(%s)', (history_path,))
isdir, modification = cursor.fetchone()
if not isdir:
cursor.execute('SELECT pg_catalog.pg_read_file(%s)', (history_path,))
history = list(parse_history(cursor.fetchone()[0]))
if history[-1][0] == timeline - 1:
history[-1].append(modification.isoformat())
return history
except Exception:
logger.exception('Failed to read and parse %s', (history_path,))
def follow(self, member, role='replica', timeout=None, do_reload=False):
recovery_params = self.config.build_recovery_params(member)
self.config.write_recovery_conf(recovery_params)
# When we demoting the master or standby_leader to replica or promoting replica to a standby_leader
# and we know for sure that postgres was already running before, we will only execute on_role_change
# callback and prevent execution of on_restart/on_start callback.
# If the role remains the same (replica or standby_leader), we will execute on_start or on_restart
change_role = self.cb_called and (self.role in ('master', 'demoted') or
not {'standby_leader', 'replica'} - {self.role, role})
if change_role:
self.__cb_pending = ACTION_NOOP
if self.is_running():
if do_reload:
self.config.write_postgresql_conf()
self.reload()
else:
self.restart(block_callbacks=change_role, role=role)
else:
self.start(timeout=timeout, block_callbacks=change_role, role=role)
if change_role:
# TODO: postpone this until start completes, or maybe do even earlier
self.call_nowait(ACTION_ON_ROLE_CHANGE)
return True
def _wait_promote(self, wait_seconds):
for _ in polling_loop(wait_seconds):
data = self.controldata()
if data.get('Database cluster state') == 'in production':
return True
def promote(self, wait_seconds, on_success=None, access_is_restricted=False):
if self.role == 'master':
return True
ret = self.pg_ctl('promote', '-W')
if ret:
self.set_role('master')
if on_success is not None:
on_success()
if not access_is_restricted:
self.call_nowait(ACTION_ON_ROLE_CHANGE)
ret = self._wait_promote(wait_seconds)
return ret
def timeline_wal_position(self):
# This method could be called from different threads (simultaneously with some other `_query` calls).
# If it is called not from main thread we will create a new cursor to execute statement.
if current_thread().ident == self.__thread_ident:
return self._cluster_info_state_get('timeline'), self._cluster_info_state_get('wal_position')
with self.connection().cursor() as cursor:
cursor.execute(self.cluster_info_query)
return cursor.fetchone()[:2]
def postmaster_start_time(self):
try:
query = "SELECT pg_catalog.to_char(pg_catalog.pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ')"
if current_thread().ident == self.__thread_ident:
return self.query(query).fetchone()[0]
with self.connection().cursor() as cursor:
cursor.execute(query)
return cursor.fetchone()[0]
except psycopg2.Error:
return None
def last_operation(self):
return str(self._cluster_info_state_get('wal_position'))
def configure_server_parameters(self):
self._major_version = self.get_major_version()
self.config.setup_server_parameters()
return True
def move_data_directory(self):
if os.path.isdir(self._data_dir) and not self.is_running():
try:
new_name = '{0}_{1}'.format(self._data_dir, time.strftime('%Y-%m-%d-%H-%M-%S'))
logger.info('renaming data directory to %s', new_name)
os.rename(self._data_dir, new_name)
except OSError:
logger.exception("Could not rename data directory %s", self._data_dir)
def remove_data_directory(self):
self.set_role('uninitialized')
logger.info('Removing data directory: %s', self._data_dir)
try:
if os.path.islink(self._data_dir):
os.unlink(self._data_dir)
elif not os.path.exists(self._data_dir):
return
elif os.path.isfile(self._data_dir):
os.remove(self._data_dir)
elif os.path.isdir(self._data_dir):
# let's see if pg_xlog|pg_wal is a symlink, in this case we
# should clean the target
for pg_wal_dir in ('pg_xlog', 'pg_wal'):
pg_wal_path = os.path.join(self._data_dir, pg_wal_dir)
if os.path.exists(pg_wal_path) and os.path.islink(pg_wal_path):
pg_wal_realpath = os.path.realpath(pg_wal_path)
logger.info('Removing WAL directory: %s', pg_wal_realpath)
shutil.rmtree(pg_wal_realpath)
shutil.rmtree(self._data_dir)
except (IOError, OSError):
logger.exception('Could not remove data directory %s', self._data_dir)
self.move_data_directory()
def pick_synchronous_standby(self, cluster):
"""Finds the best candidate to be the synchronous standby.
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
synchronous standby any longer.
:returns tuple of candidate name or None, and bool showing if the member is the active synchronous standby.
"""
current = cluster.sync.sync_standby
current = current.lower() if current else current
members = {m.name.lower(): m for m in cluster.members}
candidates = []
# Pick candidates based on who has flushed WAL farthest.
# TODO: for synchronous_commit = remote_write we actually want to order on write_location
for app_name, state, sync_state in self.query(
"SELECT pg_catalog.lower(application_name), state, sync_state"
" FROM pg_catalog.pg_stat_replication"
" ORDER BY flush_{0} DESC".format(self.lsn_name)):
member = members.get(app_name)
if state != 'streaming' or not member or member.tags.get('nosync', False):
continue
if sync_state == 'sync':
return app_name, True
if sync_state == 'potential' and app_name == current:
# Prefer current even if not the best one any more to avoid indecisivness and spurious swaps.
return cluster.sync.sync_standby, False
if sync_state in ('async', 'potential'):
candidates.append(member.name)
if candidates:
return candidates[0], False
return None, False
def read_postmaster_opts(self):
"""returns the list of option names/values from postgres.opts, Empty dict if read failed or no file"""
result = {}
try:
with open(os.path.join(self._data_dir, 'postmaster.opts')) as f:
data = f.read()
for opt in data.split('" "'):
if '=' in opt and opt.startswith('--'):
name, val = opt.split('=', 1)
result[name.strip('-')] = val.rstrip('"\n')
except IOError:
logger.exception('Error when reading postmaster.opts')
return result
def single_user_mode(self, command=None, options=None):
"""run a given command in a single-user mode. If the command is empty - then just start and stop"""
cmd = [self.pgcommand('postgres'), '--single', '-D', self._data_dir]
for opt, val in sorted((options or {}).items()):
cmd.extend(['-c', '{0}={1}'.format(opt, val)])
# need a database name to connect
cmd.append(self._database)
return self.cancellable.call(cmd, communicate_input=command)
def cleanup_archive_status(self):
status_dir = os.path.join(self._data_dir, 'pg_' + self.wal_name, 'archive_status')
try:
for f in os.listdir(status_dir):
path = os.path.join(status_dir, f)
try:
if os.path.islink(path):
os.unlink(path)
elif os.path.isfile(path):
os.remove(path)
except OSError:
logger.exception('Unable to remove %s', path)
except OSError:
logger.exception('Unable to list %s', status_dir)
def fix_cluster_state(self):
self.cleanup_archive_status()
# Start in a single user mode and stop to produce a clean shutdown
opts = self.read_postmaster_opts()
opts.update({'archive_mode': 'on', 'archive_command': 'false'})
self.config.remove_recovery_conf()
return self.single_user_mode(options=opts) == 0 or None
def schedule_sanity_checks_after_pause(self):
"""
After coming out of pause we have to:
1. sync replication slots, because it might happen that slots were removed
2. get new 'Database system identifier' to make sure that it wasn't changed
"""
self.slots_handler.schedule()
self._sysid = None
+370
View File
@@ -0,0 +1,370 @@
import logging
import os
import shlex
import tempfile
import time
from patroni.dcs import RemoteMember
from patroni.utils import deep_compare
from six import string_types
logger = logging.getLogger(__name__)
class Bootstrap(object):
def __init__(self, postgresql):
self._postgresql = postgresql
self._running_custom_bootstrap = False
@property
def running_custom_bootstrap(self):
return self._running_custom_bootstrap
@property
def keep_existing_recovery_conf(self):
return self._running_custom_bootstrap and self._keep_existing_recovery_conf
@staticmethod
def process_user_options(tool, options, not_allowed_options, error_handler):
user_options = []
def option_is_allowed(name):
ret = name not in not_allowed_options
if not ret:
error_handler('{0} option for {1} is not allowed'.format(name, tool))
return ret
if isinstance(options, dict):
for k, v in options.items():
if k and v:
user_options.append('--{0}={1}'.format(k, v))
elif isinstance(options, list):
for opt in options:
if isinstance(opt, string_types) and option_is_allowed(opt):
user_options.append('--{0}'.format(opt))
elif isinstance(opt, dict):
keys = list(opt.keys())
if len(keys) != 1 or not isinstance(opt[keys[0]], string_types) or not option_is_allowed(keys[0]):
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
' and value should be a string'.format(tool, opt[keys[0]]))
user_options.append('--{0}={1}'.format(keys[0], opt[keys[0]]))
else:
error_handler('Error when parsing {0} option {1}: value should be string value'
' or a single key-value pair'.format(tool, opt))
else:
error_handler('{0} options must be list ot dict'.format(tool))
return user_options
def _initdb(self, config):
self._postgresql.set_state('initalizing new cluster')
not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version')
def error_handler(e):
raise Exception(e)
options = self.process_user_options('initdb', config or [], not_allowed_options, error_handler)
pwfile = None
if self._postgresql.config.superuser:
if 'username' in self._postgresql.config.superuser:
options.append('--username={0}'.format(self._postgresql.config.superuser['username']))
if 'password' in self._postgresql.config.superuser:
(fd, pwfile) = tempfile.mkstemp()
os.write(fd, self._postgresql.config.superuser['password'].encode('utf-8'))
os.close(fd)
options.append('--pwfile={0}'.format(pwfile))
options = ['-o', ' '.join(options)] if options else []
ret = self._postgresql.pg_ctl('initdb', *options)
if pwfile:
os.remove(pwfile)
if ret:
self._postgresql.configure_server_parameters()
else:
self._postgresql.set_state('initdb failed')
return ret
def _post_restore(self):
self._postgresql.config.restore_configuration_files()
self._postgresql.configure_server_parameters()
# make sure there is no trigger file or postgres will be automatically promoted
trigger_file = 'promote_trigger_file' if self._postgresql.major_version >= 120000 else 'trigger_file'
trigger_file = self._postgresql.config.get('recovery_conf', {}).get(trigger_file) or 'promote'
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
if os.path.exists(trigger_file):
os.unlink(trigger_file)
def _custom_bootstrap(self, config):
self._postgresql.set_state('running custom bootstrap script')
params = ['--scope=' + self._postgresql.scope, '--datadir=' + self._postgresql.data_dir]
try:
logger.info('Running custom bootstrap script: %s', config['command'])
if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0:
self._postgresql.set_state('custom bootstrap failed')
return False
except Exception:
logger.exception('Exception during custom bootstrap')
return False
self._post_restore()
if 'recovery_conf' in config:
self._postgresql.config.write_recovery_conf(config['recovery_conf'])
elif not self.keep_existing_recovery_conf:
self._postgresql.config.remove_recovery_conf()
return True
def call_post_bootstrap(self, config):
"""
runs a script after initdb or custom bootstrap script is called and waits until completion.
"""
cmd = config.get('post_bootstrap') or config.get('post_init')
if cmd:
r = self._postgresql.config.local_connect_kwargs
connstring = self._postgresql.config.format_dsn(r, True)
if 'host' not in r:
# https://www.postgresql.org/docs/current/static/libpq-pgpass.html
# A host name of localhost matches both TCP (host name localhost) and Unix domain socket
# (pghost empty or the default socket directory) connections coming from the local machine.
r['host'] = 'localhost' # set it to localhost to write into pgpass
env = self._postgresql.config.write_pgpass(r) if 'password' in r else None
try:
ret = self._postgresql.cancellable.call(shlex.split(cmd) + [connstring], env=env)
except OSError:
logger.error('post_init script %s failed', cmd)
return False
if ret != 0:
logger.error('post_init script %s returned non-zero code %d', cmd, ret)
return False
return True
def create_replica(self, clone_member):
"""
create the replica according to the replica_method
defined by the user. this is a list, so we need to
loop through all methods the user supplies
"""
self._postgresql.set_state('creating replica')
self._postgresql.schedule_sanity_checks_after_pause()
is_remote_master = isinstance(clone_member, RemoteMember)
# get list of replica methods either from clone member or from
# the config. If there is no configuration key, or no value is
# specified, use basebackup
replica_methods = (clone_member.create_replica_methods if is_remote_master
else self._postgresql.create_replica_methods) or ['basebackup']
if clone_member and clone_member.conn_url:
r = clone_member.conn_kwargs(self._postgresql.config.replication)
# add the credentials to connect to the replica origin to pgpass.
env = self._postgresql.config.write_pgpass(r)
connstring = self._postgresql.config.format_dsn(r, True)
else:
connstring = ''
env = os.environ.copy()
# if we don't have any source, leave only replica methods that work without it
replica_methods = [r for r in replica_methods
if self._postgresql.replica_method_can_work_without_replication_connection(r)]
# go through them in priority order
ret = 1
for replica_method in replica_methods:
if self._postgresql.cancellable.is_cancelled:
break
method_config = self._postgresql.replica_method_options(replica_method)
# if the method is basebackup, then use the built-in
if replica_method == "basebackup":
ret = self.basebackup(connstring, env, method_config)
if ret == 0:
logger.info("replica has been created using basebackup")
# if basebackup succeeds, exit with success
break
else:
if not self._postgresql.data_directory_empty():
if method_config.get('keep_data', False):
logger.info('Leaving data directory uncleaned')
else:
self._postgresql.remove_data_directory()
cmd = replica_method
# user-defined method; check for configuration
# not required, actually
if method_config:
# look to see if the user has supplied a full command path
# if not, use the method name as the command
cmd = method_config.pop('command', cmd)
# add the default parameters
if not method_config.get('no_params', False):
method_config.update({"scope": self._postgresql.scope,
"role": "replica",
"datadir": self._postgresql.data_dir,
"connstring": connstring})
else:
for param in ('no_params', 'no_master', 'keep_data'):
method_config.pop(param, None)
params = ["--{0}={1}".format(arg, val) for arg, val in method_config.items()]
try:
# call script with the full set of parameters
ret = self._postgresql.cancellable.call(shlex.split(cmd) + params, env=env)
# if we succeeded, stop
if ret == 0:
logger.info('replica has been created using %s', replica_method)
break
else:
logger.error('Error creating replica using method %s: %s exited with code=%s',
replica_method, cmd, ret)
except Exception:
logger.exception('Error creating replica using method %s', replica_method)
ret = 1
self._postgresql.set_state('stopped')
return ret
def basebackup(self, conn_url, env, options):
# creates a replica data dir using pg_basebackup.
# this is the default, built-in create_replica_methods
# tries twice, then returns failure (as 1)
# uses "stream" as the xlog-method to avoid sync issues
# supports additional user-supplied options, those are not validated
maxfailures = 2
ret = 1
not_allowed_options = ('pgdata', 'format', 'wal-method', 'xlog-method', 'gzip',
'version', 'compress', 'dbname', 'host', 'port', 'username', 'password')
user_options = self.process_user_options('basebackup', options, not_allowed_options, logger.error)
for bbfailures in range(0, maxfailures):
if self._postgresql.cancellable.is_cancelled:
break
if not self._postgresql.data_directory_empty():
self._postgresql.remove_data_directory()
try:
ret = self._postgresql.cancellable.call([self._postgresql.pgcommand('pg_basebackup'),
'--pgdata=' + self._postgresql.data_dir, '-X', 'stream',
'--dbname=' + conn_url] + user_options, env=env)
if ret == 0:
break
else:
logger.error('Error when fetching backup: pg_basebackup exited with code=%s', ret)
except Exception as e:
logger.error('Error when fetching backup with pg_basebackup: %s', e)
if bbfailures < maxfailures - 1:
logger.warning('Trying again in 5 seconds')
time.sleep(5)
return ret
def clone(self, clone_member):
"""
- initialize the replica from an existing member (master or replica)
- initialize the replica using the replica creation method that
works without the replication connection (i.e. restore from on-disk
base backup)
"""
ret = self.create_replica(clone_member) == 0
if ret:
self._post_restore()
return ret
def bootstrap(self, config):
""" Initialize a new node from scratch and start it. """
pg_hba = config.get('pg_hba', [])
method = config.get('method') or 'initdb'
if method != 'initdb' and method in config and 'command' in config[method]:
self._keep_existing_recovery_conf = config[method].get('keep_existing_recovery_conf')
self._running_custom_bootstrap = True
do_initialize = self._custom_bootstrap
else:
method = 'initdb'
do_initialize = self._initdb
return do_initialize(config.get(method)) and self._postgresql.config.append_pg_hba(pg_hba) \
and self._postgresql.config.save_configuration_files() and self._postgresql.start()
def create_or_update_role(self, name, password, options):
options = list(map(str.upper, options))
if 'NOLOGIN' not in options and 'LOGIN' not in options:
options.append('LOGIN')
params = [name]
if password:
options.extend(['PASSWORD', '%s'])
params.extend([password, password])
sql = """DO $$
BEGIN
SET local synchronous_commit = 'local';
PERFORM * FROM pg_authid WHERE rolname = %s;
IF FOUND THEN
ALTER ROLE "{0}" WITH {1};
ELSE
CREATE ROLE "{0}" WITH {1};
END IF;
END;$$""".format(name, ' '.join(options))
self._postgresql.query(sql, *params)
def post_bootstrap(self, config, task):
try:
postgresql = self._postgresql
superuser = postgresql.config.superuser
if 'username' in superuser and 'password' in superuser:
self.create_or_update_role(superuser['username'], superuser['password'], ['SUPERUSER'])
task.complete(self.call_post_bootstrap(config))
if task.result:
replication = postgresql.config.replication
self.create_or_update_role(replication['username'], replication.get('password'), ['REPLICATION'])
rewind = postgresql.config.rewind_credentials
if not deep_compare(rewind, superuser):
self.create_or_update_role(rewind['username'], rewind.get('password'), [])
for f in ('pg_ls_dir(text, boolean, boolean)', 'pg_stat_file(text, boolean)',
'pg_read_binary_file(text)', 'pg_read_binary_file(text, bigint, bigint, boolean)'):
sql = """DO $$
BEGIN
SET local synchronous_commit = 'local';
GRANT EXECUTE ON function pg_catalog.{0} TO "{1}";
END;$$""".format(f, rewind['username'])
postgresql.query(sql)
for name, value in (config.get('users') or {}).items():
if all(name != a.get('username') for a in (superuser, replication, rewind)):
self.create_or_update_role(name, value.get('password'), value.get('options', []))
# We were doing a custom bootstrap instead of running initdb, therefore we opened trust
# access from certain addresses to be able to reach cluster and change password
if self._running_custom_bootstrap:
self._running_custom_bootstrap = False
# If we don't have custom configuration for pg_hba.conf we need to restore original file
if not postgresql.config.get('pg_hba'):
os.unlink(postgresql.config.pg_hba_conf)
postgresql.config.restore_configuration_files()
postgresql.config.write_postgresql_conf()
postgresql.config.replace_pg_ident()
# at this point there should be no recovery.conf
postgresql.config.remove_recovery_conf()
if postgresql.config.hba_file and postgresql.config.hba_file != postgresql.config.pg_hba_conf:
postgresql.restart()
else:
postgresql.config.replace_pg_hba()
if postgresql.pending_restart:
postgresql.restart()
else:
postgresql.reload()
time.sleep(1) # give a time to postgres to "reload" configuration files
postgresql.connection().close() # close connection to reconnect with a new password
except Exception:
logger.exception('post_bootstrap')
task.complete(False)
return task.result
+36
View File
@@ -0,0 +1,36 @@
import logging
from patroni.postgresql.cancellable import CancellableExecutor
from threading import Condition, Thread
logger = logging.getLogger(__name__)
class CallbackExecutor(CancellableExecutor, Thread):
def __init__(self):
CancellableExecutor.__init__(self)
Thread.__init__(self)
self.daemon = True
self._cmd = None
self._condition = Condition()
self.start()
def call(self, cmd):
self._kill_process()
with self._condition:
self._cmd = cmd
self._condition.notify()
def run(self):
while True:
with self._condition:
if self._cmd is None:
self._condition.wait()
cmd, self._cmd = self._cmd, None
with self._lock:
if not self._start_process(cmd, close_fds=True):
continue
self._process.wait()
self._kill_children()
+129
View File
@@ -0,0 +1,129 @@
import logging
import os
import psutil
import subprocess
from patroni.exceptions import PostgresException
from patroni.utils import polling_loop
from six import string_types
from threading import Lock
logger = logging.getLogger(__name__)
class CancellableExecutor(object):
def __init__(self):
self._process = None
self._process_cmd = None
self._process_children = []
self._lock = Lock()
def _start_process(self, cmd, *args, **kwargs):
"""This method must be executed only when the `_lock` is acquired"""
try:
self._process_children = []
self._process_cmd = cmd
self._process = psutil.Popen(cmd, *args, **kwargs)
except Exception:
return logger.exception('Failed to execute %s', cmd)
return True
def _kill_process(self):
with self._lock:
if self._process is not None and self._process.is_running() and not self._process_children:
try:
self._process.suspend() # Suspend the process before getting list of childrens
except psutil.Error as e:
logger.info('Failed to suspend the process: %s', e.msg)
try:
self._process_children = self._process.children(recursive=True)
except psutil.Error:
pass
try:
self._process.kill()
logger.warning('Killed %s because it was still running', self._process_cmd)
except psutil.NoSuchProcess:
pass
except psutil.AccessDenied as e:
logger.warning('Failed to kill the process: %s', e.msg)
def _kill_children(self):
waitlist = []
with self._lock:
for child in self._process_children:
try:
child.kill()
except psutil.NoSuchProcess:
continue
except psutil.AccessDenied as e:
logger.info('Failed to kill child process: %s', e.msg)
waitlist.append(child)
psutil.wait_procs(waitlist)
class CancellableSubprocess(CancellableExecutor):
def __init__(self):
super(CancellableSubprocess, self).__init__()
self._is_cancelled = False
def call(self, *args, **kwargs):
for s in ('stdin', 'stdout', 'stderr'):
kwargs.pop(s, None)
communicate_input = 'communicate_input' in kwargs
if communicate_input:
input_data = kwargs.pop('communicate_input', None)
if not isinstance(input_data, string_types):
input_data = ''
if input_data and input_data[-1] != '\n':
input_data += '\n'
kwargs['stdin'] = subprocess.PIPE
kwargs['stdout'] = open(os.devnull, 'w')
kwargs['stderr'] = subprocess.STDOUT
try:
with self._lock:
if self._is_cancelled:
raise PostgresException('cancelled')
self._is_cancelled = False
started = self._start_process(*args, **kwargs)
if started:
if communicate_input:
if input_data:
self._process.communicate(input_data)
self._process.stdin.close()
return self._process.wait()
finally:
with self._lock:
self._process = None
self._kill_children()
def reset_is_cancelled(self):
with self._lock:
self._is_cancelled = False
@property
def is_cancelled(self):
with self._lock:
return self._is_cancelled
def cancel(self):
with self._lock:
self._is_cancelled = True
if self._process is None or not self._process.is_running():
return
self._process.terminate()
for _ in polling_loop(10):
with self._lock:
if self._process is None or not self._process.is_running():
return
self._kill_process()
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import logging
import psycopg2
from contextlib import contextmanager
from threading import Lock
logger = logging.getLogger(__name__)
class Connection(object):
def __init__(self):
self._lock = Lock()
self._connection = None
self._cursor_holder = None
def set_conn_kwargs(self, conn_kwargs):
self._conn_kwargs = conn_kwargs
def get(self):
with self._lock:
if not self._connection or self._connection.closed != 0:
self._connection = psycopg2.connect(**self._conn_kwargs)
self._connection.autocommit = True
self.server_version = self._connection.server_version
return self._connection
def cursor(self):
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
logger.info("establishing a new patroni connection to the postgres cluster")
self._cursor_holder = self.get().cursor()
return self._cursor_holder
def close(self):
if self._connection and self._connection.closed == 0:
self._connection.close()
logger.info("closed patroni connection to the postgresql cluster")
self._cursor_holder = self._connection = None
@contextmanager
def get_connection_cursor(**kwargs):
with psycopg2.connect(**kwargs) as conn:
conn.autocommit = True
with conn.cursor() as cur:
yield cur
+70
View File
@@ -0,0 +1,70 @@
import logging
from patroni.exceptions import PostgresException
logger = logging.getLogger(__name__)
def postgres_version_to_int(pg_version):
"""Convert the server_version to integer
>>> postgres_version_to_int('9.5.3')
90503
>>> postgres_version_to_int('9.3.13')
90313
>>> postgres_version_to_int('10.1')
100001
>>> postgres_version_to_int('10') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
PostgresException: 'Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: 10'
>>> postgres_version_to_int('9.6') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
PostgresException: 'Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: 9.6'
>>> postgres_version_to_int('a.b.c') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
PostgresException: 'Invalid PostgreSQL version: a.b.c'
"""
try:
components = list(map(int, pg_version.split('.')))
except ValueError:
raise PostgresException('Invalid PostgreSQL version: {0}'.format(pg_version))
if len(components) < 2 or len(components) == 2 and components[0] < 10 or len(components) > 3:
raise PostgresException('Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: {0}'.format(pg_version))
if len(components) == 2:
# new style verion numbers, i.e. 10.1 becomes 100001
components.insert(1, 0)
return int(''.join('{0:02d}'.format(c) for c in components))
def postgres_major_version_to_int(pg_version):
"""
>>> postgres_major_version_to_int('10')
100000
>>> postgres_major_version_to_int('9.6')
90600
"""
return postgres_version_to_int(pg_version + '.0')
def parse_lsn(lsn):
t = lsn.split('/')
return int(t[0], 16) * 0x100000000 + int(t[1], 16)
def parse_history(data):
for line in data.split('\n'):
values = line.strip().split('\t')
if len(values) == 3:
try:
values[0] = int(values[0])
values[1] = parse_lsn(values[1])
yield values
except (IndexError, ValueError):
logger.exception('Exception when parsing timeline history line "%s"', values)
+197
View File
@@ -0,0 +1,197 @@
import logging
import multiprocessing
import os
import psutil
import re
import signal
import subprocess
import sys
from patroni import PATRONI_ENV_PREFIX
# avoid spawning the resource tracker process
if sys.version_info >= (3, 8): # pragma: no cover
import multiprocessing.resource_tracker
multiprocessing.resource_tracker.getfd = lambda: 0
elif sys.version_info >= (3, 4): # pragma: no cover
import multiprocessing.semaphore_tracker
multiprocessing.semaphore_tracker.getfd = lambda: 0
logger = logging.getLogger(__name__)
STOP_SIGNALS = {
'smart': signal.SIGTERM,
'fast': signal.SIGINT,
'immediate': signal.SIGQUIT if os.name != 'nt' else signal.SIGABRT,
}
def pg_ctl_start(conn, cmdline, env):
if os.name != 'nt':
os.setsid()
try:
postmaster = subprocess.Popen(cmdline, close_fds=True, env=env)
conn.send(postmaster.pid)
except Exception:
logger.exception('Failed to execute %s', cmdline)
conn.send(None)
conn.close()
class PostmasterProcess(psutil.Process):
def __init__(self, pid):
self.is_single_user = False
if pid < 0:
pid = -pid
self.is_single_user = True
super(PostmasterProcess, self).__init__(pid)
@staticmethod
def _read_postmaster_pidfile(data_dir):
"""Reads and parses postmaster.pid from the data directory
:returns dictionary of values if successful, empty dictionary otherwise
"""
pid_line_names = ['pid', 'data_dir', 'start_time', 'port', 'socket_dir', 'listen_addr', 'shmem_key']
try:
with open(os.path.join(data_dir, 'postmaster.pid')) as f:
return {name: line.rstrip('\n') for name, line in zip(pid_line_names, f)}
except IOError:
return {}
def _is_postmaster_process(self):
try:
start_time = int(self._postmaster_pid.get('start_time', 0))
if start_time and abs(self.create_time() - start_time) > 3:
logger.info('Process %s is not postmaster, too much difference between PID file start time %s and '
'process start time %s', self.pid, self.create_time(), start_time)
return False
except ValueError:
logger.warning('Garbage start time value in pid file: %r', self._postmaster_pid.get('start_time'))
# Extra safety check. The process can't be ourselves, our parent or our direct child.
if self.pid == os.getpid() or self.pid == os.getppid() or self.ppid() == os.getpid():
logger.info('Patroni (pid=%s, ppid=%s), "fake postmaster" (pid=%s, ppid=%s)',
os.getpid(), os.getppid(), self.pid, self.ppid())
return False
return True
@classmethod
def _from_pidfile(cls, data_dir):
postmaster_pid = PostmasterProcess._read_postmaster_pidfile(data_dir)
try:
pid = int(postmaster_pid.get('pid', 0))
if pid:
proc = cls(pid)
proc._postmaster_pid = postmaster_pid
return proc
except ValueError:
pass
@staticmethod
def from_pidfile(data_dir):
try:
proc = PostmasterProcess._from_pidfile(data_dir)
return proc if proc and proc._is_postmaster_process() else None
except psutil.NoSuchProcess:
return None
@classmethod
def from_pid(cls, pid):
try:
return cls(pid)
except psutil.NoSuchProcess:
return None
def signal_stop(self, mode):
"""Signal postmaster process to stop
:returns None if signaled, True if process is already gone, False if error
"""
if self.is_single_user:
logger.warning("Cannot stop server; single-user server is running (PID: {0})".format(self.pid))
return False
try:
self.send_signal(STOP_SIGNALS[mode])
except psutil.NoSuchProcess:
return True
except psutil.AccessDenied as e:
logger.warning("Could not send stop signal to PostgreSQL (error: {0})".format(e))
return False
return None
def wait_for_user_backends_to_close(self):
# These regexps are cross checked against versions PostgreSQL 9.1 .. 11
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
"walreceiver|wal sender process|walsender|walwriter|background writer|"
"logical replication launcher|logical replication worker for|bgworker:) ")
try:
children = self.children()
except psutil.Error:
return logger.debug('Failed to get list of postmaster children')
user_backends = []
user_backends_cmdlines = []
for child in children:
try:
cmdline = child.cmdline()[0]
if not aux_proc_re.match(cmdline):
user_backends.append(child)
user_backends_cmdlines.append(cmdline)
except psutil.NoSuchProcess:
pass
if user_backends:
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines))
psutil.wait_procs(user_backends)
logger.debug("Backends closed")
@staticmethod
def start(pgcommand, data_dir, conf, options):
# Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information
# it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start
# in python. It will start postgres, wait for port to be open and wait until postgres will start
# accepting connections.
# Important!!! We can't just start postgres using subprocess.Popen, because in this case it
# will be our child for the rest of our live and we will have to take care of it (`waitpid`).
# So we will use the same approach as pg_ctl uses: start a new process, which will start postgres.
# This process will write postmaster pid to stdout and exit immediately. Now it's responsibility
# of init process to take care about postmaster.
# In order to make everything portable we can't use fork&exec approach here, so we will call
# ourselves and pass list of arguments which must be used to start postgres.
# On Windows, in order to run a side-by-side assembly the specified env must include a valid SYSTEMROOT.
env = {p: os.environ[p] for p in os.environ if not p.startswith(PATRONI_ENV_PREFIX)}
try:
proc = PostmasterProcess._from_pidfile(data_dir)
if proc and not proc._is_postmaster_process():
# Upon start postmaster process performs various safety checks if there is a postmaster.pid
# file in the data directory. Although Patroni already detected that the running process
# corresponding to the postmaster.pid is not a postmaster, the new postmaster might fail
# to start, because it thinks that postmaster.pid is already locked.
# Important!!! Unlink of postmaster.pid isn't an option, because it has a lot of nasty race conditions.
# Luckily there is a workaround to this problem, we can pass the pid from postmaster.pid
# in the `PG_GRANDPARENT_PID` environment variable and postmaster will ignore it.
logger.info("Telling pg_ctl that it is safe to ignore postmaster.pid for process %s", proc.pid)
env['PG_GRANDPARENT_PID'] = str(proc.pid)
except psutil.NoSuchProcess:
pass
cmdline = [pgcommand, '-D', data_dir, '--config-file={}'.format(conf)] + options
logger.debug("Starting postgres: %s", " ".join(cmdline))
ctx = multiprocessing.get_context('spawn') if sys.version_info >= (3, 4) else multiprocessing
parent_conn, child_conn = ctx.Pipe(False)
proc = ctx.Process(target=pg_ctl_start, args=(child_conn, cmdline, env))
proc.start()
pid = parent_conn.recv()
proc.join()
if pid is None:
return
logger.info('postmaster pid=%s', pid)
# TODO: In an extremely unlikely case, the process could have exited and the pid reassigned. The start
# initiation time is not accurate enough to compare to create time as start time would also likely
# be relatively close. We need the subprocess extract pid+start_time in a race free manner.
return PostmasterProcess.from_pid(pid)
+209
View File
@@ -0,0 +1,209 @@
import logging
import os
import subprocess
from patroni.dcs import Leader
from patroni.postgresql.connection import get_connection_cursor
from patroni.postgresql.misc import parse_history, parse_lsn
logger = logging.getLogger(__name__)
REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECKPOINT': 1, 'CHECK': 2, 'NEED': 3,
'NOT_NEED': 4, 'SUCCESS': 5, 'FAILED': 6})
class Rewind(object):
def __init__(self, postgresql):
self._postgresql = postgresql
self.reset_state()
@staticmethod
def configuration_allows_rewind(data):
return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
@property
def can_rewind(self):
""" check if pg_rewind executable is there and that pg_controldata indicates
we have either wal_log_hints or checksums turned on
"""
# low-hanging fruit: check if pg_rewind configuration is there
if not self._postgresql.config.get('use_pg_rewind'):
return False
cmd = [self._postgresql.pgcommand('pg_rewind'), '--help']
try:
ret = subprocess.call(cmd, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
if ret != 0: # pg_rewind is not there, close up the shop and go home
return False
except OSError:
return False
return self.configuration_allows_rewind(self._postgresql.controldata())
@property
def can_rewind_or_reinitialize_allowed(self):
return self._postgresql.config.get('remove_data_directory_on_diverged_timelines') or self.can_rewind
def trigger_check_diverged_lsn(self):
if self.can_rewind_or_reinitialize_allowed and self._state != REWIND_STATUS.NEED:
self._state = REWIND_STATUS.CHECK
def check_leader_is_not_in_recovery(self, **kwargs):
if not kwargs.get('database'):
kwargs['database'] = self._postgresql.database
try:
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **kwargs) as cur:
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
if not cur.fetchone()[0]:
return True
logger.info('Leader is still in_recovery and therefore can\'t be used for rewind')
except Exception:
return logger.exception('Exception when working with leader')
def _get_local_timeline_lsn_from_controldata(self):
timeline = lsn = None
data = self._postgresql.controldata()
try:
if data.get('Database cluster state') == 'shut down in recovery':
lsn = data.get('Minimum recovery ending location')
timeline = int(data.get("Min recovery ending loc's timeline"))
if lsn == '0/0' or timeline == 0: # it was a master when it crashed
data['Database cluster state'] = 'shut down'
if data.get('Database cluster state') == 'shut down':
lsn = data.get('Latest checkpoint location')
timeline = int(data.get("Latest checkpoint's TimeLineID"))
except (TypeError, ValueError):
logger.exception('Failed to get local timeline and lsn from pg_controldata output')
return timeline, lsn
def _get_local_timeline_lsn(self):
if self._postgresql.is_running(): # if postgres is running - get timeline and lsn from replication connection
timeline, lsn = self._postgresql.get_local_timeline_lsn_from_replication_connection()
else: # otherwise analyze pg_controldata output
timeline, lsn = self._get_local_timeline_lsn_from_controldata()
logger.info('Local timeline=%s lsn=%s', timeline, lsn)
return timeline, lsn
def _check_timeline_and_lsn(self, leader):
local_timeline, local_lsn = self._get_local_timeline_lsn()
if local_timeline is None or local_lsn is None:
return
if isinstance(leader, Leader):
if leader.member.data.get('role') != 'master':
return
# standby cluster
elif not self.check_leader_is_not_in_recovery(**leader.conn_kwargs(self._postgresql.config.replication)):
return
history = need_rewind = None
try:
with self._postgresql.get_replication_connection_cursor(**leader.conn_kwargs()) as cur:
cur.execute('IDENTIFY_SYSTEM')
master_timeline = cur.fetchone()[1]
logger.info('master_timeline=%s', master_timeline)
if local_timeline > master_timeline: # Not always supported by pg_rewind
need_rewind = True
elif master_timeline > 1:
cur.execute('TIMELINE_HISTORY %s', (master_timeline,))
history = bytes(cur.fetchone()[1]).decode('utf-8')
logger.info('master: history=%s', history)
else: # local_timeline == master_timeline == 1
need_rewind = False
except Exception:
return logger.exception('Exception when working with master via replication connection')
if history is not None:
for parent_timeline, switchpoint, _ in parse_history(history):
if parent_timeline == local_timeline:
try:
need_rewind = parse_lsn(local_lsn) >= switchpoint
except (IndexError, ValueError):
logger.exception('Exception when parsing lsn')
break
elif parent_timeline > local_timeline:
break
self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED
def rewind_or_reinitialize_needed_and_possible(self, leader):
if leader and leader.name != self._postgresql.name and leader.conn_url and self._state == REWIND_STATUS.CHECK:
self._check_timeline_and_lsn(leader)
return leader and leader.conn_url and self._state == REWIND_STATUS.NEED
def check_for_checkpoint_after_promote(self):
if self._state == REWIND_STATUS.INITIAL and self._postgresql.is_leader():
try:
timeline = int(self._postgresql.controldata().get("Latest checkpoint's TimeLineID"))
if self._postgresql.get_master_timeline() == timeline:
self._state = REWIND_STATUS.CHECKPOINT
except (TypeError, ValueError):
logger.exception('Failed to parse timeline from pg_controldata output')
def checkpoint_after_promote(self):
return self._state == REWIND_STATUS.CHECKPOINT
def pg_rewind(self, r):
# prepare pg_rewind connection
env = self._postgresql.config.write_pgpass(r)
env['PGOPTIONS'] = '-c statement_timeout=0'
dsn = self._postgresql.config.format_dsn(r, True)
logger.info('running pg_rewind from %s', dsn)
try:
return self._postgresql.cancellable.call([self._postgresql.pgcommand('pg_rewind'), '-D',
self._postgresql.data_dir, '--source-server', dsn], env=env) == 0
except OSError:
return False
def execute(self, leader):
if self._postgresql.is_running() and not self._postgresql.stop(checkpoint=False):
return logger.warning('Can not run pg_rewind because postgres is still running')
# prepare pg_rewind connection
r = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
# 1. make sure that we are really trying to rewind from the master
# 2. make sure that pg_control contains the new timeline by:
# running a checkpoint or
# waiting until Patroni on the master will expose checkpoint_after_promote=True
checkpoint_status = leader.checkpoint_after_promote if isinstance(leader, Leader) else None
if checkpoint_status is None: # master still runs the old Patroni
leader_status = self._postgresql.checkpoint(leader.conn_kwargs(self._postgresql.config.superuser))
if leader_status:
return logger.warning('Can not use %s for rewind: %s', leader.name, leader_status)
elif not checkpoint_status:
return logger.info('Waiting for checkpoint on %s before rewind', leader.name)
elif not self.check_leader_is_not_in_recovery(**r):
return
if self.pg_rewind(r):
self._state = REWIND_STATUS.SUCCESS
elif not self.check_leader_is_not_in_recovery(**r):
logger.warning('Failed to rewind because master %s become unreachable', leader.name)
else:
logger.error('Failed to rewind from healty master: %s', leader.name)
for name in ('remove_data_directory_on_rewind_failure', 'remove_data_directory_on_diverged_timelines'):
if self._postgresql.config.get(name):
logger.warning('%s is set. removing...', name)
self._postgresql.remove_data_directory()
self._state = REWIND_STATUS.INITIAL
break
else:
self._state = REWIND_STATUS.FAILED
return False
def reset_state(self):
self._state = REWIND_STATUS.INITIAL
@property
def is_needed(self):
return self._state in (REWIND_STATUS.CHECK, REWIND_STATUS.NEED)
@property
def executed(self):
return self._state > REWIND_STATUS.NOT_NEED
@property
def failed(self):
return self._state == REWIND_STATUS.FAILED
+103
View File
@@ -0,0 +1,103 @@
import logging
from patroni.postgresql.connection import get_connection_cursor
from collections import defaultdict
logger = logging.getLogger(__name__)
def compare_slots(s1, s2):
return s1['type'] == s2['type'] and (s1['type'] == 'physical' or
s1['database'] == s2['database'] and s1['plugin'] == s2['plugin'])
class SlotsHandler(object):
def __init__(self, postgresql):
self._postgresql = postgresql
self._replication_slots = {} # already existing replication slots
self.schedule()
def _query(self, sql, *params):
return self._postgresql.query(sql, *params, retry=False)
def load_replication_slots(self):
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
replication_slots = {}
cursor = self._query('SELECT slot_name, slot_type, plugin, database FROM pg_catalog.pg_replication_slots')
for r in cursor:
value = {'type': r[1]}
if r[1] == 'logical':
value.update({'plugin': r[2], 'database': r[3]})
replication_slots[r[0]] = value
self._replication_slots = replication_slots
self._schedule_load_slots = False
def drop_replication_slot(self, name):
cursor = self._query(('SELECT pg_catalog.pg_drop_replication_slot(%s) WHERE EXISTS (SELECT 1 ' +
'FROM pg_catalog.pg_replication_slots WHERE slot_name = %s AND NOT active)'), name, name)
# In normal situation rowcount should be 1, otherwise either slot doesn't exists or it is still active
return cursor.rowcount == 1
def sync_replication_slots(self, cluster):
if self._postgresql.major_version >= 90400:
try:
self.load_replication_slots()
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role)
# drop old replication slots which are not presented in desired slots
for name in set(self._replication_slots) - set(slots):
if not self.drop_replication_slot(name):
logger.error("Failed to drop replication slot '%s'", name)
self._schedule_load_slots = True
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
logical_slots = defaultdict(dict)
for name, value in slots.items():
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]):
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
name, self._replication_slots[name], value)
if not self.drop_replication_slot(name):
logger.error("Failed to drop replication slot '%s'", name)
self._schedule_load_slots = True
continue
self._replication_slots.pop(name)
if name not in self._replication_slots:
if value['type'] == 'physical':
try:
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
immediately_reserve), name, name)
except Exception:
logger.exception("Failed to create physical replication slot '%s'", name)
self._schedule_load_slots = True
elif value['type'] == 'logical' and name not in self._replication_slots:
logical_slots[value['database']][name] = value
# create new logical slots
for database, values in logical_slots.items():
conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs['database'] = database
with get_connection_cursor(**conn_kwargs) as cur:
for name, value in values.items():
try:
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
" WHERE slot_type = 'logical' AND slot_name = %s)",
(name, value['plugin'], name))
except Exception:
logger.exception("Failed to create logical replication slot '%s' plugin='%s'",
name, value['plugin'])
self._schedule_load_slots = True
self._replication_slots = slots
except Exception:
logger.exception('Exception when changing replication slots')
self._schedule_load_slots = True
def schedule(self, value=None):
if value is None:
value = self._postgresql.major_version >= 90400
self._schedule_load_slots = value
+58
View File
@@ -0,0 +1,58 @@
import json
import urllib3
import six
from six.moves.urllib_parse import urlparse, urlunparse
from .utils import USER_AGENT
class PatroniRequest(object):
def __init__(self, config, insecure=False):
cert_reqs = 'CERT_NONE' if insecure or config.get('ctl', {}).get('insecure', False) else 'CERT_REQUIRED'
self._pool = urllib3.PoolManager(num_pools=10, maxsize=10, cert_reqs=cert_reqs)
self.reload_config(config)
@staticmethod
def _get_cfg_value(config, name):
return config.get('ctl', {}).get(name) or config.get('restapi', {}).get(name)
def _apply_pool_param(self, param, value):
if value:
self._pool.connection_pool_kw[param] = value
else:
self._pool.connection_pool_kw.pop(param, None)
def _apply_ssl_file_param(self, config, name):
value = self._get_cfg_value(config, name + 'file')
self._apply_pool_param(name + '_file', value)
return value
def reload_config(self, config):
self._pool.headers = urllib3.make_headers(basic_auth=self._get_cfg_value(config, 'auth'), user_agent=USER_AGENT)
if self._apply_ssl_file_param(config, 'cert'):
self._apply_ssl_file_param(config, 'key')
else:
self._pool.connection_pool_kw.pop('key_file', None)
cacert = config.get('ctl', {}).get('cacert') or config.get('restapi', {}).get('cafile')
self._apply_pool_param('ca_certs', cacert)
def request(self, method, url, body=None, **kwargs):
if body is not None and not isinstance(body, six.string_types):
body = json.dumps(body)
return self._pool.request(method.upper(), url, body=body, **kwargs)
def __call__(self, member, method='GET', endpoint=None, data=None, **kwargs):
url = member.api_url
if endpoint:
scheme, netloc, _, _, _, _ = urlparse(url)
url = urlunparse((scheme, netloc, endpoint, '', '', ''))
return self.request(method, url, data, **kwargs)
def get(url, verify=True, **kwargs):
http = PatroniRequest({}, not verify)
return http.request('GET', url, **kwargs)
+35 -33
View File
@@ -1,68 +1,69 @@
#!/usr/bin/env python
import json
import logging
import requests
from requests.exceptions import RequestException
import sys
import boto.ec2
from patroni.utils import Retry, RetryFailedError
from patroni.request import get as requests_get
logger = logging.getLogger(__name__)
class AWSConnection(object):
def __init__(self, cluster_name):
self.available = False
self.cluster_name = cluster_name if cluster_name is not None else 'unknown'
self._retry = Retry(deadline=300, max_delay=30, max_tries=-1, retry_exceptions=(boto.exception.StandardError,))
try:
# get the instance id
r = requests.get('http://169.254.169.254/latest/dynamic/instance-identity/document', timeout=0.1)
except RequestException:
logger.info("cannot query AWS meta-data")
r = requests_get('http://169.254.169.254/latest/dynamic/instance-identity/document', timeout=2.1)
except Exception:
logger.error('cannot query AWS meta-data')
return
if r.ok:
if r.status < 400:
try:
content = r.json()
content = json.loads(r.data.decode('utf-8'))
self.instance_id = content['instanceId']
self.region = content['region']
except Exception as e:
logger.info('unable to fetch instance id and region from AWS meta-data: {}'.format(e))
except Exception:
logger.exception('unable to fetch instance id and region from AWS meta-data')
return
self.available = True
def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs)
def aws_available(self):
return self.available
def _tag_ebs(self, role):
def _tag_ebs(self, conn, role):
""" set tags, carrying the cluster name, instance role and instance id for the EBS storage """
if not self.available:
return False
tags = {'Name': 'spilo_' + self.cluster_name, 'Role': role, 'Instance': self.instance_id}
try:
conn = boto.ec2.connect_to_region(self.region)
volumes = conn.get_all_volumes(filters={'attachment.instance-id': self.instance_id})
conn.create_tags([v.id for v in volumes], tags)
except Exception as e:
logger.info('could not set tags for EBS storage devices attached: {}'.format(e))
return False
return True
volumes = conn.get_all_volumes(filters={'attachment.instance-id': self.instance_id})
conn.create_tags([v.id for v in volumes], tags)
def _tag_ec2(self, role):
def _tag_ec2(self, conn, role):
""" tag the current EC2 instance with a cluster role """
if not self.available:
return False
tags = {'Role': role}
try:
conn = boto.ec2.connect_to_region(self.region)
conn.create_tags([self.instance_id], tags)
except Exception as e:
logger.info("could not set tags for EC2 instance %s: %s", self.instance_id, e)
return False
return True
conn.create_tags([self.instance_id], tags)
def on_role_change(self, new_role):
ret = self._tag_ec2(new_role)
return self._tag_ebs(new_role) and ret
if not self.available:
return False
try:
conn = self.retry(boto.ec2.connect_to_region, self.region)
self.retry(self._tag_ec2, conn, new_role)
self.retry(self._tag_ebs, conn, new_role)
except RetryFailedError:
logger.warning("Unable to communicate to AWS "
"when setting tags for the EC2 instance {0} "
"and attached EBS volumes".format(self.instance_id))
return False
return True
def main():
@@ -72,5 +73,6 @@ def main():
else:
sys.exit("Usage: {0} action role name".format(sys.argv[0]))
if __name__ == '__main__':
main()
+268 -66
View File
@@ -23,77 +23,180 @@
# 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 argparse
import csv
import logging
import os
import psycopg2
import subprocess
import sys
import argparse
import time
if sys.hexversion >= 0x03000000:
long = int
from collections import namedtuple
logger = logging.getLogger(__name__)
RETRY_SLEEP_INTERVAL = 1
si_prefixes = ['K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
# Meaningful names to the exit codes used by WALERestore
ExitCode = type('Enum', (), {
'SUCCESS': 0, #: Succeeded
'RETRY_LATER': 1, #: External issue, retry later
'FAIL': 2 #: Don't try again unless configuration changes
})
# We need to know the current PG version in order to figure out the correct WAL directory name
def get_major_version(data_dir):
version_file = os.path.join(data_dir, 'PG_VERSION')
if os.path.isfile(version_file): # version file exists
try:
with open(version_file) as f:
return float(f.read())
except Exception:
logger.exception('Failed to read PG_VERSION from %s', data_dir)
return 0.0
def repr_size(n_bytes):
"""
>>> repr_size(1000)
'1000 Bytes'
>>> repr_size(8257332324597)
'7.5 TiB'
"""
if n_bytes < 1024:
return '{0} Bytes'.format(n_bytes)
i = -1
while n_bytes > 1023:
n_bytes /= 1024.0
i += 1
return '{0} {1}iB'.format(round(n_bytes, 1), si_prefixes[i])
def size_as_bytes(size_, prefix):
"""
>>> size_as_bytes(7.5, 'T')
8246337208320
"""
prefix = prefix.upper()
assert prefix in si_prefixes
exponent = si_prefixes.index(prefix) + 1
return int(size_ * (1024.0 ** exponent))
WALEConfig = namedtuple(
'WALEConfig',
[
'env_dir',
'threshold_mb',
'threshold_pct',
'cmd',
]
)
class WALERestore(object):
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb, threshold_pct, use_iam, no_master):
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb,
threshold_pct, use_iam, no_master, retries):
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))
wale_cmd = [
'envdir',
env_dir,
'wal-e',
]
if use_iam == 1:
wale_cmd += ['--aws-instance-profile']
self.wal_e = WALEConfig(
env_dir=env_dir,
threshold_mb=threshold_mb,
threshold_pct=threshold_pct,
cmd=wale_cmd,
)
self.init_error = (not os.path.exists(self.wal_e.env_dir))
self.retries = retries
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
"""
Creates a new replica using WAL-E
Returns
-------
ExitCode
0 = Success
1 = Error, try again
2 = Error, don't try again
"""
if self.init_error:
logger.error('init error: %r did not exist at initialization time',
self.wal_e.env_dir)
return ExitCode.FAIL
try:
should_use_s3 = self.should_use_s3_to_create_replica()
if should_use_s3 is None: # Need to retry
return ExitCode.RETRY_LATER
elif should_use_s3:
return self.create_replica_with_s3()
elif not should_use_s3:
return ExitCode.FAIL
except Exception:
logger.exception("Unhandled exception when running WAL-E restore")
return ExitCode.FAIL
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
threshold_percent = self.wal_e.threshold_pct
try:
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'])
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
# wal_segment_backup_stop wal_segment_offset_backup_stop
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
# 20310671 00000001000000000000007F 00000040
# 00000001000000000000007F 00000240
backup_strings = latest_backup.splitlines() if latest_backup else ()
if len(backup_strings) != 2:
cmd = self.wal_e.cmd + ['backup-list', '--detail', 'LATEST']
logger.debug('calling %r', cmd)
wale_output = subprocess.check_output(cmd)
reader = csv.DictReader(wale_output.decode('utf-8').splitlines(),
dialect='excel-tab')
rows = list(reader)
if not len(rows):
logger.warning('wal-e did not find any backups')
return False
names = backup_strings[0].split()
vals = backup_strings[1].split()
if (len(names) != len(vals)) or (len(names) != 7):
# This check might not add much, it was performed in the previous
# version of this code. since the old version rolled CSV parsing the
# check may have been part of the CSV parsing.
if len(rows) > 1:
logger.warning(
'wal-e returned more than one row of backups: %r',
rows)
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
backup_info = rows[0]
except subprocess.CalledProcessError:
logger.exception("could not query wal-e latest backup")
return None
try:
backup_size = backup_info['expanded_size_bytes']
backup_size = int(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
except KeyError:
logger.exception("unable to get some of WALE backup parameters")
return None
# 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,
@@ -101,41 +204,130 @@ class WALERestore(object):
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]
lsn_offset = hex((int(backup_start_segment[16:32], 16) << 24) + int(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
diff_in_bytes = backup_size
attempts_no = 0
while True:
if self.master_connection:
try:
# get the difference in bytes between the current WAL location and the backup start offset
with psycopg2.connect(self.master_connection) as con:
if con.server_version >= 100000:
wal_name = 'wal'
lsn_name = 'lsn'
else:
wal_name = 'xlog'
lsn_name = 'location'
con.autocommit = True
with con.cursor() as cur:
cur.execute(("SELECT CASE WHEN pg_catalog.pg_is_in_recovery()"
" THEN GREATEST(pg_catalog.pg_{0}_{1}_diff(COALESCE("
"pg_last_{0}_receive_{1}(), '0/0'), %s)::bigint, "
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), %s)::bigint)"
" ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), %s)::bigint"
" END").format(wal_name, lsn_name),
(backup_start_lsn, backup_start_lsn, backup_start_lsn))
diff_in_bytes = int(cur.fetchone()[0])
except psycopg2.Error:
logger.exception('could not determine difference with the master location')
if attempts_no < self.retries: # retry in case of a temporarily connection issue
attempts_no = attempts_no + 1
time.sleep(RETRY_SLEEP_INTERVAL)
continue
else:
if not self.no_master:
return False # do no more retries on the outer level
logger.info("continue with base backup from S3 since master is not available")
diff_in_bytes = 0
break
else:
# always try to use WAL-E if master connection string is not available
diff_in_bytes = 0
break
# 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)
is_size_thresh_ok = diff_in_bytes < int(threshold_megabytes) * 1048576
threshold_pct_bytes = backup_size * threshold_percent / 100.0
is_percentage_thresh_ok = float(diff_in_bytes) < int(threshold_pct_bytes)
are_thresholds_ok = is_size_thresh_ok and is_percentage_thresh_ok
class Size(object):
def __init__(self, n_bytes, prefix=None):
self.n_bytes = n_bytes
self.prefix = prefix
def __repr__(self):
if self.prefix is not None:
n_bytes = size_as_bytes(self.n_bytes, self.prefix)
else:
n_bytes = self.n_bytes
return repr_size(n_bytes)
class HumanContext(object):
def __init__(self, items):
self.items = items
def __repr__(self):
return ', '.join('{}={!r}'.format(key, value)
for key, value in self.items)
human_context = repr(HumanContext([
('threshold_size', Size(threshold_megabytes, 'M')),
('threshold_percent', threshold_percent),
('threshold_percent_size', Size(threshold_pct_bytes)),
('backup_size', Size(backup_size)),
('backup_diff', Size(diff_in_bytes)),
('is_size_thresh_ok', is_size_thresh_ok),
('is_percentage_thresh_ok', is_percentage_thresh_ok),
]))
if not are_thresholds_ok:
logger.info('wal-e backup size diff is over threshold, falling back '
'to other means of restore: %s', human_context)
else:
logger.info('Thresholds are OK, using wal-e basebackup: %s', human_context)
return are_thresholds_ok
def fix_subdirectory_path_if_broken(self, dirname):
# in case it is a symlink pointing to a non-existing location, remove it and create the actual directory
path = os.path.join(self.data_dir, dirname)
if not os.path.exists(path):
if os.path.islink(path): # broken xlog symlink, to remove
try:
os.remove(path)
except OSError:
logger.exception("could not remove broken %s symlink pointing to %s",
dirname, os.readlink(path))
return False
try:
os.mkdir(path)
except OSError:
logger.exception("coud not create missing %s directory path", dirname)
return False
return True
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'])
cmd = self.wal_e.cmd + ['backup-fetch',
'{}'.format(self.data_dir),
'LATEST']
logger.debug('calling: %r', cmd)
exit_code = subprocess.call(cmd)
except Exception as e:
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
return 1
return ExitCode.RETRY_LATER
return ret
if (exit_code == 0 and not
self.fix_subdirectory_path_if_broken('pg_xlog' if get_major_version(self.data_dir) < 10 else 'pg_wal')):
return ExitCode.FAIL
return exit_code
def main():
@@ -153,17 +345,27 @@ def main():
parser.add_argument('--no_master', type=int, default=0)
args = parser.parse_args()
# retry cloning in a loop
exit_code = None
assert args.retries >= 0
# Retry cloning in a loop. We do separate retries for the master
# connection attempt inside should_use_s3_to_create_replica,
# because we need to differentiate between the last attempt and
# the rest and make a decision when the last attempt fails on
# whether to use WAL-E or not depending on the no_master flag.
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:
no_master=args.no_master, retries=args.retries)
exit_code = restore.run()
if not exit_code == ExitCode.RETRY_LATER: # only WAL-E failures lead to the retry
logger.debug('exit_code is %r, not retrying', exit_code)
break
time.sleep(RETRY_SLEEP_INTERVAL)
return exit_code
sys.exit(ret)
if __name__ == '__main__':
main()
sys.exit(main())
+329 -65
View File
@@ -1,84 +1,259 @@
import datetime
import os
import logging
import platform
import random
import signal
import sys
import re
import time
import pytz
import dateutil.parser
from patroni.exceptions import PatroniException
from dateutil import tz
__ignore_sigterm = False
__interrupted_sleep = False
__reap_children = False
from .exceptions import PatroniException
from .version import __version__
tzutc = tz.tzutc()
logger = logging.getLogger(__name__)
USER_AGENT = 'Patroni/{0} Python/{1} {2}'.format(__version__, platform.python_version(), platform.system())
OCT_RE = re.compile(r'^[-+]?0[0-7]*')
DEC_RE = re.compile(r'^[-+]?(0|[1-9][0-9]*)')
HEX_RE = re.compile(r'^[-+]?0x[0-9a-fA-F]+')
DBL_RE = re.compile(r'^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?')
def calculate_ttl(expiration):
def deep_compare(obj1, obj2):
"""
>>> calculate_ttl(None)
>>> calculate_ttl('2015-06-10 12:56:30.552539016Z') < 0
>>> deep_compare({'1': None}, {})
False
>>> deep_compare({'1': {}}, {'1': None})
False
>>> deep_compare({'1': [1]}, {'1': [2]})
False
>>> deep_compare({'1': 2}, {'1': '2'})
True
>>> calculate_ttl('2015-06-10T12:56:30.552539016Z') < 0
>>> deep_compare({'1': {'2': [3, 4]}}, {'1': {'2': [3, 4]}})
True
>>> calculate_ttl('fail-06-10T12:56:30.552539016Z')
"""
if not expiration:
return None
try:
expiration = dateutil.parser.parse(expiration)
except (ValueError, TypeError):
return None
now = datetime.datetime.now(pytz.utc)
return int((expiration - now).total_seconds())
if set(list(obj1.keys())) != set(list(obj2.keys())): # Objects have different sets of keys
return False
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 set_ignore_sigterm(value=True):
global __ignore_sigterm
__ignore_sigterm = value
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 sigterm_handler(signo, stack_frame):
if not __ignore_sigterm:
set_ignore_sigterm()
sys.exit()
def parse_bool(value):
"""
>>> parse_bool(1)
True
>>> parse_bool('off')
False
>>> parse_bool('foo')
"""
value = str(value).lower()
if value in ('on', 'true', 'yes', '1'):
return True
if value in ('off', 'false', 'no', '0'):
return False
def sigchld_handler(signo, stack_frame):
global __interrupted_sleep, __reap_children
__reap_children = __interrupted_sleep = True
def strtol(value, strict=True):
"""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(' 1 d ') == (1, ' d')
True
>>> strtol('9s', False) == (9, 's')
True
>>> strtol(' s ', False) == (1, 's')
True
"""
value = str(value).strip()
for regex, base in ((HEX_RE, 16), (OCT_RE, 8), (DEC_RE, 10)):
match = regex.match(value)
if match:
end = match.end()
return int(value[:end], base), value[end:]
return (None if strict else 1), value
def sleep(interval):
global __interrupted_sleep
current_time = time.time()
end_time = current_time + interval
while current_time < end_time:
__interrupted_sleep = False
time.sleep(end_time - current_time)
if not __interrupted_sleep: # we will ignore only sigchld
break
current_time = time.time()
__interrupted_sleep = False
def strtod(value):
"""As most as possible close equivalent of strtod(3) function used by postgres to parse parameter values.
>>> strtod(' A ') == (None, 'A')
True
"""
value = str(value).strip()
match = DBL_RE.match(value)
if match:
end = match.end()
return float(value[:end]), value[end:]
return None, value
def setup_signal_handlers():
signal.signal(signal.SIGTERM, sigterm_handler)
signal.signal(signal.SIGCHLD, sigchld_handler)
def rint(value):
"""
>>> rint(0.5) == 0
True
>>> rint(0.501) == 1
True
>>> rint(1.5) == 2
True
"""
ret = round(value)
return 2.0 * round(value / 2.0) if abs(ret - value) == 0.5 else ret
def reap_children():
global __reap_children
if __reap_children:
try:
while True:
ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0):
break
except OSError:
pass
finally:
__reap_children = False
def convert_to_base_unit(value, unit, base_unit):
convert = {
'B': {'B': 1, 'kB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024},
'kB': {'B': 1.0 / 1024, 'kB': 1, 'MB': 1024, 'GB': 1024 * 1024, 'TB': 1024 * 1024 * 1024},
'MB': {'B': 1.0 / (1024 * 1024), 'kB': 1.0 / 1024, 'MB': 1, 'GB': 1024, 'TB': 1024 * 1024},
'ms': {'us': 1.0 / 1000, 'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
's': {'us': 1.0 / (1000 * 1000), 'ms': 1.0 / 1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
'min': {'us': 1.0 / (1000 * 1000 * 60), 'ms': 1.0 / (1000 * 60), 's': 1.0 / 60, 'min': 1, 'h': 60, 'd': 60 * 24}
}
round_order = {
'TB': 'GB', 'GB': 'MB', 'MB': 'kB', 'kB': 'B',
'd': 'h', 'h': 'min', 'min': 's', 's': 'ms', 'ms': 'us'
}
if base_unit and base_unit not in convert:
base_value, base_unit = strtol(base_unit, False)
else:
base_value = 1
if base_unit in convert and unit in convert[base_unit]:
value *= convert[base_unit][unit] / float(base_value)
if unit in round_order:
multiplier = convert[base_unit][round_order[unit]]
value = rint(value / float(multiplier)) * multiplier
return value
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('1TB', 'GB') is None
True
>>> parse_int(0) == 0
True
>>> parse_int('6GB', '16MB') == 384
True
>>> parse_int('4097.4kB', 'kB') == 4097
True
>>> parse_int('4097.5kB', 'kB') == 4098
True
"""
val, unit = strtol(value)
if val is None and unit.startswith('.') or unit and unit[0] in ('.', 'e', 'E'):
val, unit = strtod(value)
if val is not None:
unit = unit.strip()
if not unit:
return int(rint(val))
val = convert_to_base_unit(val, unit, base_unit)
if val is not None:
return int(rint(val))
def parse_real(value, base_unit=None):
"""
>>> parse_real(' +0.0005 ') == 0.0005
True
>>> parse_real('0.0005ms', 'ms') == 0.0
True
>>> parse_real('0.00051ms', 'ms') == 0.001
True
"""
val, unit = strtod(value)
if val is not None:
unit = unit.strip()
if not unit:
return val
return convert_to_base_unit(val, unit, base_unit)
def compare_values(vartype, unit, old_value, new_value):
"""
>>> compare_values('enum', None, 'remote_write', 'REMOTE_WRITE')
True
>>> compare_values('real', None, '1e-06', 0.000001)
True
"""
converters = {
'bool': lambda v1, v2: parse_bool(v1),
'integer': parse_int,
'real': parse_real,
'enum': lambda v1, v2: str(v1).lower(),
'string': lambda v1, v2: str(v1)
}
convert = converters.get(vartype) or converters['string']
old_value = convert(old_value, None)
new_value = convert(new_value, unit)
return old_value is not None and new_value is not None and old_value == new_value
def _sleep(interval):
time.sleep(interval)
class RetryFailedError(PatroniException):
@@ -91,7 +266,7 @@ class Retry(object):
"""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,
sleep_func=sleep, deadline=None, retry_exceptions=PatroniException):
sleep_func=_sleep, deadline=None, retry_exceptions=PatroniException):
"""Create a :class:`Retry` instance for retrying function calls
:param max_tries: How many times to retry the command. -1 means infinite tries.
@@ -125,6 +300,17 @@ class Retry(object):
max_jitter=self.max_jitter / 100.0, max_delay=self.max_delay, sleep_func=self.sleep_func,
deadline=self.deadline, retry_exceptions=self.retry_exceptions)
@property
def sleeptime(self):
return self._cur_delay + (random.randint(0, self.max_jitter) / 100.0)
def update_delay(self):
self._cur_delay = min(self._cur_delay * self.backoff, self.max_delay)
@property
def stoptime(self):
return self._cur_stoptime
def __call__(self, func, *args, **kwargs):
"""Call a function with arguments until it completes without throwing a `retry_exceptions`
@@ -140,15 +326,93 @@ class Retry(object):
if self.deadline is not None and self._cur_stoptime is None:
self._cur_stoptime = time.time() + self.deadline
return func(*args, **kwargs)
except self.retry_exceptions:
except self.retry_exceptions as e:
# Note: max_tries == -1 means infinite tries.
if self._attempts == self.max_tries:
logger.warning('Retry got exception: %s', e)
raise RetryFailedError("Too many retry attempts")
self._attempts += 1
sleeptime = self._cur_delay + (random.randint(0, self.max_jitter) / 100.0)
sleeptime = self.sleeptime
if self._cur_stoptime is not None and time.time() + sleeptime >= self._cur_stoptime:
logger.warning('Retry got exception: %s', e)
raise RetryFailedError("Exceeded retry deadline")
else:
self.sleep_func(sleeptime)
self._cur_delay = min(self._cur_delay * self.backoff, self.max_delay)
logger.debug('Retry got exception: %s', e)
self.sleep_func(sleeptime)
self.update_delay()
def polling_loop(timeout, interval=1):
"""Returns an iterator that returns values until timeout has passed. Timeout is measured from start of iteration."""
start_time = time.time()
iteration = 0
end_time = start_time + timeout
while time.time() < end_time:
yield iteration
iteration += 1
time.sleep(interval)
def split_host_port(value, default_port):
t = value.rsplit(':', 1)
t.append(default_port)
return t[0], int(t[1])
def uri(proto, netloc, path='', user=None):
host, port = netloc if isinstance(netloc, (list, tuple)) else split_host_port(netloc, 0)
if host and ':' in host and host[0] != '[' and host[-1] != ']':
host = '[{0}]'.format(host)
port = ':{0}'.format(port) if port else ''
path = '/{0}'.format(path) if path and not path.startswith('/') else path
user = '{0}@'.format(user) if user else ''
return '{0}://{1}{2}{3}{4}'.format(proto, user, host, port, path)
def is_standby_cluster(config):
# Check whether or not provided configuration describes a standby cluster
return isinstance(config, dict) and (config.get('host') or config.get('port') or config.get('restore_command'))
def cluster_as_json(cluster):
leader_name = cluster.leader.name if cluster.leader else None
xlog_location_cluster = cluster.last_leader_operation or 0
ret = {'members': []}
for m in cluster.members:
if m.name == leader_name:
config = cluster.config.data if cluster.config and cluster.config.modify_index else {}
role = 'standby_leader' if is_standby_cluster(config.get('standby_cluster')) else 'leader'
elif m.name == cluster.sync.sync_standby:
role = 'sync_standby'
else:
role = 'replica'
conn_kwargs = m.conn_kwargs()
member = {'name': m.name, 'host': conn_kwargs['host'], 'port': int(conn_kwargs['port']),
'role': role, 'state': m.data.get('state', ''), 'api_url': m.api_url}
optional_attributes = ('timeline', 'pending_restart', 'scheduled_restart', 'tags')
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
if m.name != leader_name:
xlog_location = m.data.get('xlog_location')
if xlog_location is None:
member['lag'] = 'unknown'
elif xlog_location_cluster >= xlog_location:
member['lag'] = xlog_location_cluster - xlog_location
else:
member['lag'] = 0
ret['members'].append(member)
# sort members by name for consistency
ret['members'].sort(key=lambda m: m['name'])
if cluster.is_paused():
ret['pause'] = True
if cluster.failover and cluster.failover.scheduled_at:
ret['scheduled_switchover'] = {'at': cluster.failover.scheduled_at.isoformat()}
if cluster.failover.leader:
ret['scheduled_switchover']['from'] = cluster.failover.leader
if cluster.failover.candidate:
ret['scheduled_switchover']['to'] = cluster.failover.candidate
return ret
+1 -1
View File
@@ -1 +1 @@
__version__ = '0.90'
__version__ = '1.6.3'
+2
View File
@@ -0,0 +1,2 @@
from patroni.watchdog.base import WatchdogError, Watchdog
__all__ = ['WatchdogError', 'Watchdog']

Some files were not shown because too many files have changed in this diff Show More